summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-16 15:49:12 -0300
committerGabriel Schneider <[email protected]>2026-08-18 23:44:42 -0300
commit1551e409c31992437cb2fa864f576d45c8433801 (patch)
treee2fae8451f87b735a1360c7c2e383fdc40165789
parentbe2a9957708cbf0c478ca861c4a1f0f227bbfe10 (diff)
downloadpardes-1551e409c31992437cb2fa864f576d45c8433801.tar.gz
pardes-1551e409c31992437cb2fa864f576d45c8433801.zip
big slow change: prebuilt shaders (SPIR-V/Metal), core gui reflow, docs, web + snapshot refresh
-rw-r--r--build.zig163
-rw-r--r--build/snap.zig4
-rw-r--r--docs/config.md132
-rw-r--r--docs/design.typ216
-rw-r--r--docs/helix-keys.md4
-rw-r--r--docs/macos.md176
-rw-r--r--docs/web.md24
-rw-r--r--shaders/crt.ci.metal376
-rw-r--r--shaders/crt.frag.glsl173
-rw-r--r--shaders/image.frag.glsl91
-rw-r--r--shaders/image.vert.glsl16
-rw-r--r--shaders/overlay.frag.es.glsl11
-rw-r--r--shaders/prebuilt/crt.frag.glsl162
-rw-r--r--shaders/prebuilt/crt.frag.spvbin4108 -> 13348 bytes
-rw-r--r--shaders/prebuilt/crt.vert.glsl9
-rw-r--r--shaders/prebuilt/image.frag.glsl99
-rw-r--r--shaders/prebuilt/image.frag.spvbin624 -> 10372 bytes
-rw-r--r--shaders/prebuilt/image.vert.glsl49
-rw-r--r--shaders/prebuilt/image.vert.spvbin3152 -> 5032 bytes
-rw-r--r--shaders/prebuilt/overlay.frag.glsl9
-rw-r--r--shaders/prebuilt/overlay.vert.glsl (renamed from shaders/overlay.vert.es.glsl)6
-rw-r--r--shaders/prebuilt/ui.frag.glsl110
-rw-r--r--shaders/prebuilt/ui.frag.spvbin1148 -> 9712 bytes
-rw-r--r--shaders/prebuilt/ui.vert.glsl72
-rw-r--r--shaders/prebuilt/ui.vert.spvbin3520 -> 5468 bytes
-rw-r--r--shaders/ui.frag.es.glsl16
-rw-r--r--shaders/ui.frag.glsl94
-rw-r--r--shaders/ui.vert.es.glsl41
-rw-r--r--shaders/ui.vert.glsl28
-rw-r--r--src/builtins.zig305
-rw-r--r--src/config.zig67
-rw-r--r--src/dump.zig152
-rw-r--r--src/effect_sources.zig242
-rw-r--r--src/file_pane.zig388
-rw-r--r--src/file_watch.zig218
-rw-r--r--src/fonts.zig46
-rw-r--r--src/gui/crt.zig108
-rw-r--r--src/gui/font.c11
-rw-r--r--src/gui/font.h5
-rw-r--r--src/gui/gui.zig2750
-rw-r--r--src/image_pane.zig262
-rw-r--r--src/look.zig2
-rw-r--r--src/macos.zig843
-rw-r--r--src/macos/Sources/AppDelegate.swift57
-rw-r--r--src/macos/Sources/FileWatcher.swift122
-rw-r--r--src/macos/Sources/PardesView.swift562
-rw-r--r--src/macos/Sources/ScenePostprocessor.swift361
-rwxr-xr-xsrc/macos/build-e2e.sh9
-rw-r--r--src/macos/pardes.h126
-rw-r--r--src/main.zig18
-rw-r--r--src/modal.zig412
-rw-r--r--src/output_pane.zig377
-rw-r--r--src/output_pane_integration_test.zig336
-rw-r--r--src/panel_animation.zig335
-rw-r--r--src/pardes.zig8281
-rw-r--r--src/pdf_pane.zig2738
-rw-r--r--src/pdf_pane_integration_test.zig1714
-rw-r--r--src/runtime_config.zig568
-rw-r--r--src/shell_bin.zig153
-rw-r--r--src/syntax.zig4
-rw-r--r--src/term_pane.zig678
-rw-r--r--src/tty/panel_compositor.zig730
-rw-r--r--src/tty/tty.zig238
-rw-r--r--src/tutor.txt80
-rw-r--r--src/user_config.zig2
-rw-r--r--src/web.zig11
-rw-r--r--src/web/app.mjs39
-rw-r--r--src/web/pardes.css8
-rw-r--r--test/hxdiff.zig2
-rw-r--r--test/image_harness.zig134
-rw-r--r--test/macos-snapshots/boot.golden1
-rw-r--r--test/macos-snapshots/boot.snap8
-rw-r--r--test/macos-snapshots/font.snap5
-rw-r--r--test/macos_e2e.swift69
-rw-r--r--test/pdf_sections_bench.zig6
-rw-r--r--test/perf.zig2
-rw-r--r--test/snapshot.zig17
-rw-r--r--test/snapshots/builtins.golden192
-rw-r--r--test/snapshots/builtins.snap6
-rw-r--r--test/snapshots/chordcut.golden164
-rw-r--r--test/snapshots/chordcut.snap6
-rw-r--r--test/snapshots/cmdword.golden16
-rw-r--r--test/snapshots/cursorline.golden144
-rw-r--r--test/snapshots/dump.golden3
-rw-r--r--test/snapshots/fileedit.golden42
-rw-r--r--test/snapshots/fileedit.snap11
-rw-r--r--test/snapshots/filewatch.golden74
-rw-r--r--test/snapshots/fsearch.golden184
-rw-r--r--test/snapshots/grep.golden140
-rw-r--r--test/snapshots/hscroll.golden72
-rw-r--r--test/snapshots/images.golden282
-rw-r--r--test/snapshots/images.snap23
-rw-r--r--test/snapshots/layout-close.golden44
-rw-r--r--test/snapshots/layout-close.snap37
-rw-r--r--test/snapshots/layout-open.golden48
-rw-r--r--test/snapshots/layout-open.snap6
-rw-r--r--test/snapshots/leader.golden52
-rw-r--r--test/snapshots/leader.snap12
-rw-r--r--test/snapshots/look-center.golden42
-rw-r--r--test/snapshots/look-file.golden72
-rw-r--r--test/snapshots/lookloaded.golden56
-rw-r--r--test/snapshots/lookloaded.snap6
-rw-r--r--test/snapshots/multicursor.golden230
-rw-r--r--test/snapshots/rsearch.golden170
-rw-r--r--test/snapshots/scrolloff.golden84
-rw-r--r--test/snapshots/syntax.golden190
-rw-r--r--test/snapshots/tag.golden12
-rw-r--r--test/snapshots/tagbottomimage.golden8
-rw-r--r--test/snapshots/tagbottomimage.snap12
-rw-r--r--test/snapshots/tinywin.golden72
-rw-r--r--test/snapshots/ttyhelp.golden72
-rw-r--r--test/snapshots/ttylook.golden16
-rw-r--r--test/snapshots/tutor.golden54
-rw-r--r--test/web-snapshots/README.md22
-rw-r--r--test/web-snapshots/source-list.dump.zon17
-rw-r--r--test/web-snapshots/source-list.golden382
-rw-r--r--test/web-snapshots/source-list.snap23
-rw-r--r--test/web_harness.mjs38
-rw-r--r--test/web_snapshot.mjs9
-rw-r--r--tools/embed_zig_sources.zig29
120 files changed, 19424 insertions, 9433 deletions
diff --git a/build.zig b/build.zig
index 1df6e8dd..b1990913 100644
--- a/build.zig
+++ b/build.zig
@@ -21,12 +21,12 @@ pub const macos_min_version: std.SemanticVersion = .{ .major = 13, .minor = 0, .
const zls_version = "0.16.1-dev+3e0d0820";
pub const TreeSitterGrammars = enum { disabled, zig, minimal, full };
-/// The GUI shell's shaders, spelled ONCE. Three things read this list: the
-/// eight SPIR-V imports the gui module embeds, the glslc runs that produce
-/// them, and `zig build shaders`, which writes the same set into the tracked
-/// shaders/prebuilt/. Each name is `shaders/<name>.glsl`, and the `.vert`/
-/// `.frag` in it is also the glslc shader stage — adding a shader is adding a
-/// name here plus the @embedFile in src/gui/gui.zig.
+/// The GUI shell's shaders, spelled ONCE. The runtime SPIR-V imports, their
+/// EffectCode source imports, and `zig build shaders` all read this list. That
+/// last step writes BOTH the bytecode and its exact source snapshot into the
+/// tracked shaders/prebuilt/ pair. Each name is `shaders/<name>.glsl`, and the
+/// `.vert`/`.frag` in it is also the glslc shader stage — adding a shader is
+/// adding a name here plus the @embedFile in src/gui/gui.zig.
const gui_shaders = [_][]const u8{
"ui.vert", "ui.frag",
"overlay.vert", "overlay.frag",
@@ -34,6 +34,10 @@ const gui_shaders = [_][]const u8{
"crt.vert", "crt.frag",
};
+/// Every GUI shader is also a source import for EffectCode. Live builds import
+/// shaders/*.glsl; prebuilt builds import the source snapshot paired with the
+/// committed SPIR-V, so the builtin cannot print code other than what produced
+/// the bytecode that build executes.
pub fn build(b: *std.Build) void {
const platform = b.option(Platform, "platform", "which shell to build (tty, gui, web, macos)") orelse .tty;
// Default target is the Steam Deck (deckcap's trick): x86_64 linux-gnu
@@ -105,16 +109,17 @@ pub fn build(b: *std.Build) void {
// here needs: glslc, which ships with the Vulkan SDK / shaderc and is not
// on a stock machine. It is also the input that changes least often, so
// -Dprebuilt-shaders decouples the two: the SPIR-V compiled from
- // shaders/*.glsl is committed under shaders/prebuilt/, and this flag
- // embeds that copy instead of shelling out. `-Dplatform=gui` then builds
- // with nothing but a C toolchain.
+ // shaders/*.glsl and its exact GLSL snapshot are committed together under
+ // shaders/prebuilt/, and this flag embeds that pair instead of shelling
+ // out. `-Dplatform=gui` then builds with nothing but a C toolchain.
//
// Off by default because it trades a dependency for a freshness problem:
- // with the flag on, the .glsl sources are NOT build inputs, so editing one
- // changes nothing until someone runs `zig build shaders` (see below). The
- // default therefore stays the honest one — compile the shaders that are
- // actually in the tree.
+ // with the flag on, the live .glsl sources are NOT build inputs, so editing
+ // one changes neither runtime bytecode nor EffectCode until someone runs
+ // `zig build shaders` (see below). The default therefore stays the honest
+ // one — compile the shaders that are actually in the tree.
const prebuilt_shaders = b.option(bool, "prebuilt-shaders", "embed the committed shaders/prebuilt/*.spv instead of running glslc (-Dplatform=gui without a Vulkan SDK)") orelse false;
+ const gui_shader_sources_prebuilt = platform == .gui and prebuilt_shaders;
// The browser shell is a freestanding wasm core plus ordinary web files.
// JavaScript owns the loop and IO; HTML/CSS own rendering.
@@ -132,23 +137,28 @@ pub fn build(b: *std.Build) void {
if (!enable_mupdf)
pdf_scroll_bench_step.dependOn(&b.addFail("pdf-scroll-bench is unavailable with -Dmupdf=false").step);
- // The other half of -Dprebuilt-shaders: recompile every shader and write
- // the SPIR-V back into shaders/prebuilt/, which is TRACKED rather than
- // generated-and-gitignored, because a machine without glslc cannot
- // regenerate it. Deliberately independent of -Dplatform: whoever changes a
- // shader runs `zig build shaders` on a machine that has the compiler and
- // commits the diff (`jj diff shaders/prebuilt` says whether the cache had
- // drifted), and no gui build is involved either way.
- const shaders_step = b.step("shaders", "recompile shaders/*.glsl into the tracked shaders/prebuilt/*.spv (glslc; refreshes what -Dprebuilt-shaders embeds)");
+ // The other half of -Dprebuilt-shaders: recompile every shader and update
+ // BOTH tracked artifacts — SPIR-V plus the exact GLSL EffectCode will print.
+ // A machine without glslc can then consume the pair without consulting the
+ // live source. Deliberately independent of -Dplatform: whoever changes a
+ // shader runs this on a machine that has the compiler and commits the diff
+ // (`jj diff shaders/prebuilt` says whether the pair drifted).
+ const shaders_step = b.step("shaders", "refresh paired shaders/prebuilt/*.spv + *.glsl snapshots (glslc)");
const update_shaders = b.addUpdateSourceFiles();
- for (gui_shaders) |name|
+ for (gui_shaders) |name| {
update_shaders.addCopyFileToSource(compileGlsl(b, name), b.fmt("shaders/prebuilt/{s}.spv", .{name}));
+ update_shaders.addCopyFileToSource(
+ b.path(b.fmt("shaders/{s}.glsl", .{name})),
+ b.fmt("shaders/prebuilt/{s}.glsl", .{name}),
+ );
+ }
shaders_step.dependOn(&update_shaders.step);
// Web LOOK has a tiny read-only source filesystem. Generate one Zig module
- // on the host from every tracked .zig path + its current working-tree
- // contents. The generator prefers Git as the tracked-file filter and has a
- // conservative filesystem fallback for source archives without `.git`.
+ // on the host from every tracked or new/nonignored .zig path + its current
+ // working-tree contents. The generator prefers Git as the ignore-aware
+ // path filter and has a conservative filesystem fallback for source
+ // archives without `.git`.
const source_gen = b.addExecutable(.{
.name = "pardes-embed-zig-sources",
.root_module = b.createModule(.{
@@ -164,7 +174,7 @@ pub fn build(b: *std.Build) void {
// discovery intentionally runs whenever a consumer builds it.
run_source_gen.has_side_effects = true;
const inspect_sources = b.addInstallFileWithDir(embedded_sources, .{ .custom = "web" }, "embedded_sources.zig");
- b.step("embed-web-sources", "generate zig-out/web/embedded_sources.zig from tracked .zig files").dependOn(&inspect_sources.step);
+ b.step("embed-web-sources", "generate zig-out/web/embedded_sources.zig from working-tree .zig files").dependOn(&inspect_sources.step);
snap_build.addWeb(b);
@@ -192,7 +202,7 @@ pub fn build(b: *std.Build) void {
.link_libc = !is_web,
});
// helix differential harness (test/hxdiff.zig): a second compilation of
- // the sans-IO core, driven headlessly. Mirrors root_mod's wiring for
+ // the core, driven headlessly. Mirrors root_mod's wiring for
// everything src/pardes.zig pulls in (ghostty-vt, tree-sitter, zstbi, the
// option modules); imported as "pardes" by the harness exe below.
const hx_core_mod = b.createModule(.{
@@ -256,6 +266,9 @@ pub fn build(b: *std.Build) void {
opts.addOption(bool, "enable_tracy", tracy != null);
opts.addOption(bool, "zls_backend", zls_backend);
opts.addOption(bool, "mupdf", enable_mupdf);
+ // Meaningful only for the SDL shell. Keeping the platform condition here
+ // prevents Config/EffectCode from describing tty/macOS/web as "prebuilt".
+ opts.addOption(bool, "gui_shader_sources_prebuilt", gui_shader_sources_prebuilt);
// Which ZLS is compiled in, for `SPC l i`. Kept next to the dependency it
// names: the .zon pins a commit, and a status screen that cannot say WHICH
// analyser answered is not worth opening.
@@ -648,6 +661,16 @@ pub fn build(b: *std.Build) void {
root_mod.addOptions("pardes_config", opts);
hx_core_mod.addOptions("pardes_config", opts);
+ const gui_effect_source_dir = if (gui_shader_sources_prebuilt) "shaders/prebuilt" else "shaders";
+ for ([_]*std.Build.Module{ root_mod, hx_core_mod }) |mod| {
+ if (platform == .gui) for (gui_shaders) |name| mod.addAnonymousImport(
+ b.fmt("effect-source-{s}.glsl", .{name}),
+ .{ .root_source_file = b.path(b.fmt("{s}/{s}.glsl", .{ gui_effect_source_dir, name })) },
+ );
+ if (platform == .macos) mod.addAnonymousImport("effect-source-crt.ci.metal", .{
+ .root_source_file = b.path("shaders/crt.ci.metal"),
+ });
+ }
// SDL3 shell wiring (-Dplatform=gui). SDL3 and FreeType are both built
// from source as static libraries; gui.zig @cImports their headers plus
@@ -733,7 +756,7 @@ pub fn build(b: *std.Build) void {
// one. Deliberately NOT here: an xcframework, lipo and an Xcode
// project. The first two are for a UNIVERSAL binary and this ships
// arm64; the third is a second build system to keep in step for what
- // a plist and three install steps already do. ghostty's
+ // a plist and a few install steps already do. ghostty's
// src/build/GhosttyXCFramework.zig is the map if that day comes.
const header = b.addTranslateC(.{
.root_source_file = b.path("src/macos/pardes.h"),
@@ -781,7 +804,7 @@ pub fn build(b: *std.Build) void {
//
// A bundle is a directory with a plist, a binary and an icon in it, so
// it is assembled HERE rather than by a script the build shells out to.
- // Every input is a file the graph knows — the archive, the three Swift
+ // Every input is a file the graph knows — the archive, the four Swift
// sources, the header, the plist, the icon generator — which is what
// makes the app rebuild when one of them moves and stay untouched when
// none does. The script this replaced took an install PREFIX and
@@ -824,10 +847,28 @@ pub fn build(b: *std.Build) void {
link.addFileArg(b.path("src/macos/pardes.h"));
link.addArg("-o");
const app_bin = link.addOutputFileArg("pardes");
- for ([_][]const u8{ "main.swift", "AppDelegate.swift", "PardesView.swift" }) |src|
+ for ([_][]const u8{
+ "main.swift",
+ "AppDelegate.swift",
+ "PardesView.swift",
+ "ScenePostprocessor.swift",
+ "FileWatcher.swift",
+ }) |src|
link.addFileArg(b.path(b.fmt("src/macos/Sources/{s}", .{src})));
link.addFileArg(lib_archive);
- link.addArgs(&.{ "-lc++", "-framework", "AppKit", "-framework", "CoreText", "-framework", "CoreGraphics" });
+ link.addArgs(&.{
+ "-lc++",
+ "-framework",
+ "AppKit",
+ "-framework",
+ "CoreText",
+ "-framework",
+ "CoreGraphics",
+ "-framework",
+ "CoreImage",
+ "-framework",
+ "Metal",
+ });
// The icon is generated, not committed: the mark is drawn out of
// the same palette PardesView.swift renders cells with, so a colour
@@ -854,6 +895,14 @@ pub fn build(b: *std.Build) void {
const install_app_bin = b.addInstallFileWithDir(app_bin, .{ .custom = "pardes.app/Contents/MacOS" }, "pardes");
const install_plist = b.addInstallFileWithDir(plist_out, .{ .custom = "pardes.app/Contents" }, "Info.plist");
+ // Runtime Metal compilation keeps this exact source shared with
+ // EffectCode's build-time embedding; no generated Swift literal
+ // or second shader copy can drift from what the app executes.
+ const install_scene_kernel = b.addInstallFileWithDir(
+ b.path("shaders/crt.ci.metal"),
+ .{ .custom = "pardes.app/Contents/Resources" },
+ "crt.ci.metal",
+ );
// CFBundleIconFile names this without its extension; without both
// halves the Dock shows the generic blank page.
const install_icon = b.addInstallFileWithDir(icon_dir.path(b, "pardes.icns"), .{ .custom = "pardes.app/Contents/Resources" }, "pardes.icns");
@@ -865,7 +914,7 @@ pub fn build(b: *std.Build) void {
// asking for — both are notarization's requirements rather than a
// signature's, and --timestamp on an ad-hoc signature is an error.
//
- // It signs the DIRECTORY, so it must follow all three installs: a
+ // It signs the DIRECTORY, so it must follow all four installs: a
// signature taken before the icon lands is a signature the icon
// then breaks. Always runs, because the bundle it edits lives
// outside the cache and zig cannot know what is in it.
@@ -876,6 +925,7 @@ pub fn build(b: *std.Build) void {
sign.step.dependOn(&install_app_bin.step);
sign.step.dependOn(&install_plist.step);
sign.step.dependOn(&install_icon.step);
+ sign.step.dependOn(&install_scene_kernel.step);
app_step.dependOn(&sign.step);
// ...and the thing you hand someone. UDZO is the compressed
@@ -935,11 +985,10 @@ pub fn build(b: *std.Build) void {
e2e_step.dependOn(&b.addFail("macos-e2e needs a Darwin target; drop -Dtarget= or pass -Dtarget=native").step);
// Same step name the other native platforms use, because in this
- // configuration theirs is not declared: the ABI guard and the core's
- // own inline tests are what `-Dplatform=macos` has to keep green.
- const unit_step = b.step("unit-test", "run the C-ABI guard and the core unit tests");
+ // configuration theirs is not declared. macos.zig imports the core, so
+ // this one aggregate reaches both the C-ABI guard and the core tests.
+ const unit_step = b.step("unit-test", "run the C-ABI guard and core unit tests");
unit_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = root_mod })).step);
- unit_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = hx_core_mod })).step);
web_step.dependOn(&b.addFail("web needs -Dplatform=web -Dtarget=wasm32-freestanding -Ddump=<dump.zon>").step);
} else {
// binary name: the native-shaped linux-x86_64 tty build stays `pardes`
@@ -1010,7 +1059,7 @@ pub fn build(b: *std.Build) void {
pdf_harness_step.dependOn(&b.addFail("pdf-harness is unavailable with -Dmupdf=false").step);
}
- // helix differential harness: drive the sans-IO core headlessly over
+ // helix differential harness: drive the core headlessly over
// JSON-Lines cases, one contract result line per case on stdout —
// the pardes half of the pardes-vs-helix diff (helix's hx-harness on
// its pardes-harness branch speaks the same contract).
@@ -1101,23 +1150,7 @@ pub fn build(b: *std.Build) void {
run_perf.setCwd(b.path("."));
b.step("perf", "large-file / long-line latency table (-- [--json] [--reps N] [--base old.json])").dependOn(&run_perf.step);
- // modal.zig is pure std — its inline unit tests run here
- const unit = b.addTest(.{ .root_module = b.createModule(.{
- .target = target,
- .optimize = optimize,
- .root_source_file = b.path("src/modal.zig"),
- }) });
- const unit_step = b.step("unit-test", "run the modal.zig + crt.zig + deck replay + core unit tests");
- unit_step.dependOn(&b.addRunArtifact(unit).step);
- // Native config path/loading is std-only and has its own hermetic
- // filesystem/environment tests. The core receives bytes through
- // Options, so this IO module stays outside the core module graph.
- const user_config_test = b.addTest(.{ .root_module = b.createModule(.{
- .target = target,
- .optimize = optimize,
- .root_source_file = b.path("src/user_config.zig"),
- }) });
- unit_step.dependOn(&b.addRunArtifact(user_config_test).step);
+ const unit_step = b.step("unit-test", "run native shell and module unit tests");
// Secure tempfile creation is native-shell IO, isolated from the core
// and tested as its own libc-linked module.
const temp_file_test = b.addTest(.{ .root_module = b.createModule(.{
@@ -1180,21 +1213,13 @@ pub fn build(b: *std.Build) void {
.root_source_file = b.path("src/gui/deck.zig"),
}));
unit_step.dependOn(&b.addRunArtifact(deck_test).step);
- // the core's own inline tests (config.zig's wheel drift guard). It
- // links the SAME module hxdiff does rather than a fourth hand-wired
- // copy of ghostty-vt/tree-sitter/zstbi — a second wiring of that graph
- // is a second thing to keep in step.
- const core_test = b.addTest(.{ .root_module = hx_core_mod });
- unit_step.dependOn(&b.addRunArtifact(core_test).step);
- // gui.zig's own inline tests (the fractional-scroll motion).
- // Only reachable with -Dplatform=gui, and it links the SAME module
- // the gui exe does
- // for the same reason core_test does: SDL, the shaders and the font
- // are already wired onto it, and a second wiring is a second thing to
- // keep in step.
- if (platform == .gui) {
- const gui_test = b.addTest(.{ .root_module = root_mod });
- unit_step.dependOn(&b.addRunArtifact(gui_test).step);
+ // Shell-specific inline tests. main() imports a shell inside its
+ // runtime switch, which test analysis never enters. main.zig's test
+ // block names the selected shell, user config, and platform-only
+ // helpers; its ordinary core import reaches pardes.zig and modal.zig.
+ if (platform == .tty or platform == .gui) {
+ const shell_test = b.addTest(.{ .root_module = root_mod });
+ unit_step.dependOn(&b.addRunArtifact(shell_test).step);
}
}
}
diff --git a/build/snap.zig b/build/snap.zig
index 2c135fef..02ec6f9e 100644
--- a/build/snap.zig
+++ b/build/snap.zig
@@ -40,8 +40,8 @@ pub fn addTty(b: *std.Build, opts: struct {
}
/// Browser regression suite: make an isolated freestanding web build from the
-/// real TTY dump captured after running `git ls-files '*.zig'`. The
-/// dependency-free Chrome/CDP driver then exercises real DOM pointer events.
+/// real TTY dump captured from Git's tracked-plus-new/nonignored `.zig` view.
+/// The dependency-free Chrome/CDP driver then exercises real DOM pointer events.
/// This recursive build selects `web`, not `web-snap`, so it bottoms out.
pub fn addWeb(b: *std.Build) void {
const build_web_snap = b.addSystemCommand(&.{
diff --git a/docs/config.md b/docs/config.md
index 30962ddb..7e27216b 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -16,15 +16,39 @@ still resolves, because "nothing is there yet" is the answer `Config` exists
to give. There is one case with no path at all: a native launch with no `HOME`
set, which `Config` reports as such.
-`Config` (`SPC f c`, or the word executed anywhere) prints the resolved path
-into a `+Config` output pane, so the machine answers this rather than the list
-above. The path is printed whether or not a file is there — that is the case
-you ask in — and the row is ordinary text, so a right click on it opens the
-file.
+`Config` (`SPC f c`, or the word executed anywhere) opens one refreshable
+`+Config` pane. It reports the startup path and every live config-like value:
+theme, colors, wrapping, tag position, debug mode, the requested shell and the
+executable actually resolved at the last spawn, requested/effective GUI font
+and size, tagline scale, panel transition,
+scene effects, hover delay, platform, native-image support, and (on SDL) whether
+the executable uses live-built shaders or the paired prebuilt shader snapshot.
+Platform-dependent rows say `unsupported` instead of looking like an off or
+empty supported setting: TTY reports Font and scene shaders as unsupported;
+web reports Font, panel transitions, and scene shaders as unsupported. Tagline
+font size is its own row and capability — GUI font selection is native-only,
+while the browser still applies the compiled tagline percentage to its DOM.
+The startup path is printed whether or not a file exists — that is usually when
+it is most useful — and is ordinary selectable text, so a right click on it
+opens the file. Shell follows the same requested/effective/pending model as
+Font. `Compiled default shell` is the command built into the binary; `Shell
+effective (last spawn)` is the executable the native host really chose after
+installation lookup and fallback. A changed request remains pending until a
+terminal is spawned, because the core does not resolve native executables.
+
+The mutable global values live together in the plain `runtime_config.State` record.
+One plain capability record gates the setting registry, leader table,
+`EffectCode`, and report; the compile-time setting table generates both setter
+builtins and their `Config` rows. Exhaustive checks require every table-backed
+toggle, transition, and scene-effect switch to occur exactly once, so those
+generated setting builtins cannot quietly lose their query row or leave a
+renderer switch unnamed. Manual pane-local actions remain with their payload (for
+example an image tag reports its renderer choices); they are not global
+configuration.
The browser build has no local user-config path and does not load this file.
-(Nor does it have the `Font` builtin, or a language backend, or ptys of its
-own — see `docs/web.md`.)
+(Nor does it have `Font`, the effect builtins or `EffectCode`, a language
+backend, or ptys of its own — see `docs/web.md`.)
The format is one existing builtin command per line, using the same spelling
and argument parsing as commands executed inside pardes:
@@ -32,14 +56,16 @@ and argument parsing as commands executed inside pardes:
```text
Theme acme
Font DejaVuSansMono-Regular
+TaglineSize 82
Shell zsh
Wrap
```
A line matches a builtin whose name takes NO argument only as that whole word:
`Kill` runs, `Kill something` does not. Builtins that take one (`Theme`,
-`Font`, `Shell`, `Restore`, `Find`, `Grep`, `Rename`, `WsSymbols`, `Look`,
-`Exec`) take everything after the name as the argument.
+`Font`, `TaglineSize`, `Shell`, `Restore`, `Find`, `Grep`, `Rename`,
+`WsSymbols`, `Look`, `Exec`, `EffectCode`) take everything after the name as
+the argument.
`Theme <name>` wants one of the 228 names in the ring. Do not derive the
spelling — read it off `ThemeSel` (`SPC t t`), which lists every one as the
@@ -60,6 +86,20 @@ terminal's font belongs to its emulator and a browser's to the page — so a
resolve the name by walking the font directories on every lookup, so a face
installed a moment ago is findable.
+`Font` is asynchronous at the renderer boundary. `Config` therefore keeps
+requested name/path, pending state, and the effective face/point-or-pixel size
+as separate facts; a failed request never gets reported as the face on screen.
+Taglines use a distinct face size in both native GUI renderers. Execute
+`TaglineSize <percent>` to change it live, for example `TaglineSize 70`; the
+accepted range is 1 through 100 and the default comes from
+`gui_tagline_font_percent` in `src/config.zig` (82). The native renderer
+remeasures both the glyph and its visible tag band while retaining the body's
+cell grid. The 100% ceiling is deliberate: a tagline remains exactly one
+logical grid row, so a larger face or band would overlap its pane body or a
+neighbour instead of leaving the body grid stable. `Config` reports the active
+percentage. The browser applies the same compiled percentage to its DOM glyphs
+but has no runtime setter.
+
What happens to a codepoint the chosen face has no glyph for differs by shell.
The SDL GUI falls back through a chain it builds itself: embedded Adwaita
Mono, then installed `NotoSansMono-Regular`, `DejaVuSansMono`,
@@ -76,3 +116,77 @@ bad line does not prevent later lines from running. Top-level text that is not
a builtin is not sent to a shell. (`Exec ...` remains an ordinary builtin and
therefore keeps its normal behavior.) Key bindings remain compile-time choices
in `src/config.zig`; this startup file does not remap them.
+
+## Panel and scene effects
+
+Exactly one panel transition is selected at a time. Executing its builtin a
+second time turns it off; selecting another replaces it:
+
+```text
+PanelSlide
+PanelZoom
+PanelDissolve
+PanelAscii
+PanelVertical
+```
+
+All panel transitions start off. Slide uses cubic ease-out and zoom uses an
+overshooting ease-out-back. Dissolve and ASCII compare the last successfully
+presented grid with the new one: cells which did not change are immediately
+canonical, while changed cells cross from old to new through smoothstep or
+stable themed punctuation. Vertical is a pane-lifecycle effect: a newly added
+pane rises from below inside its own fixed box, and a deleted pane's frozen
+content rises out; surviving panes are never animated. The TTY implementation
+performs those operations directly on a copy of the canonical cell grid.
+The SDL and native macOS GUI implementations pass plain panel tracks to their
+GPU shaders, including native image/PDF pixels; layout itself commits
+immediately and remains the one authoritative geometry. DOM web intentionally
+does not expose these builtins: its renderer is selectable HTML/CSS and has no
+canvas or shader stage.
+
+The scene effects are independent switches and can be combined:
+
+```text
+Crt
+Ripple
+Glitch
+```
+
+They share one full-scene shader pass in SDL and macOS. With all three off the
+pass is bypassed. CRT works in linear light with restrained scanlines, mask,
+bloom, curvature, and noise rather than remapping the theme to a strong fixed
+palette; Ripple and Glitch primarily perturb sample coordinates.
+
+`EffectCode <effect-builtin>` opens the build-embedded effect math, host
+paint/submission path, and backend shader/grid sources, for example
+`EffectCode PanelAscii` or, in a GUI build, `EffectCode Crt`. TTY exposes it
+for its grid transitions; native GUI builds
+expose it for transitions and scene shaders. It is absent on web, where no
+effect argument could succeed. Shared
+passes are shown as shared source segments rather than manufactured per-effect
+copies. The command works from an installed binary and does not need the source
+checkout beside it. SDL output also labels its shader provenance. An ordinary
+build prints the live GLSL that `glslc` compiled for that executable;
+`-Dprebuilt-shaders` prints the tracked GLSL snapshot paired with the committed
+SPIR-V instead and labels those segments with their `shaders/prebuilt/` paths.
+`zig build shaders` refreshes both files of every pair together,
+so editing live GLSL without that explicit refresh changes neither half of a
+prebuilt executable.
+
+## Delayed Look preview
+
+The preview is enabled by default. Moving the pointer onto selectable text and
+leaving it still for `look_preview_delay_frames` (2 animation ticks, roughly
+33 ms at the 60 Hz animation cadence) paints a
+subtle theme-derived preview of the exact operand a right-click Look would receive.
+Repeated motion reports in the same semantic grid cell do not restart the
+delay. The preview uses the same side-effect-free word/path expansion as Look;
+it does not focus a pane, move a cursor, install a selection, activate a PDF
+page, or execute anything. Motion to another operand, pointer leave, input,
+pane teardown, and relevant content changes cancel it.
+
+Set this compile-time option in `src/config.zig` to disable the feature:
+
+```zig
+pub const look_preview_delay_frames: ?u16 = null;
+```
diff --git a/docs/design.typ b/docs/design.typ
index 7ee9f1ac..8dd14cdf 100644
--- a/docs/design.typ
+++ b/docs/design.typ
@@ -43,21 +43,35 @@ its own renderer; the core owns everything the user would recognize as pardes.
effect and back; everything with a lifetime — a pty, a window, the
clipboard — is still asked for.],
[*shell*], [one MODULE per platform (tty is one file; gui adds the CRT and
- gamepad files, a C font loader and twelve GLSL shaders; web and macOS each
+ gamepad files, a C font loader and eight GLSL shaders; web and macOS each
add a host language).
Owns the event loop, translates native input
into core events, renders the core's surface, and performs the core's
requested effects (spawn a shell, write a pty, open a link).],
)
+Native shells share `shell_bin`'s OSC 133 startup snippets, but not their
+files. Each host owns a private `mkstemp` pair for its lifetime, writes and
+closes both before the first fork, passes those unpredictable paths directly
+in child argv, and unlinks them at teardown. Concurrent tty, SDL and macOS
+launches therefore cannot truncate, source, or replace one another's startup
+files.
+
This also collapses the prototype's *two* applications into one: the browser
build today is a separate 800-line read-only replay viewer. In the rewrite,
loading a dump of another instance is a first-class feature of the one
application, the way acme handles dumps: `pardes -l state.zon` on every
-platform reconstructs the panes (terminals by replaying their raw VT streams
-into fresh emulators, files, images and PDFs from their bytes — a PDF rides the
-dump's `image` kind, carrying its path, its bytes and the page it was on). The web shell embeds
-a dump and leaves `spawn` unanswered. Same core, no viewer fork.
+platform reconstructs the panes—terminals by replaying their raw VT streams
+into fresh emulators, files, images and PDFs from their bytes. Editable tag
+tails are stored separately from their dynamic live prefixes, and image records
+retain PETSCII, palette, and ASCII renderer choices. A PDF rides the dump's
+`image` kind, carrying its path, its bytes and the page it was on. The web shell embeds
+a dump and leaves `spawn` unanswered. The reader validates weights, scroll
+ranges, pane references, and bounded tag tails before constructing anything;
+invalid base64 fails instead of silently becoming empty content. A PDF record
+whose path cannot be opened—or a build without MuPDF—falls back to an ordinary
+file pane with those exact embedded bytes and its original editable tail. Same
+core, no viewer fork.
The boundary is two data types, both plain values:
@@ -76,7 +90,8 @@ motion plus buttons. The shells do this translation and nothing else with
input: one event vocabulary, four dialects translated at the door.
*Surface* (out): `cols`, `rows`, a grid of cells — grapheme, fg, bg, attrs — a
-cursor, and the pixel attachments. This is the *canonical interface*: it is literally what the
+cursor, pixel attachments, and a bounded list of plain panel-transition tracks.
+This is the *canonical interface*: it is literally what the
tty shell hands to vaxis, cell by cell. SDL rasterizes the same grid through a
glyph atlas; the browser reads a packed copy and patches native DOM cells. If it
cannot be expressed in the surface, it does not
@@ -84,8 +99,9 @@ exist in pardes. (Pixel images ride along as a list of PLACEMENTS rather than
one per pane — a PDF pane contributes every page its viewport intersects, and
pages can be arbitrarily short. The SDL shells blit RGBA, the tty shell falls
back to the petscii matcher, kitty
-graphics when available. The per-pane rectangles live on `Pardes`, not on the
-surface: a shell that wants them asks the core.)
+graphics when available. Transition tracks identify their pane by slot and
+serial and carry only phase, effect, frame, and from/to cell boxes. Canonical
+layout is committed immediately; tracks are finite presentation data.)
*Effect* (out, queued): `spawn{pane, cwd}`, `write{pane, bytes}`,
`resize_pty{pane, cols, rows}`, `open_link`, `new_file{pane, serial}`,
@@ -95,9 +111,18 @@ The core never performs IO for any of these; it asks — and four of the asks ha
answer coming back: `read_clipboard` returns an ordinary `paste` event (or
nothing at all when the shell cannot read the clipboard — most terminals refuse
the OSC 52 read), `lsp` returns `lsp_resp`, `pipe` returns `pipe_resp`, and
-`watch` returns `file_changed` whenever the shell notices the file moved under
-it. This
-is what makes the browser build honest instead of a
+`watch` returns `file_changed` whenever the shell notices a text file or PDF
+moved under it. The tty and SDL hosts currently implement that path with Linux
+inotify; the native macOS host watches both each file and its parent directory
+with debounced DispatchSources, then restats the exact path under a pane-generation
+guard. The file catches in-place writes while the parent follows rename-over
+saves. Text snapshots are filtered by their content hash; PDFs use bounded
+inode/size/time identity and commit it only when equal stats bracket a
+successful transactional MuPDF reopen. A mismatched transaction gets one
+bounded self-retry. This catches rename-over saves without reading a large PDF
+merely to notice it changed or spinning on a malformed one. Web has no
+filesystem watcher. A PDF response retains its
+reading position and pane settings. This is what makes the browser build honest instead of a
fork: a wasm shell simply
answers `spawn` differently (or not at all) — the core does not know.
@@ -111,10 +136,20 @@ if (platform == .web and target.kind == .url)
return .{ .open_link = target.text };
```
-There is one such file by design: `look.zig` holds *what a click on text means* —
-expansion of the word under the click (acme's `isfilec`), path/`:line`
-resolution, url detection, and the per-platform outcomes, kept adjacent so the
-divergence is visible in one screenful.
+There is one such file by design: `look.zig` holds path/`:line` resolution,
+URL detection, and the per-platform outcomes. The core's one `pointerOperand`
+primitive owns click-word expansion and is shared verbatim by right-click and
+the delayed hover preview; that policy stays beside input because it also
+observes live pane selections and wrapped grid coordinates.
+
+Pane implementations are similarly flat and direct: `file_pane.zig`,
+`term_pane.zig`, `image_pane.zig`, `output_pane.zig`, and `pdf_pane.zig` own
+their kind-specific storage and operations. `pardes.zig` keeps the layout,
+input dispatch, cross-pane invariants, and the small calls joining those
+modules. There is no pane vtable or callback layer; the kind is already plain
+data, so a direct switch/call is the shortest boundary.
+The large end-to-end PDF cases live in `pdf_pane_integration_test.zig`, keeping
+pane-specific fixtures and raster assertions out of that core file as well.
= Data structures
@@ -123,24 +158,76 @@ heap-allocated on demand and their contents (file bytes, the yank register, PDF
rasters, tree-sitter state) grow with what you open; everything else is sized
at init.
+User-settable runtime choices are one plain `runtime_config.State`: booleans,
+theme index, owned bounded shell/font strings, requested/effective font facts,
+one panel-transition enum, and scene-effect booleans. A compile-time `settings`
+array generates each setting builtin and the rows of the single `Config`
+query. It has no callbacks and no parallel query registry to drift from it.
+
+Horizontal layout weights are fixed-point integers and geometry rounds
+cumulative boundaries. Splitting a column replaces only its weight `W` by
+`A+B=W` at the same position. Therefore every boundary outside the source
+column is bit-identical before and after the split, including at awkward
+non-dyadic screen widths; only the source and new column can receive movement
+tracks. Vertical splits apply the corresponding rule to the source pane's
+weight.
+
+= Presentation animation
+
+`panel_animation.zig` is backend-neutral data and math: five transitions,
+their easing, exact endpoint progress, stable per-cell noise, and a POD track.
+The core detects opening/moving rectangles when it commits layout and publishes
+only active tracks. It retains the last successfully presented canonical grid
+and a semantic old/new cell mask; unused grapheme bytes do not manufacture a
+change. A separate dense closing-track list is presentation-only state for a
+pane whose functional lifetime has already ended. Pointer input inverts the
+presented slide/zoom/vertical rectangle back to the canonical grid, lets
+unchanged dissolve/ASCII cells through immediately, and rejects closing
+pixels, so pixels and gestures cannot disagree during a transition.
+
+TTY copies the canonical `Surface` grid into a compositor scratch grid, clears
+slide/zoom destinations, then paints moving, opening, and closing panels in
+order.
+Cleared geometry uses the theme page color when it is explicit and the host
+terminal default only for transparent themes, so a light theme cannot flash a
+dark gap. Slide/zoom change the copied rectangle. Dissolve changes only diff
+cells from their old value to their new value; ASCII takes the same cells
+through punctuation; vertical raises only an opening or frozen closing pane
+inside its own clip. SDL supplies old/new glyph data, diff flags,
+final/presented boxes, and effect parameters to the glyph and native-image
+shaders. macOS passes the same records across its plain C ABI and composites
+old/new panel images in Metal/Core Image. Scene `Crt`, `Ripple`, and
+`Glitch` bits share one full-window pass in each native GUI. DOM web is a
+separate platform, not a shader GUI: retaining selectable HTML/CSS is more
+important than duplicating the renderer in canvas, so it exposes neither
+effect family.
+
+`EffectCode <effect>` writes the actual backend math, host submission, and
+shader/grid source segments embedded by the build into an ordinary output
+pane. This makes the implementation inspectable
+after installation and makes sharing explicit: several builtins can quite
+honestly print the same shader with different uniform bits.
+
```zig
Pardes
ncol + col_weight[6], col_terms[6][16], col_n[6] // columns as flat arrays
panes: [16]?*Pane // slot array; id = index
active: usize
drag: Drag // none | select | move | border_v | border_h | tag
- theme_idx: usize // index into themes (228 of them)
- colors_on: bool
+ settings: runtime_config.State // theme, shell/font, display/effect choices
rects: [16]Rect // where each pane landed, this frame
+ panel_tracks: [16]?Track // live panes, serial-guarded
+ closing_panel_tracks: [16]Track // dense visual tombstones, no pane owner
+ presented_cells + changed_cells // acknowledged baseline + semantic diff
effects: [4096]Effect + head/len // a fixed ring
Pane
tag: TagLine // live prefix (cwd/path) + editable tail
- kind: enum { terminal, file, image, pdf } // + independent optional payloads
vt, stream: ghostty-vt Terminal and its stream — on EVERY pane, not just
terminals, which is what lets the same keys and the
same parity suite drive a file and a shell
- file: ?File / image: ?Image / pdf: ?PdfView
+ file: ?file_pane.State / image: ?image_pane.State / pdf: ?pdf_pane.State
+ // payload presence is the kind; none = terminal
vweight: f32
mode: enum { normal, insert, tty } // helix-modal; tty = raw to the pty
// `v` adds a fourth thing to DISPLAY, "select",
@@ -148,13 +235,14 @@ Pane
cursor: absolute body position // rides the scrollback, not the screen
sel: [3]Sel + msel/vsel + sels[63] // per-button block sels, the line and
// char modal ones, and up to 64 cursors
- ovl: ?Ovl // ONE typed run, anchored to an absolute row
- undo: two stacks, not one — ed_undo[64] of overlay snapshots for a terminal,
- // and File.undo of content snapshots for a file
+ ovl: ?term_pane.EditBuffer // ONE typed run, anchored to an absolute row
+ undo: two stacks, not one — term_pane.Snapshot history for an edit buffer,
+ // and file_pane.State history for file content
-File = path + bytes + line index + Syn (tree-sitter highlight bytes)
-Image = decoded RGBA + petscii grid cache
-PdfView = MuPDF document + per-page rasters + cached outline
+file_pane.State = path + bytes + line index + Syn (tree-sitter highlight bytes)
+image_pane.State = decoded RGBA + petscii grid cache
+pdf_pane.State = MuPDF document + continuous layout + search/selection/outline
+ + bounded per-page raster relay + frame placement decisions
```
Layout is arithmetic, not objects: columns are weights over the width, panes are
@@ -170,35 +258,11 @@ big `update` dispatch, not handler objects; no one-line helpers — inline the
four-line scan; assert invariants at entry (`assert(vsum > 0)`); static
allocation at init, arenas per frame.
-The metric was lines of code, and for the rewrite itself it held: 7,626 lines
-replaced the prototype's 12,072 with three backends instead of one and a half.
-It has not held since, and the table below is the honest version rather than
-the flattering one. Everything after the rewrite — PDF, a language backend, a
-fourth shell, 228 themes, multiple cursors — was added, not traded for
-something removed:
-
-#table(
- columns: (auto, auto, auto, 1fr),
- stroke: 0.4pt,
- [*module*], [*at the rewrite*], [*now*], [],
- [core and the pieces that grew out of it — 25 files: pardes, look, syntax, image, dump, builtins, config, output/file/term panes, normal\_input, nested, fonts, themes, …], [4,289], [22,220], [all semantics, all platforms],
- [modal.zig + petscii.zig (pure, unit-testable)], [1,410], [2,569], [carried over],
- [pdf.zig], [—], [1,581], [MuPDF, `-Dmupdf`],
- [lsp/ (seam + in-process ZLS backend)], [—], [1,663], [`.zig` only],
- [shells: tty 1,396; gui 4,968; web 601; macos.zig 1,579], [2,768], [8,544], [translate + render only],
- [main.zig + build.zig], [484], [1,471], [four targets, four codegen passes],
- [*application total* — every `.zig` under `src/`, plus `build.zig`], [*7,626*], [*38,048*], [vs the prototype's 12,072],
-)
-
-Those six rows are a partition: no file is in two of them and none is left out,
-which the old table could not say (its rows summed to 1,325 more than its own
-total, and twenty-six files were in no row at all). What the total deliberately
-does NOT count, because none of it is Zig under `src/`: 2,250 lines of Swift for
-the macOS app and its icon generator, 1,541 lines of C bridging MuPDF, 549 of
-JavaScript, HTML and CSS for the web shell, 216 of C for the SDL font loader,
-`mupdf.zig` (355) at the root, and `tools/` + `build/` (896). The test tree is
-another 5,234 lines of Zig across eight harnesses, plus 1,028 of Swift and 610
-of JavaScript — against 831 at the rewrite.
+The rewrite used source size as a pressure toward direct code, and that remains
+useful when a refactor deletes duplicate policy or state. A checked-in line-count
+inventory does not: it goes stale whenever a pane kind, backend, or generated
+asset moves. Measure the current tree when making that comparison; keep this
+document about ownership and invariants that should survive the next edit.
Two things that number is not. It is not one program's worth of growth — the
macOS and web shells and the PDF and language work are four products sharing a
@@ -214,14 +278,18 @@ captures the rendered grid — text, cursor, and per-cell style runs — through
its own ghostty terminal. Goldens are generated from the old binary
(`zig build snap -- --update`); the new binary must reproduce them byte for
byte (`pardes-snap pardes/zig-out/bin/pardes`). Eighteen scripts covered the
-checklist in Appendix A at the rewrite; eighty-seven cover it and everything
+checklist in Appendix A at the rewrite; ninety cover it and everything
since. All pass. Determinism pins: fixed workdir paths (they
appear in tags), a controlled `$HOME` with `PS1='$ '`, `LC_ALL=C`, and a
grid-stability sync primitive instead of timing guesses.
Three deliberate deviations surfaced by the oracle, kept after review: the
-greeting `ls` waits for the shell's first output (the prototype raced bash's
-startup and won only by allocator luck); dump files compare with base64 pty
+greeting `ls` waits for the exact OSC 133 B input mark after the real resize
+(the prototype raced bash's startup and won only by allocator luck); shells
+without prompt integration omit that cosmetic greeting rather than guessing.
+Commands which create a fresh shell are owned by its terminal pane until the
+host reports the actual prompt capability, then wait for the same mark when it
+exists. Dump files compare with base64 pty
history elided (it encodes prompt-redraw micro-timing, not state — the cleaned
text fields are the contract); and typed insert runs don't survive a dump
replay (they are an overlay, not pty bytes — the prototype's replay viewer had
@@ -252,12 +320,14 @@ everywhere but the web, lazy), ZLS, mvzr (the regex engine behind `s`/`S`), and
zig-tree-sitter + 26 grammars — all pinned
through `zig fetch` and wired in `build.zig`. Generated during the build:
`highlights.scm` → an options module; the vendored helix/zed theme
-sources → the generated half of the theme ring; the tracked `.zig` sources →
+sources → the generated half of the theme ring; working-tree `.zig` sources →
the web shell's read-only archive; eight GLSL shaders → SPIR-V. That last one is
the only build input wanting a tool a stock machine lacks (`glslc`), so it is
-also the only one whose output is committed: `zig build shaders` writes
-`shaders/prebuilt/*.spv` and `-Dprebuilt-shaders` embeds that copy rather than
-shelling out, which is what lets a gui build need nothing but a C toolchain. The
+also the only one whose output is committed: `zig build shaders` refreshes each
+paired `shaders/prebuilt/*.spv` binary and `.glsl` source snapshot together.
+`-Dprebuilt-shaders` embeds that exact pair rather than shelling out, so
+`EffectCode` cannot describe different shader text from the binary on screen;
+this is also what lets a gui build need nothing but a C toolchain. The
tutor and the embedded font are plain `@embedFile`s, not codegen. Debug builds are
incremental for the seconds-loop; release builds are the product.
@@ -292,7 +362,8 @@ everything below that the prototype never had.
*Layout.* Columns by weight (≤6), panes by vweight (16 panes in total, not
per column); global topbar;
per-pane gutter (move box + scrollbar) and tag row; `splitBelow` shrinks only
-the source (cursor row kept visible); dying pane's weight absorbed by one
+the source (cursor row kept visible); `Newcol` takes width only from the source
+column and cannot resize any unrelated column; dying pane's weight absorbed by one
sibling; emptied column hands width to a neighbor; border-drag resize on a
pane's own trailing edge (v and h), hover shows `╎`/`╌` glyph-only hints;
move-drag via the gutter box with preview; Alt-n new shell below, Alt-c move
@@ -321,8 +392,10 @@ has beyond a path: `` @`ls -la` `` is taken WHOLE and runs as a command rather
than opening as a file, and `@p7:10:5` addresses a live pane by number for the
things — terminals, output buffers — that have no path to name.
Middle+left chord: kept
-left selection appended as trailing CLI argument. Wheel: scroll hovered pane,
-batched.
+left selection appended as trailing CLI argument. A stationary pointer gets a
+delayed, theme-derived highlight of the exact side-effect-free selection that
+Look would expand; it neither focuses nor installs that selection, and pointer
+leave/input/content invalidation cancels it. Wheel: scroll hovered pane, batched.
*Modes.* normal: helix motions (`h j k l w b e W B E 0 $ ^`, `f F t T` and
`Alt-.`, counts, `gg ge gh gs gl g| G`, `Ctrl-d/u/f/b`, `zt zz zb zj zk`),
@@ -349,9 +422,10 @@ cannot clobber what the desktop was holding. A paste from an outer terminal
arrives bracketed, as one `paste` event.
*Tag.* Live prefix (mode indicator, cwd or path) + editable tail with the
-full modal editor; defaults `New Del` / `Save New Del`; an image tag is
-`img <path> New Del` like any other (its renderer toggles are builtins under
-`SPC t p/l/a`). Topbar:
+full modal editor; defaults `New Del` / `Save New Del`; an image tag reports
+`img petscii:<on|off> palette:<commodore|terminal> ascii:<on|off> <path>`
+before the ordinary tail (its renderer toggles are builtins under `SPC t
+p/l/a`). Topbar:
`New Newcol Find Grep Help Tutor Dump NextColor Debug Kill` — execute-only
(left click inert);
Colors and Crt left it for their leader paths.
@@ -408,10 +482,12 @@ scrollbar, Colors/NextColor, and link-LOOK opening a new
tab (new — the prototype has no web link handling). One-finger touch is
deliberately complete by itself: tap = LOOK; drag past a small slop = natural
scroll, with no LOOK on release. A build-generated read-only archive lets LOOK
-open the current contents of Git-tracked Pardes `.zig` files despite the web
+open the current contents of tracked or new/nonignored Pardes `.zig` files despite the web
shell having no host filesystem. The published launcher dump is captured from
-a running Pardes TTY after `git ls-files '*.zig'`, so its complete terminal
-listing is the set the user can open. The freestanding module carries no host
+a running Pardes TTY after
+`git ls-files --cached --others --exclude-standard -- '*.zig' | sort`, so its
+complete terminal listing is the same tracked-plus-new/nonignored set the user
+can open. The freestanding module carries no host
libc, SDL, or WebGL; Tree-sitter's C runtime and the selected parsers link into
the module through a tiny local ABI shim (Zig is the compact web default). A body gesture retains
tap-LOOK/drag-scroll, but finger-down on
diff --git a/docs/helix-keys.md b/docs/helix-keys.md
index addc23da..2aff75cd 100644
--- a/docs/helix-keys.md
+++ b/docs/helix-keys.md
@@ -95,7 +95,7 @@ language-backend queries, and the shell pipe.
| `Ctrl-w` + `h/j/k/l`/arrows | directional pane focus prefix — normal/tty modes only | pardes' own window handling (helix window mode skipped, section C). Runs the SAME `Left`/`Down`/`Up`/`Right` builtins `SPC w h/j/k/l` runs; kept alongside the leader because a pane in raw **tty** mode never sees `SPC` (the shell owns it), so this is the only keyboard way out of one. Insert mode owns `Ctrl-w` = delete-word-back, so a tag being TYPED into swallows it; from a tag in normal mode (`:`) it moves focus to the neighbour's BODY, while the bare letters `h/j/k/l` there move to its TAGLINE (next row) | pardes-specific |
| `Alt-n` | new terminal below (any mode) | shadows helix `Alt-n` TS sibling-select — skipped anyway (tree-sitter) | pardes-specific |
| `Alt-c` | move active terminal to a fresh column (any mode) | helix `Alt-c` is change-noyank; the pardes window op wins (do-not-touch contract). `Alt-d` + `i` covers the behavior | waived (`alt-c-window-op`) |
-| `Space` (normal, body) | the pardes LEADER: a key path from here runs a BUILTIN with no arguments — the same builtins the topbar and the tags hold. `SPC k` Kill, `SPC d` Del, `SPC f s` Save, `SPC f n` New (an empty temporary file focused in the calling pane's column), `SPC f f` Find (fd over the pane's directory, results into `+Search`), `SPC f g` Grep (grep -R over the CONTENTS of every pane's directory, the ones another pane already covers dropped, rows relative to the asking pane's own directory, results into `+Search`; like Find it takes an ARGUMENT — `Grep foo` executed, or a selection chorded onto the word, searches that and skips the input), `SPC h t` Tutor, `SPC c n`/`SPC c d` Newcol/Delcol, `SPC y/Y/p/P/R` the five clipboard words (next row), `SPC t d/c/n/r` Debug/Colors/NextColor/Crt, `SPC t t` ThemeSel (the whole theme ring as `Theme <name>` rows in an output buffer — rows that are COMMANDS rather than places, so a theme is previewed by EXECUTING one, Tab or a middle click; `n`/`N` do not run them, they step past to the next look-able text on the ring), `SPC t p/l/a` Petscii/Palette/Ascii (an image pane's renderer toggles — the words its tag used to spell out, so the bar stays a bar), `SPC s d`/`SPC s r` Dump/Restore, `SPC w h/j/k/l` Left/Down/Up/Right (directional pane focus — the `Ctrl-w` prefix's four moves as builtins), `SPC j o`/`SPC j i`/`SPC j j`/`SPC j l` Back/Forward/Last/Jumplist (the jump stack: walk it back, walk it forward, hop to the pane before this one — what body-normal Esc runs — and read the stack as a clickable buffer). `?` at ANY depth opens the Help builtin listing what the prefix can still reach (`SPC ?` = all, `SPC h ?` = the docs group), into a `+Help` output buffer. The pending path shows at the right edge of the active pane's tag; Esc — or any unmapped key — abandons it | helix spends Space on pickers/LSP (section C); pardes has neither, and acme's builtins are what a leader is for. Enum-derived: the paths, the Help lines and the execute dispatch all fold out of one `Builtin` enum at comptime. Normal mode on a BODY only — tags are always insert, tty keys belong to the program | pardes-specific |
+| `Space` (normal, body) | starts the pardes LEADER: a key path runs the same builtin words used by tags and the topbar. The main groups are `f` files, `h` docs, `c` columns, `t` toggles/effects, `a` panel animations, `s` session, `j` jumps, `l` language, and `w` directional focus; `SPC ?` lists every path and `<prefix> ?` lists one group in `+Help`. The pending path appears on the active pane's transient body/message row. Esc or an unmapped key abandons it. Paths, Help rows, and dispatch are all generated from the builtin registry at comptime; the leader applies only to a BODY in normal mode because tags and tty programs own their input | pardes-specific; helix spends Space on pickers/LSP (section C), while pardes uses acme-style executable words |
| `SPC y` `SPC Y` `SPC p` `SPC P` `SPC R` | helix's clipboard menu on helix's own letters: yank the selection to the system clipboard (`ClipYank`) or the PRIMARY selection alone (`ClipYankMain`), paste the system clipboard after (`ClipPaste`) / before (`ClipPasteBefore`) the selection, replace the selection with it (`ClipReplace`) | the ONLY five words in pardes that touch the desktop's clipboard — `y`/`d`/`c`/`p`/`P`/`R` and the acme cut/paste chords are the internal register alone. Builtins rather than bare chords because a leader path names a builtin: they land in Help's index and are executable words like every other verb. One divergence: pardes keeps a single register VALUE where helix keeps one per range, so `SPC y` at N cursors joins them with newlines (`Pardes.setYank`, the divergence `msel-yank-paste` already waives). On a tty the write is OSC 52 out and the READ is OSC 52 back, which many terminals refuse or gate — so `SPC y` works there and `SPC p` can be a no-op | out of corpus |
| a paste from the OUTER terminal | one `Event.paste`, spliced in at the cursor | the tty shell enables bracketed paste and coalesces `paste_start`..`paste_end` into a single event; before that the bytes arrived as individual key presses and normal mode RAN them, which is how a pasted `d` deleted a line. The bytes deliberately never enter the yank register — clipboard and default register are separate stores in both directions | pardes-specific |
| `/` (any pane) | pardes' own plain-substring search into a `+Search` output buffer: the tag takes the pattern, Enter fills the buffer, and its rows are ordinary look targets. Enter also GOES to the first row — the buffer is focused and then the step `n` is and the look Enter is run in it (`Pardes.lookFirstHit`), so `/foo` lands on the first hit with the matched span selected. A pattern that matched nothing opens its empty buffer and moves nothing | KEEP, do not touch; not in the corpus (helix `/` is regex search). Find and Grep answer with OTHER files and deliberately do NOT jump. The stepping half is the next row | pardes-specific |
@@ -348,7 +348,7 @@ Files (all in `test/hxcases/`):
(one yank register, not one value per range), and `sel-regex-caret` /
`sel-regex-dot-newline` (mvzr is not the Rust regex crate: no
multi-line `^`/`$`, and `.` matches a newline).
-- `test/hxdiff.zig` builds `pardes-hxdiff`, which drives the sans-IO core
+- `test/hxdiff.zig` builds `pardes-hxdiff`, which drives the core
headlessly at 80x24 (22 body rows, matching helix's 22 text rows).
Run it:
diff --git a/docs/macos.md b/docs/macos.md
index 12e598b5..b800513f 100644
--- a/docs/macos.md
+++ b/docs/macos.md
@@ -44,6 +44,11 @@ the view and drives its own frame clock, and the ABI grows a `platform` pointer
field carrying the `NSView*` — one field, because the rest of the seam does not
change. Nothing here is designed to make that harder.
+That does not rule out a *postprocess*. CoreText is still the renderer and the
+cell ABI is unchanged, but shader effects draw that same frame into a retained
+bitmap and feed it through Core Image kernels on one Metal context.
+There is no second glyph atlas, pane renderer, or view pointer in the ABI.
+
## Why the ABI mirrors src/web.zig
The browser and a Cocoa app are the same host, and the browser proved the shape
@@ -78,6 +83,11 @@ asking for a thin insert caret instead of a block. `Surface.images` crosses
separately as `pardes_frame_images` / `pardes_frame_image_list` — see "Pixel
attachments" below.
+`pardes_scene` is the matching full-window snapshot: a bit each for CRT,
+ripple, and glitch, plus the 60 Hz time/frame that animates them. It is returned
+by value, so the renderer never holds a pointer into live core state. A zero
+flags word is also the fast-path contract: draw the CoreText frame directly.
+
**Input.** `pardes_key` takes a codepoint and the host's already-composed text,
because composition is AppKit's job and the core only ever wants finished
characters. Keys that carry no text are the four ASCII controls (enter, escape,
@@ -123,15 +133,19 @@ because it owns the filesystem side too.
**Lifecycle.** `pardes_init` returns 0 or an opaque nonzero code,
`pardes_deinit` tears down, `pardes_tick` drains pty output plus the effect
queue and returns whether anything changed, `pardes_should_quit` reports the
-Exit builtin or the last pane closing, and `pardes_animating` says a theme
-transition wants ~60 Hz ticks until it settles — the one thing in an otherwise
-event-driven frontend that redraws on a clock, handled in `tty.zig` by sleeping
-the loop and forcing a tick.
+Exit builtin or the last pane closing, and `pardes_animating` says
+`pardes_animation_tick` wants a ~60 Hz call until it settles — the one thing in
+an otherwise event-driven frontend that redraws on a clock. A persistent scene
+effect deliberately keeps that clock armed until its builtin turns it off.
+Ordinary input and pty pumps never count as elapsed animation frames.
The ordering contract is the part a header cannot enforce. **Init with the real
-grid size.** The core defers each shell's greeting until it has seen a resize:
-`sync()` in `src/pardes.zig` only emits the opening `ls` once `resize_count > 0`
-and the pty has produced its first prompt. Setting `Options.cols`/`rows` alone
+grid size.** The core defers each integrated shell's greeting until it has seen
+a resize: `sync()` in `src/pardes.zig` only emits the opening `ls` once
+`resize_count > 0` and parsed OSC 133 B says the prompt has handed the cursor
+to input. An unintegrated shell has no reliable readiness signal, so it skips
+the cosmetic greeting; an explicit command still releases after its successful
+fork. Setting `Options.cols`/`rows` alone
never bumps that counter, so init must turn its arguments into an actual resize
event the way `src/web.zig` does immediately after construction — and the size
must be true, because the first `forkpty` takes its winsize from the core's
@@ -252,12 +266,12 @@ most deliberate twist there is and the one a stale velocity sample would fling
hardest. And a finger back down (`pardes_rotate(0)`) catches a coast in
progress, the way a hand catches a dial.
-The coast itself is spent by `pardes_tick`, one fixed 1/60 step per tick with a
-0.94 decay, and it makes `pardes_animating` true for as long as it lasts — so
-it rides the same 16 ms re-pump a theme transition does and needs no clock of
-its own. Fixed rather than measured on purpose: one fling then spends the same
-travel every time, which is what lets `rotate.snap` assert it instead of
-asserting the machine's timer jitter.
+The coast itself is spent by `pardes_animation_tick`, one fixed 1/60 step per
+scheduled frame with a 0.94 decay, and it makes `pardes_animating` true for as
+long as it lasts — so it rides the same 16 ms re-pump a theme transition does
+and needs no clock of its own. Fixed rather than measured on purpose: one fling
+then spends the same travel every time, which is what lets `rotate.snap` assert
+it instead of asserting the machine's timer jitter.
Both halves are goldens. `rotate.snap` turns the dial at 4° per 100 ms (40°/s,
under the floor) and asserts the screen is byte-identical across the release,
@@ -471,14 +485,18 @@ can disagree. `pardes_font_take` hands it over once, the same take-and-clear
shape as the haptic, and the view loads it with
`CTFontManagerCreateFontDescriptorsFromURL`, picks the untraited cut out of a
collection, derives bold and italic from it, and re-measures. A file it cannot
-wear leaves the screen exactly as it was — a terminal that cannot draw has no
-way back out of itself.
+wear crosses back through `pardes_font_reject` and leaves the old metrics
+exactly as they were. Success crosses through `pardes_font_ack` with the
+effective PostScript name and point size. Requested and effective values are
+therefore distinct, queryable facts rather than a request being mistaken for
+what is on screen.
-Zoom does not touch the core at all. Cmd+=, Cmd- and Cmd+0 change the point size,
-`Metrics` is rebuilt, and the new cell is reported through the same resize path
-a window drag uses; the core reflows to a different number of columns and knows
-nothing about points. Cmd+= rather than Cmd++ because AppKit matches the
-character and `=` is what is under the finger.
+Cmd+=, Cmd- and Cmd+0 change the point size, rebuild `Metrics`, and report the
+new cell through the same resize path a window drag uses. They also observe the
+effective face/point tuple through `pardes_font_observe`, so the single `Config`
+report follows a host-owned zoom without resolving a pending face request. The
+initial system face is observed the same way. Cmd+= rather than Cmd++ because
+AppKit matches the character and `=` is what is under the finger.
Both are machine-checked in `test/macos-snapshots/font.snap`, which needs two
different kinds of assertion because a snapshot is the core's cell buffer and
@@ -551,18 +569,40 @@ Turning this on also changes what an IMAGE pane is here: it was the PETSCII
glyph-art fallback, the same one a terminal without kitty graphics gets, and it
is now the real pixels.
+## Live file reload
+
+`FileWatcher.swift` gives each watched pane a vnode source on the file and one
+on its parent directory. The file catches in-place writes; the parent is
+essential because editors commonly save by renaming a fresh inode over the
+path. After the 45 ms debounce the file source is rearmed on the current inode,
+then the event returns to Zig with the pane's watch generation. Replacing or
+closing a pane advances that generation, so a callback already queued for the
+old occupant cannot reload the new one.
+
+The callback only wakes the ordinary main-thread pump. `pardes_tick` checks the
+exact owned path, filtering unrelated changes in the same directory, duplicate
+vnode events, and Pardes's own saves. Text panes compare and adopt a bounded
+byte snapshot by content hash. PDFs can be much larger than that bound, so they
+compare inode/size/time metadata and let MuPDF reopen the pathname directly.
+That identity is committed only when equal stats bracket a successful
+transactional reopen; a mismatch receives one self-scheduled retry, so it does
+not depend on a second vnode edge and cannot spin forever on a malformed stable
+file. Reading settings survive and derived page data is regenerated. A
+malformed PDF therefore leaves the last good document usable and remains
+retryable after the next real write.
+
## Themes, live
Two bugs lived here, and they were the same bug.
`ChromeTheme` fades between themes over ten 16 ms steps, advanced by a `.tick`
-event. The tty and SDL loops call `core.update(.tick)` on their own clocks;
-this host has no loop of its own, so nothing advanced it — `pardes_tick`
-drained ptys and reported `themeAnimationActive()` back without ever stepping
-the transition. The fade therefore never moved and never ended: every tagline
-kept the *previous* theme's colours until the next launch, and the 16 ms
-re-pump in `AppDelegate.pump` spun at 60 Hz for the rest of the session. The
-pump is the clock, so `pardes_tick` steps it.
+event. The tty and SDL loops call `core.update(.tick)` on their own clocks; the
+native host schedules the same clock explicitly through
+`pardes_animation_tick`. `pardes_tick` only drains work: if every input pump
+also advanced the transition, a burst of key or pty events could collapse a
+ten-frame fade into one display frame. The scheduled callback advances once,
+then pumps effects and redraws, and re-arms itself only while
+`pardes_animating` remains true.
`pardes_theme_bg` is the other half. The window background behind the titlebar
and behind a live resize was a hand-agreed `#121212` in two files; it is now
@@ -571,6 +611,65 @@ chrome's, because document backgrounds switch the instant the theme does while
chrome fades. `window.appearance` follows its luminance, so wearing `acme` no
longer leaves a dark titlebar over a cream grid.
+## Scene effects
+
+`Crt`, `Ripple`, and `Glitch` are three switches over one postprocess, not three
+stacked filters. Their canonical macOS source is `shaders/crt.ci.metal`, which
+the build installs as `pardes.app/Contents/Resources/crt.ci.metal`. The Swift
+shell loads that exact asset with `CIKernel.kernels(withMetalString:)` and runs
+it through a `CIContext` created from the system Metal device. The source is
+also a Zig build import, so `EffectCode` can print what the app executes when
+the source checkout is absent.
+
+The ordinary CoreText/attachment/cursor pass is one function. With any scene
+bit or panel track it targets a retained, backing-scale bitmap; kernels then
+sample the complete frame into the flipped view. PDFs, image panes, taglines,
+rules, and the caret therefore receive the same effect. With every bit off and
+no panel track the bitmap and Core Image context are bypassed entirely. Ripple
+and glitch alter sample coordinates only, preserving theme colors; CRT works
+in linear light with restrained bloom, scan/mask, vignette, hum, and noise
+instead of applying a broad color remap.
+
+The scene kernel alone receives a `clampedToExtent` image and the final output
+is cropped back to the original finite extent. Coordinate tears and chroma
+samples therefore clamp to the edge exactly like SDL's scene sampler instead
+of acquiring transparent-black seams from Core Image's finite source image.
+
+The Zig side owns the clock. `pardes_animation_tick` increments its wrapped
+60 Hz frame only while a scene bit is active, and `pardes_scene` derives seconds
+from that integer. Input bursts cannot accelerate the shader.
+
+Pointer input follows the same destination-to-source transform as the last
+presented scene frame before it is divided by the cell metrics. The view keeps
+that exact `pardes_scene_s` snapshot and mirrors the Metal barrel, ripple and
+glitch sampling arithmetic in `ScenePostprocessor.sourcePoint`; pixels outside
+the CRT tube have no cell. The resulting displayed-grid cell then reaches the
+core, whose panel-track mapping resolves it to canonical pane content.
+
+## Panel transitions
+
+`pardes_frame_panel_track_list` publishes the core's plain `Track` records
+without a host-side animation model: pane serial/id, phase, effect, frame, and
+logical-cell `from`/`to` boxes. The C layout and every field offset are asserted
+against the Zig extern struct on Linux. The exported list is already stable
+paint order—moving panes by slot, then opening panes by slot—so Swift only
+consumes it.
+
+CoreText still renders one canonical complete frame. When tracks exist, the
+same retained bitmap used by scene effects is fed through two kernels in
+`shaders/crt.ci.metal`: `pardesPanelClear` removes every final target first,
+then `pardesPanel` samples each target into its eased presented box. Slide uses
+ease-out cubic, zoom uses ease-out-back, dissolve uses stable pane/cell noise,
+and ASCII materialization replaces not-yet-revealed cells with procedural
+punctuation. Because the input is the finished bitmap rather than a glyph-only
+batch, backgrounds, glyphs, taglines, rules, the caret, PDF pages, and image
+panes move and dissolve together. Scene CRT/ripple/glitch runs once after the
+panel composition.
+
+With no scene bit and no panel track the retained bitmap, Core Image context,
+and Metal passes are bypassed. `EffectCode Panel*` embeds this actual Metal
+file plus its direct Swift owner, the same files the app executes.
+
### Transparent themes
A theme with `bg = null` — the curated `dark`, and every vendored
@@ -794,7 +893,12 @@ vocabulary (`start`, `wait`, `stable`, `text`, `key`, `snap`, `command`,
exists here: `fingers <n>`, `force`, `rotate <degrees> [gap_ms]`, `rotate_end`,
`drop <path> <col> <row>`, `scroll <rows> <col> <row>`,
`haptic <none|exec|look>`, `nsclick` (the AppKit-event path, as opposed to
-`click`'s direct entry-point call), `font <name>`, `zoom` and `clipboard`.
+`click`'s direct entry-point call), `font <name>`, `font-size <points>`, `zoom`,
+`tracks <phase:effect>...`, `draw-effect`, and `clipboard`. The boot script uses
+`tracks` followed by `draw-effect` to assert the moving/opening ABI order and
+that the runtime Metal owner actually accepted the panel frame (a raw fallback
+fails); the font script checks every initial/adopted/zoomed effective point
+size without changing its grid goldens.
The dial's two extras are what
make momentum testable at all: the optional gap is a real sleep before the
event, so a script can say how FAST the dial is being turned, and `rotate_end`
@@ -815,6 +919,11 @@ every pixel comes out identical, which is what keeps `draw(_:)` honest — `snap
reads the core's cell buffer and would be perfectly happy with a `draw` that
returned on its first line.
+The Linux loop proves the new C layout, flag encoding, clock wrap, embedded
+kernel source, header syntax, and static library. Compiling Swift, runtime Metal
+kernel compilation, and comparing processed pixels remain `macos-e2e` work on
+a Darwin host; Linux has neither AppKit nor Apple's Metal runtime.
+
Goldens are hermetic: a fake `$HOME` with a pinned `PS1`, `Shell bash` in the
config (fish's prompt carries a hostname), `LC_ALL=C`, `PARDES_NOTIME=1`, and
`TMPDIR` inside the per-script world so that `New`'s document has a reproducible
@@ -851,6 +960,11 @@ this matter, are both in `pardes_frame` rather than in the view — it re-render
every cell of the grid on every frame, and `draw(_:)` ignores its `dirtyRect`
for exactly that reason.
+Those figures are the direct path with all scene bits off. An enabled scene
+adds an offscreen CoreGraphics frame, one Core Image/Metal kernel, and
+presentation of its result. That opt-in cost has not been measured on the M2
+used for the table and is not folded into the direct-path claim.
+
## Not implemented
- **The `lsp` effect.** Needs a worker plus a snapshot of the pane's path and
@@ -862,12 +976,6 @@ for exactly that reason.
- **The `pipe` effect.** Selection filters need `pipeRequest(id)` copied into a
job, a worker to run the command, and a `pipe_resp` event back. Same shape as
`lsp`, one more response type.
-- **The `watch` effect.** `watchPane` is inotify and returns silently off Linux
- (`ponytail:` at `src/tty/tty.zig:1183`). macOS wants the FSEvents half of
- `std.Build.Watch`, which the standard library already has as
- `Build/Watch/FsEvents.zig`. Without it the core simply never receives
- `file_changed`, a state it tolerates because the browser has no filesystem
- either.
- **IME and marked text.** Only finished characters reach `pardes_key`, so a
dead key composes nothing and Option is Alt rather than a compose modifier.
Real composition means implementing `NSTextInputClient` *and* giving the core
diff --git a/docs/web.md b/docs/web.md
index ddc09d35..a6e2e923 100644
--- a/docs/web.md
+++ b/docs/web.md
@@ -18,6 +18,22 @@ array. `src/web/app.mjs` reads that array in one linear pass and patches stable
DOM nodes. Core effects become browser operations (links, downloads, clipboard)
or `pardes-io` custom events for a process-capable embedding host.
+Cell attribute bit 7 carries the core's tagline font role. The DOM keeps every
+cell at the body grid's fixed width and height, but scales and centres the
+tagline glyph using the build-time `gui_tagline_font_percent`; the percentage is
+exported by the module rather than duplicated in JavaScript. Animation time is
+also host-independent: `requestAnimationFrame` time is accumulated into 60 Hz
+core ticks, so 120/144 Hz displays do not accelerate frame-count transitions
+and a returning background tab has bounded catch-up work.
+
+The `web` platform is deliberately not one of the shader-capable native GUI
+shells. It exposes no `PanelSlide`/`PanelZoom`/`PanelDissolve`/`PanelAscii`/
+`PanelVertical` or `Crt`/`Ripple`/`Glitch` builtins: applying those faithfully
+would require a second canvas renderer and give up the DOM renderer's
+selectable/accessibility contract. Theme fades and the delayed,
+side-effect-free Look hover remain grid animations and continue to use the
+fixed 60 Hz ticks above.
+
Build a replay from a dump:
```sh
@@ -37,12 +53,20 @@ file-or-directory — is native-only, because the wasm module roots at
`src/web.zig` and never links `main.zig`. State comes from the embedded dump
instead.
+Web LOOK's source archive follows Git's working-tree view: tracked `.zig` files
+plus new, nonignored ones, sorted by path. The checked-in browser replay is a
+real native TTY dump of that same selection; its top and bottom launcher
+snapshots jointly cover the complete list before opening `build.zig`.
+
What is genuinely absent is narrower than "no IO". The module has no threads
and no host filesystem, so there is no startup config file, LOOK resolves
against the build-generated source archive rather than disk, and no language
BACKEND is compiled in (`zls_backend` is off for wasm — which also means
`lsp.supports` is empty, so the core never even raises a language query there;
`web.zig`'s prong for it is waiting for a host that links one).
+Replay terminals retain the same core shape but use Zig's failing IO value;
+this keeps the module freestanding without instantiating POSIX threaded IO that
+the browser can never call.
The EFFECTS themselves are all still emitted, each as a numbered code across
the ABI: `spawn` 1, `write` 2, `resize_pty` 3, `open_link` 4, `save_file` 5,
diff --git a/shaders/crt.ci.metal b/shaders/crt.ci.metal
new file mode 100644
index 00000000..32e26b21
--- /dev/null
+++ b/shaders/crt.ci.metal
@@ -0,0 +1,376 @@
+// Native macOS panel and scene postprocess. Panel kernels consume the core's
+// plain tracks; the final full-window kernel ports crt.frag.glsl and combines
+// CRT, ripple and glitch. Scene constants and sampling geometry stay in step
+// with GLSL, while panel easing/noise stays in step with panel_animation.zig.
+#include <metal_stdlib>
+#include <CoreImage/CoreImage.h>
+using namespace metal;
+
+constant float PARDES_TAU = 6.28318530718;
+
+float pardesNoise(float2 p) {
+ float3 q = fract(float3(p.x, p.y, p.x) * 0.1031);
+ q += dot(q, q.yzx + 33.33);
+ return fract((q.x + q.y) * q.z);
+}
+
+// Exact integer hash from panel_animation.cellNoise. Pane serial and logical
+// cell coordinates, not destination pixels, keep TTY and GPU reveal decisions
+// stable across scale factors and geometry transforms.
+float pardesCellNoise(float2 serialParts, float colValue, float rowValue) {
+ uint serial = uint(max(serialParts.x, 0.0)) |
+ (uint(max(serialParts.y, 0.0)) << 16);
+ uint col = uint(max(colValue, 0.0));
+ uint row = uint(max(rowValue, 0.0));
+ uint x = serial ^ (col * 0x9e3779b9u) ^ (row * 0x85ebca6bu);
+ x ^= x >> 16;
+ x *= 0x7feb352du;
+ x ^= x >> 15;
+ x *= 0x846ca68bu;
+ x ^= x >> 16;
+ return float(x & 0xffffu) / 65535.0;
+}
+
+float2 pardesSceneUV(float2 uv, float timeSeconds, float rippleOn, float glitchOn) {
+ if (rippleOn > 0.5) {
+ float2 centered = uv - 0.5;
+ float radius = length(centered);
+ // Reversed smoothstep edges are undefined in GLSL and Metal alike.
+ float envelope = 1.0 - smoothstep(0.04, 0.72, radius);
+ float wave = sin(radius * 42.0 - timeSeconds * 5.2);
+ uv += centered / max(radius, 0.00001) * wave * envelope * 0.0032;
+ }
+ if (glitchOn > 0.5) {
+ float band = floor(uv.y * 96.0);
+ float seed = pardesNoise(float2(band, floor(timeSeconds * 13.0)));
+ float tear = smoothstep(0.955, 1.0, seed);
+ uv.x += (pardesNoise(float2(seed, band)) - 0.5) * tear * 0.020;
+ }
+ return uv;
+}
+
+float4 pardesSceneSample(
+ coreimage::sampler source,
+ float2 origin,
+ float2 size,
+ float2 uv
+) {
+ return coreimage::unpremultiply(
+ source.sample(source.transform(origin + uv * size)));
+}
+
+// Keep this ordering identical to crt.frag.glsl's sceneSrgb: every caller
+// supplies its own pre-warp coordinate, then ripple/glitch move that sample,
+// then glitch splits its red/blue channels around the moved center.
+float4 pardesSceneSrgb(
+ coreimage::sampler source,
+ float2 origin,
+ float2 size,
+ float2 uv,
+ float timeSeconds,
+ float rippleOn,
+ float glitchOn
+) {
+ float2 warped = pardesSceneUV(uv, timeSeconds, rippleOn, glitchOn);
+ float4 center = pardesSceneSample(source, origin, size, warped);
+ if (glitchOn <= 0.5) return center;
+
+ float band = floor(warped.y * 96.0);
+ float seed = pardesNoise(float2(band, floor(timeSeconds * 13.0)));
+ float tear = smoothstep(0.955, 1.0, seed);
+ float2 chroma = float2((0.35 + 1.25 * tear) / size.x, 0.0);
+ float4 red = pardesSceneSample(source, origin, size, warped + chroma);
+ float4 blue = pardesSceneSample(source, origin, size, warped - chroma);
+ return float4(red.r, center.g, blue.b, center.a);
+}
+
+float3 pardesSceneLinear(
+ coreimage::sampler source,
+ float2 origin,
+ float2 size,
+ float2 uv,
+ float timeSeconds,
+ float rippleOn,
+ float glitchOn
+) {
+ return coreimage::srgb_to_linear(pardesSceneSrgb(
+ source, origin, size, uv, timeSeconds, rippleOn, glitchOn).rgb);
+}
+
+bool pardesInside(float2 p, float4 box) {
+ return p.x >= box.x && p.y >= box.y &&
+ p.x < box.x + box.z && p.y < box.y + box.w;
+}
+
+float pardesPanelProgress(float effect, float frame) {
+ float frames = effect == 1.0 ? 12.0 :
+ effect == 2.0 ? 14.0 :
+ effect == 3.0 ? 10.0 :
+ effect == 4.0 ? 16.0 :
+ effect == 5.0 ? 12.0 : 1.0;
+ if (frames <= 1.0 || frame >= frames - 1.0) return 1.0;
+ float t = clamp(frame / (frames - 1.0), 0.0, 1.0);
+ if (effect == 1.0 || effect == 5.0) { // horizontal/vertical slide
+ float u = 1.0 - t;
+ return 1.0 - u * u * u;
+ }
+ if (effect == 2.0) { // zoom: ease-out back, including its overshoot
+ const float c1 = 1.70158;
+ const float c3 = c1 + 1.0;
+ float u = t - 1.0;
+ return 1.0 + c3 * u * u * u + c1 * u * u;
+ }
+ if (effect == 3.0) return t * t * (3.0 - 2.0 * t); // dissolve
+ return t; // ASCII materialization
+}
+
+float pardesAsciiGlyph(float2 uv, int glyph) {
+ float2 p = uv * 2.0 - 1.0;
+ const float thin = 0.12;
+ float dotShape = 1.0 - smoothstep(0.10, 0.20, length(p - float2(0.0, 0.45)));
+ float barH = 1.0 - smoothstep(thin, thin + 0.08, abs(p.y));
+ float barV = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x));
+ if (glyph == 0) return dotShape;
+ if (glyph == 1) return max(dotShape,
+ 1.0 - smoothstep(0.10, 0.20, length(p + float2(0.0, 0.45))));
+ if (glyph == 2) return max(barH, barV);
+ if (glyph == 3) {
+ float d0 = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x - p.y));
+ float d1 = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x + p.y));
+ return max(max(d0, d1), max(barH, barV));
+ }
+ if (glyph == 4) {
+ float rails = max(
+ 1.0 - smoothstep(thin, thin + 0.08, abs(abs(p.x) - 0.34)),
+ 1.0 - smoothstep(thin, thin + 0.08, abs(abs(p.y) - 0.34)));
+ return rails;
+ }
+ float ring = 1.0 - smoothstep(thin, thin + 0.08,
+ abs(length(p * float2(0.8, 1.0)) - 0.48));
+ return max(ring, glyph == 6 ? dotShape : 0.0);
+}
+
+// Prepare every geometric target before any translated panel is drawn. A
+// vertical opener restores the old frame in its fixed clip; a closing pane is
+// already absent from `canvas` and therefore needs no clear at all.
+extern "C" float4 pardesPanelClear(
+ coreimage::sampler canvas,
+ coreimage::sampler previous,
+ float4 target,
+ float4 ground,
+ float effect,
+ float phase,
+ coreimage::destination destination
+) {
+ float2 coord = destination.coord();
+ if (!pardesInside(coord, target))
+ return canvas.sample(canvas.transform(coord));
+ if (effect == 1.0 || effect == 2.0) return ground;
+ if (effect == 5.0 && phase == 0.0)
+ return previous.sample(previous.transform(coord));
+ return canvas.sample(canvas.transform(coord));
+}
+
+// One panel draw over the accumulated image. Current and previous are complete
+// CoreText+attachment rasters. That makes a closing pane safe after its core
+// allocation is gone and makes ASCII/dissolve true old-to-new data effects.
+extern "C" float4 pardesPanel(
+ coreimage::sampler current,
+ coreimage::sampler previous,
+ coreimage::sampler changedMask,
+ coreimage::sampler canvas,
+ float4 target,
+ float4 from,
+ float4 to,
+ float effect,
+ float phase,
+ float frame,
+ float2 serialParts,
+ float2 cellSize,
+ float2 gridOrigin,
+ coreimage::destination destination
+) {
+ float2 coord = destination.coord();
+ float4 underneath = canvas.sample(canvas.transform(coord));
+ float progress = pardesPanelProgress(effect, frame);
+
+ float2 safeCell = max(cellSize, float2(1.0));
+ // Content transitions stay at canonical geometry. Unchanged cells bypass
+ // the effect completely; changed cells retain the exact old raster until
+ // their deterministic wave reaches them.
+ if (effect == 3.0 || effect == 4.0) {
+ if (!pardesInside(coord, target)) return underneath;
+ // During a live resize the grid is still anchored at the view's top,
+ // leaving a partial strip below its final logical row. Account for
+ // that strip before indexing the compact cell mask.
+ float2 absoluteCell = floor((coord - gridOrigin) / safeCell);
+ float changed = changedMask.sample(changedMask.transform(
+ absoluteCell + float2(0.5))).r;
+ if (changed < 0.5) return underneath;
+
+ float4 oldPixel = previous.sample(previous.transform(coord));
+ float4 newPixel = current.sample(current.transform(coord));
+ // Exact endpoint guards are not redundant: a noise hash can be zero
+ // or one, and frame zero/last must still be wholly old/new.
+ if (progress <= 0.0) return oldPixel;
+ if (progress >= 1.0) return newPixel;
+
+ // Core/TTY coordinates are relative to the target's TOP-left. Core
+ // Image is bottom-up, hence the reversed row term.
+ float2 cell = float2(
+ floor((coord.x - target.x) / safeCell.x),
+ floor((target.y + target.w - coord.y) / safeCell.y));
+ float2 cellCount = max(round(target.zw / safeCell), float2(1.0));
+ cell = clamp(cell, float2(0.0), cellCount - 1.0);
+ float noise = pardesCellNoise(serialParts, cell.x, cell.y);
+ if (effect == 3.0)
+ return noise < progress ? newPixel : oldPixel;
+
+ float diagonal = fmod(cell.x + cell.y * 2.0, 17.0) / 17.0;
+ float threshold = noise * 0.72 + diagonal * 0.28;
+ if (progress >= threshold) return newPixel;
+ if (progress + 0.22 < threshold) return oldPixel;
+
+ float2 cellUV = fract(float2(
+ (coord.x - target.x) / safeCell.x,
+ (target.y + target.w - coord.y) / safeCell.y));
+ int glyph = min(int(floor(noise * 7.0)), 6);
+ float ink = pardesAsciiGlyph(cellUV, glyph);
+ float2 cellOrigin = float2(
+ target.x + cell.x * safeCell.x,
+ target.y + target.w - (cell.y + 1.0) * safeCell.y);
+ float4 bgSample = current.sample(current.transform(
+ cellOrigin + safeCell * float2(0.12, 0.12)));
+ float4 bg = coreimage::unpremultiply(bgSample);
+ float4 fg = coreimage::unpremultiply(newPixel);
+ // A procedural mark usually crosses pixels where the target glyph has
+ // no ink, so sampling `newPixel` alone can make it background-on-
+ // background and erase the ASCII phase. Preserve target ink where it
+ // is present; otherwise derive restrained contrast from the cell's
+ // own background instead of imposing a fixed palette.
+ float3 contrastInk = dot(bg.rgb, float3(0.2126, 0.7152, 0.0722)) > 0.5
+ ? float3(0.0) : float3(1.0);
+ float sourceContrast = smoothstep(0.025, 0.14, length(fg.rgb - bg.rgb));
+ float3 inkColor = mix(contrastInk, fg.rgb, sourceContrast);
+ float3 dimInk = mix(bg.rgb, inkColor, 0.62);
+ return coreimage::premultiply(float4(
+ mix(bg.rgb, dimInk, ink), max(bg.a, newPixel.a)));
+ }
+
+ float4 shown = mix(from, to, progress);
+ shown.zw = max(shown.zw, float2(0.0));
+ if (!pardesInside(coord, shown) || shown.z <= 0.0 || shown.w <= 0.0)
+ return underneath;
+ // Vertical lifecycle motion is deliberately confined to the pane's own
+ // old/new box. It never travels across or clips a surviving neighbour.
+ if (effect == 5.0 && !pardesInside(coord, target)) return underneath;
+
+ float2 within = (coord - shown.xy) / shown.zw;
+ float2 sourceCoord = target.xy + within * target.zw;
+ if (!pardesInside(sourceCoord, target)) return underneath;
+ return phase == 2.0
+ ? previous.sample(previous.transform(sourceCoord))
+ : current.sample(current.transform(sourceCoord));
+}
+
+extern "C" float4 pardesScene(
+ coreimage::sampler source,
+ float timeSeconds,
+ float frame,
+ float crtOn,
+ float rippleOn,
+ float glitchOn,
+ float2 sceneSize,
+ float2 sceneOrigin,
+ coreimage::destination destination
+) {
+ const float barrel = 0.016;
+ const float edgeSoftnessPx = 3.5;
+ const float chromaPx = 0.28;
+ const float bloomRadiusPx = 1.75;
+ const float bloomThreshold = 0.60;
+ const float bloomSoftness = 0.25;
+ const float bloomStrength = 0.045;
+ const float scanPeriodPx = 3.0;
+ const float scanStrength = 0.055;
+ const float maskPeriodPx = 3.0;
+ const float maskStrength = 0.015;
+ const float vignetteStrength = 0.11;
+ const float humStrength = 0.0025;
+ const float noiseStrength = 0.0020;
+ const float exposure = 1.012;
+
+ float2 size = sceneSize;
+ float2 origin = sceneOrigin;
+ float2 pixel = destination.coord() - origin;
+ float2 texel = 1.0 / size;
+ float2 cc = pixel / size * 2.0 - 1.0;
+ float r2 = dot(cc, cc);
+ float2 uv = cc * (1.0 + barrel * r2 * crtOn) * 0.5 + 0.5;
+ float edge = min(min(uv.x, uv.y), min(1.0 - uv.x, 1.0 - uv.y));
+ // Match GLSL's opaque black outside the tube. Transparent black would
+ // reveal AppKit's backdrop and make the same CRT effect have no tube on a
+ // transparent theme.
+ if (crtOn > 0.5 && edge <= 0.0) return float4(0.0, 0.0, 0.0, 1.0);
+
+ float4 center = pardesSceneSrgb(
+ source, origin, size, uv, timeSeconds, rippleOn, glitchOn);
+ float3 scene = center.rgb;
+
+ // Ripple/glitch alone are coordinate effects. Returning the sampled sRGB
+ // values directly keeps exact theme colors exact.
+ if (crtOn <= 0.5) {
+ return coreimage::premultiply(float4(clamp(scene, 0.0, 1.0), center.a));
+ }
+
+ float3 centerLinear = coreimage::srgb_to_linear(scene);
+ float2 radialChroma = cc * (chromaPx * r2) * texel;
+ float3 redSample = pardesSceneLinear(
+ source, origin, size, uv + radialChroma,
+ timeSeconds, rippleOn, glitchOn);
+ float3 blueSample = pardesSceneLinear(
+ source, origin, size, uv - radialChroma,
+ timeSeconds, rippleOn, glitchOn);
+ float3 col = float3(
+ redSample.r,
+ centerLinear.g,
+ blueSample.b
+ );
+
+ float2 bloom = texel * bloomRadiusPx;
+ float3 bloomSum = float3(0.0);
+ float2 bloomOffsets[4] = {
+ float2(bloom.x, 0.0), float2(-bloom.x, 0.0),
+ float2(0.0, bloom.y), float2(0.0, -bloom.y)
+ };
+ for (uint i = 0; i < 4; i++) {
+ // Deliberately raw, like GLSL brightPass: bloom belongs to the source
+ // image and does not recursively warp through the scene effects.
+ float4 sampleValue = pardesSceneSample(
+ source, origin, size, uv + bloomOffsets[i]);
+ float3 linearValue = coreimage::srgb_to_linear(sampleValue.rgb);
+ float peak = max(linearValue.r, max(linearValue.g, linearValue.b));
+ bloomSum += linearValue * smoothstep(bloomThreshold, bloomThreshold + bloomSoftness, peak);
+ }
+ col += bloomSum * (0.25 * bloomStrength);
+
+ float scanWave = 0.5 + 0.5 * cos(pixel.y * PARDES_TAU / scanPeriodPx);
+ col *= 1.0 - scanStrength * scanWave * scanWave;
+ float3 mask = 1.0 + maskStrength * cos(
+ pixel.x * PARDES_TAU / maskPeriodPx +
+ float3(0.0, PARDES_TAU / 3.0, 2.0 * PARDES_TAU / 3.0));
+ col *= mask;
+
+ float2 edgePx = min(uv, 1.0 - uv) * size;
+ float tube = smoothstep(0.0, edgeSoftnessPx, min(edgePx.x, edgePx.y));
+ float vignette = 1.0 - vignetteStrength * pow(clamp(r2 * 0.5, 0.0, 1.0), 1.6);
+ float hum = 1.0 + humStrength * sin((uv.y * 1.35 - timeSeconds * 0.11) * PARDES_TAU);
+ col *= tube * vignette * hum * exposure;
+
+ float luma = dot(col, float3(0.2126, 0.7152, 0.0722));
+ float2 noiseSeed = pixel + float2(frame * 17.0, frame * 59.0);
+ col += (pardesNoise(noiseSeed) - 0.5) * noiseStrength *
+ (0.15 + 0.85 * clamp(luma, 0.0, 1.0));
+ return coreimage::premultiply(float4(
+ coreimage::linear_to_srgb(clamp(col, 0.0, 1.0)), center.a));
+}
diff --git a/shaders/crt.frag.glsl b/shaders/crt.frag.glsl
index 1c9b15a8..e43d5b86 100644
--- a/shaders/crt.frag.glsl
+++ b/shaders/crt.frag.glsl
@@ -1,39 +1,162 @@
#version 450
-// The Crt builtin's post-process, one pass, fully static (no animation, no
-// glitches). Screen point cc shows the scene at uv' = cc*(1 + BARREL*r2),
-// and src/crt.zig maps mouse input through that same transform — keep them
-// equal or clicks drift near the edges.
+// The Crt builtin's post-process. Screen point cc shows the scene at
+// uv' = cc*(1 + BARREL*r2), and src/gui/crt.zig maps mouse input through that
+// same transform — keep them equal or clicks drift near the edges.
layout(set = 2, binding = 0) uniform sampler2D u_scene;
+layout(std140, set = 3, binding = 0) uniform CrtUniforms {
+ float time_seconds;
+ float frame;
+ vec2 viewport;
+ // x = CRT, y = ripple, z = glitch. One scene pass can combine them.
+ vec4 effects;
+} u_crt;
layout(location = 0) out vec4 o_col;
-const float BARREL = 0.012; // barrel curvature; MUST match src/crt.zig barrel
-const float VIGNETTE = 0.07; // corner darkening: *= 1 - VIGNETTE*r2^2
-const vec2 PHOSPHOR = vec2(2.0, 2.0); // uv quantize cell in scene px (~1/7 of a glyph cell)
-const float SCAN = 0.15; // scanline depth, one raised-cosine band per PHOSPHOR.y rows
-const float CHROMA = 0.001; // R/B radial sample offset, grows toward the edges
-const float BLOOM = 0.4; // bright-pass neighbor glow added back
-const float BLOOM_MIN = 0.5; // channel level where the bright pass starts
-const float LIFT = 1.08; // output brightness lift (offsets scan+vignette losses)
+const float TAU = 6.28318530718;
+const float BARREL = 0.016; // MUST match src/gui/crt.zig barrel
+const float EDGE_SOFTNESS_PX = 3.5;
+const float CHROMA_PX = 0.28; // radial R/B separation in physical pixels at r2=1
+const float BLOOM_RADIUS_PX = 1.75;
+const float BLOOM_THRESHOLD = 0.60; // linear-light channel peak
+const float BLOOM_SOFTNESS = 0.25;
+const float BLOOM_STRENGTH = 0.045;
+const float SCAN_PERIOD_PX = 3.0;
+const float SCAN_STRENGTH = 0.055;
+const float MASK_PERIOD_PX = 3.0;
+const float MASK_STRENGTH = 0.015;
+const float VIGNETTE_STRENGTH = 0.11; // 11% only at the extreme corners
+const float HUM_STRENGTH = 0.0025;
+const float NOISE_STRENGTH = 0.0020;
+const float EXPOSURE = 1.012;
+
+vec3 srgbToLinear(vec3 c) {
+ vec3 low = c / 12.92;
+ vec3 high = pow((c + 0.055) / 1.055, vec3(2.4));
+ return mix(high, low, lessThanEqual(c, vec3(0.04045)));
+}
+
+vec3 linearToSrgb(vec3 c) {
+ c = max(c, vec3(0.0));
+ vec3 low = c * 12.92;
+ vec3 high = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055;
+ return mix(high, low, lessThanEqual(c, vec3(0.0031308)));
+}
+
+vec3 sceneLinear(vec2 uv) {
+ return srgbToLinear(texture(u_scene, uv).rgb);
+}
+
+vec3 brightPass(vec2 uv) {
+ vec3 c = sceneLinear(uv);
+ float peak = max(c.r, max(c.g, c.b));
+ return c * smoothstep(BLOOM_THRESHOLD, BLOOM_THRESHOLD + BLOOM_SOFTNESS, peak);
+}
+
+float noiseAt(vec2 p) {
+ vec3 q = fract(vec3(p.xyx) * 0.1031);
+ q += dot(q, q.yzx + 33.33);
+ return fract((q.x + q.y) * q.z);
+}
+
+// The non-CRT scene effects deliberately distort the source coordinates,
+// rather than remapping the final colors. Themes retain their palette and the
+// effects remain composable with the linear-light CRT treatment below.
+vec2 sceneUv(vec2 uv) {
+ if (u_crt.effects.y > 0.5) {
+ vec2 centered = uv - 0.5;
+ float radius = length(centered);
+ float envelope = 1.0 - smoothstep(0.04, 0.72, radius);
+ float wave = sin(radius * 42.0 - u_crt.time_seconds * 5.2);
+ uv += centered / max(radius, 0.00001) * wave * envelope * 0.0032;
+ }
+ if (u_crt.effects.z > 0.5) {
+ float band = floor(uv.y * 96.0);
+ float seed = noiseAt(vec2(band, floor(u_crt.time_seconds * 13.0)));
+ float tear = smoothstep(0.955, 1.0, seed);
+ uv.x += (noiseAt(vec2(seed, band)) - 0.5) * tear * 0.020;
+ }
+ return uv;
+}
+
+vec3 sceneSrgb(vec2 uv) {
+ vec2 warped = sceneUv(uv);
+ vec3 center = texture(u_scene, warped).rgb;
+ if (u_crt.effects.z <= 0.5) return center;
+
+ float band = floor(warped.y * 96.0);
+ float seed = noiseAt(vec2(band, floor(u_crt.time_seconds * 13.0)));
+ float tear = smoothstep(0.955, 1.0, seed);
+ vec2 chroma = vec2((0.35 + 1.25 * tear) / u_crt.viewport.x, 0.0);
+ return vec3(
+ texture(u_scene, warped + chroma).r,
+ center.g,
+ texture(u_scene, warped - chroma).b
+ );
+}
+
+vec3 effectSceneLinear(vec2 uv) {
+ return srgbToLinear(sceneSrgb(uv));
+}
void main() {
- vec2 size = vec2(textureSize(u_scene, 0));
+ vec2 size = u_crt.viewport;
+ vec2 texel = 1.0 / size;
vec2 cc = gl_FragCoord.xy / size * 2.0 - 1.0;
float r2 = dot(cc, cc);
- vec2 uv = cc * (1.0 + BARREL * r2) * 0.5 + 0.5;
+ vec2 uv = cc * (1.0 + BARREL * r2 * u_crt.effects.x) * 0.5 + 0.5;
+
+ // Ripple and glitch are useful without the CRT. They only move samples;
+ // skip every tube-specific light/mask/exposure adjustment.
+ if (u_crt.effects.x <= 0.5) {
+ // The scene texture is already sRGB. Avoid a needless transfer-curve
+ // round trip so a coordinate-only effect preserves theme bytes.
+ o_col = vec4(clamp(sceneSrgb(uv), 0.0, 1.0), 1.0);
+ return;
+ }
+
vec3 col = vec3(0.0);
- if (all(greaterThanEqual(uv, vec2(0.0))) && all(lessThanEqual(uv, vec2(1.0)))) {
- vec2 q = (floor(uv * size / PHOSPHOR) + 0.5) * PHOSPHOR / size;
- vec2 ca = cc * (CHROMA * r2);
- col = vec3(texture(u_scene, q + ca).r, texture(u_scene, q).g, texture(u_scene, q - ca).b);
- vec2 px = PHOSPHOR / size;
- vec3 nb = texture(u_scene, q + vec2(px.x, 0.0)).rgb + texture(u_scene, q - vec2(px.x, 0.0)).rgb +
- texture(u_scene, q + vec2(0.0, px.y)).rgb + texture(u_scene, q - vec2(0.0, px.y)).rgb;
- col += max(nb * 0.25 - BLOOM_MIN, 0.0) * BLOOM;
+ float edge = min(min(uv.x, uv.y), min(1.0 - uv.x, 1.0 - uv.y));
+ if (edge > 0.0) {
+ vec3 center = effectSceneLinear(uv);
+ vec2 chroma = cc * (CHROMA_PX * r2) * texel;
+ // Each radial sample follows the same ripple/glitch path from its own
+ // pre-warp coordinate. This is also how the Metal kernel composes the
+ // effects; sampling a once-warped center here made combined effects
+ // diverge between the two GUI backends.
+ col = vec3(
+ effectSceneLinear(uv + chroma).r,
+ center.g,
+ effectSceneLinear(uv - chroma).b
+ );
+
+ vec2 bloom_px = texel * BLOOM_RADIUS_PX;
+ vec3 bloom = brightPass(uv + vec2(bloom_px.x, 0.0)) +
+ brightPass(uv - vec2(bloom_px.x, 0.0)) +
+ brightPass(uv + vec2(0.0, bloom_px.y)) +
+ brightPass(uv - vec2(0.0, bloom_px.y));
+ col += bloom * (0.25 * BLOOM_STRENGTH);
+
+ // The mask has unit mean and the scan loss stays small; neither needs
+ // the old global lift that remapped every theme toward white.
+ float scan_wave = 0.5 + 0.5 * cos(gl_FragCoord.y * TAU / SCAN_PERIOD_PX);
+ col *= 1.0 - SCAN_STRENGTH * scan_wave * scan_wave;
+ vec3 mask = 1.0 + MASK_STRENGTH * cos(
+ gl_FragCoord.x * TAU / MASK_PERIOD_PX + vec3(0.0, TAU / 3.0, 2.0 * TAU / 3.0)
+ );
+ col *= mask;
+
+ vec2 edge_px = min(uv, 1.0 - uv) * size;
+ float tube = smoothstep(0.0, EDGE_SOFTNESS_PX, min(edge_px.x, edge_px.y));
+ float vignette = 1.0 - VIGNETTE_STRENGTH * pow(clamp(r2 * 0.5, 0.0, 1.0), 1.6);
+ float hum = 1.0 + HUM_STRENGTH * sin((uv.y * 1.35 - u_crt.time_seconds * 0.11) * TAU);
+ col *= tube * vignette * hum * EXPOSURE;
+
+ float luma = dot(col, vec3(0.2126, 0.7152, 0.0722));
+ vec2 noise_seed = gl_FragCoord.xy + vec2(u_crt.frame * 17.0, u_crt.frame * 59.0);
+ col += (noiseAt(noise_seed) - 0.5) * NOISE_STRENGTH * (0.15 + 0.85 * clamp(luma, 0.0, 1.0));
}
- col *= 1.0 - VIGNETTE * r2 * r2;
- col *= 1.0 - SCAN * (0.5 + 0.5 * cos(gl_FragCoord.y * 6.2831853 / PHOSPHOR.y));
- o_col = vec4(col * LIFT, 1.0);
+ o_col = vec4(linearToSrgb(clamp(col, 0.0, 1.0)), 1.0);
}
diff --git a/shaders/image.frag.glsl b/shaders/image.frag.glsl
index 927d5f65..747ceef7 100644
--- a/shaders/image.frag.glsl
+++ b/shaders/image.frag.glsl
@@ -3,8 +3,97 @@
layout(set = 2, binding = 0) uniform sampler2D u_image;
layout(location = 0) in vec2 v_uv;
+layout(location = 1) in vec2 v_panel_uv;
+layout(location = 2) flat in uvec4 v_effect;
layout(location = 0) out vec4 o_col;
+float cellNoise(uint serial, uint col, uint row) {
+ uint x = serial ^ (col * 0x9e3779b9u) ^ (row * 0x85ebca6bu);
+ x ^= x >> 16;
+ x *= 0x7feb352du;
+ x ^= x >> 15;
+ x *= 0x846ca68bu;
+ x ^= x >> 16;
+ return float(x & 0xffffu) / 65535.0;
+}
+
+// The same seven procedural ASCII marks as ui.frag.glsl and the macOS Metal
+// panel kernel. Native attachments are content in the panel, so they should
+// not switch to a smaller two-symbol animation when the glyph grid over them
+// is using `. : + * # % @`.
+float asciiGlyph(vec2 uv, int glyph) {
+ vec2 p = uv * 2.0 - 1.0;
+ float thin = 0.12;
+ float dotShape = 1.0 - smoothstep(0.10, 0.20, length(p - vec2(0.0, 0.45)));
+ float barH = 1.0 - smoothstep(thin, thin + 0.08, abs(p.y));
+ float barV = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x));
+ if (glyph == 0) return dotShape;
+ if (glyph == 1) return max(dotShape, 1.0 - smoothstep(0.10, 0.20, length(p + vec2(0.0, 0.45))));
+ if (glyph == 2) return max(barH, barV);
+ if (glyph == 3) {
+ float d0 = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x - p.y));
+ float d1 = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x + p.y));
+ return max(max(d0, d1), max(barH, barV));
+ }
+ if (glyph == 4) {
+ float rails = max(
+ 1.0 - smoothstep(thin, thin + 0.08, abs(abs(p.x) - 0.34)),
+ 1.0 - smoothstep(thin, thin + 0.08, abs(abs(p.y) - 0.34))
+ );
+ return rails;
+ }
+ float ring = 1.0 - smoothstep(thin, thin + 0.08, abs(length(p * vec2(0.8, 1.0)) - 0.48));
+ return max(ring, glyph == 6 ? dotShape : 0.0);
+}
+
void main() {
- o_col = texture(u_image, v_uv);
+ vec4 source = texture(u_image, v_uv);
+ uint effect = v_effect.x & 0x7fffffffu;
+ bool oldLayer = (v_effect.x & 0x80000000u) != 0u;
+ float progress = clamp(uintBitsToFloat(v_effect.y), 0.0, 1.0);
+ uvec2 dimensions = max(
+ uvec2(v_effect.w & 0xffffu, v_effect.w >> 16),
+ uvec2(1u));
+ uvec2 cellCoord = min(
+ uvec2(floor(max(v_panel_uv, vec2(0.0)) * vec2(dimensions))),
+ dimensions - 1u);
+ float noise = cellNoise(v_effect.z, cellCoord.x, cellCoord.y);
+ if (effect == 3u) {
+ bool reveal = progress >= 1.0 || (progress > 0.0 && noise < progress);
+ if (oldLayer == reveal) discard;
+ o_col = source;
+ return;
+ }
+ if (effect == 4u) {
+ float diagonal = float((cellCoord.x + cellCoord.y * 2u) % 17u) / 17.0;
+ float threshold = noise * 0.72 + diagonal * 0.28;
+ bool reveal = progress >= 1.0 || (progress > 0.0 && progress >= threshold);
+ bool wave = !reveal && progress > 0.0 && progress + 0.22 >= threshold;
+ if (oldLayer) {
+ if (wave || reveal) discard;
+ o_col = source;
+ return;
+ }
+ if (reveal) {
+ o_col = source;
+ return;
+ }
+ if (!wave) discard;
+ vec2 cellUV = fract(v_panel_uv * vec2(dimensions));
+ int glyph = min(int(floor(noise * 7.0)), 6);
+ float mark = asciiGlyph(cellUV, glyph);
+ float luma = dot(source.rgb, vec3(0.2126, 0.7152, 0.0722));
+ vec3 paper = source.rgb * 0.10;
+ // Preserve source colour when it already separates from the dimmed
+ // paper. Solid black pixels have no such separation, so derive a
+ // readable mark from their own luminance instead of letting the ASCII
+ // phase disappear on dark images/PDF regions.
+ vec3 contrastInk = luma > 0.5 ? vec3(0.0) : vec3(1.0);
+ float sourceContrast = smoothstep(0.025, 0.14, length(source.rgb - paper));
+ vec3 ink = mix(contrastInk, source.rgb, sourceContrast);
+ vec3 dimInk = mix(paper, ink, 0.62);
+ o_col = vec4(mix(paper, dimInk, mark), source.a);
+ return;
+ }
+ o_col = source;
}
diff --git a/shaders/image.vert.glsl b/shaders/image.vert.glsl
index ace55a53..04196ce4 100644
--- a/shaders/image.vert.glsl
+++ b/shaders/image.vert.glsl
@@ -4,8 +4,13 @@
// the pane's cell rectangle and the aspect-preserving contain fit to NDC.
layout(location = 0) in vec4 a_rect; // x0, y0, x1, y1
layout(location = 1) in vec4 a_uv_rect; // u0, v0, u1, v1
+layout(location = 2) in vec4 a_panel_rect;
+layout(location = 3) in vec4 a_present_rect;
+layout(location = 4) in uvec4 a_effect;
layout(location = 0) out vec2 v_uv;
+layout(location = 1) out vec2 v_panel_uv;
+layout(location = 2) flat out uvec4 v_effect;
void main() {
vec2 pos;
@@ -29,5 +34,16 @@ void main() {
pos = vec2(a_rect.x, a_rect.w);
v_uv = vec2(a_uv_rect.x, a_uv_rect.w);
}
+ v_panel_uv = v_uv;
+ uint effect = a_effect.x & 0x7fffffffu;
+ if (effect != 0u) {
+ vec2 panel_size = a_panel_rect.zw - a_panel_rect.xy;
+ panel_size.x = abs(panel_size.x) < 0.000001 ? 0.000001 : panel_size.x;
+ panel_size.y = abs(panel_size.y) < 0.000001 ? -0.000001 : panel_size.y;
+ v_panel_uv = (pos - a_panel_rect.xy) / panel_size;
+ if (effect == 1u || effect == 2u || effect == 5u)
+ pos = mix(a_present_rect.xy, a_present_rect.zw, v_panel_uv);
+ }
+ v_effect = a_effect;
gl_Position = vec4(pos, 0.0, 1.0);
}
diff --git a/shaders/overlay.frag.es.glsl b/shaders/overlay.frag.es.glsl
deleted file mode 100644
index 37032c9a..00000000
--- a/shaders/overlay.frag.es.glsl
+++ /dev/null
@@ -1,11 +0,0 @@
-#version 300 es
-
-precision mediump float;
-
-in vec4 v_color;
-
-out vec4 o_col;
-
-void main() {
- o_col = v_color;
-}
diff --git a/shaders/prebuilt/crt.frag.glsl b/shaders/prebuilt/crt.frag.glsl
new file mode 100644
index 00000000..e43d5b86
--- /dev/null
+++ b/shaders/prebuilt/crt.frag.glsl
@@ -0,0 +1,162 @@
+#version 450
+
+// The Crt builtin's post-process. Screen point cc shows the scene at
+// uv' = cc*(1 + BARREL*r2), and src/gui/crt.zig maps mouse input through that
+// same transform — keep them equal or clicks drift near the edges.
+
+layout(set = 2, binding = 0) uniform sampler2D u_scene;
+layout(std140, set = 3, binding = 0) uniform CrtUniforms {
+ float time_seconds;
+ float frame;
+ vec2 viewport;
+ // x = CRT, y = ripple, z = glitch. One scene pass can combine them.
+ vec4 effects;
+} u_crt;
+
+layout(location = 0) out vec4 o_col;
+
+const float TAU = 6.28318530718;
+const float BARREL = 0.016; // MUST match src/gui/crt.zig barrel
+const float EDGE_SOFTNESS_PX = 3.5;
+const float CHROMA_PX = 0.28; // radial R/B separation in physical pixels at r2=1
+const float BLOOM_RADIUS_PX = 1.75;
+const float BLOOM_THRESHOLD = 0.60; // linear-light channel peak
+const float BLOOM_SOFTNESS = 0.25;
+const float BLOOM_STRENGTH = 0.045;
+const float SCAN_PERIOD_PX = 3.0;
+const float SCAN_STRENGTH = 0.055;
+const float MASK_PERIOD_PX = 3.0;
+const float MASK_STRENGTH = 0.015;
+const float VIGNETTE_STRENGTH = 0.11; // 11% only at the extreme corners
+const float HUM_STRENGTH = 0.0025;
+const float NOISE_STRENGTH = 0.0020;
+const float EXPOSURE = 1.012;
+
+vec3 srgbToLinear(vec3 c) {
+ vec3 low = c / 12.92;
+ vec3 high = pow((c + 0.055) / 1.055, vec3(2.4));
+ return mix(high, low, lessThanEqual(c, vec3(0.04045)));
+}
+
+vec3 linearToSrgb(vec3 c) {
+ c = max(c, vec3(0.0));
+ vec3 low = c * 12.92;
+ vec3 high = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055;
+ return mix(high, low, lessThanEqual(c, vec3(0.0031308)));
+}
+
+vec3 sceneLinear(vec2 uv) {
+ return srgbToLinear(texture(u_scene, uv).rgb);
+}
+
+vec3 brightPass(vec2 uv) {
+ vec3 c = sceneLinear(uv);
+ float peak = max(c.r, max(c.g, c.b));
+ return c * smoothstep(BLOOM_THRESHOLD, BLOOM_THRESHOLD + BLOOM_SOFTNESS, peak);
+}
+
+float noiseAt(vec2 p) {
+ vec3 q = fract(vec3(p.xyx) * 0.1031);
+ q += dot(q, q.yzx + 33.33);
+ return fract((q.x + q.y) * q.z);
+}
+
+// The non-CRT scene effects deliberately distort the source coordinates,
+// rather than remapping the final colors. Themes retain their palette and the
+// effects remain composable with the linear-light CRT treatment below.
+vec2 sceneUv(vec2 uv) {
+ if (u_crt.effects.y > 0.5) {
+ vec2 centered = uv - 0.5;
+ float radius = length(centered);
+ float envelope = 1.0 - smoothstep(0.04, 0.72, radius);
+ float wave = sin(radius * 42.0 - u_crt.time_seconds * 5.2);
+ uv += centered / max(radius, 0.00001) * wave * envelope * 0.0032;
+ }
+ if (u_crt.effects.z > 0.5) {
+ float band = floor(uv.y * 96.0);
+ float seed = noiseAt(vec2(band, floor(u_crt.time_seconds * 13.0)));
+ float tear = smoothstep(0.955, 1.0, seed);
+ uv.x += (noiseAt(vec2(seed, band)) - 0.5) * tear * 0.020;
+ }
+ return uv;
+}
+
+vec3 sceneSrgb(vec2 uv) {
+ vec2 warped = sceneUv(uv);
+ vec3 center = texture(u_scene, warped).rgb;
+ if (u_crt.effects.z <= 0.5) return center;
+
+ float band = floor(warped.y * 96.0);
+ float seed = noiseAt(vec2(band, floor(u_crt.time_seconds * 13.0)));
+ float tear = smoothstep(0.955, 1.0, seed);
+ vec2 chroma = vec2((0.35 + 1.25 * tear) / u_crt.viewport.x, 0.0);
+ return vec3(
+ texture(u_scene, warped + chroma).r,
+ center.g,
+ texture(u_scene, warped - chroma).b
+ );
+}
+
+vec3 effectSceneLinear(vec2 uv) {
+ return srgbToLinear(sceneSrgb(uv));
+}
+
+void main() {
+ vec2 size = u_crt.viewport;
+ vec2 texel = 1.0 / size;
+ vec2 cc = gl_FragCoord.xy / size * 2.0 - 1.0;
+ float r2 = dot(cc, cc);
+ vec2 uv = cc * (1.0 + BARREL * r2 * u_crt.effects.x) * 0.5 + 0.5;
+
+ // Ripple and glitch are useful without the CRT. They only move samples;
+ // skip every tube-specific light/mask/exposure adjustment.
+ if (u_crt.effects.x <= 0.5) {
+ // The scene texture is already sRGB. Avoid a needless transfer-curve
+ // round trip so a coordinate-only effect preserves theme bytes.
+ o_col = vec4(clamp(sceneSrgb(uv), 0.0, 1.0), 1.0);
+ return;
+ }
+
+ vec3 col = vec3(0.0);
+ float edge = min(min(uv.x, uv.y), min(1.0 - uv.x, 1.0 - uv.y));
+ if (edge > 0.0) {
+ vec3 center = effectSceneLinear(uv);
+ vec2 chroma = cc * (CHROMA_PX * r2) * texel;
+ // Each radial sample follows the same ripple/glitch path from its own
+ // pre-warp coordinate. This is also how the Metal kernel composes the
+ // effects; sampling a once-warped center here made combined effects
+ // diverge between the two GUI backends.
+ col = vec3(
+ effectSceneLinear(uv + chroma).r,
+ center.g,
+ effectSceneLinear(uv - chroma).b
+ );
+
+ vec2 bloom_px = texel * BLOOM_RADIUS_PX;
+ vec3 bloom = brightPass(uv + vec2(bloom_px.x, 0.0)) +
+ brightPass(uv - vec2(bloom_px.x, 0.0)) +
+ brightPass(uv + vec2(0.0, bloom_px.y)) +
+ brightPass(uv - vec2(0.0, bloom_px.y));
+ col += bloom * (0.25 * BLOOM_STRENGTH);
+
+ // The mask has unit mean and the scan loss stays small; neither needs
+ // the old global lift that remapped every theme toward white.
+ float scan_wave = 0.5 + 0.5 * cos(gl_FragCoord.y * TAU / SCAN_PERIOD_PX);
+ col *= 1.0 - SCAN_STRENGTH * scan_wave * scan_wave;
+ vec3 mask = 1.0 + MASK_STRENGTH * cos(
+ gl_FragCoord.x * TAU / MASK_PERIOD_PX + vec3(0.0, TAU / 3.0, 2.0 * TAU / 3.0)
+ );
+ col *= mask;
+
+ vec2 edge_px = min(uv, 1.0 - uv) * size;
+ float tube = smoothstep(0.0, EDGE_SOFTNESS_PX, min(edge_px.x, edge_px.y));
+ float vignette = 1.0 - VIGNETTE_STRENGTH * pow(clamp(r2 * 0.5, 0.0, 1.0), 1.6);
+ float hum = 1.0 + HUM_STRENGTH * sin((uv.y * 1.35 - u_crt.time_seconds * 0.11) * TAU);
+ col *= tube * vignette * hum * EXPOSURE;
+
+ float luma = dot(col, vec3(0.2126, 0.7152, 0.0722));
+ vec2 noise_seed = gl_FragCoord.xy + vec2(u_crt.frame * 17.0, u_crt.frame * 59.0);
+ col += (noiseAt(noise_seed) - 0.5) * NOISE_STRENGTH * (0.15 + 0.85 * clamp(luma, 0.0, 1.0));
+ }
+ o_col = vec4(linearToSrgb(clamp(col, 0.0, 1.0)), 1.0);
+}
diff --git a/shaders/prebuilt/crt.frag.spv b/shaders/prebuilt/crt.frag.spv
index 326aa534..057f0e06 100644
--- a/shaders/prebuilt/crt.frag.spv
+++ b/shaders/prebuilt/crt.frag.spv
Binary files differ
diff --git a/shaders/prebuilt/crt.vert.glsl b/shaders/prebuilt/crt.vert.glsl
new file mode 100644
index 00000000..4e15fcdf
--- /dev/null
+++ b/shaders/prebuilt/crt.vert.glsl
@@ -0,0 +1,9 @@
+#version 450
+
+// CRT post-process pass: one fullscreen triangle from gl_VertexIndex, no
+// vertex buffer. The fragment shader derives uv from gl_FragCoord.
+
+void main() {
+ vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2));
+ gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
+}
diff --git a/shaders/prebuilt/image.frag.glsl b/shaders/prebuilt/image.frag.glsl
new file mode 100644
index 00000000..747ceef7
--- /dev/null
+++ b/shaders/prebuilt/image.frag.glsl
@@ -0,0 +1,99 @@
+#version 450
+
+layout(set = 2, binding = 0) uniform sampler2D u_image;
+
+layout(location = 0) in vec2 v_uv;
+layout(location = 1) in vec2 v_panel_uv;
+layout(location = 2) flat in uvec4 v_effect;
+layout(location = 0) out vec4 o_col;
+
+float cellNoise(uint serial, uint col, uint row) {
+ uint x = serial ^ (col * 0x9e3779b9u) ^ (row * 0x85ebca6bu);
+ x ^= x >> 16;
+ x *= 0x7feb352du;
+ x ^= x >> 15;
+ x *= 0x846ca68bu;
+ x ^= x >> 16;
+ return float(x & 0xffffu) / 65535.0;
+}
+
+// The same seven procedural ASCII marks as ui.frag.glsl and the macOS Metal
+// panel kernel. Native attachments are content in the panel, so they should
+// not switch to a smaller two-symbol animation when the glyph grid over them
+// is using `. : + * # % @`.
+float asciiGlyph(vec2 uv, int glyph) {
+ vec2 p = uv * 2.0 - 1.0;
+ float thin = 0.12;
+ float dotShape = 1.0 - smoothstep(0.10, 0.20, length(p - vec2(0.0, 0.45)));
+ float barH = 1.0 - smoothstep(thin, thin + 0.08, abs(p.y));
+ float barV = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x));
+ if (glyph == 0) return dotShape;
+ if (glyph == 1) return max(dotShape, 1.0 - smoothstep(0.10, 0.20, length(p + vec2(0.0, 0.45))));
+ if (glyph == 2) return max(barH, barV);
+ if (glyph == 3) {
+ float d0 = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x - p.y));
+ float d1 = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x + p.y));
+ return max(max(d0, d1), max(barH, barV));
+ }
+ if (glyph == 4) {
+ float rails = max(
+ 1.0 - smoothstep(thin, thin + 0.08, abs(abs(p.x) - 0.34)),
+ 1.0 - smoothstep(thin, thin + 0.08, abs(abs(p.y) - 0.34))
+ );
+ return rails;
+ }
+ float ring = 1.0 - smoothstep(thin, thin + 0.08, abs(length(p * vec2(0.8, 1.0)) - 0.48));
+ return max(ring, glyph == 6 ? dotShape : 0.0);
+}
+
+void main() {
+ vec4 source = texture(u_image, v_uv);
+ uint effect = v_effect.x & 0x7fffffffu;
+ bool oldLayer = (v_effect.x & 0x80000000u) != 0u;
+ float progress = clamp(uintBitsToFloat(v_effect.y), 0.0, 1.0);
+ uvec2 dimensions = max(
+ uvec2(v_effect.w & 0xffffu, v_effect.w >> 16),
+ uvec2(1u));
+ uvec2 cellCoord = min(
+ uvec2(floor(max(v_panel_uv, vec2(0.0)) * vec2(dimensions))),
+ dimensions - 1u);
+ float noise = cellNoise(v_effect.z, cellCoord.x, cellCoord.y);
+ if (effect == 3u) {
+ bool reveal = progress >= 1.0 || (progress > 0.0 && noise < progress);
+ if (oldLayer == reveal) discard;
+ o_col = source;
+ return;
+ }
+ if (effect == 4u) {
+ float diagonal = float((cellCoord.x + cellCoord.y * 2u) % 17u) / 17.0;
+ float threshold = noise * 0.72 + diagonal * 0.28;
+ bool reveal = progress >= 1.0 || (progress > 0.0 && progress >= threshold);
+ bool wave = !reveal && progress > 0.0 && progress + 0.22 >= threshold;
+ if (oldLayer) {
+ if (wave || reveal) discard;
+ o_col = source;
+ return;
+ }
+ if (reveal) {
+ o_col = source;
+ return;
+ }
+ if (!wave) discard;
+ vec2 cellUV = fract(v_panel_uv * vec2(dimensions));
+ int glyph = min(int(floor(noise * 7.0)), 6);
+ float mark = asciiGlyph(cellUV, glyph);
+ float luma = dot(source.rgb, vec3(0.2126, 0.7152, 0.0722));
+ vec3 paper = source.rgb * 0.10;
+ // Preserve source colour when it already separates from the dimmed
+ // paper. Solid black pixels have no such separation, so derive a
+ // readable mark from their own luminance instead of letting the ASCII
+ // phase disappear on dark images/PDF regions.
+ vec3 contrastInk = luma > 0.5 ? vec3(0.0) : vec3(1.0);
+ float sourceContrast = smoothstep(0.025, 0.14, length(source.rgb - paper));
+ vec3 ink = mix(contrastInk, source.rgb, sourceContrast);
+ vec3 dimInk = mix(paper, ink, 0.62);
+ o_col = vec4(mix(paper, dimInk, mark), source.a);
+ return;
+ }
+ o_col = source;
+}
diff --git a/shaders/prebuilt/image.frag.spv b/shaders/prebuilt/image.frag.spv
index 84a174ad..c3d87e58 100644
--- a/shaders/prebuilt/image.frag.spv
+++ b/shaders/prebuilt/image.frag.spv
Binary files differ
diff --git a/shaders/prebuilt/image.vert.glsl b/shaders/prebuilt/image.vert.glsl
new file mode 100644
index 00000000..04196ce4
--- /dev/null
+++ b/shaders/prebuilt/image.vert.glsl
@@ -0,0 +1,49 @@
+#version 450
+
+// One instance per Surface image attachment. The CPU has already converted
+// the pane's cell rectangle and the aspect-preserving contain fit to NDC.
+layout(location = 0) in vec4 a_rect; // x0, y0, x1, y1
+layout(location = 1) in vec4 a_uv_rect; // u0, v0, u1, v1
+layout(location = 2) in vec4 a_panel_rect;
+layout(location = 3) in vec4 a_present_rect;
+layout(location = 4) in uvec4 a_effect;
+
+layout(location = 0) out vec2 v_uv;
+layout(location = 1) out vec2 v_panel_uv;
+layout(location = 2) flat out uvec4 v_effect;
+
+void main() {
+ vec2 pos;
+ int corner = gl_VertexIndex % 6;
+ if (corner == 0) {
+ pos = vec2(a_rect.x, a_rect.y);
+ v_uv = vec2(a_uv_rect.x, a_uv_rect.y);
+ } else if (corner == 1) {
+ pos = vec2(a_rect.z, a_rect.y);
+ v_uv = vec2(a_uv_rect.z, a_uv_rect.y);
+ } else if (corner == 2) {
+ pos = vec2(a_rect.z, a_rect.w);
+ v_uv = vec2(a_uv_rect.z, a_uv_rect.w);
+ } else if (corner == 3) {
+ pos = vec2(a_rect.x, a_rect.y);
+ v_uv = vec2(a_uv_rect.x, a_uv_rect.y);
+ } else if (corner == 4) {
+ pos = vec2(a_rect.z, a_rect.w);
+ v_uv = vec2(a_uv_rect.z, a_uv_rect.w);
+ } else {
+ pos = vec2(a_rect.x, a_rect.w);
+ v_uv = vec2(a_uv_rect.x, a_uv_rect.w);
+ }
+ v_panel_uv = v_uv;
+ uint effect = a_effect.x & 0x7fffffffu;
+ if (effect != 0u) {
+ vec2 panel_size = a_panel_rect.zw - a_panel_rect.xy;
+ panel_size.x = abs(panel_size.x) < 0.000001 ? 0.000001 : panel_size.x;
+ panel_size.y = abs(panel_size.y) < 0.000001 ? -0.000001 : panel_size.y;
+ v_panel_uv = (pos - a_panel_rect.xy) / panel_size;
+ if (effect == 1u || effect == 2u || effect == 5u)
+ pos = mix(a_present_rect.xy, a_present_rect.zw, v_panel_uv);
+ }
+ v_effect = a_effect;
+ gl_Position = vec4(pos, 0.0, 1.0);
+}
diff --git a/shaders/prebuilt/image.vert.spv b/shaders/prebuilt/image.vert.spv
index 1105bae2..35165d69 100644
--- a/shaders/prebuilt/image.vert.spv
+++ b/shaders/prebuilt/image.vert.spv
Binary files differ
diff --git a/shaders/prebuilt/overlay.frag.glsl b/shaders/prebuilt/overlay.frag.glsl
new file mode 100644
index 00000000..86c0188b
--- /dev/null
+++ b/shaders/prebuilt/overlay.frag.glsl
@@ -0,0 +1,9 @@
+#version 450
+
+layout(location = 0) in vec4 v_color;
+
+layout(location = 0) out vec4 o_col;
+
+void main() {
+ o_col = v_color;
+}
diff --git a/shaders/overlay.vert.es.glsl b/shaders/prebuilt/overlay.vert.glsl
index edea124d..5f6be593 100644
--- a/shaders/overlay.vert.es.glsl
+++ b/shaders/prebuilt/overlay.vert.glsl
@@ -1,11 +1,9 @@
-#version 300 es
-
-precision mediump float;
+#version 450
layout(location = 0) in vec2 a_pos;
layout(location = 1) in vec4 a_color;
-out vec4 v_color;
+layout(location = 0) out vec4 v_color;
void main() {
v_color = a_color;
diff --git a/shaders/prebuilt/ui.frag.glsl b/shaders/prebuilt/ui.frag.glsl
new file mode 100644
index 00000000..2113f6ae
--- /dev/null
+++ b/shaders/prebuilt/ui.frag.glsl
@@ -0,0 +1,110 @@
+#version 450
+
+// Sample the glyph atlas (R = coverage 0..1) and mix the cell's bg up to its fg
+// by that coverage. Fully opaque: the swapchain is SDR, no blending needed.
+
+layout(set = 2, binding = 0) uniform sampler2D u_atlas;
+
+layout(location = 0) in vec2 v_uv;
+layout(location = 1) in vec3 v_fg;
+layout(location = 2) in vec3 v_bg;
+layout(location = 3) in vec2 v_cell_uv;
+layout(location = 4) flat in uvec4 v_effect;
+
+layout(location = 0) out vec4 o_col;
+
+float cellNoise(uint serial, uint col, uint row) {
+ uint x = serial ^ (col * 0x9e3779b9u) ^ (row * 0x85ebca6bu);
+ x ^= x >> 16;
+ x *= 0x7feb352du;
+ x ^= x >> 15;
+ x *= 0x846ca68bu;
+ x ^= x >> 16;
+ return float(x & 0xffffu) / 65535.0;
+}
+
+float asciiGlyph(vec2 uv, int glyph) {
+ vec2 p = uv * 2.0 - 1.0;
+ float thin = 0.12;
+ float dotShape = 1.0 - smoothstep(0.10, 0.20, length(p - vec2(0.0, 0.45)));
+ float barH = 1.0 - smoothstep(thin, thin + 0.08, abs(p.y));
+ float barV = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x));
+ if (glyph == 0) return dotShape;
+ if (glyph == 1) return max(dotShape, 1.0 - smoothstep(0.10, 0.20, length(p + vec2(0.0, 0.45))));
+ if (glyph == 2) return max(barH, barV);
+ if (glyph == 3) {
+ float d0 = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x - p.y));
+ float d1 = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x + p.y));
+ return max(max(d0, d1), max(barH, barV));
+ }
+ if (glyph == 4) {
+ float rails = max(
+ 1.0 - smoothstep(thin, thin + 0.08, abs(abs(p.x) - 0.34)),
+ 1.0 - smoothstep(thin, thin + 0.08, abs(abs(p.y) - 0.34))
+ );
+ return rails;
+ }
+ float ring = 1.0 - smoothstep(thin, thin + 0.08, abs(length(p * vec2(0.8, 1.0)) - 0.48));
+ return max(ring, glyph == 6 ? dotShape : 0.0);
+}
+
+void main() {
+ float a = texture(u_atlas, v_uv).r;
+ bool oldLayer = (v_effect.x & 0x80000000u) != 0u;
+ uint effect = v_effect.x & 0x7fffffffu;
+ float progress = clamp(uintBitsToFloat(v_effect.y), 0.0, 1.0);
+ uint cellCol = v_effect.w & 0xffffu;
+ uint cellRow = v_effect.w >> 16;
+ float noise = cellNoise(v_effect.z, cellCol, cellRow);
+
+ vec3 cell = mix(v_bg, v_fg, a);
+
+ // 3 = data dissolve. Changed cells retain the frozen old cell until their
+ // stable threshold, then atomically become the new cell. Exact endpoints
+ // mirror panel_animation.dissolveRevealed, including the zero hash.
+ if (effect == 3u) {
+ bool reveal = progress >= 1.0 || (progress > 0.0 && noise < progress);
+ if (oldLayer ? reveal : !reveal) discard;
+ o_col = vec4(cell, 1.0);
+ return;
+ }
+
+ // 4 = ASCII materialization. A diagonal/noise threshold matches the TTY
+ // compositor's idea: cells briefly become punctuation, then their real
+ // atlas glyph arrives. The procedural marks keep the GUI implementation
+ // entirely shader-side.
+ if (effect == 4u) {
+ if (progress <= 0.0) {
+ if (!oldLayer) discard;
+ o_col = vec4(cell, 1.0);
+ return;
+ }
+ if (progress >= 1.0) {
+ if (oldLayer) discard;
+ o_col = vec4(cell, 1.0);
+ return;
+ }
+ float diagonal = float((cellCol + cellRow * 2u) % 17u) / 17.0;
+ float threshold = noise * 0.72 + diagonal * 0.28;
+ if (progress >= threshold) {
+ if (oldLayer) discard;
+ o_col = vec4(cell, 1.0);
+ return;
+ }
+ if (progress + 0.22 >= threshold) {
+ if (oldLayer) discard;
+ int glyph = int(floor(noise * 7.0));
+ float ink = asciiGlyph(v_cell_uv, min(glyph, 6));
+ vec3 dimInk = mix(v_bg, v_fg, 0.62);
+ o_col = vec4(mix(v_bg, dimInk, ink), 1.0);
+ return;
+ }
+ if (!oldLayer) discard;
+ o_col = vec4(cell, 1.0);
+ return;
+ }
+
+ // FreeType's hinted grayscale bitmap is already the intended coverage.
+ // Preserve it exactly; weight-changing gamma hacks blur small stems.
+ o_col = vec4(cell, 1.0);
+}
diff --git a/shaders/prebuilt/ui.frag.spv b/shaders/prebuilt/ui.frag.spv
index 8d57242a..b97495b3 100644
--- a/shaders/prebuilt/ui.frag.spv
+++ b/shaders/prebuilt/ui.frag.spv
Binary files differ
diff --git a/shaders/prebuilt/ui.vert.glsl b/shaders/prebuilt/ui.vert.glsl
new file mode 100644
index 00000000..08627dc0
--- /dev/null
+++ b/shaders/prebuilt/ui.vert.glsl
@@ -0,0 +1,72 @@
+#version 450
+
+// Terminal cell renderer. The CPU uploads one instance per visible cell; this
+// shader expands each instance into a two-triangle quad with gl_VertexIndex.
+// The fragment shader mixes fg/bg by the atlas's alpha (a glyph mask in the R
+// channel), so background fills and glyph foregrounds draw in a single pass.
+
+layout(location = 0) in vec4 a_rect; // x0, y0, x1, y1
+layout(location = 1) in vec4 a_uv_rect; // u0, v0, u1, v1
+layout(location = 2) in vec3 a_fg;
+layout(location = 3) in vec3 a_bg;
+// Final and presented panel rectangles in NDC. The CPU still uploads logical
+// final cells; this shader is the only place transition geometry is applied.
+layout(location = 4) in vec4 a_panel_rect;
+layout(location = 5) in vec4 a_present_rect;
+// effect id, eased-progress float bits, pane serial, local row/column packed
+// into one uint. The high effect bit marks the frozen-old cell layer; integer
+// transport preserves the exact cross-backend hash.
+layout(location = 6) in uvec4 a_effect;
+
+layout(location = 0) out vec2 v_uv;
+layout(location = 1) out vec3 v_fg;
+layout(location = 2) out vec3 v_bg;
+layout(location = 3) out vec2 v_cell_uv;
+layout(location = 4) flat out uvec4 v_effect;
+
+void main() {
+ vec2 pos;
+ vec2 uv;
+ int corner = gl_VertexIndex % 6;
+ if (corner == 0) {
+ pos = vec2(a_rect.x, a_rect.y);
+ uv = vec2(a_uv_rect.x, a_uv_rect.y);
+ v_cell_uv = vec2(0.0, 0.0);
+ } else if (corner == 1) {
+ pos = vec2(a_rect.z, a_rect.y);
+ uv = vec2(a_uv_rect.z, a_uv_rect.y);
+ v_cell_uv = vec2(1.0, 0.0);
+ } else if (corner == 2) {
+ pos = vec2(a_rect.z, a_rect.w);
+ uv = vec2(a_uv_rect.z, a_uv_rect.w);
+ v_cell_uv = vec2(1.0, 1.0);
+ } else if (corner == 3) {
+ pos = vec2(a_rect.x, a_rect.y);
+ uv = vec2(a_uv_rect.x, a_uv_rect.y);
+ v_cell_uv = vec2(0.0, 0.0);
+ } else if (corner == 4) {
+ pos = vec2(a_rect.z, a_rect.w);
+ uv = vec2(a_uv_rect.z, a_uv_rect.w);
+ v_cell_uv = vec2(1.0, 1.0);
+ } else {
+ pos = vec2(a_rect.x, a_rect.w);
+ uv = vec2(a_uv_rect.x, a_uv_rect.w);
+ v_cell_uv = vec2(0.0, 1.0);
+ }
+
+ uint effect = a_effect.x & 0x7fffffffu;
+ if (effect == 1u || effect == 2u || effect == 5u) {
+ // NDC y points up, so a top-left/bottom-right box has a NEGATIVE y
+ // delta. Preserve that sign or every local y is mirrored/outside.
+ vec2 panel_size = a_panel_rect.zw - a_panel_rect.xy;
+ panel_size.x = abs(panel_size.x) < 0.000001 ? 0.000001 : panel_size.x;
+ panel_size.y = abs(panel_size.y) < 0.000001 ? -0.000001 : panel_size.y;
+ vec2 within = (pos - a_panel_rect.xy) / panel_size;
+ pos = mix(a_present_rect.xy, a_present_rect.zw, within);
+ }
+ v_uv = uv;
+ v_fg = a_fg;
+ v_bg = a_bg;
+ v_effect = a_effect;
+ gl_Position = vec4(pos, 0.0, 1.0);
+}
diff --git a/shaders/prebuilt/ui.vert.spv b/shaders/prebuilt/ui.vert.spv
index 186a91fc..f0d5f653 100644
--- a/shaders/prebuilt/ui.vert.spv
+++ b/shaders/prebuilt/ui.vert.spv
Binary files differ
diff --git a/shaders/ui.frag.es.glsl b/shaders/ui.frag.es.glsl
deleted file mode 100644
index 736e48f5..00000000
--- a/shaders/ui.frag.es.glsl
+++ /dev/null
@@ -1,16 +0,0 @@
-#version 300 es
-
-precision mediump float;
-
-uniform sampler2D u_atlas;
-
-in vec2 v_uv;
-in vec3 v_fg;
-in vec3 v_bg;
-
-out vec4 o_col;
-
-void main() {
- float a = texture(u_atlas, v_uv).r;
- o_col = vec4(mix(v_bg, v_fg, a), 1.0);
-}
diff --git a/shaders/ui.frag.glsl b/shaders/ui.frag.glsl
index d00a0601..2113f6ae 100644
--- a/shaders/ui.frag.glsl
+++ b/shaders/ui.frag.glsl
@@ -8,13 +8,103 @@ layout(set = 2, binding = 0) uniform sampler2D u_atlas;
layout(location = 0) in vec2 v_uv;
layout(location = 1) in vec3 v_fg;
layout(location = 2) in vec3 v_bg;
+layout(location = 3) in vec2 v_cell_uv;
+layout(location = 4) flat in uvec4 v_effect;
layout(location = 0) out vec4 o_col;
+float cellNoise(uint serial, uint col, uint row) {
+ uint x = serial ^ (col * 0x9e3779b9u) ^ (row * 0x85ebca6bu);
+ x ^= x >> 16;
+ x *= 0x7feb352du;
+ x ^= x >> 15;
+ x *= 0x846ca68bu;
+ x ^= x >> 16;
+ return float(x & 0xffffu) / 65535.0;
+}
+
+float asciiGlyph(vec2 uv, int glyph) {
+ vec2 p = uv * 2.0 - 1.0;
+ float thin = 0.12;
+ float dotShape = 1.0 - smoothstep(0.10, 0.20, length(p - vec2(0.0, 0.45)));
+ float barH = 1.0 - smoothstep(thin, thin + 0.08, abs(p.y));
+ float barV = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x));
+ if (glyph == 0) return dotShape;
+ if (glyph == 1) return max(dotShape, 1.0 - smoothstep(0.10, 0.20, length(p + vec2(0.0, 0.45))));
+ if (glyph == 2) return max(barH, barV);
+ if (glyph == 3) {
+ float d0 = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x - p.y));
+ float d1 = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x + p.y));
+ return max(max(d0, d1), max(barH, barV));
+ }
+ if (glyph == 4) {
+ float rails = max(
+ 1.0 - smoothstep(thin, thin + 0.08, abs(abs(p.x) - 0.34)),
+ 1.0 - smoothstep(thin, thin + 0.08, abs(abs(p.y) - 0.34))
+ );
+ return rails;
+ }
+ float ring = 1.0 - smoothstep(thin, thin + 0.08, abs(length(p * vec2(0.8, 1.0)) - 0.48));
+ return max(ring, glyph == 6 ? dotShape : 0.0);
+}
+
void main() {
float a = texture(u_atlas, v_uv).r;
+ bool oldLayer = (v_effect.x & 0x80000000u) != 0u;
+ uint effect = v_effect.x & 0x7fffffffu;
+ float progress = clamp(uintBitsToFloat(v_effect.y), 0.0, 1.0);
+ uint cellCol = v_effect.w & 0xffffu;
+ uint cellRow = v_effect.w >> 16;
+ float noise = cellNoise(v_effect.z, cellCol, cellRow);
+
+ vec3 cell = mix(v_bg, v_fg, a);
+
+ // 3 = data dissolve. Changed cells retain the frozen old cell until their
+ // stable threshold, then atomically become the new cell. Exact endpoints
+ // mirror panel_animation.dissolveRevealed, including the zero hash.
+ if (effect == 3u) {
+ bool reveal = progress >= 1.0 || (progress > 0.0 && noise < progress);
+ if (oldLayer ? reveal : !reveal) discard;
+ o_col = vec4(cell, 1.0);
+ return;
+ }
+
+ // 4 = ASCII materialization. A diagonal/noise threshold matches the TTY
+ // compositor's idea: cells briefly become punctuation, then their real
+ // atlas glyph arrives. The procedural marks keep the GUI implementation
+ // entirely shader-side.
+ if (effect == 4u) {
+ if (progress <= 0.0) {
+ if (!oldLayer) discard;
+ o_col = vec4(cell, 1.0);
+ return;
+ }
+ if (progress >= 1.0) {
+ if (oldLayer) discard;
+ o_col = vec4(cell, 1.0);
+ return;
+ }
+ float diagonal = float((cellCol + cellRow * 2u) % 17u) / 17.0;
+ float threshold = noise * 0.72 + diagonal * 0.28;
+ if (progress >= threshold) {
+ if (oldLayer) discard;
+ o_col = vec4(cell, 1.0);
+ return;
+ }
+ if (progress + 0.22 >= threshold) {
+ if (oldLayer) discard;
+ int glyph = int(floor(noise * 7.0));
+ float ink = asciiGlyph(v_cell_uv, min(glyph, 6));
+ vec3 dimInk = mix(v_bg, v_fg, 0.62);
+ o_col = vec4(mix(v_bg, dimInk, ink), 1.0);
+ return;
+ }
+ if (!oldLayer) discard;
+ o_col = vec4(cell, 1.0);
+ return;
+ }
+
// FreeType's hinted grayscale bitmap is already the intended coverage.
// Preserve it exactly; weight-changing gamma hacks blur small stems.
- vec3 col = mix(v_bg, v_fg, a);
- o_col = vec4(col, 1.0);
+ o_col = vec4(cell, 1.0);
}
diff --git a/shaders/ui.vert.es.glsl b/shaders/ui.vert.es.glsl
deleted file mode 100644
index 271524be..00000000
--- a/shaders/ui.vert.es.glsl
+++ /dev/null
@@ -1,41 +0,0 @@
-#version 300 es
-
-precision mediump float;
-
-layout(location = 0) in vec4 a_rect;
-layout(location = 1) in vec4 a_uv_rect;
-layout(location = 2) in vec3 a_fg;
-layout(location = 3) in vec3 a_bg;
-
-out vec2 v_uv;
-out vec3 v_fg;
-out vec3 v_bg;
-
-void main() {
- vec2 pos;
- vec2 uv;
- int corner = gl_VertexID % 6;
- if (corner == 0) {
- pos = vec2(a_rect.x, a_rect.y);
- uv = vec2(a_uv_rect.x, a_uv_rect.y);
- } else if (corner == 1) {
- pos = vec2(a_rect.z, a_rect.y);
- uv = vec2(a_uv_rect.z, a_uv_rect.y);
- } else if (corner == 2) {
- pos = vec2(a_rect.z, a_rect.w);
- uv = vec2(a_uv_rect.z, a_uv_rect.w);
- } else if (corner == 3) {
- pos = vec2(a_rect.x, a_rect.y);
- uv = vec2(a_uv_rect.x, a_uv_rect.y);
- } else if (corner == 4) {
- pos = vec2(a_rect.z, a_rect.w);
- uv = vec2(a_uv_rect.z, a_uv_rect.w);
- } else {
- pos = vec2(a_rect.x, a_rect.w);
- uv = vec2(a_uv_rect.x, a_uv_rect.w);
- }
- v_uv = uv;
- v_fg = a_fg;
- v_bg = a_bg;
- gl_Position = vec4(pos, 0.0, 1.0);
-}
diff --git a/shaders/ui.vert.glsl b/shaders/ui.vert.glsl
index c7567a8f..08627dc0 100644
--- a/shaders/ui.vert.glsl
+++ b/shaders/ui.vert.glsl
@@ -9,10 +9,20 @@ layout(location = 0) in vec4 a_rect; // x0, y0, x1, y1
layout(location = 1) in vec4 a_uv_rect; // u0, v0, u1, v1
layout(location = 2) in vec3 a_fg;
layout(location = 3) in vec3 a_bg;
+// Final and presented panel rectangles in NDC. The CPU still uploads logical
+// final cells; this shader is the only place transition geometry is applied.
+layout(location = 4) in vec4 a_panel_rect;
+layout(location = 5) in vec4 a_present_rect;
+// effect id, eased-progress float bits, pane serial, local row/column packed
+// into one uint. The high effect bit marks the frozen-old cell layer; integer
+// transport preserves the exact cross-backend hash.
+layout(location = 6) in uvec4 a_effect;
layout(location = 0) out vec2 v_uv;
layout(location = 1) out vec3 v_fg;
layout(location = 2) out vec3 v_bg;
+layout(location = 3) out vec2 v_cell_uv;
+layout(location = 4) flat out uvec4 v_effect;
void main() {
vec2 pos;
@@ -21,24 +31,42 @@ void main() {
if (corner == 0) {
pos = vec2(a_rect.x, a_rect.y);
uv = vec2(a_uv_rect.x, a_uv_rect.y);
+ v_cell_uv = vec2(0.0, 0.0);
} else if (corner == 1) {
pos = vec2(a_rect.z, a_rect.y);
uv = vec2(a_uv_rect.z, a_uv_rect.y);
+ v_cell_uv = vec2(1.0, 0.0);
} else if (corner == 2) {
pos = vec2(a_rect.z, a_rect.w);
uv = vec2(a_uv_rect.z, a_uv_rect.w);
+ v_cell_uv = vec2(1.0, 1.0);
} else if (corner == 3) {
pos = vec2(a_rect.x, a_rect.y);
uv = vec2(a_uv_rect.x, a_uv_rect.y);
+ v_cell_uv = vec2(0.0, 0.0);
} else if (corner == 4) {
pos = vec2(a_rect.z, a_rect.w);
uv = vec2(a_uv_rect.z, a_uv_rect.w);
+ v_cell_uv = vec2(1.0, 1.0);
} else {
pos = vec2(a_rect.x, a_rect.w);
uv = vec2(a_uv_rect.x, a_uv_rect.w);
+ v_cell_uv = vec2(0.0, 1.0);
+ }
+
+ uint effect = a_effect.x & 0x7fffffffu;
+ if (effect == 1u || effect == 2u || effect == 5u) {
+ // NDC y points up, so a top-left/bottom-right box has a NEGATIVE y
+ // delta. Preserve that sign or every local y is mirrored/outside.
+ vec2 panel_size = a_panel_rect.zw - a_panel_rect.xy;
+ panel_size.x = abs(panel_size.x) < 0.000001 ? 0.000001 : panel_size.x;
+ panel_size.y = abs(panel_size.y) < 0.000001 ? -0.000001 : panel_size.y;
+ vec2 within = (pos - a_panel_rect.xy) / panel_size;
+ pos = mix(a_present_rect.xy, a_present_rect.zw, within);
}
v_uv = uv;
v_fg = a_fg;
v_bg = a_bg;
+ v_effect = a_effect;
gl_Position = vec4(pos, 0.0, 1.0);
}
diff --git a/src/builtins.zig b/src/builtins.zig
index bb1a1273..d42aeafc 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -1,4 +1,5 @@
-//! The builtins: one struct each, and nothing hand-maintained about them.
+//! The builtins: one struct each, plus setting commands generated from one
+//! runtime_config table.
//!
//! Executing a builtin's NAME (middle-click / Tab) runs it through the one
//! dispatcher (Pardes.runBuiltin), no matter where the name appears. The
@@ -6,20 +7,14 @@
//! one sitting in a tag, the one Help prints, the one you execute — so
//! `std.meta.stringToEnum` is the lookup and there is no name table to sync.
//!
-//! A zig file IS a struct, so THIS FILE'S declarations are the list: `all`
-//! walks them at comptime and `Builtin` folds an enum out of that, in source
-//! order. Adding a builtin is writing one struct here — there is no list to
-//! append to and no switch prong to add, so there is nothing to forget. The
-//! hand-written enum and the four-hundred-line-away switch this replaces were
-//! two lists that had to agree; now the code IS the data.
+//! A zig file IS a struct, so THIS FILE'S declarations are the manual list:
+//! the registry walks them at comptime and appends the enabled settings from
+//! runtime_config.settings. There is no hand-maintained enum or dispatcher
+//! switch to keep in sync; declarations and setting descriptors are the data.
//!
-//! What makes a declaration a builtin is its SHAPE: a struct declaring
-//! `pub fn run(Ctx) void`. That is strict enough that Ctx and the two folds
-//! below fall out by construction rather than by a blocklist — a helper can
-//! never accidentally become a builtin, and a builtin whose run has the wrong
-//! signature silently vanishes instead of half-working, which the leader
-//! table catches immediately (see leader_path: a missing key path is a
-//! compile error).
+//! A manual builtin is a struct declaring `pub fn run(Ctx) void`; an optional
+//! explicit `enabled` declaration gates it. Helpers have no `run`, and a
+//! claimed builtin with the wrong signature is a compile error.
//!
//! What is NOT here: the key bindings. `leader_path` is ONE table in
//! config.zig next to `topbar_str` and every other syntactic choice — the
@@ -30,12 +25,19 @@ const pardes = @import("pardes.zig");
const Pardes = pardes.Pardes;
const Pane = pardes.Pane;
const output_pane = @import("output_pane.zig");
+const image_pane = @import("image_pane.zig");
const config = @import("config.zig");
-/// The installed fonts, for the two builtins at the bottom of this file. A
-/// GUI-only file behind a comptime branch, the way look.zig imports the web's
-/// source archive: the tty and web builds evaluate the other arm and compile
-/// none of it.
-const fonts = if (pardes.font_picker) @import("fonts.zig") else struct {};
+const runtime_config = @import("runtime_config.zig");
+
+/// The platform's runtime-setting facilities, stated once as plain data.
+/// Registry generation, leader paths, Config, and EffectCode all consume this
+/// exact value rather than rebuilding equivalent-looking boolean expressions.
+pub const capabilities: runtime_config.Capabilities = .{
+ .font_picker = pardes.font_picker,
+ .panel_transitions = pardes.platform != .web,
+ .scene_shaders = pardes.platform == .gui or pardes.platform == .macos,
+ .tagline_font_size = pardes.platform != .tty,
+};
/// What a builtin gets to act on. One bundle rather than five parameters
/// because most builtins want two of them and zig rejects the unused rest.
@@ -61,7 +63,7 @@ fn isEnabled(comptime T: type) bool {
return !@hasDecl(T, "enabled") or T.enabled;
}
-fn builtinCount() comptime_int {
+fn manualBuiltinCount() comptime_int {
comptime {
var count = 0;
for (@typeInfo(@This()).@"struct".decls) |d| {
@@ -76,10 +78,10 @@ fn builtinCount() comptime_int {
}
}
-fn builtinList() [builtinCount()]type {
+fn manualBuiltinList() [manualBuiltinCount()]type {
comptime {
@setEvalBranchQuota(4000);
- var list: [builtinCount()]type = undefined;
+ var list: [manualBuiltinCount()]type = undefined;
var count = 0;
for (@typeInfo(@This()).@"struct".decls) |d| {
if (@TypeOf(@field(@This(), d.name)) != type) continue;
@@ -92,6 +94,28 @@ fn builtinList() [builtinCount()]type {
}
}
+fn settingCount() comptime_int {
+ comptime {
+ var count = 0;
+ for (runtime_config.settings) |setting| if (setting.enabled(capabilities)) {
+ count += 1;
+ };
+ return count;
+ }
+}
+
+fn settingList() [settingCount()]runtime_config.Setting {
+ comptime {
+ var list: [settingCount()]runtime_config.Setting = undefined;
+ var count = 0;
+ for (runtime_config.settings) |setting| if (setting.enabled(capabilities)) {
+ list[count] = setting;
+ count += 1;
+ };
+ return list;
+ }
+}
+
/// A builtin's user-visible word: the LAST dotted segment of `@typeName`,
/// because @typeName spells a file-scope struct fully qualified
/// ("builtins.Kill"). Deriving it beats a `pub const name` field per struct,
@@ -108,6 +132,8 @@ pub const OutputTraits = struct {
steps: bool = false,
jumps: bool = false,
commands: bool = false,
+ doc: bool = false,
+ saves: bool = false,
};
/// The enum: field name = struct name, value = index into `all()`. Everything
@@ -118,31 +144,55 @@ pub const OutputTraits = struct {
/// walk only sees this namespace's type, not functions whose signatures depend
/// on the builtin enum being constructed.
pub const registry = struct {
- pub fn all() [builtinCount()]type {
- return builtinList();
- }
-
pub fn Builtin() type {
- const cmds = builtinList();
- const Tag = std.math.IntFittingRange(0, cmds.len - 1);
- var names: [cmds.len][]const u8 = undefined;
- for (cmds, 0..) |T, i| names[i] = word(T);
- return @Enum(Tag, .exhaustive, &names, &std.simd.iota(Tag, cmds.len));
+ @setEvalBranchQuota(20_000);
+ const manual = manualBuiltinList();
+ const generated = settingList();
+ const count = manual.len + generated.len;
+ const Tag = std.math.IntFittingRange(0, count - 1);
+ var names: [count][]const u8 = undefined;
+ for (manual, 0..) |T, i| names[i] = word(T);
+ for (generated, manual.len..) |setting, i| names[i] = setting.word;
+ for (names, 0..) |name, i| for (names[i + 1 ..]) |later|
+ if (std.mem.eql(u8, name, later)) @compileError("duplicate builtin name: " ++ name);
+ return @Enum(Tag, .exhaustive, &names, &std.simd.iota(Tag, count));
}
pub fn takesArg(b: Builtin()) bool {
- inline for (builtinList(), 0..) |T, i|
+ inline for (manualBuiltinList(), 0..) |T, i|
if (@intFromEnum(b) == i) return @hasDecl(T, "takes_arg") and T.takes_arg;
+ inline for (comptime settingList(), manualBuiltinCount()..) |setting, i|
+ if (@intFromEnum(b) == i) return setting.takesArg();
unreachable;
}
pub fn outputTraits(b: Builtin()) ?OutputTraits {
- inline for (builtinList(), 0..) |T, i|
+ inline for (manualBuiltinList(), 0..) |T, i|
if (@intFromEnum(b) == i) return if (@hasDecl(T, "output")) T.output else null;
+ inline for (comptime settingList(), manualBuiltinCount()..) |_, i|
+ if (@intFromEnum(b) == i) return null;
+ unreachable;
+ }
+
+ pub fn dispatch(b: Builtin(), c: Ctx) void {
+ inline for (manualBuiltinList(), 0..) |T, i|
+ if (@intFromEnum(b) == i) return T.run(c);
+ inline for (comptime settingList(), manualBuiltinCount()..) |setting, i|
+ if (@intFromEnum(b) == i) return c.p.applySettingBuiltin(setting, c.arg);
unreachable;
}
};
+test "capabilities exactly gate setting and effect-source builtins" {
+ const Builtin = registry.Builtin();
+ for (runtime_config.settings) |setting| {
+ const registered = std.meta.stringToEnum(Builtin, setting.word) != null;
+ try std.testing.expectEqual(setting.enabled(capabilities), registered);
+ }
+ const effect_code_registered = std.meta.stringToEnum(Builtin, "EffectCode") != null;
+ try std.testing.expectEqual(EffectCode.enabled, effect_code_registered);
+}
+
// ---- the two acme verbs ----
// Look and Execute are the verbs the whole environment is built on, and they
@@ -232,42 +282,12 @@ pub const Msg = struct {
}
};
-// ---- display toggles ----
-
-pub const Debug = struct {
- pub fn run(c: Ctx) void {
- c.p.show_debug = !c.p.show_debug;
- }
-};
-
-pub const Colors = struct {
- pub fn run(c: Ctx) void {
- c.p.colors_on = !c.p.colors_on;
- }
-};
-
-/// Soft wrap: a file line too long for the pane renders across several body
-/// rows instead of running off the right edge. Global, the way Colors is —
-/// it changes what every file pane looks like, so it is a switch you flip
-/// once rather than something a pane carries. ON by default (Pardes.wrap_on),
-/// so this is the word that turns wrapping OFF and gives hscroll back.
-pub const Wrap = struct {
- pub fn run(c: Ctx) void {
- c.p.wrap_on = !c.p.wrap_on;
- }
-};
-
-/// Taglines at the BOTTOM of every pane instead of the top. Global like Wrap
-/// and for the same reason — a layout where some panes label themselves above
-/// and others below is unreadable, so it is one switch for the whole screen.
-/// The transient message row gives that row up and takes the one above it (see
-/// render), so it is the pane's last BODY row either way and a prompt stays
-/// beside the tag it is typed into.
-pub const Tagbottom = struct {
- pub fn run(c: Ctx) void {
- c.p.tag_bottom = !c.p.tag_bottom;
- }
-};
+// ---- display choices ----
+//
+// The plain toggles, named theme/shell/font setters and animation effects are
+// generated from runtime_config.settings. Keeping their command metadata and
+// their query order in the same value table is what prevents a settable choice
+// from disappearing from Config. Hand-written commands continue below.
/// One step along the ring. With 228 themes in it this is no longer a way to
/// REACH a theme — ThemeSel is — but it is still the way to browse one, and the
@@ -278,7 +298,7 @@ pub const Tagbottom = struct {
/// clicks was never what made it useful.
pub const NextColor = struct {
pub fn run(c: Ctx) void {
- c.p.setThemeIndex((c.p.theme_idx + 1) % pardes.themes.len);
+ c.p.setThemeIndex((@as(usize, c.p.settings.theme) + 1) % pardes.themes.len);
}
};
@@ -292,41 +312,6 @@ pub const NextColor = struct {
/// name->index map, which is a second copy of the ring to build for a lookup
/// nobody will ever measure. 228 short string compares is microseconds, and it
/// happens once per theme change, not once per frame.
-pub const Theme = struct {
- pub const takes_arg = true;
- pub fn run(c: Ctx) void {
- const want = std.mem.trim(u8, c.arg orelse return, " \t\r\n");
- for (pardes.themes, 0..) |t, i| {
- if (std.mem.eql(u8, t.name, want)) {
- c.p.setThemeIndex(i);
- return;
- }
- }
- }
-};
-
-/// The binary a NEW terminal execs: `Shell fish`, `Shell zsh`,
-/// `Shell /opt/homebrew/bin/bash`. A bare name is looked up on the shells'
-/// side against the handful of directories a shell actually lives in — not
-/// $PATH, because the lookup has to finish BEFORE the fork (a PATH search
-/// allocates, and nothing between fork and exec may).
-///
-/// Takes a NAME, so it has no leader path and none in the topbar, for the same
-/// reason Theme has neither. Panes already open keep the shell they are
-/// running; this is what the next one starts.
-pub const Shell = struct {
- pub const takes_arg = true;
- pub fn run(c: Ctx) void {
- const want = std.mem.trim(u8, c.arg orelse return, " \t\r\n");
- // A name too long for the buffer is not silently truncated into a
- // DIFFERENT binary — `/very/long/path/to/fish` clipped is a path that
- // either does not exist or, worse, does.
- if (want.len == 0 or want.len > c.p.shell_buf.len) return;
- @memcpy(c.p.shell_buf[0..want.len], want);
- c.p.shell_len = @intCast(want.len);
- }
-};
-
/// ...and the list of what Theme takes, as a buffer you walk. Its rows are
/// `Theme <name>` COMMANDS rather than locations, which is one flag on the
/// buffer (output_pane.Traits.commands) and changes what a step SELECTS: the
@@ -340,28 +325,12 @@ pub const ThemeSel = struct {
}
};
-pub const Crt = struct {
- pub fn run(c: Ctx) void {
- c.p.crt_on = !c.p.crt_on;
- }
-};
-
-// ---- the GUI's font, and NOTHING on any other platform ----
-//
-// Theme and ThemeSel again, one layer down: a word that takes a name, and the
-// list of what it takes. What is different is that these two only EXIST in a
-// gui build, and the mechanism is the one this file's header describes rather
-// than a new one — `all()` folds the enum out of the SHAPE of each decl, so a
-// struct whose `run` is not `fn (Ctx) void` is not a builtin. Here `run` is a
-// void const on tty and web: the enum has no field, `SPC ?` has no row, the
-// dispatcher has no prong, and nothing in a tty binary ever opens a font
-// directory. The body sits inside the struct as an ordinary private decl,
-// which the walk never sees (it only reads pub, file-scope declarations) and
-// which nothing on those platforms ever analyses.
+// ---- the GUI's font list, and NOTHING on any other platform ----
//
-// A `pub const Font = if (gui) struct {...} else struct {}` would read better
-// and is WRONG: zig names a struct born inside an if-expression
-// "builtins.Font__struct_32751", and that name is the user-visible word.
+// Runtime settings such as Font are generated from runtime_config.settings.
+// FontSel remains hand-written because it opens a result pane. Its explicit
+// `enabled` bit is the same feature gate the registry uses for PDF commands:
+// disabled commands have no enum field, help row, or dispatcher case.
/// The GUI font BY NAME — `Font DejaVuSansMono-Regular`, the way `Theme <name>`
/// takes a theme, and inert without an argument for the same reason (there is
@@ -371,26 +340,8 @@ pub const Crt = struct {
/// findable.
///
/// The core cannot load a font — it has no rasterizer, no atlas and no window
-/// — so this asks: the resolved PATH goes in fonts.want, the shell takes it on
+/// — so this asks: the resolved PATH goes in runtime config, the shell takes it on
/// its next pass and re-rasters. Exactly the shape Restore already has.
-pub const Font = struct {
- pub const takes_arg = true;
- pub const enabled = pardes.font_picker;
- pub fn run(c: Ctx) void {
- if (comptime enabled) apply(c) else unreachable;
- }
- fn apply(c: Ctx) void {
- const want = std.mem.trim(u8, c.arg orelse return, " \t\r\n");
- const hit = fonts.list(c.p.scratch.allocator(), want);
- if (hit.len == 0) return;
- const path = hit[0].path;
- if (path.len > fonts.want_buf.len) return;
- @memcpy(fonts.want_buf[0..path.len], path);
- fonts.want = fonts.want_buf[0..path.len];
- fonts.chosen = std.fs.path.stem(fonts.want.?);
- }
-};
-
/// ...and the list of what Font takes: every MONOSPACE font on the machine,
/// one `Font <name>` row each, in the picker ThemeSel already is (rows that
/// are commands, so n/N select each one WHOLE and Tab runs it — walking with
@@ -403,7 +354,7 @@ pub const Font = struct {
/// them would mean the list wearing one on the way past. See fonts.monospaced.
pub const FontSel = struct {
pub const output: OutputTraits = .{ .name = config.fonts_buffer, .steps = true, .commands = true };
- pub const enabled = pardes.font_picker;
+ pub const enabled = capabilities.font_picker;
pub fn run(c: Ctx) void {
if (comptime enabled) apply(c) else unreachable;
}
@@ -413,30 +364,28 @@ pub const FontSel = struct {
};
/// Toggle a native PDF between the reading-oriented fit-width view and the
-/// whole-page-height view. Like Font on non-GUI builds, this declaration's
-/// `run` deliberately has the wrong shape when MuPDF is disabled: `all()` then
-/// omits it entirely, so the enum, Help and runtime binary contain no
-/// PdfFit.
+/// whole-page-height view. The explicit feature gate omits the command, help
+/// row, and dispatcher case when MuPDF is disabled.
pub const PdfFit = struct {
pub const enabled = pardes.pdf_enabled;
pub fn run(c: Ctx) void {
if (comptime enabled) apply(c) else unreachable;
}
fn apply(c: Ctx) void {
- c.p.togglePdfFit(c.pane);
+ pardes.pdf_pane.toggleFit(c.pane);
}
};
/// Cycle a native PDF through original pixels, a chroma-preserving themed
-/// filter, and a full theme duotone. It has the same compile-time and
-/// pane-local shape as PdfFit: absent without MuPDF and inert off a PDF pane.
+/// filter, and a full theme duotone. It has the same explicit feature gate as
+/// PdfFit: absent without MuPDF and inert off a PDF pane.
pub const PdfTint = struct {
pub const enabled = pardes.pdf_enabled;
pub fn run(c: Ctx) void {
if (comptime enabled) apply(c) else unreachable;
}
fn apply(c: Ctx) void {
- c.p.togglePdfTint(c.pane);
+ pardes.pdf_pane.toggleTint(c.pane);
}
};
@@ -450,42 +399,34 @@ pub const PdfSections = struct {
if (comptime enabled) apply(c) else unreachable;
}
fn apply(c: Ctx) void {
- c.p.openPdfSections(c.id);
+ pardes.pdf_pane.openSections(c.p, c.id);
}
};
-// The image pane's three renderer toggles. They used to be words the image tag
-// printed and the execute dispatcher matched by hand; as builtins they are
-// executable anywhere, pressable under SPC and listed by `SPC ?`, the whole
-// reason the tag no longer carries them. Each acts on the pane it runs in and
-// is inert anywhere else, the way Save is on a terminal — flipping the field is
-// the whole toggle: drawImage re-matches the glyph grid when it sees
-// grid_mode/grid_ascii disagree with the live ones.
+// The image pane's three renderer toggles. They used to be executable words
+// interpreted by a special tag dispatcher; as ordinary builtins they are
+// pressable under SPC and listed by `SPC ?`. The tag now reports their plain
+// live values without becoming a second mutation path. Each acts on the pane
+// it runs in and is inert elsewhere, the way Save is on a terminal.
/// glyph art over the host's pixels
pub const Petscii = struct {
pub fn run(c: Ctx) void {
- if (c.pane.image) |*iv| {
- iv.petscii = !iv.petscii;
- }
+ if (c.pane.image) |*state| image_pane.togglePetscii(state);
}
};
/// the C64 palette or the terminal's own 16
pub const Palette = struct {
pub fn run(c: Ctx) void {
- if (c.pane.image) |*iv| {
- iv.pmode = if (iv.pmode == .commodore) .terminal else .commodore;
- }
+ if (c.pane.image) |*state| image_pane.togglePalette(state);
}
};
/// add the printable ASCII bitmaps to the matcher's glyph set
pub const Ascii = struct {
pub fn run(c: Ctx) void {
- if (c.pane.image) |*iv| {
- iv.ascii = !iv.ascii;
- }
+ if (c.pane.image) |*state| image_pane.toggleAscii(state);
}
};
@@ -573,10 +514,12 @@ pub const New = struct {
pub const Newcol = struct {
pub fn run(c: Ctx) void {
const free = c.p.freeSlot() orelse return;
- if (c.p.ncol >= pardes.MAX_COLS) return;
+ if (!c.p.layoutCanSplitColumn(c.id)) return;
const nt = c.p.newShell(free, "") catch return;
nt.greet = true;
- c.p.layoutAppendColumn(free);
+ // The shared preflight above is the complete geometry refusal set.
+ // Pane creation does not alter layout, so the same split is infallible.
+ std.debug.assert(c.p.layoutSplitColumn(c.id, free, false));
c.p.active = free;
}
};
@@ -647,6 +590,22 @@ pub const Config = struct {
}
};
+/// Source code for the concrete backend implementation of a Panel*/scene
+/// effect. The bytes are embedded at build time, so this works from an
+/// installed executable rather than depending on a source checkout.
+pub const EffectCode = struct {
+ pub const takes_arg = true;
+ pub const enabled = capabilities.panel_transitions or capabilities.scene_shaders;
+ pub const output: OutputTraits = .{ .name = config.effect_code_buffer };
+ pub fn run(c: Ctx) void {
+ if (comptime enabled)
+ output_pane.openEffectCode(c.p, c.id, c.arg orelse return) catch |err|
+ c.p.reportError(c.id, "effect code", err)
+ else
+ unreachable;
+ }
+};
+
// ---- search ----
// The two builtins that ASK for something — Find walks file NAMES under this
diff --git a/src/config.zig b/src/config.zig
index de971db9..c8a8f8cf 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -145,7 +145,6 @@ pub const leader_path = paths: {
.Wrap = "tw",
.Tagbottom = "tb",
.NextColor = "tn",
- .Crt = "tr",
// the theme picker joins the toggles it belongs with; `Theme` itself takes
// a NAME, and a key path can never carry one, so it has none (the same
// reason Look and Exec have none)
@@ -180,20 +179,39 @@ pub const leader_path = paths: {
.Look = null,
.Exec = null,
});
- // The GUI's two font builtins, in the same `t` group as the theme picker
- // they mirror — but set here rather than named above, because on tty and
- // web they are not builtins at all (see builtins.zig) and the literal's
- // type has no field to write. `Font` takes a NAME, so it has no path, for
- // the same reason `Theme` has none.
- if (pardes.font_picker) {
+ // The GUI's font setting and picker, in the same `t` group as the theme
+ // picker they mirror. Their explicit availability metadata means tty and
+ // web Builtin enums have no fields for them. `Font` takes a NAME and
+ // `TaglineSize` takes a percentage, so neither has a path: a leader chord
+ // cannot carry either argument.
+ if (builtins.capabilities.font_picker) {
table.set(.FontSel, "tf");
table.set(.Font, null);
+ table.set(.TaglineSize, null);
}
- // The pane-local PDF commands exist only in MuPDF builds (their run
- // declarations deliberately change shape when the feature is off), so
- // name their paths through the same comptime branch rather than making
- // feature-off enums mention them. PdfTint/PdfFit retain their display
- // slots and PdfSections takes the mnemonic `s` between them.
+ // Panel transitions are implemented by the fixed cell grid in TTY and by
+ // shader-capable native GUI shells. The DOM web shell does not advertise
+ // them until it has an equivalent renderer.
+ if (builtins.capabilities.panel_transitions) {
+ table.set(.PanelSlide, "as");
+ table.set(.PanelZoom, "az");
+ table.set(.PanelDissolve, "ad");
+ table.set(.PanelAscii, "aa");
+ table.set(.PanelVertical, "av");
+ }
+ if (builtins.capabilities.scene_shaders) {
+ table.set(.Crt, "tr");
+ table.set(.Ripple, "tR");
+ table.set(.Glitch, "tg");
+ }
+ // Takes the name of an effect builtin; a leader path cannot carry it.
+ // The web build has no runnable effect argument, so it has no command,
+ // help row, dispatcher case, or leader entry for EffectCode either.
+ if (builtins.EffectCode.enabled) table.set(.EffectCode, null);
+ // The pane-local PDF commands exist only in MuPDF builds through their
+ // explicit registry availability, so name their paths inside the same
+ // comptime branch. PdfTint/PdfFit retain their display slots and
+ // PdfSections takes the mnemonic `s` between them.
if (pardes.pdf_enabled) {
table.set(.PdfTint, "ti");
table.set(.PdfSections, "ts");
@@ -328,10 +346,11 @@ pub const tty_blank: enum { prompt, prompt_and_input } = .prompt;
// is where topbar clicks land, so its exact bytes are load-bearing (every
// snapshot golden records the column each word starts at). Comptime-checked
// against the enum in pardes.zig so a rename cannot silently rot it.
-// Colors and Crt are NOT here: both are set-once display switches you flip and
-// forget, and a bar you read every frame should not spend width on them now
-// that `SPC t c` / `SPC t r` press them. NextColor stays — it is the one you
-// cycle repeatedly, so a click beats a three-key path.
+// Colors and the scene/panel effects are NOT here: they are display switches
+// you flip and forget, and a bar read every frame should not spend width on
+// them now that their leader paths are discoverable through Help. NextColor
+// stays — it is the one you cycle repeatedly, so a click beats a three-key
+// path.
//
// Ordered by day-to-day usefulness, in stable functional groups: creation
// (New/Newcol), search/navigation (Find/Grep), learning (Help/Tutor), session
@@ -431,6 +450,21 @@ pub const search_submit: []const Chord = &.{.{ .cp = Key.enter }};
pub const wheel_rows: i32 = 1;
pub const wheel_cols: i32 = 4;
+/// Enabled by default: rest the pointer over text for this many animation
+/// frames before showing the exact span a right-click Look would expand.
+/// Repeated motion inside the same cell does not restart the count; moving to
+/// another cell does. Set to `null` to compile the preview out while retaining
+/// ordinary mouse hover and resize-handle hints.
+pub const look_preview_delay_frames: ?u16 = 2;
+
+/// GUI shells rasterize pane-tag text at this percentage of the body face
+/// while retaining the same cell geometry. TTY ignores the visual role.
+pub const gui_tagline_font_percent: u8 = 82;
+comptime {
+ if (gui_tagline_font_percent == 0 or gui_tagline_font_percent > 100)
+ @compileError("config.gui_tagline_font_percent must be in 1...100");
+}
+
/// Touchpad drift guard, in ticks. A two-finger swipe that is MEANT to be
/// vertical carries a little sideways drift, and the pad faithfully turns that
/// drift into wheel_left/wheel_right — so a plain scroll slides the view
@@ -669,6 +703,7 @@ pub const pipe_marker = " |";
pub const search_buffer = "+Search";
pub const help_buffer = "+Help";
pub const config_buffer = "+Config";
+pub const effect_code_buffer = "+EffectCode";
pub const jumps_buffer = "+Jumps";
pub const themes_buffer = "+Themes";
pub const fonts_buffer = "+Fonts";
diff --git a/src/dump.zig b/src/dump.zig
index 44f61cf4..566cf03c 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -40,6 +40,13 @@ pub const magic = "pardes-dump";
pub const version: u32 = 1;
pub const max_panes: usize = 16;
pub const max_cols: usize = 6;
+/// Bounds the only user-editable, schema-owned tag fragment. Keep this beside
+/// the dump limits so readers can reject data before copying it into a pane.
+pub const max_tag_tail: usize = 4096;
+/// Output arguments are typed in the same bounded one-line tag storage. Keep
+/// the schema limit named independently so a dump reader can validate it
+/// without importing the output-pane implementation.
+pub const max_origin_arg: usize = max_tag_tail;
pub const Size = struct {
cols: u16,
@@ -77,9 +84,19 @@ pub const File = struct {
origin_arg: []const u8 = "",
};
+pub const ImagePalette = enum {
+ commodore,
+ terminal,
+};
+
pub const Image = struct {
path: []const u8 = "",
bytes_b64: []const u8 = "",
+ // Defaults are the renderer's historical state, so version-1 dumps which
+ // predate these fields remain readable without a schema fork.
+ petscii: bool = false,
+ palette: ImagePalette = .commodore,
+ ascii: bool = true,
};
pub const Pane = struct {
@@ -90,6 +107,9 @@ pub const Pane = struct {
cols: u16 = 0,
rows: u16 = 0,
vweight: f32 = 1,
+ /// Exact editable tail when the user touched it. Null retains the live
+ /// default; a present empty slice deliberately restores an empty tail.
+ tag_tail: ?[]const u8 = null,
terminal: ?Terminal = null,
file: ?File = null,
image: ?Image = null,
@@ -118,12 +138,20 @@ pub fn validate(state: State) !void {
if (state.columns.len == 0 or state.columns.len > max_cols) return error.BadDumpColumns;
if (state.active >= state.panes.len) return error.BadDumpActive;
for (state.columns) |col| {
+ if (!std.math.isFinite(col.weight) or col.weight <= 0) return error.BadDumpColumns;
if (col.panes.len == 0 or col.panes.len > max_panes) return error.BadDumpColumns;
for (col.panes) |pane| {
if (pane >= state.panes.len) return error.BadDumpPaneRef;
}
}
for (state.panes) |pane| {
+ if (!std.math.isFinite(pane.vweight) or pane.vweight <= 0)
+ return error.BadDumpPaneWeight;
+ if (pane.scroll > std.math.maxInt(i32)) return error.BadDumpPaneScroll;
+ if (pane.tag_tail) |tail| if (tail.len > max_tag_tail)
+ return error.BadDumpTagTail;
+ if (pane.file) |file| if (file.origin_arg.len > max_origin_arg)
+ return error.BadDumpOriginArg;
switch (pane.kind) {
.terminal => if (pane.terminal == null) return error.BadDumpPaneKind,
.file => if (pane.file == null) return error.BadDumpPaneKind,
@@ -132,6 +160,37 @@ pub fn validate(state: State) !void {
}
}
+test "oversized recognized output argument is rejected before restore" {
+ var oversized: [max_origin_arg + 1]u8 = @splat('x');
+ const pane_ids = [_]usize{0};
+ const columns = [_]Column{.{ .panes = &pane_ids }};
+ const panes = [_]Pane{.{
+ .kind = .file,
+ .tag = "+Search",
+ .body = "",
+ .file = .{
+ .path = "/tmp/+Search",
+ .origin = "Find",
+ .origin_arg = &oversized,
+ },
+ }};
+ const state: State = .{
+ .screen = .{ .cols = 80, .rows = 24 },
+ .columns = &columns,
+ .panes = &panes,
+ };
+
+ // Serialize without writeFile: that writer correctly rejects the state
+ // too, while this exercises the hostile bytes a restore actually reads.
+ var encoded: std.Io.Writer.Allocating = .init(std.testing.allocator);
+ defer encoded.deinit();
+ try std.zon.stringify.serialize(state, .{}, &encoded.writer);
+ try std.testing.expectError(
+ error.BadDumpOriginArg,
+ readZon(std.testing.allocator, encoded.written(), "oversized-origin-arg"),
+ );
+}
+
pub fn writeFile(io: std.Io, gpa: std.mem.Allocator, path: []const u8, state: State) !void {
try validate(state);
var out: std.Io.Writer.Allocating = .init(gpa);
@@ -257,3 +316,96 @@ test "dump zon roundtrip" {
try std.testing.expectEqualStrings("alpha\nbeta\n", bytes);
}
}
+
+test "version-one image records default old fields and roundtrip new state" {
+ const gpa = std.testing.allocator;
+ const legacy =
+ \\.{
+ \\ .magic = "pardes-dump",
+ \\ .version = 1,
+ \\ .screen = .{ .cols = 80, .rows = 24 },
+ \\ .active = 0,
+ \\ .topbar = "",
+ \\ .theme = "dark",
+ \\ .columns = .{.{ .panes = .{0} }},
+ \\ .panes = .{.{
+ \\ .kind = .image,
+ \\ .tag = "img /tmp/old.ppm New Del",
+ \\ .body = "",
+ \\ .image = .{ .path = "/tmp/old.ppm", .bytes_b64 = "" },
+ \\ }},
+ \\}
+ ;
+ const old = try readZon(gpa, legacy, "legacy-image");
+ defer free(gpa, old);
+ const old_image = old.panes[0].image.?;
+ try std.testing.expect(!old_image.petscii);
+ try std.testing.expectEqual(ImagePalette.commodore, old_image.palette);
+ try std.testing.expect(old_image.ascii);
+ try std.testing.expect(old.panes[0].tag_tail == null);
+
+ const pane = Pane{
+ .kind = .image,
+ .tag = "img petscii:on palette:terminal ascii:off /tmp/new.ppm",
+ .body = "",
+ .tag_tail = "",
+ .image = .{
+ .path = "/tmp/new.ppm",
+ .petscii = true,
+ .palette = .terminal,
+ .ascii = false,
+ },
+ };
+ const ids = [_]usize{0};
+ const columns = [_]Column{.{ .panes = &ids }};
+ const panes = [_]Pane{pane};
+ const state = State{
+ .screen = .{ .cols = 80, .rows = 24 },
+ .columns = &columns,
+ .panes = &panes,
+ };
+ var out: std.Io.Writer.Allocating = .init(gpa);
+ defer out.deinit();
+ try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer);
+ const parsed = try readZon(gpa, out.written(), "new-image");
+ defer free(gpa, parsed);
+ const restored = parsed.panes[0].image.?;
+ try std.testing.expect(restored.petscii);
+ try std.testing.expectEqual(ImagePalette.terminal, restored.palette);
+ try std.testing.expect(!restored.ascii);
+ try std.testing.expect(parsed.panes[0].tag_tail != null);
+ try std.testing.expectEqualStrings("", parsed.panes[0].tag_tail.?);
+}
+
+test "validation bounds pane restore state" {
+ const ids = [_]usize{0};
+ const columns = [_]Column{.{ .panes = &ids }};
+ var panes = [_]Pane{.{
+ .kind = .terminal,
+ .tag = "term",
+ .body = "",
+ .terminal = .{},
+ }};
+ const state = State{
+ .screen = .{ .cols = 80, .rows = 24 },
+ .columns = &columns,
+ .panes = &panes,
+ };
+ try validate(state);
+
+ panes[0].vweight = 0;
+ try std.testing.expectError(error.BadDumpPaneWeight, validate(state));
+ panes[0].vweight = std.math.inf(f32);
+ try std.testing.expectError(error.BadDumpPaneWeight, validate(state));
+ panes[0].vweight = 1;
+
+ panes[0].scroll = @as(usize, @intCast(std.math.maxInt(i32))) + 1;
+ try std.testing.expectError(error.BadDumpPaneScroll, validate(state));
+ panes[0].scroll = 0;
+
+ var oversized_tail: [max_tag_tail + 1]u8 = @splat('x');
+ panes[0].tag_tail = &oversized_tail;
+ try std.testing.expectError(error.BadDumpTagTail, validate(state));
+ panes[0].tag_tail = oversized_tail[0..max_tag_tail];
+ try validate(state);
+}
diff --git a/src/effect_sources.zig b/src/effect_sources.zig
new file mode 100644
index 00000000..87026278
--- /dev/null
+++ b/src/effect_sources.zig
@@ -0,0 +1,242 @@
+//! Build-embedded source behind EffectCode.
+//!
+//! Several builtins intentionally share one shader pass. Returning segments
+//! makes that sharing visible instead of copying or manufacturing a pretend
+//! per-effect program.
+
+const std = @import("std");
+const build_config = @import("pardes_config");
+const runtime_config = @import("runtime_config.zig");
+
+const panel_math = @embedFile("panel_animation.zig");
+const tty_compositor = @embedFile("tty/panel_compositor.zig");
+const panel_vertex = @embedFile("effect-source-ui.vert.glsl");
+const panel_fragment = @embedFile("effect-source-ui.frag.glsl");
+const image_vertex = @embedFile("effect-source-image.vert.glsl");
+const image_fragment = @embedFile("effect-source-image.frag.glsl");
+const scene_vertex = @embedFile("effect-source-crt.vert.glsl");
+const scene_fragment = @embedFile("effect-source-crt.frag.glsl");
+const gui_scene_mapping = @embedFile("gui/crt.zig");
+const gui_host = @embedFile("gui/gui.zig");
+const mac_scene = @embedFile("effect-source-crt.ci.metal");
+const mac_postprocessor = @embedFile("macos/Sources/ScenePostprocessor.swift");
+const mac_view = @embedFile("macos/Sources/PardesView.swift");
+
+fn sourceSection(
+ comptime source: []const u8,
+ comptime begin_marker: []const u8,
+ comptime end_marker: []const u8,
+) []const u8 {
+ // gui.zig is intentionally embedded as the source actually compiled, then
+ // narrowed to its marked host paths. The comptime byte scan is larger than
+ // Zig's small default quota but contributes no runtime work.
+ @setEvalBranchQuota(1_000_000);
+ const begin = std.mem.indexOf(u8, source, begin_marker) orelse
+ @compileError("EffectCode source begin marker is missing");
+ const body = begin + begin_marker.len;
+ const end = std.mem.indexOfPos(u8, source, body, end_marker) orelse
+ @compileError("EffectCode source end marker is missing");
+ return source[body..end];
+}
+
+const gui_panel_host = sourceSection(
+ gui_host,
+ "// EFFECT_CODE_PANEL_HOST_BEGIN\n",
+ "// EFFECT_CODE_PANEL_HOST_END",
+);
+const gui_native_panel = sourceSection(
+ gui_host,
+ "// EFFECT_CODE_NATIVE_PANEL_BEGIN\n",
+ "// EFFECT_CODE_NATIVE_PANEL_END",
+);
+const gui_frame_submission = sourceSection(
+ gui_host,
+ "// EFFECT_CODE_FRAME_SUBMISSION_BEGIN\n",
+ "// EFFECT_CODE_FRAME_SUBMISSION_END",
+);
+const gui_cell_instance = sourceSection(
+ gui_host,
+ "// EFFECT_CODE_CELL_INSTANCE_BEGIN\n",
+ "// EFFECT_CODE_CELL_INSTANCE_END",
+);
+
+const ui_vertex_path = if (build_config.gui_shader_sources_prebuilt)
+ "shaders/prebuilt/ui.vert.glsl"
+else
+ "shaders/ui.vert.glsl";
+const ui_fragment_path = if (build_config.gui_shader_sources_prebuilt)
+ "shaders/prebuilt/ui.frag.glsl"
+else
+ "shaders/ui.frag.glsl";
+const image_vertex_path = if (build_config.gui_shader_sources_prebuilt)
+ "shaders/prebuilt/image.vert.glsl"
+else
+ "shaders/image.vert.glsl";
+const image_fragment_path = if (build_config.gui_shader_sources_prebuilt)
+ "shaders/prebuilt/image.frag.glsl"
+else
+ "shaders/image.frag.glsl";
+const scene_vertex_path = if (build_config.gui_shader_sources_prebuilt)
+ "shaders/prebuilt/crt.vert.glsl"
+else
+ "shaders/crt.vert.glsl";
+const scene_fragment_path = if (build_config.gui_shader_sources_prebuilt)
+ "shaders/prebuilt/crt.frag.glsl"
+else
+ "shaders/crt.frag.glsl";
+
+pub const Backend = @TypeOf(build_config.platform);
+pub const backend: Backend = build_config.platform;
+
+pub const GuiShaderSourceMode = enum {
+ live,
+ prebuilt_snapshot,
+
+ pub fn label(mode: GuiShaderSourceMode) []const u8 {
+ return switch (mode) {
+ .live => "live GLSL compiled during this build",
+ .prebuilt_snapshot => "paired GLSL snapshot for committed SPIR-V",
+ };
+ }
+};
+
+/// Only the SDL backend consumes GLSL/SPIR-V. Other backends deliberately have
+/// no shader-source mode rather than being mislabeled "live" or "prebuilt".
+pub fn guiShaderSourceMode() ?GuiShaderSourceMode {
+ if (backend != .gui) return null;
+ return if (build_config.gui_shader_sources_prebuilt)
+ .prebuilt_snapshot
+ else
+ .live;
+}
+
+pub const Segment = struct {
+ path: []const u8,
+ source: []const u8,
+};
+
+const tty_panel = [_]Segment{
+ .{ .path = "src/panel_animation.zig", .source = panel_math },
+ .{ .path = "src/tty/panel_compositor.zig", .source = tty_compositor },
+};
+const gui_panel = [_]Segment{
+ .{ .path = "src/panel_animation.zig", .source = panel_math },
+ .{ .path = "src/gui/gui.zig#panel-host", .source = gui_panel_host },
+ .{ .path = "src/gui/gui.zig#native-panel", .source = gui_native_panel },
+ .{ .path = "src/gui/gui.zig#frame-submission", .source = gui_frame_submission },
+ .{ .path = "src/gui/gui.zig#cell-instance", .source = gui_cell_instance },
+ .{ .path = ui_vertex_path, .source = panel_vertex },
+ .{ .path = ui_fragment_path, .source = panel_fragment },
+ .{ .path = image_vertex_path, .source = image_vertex },
+ .{ .path = image_fragment_path, .source = image_fragment },
+};
+const gui_scene = [_]Segment{
+ .{ .path = "src/gui/crt.zig", .source = gui_scene_mapping },
+ .{ .path = "src/gui/gui.zig#frame-submission", .source = gui_frame_submission },
+ .{ .path = scene_vertex_path, .source = scene_vertex },
+ .{ .path = scene_fragment_path, .source = scene_fragment },
+};
+const mac_panel = [_]Segment{
+ .{ .path = "src/panel_animation.zig", .source = panel_math },
+ .{ .path = "src/macos/Sources/PardesView.swift", .source = mac_view },
+ .{ .path = "src/macos/Sources/ScenePostprocessor.swift", .source = mac_postprocessor },
+ .{ .path = "shaders/crt.ci.metal", .source = mac_scene },
+};
+const mac_scene_segments = [_]Segment{
+ .{ .path = "src/macos/Sources/PardesView.swift", .source = mac_view },
+ .{ .path = "src/macos/Sources/ScenePostprocessor.swift", .source = mac_postprocessor },
+ .{ .path = "shaders/crt.ci.metal", .source = mac_scene },
+};
+
+/// Sources for the backend this executable was built for. Keeping the backend
+/// compile-time and out of the call signature is important: a TTY binary must
+/// not carry the macOS host and Metal source merely because another switch arm
+/// could have returned them.
+pub fn forSetting(setting: runtime_config.Setting) ?[]const Segment {
+ return switch (setting.action) {
+ .transition => switch (backend) {
+ .tty => &tty_panel,
+ .gui => &gui_panel,
+ .macos => &mac_panel,
+ .web => null,
+ },
+ .scene => switch (backend) {
+ .gui => &gui_scene,
+ .macos => &mac_scene_segments,
+ .tty, .web => null,
+ },
+ else => null,
+ };
+}
+
+test "every current-backend effect exposes embedded implementation code" {
+ for (runtime_config.settings) |setting| switch (setting.action) {
+ .transition => if (backend != .web) {
+ const segments = forSetting(setting) orelse return error.MissingTransitionSource;
+ for (segments) |segment| {
+ try std.testing.expect(segment.path.len > 0);
+ try std.testing.expect(segment.source.len > 0);
+ }
+ } else try std.testing.expect(forSetting(setting) == null),
+ .scene => if (backend == .gui or backend == .macos) {
+ const segments = forSetting(setting) orelse return error.MissingSceneSource;
+ for (segments) |segment| {
+ try std.testing.expect(segment.path.len > 0);
+ try std.testing.expect(segment.source.len > 0);
+ }
+ } else try std.testing.expect(forSetting(setting) == null),
+ else => {},
+ };
+}
+
+test "GUI scene EffectCode includes both runtime shader stages" {
+ if (comptime backend == .gui) {
+ try std.testing.expectEqual(@as(usize, 4), gui_scene.len);
+ try std.testing.expectEqualStrings("src/gui/crt.zig", gui_scene[0].path);
+ try std.testing.expectEqualStrings("src/gui/gui.zig#frame-submission", gui_scene[1].path);
+ try std.testing.expectEqualStrings(scene_vertex_path, gui_scene[2].path);
+ try std.testing.expectEqualStrings(scene_fragment_path, gui_scene[3].path);
+ try std.testing.expect(std.mem.indexOf(u8, gui_scene[0].source, "pub fn mapScene") != null);
+ try std.testing.expect(std.mem.indexOf(u8, gui_scene[1].source, "SDL_PushGPUFragmentUniformData") != null);
+ try std.testing.expect(std.mem.indexOf(u8, gui_scene[2].source, "gl_VertexIndex") != null);
+ try std.testing.expect(std.mem.indexOf(u8, gui_scene[3].source, "u_crt.effects") != null);
+ }
+}
+
+test "GUI panel EffectCode includes the actual host paint and submission path" {
+ if (comptime backend == .gui) {
+ try std.testing.expect(std.mem.indexOf(u8, gui_panel_host, "fn makePaintPlan") != null);
+ try std.testing.expect(std.mem.indexOf(u8, gui_panel_host, "fn setTransitionFields") != null);
+ try std.testing.expect(std.mem.indexOf(u8, gui_native_panel, "fn prepareNativeImages") != null);
+ try std.testing.expect(std.mem.indexOf(u8, gui_native_panel, "fn drawNativeImagesGpu") != null);
+ try std.testing.expect(std.mem.indexOf(u8, gui_native_panel, "active.visualBox()") != null);
+ try std.testing.expect(std.mem.indexOf(u8, gui_frame_submission, "for (paint_plan.batches") != null);
+ try std.testing.expect(std.mem.indexOf(u8, gui_cell_instance, "fn emitInstance") != null);
+ try std.testing.expect(std.mem.indexOf(u8, gui_cell_instance, "setTransitionFields") != null);
+ try std.testing.expect(std.mem.indexOf(u8, image_fragment, "vec3 contrastInk") != null);
+ }
+}
+
+test "shader source provenance is GUI-only and follows the build input" {
+ if (comptime backend == .gui) {
+ const expected: GuiShaderSourceMode = if (build_config.gui_shader_sources_prebuilt)
+ .prebuilt_snapshot
+ else
+ .live;
+ try std.testing.expectEqual(expected, guiShaderSourceMode().?);
+ try std.testing.expect(expected.label().len > 0);
+ } else try std.testing.expect(guiShaderSourceMode() == null);
+}
+
+test "mac EffectCode includes the runtime panel and scene wiring" {
+ if (comptime backend == .macos) {
+ try std.testing.expect(std.mem.indexOf(u8, mac_scene, "extern \"C\" float4 pardesPanelClear") != null);
+ try std.testing.expect(std.mem.indexOf(u8, mac_scene, "extern \"C\" float4 pardesPanel") != null);
+ try std.testing.expect(std.mem.indexOf(u8, mac_scene, "extern \"C\" float4 pardesScene") != null);
+ try std.testing.expect(std.mem.indexOf(u8, mac_scene, "float3 contrastInk") != null);
+ try std.testing.expect(std.mem.indexOf(u8, mac_view, "hideCursor: !tracks.isEmpty") != null);
+ try std.testing.expect(std.mem.indexOf(u8, mac_postprocessor, "panelClearKernel.apply") != null);
+ try std.testing.expect(std.mem.indexOf(u8, mac_postprocessor, "panelKernel.apply") != null);
+ try std.testing.expect(std.mem.indexOf(u8, mac_postprocessor, "sceneKernel.apply") != null);
+ }
+}
diff --git a/src/file_pane.zig b/src/file_pane.zig
index b0921bd0..cf2b9571 100644
--- a/src/file_pane.zig
+++ b/src/file_pane.zig
@@ -1,4 +1,4 @@
-//! File panes: everything a Pane does BECAUSE it has `file: ?File` set — the
+//! File panes: everything a Pane does BECAUSE it has `file: ?State` set — the
//! disk read, the content swap undo/redo commits through, the tree-sitter
//! highlight window, and the two render passes only a file has (the line
//! number gutter and the syntax recolor). The rest of a file pane's behaviour
@@ -8,14 +8,79 @@ const pardes = @import("pardes.zig");
const config = @import("config.zig");
const Pardes = pardes.Pardes;
const Pane = pardes.Pane;
-const File = pardes.File;
-const FileSnap = pardes.FileSnap;
const modal = @import("modal.zig");
const look = @import("look.zig");
+const output_pane = @import("output_pane.zig");
const syntax = @import("syntax.zig");
const tracy = @import("tracy.zig");
+const dump = @import("dump.zig");
const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2;
+const undo_max = 256;
+
+/// Content and primary selection at one file edit boundary. Keeping only the
+/// primary avoids putting pardes.MAX_SELS ranges in every history entry.
+pub const Snapshot = struct {
+ content: []u8,
+ cur_row: i32,
+ cur_col: i32,
+ vsel: pardes.CharSel,
+};
+
+/// A file pane's backing: owned content, its derived caches, and undo history.
+pub const State = struct {
+ path: []u8,
+ content: []u8,
+ /// Monotonic content identity for asynchronous edits. Every content swap
+ /// goes through setContent, which bumps this; a pipe completion accepted
+ /// against another revision would overwrite intervening work.
+ revision: u32 = 0,
+ /// Revision last handed to Save. Equal means the screen matches disk.
+ saved_revision: u32 = 0,
+ /// Non-null for a generated output buffer rather than an on-disk file.
+ output: ?output_pane.Output = null,
+ scroll: usize = 0,
+ /// `line_starts[i]` is line i's byte offset. Empty means not built yet.
+ line_starts: []usize = &.{},
+ /// One tree_sitter_gpa-owned syntax.Syn byte per highlighted source byte.
+ highlights: []u8 = &.{},
+ highlight_start: usize = 0,
+ syntax_dirty: bool = true,
+ undo: [undo_max]Snapshot = undefined,
+ undo_len: usize = 0,
+ redo: [undo_max]Snapshot = undefined,
+ redo_len: usize = 0,
+};
+
+/// Serialize file-owned bytes and identity; output origin vocabulary is
+/// supplied by output_pane at the core dispatch edge.
+pub fn dumpPane(
+ arena: std.mem.Allocator,
+ pane: *const Pane,
+ file: *const State,
+ tag: []const u8,
+ body: []const u8,
+ scroll: usize,
+ origin: []const u8,
+ origin_arg: []const u8,
+) !dump.Pane {
+ return .{
+ .kind = .file,
+ .tag = tag,
+ .body = body,
+ .scroll = scroll,
+ .cols = pane.cols,
+ .rows = pane.rows,
+ .vweight = pane.vweight,
+ .file = .{
+ .path = file.path,
+ .content = file.content,
+ .content_b64 = try dump.encodeBytes(arena, file.content),
+ .origin = origin,
+ .origin_arg = origin_arg,
+ },
+ };
+}
pub fn byteDisplayWidth(byte: u8) usize {
return if (byte == '\t') config.tab_width else 1;
@@ -39,6 +104,67 @@ pub fn byteAtDisplay(text: []const u8, display_col: usize) usize {
return text.len;
}
+/// File cursor columns may live past EOL. Tabs expand before that boundary;
+/// every virtual column after it remains one screen cell.
+pub fn rawDisplayCol(line_text: []const u8, raw_col: usize) usize {
+ const bounded = @min(raw_col, line_text.len);
+ return displayWidth(line_text[0..bounded]) +| (raw_col - bounded);
+}
+
+pub fn rawAtDisplay(line_text: []const u8, display_col: usize) usize {
+ const width = displayWidth(line_text);
+ if (display_col > width) return line_text.len +| (display_col - width);
+ return byteAtDisplay(line_text, display_col);
+}
+
+pub fn sourceLine(pane: *const Pane, row: i32) []const u8 {
+ const f = pane.file orelse return "";
+ if (row < 0) return "";
+ return modal.lineSlice(f.content, @intCast(row));
+}
+
+pub fn displayOffset(pane: *const Pane, row: i32, from_raw: i32, to_raw: i32) i32 {
+ const line_text = sourceLine(pane, row);
+ const from: usize = @intCast(@max(0, from_raw));
+ const to: usize = @intCast(@max(0, to_raw));
+ const from_display = rawDisplayCol(line_text, from);
+ const to_display = rawDisplayCol(line_text, to);
+ if (to_display >= from_display) return @intCast(to_display - from_display);
+ return -@as(i32, @intCast(from_display - to_display));
+}
+
+pub fn displayEndOffset(pane: *const Pane, row: i32, from_raw: i32, at_raw: i32) i32 {
+ const start = displayOffset(pane, row, from_raw, at_raw);
+ const line_text = sourceLine(pane, row);
+ const at: usize = @intCast(@max(0, at_raw));
+ if (at >= line_text.len) return start;
+ return start + @as(i32, @intCast(byteDisplayWidth(line_text[at]))) - 1;
+}
+
+pub fn byteAtRowDisplay(pane: *const Pane, row: i32, from_raw: i32, display_col: i32) i32 {
+ const line_text = sourceLine(pane, row);
+ const from: usize = @intCast(@max(0, from_raw));
+ const display: usize = @intCast(@max(0, display_col));
+ if (from >= line_text.len) return @intCast(from +| display);
+ return @intCast(from +| rawAtDisplay(line_text[from..], display));
+}
+
+/// Convert between rendered pane rows (whose file body includes PREFIX_W)
+/// and source-byte columns. Non-file rows are already in byte coordinates.
+pub fn renderedLineByteCol(pane: *const Pane, row: i32, line_text: []const u8, display_col: usize) usize {
+ if (pane.file == null or row < pardes.BOX_H) return display_col;
+ const prefix = @min(@as(usize, config.PREFIX_W), line_text.len);
+ if (display_col <= prefix) return display_col;
+ return prefix +| rawAtDisplay(line_text[prefix..], display_col - prefix);
+}
+
+pub fn renderedLineDisplayCol(pane: *const Pane, row: i32, line_text: []const u8, byte_col: usize) usize {
+ if (pane.file == null or row < pardes.BOX_H) return byte_col;
+ const prefix = @min(@as(usize, config.PREFIX_W), line_text.len);
+ if (byte_col <= prefix) return byte_col;
+ return prefix +| rawDisplayCol(line_text[prefix..], byte_col - prefix);
+}
+
fn fitEnd(text: []const u8, start: usize, width: usize) usize {
var end = start;
var used: usize = 0;
@@ -64,7 +190,7 @@ pub fn lineCount(content: []const u8) usize {
/// INVALIDATION — the part that rots if nobody says it out loud. The index is
/// dropped in EXACTLY ONE PLACE: setContent, immediately below, which is the
/// funnel every content swap in the editor already goes through (typing, undo,
-/// redo, a save's normalisation, an output buffer refilling itself). A File
+/// redo, a save's normalisation, an output buffer refilling itself). A State
/// built by a struct literal starts with an empty index, and empty reads as
/// "not built yet" — a real index always has at least one entry, because a
/// file always has at least one line. So there is one and only one way to make
@@ -74,7 +200,7 @@ pub fn lineCount(content: []const u8) usize {
/// swallow that and scan the old way, so OOM there is slow rather than wrong;
/// callers that need the whole table say `try` and drop the keystroke, which
/// is what they already did when their own arena ran out.
-pub fn lineIndex(gpa: std.mem.Allocator, f: *File) ![]const usize {
+pub fn lineIndex(gpa: std.mem.Allocator, f: *State) ![]const usize {
if (f.line_starts.len > 0) return f.line_starts;
// Exact allocation: deinitPane frees `line_starts` itself, so the stored
// slice must span the complete allocation rather than spare capacity.
@@ -92,18 +218,65 @@ pub fn lineIndex(gpa: std.mem.Allocator, f: *File) ![]const usize {
}
/// line count, O(1) once the index is warm
-pub fn nlines(gpa: std.mem.Allocator, f: *File) usize {
+pub fn nlines(gpa: std.mem.Allocator, f: *State) usize {
const idx = lineIndex(gpa, f) catch return lineCount(f.content);
return idx.len;
}
/// byte offset of line `row`, or content.len past the end — modal
/// .lineStartOffset's contract exactly, without its walk
-pub fn lineStart(gpa: std.mem.Allocator, f: *File, row: usize) usize {
+pub fn lineStart(gpa: std.mem.Allocator, f: *State, row: usize) usize {
const idx = lineIndex(gpa, f) catch return modal.lineStartOffset(f.content, row);
return if (row >= idx.len) f.content.len else idx[row];
}
+pub fn cursorLines(arena: std.mem.Allocator, pane: *Pane, f: *State) ![]const []const u8 {
+ const index = try lineIndex(pane.gpa, f);
+ const lines = try arena.alloc([]const u8, index.len);
+ for (index, 0..) |start, i| {
+ const end = if (i + 1 < index.len) index[i + 1] - 1 else f.content.len;
+ lines[i] = f.content[start..end];
+ }
+ return lines;
+}
+
+/// Use the file's line index only when `text` is its complete live content.
+/// Edit-buffer fragments and other temporary text retain modal's scan path.
+fn contentIndex(pane: *Pane, text: []const u8) ?[]const usize {
+ const f = if (pane.file) |*file| file else return null;
+ if (text.ptr != f.content.ptr or text.len != f.content.len) return null;
+ return lineIndex(pane.gpa, f) catch null;
+}
+
+pub fn textOffset(pane: *Pane, text: []const u8, cursor: modal.Cursor) usize {
+ const index = contentIndex(pane, text) orelse return modal.hxOff(text, cursor);
+ const row = @min(cursor.row, index.len - 1);
+ const start = index[row];
+ const end = if (row + 1 < index.len) index[row + 1] - 1 else text.len;
+ return @min(start + cursor.col, end);
+}
+
+pub fn textLineStart(pane: *Pane, text: []const u8, row: usize) usize {
+ const index = contentIndex(pane, text) orelse return modal.lineStartOffset(text, row);
+ return if (row >= index.len) text.len else index[row];
+}
+
+pub fn textLineCount(pane: *Pane, text: []const u8) usize {
+ const index = contentIndex(pane, text) orelse return modal.hxLineCount(text);
+ return index.len;
+}
+
+pub fn textPosition(pane: *Pane, text: []const u8, offset: usize) modal.Cursor {
+ const index = contentIndex(pane, text) orelse return modal.hxPos(text, offset);
+ const bounded = @min(offset, text.len);
+ const row = std.sort.upperBound(usize, index, bounded, struct {
+ fn cmp(key: usize, item: usize) std.math.Order {
+ return std.math.order(key, item);
+ }
+ }.cmp) - 1;
+ return .{ .row = row, .col = bounded - index[row] };
+}
+
pub fn open(p: *Pardes, id: usize, path: []const u8, line: usize) !*Pane {
const content = try look.readFile(p.gpa, path);
errdefer p.gpa.free(content);
@@ -113,7 +286,6 @@ pub fn open(p: *Pardes, id: usize, path: []const u8, line: usize) !*Pane {
const total = lineCount(content);
const scroll: usize = if (line > 0 and line <= total) line - 1 else 0;
pane.file = .{ .path = path_copy, .content = content, .scroll = scroll };
- pane.kind = .file;
pane.cur_pinned = true;
pane.cur_row = @intCast(scroll);
// watches follow pane lifetime: this is the only place a real file is read
@@ -122,9 +294,56 @@ pub fn open(p: *Pardes, id: usize, path: []const u8, line: usize) !*Pane {
return pane;
}
+/// Rebuild a dumped file or output buffer. Byte ownership, output identity,
+/// cursor projection, and file watching are all properties of this payload;
+/// column registration and custom tag restoration remain core invariants.
+pub fn restore(p: *Pardes, id: usize, src: dump.Pane) !*Pane {
+ const saved = src.file.?;
+ const content: []u8 = if (saved.content_b64.len > 0)
+ try dump.decodeBytes(p.gpa, saved.content_b64)
+ else
+ try p.gpa.dupe(u8, saved.content);
+ errdefer p.gpa.free(content);
+ const path = try p.gpa.dupe(u8, saved.path);
+ errdefer p.gpa.free(path);
+
+ const output: ?output_pane.Output = if (output_pane.fromWord(saved.origin)) |origin| blk: {
+ var value: output_pane.Output = .{ .from = origin };
+ try output_pane.setArg(&value, saved.origin_arg);
+ break :blk value;
+ } else null;
+
+ const pane = try p.newDocPane(id);
+ pane.file = .{ .path = path, .content = content, .output = output, .scroll = src.scroll };
+ pane.cur_pinned = true;
+ pane.cur_row = @intCast(src.scroll);
+ pane.cols = @max(1, src.cols);
+ pane.rows = @max(1, src.rows);
+ // A restored file is watched exactly like one opened from disk. Its dump
+ // bytes may differ from disk; the first external write reconciles them and
+ // leaves the restored version one undo away. Output buffers have no file.
+ if (output == null) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } });
+ return pane;
+}
+
+/// Release the complete file payload while the owning pane is still installed
+/// (the slot is needed to identify a disappearing file watch). Common pane
+/// overlays and the shared terminal stub remain the core's responsibility.
+pub fn deinit(p: *Pardes, pane: *Pane, file: *State) void {
+ if (file.output == null) for (p.panes, 0..) |slot, id| {
+ if (slot == pane) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } });
+ };
+ p.gpa.free(file.path);
+ p.gpa.free(file.content);
+ if (file.line_starts.len > 0) p.gpa.free(file.line_starts);
+ if (file.highlights.len > 0) p.tree_sitter_gpa.free(file.highlights);
+ for (file.undo[0..file.undo_len]) |snap| p.gpa.free(snap.content);
+ for (file.redo[0..file.redo_len]) |snap| p.gpa.free(snap.content);
+}
+
/// The ONE content swap. Everything that edits a file pane lands here, which
/// is what lets the line index above have a single invalidation point.
-pub fn setContent(p: *Pardes, f: *File, new: []u8) void {
+pub fn setContent(p: *Pardes, f: *State, new: []u8) void {
p.gpa.free(f.content);
f.content = new;
f.revision +%= 1;
@@ -142,7 +361,7 @@ pub fn setContent(p: *Pardes, f: *File, new: []u8) void {
/// undo/redo restores the selection recorded with the snapshot (helix keeps
/// selections in its history transactions), clamped: the content it was taken
/// against may be shorter than the one it is being restored onto.
-pub fn restoreSnap(pane: *Pane, f: *File, snap: FileSnap) void {
+pub fn restoreSnap(pane: *Pane, f: *State, snap: Snapshot) void {
const n = nlines(pane.gpa, f);
const row: usize = @min(@as(usize, @intCast(@max(0, snap.cur_row))), n - 1);
const llen = modal.lineSlice(f.content, row).len;
@@ -155,6 +374,80 @@ pub fn restoreSnap(pane: *Pane, f: *File, snap: FileSnap) void {
pane.ensureCursorVisible();
}
+fn pushHistory(gpa: std.mem.Allocator, slots: []Snapshot, len: *usize, snap: Snapshot) void {
+ if (len.* == slots.len) {
+ gpa.free(slots[0].content);
+ std.mem.copyForwards(Snapshot, slots[0 .. slots.len - 1], slots[1..]);
+ len.* -= 1;
+ }
+ slots[len.*] = snap;
+ len.* += 1;
+}
+
+pub fn pushUndo(p: *Pardes, pane: *Pane) void {
+ const f = if (pane.file) |*file| file else return;
+ if (f.undo_len > 0 and std.mem.eql(u8, f.undo[f.undo_len - 1].content, f.content)) return;
+ const snap: Snapshot = .{
+ .content = p.gpa.dupe(u8, f.content) catch return,
+ .cur_row = pane.cur_row,
+ .cur_col = pane.cur_col,
+ .vsel = pane.vsel,
+ };
+ pushHistory(p.gpa, &f.undo, &f.undo_len, snap);
+ for (f.redo[0..f.redo_len]) |item| p.gpa.free(item.content);
+ f.redo_len = 0;
+}
+
+pub fn undo(p: *Pardes, pane: *Pane) void {
+ const f = if (pane.file) |*file| file else return;
+ if (f.undo_len == 0) return;
+ const current: Snapshot = .{
+ .content = p.gpa.dupe(u8, f.content) catch return,
+ .cur_row = pane.cur_row,
+ .cur_col = pane.cur_col,
+ .vsel = pane.vsel,
+ };
+ pushHistory(p.gpa, &f.redo, &f.redo_len, current);
+ f.undo_len -= 1;
+ const previous = f.undo[f.undo_len];
+ setContent(p, f, previous.content);
+ restoreSnap(pane, f, previous);
+}
+
+pub fn redo(p: *Pardes, pane: *Pane) void {
+ const f = if (pane.file) |*file| file else return;
+ if (f.redo_len == 0) return;
+ const current: Snapshot = .{
+ .content = p.gpa.dupe(u8, f.content) catch return,
+ .cur_row = pane.cur_row,
+ .cur_col = pane.cur_col,
+ .vsel = pane.vsel,
+ };
+ pushHistory(p.gpa, &f.undo, &f.undo_len, current);
+ f.redo_len -= 1;
+ const next = f.redo[f.redo_len];
+ setContent(p, f, next.content);
+ restoreSnap(pane, f, next);
+}
+
+/// Commit an externally rewritten file onto the same undo history as typed
+/// edits. Unsaved work remains one `u` away; there is no third merge state.
+pub fn changed(p: *Pardes, id: u8, bytes: []const u8) void {
+ const pane = p.panes[id] orelse return;
+ const f = if (pane.file) |*file| file else return;
+ if (std.mem.eql(u8, f.content, bytes)) return;
+ const new = p.gpa.dupe(u8, bytes) catch return;
+ pushUndo(p, pane);
+ setContent(p, f, new);
+ // restoreSnap only consumes cursor/selection from this synthetic snapshot.
+ restoreSnap(pane, f, .{
+ .content = undefined,
+ .cur_row = pane.cur_row,
+ .cur_col = pane.cur_col,
+ .vsel = pane.vsel,
+ });
+}
+
/// re-highlight the visible window of any file whose syntax went stale
/// (edit, scroll, load) — visible-range-first so big files stay snappy
pub fn refreshHighlights(p: *Pardes) void {
@@ -165,7 +458,7 @@ pub fn refreshHighlights(p: *Pardes) void {
if (pane.file == null) continue;
const f = &pane.file.?;
if (!f.syntax_dirty) continue;
- if (!p.colors_on) {
+ if (!p.settings.colors) {
if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights);
f.highlights = &.{};
f.highlight_start = 0;
@@ -222,7 +515,7 @@ pub fn refreshHighlights(p: *Pardes) void {
/// column it began, recorded into pane.wrap_line/wrap_col as the rows are
/// built. `wrap_n` stays 0 for an unwrapped body, and that is the value the
/// readers treat as "rows are lines", so the off path never consults an array.
-pub fn bodyText(arena: std.mem.Allocator, pane: *Pane, f: *File, wrap: bool) ![]const u8 {
+pub fn bodyText(arena: std.mem.Allocator, pane: *Pane, f: *State, wrap: bool) ![]const u8 {
// the pane's own content width, less the ONE column the break marker sits
// in — a wrapped row that filled its last cell would have nowhere to say it
// wrapped. 0 means don't wrap. A pane taller than the map refuses too,
@@ -245,7 +538,7 @@ pub fn bodyText(arena: std.mem.Allocator, pane: *Pane, f: *File, wrap: bool) ![]
/// Run the file-body row walk. With no destination it is the exact sizing
/// pass; with one it fills that allocation and records the wrapping map.
-fn fillBody(dst: ?[]u8, pane: *Pane, f: *File, width: usize, record_wrap: bool) usize {
+fn fillBody(dst: ?[]u8, pane: *Pane, f: *State, width: usize, record_wrap: bool) usize {
if (record_wrap) pane.wrap_n = 0;
// start ON the first visible line instead of walking the file to it: this
// walk was O(f.scroll) and recolorSyntax below ran the identical one again
@@ -313,7 +606,7 @@ fn fillBody(dst: ?[]u8, pane: *Pane, f: *File, width: usize, record_wrap: bool)
}
/// line-number gutter: mute the first PREFIX_W columns. Cheap chrome, not
-/// gated on colors_on; selection/cursor passes still win. The cursor row's
+/// gated on settings.colors; selection/cursor passes still win. The cursor row's
/// number takes the tag style (same row math as renderPane's cursor pass) so
/// the eye finds the current line.
pub fn drawGutter(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, active: bool) void {
@@ -327,7 +620,7 @@ pub fn drawGutter(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, bod
// gutter is naming the line you are on, and that is still one line
const cur_line: i32 = if (active and !pane.tag_edit) gcrow else std.math.minInt(i32);
// the body's first row, the way renderPane derives it (Tagbottom)
- const body_y = if (p.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
var vr: u16 = 0;
while (vr < body_h) : (vr += 1) {
const row_line: i32 = if (pane.wrap_n == 0)
@@ -360,7 +653,7 @@ fn synStyle(p: *Pardes, sy: syntax.Syn) ?SynStyle {
/// syntax colors: recolor each content cell from its tree-sitter style byte;
/// content starts after the lineno gutter
-pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *File, r: pardes.Rect, tx: u16, tw: u16, body_h: u16) void {
+pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *State, r: pardes.Rect, tx: u16, tw: u16, body_h: u16) void {
if (f.highlights.len == 0) return;
const s = &p.surface;
const tz_recolor = tracy.zone(@src(), "synRecolor");
@@ -368,7 +661,7 @@ pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *File, r: pardes.Rect, tx: u16,
// indexed start, same as bodyText — an empty tail simply paints nothing
var flines = std.mem.splitScalar(u8, f.content[lineStart(p.gpa, f, f.scroll)..], '\n');
const total = nlines(p.gpa, f);
- const body_y = if (p.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
var vr: u16 = 0;
while (vr < body_h) : (vr += 1) {
// A colour has to land on the byte it belongs to, so this walk reads
@@ -420,3 +713,64 @@ pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *File, r: pardes.Rect, tx: u16,
}
}
}
+
+/// Mark every visible wrapped row which continues onto the next screen row.
+/// This is file chrome: it follows syntax recoloring and precedes the shared
+/// selection passes, so neither source ink nor the marker can win over a user
+/// selection.
+pub fn drawWrapMarkers(
+ p: *Pardes,
+ pane: *const Pane,
+ r: pardes.Rect,
+ tx: u16,
+ tw: u16,
+ body_h: u16,
+ pane_bg: pardes.Color,
+) void {
+ if (tw <= config.PREFIX_W + 1) return;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const marker_fg = p.chromeTheme().lineno;
+ var row: u16 = 0;
+ while (row + 1 < pane.wrap_n and row + 1 < body_h) : (row += 1) {
+ if (pane.wrap_line[row + 1] != pane.wrap_line[row]) continue;
+ p.surface.set(tx + tw - 1, body_y + row, config.wrap_marker, .{
+ .fg = .{ .rgb = marker_fg },
+ .bg = pane_bg,
+ });
+ }
+}
+
+/// Paint one logical file word through the last frame's wrap map. Unlike a
+/// rectangular mouse selection, a path may cross continuation rows without
+/// highlighting unrelated cells between its endpoints.
+pub fn paintWordSelection(
+ p: *Pardes,
+ pane: *Pane,
+ r: pardes.Rect,
+ row: i32,
+ word_lo: i32,
+ word_hi: i32,
+ bg: [3]u8,
+) void {
+ const tx = r.x + config.GUTTER;
+ const tw = r.w - config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ var vr: i32 = 0;
+ while (vr + @as(i32, pardes.BOX_H) < @as(i32, r.h)) : (vr += 1) {
+ const here = pane.wrapAt(vr);
+ if (here.line != row) continue;
+ var hi = word_hi;
+ const next = pane.wrapAt(vr + 1);
+ if (next.line == row) hi = @min(hi, next.at);
+ const lo = @max(word_lo, here.at);
+ if (hi <= lo) continue;
+ const c0 = @as(i32, config.PREFIX_W) + displayOffset(pane, row, here.at, lo);
+ const c1 = @as(i32, config.PREFIX_W) + displayEndOffset(pane, row, here.at, hi - 1);
+ var col = @max(@as(i32, config.PREFIX_W), c0);
+ while (col <= c1 and col < @as(i32, tw)) : (col += 1) {
+ const cell = p.surface.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(vr)));
+ cell.default = false;
+ cell.style.bg = .{ .rgb = bg };
+ }
+ }
+}
diff --git a/src/file_watch.zig b/src/file_watch.zig
new file mode 100644
index 00000000..b616d481
--- /dev/null
+++ b/src/file_watch.zig
@@ -0,0 +1,218 @@
+//! Native file-watch state and the Linux mark/reconcile transaction shared by
+//! the TTY and SDL hosts. Their event loops still own blocking waits, wake
+//! coalescing, and retry scheduling; this file owns the identical synchronous
+//! operation each wake performs. Text buffers already own the bytes whose hash
+//! is their generation. Large PDFs use metadata which changes for in-place and
+//! rename-over saves, avoiding a second whole-document allocation.
+const std = @import("std");
+const builtin = @import("builtin");
+const libc = std.c;
+const linux = std.os.linux;
+const pardes = @import("pardes.zig");
+const look = @import("look.zig");
+const message = @import("message.zig");
+
+pub const Identity = struct {
+ inode: std.Io.File.INode,
+ size: u64,
+ mtime_ns: i96,
+ ctime_ns: i96,
+
+ pub fn eql(a: Identity, b: Identity) bool {
+ return std.meta.eql(a, b);
+ }
+};
+
+/// The last disk generation accepted by a native shell. Text panes already
+/// need their bytes, so their exact content hash is cheapest. PDF panes reopen
+/// the pathname directly and use metadata, avoiding a second whole-document
+/// allocation merely to discover whether MuPDF should reopen it.
+pub const Generation = union(enum) {
+ text: u64,
+ pdf: ?Identity,
+};
+
+/// One native Linux directory mark. The descriptor belongs to the containing
+/// directory because editor-style rename-over saves replace the file inode.
+/// `generation` is the last snapshot the core accepted, not merely one a host
+/// observed; serial prevents a reused pane slot from committing stale data.
+pub const Watch = struct {
+ wd: c_int,
+ serial: u32,
+ generation: Generation,
+};
+
+pub const Table = [pardes.MAX_PANES]?Watch;
+
+/// Mark or unmark one pane pathname. Panes in the same directory share an
+/// inotify descriptor, so removing one pane drops the kernel mark only after
+/// its last user disappears.
+pub fn watchPane(
+ fd: c_int,
+ watches: *Table,
+ id: u8,
+ path: ?[]const u8,
+ serial: u32,
+ generation: Generation,
+) void {
+ if (comptime builtin.os.tag != .linux) return;
+ if (fd < 0) return;
+ if (watches[id]) |old| {
+ var shared = false;
+ for (watches, 0..) |other, i| {
+ const candidate = other orelse continue;
+ if (i != id and candidate.wd == old.wd) shared = true;
+ }
+ if (!shared) _ = libc.inotify_rm_watch(fd, old.wd);
+ watches[id] = null;
+ }
+ const watched_path = path orelse return;
+ const dir = std.fs.path.dirname(watched_path) orelse ".";
+ var dir_buf: [4096:0]u8 = undefined;
+ if (dir.len >= dir_buf.len) return;
+ @memcpy(dir_buf[0..dir.len], dir);
+ dir_buf[dir.len] = 0;
+ // CLOSE_WRITE coalesces one writer's writes; MOVED_TO and CREATE cover
+ // rename-over and delete-then-recreate saves.
+ const mask = linux.IN.CLOSE_WRITE | linux.IN.MOVED_TO | linux.IN.CREATE | linux.IN.ONLYDIR;
+ const wd = libc.inotify_add_watch(fd, dir_buf[0..dir.len :0], mask);
+ if (wd < 0) return;
+ watches[id] = .{ .wd = wd, .serial = serial, .generation = generation };
+}
+
+/// Reconcile one marked pane. A true result requests an immediate second pass:
+/// the PDF pathname changed between the stat before MuPDF reopened it and the
+/// stat after, so committing either identity would lose a generation.
+pub fn reloadPane(
+ core: *pardes.Pardes,
+ io: std.Io,
+ gpa: std.mem.Allocator,
+ watches: *Table,
+ id: usize,
+ announce: bool,
+) bool {
+ const watched = watches[id] orelse return false;
+ const pane = core.panes[id] orelse return false;
+ if (pane.serial != watched.serial) return false;
+
+ if (pane.file) |file| {
+ const bytes = look.readFile(gpa, file.path) catch return false;
+ defer gpa.free(bytes);
+ const hash = std.hash.Wyhash.hash(0, bytes);
+ switch (watched.generation) {
+ .text => |accepted| if (accepted == hash) return false,
+ .pdf => {},
+ }
+ if (!core.reloadWatchedFile(@intCast(id), bytes)) return false;
+ const current = core.panes[id] orelse return false;
+ if (current.serial != watched.serial) return false;
+ const current_file = current.file orelse return false;
+ if (!std.mem.eql(u8, current_file.content, bytes)) return false;
+ if (watches[id]) |*live| {
+ if (live.serial == watched.serial)
+ live.generation = .{ .text = hash };
+ }
+ if (announce) {
+ var msg_buf: [256]u8 = undefined;
+ core.setMessage(id, message.stamp(&msg_buf, "reloaded", current_file.path));
+ }
+ return false;
+ }
+
+ const path = pane.pdfPath() orelse return false;
+ const before = identify(io, path) catch return false;
+ switch (watched.generation) {
+ .pdf => |accepted| if (accepted) |identity| if (identity.eql(before)) return false,
+ .text => {},
+ }
+ if (!core.reloadWatchedFile(@intCast(id), &.{})) return false;
+ const current = core.panes[id] orelse return false;
+ if (current.serial != watched.serial) return false;
+ const current_path = current.pdfPath() orelse return false;
+ const after = identify(io, current_path) catch return true;
+ if (!before.eql(after)) return true;
+ if (watches[id]) |*live| {
+ if (live.serial == watched.serial)
+ live.generation = .{ .pdf = after };
+ }
+ if (announce) {
+ var msg_buf: [256]u8 = undefined;
+ core.setMessage(id, message.stamp(&msg_buf, "reloaded", current_path));
+ }
+ return false;
+}
+
+/// Install the directory mark before reconciling the open bytes/path. This
+/// closes the drain-time race without requiring another filesystem event.
+pub fn applyEffect(
+ core: *pardes.Pardes,
+ io: std.Io,
+ gpa: std.mem.Allocator,
+ fd: c_int,
+ watches: *Table,
+ id: u8,
+ on: bool,
+) bool {
+ var path: ?[]const u8 = null;
+ var serial: u32 = 0;
+ var generation: Generation = .{ .text = 0 };
+ if (on) if (core.panes[id]) |pane| {
+ serial = pane.serial;
+ if (pane.file) |file| {
+ path = file.path;
+ generation = .{ .text = std.hash.Wyhash.hash(0, file.content) };
+ } else if (pane.pdfPath()) |pdf_path| {
+ path = pdf_path;
+ generation = .{ .pdf = null };
+ }
+ };
+ watchPane(fd, watches, id, path, serial, generation);
+ return on and watches[id] != null and reloadPane(core, io, gpa, watches, id, false);
+}
+
+/// Reconcile every mark after a coalesced directory wake.
+pub fn reloadChanged(
+ core: *pardes.Pardes,
+ io: std.Io,
+ gpa: std.mem.Allocator,
+ watches: *Table,
+) bool {
+ var retry = false;
+ for (watches, 0..) |slot, id| {
+ if (slot != null) retry = reloadPane(core, io, gpa, watches, id, true) or retry;
+ }
+ return retry;
+}
+
+pub fn identify(io: std.Io, path: []const u8) !Identity {
+ const stat = try std.Io.Dir.cwd().statFile(io, path, .{});
+ if (stat.kind != .file) return error.NotFile;
+ return .{
+ .inode = stat.inode,
+ .size = stat.size,
+ .mtime_ns = stat.mtime.nanoseconds,
+ .ctime_ns = stat.ctime.nanoseconds,
+ };
+}
+
+test "file identity changes for in-place and rename-over writes" {
+ const io = std.testing.io;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "a" });
+ var path_buf: [256]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/watched", .{tmp.sub_path});
+ const before = try identify(io, path);
+ try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "longer" });
+ const after = try identify(io, path);
+ try std.testing.expect(!before.eql(after));
+
+ // The dominant editor save shape keeps size/content arbitrary but swaps
+ // the inode. Keep this replacement the same length to prove identity does
+ // not rely on size changing.
+ try tmp.dir.writeFile(io, .{ .sub_path = "replacement", .data = "swap!!" });
+ try tmp.dir.rename("replacement", tmp.dir, "watched", io);
+ const replaced = try identify(io, path);
+ try std.testing.expect(after.inode != replaced.inode);
+ try std.testing.expect(!after.eql(replaced));
+}
diff --git a/src/fonts.zig b/src/fonts.zig
index 1e82956e..4cfba44c 100644
--- a/src/fonts.zig
+++ b/src/fonts.zig
@@ -58,29 +58,6 @@ const max_depth = 8;
pub const Font = struct { name: []const u8, path: []const u8 };
-/// The font the shell should be wearing, as a PATH — written by the Font
-/// builtin, taken by the shell on its next pass through the loop. Exactly the
-/// shape Pardes.restore_req has, including the buffer behind it: the request
-/// outlives the scratch arena the walk found the path in.
-///
-/// A module var rather than a field on Pardes because the core does not have a
-/// font, has no opinion about one, and on every other platform does not have
-/// this file either — a field would be state the tty build carries around to
-/// never touch.
-pub var want_buf: [4096]u8 = undefined;
-pub var want: ?[]const u8 = null;
-
-/// ...and the same request as a NAME, for the Debug overlay to show. A slice
-/// of `want_buf` rather than a second buffer: the stem of the path IS the name
-/// the picker listed, and the only writer of one is the writer of the other.
-///
-/// What was CHOSEN, not what is worn: the shell may turn a file down (gui.zig
-/// keeps the face that works when FreeType refuses one) and it alone knows
-/// what it booted in — embedded Adwaita Mono under SDL, the system monospace
-/// face under AppKit. Empty means nothing has been chosen and the shell is
-/// still wearing whichever of those it is.
-pub var chosen: []const u8 = "";
-
/// Optional fallback faces, in preference order after the always-available
/// embedded Adwaita Mono face. Keep text faces before symbol faces so ordinary
/// letters retain terminal-like metrics; DejaVu Sans is the final broad,
@@ -99,22 +76,27 @@ pub const fallback_names = [_][]const u8{
pub fn list(arena: std.mem.Allocator, want_name: ?[]const u8) []const Font {
if (want_name) |name| {
const wanted = [1][]const u8{name};
- return scan(arena, &wanted);
+ return scan(arena, &wanted, true);
}
- return scan(arena, null);
+ return scan(arena, null, true);
}
/// Installed members of `fallback_names`, returned in preference order. This
/// is runtime discovery only: no local font path or exploration result enters
/// the build, and a machine with none simply uses embedded Adwaita Mono.
pub fn fallbacks(arena: std.mem.Allocator) []const Font {
- return scan(arena, &fallback_names);
+ return scan(arena, &fallback_names, false);
}
/// One bounded directory walk for the picker, one named font, or the fallback
-/// chain. `wanted == null` means every monospace face; named scans accept
-/// proportional symbol/general faces and preserve the requested priority.
-fn scan(arena: std.mem.Allocator, wanted: ?[]const []const u8) []const Font {
+/// chain. User-facing lists always require a fixed-pitch face, including an
+/// exact `Font <name>` request. The fallback chain opts out deliberately so a
+/// broad symbol face may fill glyphs the primary grid face does not contain.
+fn scan(
+ arena: std.mem.Allocator,
+ wanted: ?[]const []const u8,
+ require_monospaced: bool,
+) []const Font {
// ...from the arena, not the stack: 4096 faces of {name, path} is 128 KiB,
// which is a fine thing to hand back to an arena that resets at the end of
// the keystroke and not a thing to put on a call stack. An allocator that
@@ -123,8 +105,8 @@ fn scan(arena: std.mem.Allocator, wanted: ?[]const []const u8) []const Font {
const found = arena.alloc(Font, max_fonts) catch return &.{};
var found_len: usize = 0;
// Zig 0.16 moved the filesystem behind std.Io; the blocking
- // single-threaded implementation is the synchronous walk a sans-IO core
- // wants, the same one look.find uses.
+ // single-threaded implementation is the synchronous walk the core uses,
+ // the same one look.find uses.
const io = std.Io.Threaded.global_single_threaded.io();
const home: []const u8 = if (libc.getenv("HOME")) |h| std.mem.span(h) else "";
var root_buf: [512]u8 = undefined;
@@ -179,7 +161,7 @@ fn scan(arena: std.mem.Allocator, wanted: ?[]const []const u8) []const Font {
// e.path points into the walker's own buffer and dies at the next
// next(), so the path is spelled out here and copied below
const path = std.fmt.bufPrintSentinel(&path_buf, "{s}/{s}", .{ root, e.path }, 0) catch continue;
- if (wanted == null and !monospaced(path)) continue;
+ if (require_monospaced and !monospaced(path)) continue;
found[found_len] = .{
.name = arena.dupe(u8, name) catch break,
.path = arena.dupe(u8, path) catch break,
diff --git a/src/gui/crt.zig b/src/gui/crt.zig
index 9cf81593..2a5ea7e4 100644
--- a/src/gui/crt.zig
+++ b/src/gui/crt.zig
@@ -6,38 +6,96 @@
const std = @import("std");
-/// MUST match BARREL in shaders/crt.frag.glsl
-pub const barrel: f32 = 0.012;
+/// MUST match BARREL in shaders/crt.frag.glsl.
+pub const barrel: f32 = 0.016;
pub const Point = struct { x: f32, y: f32 };
+pub const Effects = struct { crt: bool = false, ripple: bool = false, glitch: bool = false };
+pub const Frame = struct { effects: Effects = .{}, time_seconds: f32 = 0 };
-/// physical window pixels -> the scene pixels the shader displays there,
-/// clamped to the window. The shader's phosphor quantization and chromatic
-/// offset displace at most a few pixels — well under a cell — and are
-/// ignored here.
-pub fn map(x: f32, y: f32, w: f32, h: f32) Point {
- const ccx = x / w * 2.0 - 1.0;
- const ccy = y / h * 2.0 - 1.0;
- const s = 1.0 + barrel * (ccx * ccx + ccy * ccy);
+fn fract(x: f32) f32 {
+ return x - @floor(x);
+}
+
+fn smoothstep(lo: f32, hi: f32, value: f32) f32 {
+ const t = std.math.clamp((value - lo) / (hi - lo), 0, 1);
+ return t * t * (3 - 2 * t);
+}
+
+/// Match `sceneUv` in crt.frag.glsl. This is a forward screen→sample mapping:
+/// a physical pointer is sent to the logical scene pixel displayed under it.
+pub fn mapScene(x: f32, y: f32, w: f32, h: f32, effects: Effects, time_seconds: f32) ?Point {
+ const safe_w = @max(1, w);
+ const safe_h = @max(1, h);
+ const ccx = x / safe_w * 2.0 - 1.0;
+ const ccy = y / safe_h * 2.0 - 1.0;
+ const s = 1.0 + barrel * (ccx * ccx + ccy * ccy) * @as(f32, @floatFromInt(@intFromBool(effects.crt)));
+ var u = ccx * s * 0.5 + 0.5;
+ var v = ccy * s * 0.5 + 0.5;
+ // The CRT shader paints no scene when this barrel-mapped coordinate lies
+ // on or beyond the tube edge. There is no logical edge cell under those
+ // black pixels, so input must be inert there rather than clamped.
+ if (effects.crt and (u <= 0 or u >= 1 or v <= 0 or v >= 1)) return null;
+ if (effects.ripple) {
+ const cx = u - 0.5;
+ const cy = v - 0.5;
+ const radius = @sqrt(cx * cx + cy * cy);
+ const envelope = 1.0 - smoothstep(0.04, 0.72, radius);
+ const wave = @sin(radius * 42.0 - time_seconds * 5.2);
+ const len = @max(radius, 0.00001);
+ u += cx / len * wave * envelope * 0.0032;
+ v += cy / len * wave * envelope * 0.0032;
+ }
+ if (effects.glitch) {
+ const band = @floor(v * 96.0);
+ const tick = @floor(time_seconds * 13.0);
+ const seed = noiseAt(band, tick);
+ const tear = smoothstep(0.955, 1.0, seed);
+ u += (noiseAt(seed, band) - 0.5) * tear * 0.020;
+ }
return .{
- .x = std.math.clamp((ccx * s + 1.0) * 0.5 * w, 0, w - 1),
- .y = std.math.clamp((ccy * s + 1.0) * 0.5 * h, 0, h - 1),
+ .x = std.math.clamp(u * safe_w, 0, safe_w - 1),
+ .y = std.math.clamp(v * safe_h, 0, safe_h - 1),
};
}
+fn noiseAt(x: f32, y: f32) f32 {
+ var qx = fract(x * 0.1031);
+ var qy = fract(y * 0.1031);
+ var qz = fract(x * 0.1031);
+ const d = qx * (qy + 33.33) + qy * (qz + 33.33) + qz * (qx + 33.33);
+ qx += d;
+ qy += d;
+ qz += d;
+ return fract((qx + qy) * qz);
+}
+
test "map matches the shader's barrel formula" {
// center is a fixed point
- try std.testing.expectApproxEqAbs(@as(f32, 500), map(500, 250, 1000, 500).x, 0.001);
- try std.testing.expectApproxEqAbs(@as(f32, 250), map(500, 250, 1000, 500).y, 0.001);
- // corners: cc=(±1,±1), r2=2, scale 1.024 pushes past the tube -> clamped
- try std.testing.expectEqual(@as(f32, 0), map(0, 0, 1000, 500).x);
- try std.testing.expectEqual(@as(f32, 0), map(0, 0, 1000, 500).y);
- try std.testing.expectEqual(@as(f32, 999), map(1000, 500, 1000, 500).x);
- try std.testing.expectEqual(@as(f32, 499), map(1000, 500, 1000, 500).y);
- // (100,100) of 1000x500: cc=(-0.8,-0.6), r2=1.0, s=1.012 ->
- // cc'=(-0.8096,-0.6072) -> (95.2, 98.2)
- try std.testing.expectApproxEqAbs(@as(f32, 95.2), map(100, 100, 1000, 500).x, 0.01);
- try std.testing.expectApproxEqAbs(@as(f32, 98.2), map(100, 100, 1000, 500).y, 0.01);
- // (900,250): cc=(0.8,0), r2=0.64, s=1.00768 -> x'=903.072
- try std.testing.expectApproxEqAbs(@as(f32, 903.072), map(900, 250, 1000, 500).x, 0.01);
+ const center = mapScene(500, 250, 1000, 500, .{ .crt = true }, 0).?;
+ try std.testing.expectApproxEqAbs(@as(f32, 500), center.x, 0.001);
+ try std.testing.expectApproxEqAbs(@as(f32, 250), center.y, 0.001);
+ // Corners barrel beyond the tube and are black/inert in the shader.
+ try std.testing.expect(mapScene(0, 0, 1000, 500, .{ .crt = true }, 0) == null);
+ try std.testing.expect(mapScene(1000, 500, 1000, 500, .{ .crt = true }, 0) == null);
+ // (100,100) of 1000x500: cc=(-0.8,-0.6), r2=1.0, s=1.016 ->
+ // cc'=(-0.8128,-0.6096) -> (93.6, 97.6)
+ const near_edge = mapScene(100, 100, 1000, 500, .{ .crt = true }, 0).?;
+ try std.testing.expectApproxEqAbs(@as(f32, 93.6), near_edge.x, 0.01);
+ try std.testing.expectApproxEqAbs(@as(f32, 97.6), near_edge.y, 0.01);
+ // (900,250): cc=(0.8,0), r2=0.64, s=1.01024 -> x'=904.096
+ const side = mapScene(900, 250, 1000, 500, .{ .crt = true }, 0).?;
+ try std.testing.expectApproxEqAbs(@as(f32, 904.096), side.x, 0.01);
+}
+
+test "scene mapping composes ripple and deterministic glitch" {
+ const plain = mapScene(300, 200, 800, 600, .{}, 2.5).?;
+ try std.testing.expectApproxEqAbs(@as(f32, 300), plain.x, 0.001);
+ try std.testing.expectApproxEqAbs(@as(f32, 200), plain.y, 0.001);
+ const ripple = mapScene(300, 200, 800, 600, .{ .ripple = true }, 2.5).?;
+ try std.testing.expect(ripple.x != plain.x or ripple.y != plain.y);
+ try std.testing.expectEqual(
+ mapScene(300, 200, 800, 600, .{ .glitch = true }, 2.5).?,
+ mapScene(300, 200, 800, 600, .{ .glitch = true }, 2.5).?,
+ );
}
diff --git a/src/gui/font.c b/src/gui/font.c
index e94480b5..9fc5e039 100644
--- a/src/gui/font.c
+++ b/src/gui/font.c
@@ -60,6 +60,17 @@ UIFont *ui_font_new(const uint8_t *data, int32_t len) {
return f;
}
+const char *ui_font_name(UIFont *f) {
+ if (!f || !f->face)
+ return NULL;
+ const char *postscript = FT_Get_Postscript_Name(f->face);
+ if (postscript && postscript[0])
+ return postscript;
+ if (f->face->family_name && f->face->family_name[0])
+ return f->face->family_name;
+ return NULL;
+}
+
int ui_font_has_glyph(UIFont *f, int32_t cp) {
if (!f || cp < 0)
return 0;
diff --git a/src/gui/font.h b/src/gui/font.h
index 050fbeb0..2aff8b1a 100644
--- a/src/gui/font.h
+++ b/src/gui/font.h
@@ -15,6 +15,11 @@ typedef struct UIFont UIFont;
// lifetime of the font. Returns NULL on failure.
UIFont *ui_font_new(const uint8_t *data, int32_t len);
+// The face FreeType actually accepted. Prefer its PostScript name (the same
+// identity the macOS shell reports); fall back to the family name when the
+// font has no PostScript table. Borrowed for the UIFont lifetime.
+const char *ui_font_name(UIFont *f);
+
// Whether the selected Unicode charmap maps `cp` to a real glyph. Glyph zero is
// the face's .notdef box and deliberately does not count for fallback choice.
int ui_font_has_glyph(UIFont *f, int32_t cp);
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index f36de939..2c556895 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -5,11 +5,6 @@
//! FreeType-hinted R8 atlas. Test modes: PARDES_TEST_GRID=1 is headless (no SDL,
//! stdin escape sequences in, text grid frames out); PARDES_TEST=1 keeps the
//! real renderer, drives input from stdin, and captures frames to PPM.
-//!
-//! The same file is the BROWSER shell (-Dplatform=web, wasm32-emscripten):
-//! WebGL2 instead of the GPU device, an embedded dump instead of ptys, and a
-//! requestAnimationFrame callback instead of a blocking loop. Comptime
-//! branches on is_emscripten, the way the stdlib branches on os.tag.
const std = @import("std");
const builtin = @import("builtin");
const posix = std.posix;
@@ -21,20 +16,19 @@ const pardes = @import("../pardes.zig");
const config = @import("../config.zig");
const look = @import("../look.zig");
const message = @import("../message.zig");
+const file_watch = @import("../file_watch.zig");
const deck = @import("deck.zig");
const crt = @import("crt.zig");
const fonts = @import("../fonts.zig"); // the Font builtin's half of the seam
const selection_pipe = @import("../selection_pipe.zig");
-const is_emscripten = builtin.os.tag == .emscripten;
-const temp_file = if (is_emscripten) struct {} else @import("../temp_file.zig");
-const shell_bin = if (is_emscripten) struct {} else @import("../shell_bin.zig");
-const nested = if (is_emscripten) struct {} else @import("../nested.zig");
+const temp_file = @import("../temp_file.zig");
+const shell_bin = @import("../shell_bin.zig");
+const nested = @import("../nested.zig");
pub const c = @cImport({
@cDefine("SDL_DISABLE_OLD_NAMES", "1");
@cInclude("SDL3/SDL.h");
- if (is_emscripten) @cInclude("GLES3/gl3.h");
@cInclude("font.h");
});
@@ -43,30 +37,20 @@ extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_i
extern "c" fn chdir(path: [*:0]const u8) c_int;
extern "c" fn _exit(status: c_int) noreturn;
extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
-extern "c" fn emscripten_get_element_css_size(target: [*:0]const u8, width: *f64, height: *f64) c_int;
-const EMSCRIPTEN_RESULT_SUCCESS: c_int = 0;
-
// TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize)
const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467));
const log = std.log.scoped(.gui);
const font_ttf = @embedFile("AdwaitaMono-Regular.ttf");
-const vert_spv = if (is_emscripten) "" else @embedFile("ui.vert.spv");
-const frag_spv = if (is_emscripten) "" else @embedFile("ui.frag.spv");
-const overlay_vert_spv = if (is_emscripten) "" else @embedFile("overlay.vert.spv");
-const overlay_frag_spv = if (is_emscripten) "" else @embedFile("overlay.frag.spv");
-const image_vert_spv = if (is_emscripten) "" else @embedFile("image.vert.spv");
-const image_frag_spv = if (is_emscripten) "" else @embedFile("image.frag.spv");
-const crt_vert_spv = if (is_emscripten) "" else @embedFile("crt.vert.spv");
-const crt_frag_spv = if (is_emscripten) "" else @embedFile("crt.frag.spv");
-// web: GL ES shader SOURCES, compiled by WebGL at init (no SPIR-V there)
-const vert_glsl_es = if (is_emscripten) @embedFile("ui.vert.es.glsl") else "";
-const frag_glsl_es = if (is_emscripten) @embedFile("ui.frag.es.glsl") else "";
-const overlay_vert_glsl_es = if (is_emscripten) @embedFile("overlay.vert.es.glsl") else "";
-const overlay_frag_glsl_es = if (is_emscripten) @embedFile("overlay.frag.es.glsl") else "";
-// web: no ptys — the whole state replays from a dump embedded at build time
-const embedded_dump = if (is_emscripten) @embedFile("embedded.dump.zon") else "";
+const vert_spv = @embedFile("ui.vert.spv");
+const frag_spv = @embedFile("ui.frag.spv");
+const overlay_vert_spv = @embedFile("overlay.vert.spv");
+const overlay_frag_spv = @embedFile("overlay.frag.spv");
+const image_vert_spv = @embedFile("image.vert.spv");
+const image_frag_spv = @embedFile("image.frag.spv");
+const crt_vert_spv = @embedFile("crt.vert.spv");
+const crt_frag_spv = @embedFile("crt.frag.spv");
/// What the shell paints where the core painted nothing: behind the grid — the
/// strip left over when the window is not a whole number of cells tall — and
@@ -120,15 +104,42 @@ const p9_arrow_mask = blk: {
break :blk m;
};
-// web: glyphs raster at 1.5x and the canvas backs at CSS×1.5 so text stays
-// sharp on scaled canvases (the prototype's -Dweb-render-scale, which never
-// shipped non-default — hardcoded here instead of a build option).
-const web_render_scale: f32 = 1.5;
-
-// 2048 fits ~2500 glyphs at the 2x native cell (~20x40); web needs it too
+// 2048 fits ~2500 glyphs at the 2x native cell (~20x40).
const atlas_w: u32 = 2048;
const atlas_h: u32 = 2048;
const Slot = struct { u: u32, v: u32 };
+const GlyphKey = struct {
+ codepoint: u32,
+ role: pardes.FontRole,
+};
+
+/// Raster parameters for the smaller tagline face. The atlas slot remains a
+/// body-sized cell: only the glyph shrinks, and its own line box is centered
+/// vertically inside that fixed geometry.
+const TaglineRaster = struct {
+ scale: f32,
+ baseline: i32,
+ height: u32,
+};
+
+fn taglineRaster(font: *c.UIFont, body_px: f32, body_cell_h: u32, percent: u8) TaglineRaster {
+ const px = body_px * @as(f32, @floatFromInt(percent)) / 100.0;
+ const scale = c.ui_font_scale_for_height(font, px);
+ var ignored_w: c_int = 1;
+ var own_h: c_int = 1;
+ var own_ascent: c_int = 1;
+ c.ui_font_cell_metrics(font, scale, &ignored_w, &own_h, &own_ascent);
+ const fixed_h: c_int = @intCast(body_cell_h);
+ const band_h = std.math.clamp(own_h, 1, fixed_h);
+ return .{
+ .scale = scale,
+ // Tagline slots use only their first `height` texel rows. Centering is
+ // done by the cell quad, not baked into this baseline, so changing the
+ // live percentage changes the chrome band as well as the glyph ink.
+ .baseline = std.math.clamp(own_ascent, 1, band_h),
+ .height = @intCast(band_h),
+ };
+}
const max_fallback_fonts = 1 + fonts.fallback_names.len;
const LoadedFallback = struct {
face: *c.UIFont,
@@ -146,7 +157,8 @@ const touch_scroll_tick: f32 = 0.02;
/// SDL input can wake the loop faster than display cadence, so ticking once
/// per pass would make animation duration depend on pty traffic or mouse
/// motion. This monotonic gate keeps it near 60 Hz without sleeping the event
-/// loop; the first frame after activation displays the exact source palette.
+/// loop. It is consulted only AFTER a successful presentation: the current
+/// sample reaches the screen before the display interval may advance it.
const AnimationClock = struct {
next_ns: u64 = 0,
@@ -165,6 +177,21 @@ const AnimationClock = struct {
}
};
+/// Commit exactly what the GPU accepted, then spend at most one display-clock
+/// step. The order is the invariant: a persistent scene effect may keep
+/// `AnimationClock` armed indefinitely, but it still cannot consume frame zero
+/// of a panel/theme/hover animation created earlier in this loop before that
+/// source sample is rendered.
+fn finishPresentedAnimationFrame(
+ clock: *AnimationClock,
+ core: *pardes.Pardes,
+ tracks: []const pardes.panel_animation.Track,
+ now_ns: u64,
+) void {
+ core.acknowledgePanelPresentation(tracks);
+ if (clock.due(core.animationActive(), now_ns)) core.update(.tick);
+}
+
test "GUI animation clock is active-only and cadence gated" {
var clock: AnimationClock = .{};
try std.testing.expect(!clock.due(false, 100));
@@ -175,9 +202,43 @@ test "GUI animation clock is active-only and cadence gated" {
try std.testing.expectEqual(@as(u64, 0), clock.next_ns);
}
-/// Web tap-vs-scroll heuristic, measured after normalized coordinates have
-/// been mapped into backing pixels (12px = 8 CSS px at web_render_scale).
-const touch_tap_slop_px: f32 = 12.0;
+test "a persistent scene presents a new panel's frame zero before advancing it" {
+ const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer core.deinit();
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ var clock: AnimationClock = .{};
+
+ // The persistent effect arms the cadence gate before any panel animation
+ // exists—the state which used to make a due tick skip a new track's first
+ // sample in the old tick-before-render loop.
+ core.settings.scene_effects.crt = true;
+ const scene_frame = try core.render(arena.allocator());
+ finishPresentedAnimationFrame(&clock, core, scene_frame.panelTracks(), 100);
+ try std.testing.expectEqual(@as(u64, 100 + pardes.animation.frame_ns), clock.next_ns);
+
+ core.settings.panel_transition = .slide;
+ core.update(.{ .command = "Newcol" });
+ _ = arena.reset(.retain_capacity);
+ const first = try core.render(arena.allocator());
+ const track_count = first.panelTracks().len;
+ try std.testing.expect(track_count > 0);
+ for (first.panelTracks()) |track| try std.testing.expectEqual(@as(u16, 0), track.frame);
+
+ // The cadence is already due, but finishPresentedAnimationFrame consumes
+ // it only after acknowledging the frame-zero records above.
+ finishPresentedAnimationFrame(
+ &clock,
+ core,
+ first.panelTracks(),
+ 100 + pardes.animation.frame_ns,
+ );
+ _ = arena.reset(.retain_capacity);
+ const second = try core.render(arena.allocator());
+ try std.testing.expectEqual(track_count, second.panelTracks().len);
+ for (second.panelTracks()) |track| try std.testing.expectEqual(@as(u16, 1), track.frame);
+}
+
const max_overlay_vertices: usize = 6 + touch_click_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices);
// Ctrl+ / Ctrl-: how far one press moves g.px, and the two sizes it stops at.
@@ -203,9 +264,8 @@ const font_px_max: f32 = 72.0;
test "every font size step moves the cell, and the ends are reachable exactly" {
const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?;
defer c.ui_font_free(font);
- // walk the WHOLE range rather than one ladder: dispatch starts from 27
- // native and 18 web, so both the odd and the even rungs get pressed, and
- // a stall on either is a dead keystroke for somebody.
+ // Walk the whole range rather than only the 27px runtime ladder, so both
+ // odd and even rungs are exercised and a rounding stall cannot hide.
var px = font_px_min + font_px_step;
while (px <= font_px_max) : (px += 1.0) {
var cw: c_int = 0;
@@ -268,6 +328,101 @@ test "FreeType atlas raster is grayscale, bounded, and clears blank cells" {
);
}
+const InkBounds = struct { min_x: usize, min_y: usize, max_x: usize, max_y: usize };
+
+fn inkBounds(bitmap: []const u8, stride: usize, w: usize, h: usize) ?InkBounds {
+ var bounds: ?InkBounds = null;
+ for (0..h) |y| for (0..w) |x| {
+ if (bitmap[y * stride + x] == 0) continue;
+ if (bounds) |*b| {
+ b.min_x = @min(b.min_x, x);
+ b.min_y = @min(b.min_y, y);
+ b.max_x = @max(b.max_x, x);
+ b.max_y = @max(b.max_y, y);
+ } else bounds = .{ .min_x = x, .min_y = y, .max_x = x, .max_y = y };
+ };
+ return bounds;
+}
+
+test "tagline glyph shrinks and stays centered in body cell geometry" {
+ const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?;
+ defer c.ui_font_free(font);
+
+ const body_scale = c.ui_font_scale_for_height(font, 27.0);
+ var cell_w: c_int = 1;
+ var cell_h: c_int = 1;
+ var body_ascent: c_int = 1;
+ c.ui_font_cell_metrics(font, body_scale, &cell_w, &cell_h, &body_ascent);
+ const tag = taglineRaster(font, 27.0, @intCast(cell_h), config.gui_tagline_font_percent);
+ try std.testing.expect(tag.scale < body_scale);
+
+ const stride: c_int = 128;
+ var body: [128 * 128]u8 = @splat(0);
+ var tagline: [128 * 128]u8 = @splat(0);
+ try std.testing.expectEqual(@as(c_int, 1), c.ui_font_raster(
+ font,
+ body_scale,
+ 'H',
+ &body,
+ stride,
+ cell_w,
+ cell_h,
+ body_ascent,
+ ));
+ try std.testing.expectEqual(@as(c_int, 1), c.ui_font_raster(
+ font,
+ tag.scale,
+ 'H',
+ &tagline,
+ stride,
+ cell_w,
+ @intCast(tag.height),
+ tag.baseline,
+ ));
+ const b = inkBounds(&body, @intCast(stride), @intCast(cell_w), @intCast(cell_h)).?;
+ const t = inkBounds(&tagline, @intCast(stride), @intCast(cell_w), @intCast(cell_h)).?;
+ try std.testing.expect(t.max_x - t.min_x < b.max_x - b.min_x);
+ try std.testing.expect(t.max_y - t.min_y < b.max_y - b.min_y);
+ // Compare doubled centers to avoid floating point. Hinting may move either
+ // glyph by a pixel, but the smaller one must not hug an edge of the slot.
+ const body_center_x: isize = @intCast(b.min_x + b.max_x);
+ const tag_center_x: isize = @intCast(t.min_x + t.max_x);
+ const body_center_y: isize = @intCast(b.min_y + b.max_y);
+ const centered_top: isize = @intCast((@as(u32, @intCast(cell_h)) - tag.height) / 2);
+ const tag_center_y: isize = @as(isize, @intCast(t.min_y + t.max_y)) + centered_top * 2;
+ try std.testing.expect(@abs(tag_center_x - body_center_x) <= 2);
+ try std.testing.expect(@abs(tag_center_y - body_center_y) <= 4);
+ try std.testing.expect(tag.height < @as(u32, @intCast(cell_h)));
+}
+
+test "tagline percentage changes measured band height without body metrics" {
+ const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?;
+ defer c.ui_font_free(font);
+ const body_scale = c.ui_font_scale_for_height(font, 27.0);
+ var cell_w: c_int = 1;
+ var cell_h: c_int = 1;
+ var ascent: c_int = 1;
+ c.ui_font_cell_metrics(font, body_scale, &cell_w, &cell_h, &ascent);
+ const small = taglineRaster(font, 27.0, @intCast(cell_h), 40);
+ const configured = taglineRaster(font, 27.0, @intCast(cell_h), 82);
+ const full = taglineRaster(font, 27.0, @intCast(cell_h), 100);
+ try std.testing.expect(small.height < configured.height);
+ try std.testing.expect(configured.height <= full.height);
+ try std.testing.expect(full.height <= @as(u32, @intCast(cell_h)));
+ // The body measurement is an input and remains the same one-row grid.
+ try std.testing.expectEqual(body_scale, c.ui_font_scale_for_height(font, 27.0));
+}
+
+test "glyph atlas cache separates body and tagline roles" {
+ var glyphs = std.AutoHashMap(GlyphKey, Slot).init(std.testing.allocator);
+ defer glyphs.deinit();
+ try glyphs.put(.{ .codepoint = 'A', .role = .body }, .{ .u = 0, .v = 0 });
+ try glyphs.put(.{ .codepoint = 'A', .role = .tagline }, .{ .u = 10, .v = 0 });
+ try std.testing.expectEqual(@as(usize, 2), glyphs.count());
+ try std.testing.expectEqual(@as(u32, 0), glyphs.get(.{ .codepoint = 'A', .role = .body }).?.u);
+ try std.testing.expectEqual(@as(u32, 10), glyphs.get(.{ .codepoint = 'A', .role = .tagline }).?.u);
+}
+
test "installed Nerd Symbols fallback covers Yazi directory icons" {
try std.testing.expectEqualStrings("SymbolsNerdFont-Regular", fonts.fallback_names[2]);
@@ -302,6 +457,18 @@ const CellInstance = extern struct {
br: f32,
bg: f32,
bb: f32,
+ panel_x0: f32 = 0,
+ panel_y0: f32 = 0,
+ panel_x1: f32 = 0,
+ panel_y1: f32 = 0,
+ present_x0: f32 = 0,
+ present_y0: f32 = 0,
+ present_x1: f32 = 0,
+ present_y1: f32 = 0,
+ effect: u32 = 0,
+ progress_bits: u32 = @bitCast(@as(f32, 1)),
+ serial: u32 = 0,
+ cell_coord: u32 = 0,
};
const OverlayVertex = extern struct { x: f32, y: f32, r: f32, g: f32, b: f32, a: f32 };
@@ -316,9 +483,134 @@ const ImageInstance = extern struct {
v0: f32,
u1: f32,
v1: f32,
+ panel_x0: f32 = 0,
+ panel_y0: f32 = 0,
+ panel_x1: f32 = 0,
+ panel_y1: f32 = 0,
+ present_x0: f32 = 0,
+ present_y0: f32 = 0,
+ present_x1: f32 = 0,
+ present_y1: f32 = 0,
+ effect: u32 = 0,
+ progress_bits: u32 = @bitCast(@as(f32, 1)),
+ serial: u32 = 0,
+ cell_coord: u32 = 0,
};
const initial_image_capacity: u32 = pardes.MAX_PANES;
+const old_layer_bit: u32 = 0x8000_0000;
+
+/// Image placement retained across pane destruction. Deliberately does not
+/// contain ImagePlace.rgba: the producer owns those bytes, while the renderer
+/// retains only the already-uploaded GPU texture identified by `key`.
+const SavedImagePlace = struct {
+ key: pardes.ImageCacheKey,
+ pane: u8,
+ serial: u32,
+ native: pardes.NativePlacement,
+ x: u16,
+ y: u16,
+ w: u16,
+ h: u16,
+ iw: usize,
+ ih: usize,
+
+ fn from(place: pardes.ImagePlace) SavedImagePlace {
+ return .{
+ .key = place.cacheKey(),
+ .pane = place.pane,
+ .serial = place.serial,
+ .native = place.native,
+ .x = place.x,
+ .y = place.y,
+ .w = place.w,
+ .h = place.h,
+ .iw = place.iw,
+ .ih = place.ih,
+ };
+ }
+
+ fn cacheKey(place: SavedImagePlace) pardes.ImageCacheKey {
+ return place.key;
+ }
+};
+
+const PreparedImage = struct {
+ place: SavedImagePlace,
+ texture: *c.SDL_GPUTexture,
+ track: ?pardes.panel_animation.Track = null,
+ clip: ?pardes.panel_animation.Box = null,
+ old_layer: bool = false,
+};
+
+test "saved native placement retains identity and geometry but no producer bytes" {
+ try std.testing.expect(!@hasField(SavedImagePlace, "rgba"));
+ const current: pardes.ImagePlace = .{
+ .pane = 3,
+ .serial = 91,
+ .x = 4,
+ .y = 5,
+ .w = 6,
+ .h = 7,
+ .rgba = &.{ 1, 2, 3, 4 },
+ .iw = 1,
+ .ih = 1,
+ };
+ const saved = SavedImagePlace.from(current);
+ try std.testing.expect(sameSavedPlacement(saved, current));
+ try std.testing.expect(placeIntersectsBox(saved, .{ .x = 8, .y = 8, .w = 4, .h = 4 }));
+ try std.testing.expect(!placeIntersectsBox(saved, .{ .x = 20, .y = 20, .w = 2, .h = 2 }));
+}
+
+test "shader instance ABI carries aligned transition vectors" {
+ try std.testing.expectEqual(@as(usize, 104), @sizeOf(CellInstance));
+ try std.testing.expectEqual(@as(usize, 56), @offsetOf(CellInstance, "panel_x0"));
+ try std.testing.expectEqual(@as(usize, 72), @offsetOf(CellInstance, "present_x0"));
+ try std.testing.expectEqual(@as(usize, 88), @offsetOf(CellInstance, "effect"));
+ try std.testing.expectEqual(@as(usize, 80), @sizeOf(ImageInstance));
+ try std.testing.expectEqual(@as(usize, 32), @offsetOf(ImageInstance, "panel_x0"));
+ try std.testing.expectEqual(@as(usize, 48), @offsetOf(ImageInstance, "present_x0"));
+ try std.testing.expectEqual(@as(usize, 64), @offsetOf(ImageInstance, "effect"));
+ try std.testing.expectEqual(@as(usize, 32), @sizeOf([8]f32));
+
+ const tracks = [_]pardes.panel_animation.Track{
+ .{ .pane = 0, .phase = .opening, .effect = .slide },
+ .{ .pane = 1, .phase = .moving, .effect = .slide },
+ .{ .pane = 2, .phase = .moving, .effect = .slide },
+ };
+ const plan = makePaintPlan(&tracks, true);
+ try std.testing.expectEqual(@as(usize, 4), plan.len);
+ try std.testing.expect(plan.batches[0].track == null);
+ try std.testing.expectEqual(@as(u8, 1), plan.batches[1].track.?.pane);
+ try std.testing.expectEqual(@as(u8, 2), plan.batches[2].track.?.pane);
+ try std.testing.expectEqual(@as(u8, 0), plan.batches[3].track.?.pane);
+}
+
+test "GUI paint plan snaps history effects without a frozen grid" {
+ const tracks = [_]pardes.panel_animation.Track{
+ .{ .pane = 0, .phase = .opening, .effect = .ascii },
+ .{ .pane = 1, .phase = .closing, .effect = .vertical },
+ };
+ try std.testing.expectEqual(@as(usize, 1), makePaintPlan(&tracks, false).len);
+ const ready = makePaintPlan(&tracks, true);
+ try std.testing.expectEqual(@as(usize, 3), ready.len);
+ try std.testing.expectEqual(pardes.panel_animation.Phase.opening, ready.batches[1].track.?.phase);
+ try std.testing.expectEqual(pardes.panel_animation.Phase.closing, ready.batches[2].track.?.phase);
+}
+
+test "closing tombstone overlays but never owns canonical cells" {
+ const closing: pardes.panel_animation.Track = .{
+ .pane = 0,
+ .phase = .closing,
+ .effect = .vertical,
+ .from = .{ .x = 2, .y = 3, .w = 10, .h = 4 },
+ .to = .{ .x = 2, .y = -1, .w = 10, .h = 4 },
+ };
+ const plan = makePaintPlan(&.{closing}, true);
+ try std.testing.expectEqual(@as(usize, 0), paintBatchAt(&plan, 4, 4));
+ try std.testing.expectEqual(@as(usize, 2), plan.len);
+ try std.testing.expectEqual(pardes.panel_animation.Phase.closing, plan.batches[1].track.?.phase);
+}
fn updateCoreResize(core: *pardes.Pardes, cols: u16, rows: u16, cell_w: u32, cell_h: u32) bool {
if (comptime pardes.pdf_enabled) {
@@ -368,19 +660,6 @@ const Touch = struct {
scrolled: bool = false,
last_center: TouchNormPoint = .{},
} = .{},
- // Web policy: one primary finger is either a body tap/scroll or a latched
- // left-mouse gesture when it begins on layout chrome. A second simultaneous
- // finger cancels a body tap rather than producing a click.
- single: struct {
- active: bool = false,
- id: u64 = 0,
- start: TouchNormPoint = .{},
- last: TouchNormPoint = .{},
- accum_y: f32 = 0,
- scrolling: bool = false,
- mouse_drag: bool = false,
- multitouch: bool = false,
- } = .{},
click_count: u32 = 0,
click_button: pardes.Mouse.Button = .middle,
click_flash_frames: u8 = 0,
@@ -412,12 +691,6 @@ const Touch = struct {
}
}
- fn activeCount(t: *const Touch) usize {
- var count: usize = 0;
- for (&t.points) |*tp| count += @intFromBool(tp.active);
- return count;
- }
-
fn pairCenter(t: *const Touch, ids: [2]u64) ?TouchNormPoint {
var sum: TouchNormPoint = .{};
for (ids) |id| {
@@ -486,47 +759,6 @@ const Touch = struct {
t.scroll.scrolled = true;
return .{ .center = center, .ticks = ticks, .delta_y = @as(f32, @floatFromInt(ticks)) * touch_scroll_tick };
}
-
- fn beginSingle(t: *Touch, f: Finger, mouse_drag: bool) void {
- if (t.single.active) {
- if (t.single.id != f.id) t.single.multitouch = true;
- return;
- }
- if (t.activeCount() != 1) return;
- const point = TouchNormPoint{ .x = f.x, .y = f.y };
- t.single = .{ .active = true, .id = f.id, .start = point, .last = point, .mouse_drag = mouse_drag };
- }
-
- fn noteSingleMotion(t: *Touch, f: Finger, win_w: f32, win_h: f32) ?struct { center: TouchNormPoint, ticks: i32, delta_y: f32 } {
- if (!t.single.active or t.single.id != f.id or t.single.multitouch) return null;
- const center = TouchNormPoint{ .x = f.x, .y = f.y };
- var delta_y = center.y - t.single.last.y;
- t.single.last = center;
- if (!t.single.scrolling) {
- const dx = (center.x - t.single.start.x) * win_w;
- const dy = (center.y - t.single.start.y) * win_h;
- if (dx * dx + dy * dy < touch_tap_slop_px * touch_tap_slop_px) return null;
- // Once the pointer leaves the tap slop it can never click. Include
- // all travel since finger-down so the first scroll tick is prompt.
- t.single.scrolling = true;
- delta_y = center.y - t.single.start.y;
- }
- t.single.accum_y += delta_y;
- const ticks = takeScrollTicks(&t.single.accum_y);
- if (ticks == 0) return null;
- return .{ .center = center, .ticks = ticks, .delta_y = @as(f32, @floatFromInt(ticks)) * touch_scroll_tick };
- }
-
- fn finishSingle(t: *Touch, f: Finger, win_w: f32, win_h: f32) ?TouchNormPoint {
- if (!t.single.active or t.single.id != f.id) return null;
- const point = TouchNormPoint{ .x = f.x, .y = f.y };
- const dx = (point.x - t.single.start.x) * win_w;
- const dy = (point.y - t.single.start.y) * win_h;
- const left_slop = dx * dx + dy * dy >= touch_tap_slop_px * touch_tap_slop_px;
- const tap = f.kind == .up and !left_slop and !t.single.scrolling and !t.single.mouse_drag and !t.single.multitouch;
- t.single = .{};
- return if (tap) point else null;
- }
};
fn takeScrollTicks(accum: *f32) i32 {
@@ -536,59 +768,12 @@ fn takeScrollTicks(accum: *f32) i32 {
return ticks;
}
-/// The SDL boundary owns touch policy: a web body uses one-finger natural
-/// scrolling and tap-as-LOOK, while layout chrome latches a left-mouse drag;
-/// native keeps two-finger scroll/tap-as-execute. Both feed the same core mouse
-/// events and coordinate mapping.
+/// The SDL boundary owns touch policy: two-finger scroll and tap-as-execute.
fn handleFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void {
std.debug.assert(cell_w > 0 and cell_h > 0);
- if (is_emscripten)
- return handleSingleFinger(t, core, f, win_w, win_h, cell_w, cell_h);
return handlePairFinger(t, core, f, win_w, win_h, cell_w, cell_h);
}
-fn handleSingleFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void {
- switch (f.kind) {
- .down => {
- t.updatePoint(f);
- const point = TouchNormPoint{ .x = f.x, .y = f.y };
- const col = normCell(point.x, win_w, cell_w);
- const row = normCell(point.y, win_h, cell_h);
- const chrome = core.chromeTarget(col, row);
- t.beginSingle(f, chrome != null);
- if (chrome) |target|
- core.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = target.col, .row = target.row } });
- },
- .motion => {
- t.updatePoint(f);
- if (t.single.active and t.single.id == f.id and t.single.mouse_drag) {
- core.update(.{ .mouse = .{
- .button = .left,
- .kind = .drag,
- .col = normCell(f.x, win_w, cell_w),
- .row = normCell(f.y, win_h, cell_h),
- } });
- return;
- }
- const res = t.noteSingleMotion(f, win_w, win_h) orelse return;
- emitTouchScroll(core, res.center, res.ticks, res.delta_y, win_w, win_h, cell_w, cell_h);
- },
- .up, .cancel => {
- const mouse_drag = t.single.active and t.single.id == f.id and t.single.mouse_drag;
- const tap = t.finishSingle(f, win_w, win_h);
- t.updatePoint(f);
- if (mouse_drag) {
- core.update(.{ .mouse = .{
- .button = .left,
- .kind = .release,
- .col = normCell(f.x, win_w, cell_w),
- .row = normCell(f.y, win_h, cell_h),
- } });
- } else if (tap) |point| emitTouchClick(t, core, point, .right, win_w, win_h, cell_w, cell_h);
- },
- }
-}
-
fn handlePairFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void {
switch (f.kind) {
.down => {
@@ -668,14 +853,6 @@ const Msg = union(enum) {
}
};
-/// The files on open panes, watched through ONE inotify instance. Same shape
-/// and same reasoning as tty.zig's Watch, which spells it out: the mark goes on
-/// the containing DIRECTORY because nothing rewrites a file in place — a
-/// formatter, a checkout, an editor all rename a temp file over the target and
-/// swap the inode — and `hash` is what we last saw ON DISK, so our own Save
-/// never reads as an external change.
-const Watch = struct { wd: c_int, hash: u64 };
-
/// One language query, owned by the thread running it — the gui twin of
/// tty.zig's LspJob, and copied for the same reason: the core edits on.
const LspJob = struct {
@@ -889,41 +1066,6 @@ fn spawnReader(gpa: std.mem.Allocator, pt: Pty, pane: u8, gen: u32, q: *Queue) v
th.detach();
}
-/// Mark or unmark one pane's file (`path` null = unmark). Linux only: anywhere
-/// else this returns silently, the core never receives a file_changed event,
-/// and the feature is simply off — which the core already has to tolerate,
-/// since the browser build of this same shell has no filesystem at all.
-/// ponytail: darwin wants the FSEvents half of std.Build.Watch here.
-fn watchPane(fd: c_int, watches: *[pardes.MAX_PANES]?Watch, id: u8, path: ?[]const u8, hash: u64) void {
- if (comptime builtin.os.tag != .linux) return;
- if (fd < 0) return;
- if (watches[id]) |old| {
- // inotify hands out ONE descriptor per directory, so two panes on
- // files in the same directory share it: drop the mark only when the
- // last of them lets go, or closing one blinds the other.
- var shared = false;
- for (watches, 0..) |other, i| {
- const o = other orelse continue;
- if (i != id and o.wd == old.wd) shared = true;
- }
- if (!shared) _ = libc.inotify_rm_watch(fd, old.wd);
- watches[id] = null;
- }
- const p = path orelse return;
- const dir = std.fs.path.dirname(p) orelse ".";
- var dbuf: [4096:0]u8 = undefined;
- if (dir.len >= dbuf.len) return;
- @memcpy(dbuf[0..dir.len], dir);
- dbuf[dir.len] = 0;
- // CLOSE_WRITE, not MODIFY: one event when a writer is DONE rather than one
- // per write(2). MOVED_TO and CREATE catch the rename-over and the
- // delete-then-recreate that are how files are actually replaced.
- const mask = linux.IN.CLOSE_WRITE | linux.IN.MOVED_TO | linux.IN.CREATE | linux.IN.ONLYDIR;
- const wd = libc.inotify_add_watch(fd, dbuf[0..dir.len :0], mask);
- if (wd < 0) return;
- watches[id] = .{ .wd = wd, .hash = hash };
-}
-
/// Block on the inotify fd and wake the loop. Deliberately does NOT parse the
/// events: the loop re-reads every watched pane anyway, so the only thing an
/// event carries that we need is THAT something happened, and parsing would
@@ -958,31 +1100,6 @@ fn lookThread(gpa: std.mem.Allocator, fd: c_int, q: *Queue) void {
}
}
-/// Hand the core every watched pane whose bytes moved on disk. The wake says
-/// only THAT something happened, so this re-reads the lot; the hash comparison
-/// is what keeps our own Save — and any write that lands on identical content
-/// — out of the undo stack. Read on the loop rather than on the watcher thread
-/// because the core is the only thing that knows which pane a path belongs to.
-fn reloadChanged(core: *pardes.Pardes, gpa: std.mem.Allocator, watches: *[pardes.MAX_PANES]?Watch) void {
- for (watches, 0..) |*slot, id| {
- if (slot.* == null) continue;
- const pane = core.panes[id] orelse continue;
- const f = pane.file orelse continue;
- const bytes = look.readFile(gpa, f.path) catch continue;
- defer gpa.free(bytes);
- const h = std.hash.Wyhash.hash(0, bytes);
- if (h == slot.*.?.hash) continue;
- slot.*.?.hash = h;
- core.update(.{ .file_changed = .{ .pane = @intCast(id), .bytes = bytes } });
- // the hash moving IS "someone else wrote this file" (our own Save
- // restamped it), so the pane says who did what to it — and it says it
- // after the update, which cannot clear a message because only a key or
- // a mouse event does
- var mbuf: [256]u8 = undefined;
- core.setMessage(id, message.stamp(&mbuf, "reloaded", f.path));
- }
-}
-
/// Answer a language query off the render loop and push the rows to the queue
/// — the async execution model, spelled in the plumbing this shell already has
/// (a detached thread and the mutex queue the pty readers use).
@@ -1105,12 +1222,16 @@ const Gui = struct {
image_vxfer: *c.SDL_GPUTransferBuffer,
image_capacity: u32 = 0,
native_images: std.AutoHashMapUnmanaged(pardes.ImageCacheKey, *c.SDL_GPUTexture) = .empty,
+ presented_images: std.ArrayListUnmanaged(SavedImagePlace) = .empty,
+ prepared_images: std.ArrayListUnmanaged(PreparedImage) = .empty,
font: *c.UIFont,
/// the file behind `font`, when it is one the Font builtin loaded. Empty
/// for the font the binary ships with, which is @embedFile'd and not ours
/// to free — FreeType borrows these bytes for the face lifetime.
font_bytes: []u8 = &.{},
+ font_name: [255]u8 = @splat(0),
+ font_name_len: u8 = 0,
/// Faces are discovered and opened once at startup. `glyphs` below caches
/// the raster result by codepoint, so fallback probing happens once per
/// glyph/atlas epoch rather than once per cell or frame.
@@ -1122,23 +1243,25 @@ const Gui = struct {
/// Ctrl+/Ctrl- this leaves the path for) is writing here and calling that.
px: f32,
scale: f32,
+ tagline_scale: f32,
+ tagline_percent: u8,
+ tagline_height: u32,
cell_w: u32,
cell_h: u32,
ascent: i32,
+ tagline_baseline: i32,
- // glyph atlas: CPU staging bitmap + codepoint → texel slot, pen-walk alloc.
+ // glyph atlas: CPU staging bitmap + codepoint/role → texel slot, pen-walk alloc.
// The slot is also the fallback-resolution cache: after first rasterization
// every cell/frame takes the hash hit without probing any face again.
atlas_stage: []u8,
- glyphs: std.AutoHashMap(u32, Slot),
+ glyphs: std.AutoHashMap(GlyphKey, Slot),
pen_x: u32 = 0,
pen_y: u32 = 0,
atlas_dirty: bool = true,
space_slot: Slot = .{ .u = 0, .v = 0 },
touch: Touch = .{},
- mouse_x: f32 = 0,
- mouse_y: f32 = 0,
live_ctrl: bool = false,
live_alt: bool = false,
@@ -1167,6 +1290,19 @@ const Gui = struct {
scene_tex: ?*c.SDL_GPUTexture = null,
scene_tex_w: u32 = 0,
scene_tex_h: u32 = 0,
+ /// Resize-time scene target creation can fail transiently. Present the
+ /// direct frame meanwhile, then stop retrying after a small bounded run.
+ scene_failures: u8 = 0,
+ scene_target_failed: bool = false,
+ /// Exact postprocess state used by the last submitted frame. Input maps
+ /// through this snapshot, not through config/time sampled a frame later.
+ presented_scene: crt.Frame = .{},
+ /// Physical pointer state is retained separately from its mapped grid
+ /// cell. Ripple/glitch can move the displayed source under a stationary
+ /// hand, so every accepted scene frame remaps this same window point.
+ pointer_present: bool = false,
+ pointer_mapped: bool = false,
+ pointer_cell: ?MouseCell = null,
// PARDES_TEST frame capture (render into an offscreen target, dump PPM)
capture: bool = false,
@@ -1177,24 +1313,41 @@ const Gui = struct {
capture_xfer: ?*c.SDL_GPUTransferBuffer = null,
capture_xfer_size: u32 = 0,
- // steamdeck: gamepad-driven virtual mouse cursor (pixel position)
+ // Steam Deck: gamepad-driven virtual cursor in SDL window coordinates.
+ // Conversion to physical render pixels happens once in mouseCell.
gamepad: ?*c.SDL_Gamepad = null,
pad_x: f32 = 0,
pad_y: f32 = 0,
pad_scroll: f32 = 0, // fractional vertical wheel ticks (left stick)
pad_scroll_h: f32 = 0, // fractional horizontal wheel ticks (left stick)
deck: deck.Deck = .{},
-
- // web: WebGL2 handles (plain GL ints) + the CPU-side instance staging
- gl_program: c_uint = 0,
- gl_overlay_program: c_uint = 0,
- gl_atlas_tex: c_uint = 0,
- gl_vbo: c_uint = 0,
- gl_overlay_vbo: c_uint = 0,
- gl_u_atlas: c_int = -1,
- gl_instances: []CellInstance = &.{},
};
+fn setGuiFontName(g: *Gui, fallback: []const u8) void {
+ // Config says "effective", so ask the rasterizer what it accepted rather
+ // than echoing the picker label (a filename stem which need not be the
+ // face's own identity). Some old/synthetic faces have neither a
+ // PostScript nor family name; only those retain the known-good label.
+ const name = if (c.ui_font_name(g.font)) |name_z| std.mem.span(name_z) else fallback;
+ const len = @min(name.len, g.font_name.len);
+ @memcpy(g.font_name[0..len], name[0..len]);
+ g.font_name_len = @intCast(len);
+}
+
+fn guiFontName(g: *const Gui) []const u8 {
+ return g.font_name[0..g.font_name_len];
+}
+
+fn observeGuiFont(g: *const Gui, core: *pardes.Pardes) void {
+ const size: u16 = @intFromFloat(@round(g.px * 100.0));
+ _ = core.observeFont(guiFontName(g), size, .pixels);
+}
+
+fn acknowledgeGuiFont(g: *const Gui, core: *pardes.Pardes) void {
+ const size: u16 = @intFromFloat(@round(g.px * 100.0));
+ _ = core.acknowledgeFont(guiFontName(g), size, .pixels);
+}
+
fn loadFallbackFonts(g: *Gui, gpa: std.mem.Allocator) void {
// The shipped face is the first fallback whenever Font selects a narrower
// user face. A second FT_Face is cheap and keeps both lifetimes independent.
@@ -1239,37 +1392,178 @@ fn fontForCodepoint(g: *const Gui, cp: u32) *c.UIFont {
return g.font;
}
-/// A cell's on-screen rect. Native: exactly cell_w×cell_h at (0,0). Web: the
-/// canvas resize and the grid resize land on different frames, so the grid is
-/// scaled to fit and centered (letterboxed) instead of clipping.
+/// A cell's on-screen rect: exactly cell_w×cell_h at (0,0).
const CellLayout = struct { w: f32, h: f32, x_off: f32, y_off: f32 };
+const MouseCell = struct { col: u16, row: u16 };
+const WindowGeometry = struct {
+ window_w: f32,
+ window_h: f32,
+ pixel_w: f32,
+ pixel_h: f32,
+};
+
+fn windowGeometry(window: *c.SDL_Window) WindowGeometry {
+ var ww: c_int = 0;
+ var wh: c_int = 0;
+ var pw: c_int = 0;
+ var ph: c_int = 0;
+ _ = c.SDL_GetWindowSize(window, &ww, &wh);
+ _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph);
+ return .{
+ .window_w = @floatFromInt(@max(ww, 1)),
+ .window_h = @floatFromInt(@max(wh, 1)),
+ .pixel_w = @floatFromInt(@max(pw, 1)),
+ .pixel_h = @floatFromInt(@max(ph, 1)),
+ };
+}
+
+fn windowPointToPixels(geometry: WindowGeometry, x: f32, y: f32) crt.Point {
+ return .{
+ .x = x * geometry.pixel_w / geometry.window_w,
+ .y = y * geometry.pixel_h / geometry.window_h,
+ };
+}
+
+test "high-density window coordinates map once to render pixels" {
+ const geometry: WindowGeometry = .{
+ .window_w = 560,
+ .window_h = 360,
+ .pixel_w = 1120,
+ .pixel_h = 720,
+ };
+ try std.testing.expectEqual(crt.Point{ .x = 700, .y = 240 }, windowPointToPixels(geometry, 350, 120));
+}
fn fixedCellLayout(g: *const Gui) CellLayout {
return .{ .w = @floatFromInt(g.cell_w), .h = @floatFromInt(g.cell_h), .x_off = 0, .y_off = 0 };
}
-fn cellLayout(g: *const Gui, cols: u16, rows: u16, sw: u32, sh: u32) CellLayout {
- const fixed = fixedCellLayout(g);
- if (!is_emscripten) return fixed;
- if (cols == 0 or rows == 0) return fixed;
- const grid_w = @as(f32, @floatFromInt(cols)) * fixed.w;
- const grid_h = @as(f32, @floatFromInt(rows)) * fixed.h;
- const win_w: f32 = @floatFromInt(sw);
- const win_h: f32 = @floatFromInt(sh);
- const scale = @min(win_w / grid_w, win_h / grid_h);
+fn boxContains(box: pardes.panel_animation.Box, col: u16, row: u16) bool {
+ const x: f32 = @floatFromInt(col);
+ const y: f32 = @floatFromInt(row);
+ return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h;
+}
+
+// EFFECT_CODE_PANEL_HOST_BEGIN
+const PaintBatch = struct {
+ track: ?pardes.panel_animation.Track = null,
+ cell_start: u32 = 0,
+ cell_count: u32 = 0,
+ image_start: u32 = 0,
+ image_count: u32 = 0,
+};
+
+const PaintPlan = struct {
+ // One static batch plus live tracks and presentation-only closing
+ // tombstones. Slot reuse can legitimately expose both for one pane id.
+ batches: [pardes.MAX_PANES * 2 + 1]PaintBatch = @splat(.{}),
+ len: usize = 1,
+};
+
+/// Painter order shared by cells and pixel attachments. Core hit testing walks
+/// the reverse order, so the visually top panel receives the click too.
+fn makePaintPlan(tracks: []const pardes.panel_animation.Track, has_diff: bool) PaintPlan {
+ var plan: PaintPlan = .{};
+ for ([_]pardes.panel_animation.Phase{ .moving, .opening, .closing }) |phase| {
+ for (tracks) |track| {
+ if (!track.active() or track.phase != phase) continue;
+ // These effects have no honest fallback without the frozen grid.
+ // Render the canonical frame rather than materializing garbage.
+ if (track.effect.needsPreviousGrid() and !has_diff) continue;
+ std.debug.assert(plan.len < plan.batches.len);
+ plan.batches[plan.len].track = track;
+ plan.len += 1;
+ }
+ }
+ return plan;
+}
+
+fn paintBatchAt(plan: *const PaintPlan, col: u16, row: u16) usize {
+ for (plan.batches[1..plan.len], 1..) |batch, index| {
+ if (batch.track.?.phase == .closing) continue;
+ if (boxContains(batch.track.?.to, col, row)) return index;
+ }
+ return 0;
+}
+
+fn paintBatchForSerial(plan: *const PaintPlan, serial: u32) usize {
+ for (plan.batches[1..plan.len], 1..) |batch, index|
+ if (batch.track.?.serial == serial) return index;
+ return 0;
+}
+
+fn panelCellCoord(track: pardes.panel_animation.Track, col: u16, row: u16) u32 {
+ const source = track.contentBox();
+ const x0: u16 = @intFromFloat(@max(0.0, @floor(source.x)));
+ const y0: u16 = @intFromFloat(@max(0.0, @floor(source.y)));
+ return @as(u32, row -| y0) << 16 | @as(u32, col -| x0);
+}
+
+fn panelGridSize(track: pardes.panel_animation.Track) u32 {
+ const source = track.contentBox();
+ const cols: u16 = @intFromFloat(@min(@as(f32, std.math.maxInt(u16)), @max(1.0, @ceil(source.w))));
+ const rows: u16 = @intFromFloat(@min(@as(f32, std.math.maxInt(u16)), @max(1.0, @ceil(source.h))));
+ return @as(u32, rows) << 16 | @as(u32, cols);
+}
+
+fn ndcBox(box: pardes.panel_animation.Box, layout: CellLayout, win_w: f32, win_h: f32) [4]f32 {
+ const px0 = layout.x_off + box.x * layout.w;
+ const py0 = layout.y_off + box.y * layout.h;
+ const px1 = px0 + box.w * layout.w;
+ const py1 = py0 + box.h * layout.h;
return .{
- .w = fixed.w * scale,
- .h = fixed.h * scale,
- .x_off = @max(0, (win_w - grid_w * scale) * 0.5),
- .y_off = @max(0, (win_h - grid_h * scale) * 0.5),
+ px0 / win_w * 2.0 - 1.0,
+ 1.0 - py0 / win_h * 2.0,
+ px1 / win_w * 2.0 - 1.0,
+ 1.0 - py1 / win_h * 2.0,
};
}
+fn setTransitionFields(
+ instance: anytype,
+ track: ?pardes.panel_animation.Track,
+ layout: CellLayout,
+ win_w: f32,
+ win_h: f32,
+ cell_coord: u32,
+) void {
+ const active = track orelse {
+ instance.panel_x0 = 0;
+ instance.panel_y0 = 0;
+ instance.panel_x1 = 0;
+ instance.panel_y1 = 0;
+ instance.present_x0 = 0;
+ instance.present_y0 = 0;
+ instance.present_x1 = 0;
+ instance.present_y1 = 0;
+ instance.effect = 0;
+ instance.progress_bits = @bitCast(@as(f32, 1));
+ instance.serial = 0;
+ instance.cell_coord = cell_coord;
+ return;
+ };
+ const final = ndcBox(active.contentBox(), layout, win_w, win_h);
+ const presented = ndcBox(active.visualBox(), layout, win_w, win_h);
+ instance.panel_x0 = final[0];
+ instance.panel_y0 = final[1];
+ instance.panel_x1 = final[2];
+ instance.panel_y1 = final[3];
+ instance.present_x0 = presented[0];
+ instance.present_y0 = presented[1];
+ instance.present_x1 = presented[2];
+ instance.present_y1 = presented[3];
+ instance.effect = @intFromEnum(active.effect);
+ instance.progress_bits = @bitCast(active.amount());
+ instance.serial = active.serial;
+ instance.cell_coord = cell_coord;
+}
+// EFFECT_CODE_PANEL_HOST_END
+
// =====================================================================
// entry
// =====================================================================
-pub const run = if (is_emscripten) runWeb else runNative;
+pub const run = runNative;
fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
const io = init.io;
@@ -1354,6 +1648,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
c.ui_font_cell_metrics(font, scale, &cw, &chh, &asc);
const cell_w: u32 = @intCast(@max(cw, 1));
const cell_h: u32 = @intCast(@max(chh, 1));
+ const tagline = taglineRaster(font, px, cell_h, config.gui_tagline_font_percent);
if (test_mode) {
const cols = envU16(env, "PARDES_TEST_COLS") orelse 80;
const rows = envU16(env, "PARDES_TEST_ROWS") orelse 24;
@@ -1433,20 +1728,27 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
.font = font,
.px = px,
.scale = scale,
+ .tagline_scale = tagline.scale,
+ .tagline_percent = config.gui_tagline_font_percent,
+ .tagline_height = tagline.height,
.cell_w = cell_w,
.cell_h = cell_h,
.ascent = asc,
+ .tagline_baseline = tagline.baseline,
.atlas_stage = atlas_stage,
- .glyphs = std.AutoHashMap(u32, Slot).init(gpa),
+ .glyphs = std.AutoHashMap(GlyphKey, Slot).init(gpa),
.capture = test_mode,
.capture_dir = capture_dir orelse "",
};
+ setGuiFontName(&g, "Adwaita Mono");
loadFallbackFonts(&g, gpa);
defer deinitFallbackFonts(&g, gpa);
defer g.glyphs.deinit();
defer {
clearNativeImages(&g);
g.native_images.deinit(gpa);
+ g.presented_images.deinit(gpa);
+ g.prepared_images.deinit(gpa);
}
defer c.SDL_ReleaseGPUTransferBuffer(device, g.image_vxfer);
defer c.SDL_ReleaseGPUBuffer(device, g.image_vbuf);
@@ -1497,10 +1799,13 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
// construction: argv image panes no longer freeze the startup capability
// into their PETSCII preference, so their first render emits attachments.
core.native_images = true;
+ observeGuiFont(&g, core);
+ syncTaglineFont(&g, core);
- // shells emit OSC 133 prompt marks via this rc (prompt hiding, click-move)
- writeFile(shell_bin.bash_rc_path, shell_bin.bash_rc);
- writeFile(shell_bin.fish_rc_path, shell_bin.fish_rc);
+ // Private, complete before any fork and retained until the last possible
+ // spawn; children borrow only these stable in-struct path buffers.
+ var prompt_rcs = shell_bin.PromptRcs.init();
+ defer prompt_rcs.deinit();
// macos: apple's bash 3.2 prints the zsh-deprecation banner into every
// pane unless this is in the env BEFORE bash starts (the rc is too late)
if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1);
@@ -1536,7 +1841,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
_ = libc.close(inotify_fd); // ends the detached watcher's read
inotify_fd = -1;
};
- var watches: [pardes.MAX_PANES]?Watch = @splat(null);
+ var watches: file_watch.Table = @splat(null);
// The socket a pardes launched inside this one connects to (nested.zig).
// --nested opted out of the whole mechanism, including being an outer
// instance; so does any failure to bind, and then children simply open
@@ -1546,7 +1851,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
// initial spawns BEFORE any worker thread exists: forkpty from a
// multithreaded process can wedge the child before exec (see tty.zig).
- drainEffects(core, &ptys, &gens, io, gpa, &queue, &pipe_tasks, &g, inotify_fd, &watches, false);
+ drainEffects(core, &prompt_rcs, &ptys, &gens, io, gpa, &queue, &pipe_tasks, &g, inotify_fd, &watches, false);
for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), gens[id], &queue);
// ...and the one file watcher. Started even with nothing marked yet: the fd
// already exists and an unwatched inotify instance just parks in read(2) —
@@ -1616,11 +1921,12 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
// a reload — or an undo entry — per write.
.files_changed => check_files = true,
};
- if (check_files) reloadChanged(core, gpa, &watches);
+ if (check_files and file_watch.reloadChanged(core, io, gpa, &watches))
+ queue.push(.files_changed);
// 3. steamdeck: poll gamepad axes into virtual cursor / wheel events
pollGamepad(&g, core);
// 4. effects
- drainEffects(core, &ptys, &gens, io, gpa, &queue, &pipe_tasks, &g, inotify_fd, &watches, true);
+ drainEffects(core, &prompt_rcs, &ptys, &gens, io, gpa, &queue, &pipe_tasks, &g, inotify_fd, &watches, true);
if (core.quit) break;
// Restore builtin: swap in a core rebuilt from the dump; kill the live
// shells (their detached readers wake on child death; gens bumped so
@@ -1640,32 +1946,51 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
// the replay core's pane ids mean new things, and the dying core's
// `watch off` effects go into a queue nobody drains — drop the lot
// here. The new core emits its own `on`s as it builds its panes.
- for (0..watches.len) |wid| watchPane(inotify_fd, &watches, @intCast(wid), null, 0);
+ for (0..watches.len) |wid| file_watch.watchPane(
+ inotify_fd,
+ &watches,
+ @intCast(wid),
+ null,
+ 0,
+ .{ .text = 0 },
+ );
clearNativeImages(&g);
+ g.presented_images.clearRetainingCapacity();
+ g.prepared_images.clearRetainingCapacity();
nc.native_images = true;
core.deinit();
core = nc;
+ observeGuiFont(&g, core);
+ syncTaglineFont(&g, core);
}
+ // TaglineSize is pure renderer state: update the smaller face and its
+ // visual band immediately, without changing the body metrics or grid.
+ syncTaglineFont(&g, core);
// Font builtin: the core resolved a name to a path and asked for it —
// it cannot load a font itself, having no rasterizer, no atlas and no
// window. Inline here beside Restore because it is the same kind of
// thing and this is the flat loop.
- if (fonts.want) |path| blk: {
- fonts.want = null;
- const bytes = look.readFile(gpa, path) catch break :blk;
+ if (core.takeFontRequest()) |path| blk: {
+ const bytes = look.readFile(gpa, path) catch {
+ core.rejectFont();
+ break :blk;
+ };
const nf = c.ui_font_new(bytes.ptr, @intCast(bytes.len)) orelse {
// FreeType turned it down. Keep wearing the one that works: a
// font pardes cannot rasterize is a blank window with no way
// back out of it.
log.err("ui_font_new failed: {s}", .{path});
gpa.free(bytes);
+ core.rejectFont();
break :blk;
};
c.ui_font_free(g.font);
if (g.font_bytes.len != 0) gpa.free(g.font_bytes);
g.font = nf;
g.font_bytes = bytes; // FreeType borrows them for the face lifetime
+ setGuiFontName(&g, core.settings.font.requested_name.get());
refitFont(&g, core);
+ acknowledgeGuiFont(&g, core);
}
// 5. live cwd for tags/look: cheap /proc readlink per pane, per frame
pollCwds(core, &ptys);
@@ -1679,336 +2004,42 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
// 7. render — apply the wheel batch LAST before it, while
// core.surface still holds the frame the last pass drew
stepScroll(&g, core, gpa);
- if (animation_clock.due(core.themeAnimationActive(), c.SDL_GetTicksNS()))
- core.update(.tick);
_ = frame_arena.reset(.retain_capacity);
const surface = try core.render(frame_arena.allocator());
- renderFrame(&g, gpa, surface, core.theme().bg, core.crt_on, core.show_debug) catch |err| log.err("render: {t}", .{err});
- }
-}
-
-// =====================================================================
-// web entry: set everything up, register a rAF frame callback, return.
-// EXIT_RUNTIME=0 keeps the wasm runtime (and these statics) alive after.
-// =====================================================================
-
-// std.process.Init provides no gpa on emscripten — one 512 MiB fixed buffer
-// is the whole heap, never freed (the shell lives for the page).
-var web_heap: [if (is_emscripten) 512 * 1024 * 1024 else 0]u8 align(16) = undefined;
-var web_fba: std.heap.FixedBufferAllocator = undefined;
-
-const WebState = struct {
- g: Gui,
- core: *pardes.Pardes,
- gpa: std.mem.Allocator,
- frame_arena: std.heap.ArenaAllocator,
- animation_clock: AnimationClock = .{},
-};
-var live_web_state: ?*WebState = null;
-
-// Browser snapshot tests drive real DOM touch input, then read the exact same
-// rendered Surface that WebGL consumed. These tiny exports keep the test
-// contract textual (the existing .snap convention) without adding test state
-// or browser concepts to the core.
-comptime {
- if (is_emscripten) {
- @export(&webTestCols, .{ .name = "pardes_web_test_cols" });
- @export(&webTestRows, .{ .name = "pardes_web_test_rows" });
- @export(&webTestCursorX, .{ .name = "pardes_web_test_cursor_x" });
- @export(&webTestCursorY, .{ .name = "pardes_web_test_cursor_y" });
- @export(&webTestCell, .{ .name = "pardes_web_test_cell" });
- @export(&webTestCellFg, .{ .name = "pardes_web_test_cell_fg" });
- @export(&webTestCellBg, .{ .name = "pardes_web_test_cell_bg" });
- @export(&webTestCellAttrs, .{ .name = "pardes_web_test_cell_attrs" });
- @export(&webTestCellX, .{ .name = "pardes_web_test_cell_x" });
- @export(&webTestCellY, .{ .name = "pardes_web_test_cell_y" });
- }
-}
-
-fn webTestCols() callconv(.c) c_uint {
- const st = live_web_state orelse return 0;
- return st.core.surface.cols;
-}
-
-fn webTestRows() callconv(.c) c_uint {
- const st = live_web_state orelse return 0;
- return st.core.surface.rows;
-}
-
-fn webTestCursorX() callconv(.c) c_int {
- const st = live_web_state orelse return -1;
- return if (st.core.surface.cursor) |cursor| cursor.x else -1;
-}
-
-fn webTestCursorY() callconv(.c) c_int {
- const st = live_web_state orelse return -1;
- return if (st.core.surface.cursor) |cursor| cursor.y else -1;
-}
-
-fn webTestCell(col: c_uint, row: c_uint) callconv(.c) c_uint {
- const cell = webTestCellAt(col, row) orelse return ' ';
- const glyph = cell.grapheme();
- if (glyph.len == 0) return ' ';
- return std.unicode.utf8Decode(glyph) catch 0xfffd;
-}
-
-// RGB occupies 0x000000-0xffffff; the high byte distinguishes the two other
-// Color tags. The browser .snap harness renders these into d/pN/#rrggbb runs.
-fn webTestCellFg(col: c_uint, row: c_uint) callconv(.c) c_uint {
- const cell = webTestCellAt(col, row) orelse return 0x01000000;
- return webTestColor(cell.style.fg);
-}
-
-fn webTestCellBg(col: c_uint, row: c_uint) callconv(.c) c_uint {
- const cell = webTestCellAt(col, row) orelse return 0x01000000;
- return webTestColor(cell.style.bg);
-}
-
-fn webTestColor(color: pardes.Color) c_uint {
- return switch (color) {
- .default => 0x01000000,
- .index => |idx| 0x02000000 | @as(c_uint, idx),
- .rgb => |rgb| (@as(c_uint, rgb[0]) << 16) | (@as(c_uint, rgb[1]) << 8) | rgb[2],
- };
-}
-
-fn webTestCellAttrs(col: c_uint, row: c_uint) callconv(.c) c_uint {
- const cell = webTestCellAt(col, row) orelse return 0;
- const style = cell.style;
- var attrs: c_uint = 0;
- if (style.bold) attrs |= 1 << 0;
- if (style.dim) attrs |= 1 << 1;
- if (style.italic) attrs |= 1 << 2;
- if (style.blink) attrs |= 1 << 3;
- if (style.reverse) attrs |= 1 << 4;
- if (style.invisible) attrs |= 1 << 5;
- if (style.strikethrough) attrs |= 1 << 6;
- attrs |= @as(c_uint, @intFromEnum(style.ul)) << 8;
- return attrs;
-}
-
-fn webTestCellAt(col: c_uint, row: c_uint) ?*const pardes.Cell {
- const st = live_web_state orelse return null;
- const surface = &st.core.surface;
- if (col >= surface.cols or row >= surface.rows) return null;
- return surface.at(@intCast(col), @intCast(row));
-}
-
-fn webTestCellX(col: f32) callconv(.c) f32 {
- return webTestCellCenter(col, true);
-}
-
-fn webTestCellY(row: f32) callconv(.c) f32 {
- return webTestCellCenter(row, false);
-}
-
-fn webTestCellCenter(cell: f32, horizontal: bool) f32 {
- const st = live_web_state orelse return 0;
- var pixel_w: c_int = 0;
- var pixel_h: c_int = 0;
- _ = c.SDL_GetWindowSizeInPixels(st.g.window, &pixel_w, &pixel_h);
- const pw: u32 = @intCast(@max(pixel_w, 1));
- const ph: u32 = @intCast(@max(pixel_h, 1));
- var css_w: f64 = 0;
- var css_h: f64 = 0;
- if (emscripten_get_element_css_size("#canvas", &css_w, &css_h) != EMSCRIPTEN_RESULT_SUCCESS) return 0;
- const layout = cellLayout(&st.g, st.core.surface.cols, st.core.surface.rows, pw, ph);
- if (horizontal) {
- const backing = layout.x_off + (cell + 0.5) * layout.w;
- return backing * @as(f32, @floatCast(css_w)) / @as(f32, @floatFromInt(pw));
- }
- const backing = layout.y_off + (cell + 0.5) * layout.h;
- return backing * @as(f32, @floatCast(css_h)) / @as(f32, @floatFromInt(ph));
-}
-
-fn runWeb(opts_in: pardes.Options) !void {
- web_fba = .init(&web_heap);
- const gpa = web_fba.allocator();
-
- // Our finger machine deliberately maps touch to scroll/LOOK. Suppress
- // SDL's default synthetic left mouse event for the same browser touch.
- _ = c.SDL_SetHint(c.SDL_HINT_TOUCH_MOUSE_EVENTS, "0");
- if (!c.SDL_Init(c.SDL_INIT_VIDEO)) { // no gamepad in the browser
- log.err("SDL_Init: {s}", .{c.SDL_GetError()});
- return error.SdlInit;
- }
- const window = c.SDL_CreateWindow("pardes", 1120, 720, c.SDL_WINDOW_RESIZABLE | c.SDL_WINDOW_OPENGL) orelse {
- log.err("SDL_CreateWindow: {s}", .{c.SDL_GetError()});
- return error.SdlInit;
- };
- _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_PROFILE_MASK, c.SDL_GL_CONTEXT_PROFILE_ES);
- _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MAJOR_VERSION, 3);
- _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MINOR_VERSION, 0);
- _ = c.SDL_GL_SetAttribute(c.SDL_GL_DOUBLEBUFFER, 1);
- const gl_context = c.SDL_GL_CreateContext(window) orelse {
- log.err("SDL_GL_CreateContext: {s}", .{c.SDL_GetError()});
- return error.SdlInit;
- };
- if (!c.SDL_GL_MakeCurrent(window, gl_context)) {
- log.err("SDL_GL_MakeCurrent: {s}", .{c.SDL_GetError()});
- return error.SdlInit;
- }
- _ = c.SDL_GL_SetSwapInterval(1);
-
- // ---- font + cell metrics, rastered at web_render_scale ----
- const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)) orelse {
- log.err("ui_font_new failed", .{});
- return error.FontInit;
- };
- const px: f32 = 18.0;
- const base_scale = c.ui_font_scale_for_height(font, px);
- var cw: c_int = 10;
- var chh: c_int = 20;
- var asc: c_int = 16;
- c.ui_font_cell_metrics(font, base_scale, &cw, &chh, &asc);
- const scale = c.ui_font_scale_for_height(font, px * web_render_scale);
- const cell_w: u32 = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(cw, 1))) * web_render_scale));
- const cell_h: u32 = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(chh, 1))) * web_render_scale));
- const ascent: i32 = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(asc, 1))) * web_render_scale));
-
- // ---- WebGL2: runtime-compiled ES shaders + atlas texture + vbos ----
- const program = try makeGlProgramFrom(vert_glsl_es, frag_glsl_es);
- const overlay_program = try makeGlProgramFrom(overlay_vert_glsl_es, overlay_frag_glsl_es);
- var atlas_tex: c_uint = 0;
- c.glGenTextures(1, &atlas_tex);
- c.glBindTexture(c.GL_TEXTURE_2D, atlas_tex);
- c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_MIN_FILTER, c.GL_LINEAR);
- c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_MAG_FILTER, c.GL_LINEAR);
- c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_S, c.GL_CLAMP_TO_EDGE);
- c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_T, c.GL_CLAMP_TO_EDGE);
- c.glPixelStorei(c.GL_UNPACK_ALIGNMENT, 1);
- c.glTexImage2D(c.GL_TEXTURE_2D, 0, c.GL_R8, atlas_w, atlas_h, 0, c.GL_RED, c.GL_UNSIGNED_BYTE, null);
- var vbo: c_uint = 0;
- c.glGenBuffers(1, &vbo);
- var overlay_vbo: c_uint = 0;
- c.glGenBuffers(1, &overlay_vbo);
-
- const atlas_stage = try gpa.alloc(u8, atlas_w * atlas_h);
- @memset(atlas_stage, 0);
- const overlay_vertices = try gpa.alloc(OverlayVertex, max_overlay_vertices);
-
- const st = try gpa.create(WebState);
- st.* = .{
- .g = .{
- .window = window,
- .device = undefined, // web renders through GL, never the GPU device
- .swapchain_format = undefined,
- .pipeline = undefined,
- .overlay_pipeline = undefined,
- .image_pipeline = undefined,
- .crt_pipeline = undefined,
- .atlas_tex = undefined,
- .atlas_sampler = undefined,
- .linear_sampler = undefined,
- .atlas_xfer = undefined,
- .overlay_vbuf = undefined,
- .overlay_vxfer = undefined,
- .overlay_vertices = overlay_vertices,
- .image_vbuf = undefined,
- .image_vxfer = undefined,
- .gl_program = program,
- .gl_overlay_program = overlay_program,
- .gl_atlas_tex = atlas_tex,
- .gl_vbo = vbo,
- .gl_overlay_vbo = overlay_vbo,
- .gl_u_atlas = c.glGetUniformLocation(program, "u_atlas"),
- .font = font,
- .px = px,
- .scale = scale,
- .cell_w = cell_w,
- .cell_h = cell_h,
- .ascent = ascent,
- .atlas_stage = atlas_stage,
- .glyphs = std.AutoHashMap(u32, Slot).init(gpa),
- },
- .core = undefined,
- .gpa = gpa,
- .frame_arena = undefined,
- };
- const g = &st.g;
- // slot (0,0) is the space glyph (blank cells sample alpha=0 → bg only)
- _ = c.ui_font_raster(font, scale, ' ', atlas_stage.ptr, @intCast(atlas_w), @intCast(cell_w), @intCast(cell_h), ascent);
- g.pen_x = cell_w;
-
- // ---- the core: state replays from the embedded dump, no ptys ----
- const allocs = pardes.allocators.init(gpa);
- errdefer pardes.allocators.deinit();
- st.frame_arena = .init(allocs.frame);
- errdefer st.frame_arena.deinit();
- pardes.image.start(std.Io.failing, allocs.image); // decode is synchronous; no io on wasm
- errdefer pardes.image.stop();
- if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf);
- errdefer if (comptime pardes.pdf_enabled) pardes.pdf.stop();
- pardes.syntax.start(allocs.tree_sitter);
- errdefer pardes.syntax.stop();
- syncCanvasSize(g); // size the backing canvas before deriving the grid
- var opts = opts_in;
- opts.image_allocator = allocs.image;
- opts.pdf_allocator = allocs.pdf;
- opts.tree_sitter_allocator = allocs.tree_sitter;
- var pw: c_int = 0;
- var ph: c_int = 0;
- _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph);
- opts.cols = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), cell_w)));
- opts.rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), cell_h)));
- st.core = try pardes.Pardes.initFromDump(allocs.pardes, opts, embedded_dump);
- live_web_state = st;
-
- _ = c.SDL_StartTextInput(window);
- std.os.emscripten.emscripten_set_main_loop_arg(webFrame, st, 0, 0);
-}
-
-fn webFrame(arg: ?*anyopaque) callconv(.c) void {
- const st: *WebState = @ptrCast(@alignCast(arg.?));
- const g = &st.g;
- const core = st.core;
- // 1. input
- var sev = std.mem.zeroes(c.SDL_Event);
- while (c.SDL_PollEvent(&sev)) dispatch(g, core, &sev);
- // 2. effects
- drainEffectsWeb(core, st.gpa, g);
- if (core.quit) {
- std.os.emscripten.emscripten_cancel_main_loop();
- live_web_state = null;
- core.deinit();
- st.frame_arena.deinit();
- pardes.image.stop();
- if (comptime pardes.pdf_enabled) pardes.pdf.stop();
- pardes.syntax.stop();
- pardes.allocators.deinit();
- return;
- }
- // 3. the backing canvas follows the page, the grid follows the canvas
- syncCanvasSize(g);
- var pw: c_int = 0;
- var ph: c_int = 0;
- _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph);
- const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w)));
- const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h)));
- if (updateCoreResize(core, cols, rows, g.cell_w, g.cell_h)) resetScroll(g);
- // 4. render — apply the wheel batch last, see runNative
- stepScroll(g, core, st.gpa);
- if (st.animation_clock.due(core.themeAnimationActive(), c.SDL_GetTicksNS()))
- core.update(.tick);
- _ = st.frame_arena.reset(.retain_capacity);
- const surface = core.render(st.frame_arena.allocator()) catch return;
- renderFrameGl(g, st.gpa, surface, core.theme().bg, core.show_debug) catch return;
-}
-
-/// the canvas follows the page (CSS); the SDL window follows the canvas,
-/// backed at web_render_scale (postWinsize in the prototype).
-fn syncCanvasSize(g: *Gui) void {
- var css_w: f64 = 0;
- var css_h: f64 = 0;
- if (emscripten_get_element_css_size("#canvas", &css_w, &css_h) != EMSCRIPTEN_RESULT_SUCCESS or css_w <= 0 or css_h <= 0) return;
- const want_w: c_int = @intFromFloat(@round(css_w * web_render_scale));
- const want_h: c_int = @intFromFloat(@round(css_h * web_render_scale));
- var cur_w: c_int = 0;
- var cur_h: c_int = 0;
- _ = c.SDL_GetWindowSizeInPixels(g.window, &cur_w, &cur_h);
- if (cur_w != want_w or cur_h != want_h) {
- _ = c.SDL_SetWindowSize(g.window, want_w, want_h);
- _ = c.SDL_SyncWindow(g.window);
+ const scene_requested = core.settings.scene_effects.crt or
+ core.settings.scene_effects.ripple or core.settings.scene_effects.glitch;
+ if (!scene_requested) g.scene_failures = 0;
+ const presented = renderFrame(
+ &g,
+ gpa,
+ surface,
+ core.theme().bg,
+ core.settings.scene_effects,
+ core.settings.debug,
+ ) catch |err| blk: {
+ log.err("render: {t}", .{err});
+ break :blk false;
+ };
+ if (g.scene_target_failed) {
+ g.scene_failures +|= 1;
+ log.err("scene target unavailable (attempt {d}/3)", .{g.scene_failures});
+ if (g.scene_failures >= 3) {
+ core.disableSceneEffects();
+ g.scene_failures = 0;
+ }
+ } else if (scene_requested and presented) g.scene_failures = 0;
+ if (presented) {
+ // Ripple/glitch move source cells under a stationary physical
+ // pointer. Re-feed only when that accepted scene maps to a new
+ // cell, using the same core mouse path a real motion event uses.
+ refreshPresentedPointer(&g, core);
+ finishPresentedAnimationFrame(
+ &animation_clock,
+ core,
+ surface.panelTracks(),
+ c.SDL_GetTicksNS(),
+ );
+ }
}
}
@@ -2046,13 +2077,13 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
break :blk try pardes.Pardes.initFromDump(allocs.pardes, opts, bytes);
} else try pardes.Pardes.init(allocs.pardes, opts);
defer core.deinit();
- // the requested grid arrives as a resize EVENT (not init opts) so the core
- // counts a resize and releases the shell greeting on first output
+ // The requested grid arrives as a resize EVENT (not init opts) so the core
+ // counts it; an integrated shell releases its greeting at OSC 133 B.
if (opts.load_path == null)
core.update(.{ .resize = .{ .cols = grid_cols, .rows = grid_rows } });
- writeFile(shell_bin.bash_rc_path, shell_bin.bash_rc);
- writeFile(shell_bin.fish_rc_path, shell_bin.fish_rc);
+ var prompt_rcs = shell_bin.PromptRcs.init();
+ defer prompt_rcs.deinit();
// macos: apple's bash 3.2 prints the zsh-deprecation banner into every
// pane unless this is in the env BEFORE bash starts (the rc is too late)
if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1);
@@ -2081,8 +2112,8 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
// no inotify here on purpose: this mode's whole contract is one frame per
// scripted input event, and a reload that arrives on its own clock would
// put a frame in the stream nothing asked for. -1 makes watchPane a no-op.
- var watches: [pardes.MAX_PANES]?Watch = @splat(null);
- drainEffects(core, &ptys, &gens, io, gpa, &queue, &pipe_tasks, null, -1, &watches, false);
+ var watches: file_watch.Table = @splat(null);
+ drainEffects(core, &prompt_rcs, &ptys, &gens, io, gpa, &queue, &pipe_tasks, null, -1, &watches, false);
for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), gens[id], &queue);
setStdinRaw() catch {}; // stdin may be a pipe, not a pty — best effort
@@ -2095,6 +2126,9 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
{
const surface = try core.render(frame_arena.allocator());
try dumpGrid(gpa, surface);
+ // The grid protocol writes canonical cells; panel tracks are metadata
+ // for a compositor it deliberately does not run.
+ core.acknowledgePanelPresentation(&.{});
}
while (!core.quit) {
const r = try feed.pump(gpa, core, null);
@@ -2136,12 +2170,12 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
.files_changed => {},
.command => |line| gpa.free(line),
};
- drainEffects(core, &ptys, &gens, io, gpa, &queue, &pipe_tasks, null, -1, &watches, true);
+ drainEffects(core, &prompt_rcs, &ptys, &gens, io, gpa, &queue, &pipe_tasks, null, -1, &watches, true);
pollCwds(core, &ptys);
// The grid harness polls stdin at the same 16 ms cadence as native
// SDL. Advancing here lets `stable` wait for exact endpoint colors;
// once inactive it resumes the old event-only frame contract.
- if (core.themeAnimationActive()) {
+ if (core.animationActive()) {
core.update(.tick);
n_events += 1;
}
@@ -2149,6 +2183,7 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
_ = frame_arena.reset(.retain_capacity);
const surface = try core.render(frame_arena.allocator());
try dumpGrid(gpa, surface);
+ core.acknowledgePanelPresentation(&.{});
}
}
@@ -2217,13 +2252,15 @@ const StdinFeed = struct {
_ = c.SDL_GetWindowSizeInPixels(gp.window, &wpw, &wph);
const win_w: f32 = @floatFromInt(@max(wpw, 1));
const win_h: f32 = @floatFromInt(@max(wph, 1));
- if (core.crt_on) {
- // touch points are physical: same CRT mapping as the mouse
- const m = crt.map(finger.x * win_w, finger.y * win_h, win_w, win_h);
+ if (mapScenePoint(gp, finger.x * win_w, finger.y * win_h, win_w, win_h)) |m| {
finger.x = m.x / win_w;
finger.y = m.y / win_h;
+ handleFinger(&gp.touch, core, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h));
+ } else {
+ finger.kind = .cancel;
+ handleFinger(&gp.touch, core, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h));
+ core.update(.pointer_leave);
}
- handleFinger(&gp.touch, core, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h));
} else {
handleFinger(&grid_touch, core, finger, @floatFromInt(core.screen_w), @floatFromInt(core.screen_h), 1, 1);
}
@@ -2414,6 +2451,11 @@ fn dumpGrid(gpa: std.mem.Allocator, surface: *pardes.Surface) !void {
fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
switch (sev.type) {
c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => core.quit = true,
+ c.SDL_EVENT_WINDOW_MOUSE_LEAVE => {
+ g.pointer_present = false;
+ g.pointer_mapped = false;
+ core.update(.pointer_leave);
+ },
// window resizes are picked up by the per-frame grid check
c.SDL_EVENT_KEY_DOWN => {
g.live_ctrl = (sev.key.mod & c.SDL_KMOD_CTRL) != 0;
@@ -2461,6 +2503,7 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
if (want != g.px) {
g.px = want;
refitFont(g, core);
+ observeGuiFont(g, core);
}
return;
}
@@ -2492,15 +2535,30 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
},
c.SDL_EVENT_MOUSE_BUTTON_DOWN, c.SDL_EVENT_MOUSE_BUTTON_UP => {
const b = sev.button;
- g.mouse_x = b.x;
- g.mouse_y = b.y;
const button: pardes.Mouse.Button = switch (b.button) {
1 => .left,
2 => .middle,
3 => .right,
else => return,
};
- const mc = mouseCell(g, core, b.x, b.y);
+ g.pad_x = b.x;
+ g.pad_y = b.y;
+ g.pointer_present = true;
+ const mc = mouseCell(g, b.x, b.y) orelse {
+ g.pointer_mapped = false;
+ // A release outside the visible CRT tube still ends a drag at
+ // its last real cell; a press on black margin is inert.
+ if (!b.down) if (g.pointer_cell) |last| core.update(.{ .mouse = .{
+ .button = button,
+ .kind = .release,
+ .col = last.col,
+ .row = last.row,
+ } });
+ core.update(.pointer_leave);
+ return;
+ };
+ g.pointer_mapped = true;
+ g.pointer_cell = mc;
core.update(.{
.mouse = .{
.button = button,
@@ -2516,11 +2574,10 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
},
c.SDL_EVENT_MOUSE_MOTION => {
const m = sev.motion;
- g.mouse_x = m.x;
- g.mouse_y = m.y;
// pad cursor continues from wherever the pointer last was
g.pad_x = m.x;
g.pad_y = m.y;
+ g.pointer_present = true;
// drag = motion with a button held (selection extension keys off it)
const held: ?pardes.Mouse.Button = if ((m.state & c.SDL_BUTTON_LMASK) != 0)
.left
@@ -2530,7 +2587,13 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
.right
else
null;
- const mc = mouseCell(g, core, m.x, m.y);
+ const mc = mouseCell(g, m.x, m.y) orelse {
+ g.pointer_mapped = false;
+ core.update(.pointer_leave);
+ return;
+ };
+ g.pointer_mapped = true;
+ g.pointer_cell = mc;
core.update(.{ .mouse = .{
.button = held orelse .none,
.kind = if (held != null) .drag else .motion,
@@ -2541,9 +2604,16 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
c.SDL_EVENT_MOUSE_WHEEL => {
const w = sev.wheel;
if (w.y == 0 and w.x == 0) return;
- g.mouse_x = w.mouse_x;
- g.mouse_y = w.mouse_y;
- const mc = mouseCell(g, core, w.mouse_x, w.mouse_y);
+ g.pad_x = w.mouse_x;
+ g.pad_y = w.mouse_y;
+ g.pointer_present = true;
+ const mc = mouseCell(g, w.mouse_x, w.mouse_y) orelse {
+ g.pointer_mapped = false;
+ core.update(.pointer_leave);
+ return;
+ };
+ g.pointer_mapped = true;
+ g.pointer_cell = mc;
if (w.y != 0 and std.math.isFinite(w.y)) {
// Preserve SDL's floating-point distance. Input events drained
// in this loop naturally form one render batch; stepScroll
@@ -2607,30 +2677,19 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
var pw: c_int = 0;
var ph: c_int = 0;
_ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph);
- var win_w: f32 = @floatFromInt(@max(pw, 1));
- var win_h: f32 = @floatFromInt(@max(ph, 1));
- var fcw: f32 = @floatFromInt(g.cell_w);
- var fch: f32 = @floatFromInt(g.cell_h);
- if (!is_emscripten and core.crt_on) {
- // touch points are physical too: same CRT mapping as the mouse
- const m = crt.map(finger.x * win_w, finger.y * win_h, win_w, win_h);
+ const win_w: f32 = @floatFromInt(@max(pw, 1));
+ const win_h: f32 = @floatFromInt(@max(ph, 1));
+ const fcw: f32 = @floatFromInt(g.cell_w);
+ const fch: f32 = @floatFromInt(g.cell_h);
+ if (mapScenePoint(g, finger.x * win_w, finger.y * win_h, win_w, win_h)) |m| {
finger.x = m.x / win_w;
finger.y = m.y / win_h;
+ handleFinger(&g.touch, core, finger, win_w, win_h, fcw, fch);
+ } else {
+ finger.kind = .cancel;
+ handleFinger(&g.touch, core, finger, win_w, win_h, fcw, fch);
+ core.update(.pointer_leave);
}
- if (is_emscripten) {
- // remap normalized coords into the letterboxed grid so the
- // shared touch machine's cell math stays exact
- const layout = cellLayout(g, core.screen_w, core.screen_h, @intCast(@max(pw, 1)), @intCast(@max(ph, 1)));
- const grid_w = @max(1.0, @as(f32, @floatFromInt(core.screen_w)) * layout.w);
- const grid_h = @max(1.0, @as(f32, @floatFromInt(core.screen_h)) * layout.h);
- finger.x = std.math.clamp((finger.x * win_w - layout.x_off) / grid_w, 0.0, 1.0);
- finger.y = std.math.clamp((finger.y * win_h - layout.y_off) / grid_h, 0.0, 1.0);
- win_w = grid_w;
- win_h = grid_h;
- fcw = layout.w;
- fch = layout.h;
- }
- handleFinger(&g.touch, core, finger, win_w, win_h, fcw, fch);
},
c.SDL_EVENT_PINCH_BEGIN, c.SDL_EVENT_PINCH_UPDATE => core.update(.{ .pinch = sev.pinch.scale }),
// ---- steamdeck: first gamepad drives a virtual mouse (buttons here,
@@ -2677,29 +2736,30 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
var acts: [3]deck.Action = undefined;
for (acts[0..g.deck.feed(in, &acts)]) |act| switch (act) {
.move => |mv| {
- var pw: c_int = 0;
- var ph: c_int = 0;
- _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph);
- // pad_x/pad_y stay PHYSICAL; mouseCell applies the CRT
- // mapping once at cell conversion, never into the pad
- const old = mouseCell(g, core, g.pad_x, g.pad_y);
- g.pad_x = std.math.clamp(g.pad_x + mv.dx, 0, @as(f32, @floatFromInt(@max(pw, 1))) - 1);
- g.pad_y = std.math.clamp(g.pad_y + mv.dy, 0, @as(f32, @floatFromInt(@max(ph, 1))) - 1);
+ const geometry = windowGeometry(g.window);
+ const old = mouseCellWithGeometry(g, g.pad_x, g.pad_y, geometry);
+ // deck.Action.move is in physical screen pixels. Keep the
+ // stored/warped cursor in SDL window coordinates.
+ const dx = mv.dx * geometry.window_w / geometry.pixel_w;
+ const dy = mv.dy * geometry.window_h / geometry.pixel_h;
+ g.pad_x = std.math.clamp(g.pad_x + dx, 0, geometry.window_w - 1);
+ g.pad_y = std.math.clamp(g.pad_y + dy, 0, geometry.window_h - 1);
// the SDL cursor (plan9 arrow) rides along, so the pad
// cursor and a hardware mouse are one visible pointer
c.SDL_WarpMouseInWindow(g.window, g.pad_x, g.pad_y);
- const mc = mouseCell(g, core, g.pad_x, g.pad_y);
- if (mc.col == old.col and mc.row == old.row) continue;
+ g.pointer_present = true;
+ const mc = mouseCellWithGeometry(g, g.pad_x, g.pad_y, geometry) orelse {
+ g.pointer_mapped = false;
+ core.update(.pointer_leave);
+ continue;
+ };
+ g.pointer_mapped = true;
+ g.pointer_cell = mc;
+ if (old) |previous|
+ if (mc.col == previous.col and mc.row == previous.row) continue;
// moving with a click held drags, so selections stretch
// (a firm right-pad press drags-selects like a laptop pad)
- const held: ?pardes.Mouse.Button = if (g.deck.r2_held or g.deck.rpad_click)
- .left
- else if (g.deck.l2_held)
- .middle
- else if (g.deck.look_held)
- .right
- else
- null;
+ const held = heldPointerButton(g);
core.update(.{ .mouse = .{
.button = held orelse .none,
.kind = if (held != null) .drag else .motion,
@@ -2708,20 +2768,44 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
} });
},
.click => |ck| {
- const mc = mouseCell(g, core, g.pad_x, g.pad_y);
+ const button: pardes.Mouse.Button = switch (ck.which) {
+ .select => .left,
+ .execute => .middle,
+ .look => .right,
+ };
+ g.pointer_present = true;
+ const mc = mouseCell(g, g.pad_x, g.pad_y) orelse {
+ g.pointer_mapped = false;
+ // Mirror hardware mouse releases: black CRT margins
+ // are inert for presses, but cannot strand a drag whose
+ // button was pressed over the visible tube.
+ if (!ck.down) if (g.pointer_cell) |last| core.update(.{ .mouse = .{
+ .button = button,
+ .kind = .release,
+ .col = last.col,
+ .row = last.row,
+ } });
+ core.update(.pointer_leave);
+ continue;
+ };
+ g.pointer_mapped = true;
+ g.pointer_cell = mc;
core.update(.{ .mouse = .{
- .button = switch (ck.which) {
- .select => .left,
- .execute => .middle,
- .look => .right,
- },
+ .button = button,
.kind = if (ck.down) .press else .release,
.col = mc.col,
.row = mc.row,
} });
},
.wheel => |ticks| {
- const mc = mouseCell(g, core, g.pad_x, g.pad_y);
+ g.pointer_present = true;
+ const mc = mouseCell(g, g.pad_x, g.pad_y) orelse {
+ g.pointer_mapped = false;
+ core.update(.pointer_leave);
+ continue;
+ };
+ g.pointer_mapped = true;
+ g.pointer_cell = mc;
var left = ticks;
while (left != 0) {
left += if (ticks > 0) -1 else 1;
@@ -2787,46 +2871,56 @@ fn keyDown(core: *pardes.Pardes, sym: c.SDL_Keycode, mod: c.SDL_Keymod) void {
}
fn pixelCell(px: f32, cell: u32) u16 {
- return pixelCellF(px, @floatFromInt(cell));
+ const idx = @floor(@max(px, 0) / @as(f32, @floatFromInt(@max(cell, 1))));
+ return @intFromFloat(@min(idx, 10_000));
}
-fn pixelCellF(px: f32, cell: f32) u16 {
- const idx = @floor(@max(px, 0) / @max(cell, 1));
- return @intFromFloat(@min(idx, 10_000));
+fn mapScenePoint(g: *const Gui, x: f32, y: f32, w: f32, h: f32) ?crt.Point {
+ return crt.mapScene(x, y, w, h, g.presented_scene.effects, g.presented_scene.time_seconds);
}
-/// mouse coords → cell. Native windows are pixel-exact; web SDL reports CSS
-/// points into a letterboxed canvas backed at web_render_scale. With the Crt
-/// builtin on, the physical point first maps to the scene point the shader
-/// displays there (crt.map) — this is the one physical→logical spot shared
-/// by the real mouse and the deck's virtual cursor.
-fn mouseCell(g: *const Gui, core: *const pardes.Pardes, x: f32, y: f32) struct { col: u16, row: u16 } {
- if (!is_emscripten) {
- var mx = x;
- var my = y;
- if (core.crt_on) {
- var pw: c_int = 0;
- var ph: c_int = 0;
- _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph);
- const m = crt.map(mx, my, @floatFromInt(@max(pw, 1)), @floatFromInt(@max(ph, 1)));
- mx = m.x;
- my = m.y;
- }
- return .{ .col = pixelCell(mx, g.cell_w), .row = pixelCell(my, g.cell_h) };
- }
- var pw: c_int = 0;
- var ph: c_int = 0;
- _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph);
- var ww: c_int = 0;
- var wh: c_int = 0;
- _ = c.SDL_GetWindowSize(g.window, &ww, &wh);
- const px = x * @as(f32, @floatFromInt(@max(pw, 1))) / @as(f32, @floatFromInt(@max(ww, 1)));
- const py = y * @as(f32, @floatFromInt(@max(ph, 1))) / @as(f32, @floatFromInt(@max(wh, 1)));
- const layout = cellLayout(g, core.screen_w, core.screen_h, @intCast(@max(pw, 1)), @intCast(@max(ph, 1)));
- return .{
- .col = pixelCellF(px - layout.x_off, layout.w),
- .row = pixelCellF(py - layout.y_off, layout.h),
+fn mouseCellWithGeometry(g: *const Gui, x: f32, y: f32, geometry: WindowGeometry) ?MouseCell {
+ const physical = windowPointToPixels(geometry, x, y);
+ const mapped = mapScenePoint(g, physical.x, physical.y, geometry.pixel_w, geometry.pixel_h) orelse return null;
+ return .{ .col = pixelCell(mapped.x, g.cell_w), .row = pixelCell(mapped.y, g.cell_h) };
+}
+
+/// SDL window coords → the scene cell displayed at that physical point.
+/// Mouse, touch and the Deck pointer all share the same CRT/ripple/glitch map.
+fn mouseCell(g: *const Gui, x: f32, y: f32) ?MouseCell {
+ return mouseCellWithGeometry(g, x, y, windowGeometry(g.window));
+}
+
+fn heldPointerButton(g: *const Gui) ?pardes.Mouse.Button {
+ const buttons = c.SDL_GetMouseState(null, null);
+ if ((buttons & c.SDL_BUTTON_LMASK) != 0 or g.deck.r2_held or g.deck.rpad_click) return .left;
+ if ((buttons & c.SDL_BUTTON_MMASK) != 0 or g.deck.l2_held) return .middle;
+ if ((buttons & c.SDL_BUTTON_RMASK) != 0 or g.deck.look_held) return .right;
+ return null;
+}
+
+/// Reconcile the retained physical point with the exact scene image the GPU
+/// just accepted. Same-cell frames are deliberately silent: otherwise a 60 Hz
+/// scene would continuously reset the core's hover debounce.
+fn refreshPresentedPointer(g: *Gui, core: *pardes.Pardes) void {
+ if (!g.pointer_present) return;
+ const mc = mouseCell(g, g.pad_x, g.pad_y) orelse {
+ if (g.pointer_mapped) core.update(.pointer_leave);
+ g.pointer_mapped = false;
+ return;
};
+ const moved = !g.pointer_mapped or g.pointer_cell == null or
+ g.pointer_cell.?.col != mc.col or g.pointer_cell.?.row != mc.row;
+ g.pointer_mapped = true;
+ g.pointer_cell = mc;
+ if (!moved) return;
+ const held = heldPointerButton(g);
+ core.update(.{ .mouse = .{
+ .button = held orelse .none,
+ .kind = if (held != null) .drag else .motion,
+ .col = mc.col,
+ .row = mc.row,
+ } });
}
/// steamdeck support: poll the sticks each frame, mirroring the trackpads —
@@ -2835,27 +2929,44 @@ fn mouseCell(g: *const Gui, core: *const pardes.Pardes, x: f32, y: f32) struct {
/// ticks (both axes: vertical + horizontal) at the cursor position.
fn pollGamepad(g: *Gui, core: *pardes.Pardes) void {
const pad = g.gamepad orelse return;
- var pw: c_int = 0;
- var ph: c_int = 0;
- _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph);
- const win_w: f32 = @floatFromInt(@max(pw, 1));
- const win_h: f32 = @floatFromInt(@max(ph, 1));
+ const geometry = windowGeometry(g.window);
const deadzone: f32 = 8000;
// right stick = pointer
const ax: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_RIGHTX));
const ay: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_RIGHTY));
- const old = mouseCell(g, core, g.pad_x, g.pad_y);
+ const old = mouseCellWithGeometry(g, g.pad_x, g.pad_y, geometry);
// full tilt ≈ 0.4 cell-heights per frame: a gentle, aimable glide (the
// mouse-move sensitivity knob — raise for a faster pointer)
const speed: f32 = @as(f32, @floatFromInt(g.cell_h)) * 0.4;
- if (@abs(ax) > deadzone) g.pad_x = std.math.clamp(g.pad_x + ax / 32767.0 * speed, 0, win_w - 1);
- if (@abs(ay) > deadzone) g.pad_y = std.math.clamp(g.pad_y + ay / 32767.0 * speed, 0, win_h - 1);
+ const speed_x = speed * geometry.window_w / geometry.pixel_w;
+ const speed_y = speed * geometry.window_h / geometry.pixel_h;
+ const pointer_moved = @abs(ax) > deadzone or @abs(ay) > deadzone;
+ if (@abs(ax) > deadzone) g.pad_x = std.math.clamp(g.pad_x + ax / 32767.0 * speed_x, 0, geometry.window_w - 1);
+ if (@abs(ay) > deadzone) g.pad_y = std.math.clamp(g.pad_y + ay / 32767.0 * speed_y, 0, geometry.window_h - 1);
// only on actual stick movement — an unconditional per-frame warp would
// pin the pointer and fight any hardware mouse
- if (@abs(ax) > deadzone or @abs(ay) > deadzone) c.SDL_WarpMouseInWindow(g.window, g.pad_x, g.pad_y);
- const mc = mouseCell(g, core, g.pad_x, g.pad_y);
- if (mc.col != old.col or mc.row != old.row)
- core.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = mc.col, .row = mc.row } });
+ if (pointer_moved) {
+ g.pointer_present = true;
+ c.SDL_WarpMouseInWindow(g.window, g.pad_x, g.pad_y);
+ }
+ const mc = mouseCellWithGeometry(g, g.pad_x, g.pad_y, geometry) orelse {
+ if (g.pointer_present and g.pointer_mapped) core.update(.pointer_leave);
+ if (g.pointer_present) g.pointer_mapped = false;
+ return;
+ };
+ if (g.pointer_present) {
+ g.pointer_mapped = true;
+ g.pointer_cell = mc;
+ if (old == null or mc.col != old.?.col or mc.row != old.?.row) {
+ const held = heldPointerButton(g);
+ core.update(.{ .mouse = .{
+ .button = held orelse .none,
+ .kind = if (held != null) .drag else .motion,
+ .col = mc.col,
+ .row = mc.row,
+ } });
+ }
+ }
// left stick = scroll (both axes)
const lx: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_LEFTX));
const ly: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_LEFTY));
@@ -2879,6 +2990,7 @@ fn pollGamepad(g: *Gui, core: *pardes.Pardes) void {
fn drainEffects(
core: *pardes.Pardes,
+ prompt_rcs: *const shell_bin.PromptRcs,
ptys: *[pardes.MAX_PANES]?Pty,
gens: *[pardes.MAX_PANES]u32,
io: std.Io,
@@ -2887,7 +2999,7 @@ fn drainEffects(
pipe_tasks: *PipeTasks,
g: ?*Gui, // null in grid test mode (no SDL: clipboard effects are no-ops)
inotify_fd: c_int,
- watches: *[pardes.MAX_PANES]?Watch,
+ watches: *file_watch.Table,
threads_ok: bool,
) void {
while (core.nextEffect()) |effect| switch (effect) {
@@ -2909,7 +3021,7 @@ fn drainEffects(
cwd_buf[cwd.len] = 0;
cwd_z = @ptrCast(&cwd_buf);
}
- const pt = forkShell(core.shellBin(), cwd_z, core.screen_h, core.screen_w);
+ const pt = forkShell(core, sp.pane, prompt_rcs, core.shellBin(), cwd_z, core.screen_h, core.screen_w);
ptys[sp.pane] = pt;
// report the pane's starting directory back to the core (tags); the
// slot needs no occupancy reset, nothing about it is remembered
@@ -2940,7 +3052,10 @@ fn drainEffects(
_ = libc.close(fd);
// our own write is about to come back as a watch event: restamp
// from the bytes we just put there so it reads as "no change"
- if (watches[sf.pane]) |*w| w.hash = std.hash.Wyhash.hash(0, f.content);
+ if (watches[sf.pane]) |*w| if (w.serial == pane.serial) switch (w.generation) {
+ .text => w.generation = .{ .text = std.hash.Wyhash.hash(0, f.content) },
+ .pdf => {},
+ };
// ...and say so on the pane's message row. AFTER the write, not
// beside it: every `continue` above is a save that did not happen
// and must not be reported as one.
@@ -2988,26 +3103,19 @@ fn drainEffects(
.lsp => |e| if (threads_ok) spawnLsp(core, queue, e),
.pipe => |e| if (threads_ok) spawnPipe(core, io, gpa, queue, pipe_tasks, e),
.watch => |w| {
- // starting, the path and the on-disk bytes are read off the core
- // (same split as save_file); stopping, the pane is already gone
- var path: ?[]const u8 = null;
- var hash: u64 = 0;
- if (w.on) if (core.panes[w.pane]) |pane| if (pane.file) |f| {
- path = f.path;
- hash = std.hash.Wyhash.hash(0, f.content);
- };
- watchPane(inotify_fd, watches, w.pane, path, hash);
+ if (file_watch.applyEffect(core, io, gpa, inotify_fd, watches, w.pane, w.on))
+ queue.push(.files_changed);
},
.quit => {},
};
}
-fn forkShell(bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) Pty {
+fn forkShell(core: *pardes.Pardes, pane: usize, prompt_rcs: *const shell_bin.PromptRcs, bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) Pty {
var master: c_int = undefined;
// resolved BEFORE the fork, into this frame, which the child inherits:
// nothing between fork and exec may allocate, and a PATH search would
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const spawn = shell_bin.resolve(bin, &path_buf);
+ const spawn = shell_bin.resolve(bin, &path_buf, prompt_rcs);
const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
const pid = forkpty(&master, null, null, &ws);
if (pid == 0) {
@@ -3015,6 +3123,7 @@ fn forkShell(bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) Pty {
_ = execv(spawn.path, &spawn.argv);
_exit(127);
}
+ if (pid > 0) core.acknowledgeShell(pane, std.mem.span(spawn.path), spawn.argv[1] != null);
return .{ .fd = master, .pid = pid };
}
@@ -3038,64 +3147,6 @@ fn ttyTakenAt(ctx: ?*anyopaque, pane: usize) bool {
return look.ttyTaken(pt.pid, pt.fd);
}
-/// web: no ptys, no fs — panes replay from the embedded dump. Only the
-/// clipboard and open_link effects have a browser meaning.
-fn drainEffectsWeb(core: *pardes.Pardes, gpa: std.mem.Allocator, g: *Gui) void {
- _ = g;
- while (core.nextEffect()) |effect| switch (effect) {
- .set_clipboard => {
- const y = core.yank orelse continue;
- const z = gpa.dupeZ(u8, y) catch continue;
- defer gpa.free(z);
- _ = c.SDL_SetClipboardText(z.ptr);
- },
- .read_clipboard => {
- // emscripten fills SDL's clipboard only from a real paste gesture,
- // so an empty answer here is the ordinary case and not a failure.
- const raw = c.SDL_GetClipboardText() orelse continue;
- defer c.SDL_free(raw);
- const text = std.mem.span(raw);
- if (text.len == 0) continue;
- core.update(.{ .paste = text });
- },
- // look on a URL → a new tab
- .open_link => |url| openLinkWeb(gpa, url.slice()),
- // nothing to spawn/write/resize/save/dump into, and no filesystem to
- // watch — all no-ops
- .spawn, .write, .resize_pty, .save_file, .new_file, .write_dump, .lsp, .pipe, .watch, .quit => {},
- };
-}
-
-/// window.open in a tiny generated script; single quotes and backslashes are
-/// %-escaped so the url can't break out of the JS string literal.
-fn openLinkWeb(gpa: std.mem.Allocator, url: []const u8) void {
- const prefix = "window.open('";
- const suffix = "','_blank')";
- var escaped_len: usize = 0;
- for (url) |ch| escaped_len = std.math.add(usize, escaped_len, if (ch == '\'' or ch == '\\') 3 else 1) catch return;
- const script_len = std.math.add(usize, prefix.len + suffix.len, escaped_len) catch return;
- const script = gpa.allocSentinel(u8, script_len, 0) catch return;
- defer gpa.free(script);
- @memcpy(script[0..prefix.len], prefix);
- var at = prefix.len;
- for (url) |ch| switch (ch) {
- '\'' => {
- @memcpy(script[at..][0..3], "%27");
- at += 3;
- },
- '\\' => {
- @memcpy(script[at..][0..3], "%5C");
- at += 3;
- },
- else => {
- script[at] = ch;
- at += 1;
- },
- };
- @memcpy(script[at..][0..suffix.len], suffix);
- std.os.emscripten.emscripten_run_script(script.ptr);
-}
-
// =====================================================================
// fractional scroll: whole rows for the core, sub-row offsets for the picture
// =====================================================================
@@ -3247,7 +3298,7 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void {
g.scroll_rect = r;
// the body origin travels with the rect: the two draw sites below have no
// core to ask, and with Tagbottom the body starts at r.y, not r.y + BOX_H
- const body_y = if (core.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const body_y = if (core.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
g.scroll_body_y = body_y;
if (st.rows != 0) {
// The offset opens a gap at the trailing edge of the travel, and what
@@ -3284,7 +3335,10 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void {
/// in one flat pass, so without it the overhanging rows would land on the
/// pane's own tag and on whatever is below it.
fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32, page: [3]u8) u32 {
- if (g.scroll_pane == null or g.scroll_lag == 0) return 0;
+ const scroll_pane = g.scroll_pane orelse return 0;
+ if (g.scroll_lag == 0) return 0;
+ for (surface.panelTracks()) |track|
+ if (track.active() and track.pane == scroll_pane) return 0;
const r = g.scroll_rect;
if (r.w <= config.GUTTER or r.h <= pardes.BOX_H) return 0;
const x0 = r.x + config.GUTTER;
@@ -3303,7 +3357,7 @@ fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pard
var col = x0;
while (col < x0 + bw) : (col += 1) {
const sidx: u32 = @as(u32, row) * surface.cols + col;
- emitInstance(g, instances, base + n, col, row, shifted, win_w, win_h, surface.at(col, row), sidx == cursor_idx and !cursor_bar, page);
+ emitInstance(g, instances, base + n, col, row, shifted, win_w, win_h, null, surface.at(col, row), false, sidx == cursor_idx and !cursor_bar, page);
n += 1;
}
}
@@ -3313,7 +3367,7 @@ fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pard
const erow: u16 = if (g.scroll_lag > 0) y0 + bh else y0 - 1;
var i: u16 = 0;
while (i < bw) : (i += 1) {
- emitInstance(g, instances, base + n, x0 + i, erow, shifted, win_w, win_h, &g.scroll_edge[i], false, page);
+ emitInstance(g, instances, base + n, x0 + i, erow, shifted, win_w, win_h, null, &g.scroll_edge[i], false, false, page);
n += 1;
}
}
@@ -3358,6 +3412,52 @@ fn surfaceHasNativeKey(surface: *const pardes.Surface, key: pardes.ImageCacheKey
return false;
}
+fn snapshotHasNativeKey(g: *const Gui, key: pardes.ImageCacheKey) bool {
+ for (g.presented_images.items) |place|
+ if (place.key.eql(key)) return true;
+ return false;
+}
+
+fn rememberPresentedImages(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface) void {
+ var next: std.ArrayListUnmanaged(SavedImagePlace) = .empty;
+ var adopted = false;
+ defer if (!adopted) next.deinit(gpa);
+ next.ensureTotalCapacity(gpa, surface.nimages) catch return;
+ for (surface.images[0..surface.nimages]) |maybe| {
+ const place = maybe orelse continue;
+ if (validImageBytes(place) == null or !g.native_images.contains(place.cacheKey())) continue;
+ next.appendAssumeCapacity(SavedImagePlace.from(place));
+ }
+ var old = g.presented_images;
+ g.presented_images = next;
+ adopted = true;
+ old.deinit(gpa);
+}
+
+fn sameSavedPlacement(saved: SavedImagePlace, current: pardes.ImagePlace) bool {
+ return saved.key.eql(current.cacheKey()) and
+ saved.pane == current.pane and saved.serial == current.serial and
+ saved.x == current.x and saved.y == current.y and
+ saved.w == current.w and saved.h == current.h and
+ saved.iw == current.iw and saved.ih == current.ih and
+ std.meta.eql(saved.native, current.native);
+}
+
+fn snapshotContainsPlacement(g: *const Gui, current: pardes.ImagePlace) bool {
+ for (g.presented_images.items) |saved|
+ if (sameSavedPlacement(saved, current)) return true;
+ return false;
+}
+
+fn placeIntersectsBox(place: SavedImagePlace, box: pardes.panel_animation.Box) bool {
+ const x0: f32 = @floatFromInt(place.x);
+ const y0: f32 = @floatFromInt(place.y);
+ const x1: f32 = @floatFromInt(@as(u32, place.x) + place.w);
+ const y1: f32 = @floatFromInt(@as(u32, place.y) + place.h);
+ return x0 < box.x + box.w and x1 > box.x and
+ y0 < box.y + box.h and y1 > box.y;
+}
+
fn ensureImageBuffers(g: *Gui, needed: u32) bool {
if (needed <= g.image_capacity) return true;
var capacity = @max(@as(u32, 1), g.image_capacity);
@@ -3402,13 +3502,13 @@ fn validImageBytes(place: pardes.ImagePlace) ?u32 {
const NativeBounds = struct { w: u32, h: u32 };
-fn nativeBounds(g: *const Gui, place: pardes.ImagePlace) ?NativeBounds {
+fn nativeBounds(g: *const Gui, place: anytype) ?NativeBounds {
const bound_w = std.math.mul(u32, place.w, g.cell_w) catch return null;
const bound_h = std.math.mul(u32, place.h, g.cell_h) catch return null;
return .{ .w = bound_w, .h = bound_h };
}
-fn nativePlaceGeometry(g: *const Gui, place: pardes.ImagePlace) ?pardes.image.NativeGeometry {
+fn nativePlaceGeometry(g: *const Gui, place: anytype) ?pardes.image.NativeGeometry {
if (comptime pardes.pdf_enabled) {
if (place.native.geometry) |geometry| return geometry;
const bounds = nativeBounds(g, place) orelse return null;
@@ -3425,7 +3525,7 @@ fn nativePlaceGeometry(g: *const Gui, place: pardes.ImagePlace) ?pardes.image.Na
return null;
}
-fn nativePlaceDrawable(g: *const Gui, place: pardes.ImagePlace) bool {
+fn nativePlaceDrawable(g: *const Gui, place: anytype) bool {
if (comptime pardes.pdf_enabled) return nativePlaceGeometry(g, place) != null;
const bounds = nativeBounds(g, place) orelse return false;
const fit = pardes.image.contain(place.iw, place.ih, bounds.w, bounds.h);
@@ -3493,14 +3593,91 @@ fn uploadNativeTexture(
/// Upload new pixel generations and the small per-frame placement buffer.
/// Returns the number of image instances drawNativeImagesGpu will consume.
+// EFFECT_CODE_NATIVE_PANEL_BEGIN
+fn appendPreparedImage(
+ g: *Gui,
+ gpa: std.mem.Allocator,
+ place: SavedImagePlace,
+ track: ?pardes.panel_animation.Track,
+ clip: ?pardes.panel_animation.Box,
+ old_layer: bool,
+) void {
+ const texture = g.native_images.get(place.key) orelse return;
+ if (!nativePlaceDrawable(g, place)) return;
+ g.prepared_images.append(gpa, .{
+ .place = place,
+ .texture = texture,
+ .track = track,
+ .clip = clip,
+ .old_layer = old_layer,
+ }) catch {};
+}
+
+fn emitPreparedImage(
+ g: *Gui,
+ instance: *ImageInstance,
+ prepared: PreparedImage,
+ layout: CellLayout,
+ win_w: f32,
+ win_h: f32,
+) bool {
+ const place = prepared.place;
+ const body_x = @as(u32, place.x) * g.cell_w;
+ const body_y = @as(u32, place.y) * g.cell_h;
+ if (comptime pardes.pdf_enabled) {
+ const geometry = nativePlaceGeometry(g, place) orelse return false;
+ const px0 = @as(f32, @floatFromInt(body_x + geometry.dst.x));
+ const py0 = @as(f32, @floatFromInt(body_y + geometry.dst.y)) + place.native.pixel_offset_y;
+ const px1 = px0 + @as(f32, @floatFromInt(geometry.dst.w));
+ const py1 = py0 + @as(f32, @floatFromInt(geometry.dst.h));
+ const image_w: f32 = @floatFromInt(place.iw);
+ const image_h: f32 = @floatFromInt(place.ih);
+ instance.* = .{
+ .x0 = (px0 / win_w) * 2.0 - 1.0,
+ .y0 = 1.0 - (py0 / win_h) * 2.0,
+ .x1 = (px1 / win_w) * 2.0 - 1.0,
+ .y1 = 1.0 - (py1 / win_h) * 2.0,
+ .u0 = @as(f32, @floatFromInt(geometry.src.x)) / image_w,
+ .v0 = @as(f32, @floatFromInt(geometry.src.y)) / image_h,
+ .u1 = @as(f32, @floatFromInt(geometry.src.x + geometry.src.w)) / image_w,
+ .v1 = @as(f32, @floatFromInt(geometry.src.y + geometry.src.h)) / image_h,
+ };
+ } else {
+ const bounds = nativeBounds(g, place) orelse return false;
+ const fit = pardes.image.contain(place.iw, place.ih, bounds.w, bounds.h);
+ if (fit.w == 0 or fit.h == 0) return false;
+ instance.* = .{
+ .x0 = (@as(f32, @floatFromInt(body_x)) / win_w) * 2.0 - 1.0,
+ .y0 = 1.0 - (@as(f32, @floatFromInt(body_y)) / win_h) * 2.0,
+ .x1 = (@as(f32, @floatFromInt(body_x + fit.w)) / win_w) * 2.0 - 1.0,
+ .y1 = 1.0 - (@as(f32, @floatFromInt(body_y + fit.h)) / win_h) * 2.0,
+ .u0 = 0,
+ .v0 = 0,
+ .u1 = 1,
+ .v1 = 1,
+ };
+ }
+ setTransitionFields(
+ instance,
+ prepared.track,
+ layout,
+ win_w,
+ win_h,
+ if (prepared.track) |active| panelGridSize(active) else 0,
+ );
+ if (prepared.old_layer) instance.effect |= old_layer_bit;
+ return true;
+}
+
fn prepareNativeImages(
g: *Gui,
gpa: std.mem.Allocator,
cmd: *c.SDL_GPUCommandBuffer,
surface: *pardes.Surface,
+ plan: *PaintPlan,
sw: u32,
sh: u32,
-) u32 {
+) bool {
for (surface.images[0..surface.nimages]) |maybe| {
const place = maybe orelse continue;
if (validImageBytes(place) == null) continue;
@@ -3512,7 +3689,8 @@ fn prepareNativeImages(
var stale_len: usize = 0;
var iterator = g.native_images.iterator();
while (iterator.next()) |entry| {
- if (surfaceHasNativeKey(surface, entry.key_ptr.*)) continue;
+ if (surfaceHasNativeKey(surface, entry.key_ptr.*) or
+ snapshotHasNativeKey(g, entry.key_ptr.*)) continue;
stale[stale_len] = entry.key_ptr.*;
stale_len += 1;
if (stale_len == stale.len) break;
@@ -3521,94 +3699,151 @@ fn prepareNativeImages(
if (stale_len < stale.len) break;
}
- var count: u32 = 0;
- for (surface.images[0..surface.nimages]) |maybe| {
- const place = maybe orelse continue;
- if (g.native_images.contains(place.cacheKey()) and nativePlaceDrawable(g, place)) count += 1;
+ g.prepared_images.clearRetainingCapacity();
+ for (plan.batches[0..plan.len], 0..) |*batch, batch_index| {
+ batch.image_start = std.math.cast(u32, g.prepared_images.items.len) orelse return false;
+ const track = batch.track;
+
+ if (track) |active| {
+ if (active.phase == .closing) {
+ for (g.presented_images.items) |saved| {
+ if (saved.serial != active.serial) continue;
+ appendPreparedImage(g, gpa, saved, active, null, false);
+ }
+ } else if (active.effect == .dissolve or active.effect == .ascii) {
+ // Old layer first. Its shader half disappears at the same
+ // per-cell threshold at which the current half appears.
+ for (g.presented_images.items) |saved| {
+ if (!placeIntersectsBox(saved, active.contentBox())) continue;
+ appendPreparedImage(g, gpa, saved, active, active.contentBox(), true);
+ }
+ }
+ }
+
+ if (track == null or track.?.phase != .closing) {
+ for (surface.images[0..surface.nimages]) |maybe| {
+ const current = maybe orelse continue;
+ if (validImageBytes(current) == null or
+ paintBatchForSerial(plan, current.serial) != batch_index) continue;
+ const saved = SavedImagePlace.from(current);
+ const current_track = if (track) |active| switch (active.effect) {
+ // An identical cached placement is semantically unchanged
+ // and must bypass a data effect exactly like an unchanged
+ // cell in Surface.changed_cells.
+ .dissolve, .ascii => if (snapshotContainsPlacement(g, current)) null else active,
+ else => active,
+ } else null;
+ appendPreparedImage(g, gpa, saved, current_track, null, false);
+ }
+ }
+ // Frozen visual under an incoming lifecycle pane. Append it after
+ // canonical static placements so it restores the old target pixels;
+ // the translated opening batch is painted later over this fixed clip.
+ if (batch_index == 0) for (plan.batches[1..plan.len]) |tracked| {
+ const active = tracked.track.?;
+ if (active.effect != .vertical or active.phase != .opening) continue;
+ for (g.presented_images.items) |saved| {
+ if (!placeIntersectsBox(saved, active.contentBox())) continue;
+ appendPreparedImage(g, gpa, saved, null, active.contentBox(), false);
+ }
+ };
+ const image_end = std.math.cast(u32, g.prepared_images.items.len) orelse return false;
+ batch.image_count = image_end - batch.image_start;
}
- if (count == 0) return 0;
- if (!ensureImageBuffers(g, count)) return 0;
- const ptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, g.image_vxfer, false) orelse return 0);
+ const capacity = std.math.cast(u32, g.prepared_images.items.len) orelse return false;
+ if (capacity == 0) return true;
+ if (!ensureImageBuffers(g, capacity)) return false;
+
+ const ptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, g.image_vxfer, false) orelse return false);
const instances: [*]ImageInstance = @ptrCast(@alignCast(ptr));
const win_w: f32 = @floatFromInt(sw);
const win_h: f32 = @floatFromInt(sh);
+ const layout = fixedCellLayout(g);
var idx: u32 = 0;
- for (surface.images[0..surface.nimages]) |maybe| {
- const place = maybe orelse continue;
- if (!g.native_images.contains(place.cacheKey())) continue;
-
- const body_x = @as(u32, place.x) * g.cell_w;
- const body_y = @as(u32, place.y) * g.cell_h;
- if (comptime pardes.pdf_enabled) {
- const geometry = nativePlaceGeometry(g, place) orelse continue;
- const px0 = @as(f32, @floatFromInt(body_x + geometry.dst.x));
- const py0 = @as(f32, @floatFromInt(body_y + geometry.dst.y)) +
- place.native.pixel_offset_y;
- const px1 = px0 + @as(f32, @floatFromInt(geometry.dst.w));
- const py1 = py0 + @as(f32, @floatFromInt(geometry.dst.h));
- const image_w: f32 = @floatFromInt(place.iw);
- const image_h: f32 = @floatFromInt(place.ih);
- instances[idx] = .{
- .x0 = (px0 / win_w) * 2.0 - 1.0,
- .y0 = 1.0 - (py0 / win_h) * 2.0,
- .x1 = (px1 / win_w) * 2.0 - 1.0,
- .y1 = 1.0 - (py1 / win_h) * 2.0,
- .u0 = @as(f32, @floatFromInt(geometry.src.x)) / image_w,
- .v0 = @as(f32, @floatFromInt(geometry.src.y)) / image_h,
- .u1 = @as(f32, @floatFromInt(geometry.src.x + geometry.src.w)) / image_w,
- .v1 = @as(f32, @floatFromInt(geometry.src.y + geometry.src.h)) / image_h,
- };
- } else {
- const bounds = nativeBounds(g, place) orelse continue;
- const fit = pardes.image.contain(place.iw, place.ih, bounds.w, bounds.h);
- if (fit.w == 0 or fit.h == 0) continue;
- instances[idx] = .{
- .x0 = (@as(f32, @floatFromInt(body_x)) / win_w) * 2.0 - 1.0,
- .y0 = 1.0 - (@as(f32, @floatFromInt(body_y)) / win_h) * 2.0,
- .x1 = (@as(f32, @floatFromInt(body_x + fit.w)) / win_w) * 2.0 - 1.0,
- .y1 = 1.0 - (@as(f32, @floatFromInt(body_y + fit.h)) / win_h) * 2.0,
- .u0 = 0,
- .v0 = 0,
- .u1 = 1,
- .v1 = 1,
- };
- }
+ for (g.prepared_images.items) |prepared| {
+ if (!emitPreparedImage(g, &instances[idx], prepared, layout, win_w, win_h)) continue;
idx += 1;
}
c.SDL_UnmapGPUTransferBuffer(g.device, g.image_vxfer);
- if (idx == 0) return 0;
+ // The planning pass already rejected undrawable placements, so a mismatch
+ // here would desynchronize batch offsets from texture bindings.
+ std.debug.assert(idx == capacity);
const copy = c.SDL_BeginGPUCopyPass(cmd);
const src = c.SDL_GPUTransferBufferLocation{ .transfer_buffer = g.image_vxfer, .offset = 0 };
const dst = c.SDL_GPUBufferRegion{ .buffer = g.image_vbuf, .offset = 0, .size = idx * @sizeOf(ImageInstance) };
c.SDL_UploadToGPUBuffer(copy, &src, &dst, false);
c.SDL_EndGPUCopyPass(copy);
- return idx;
+ return true;
+}
+
+fn imageScissor(
+ g: *const Gui,
+ prepared: PreparedImage,
+ max_w: u32,
+ max_h: u32,
+) c.SDL_Rect {
+ if (prepared.clip) |box| return panelBoxScissor(g, box, max_w, max_h);
+ if (prepared.track) |active| {
+ // Vertical travel is visible only through the pane's fixed lifecycle
+ // box. Other effects retain their historical visual-box clipping.
+ const box = if (active.effect == .vertical) active.contentBox() else active.visualBox();
+ return panelBoxScissor(g, box, max_w, max_h);
+ }
+ const place = prepared.place;
+ return .{
+ .x = @intCast(@as(u32, place.x) * g.cell_w),
+ .y = @intCast(@as(u32, place.y) * g.cell_h),
+ .w = @intCast(@as(u32, place.w) * g.cell_w),
+ .h = @intCast(@as(u32, place.h) * g.cell_h),
+ };
+}
+
+fn panelBoxScissor(g: *const Gui, box: pardes.panel_animation.Box, max_w: u32, max_h: u32) c.SDL_Rect {
+ const x0: i32 = @intFromFloat(@floor(box.x * @as(f32, @floatFromInt(g.cell_w))));
+ const y0: i32 = @intFromFloat(@floor(box.y * @as(f32, @floatFromInt(g.cell_h))));
+ const x1: i32 = @intFromFloat(@ceil((box.x + box.w) * @as(f32, @floatFromInt(g.cell_w))));
+ const y1: i32 = @intFromFloat(@ceil((box.y + box.h) * @as(f32, @floatFromInt(g.cell_h))));
+ const clipped_x0 = std.math.clamp(x0, 0, @as(i32, @intCast(max_w)));
+ const clipped_y0 = std.math.clamp(y0, 0, @as(i32, @intCast(max_h)));
+ const clipped_x1 = std.math.clamp(x1, clipped_x0, @as(i32, @intCast(max_w)));
+ const clipped_y1 = std.math.clamp(y1, clipped_y0, @as(i32, @intCast(max_h)));
+ return .{
+ .x = clipped_x0,
+ .y = clipped_y0,
+ .w = clipped_x1 - clipped_x0,
+ .h = clipped_y1 - clipped_y0,
+ };
}
-fn drawNativeImagesGpu(g: *Gui, rp: ?*c.SDL_GPURenderPass, surface: *pardes.Surface) void {
+fn drawNativeImagesGpu(
+ g: *Gui,
+ rp: ?*c.SDL_GPURenderPass,
+ surface: *pardes.Surface,
+ plan: *const PaintPlan,
+ batch_index: usize,
+) void {
const pass = rp orelse return;
+ const batch = plan.batches[batch_index];
c.SDL_BindGPUGraphicsPipeline(pass, g.image_pipeline);
- var idx: u32 = 0;
- for (surface.images[0..surface.nimages]) |maybe| {
- const place = maybe orelse continue;
- const texture = g.native_images.get(place.cacheKey()) orelse continue;
- if (!nativePlaceDrawable(g, place)) continue;
- const clip = c.SDL_Rect{
- .x = @intCast(@as(u32, place.x) * g.cell_w),
- .y = @intCast(@as(u32, place.y) * g.cell_h),
- .w = @intCast(@as(u32, place.w) * g.cell_w),
- .h = @intCast(@as(u32, place.h) * g.cell_h),
- };
- c.SDL_SetGPUScissor(pass, &clip);
- const sampler = c.SDL_GPUTextureSamplerBinding{ .texture = texture, .sampler = g.linear_sampler };
- c.SDL_BindGPUFragmentSamplers(pass, 0, &sampler, 1);
- const binding = c.SDL_GPUBufferBinding{ .buffer = g.image_vbuf, .offset = idx * @sizeOf(ImageInstance) };
- c.SDL_BindGPUVertexBuffers(pass, 0, &binding, 1);
- c.SDL_DrawGPUPrimitives(pass, 6, 1, 0, 0);
+ const max_w = @as(u32, surface.cols) * g.cell_w;
+ const max_h = @as(u32, surface.rows) * g.cell_h;
+ var idx = batch.image_start;
+ const end = batch.image_start + batch.image_count;
+ for (g.prepared_images.items[batch.image_start..end]) |prepared| {
+ const clip = imageScissor(g, prepared, max_w, max_h);
+ if (clip.w > 0 and clip.h > 0) {
+ c.SDL_SetGPUScissor(pass, &clip);
+ const sampler = c.SDL_GPUTextureSamplerBinding{ .texture = prepared.texture, .sampler = g.linear_sampler };
+ c.SDL_BindGPUFragmentSamplers(pass, 0, &sampler, 1);
+ const binding = c.SDL_GPUBufferBinding{ .buffer = g.image_vbuf, .offset = idx * @sizeOf(ImageInstance) };
+ c.SDL_BindGPUVertexBuffers(pass, 0, &binding, 1);
+ c.SDL_DrawGPUPrimitives(pass, 6, 1, 0, 0);
+ }
idx += 1;
}
+ std.debug.assert(idx == batch.image_start + batch.image_count);
const whole = c.SDL_Rect{
.x = 0,
.y = 0,
@@ -3617,9 +3852,30 @@ fn drawNativeImagesGpu(g: *Gui, rp: ?*c.SDL_GPURenderPass, surface: *pardes.Surf
};
c.SDL_SetGPUScissor(pass, &whole);
}
+// EFFECT_CODE_NATIVE_PANEL_END
-fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, theme_bg: ?[3]u8, crt_on: bool, debug_on: bool) !void {
- const cmd = c.SDL_AcquireGPUCommandBuffer(g.device) orelse return;
+// EFFECT_CODE_FRAME_SUBMISSION_BEGIN
+fn renderFrame(
+ g: *Gui,
+ gpa: std.mem.Allocator,
+ surface: *pardes.Surface,
+ theme_bg: ?[3]u8,
+ scene_effects: pardes.panel_animation.SceneEffect,
+ debug_on: bool,
+) !bool {
+ // Per-attempt, not per-last-success: an early swapchain/capture return
+ // after one real target failure must not count as another failed retry.
+ g.scene_target_failed = false;
+ const cmd = c.SDL_AcquireGPUCommandBuffer(g.device) orelse return false;
+ var command_consumed = false;
+ defer if (!command_consumed) {
+ // CPU caches become authoritative as uploads are enqueued (glyphs lose
+ // atlas_dirty; native textures enter the map). Submit every abandoned
+ // buffer so those transactions remain true. Submission is valid with
+ // or without a swapchain texture; cancellation is not valid after one
+ // is acquired and would discard capture-mode uploads before it.
+ _ = c.SDL_SubmitGPUCommandBuffer(cmd);
+ };
var sw: u32 = 0;
var sh: u32 = 0;
var target: *c.SDL_GPUTexture = undefined;
@@ -3628,42 +3884,63 @@ fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, theme_
sw = @as(u32, surface.cols) * g.cell_w;
sh = @as(u32, surface.rows) * g.cell_h;
if (sw == 0 or sh == 0) {
+ command_consumed = true;
_ = c.SDL_SubmitGPUCommandBuffer(cmd);
- return;
+ return false;
}
try ensureCaptureTexture(g, sw, sh);
target = g.capture_tex.?;
} else {
var swap_tex: ?*c.SDL_GPUTexture = null;
if (!c.SDL_AcquireGPUSwapchainTexture(cmd, g.window, &swap_tex, &sw, &sh)) {
+ command_consumed = true;
_ = c.SDL_SubmitGPUCommandBuffer(cmd);
- return;
+ return false;
}
target = swap_tex orelse { // swapchain busy: skip the frame
+ command_consumed = true;
_ = c.SDL_SubmitGPUCommandBuffer(cmd);
- return;
+ return false;
};
}
const win_w: f32 = @floatFromInt(sw);
const win_h: f32 = @floatFromInt(sh);
- // Crt on: the scene pass lands in an offscreen texture, then a second
- // pass draws it through the CRT shader onto the real target
- if (crt_on) try ensureSceneTexture(g, sw, sh);
- const scene: *c.SDL_GPUTexture = if (crt_on) g.scene_tex.? else target;
+ // All full-window effects share one offscreen scene and one composable
+ // shader pass. With no bits set the ordinary render remains direct.
+ var scene_on = scene_effects.crt or scene_effects.ripple or scene_effects.glitch;
+ if (scene_on) ensureSceneTexture(g, sw, sh) catch {
+ // The optional postprocess target does not own canonical rendering.
+ // Use this already-acquired command/swapchain directly for the frame;
+ // the loop bounds retries and clears the public bits after three.
+ g.scene_target_failed = true;
+ scene_on = false;
+ };
+ const scene: *c.SDL_GPUTexture = if (scene_on) g.scene_tex.? else target;
const layout = fixedCellLayout(g);
- const overlay_count = buildOverlay(g, surface, layout, sw, sh, debug_on);
- if (overlay_count != 0) uploadOverlayGpu(g, cmd, overlay_count);
- const image_count = prepareNativeImages(g, gpa, cmd, surface, sw, sh);
+ var paint_plan = makePaintPlan(surface.panelTracks(), surface.hasPanelDiff());
+ // Cursors/debug overlays do not carry pane ownership. Hide them for the
+ // short interval in which panel geometry differs from logical geometry;
+ // otherwise a cursor could remain pinned at the final cell, or paint over
+ // a later opening pane, while its own pane moves underneath it.
+ var overlay_count = if (paint_plan.len == 1)
+ buildOverlay(g, surface, layout, sw, sh, debug_on)
+ else
+ 0;
+ if (overlay_count != 0 and !uploadOverlayGpu(g, cmd, overlay_count))
+ overlay_count = 0;
+ if (!prepareNativeImages(g, gpa, cmd, surface, &paint_plan, sw, sh)) {
+ // Planning wrote offsets before buffer growth/map could fail. Never
+ // let those counts index the previous frame's smaller/stale buffer.
+ g.prepared_images.clearRetainingCapacity();
+ for (paint_plan.batches[0..paint_plan.len]) |*batch| {
+ batch.image_start = 0;
+ batch.image_count = 0;
+ }
+ }
const cells: u32 = @as(u32, surface.cols) * surface.rows;
- // ponytail: ×2, because the fractional-scroll pane is drawn a second time,
- // offset, out of the tail of the same buffer — and a pane is never bigger
- // than the grid. The ceiling is one grid's worth of vertex memory nobody
- // uses when nothing is sliding; sizing it to the actual pane instead means
- // re-creating the buffer on every frame that starts or ends a slide.
- if (cells != 0) try ensureVbuf(g, cells * 2);
const page = ground(theme_bg);
var color_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo);
@@ -3677,25 +3954,147 @@ fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, theme_
color_target.load_op = c.SDL_GPU_LOADOP_CLEAR;
color_target.store_op = c.SDL_GPU_STOREOP_STORE;
- if (cells != 0 and g.vbuf != null) {
+ var shifted: u32 = 0;
+ var row: u16 = 0;
+ while (row < surface.rows) : (row += 1) {
+ var col: u16 = 0;
+ while (col < surface.cols) : (col += 1) {
+ const batch_index = paintBatchAt(&paint_plan, col, row);
+ const track = paint_plan.batches[batch_index].track;
+ const data_effect = if (track) |active|
+ active.effect == .dissolve or active.effect == .ascii
+ else
+ false;
+ const data_diff = data_effect and surface.panelCellChanged(col, row);
+ paint_plan.batches[batch_index].cell_count = std.math.add(
+ u32,
+ paint_plan.batches[batch_index].cell_count,
+ if (data_diff) 2 else 1,
+ ) catch return error.GpuCapacity;
+ }
+ }
+ // Vertical opening retains the frozen grid below the incoming pane;
+ // closing is a presentation-only copy above the new canonical grid. Only
+ // those pane boxes are duplicated, so surviving panes never get tracks.
+ for (paint_plan.batches[1..paint_plan.len]) |*batch| {
+ const track = batch.track.?;
+ const duplicate_under = track.effect == .vertical and track.phase == .opening;
+ if (!duplicate_under and track.phase != .closing) continue;
+ row = 0;
+ while (row < surface.rows) : (row += 1) {
+ var col: u16 = 0;
+ while (col < surface.cols) : (col += 1) {
+ if (!boxContains(track.contentBox(), col, row)) continue;
+ const destination = if (duplicate_under) &paint_plan.batches[0] else batch;
+ destination.cell_count = std.math.add(u32, destination.cell_count, 1) catch return error.GpuCapacity;
+ }
+ }
+ }
+ var cell_next: [pardes.MAX_PANES * 2 + 1]u32 = @splat(0);
+ var cell_total: u32 = 0;
+ for (paint_plan.batches[0..paint_plan.len], 0..) |*batch, batch_index| {
+ batch.cell_start = cell_total;
+ cell_next[batch_index] = cell_total;
+ cell_total = std.math.add(u32, cell_total, batch.cell_count) catch return error.GpuCapacity;
+ }
+ if (cell_total != 0) {
+ // Reserve one further grid for the fractional-scroll duplicate. Its
+ // path suppresses itself for an animated pane, but another static pane
+ // may still be scrolling while a closing tombstone is visible.
+ const capacity = std.math.add(u32, cell_total, cells) catch return error.GpuCapacity;
+ try ensureVbuf(g, capacity);
+
// ponytail: full re-upload every frame; the prototype's dirty-range
// diffing (cell_keys + coalesced ranges) is skipped for now.
const vptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, g.vxfer.?, false) orelse {
+ command_consumed = true;
_ = c.SDL_SubmitGPUCommandBuffer(cmd);
- return;
+ return false;
});
const instances: [*]CellInstance = @ptrCast(@alignCast(vptr));
const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else cells;
const cursor_bar = if (surface.cursor) |cu| cu.bar else false;
- var row: u16 = 0;
+ row = 0;
while (row < surface.rows) : (row += 1) {
var col: u16 = 0;
while (col < surface.cols) : (col += 1) {
- const idx: u32 = @as(u32, row) * surface.cols + col;
- emitInstance(g, instances, idx, col, row, layout, win_w, win_h, surface.at(col, row), idx == cursor_idx and !cursor_bar, page);
+ const batch_index = paintBatchAt(&paint_plan, col, row);
+ const track = paint_plan.batches[batch_index].track;
+ const logical_idx: u32 = @as(u32, row) * surface.cols + col;
+ const data_effect = if (track) |active|
+ active.effect == .dissolve or active.effect == .ascii
+ else
+ false;
+ const data_diff = data_effect and surface.panelCellChanged(col, row);
+ if (data_diff) {
+ emitInstance(
+ g,
+ instances,
+ cell_next[batch_index],
+ col,
+ row,
+ layout,
+ win_w,
+ win_h,
+ track,
+ &surface.previous_cells[logical_idx],
+ true,
+ false,
+ page,
+ );
+ cell_next[batch_index] += 1;
+ }
+ emitInstance(
+ g,
+ instances,
+ cell_next[batch_index],
+ col,
+ row,
+ layout,
+ win_w,
+ win_h,
+ if (data_effect and !data_diff) null else track,
+ surface.at(col, row),
+ false,
+ paint_plan.len == 1 and logical_idx == cursor_idx and !cursor_bar,
+ page,
+ );
+ cell_next[batch_index] += 1;
}
}
- const shifted = emitScrollRows(g, instances, cells, surface, layout, win_w, win_h, page);
+ for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| {
+ const track = batch.track.?;
+ const duplicate_under = track.effect == .vertical and track.phase == .opening;
+ if (!duplicate_under and track.phase != .closing) continue;
+ row = 0;
+ while (row < surface.rows) : (row += 1) {
+ var col: u16 = 0;
+ while (col < surface.cols) : (col += 1) {
+ if (!boxContains(track.contentBox(), col, row)) continue;
+ const logical_idx: u32 = @as(u32, row) * surface.cols + col;
+ const destination: usize = if (duplicate_under) 0 else batch_index;
+ emitInstance(
+ g,
+ instances,
+ cell_next[destination],
+ col,
+ row,
+ layout,
+ win_w,
+ win_h,
+ if (duplicate_under) null else track,
+ &surface.previous_cells[logical_idx],
+ false,
+ false,
+ page,
+ );
+ cell_next[destination] += 1;
+ }
+ }
+ }
+ for (paint_plan.batches[0..paint_plan.len], 0..) |batch, batch_index|
+ std.debug.assert(cell_next[batch_index] == batch.cell_start + batch.cell_count);
+ shifted = emitScrollRows(g, instances, cell_total, surface, layout, win_w, win_h, page);
c.SDL_UnmapGPUTransferBuffer(g.device, g.vxfer.?);
// emitInstance may have rasterized new glyphs into the staging atlas;
@@ -3704,80 +4103,123 @@ fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, theme_
const copy = c.SDL_BeginGPUCopyPass(cmd);
const src = c.SDL_GPUTransferBufferLocation{ .transfer_buffer = g.vxfer.?, .offset = 0 };
- const dst = c.SDL_GPUBufferRegion{ .buffer = g.vbuf.?, .offset = 0, .size = (cells + shifted) * @sizeOf(CellInstance) };
+ const dst = c.SDL_GPUBufferRegion{ .buffer = g.vbuf.?, .offset = 0, .size = (cell_total + shifted) * @sizeOf(CellInstance) };
c.SDL_UploadToGPUBuffer(copy, &src, &dst, false);
c.SDL_EndGPUCopyPass(copy);
-
- const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null);
- c.SDL_BindGPUGraphicsPipeline(rp, g.pipeline);
- const samp_binding = c.SDL_GPUTextureSamplerBinding{ .texture = g.atlas_tex, .sampler = g.atlas_sampler };
- c.SDL_BindGPUFragmentSamplers(rp, 0, &samp_binding, 1);
- const vbinding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = 0 };
- c.SDL_BindGPUVertexBuffers(rp, 0, &vbinding, 1);
- c.SDL_DrawGPUPrimitives(rp, 6, cells, 0, 0);
- if (shifted != 0) {
- // second pass, clipped to the pane body: it overdraws the same
- // pane's un-offset cells, and its overhang stops at the body edge
- const clip = scrollScissor(g, layout, sw, sh);
- c.SDL_SetGPUScissor(rp, &clip);
- const sbinding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = cells * @sizeOf(CellInstance) };
- c.SDL_BindGPUVertexBuffers(rp, 0, &sbinding, 1);
- c.SDL_DrawGPUPrimitives(rp, 6, shifted, 0, 0);
- const whole = c.SDL_Rect{ .x = 0, .y = 0, .w = @intCast(sw), .h = @intCast(sh) };
- c.SDL_SetGPUScissor(rp, &whole);
- }
- c.SDL_EndGPURenderPass(rp);
- } else {
- // no grid yet: just clear to the page bg
- const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null);
- c.SDL_EndGPURenderPass(rp);
}
- // Images belong above the opaque cell backgrounds and below interaction
- // overlays. A second load pass expresses that ordering without teaching
- // the canonical cell surface about alpha or backend textures.
- if (image_count != 0 or overlay_count != 0) {
- color_target.load_op = c.SDL_GPU_LOADOP_LOAD;
- const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null);
- if (image_count != 0) drawNativeImagesGpu(g, rp, surface);
- drawOverlayGpu(g, rp, overlay_count);
- c.SDL_EndGPURenderPass(rp);
+ // Paint one complete panel before the next: its opaque cells followed by
+ // its native attachments. A phase-wide image tail would let an earlier
+ // pane's PDF/image cover a later pane's cells when their moving boxes
+ // overlap. PaintPlan is static, then moving slots, then opening slots.
+ const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null);
+ const whole = c.SDL_Rect{ .x = 0, .y = 0, .w = @intCast(sw), .h = @intCast(sh) };
+ for (paint_plan.batches[0..paint_plan.len], 0..) |batch, batch_index| {
+ const has_shifted = batch_index == 0 and shifted != 0;
+ if ((batch.cell_count != 0 or has_shifted) and g.vbuf != null) {
+ c.SDL_BindGPUGraphicsPipeline(rp, g.pipeline);
+ const samp_binding = c.SDL_GPUTextureSamplerBinding{ .texture = g.atlas_tex, .sampler = g.atlas_sampler };
+ c.SDL_BindGPUFragmentSamplers(rp, 0, &samp_binding, 1);
+ if (batch.cell_count != 0) {
+ const panel_clipped = if (batch.track) |active| active.effect == .vertical else false;
+ if (panel_clipped) {
+ const clip = panelBoxScissor(g, batch.track.?.contentBox(), sw, sh);
+ c.SDL_SetGPUScissor(rp, &clip);
+ }
+ const binding = c.SDL_GPUBufferBinding{
+ .buffer = g.vbuf.?,
+ .offset = batch.cell_start * @sizeOf(CellInstance),
+ };
+ c.SDL_BindGPUVertexBuffers(rp, 0, &binding, 1);
+ c.SDL_DrawGPUPrimitives(rp, 6, batch.cell_count, 0, 0);
+ if (panel_clipped) c.SDL_SetGPUScissor(rp, &whole);
+ }
+ if (has_shifted) {
+ // The fractional duplicate is static content and remains
+ // below the static pane's native attachments.
+ const clip = scrollScissor(g, layout, sw, sh);
+ c.SDL_SetGPUScissor(rp, &clip);
+ const binding = c.SDL_GPUBufferBinding{
+ .buffer = g.vbuf.?,
+ .offset = cell_total * @sizeOf(CellInstance),
+ };
+ c.SDL_BindGPUVertexBuffers(rp, 0, &binding, 1);
+ c.SDL_DrawGPUPrimitives(rp, 6, shifted, 0, 0);
+ c.SDL_SetGPUScissor(rp, &whole);
+ }
+ }
+ if (batch.image_count != 0)
+ drawNativeImagesGpu(g, rp, surface, &paint_plan, batch_index);
}
+ drawOverlayGpu(g, rp, overlay_count);
+ c.SDL_EndGPURenderPass(rp);
- if (crt_on) {
+ var rendered_scene: crt.Frame = .{};
+ if (scene_on) {
var crt_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo);
crt_target.texture = target;
crt_target.load_op = c.SDL_GPU_LOADOP_DONT_CARE;
crt_target.store_op = c.SDL_GPU_STOREOP_STORE;
- const rp = c.SDL_BeginGPURenderPass(cmd, &crt_target, 1, null);
- c.SDL_BindGPUGraphicsPipeline(rp, g.crt_pipeline);
+ const crt_pass = c.SDL_BeginGPURenderPass(cmd, &crt_target, 1, null);
+ c.SDL_BindGPUGraphicsPipeline(crt_pass, g.crt_pipeline);
const samp_binding = c.SDL_GPUTextureSamplerBinding{ .texture = g.scene_tex.?, .sampler = g.linear_sampler };
- c.SDL_BindGPUFragmentSamplers(rp, 0, &samp_binding, 1);
- c.SDL_DrawGPUPrimitives(rp, 3, 1, 0, 0);
- c.SDL_EndGPURenderPass(rp);
+ c.SDL_BindGPUFragmentSamplers(crt_pass, 0, &samp_binding, 1);
+ const now_ns = c.SDL_GetTicksNS();
+ const time_wrap_ns: u64 = 4096 * std.time.ns_per_s;
+ const time_seconds: f32 = @floatCast(
+ @as(f64, @floatFromInt(now_ns % time_wrap_ns)) / @as(f64, std.time.ns_per_s),
+ );
+ rendered_scene = .{
+ .effects = .{
+ .crt = scene_effects.crt,
+ .ripple = scene_effects.ripple,
+ .glitch = scene_effects.glitch,
+ },
+ .time_seconds = time_seconds,
+ };
+ const crt_uniforms = [8]f32{
+ time_seconds,
+ @floatFromInt((now_ns / (std.time.ns_per_s / 60)) % 65_536),
+ @floatFromInt(sw),
+ @floatFromInt(sh),
+ @floatFromInt(@intFromBool(scene_effects.crt)),
+ @floatFromInt(@intFromBool(scene_effects.ripple)),
+ @floatFromInt(@intFromBool(scene_effects.glitch)),
+ 0,
+ };
+ c.SDL_PushGPUFragmentUniformData(cmd, 0, &crt_uniforms, @sizeOf(@TypeOf(crt_uniforms)));
+ c.SDL_DrawGPUPrimitives(crt_pass, 3, 1, 0, 0);
+ c.SDL_EndGPURenderPass(crt_pass);
}
-
- if (g.capture) return captureFrame(g, gpa, cmd, target, sw, sh);
- _ = c.SDL_SubmitGPUCommandBuffer(cmd);
+ if (g.capture) {
+ // captureFrame consumes `cmd` on every success and error path: its
+ // allocation failures submit directly, and the ordinary path submits
+ // while acquiring the readback fence.
+ command_consumed = true;
+ try captureFrame(g, gpa, cmd, target, sw, sh);
+ g.presented_scene = rendered_scene;
+ if (paint_plan.len == 1) rememberPresentedImages(g, gpa, surface);
+ return true;
+ }
+ command_consumed = true;
+ const submitted = c.SDL_SubmitGPUCommandBuffer(cmd);
+ if (submitted) {
+ g.presented_scene = rendered_scene;
+ if (paint_plan.len == 1) rememberPresentedImages(g, gpa, surface);
+ }
+ return submitted;
}
+// EFFECT_CODE_FRAME_SUBMISSION_END
-fn emitInstance(
- g: *Gui,
- instances: [*]CellInstance,
- idx: u32,
- col: u16,
- row: u16,
- layout: CellLayout,
- win_w: f32,
- win_h: f32,
- cell: *const pardes.Cell,
- is_cursor: bool,
- /// the ground this frame: what a default background resolves to
- page: [3]u8,
-) void {
- // Resolve the cell style to concrete fg/bg. A normal block cursor reverses
- // the cell; the insert cursor is a separate overlay, so the glyph beneath
- // it remains in this base pass.
+// EFFECT_CODE_CELL_INSTANCE_BEGIN
+const ResolvedCell = struct {
+ slot: Slot,
+ fg: [3]u8,
+ bg: [3]u8,
+ role: pardes.FontRole,
+};
+
+fn resolveCell(g: *Gui, cell: *const pardes.Cell, is_cursor: bool, page: [3]u8) ResolvedCell {
var fg = fg_default;
var bg = page;
var reverse = is_cursor;
@@ -3801,36 +4243,95 @@ fn emitInstance(
}
if (reverse) std.mem.swap([3]u8, &fg, &bg);
- // cell pixel rect (top-left origin) → NDC (y up); the layout letterboxes on web
+ const role: pardes.FontRole = if (cell.default)
+ .body
+ else
+ cell.style.font_role;
+ const cp = cellCodepoint(cell);
+ return .{
+ .slot = if (cp == ' ') g.space_slot else ensureGlyph(g, cp, role),
+ .fg = fg,
+ .bg = bg,
+ .role = role,
+ };
+}
+
+fn emitInstance(
+ g: *Gui,
+ instances: [*]CellInstance,
+ idx: u32,
+ col: u16,
+ row: u16,
+ layout: CellLayout,
+ win_w: f32,
+ win_h: f32,
+ track: ?pardes.panel_animation.Track,
+ cell: *const pardes.Cell,
+ /// Old and new data layers carry their own quad geometry. The shader
+ /// discards exactly one at every reveal state, so a body/tagline role
+ /// change retains the correct band height on both sides of the diff.
+ old_layer: bool,
+ is_cursor: bool,
+ /// the ground this frame: what a default background resolves to
+ page: [3]u8,
+) void {
+ const resolved = resolveCell(g, cell, is_cursor, page);
+
+ // Cell pixel rect (top-left origin) → NDC (y up).
const px0 = layout.x_off + @as(f32, @floatFromInt(col)) * layout.w;
- const py0 = layout.y_off + @as(f32, @floatFromInt(row)) * layout.h;
+ const visual_h: f32 = if (resolved.role == .tagline)
+ @floatFromInt(g.tagline_height)
+ else
+ layout.h;
+ const py0 = layout.y_off + @as(f32, @floatFromInt(row)) * layout.h +
+ @max(0.0, (layout.h - visual_h) * 0.5);
const x0 = (px0 / win_w) * 2.0 - 1.0;
const x1 = ((px0 + layout.w) / win_w) * 2.0 - 1.0;
const y0 = 1.0 - (py0 / win_h) * 2.0;
- const y1 = 1.0 - ((py0 + layout.h) / win_h) * 2.0;
-
- const cp = cellCodepoint(cell);
- const slot = if (cp == ' ') g.space_slot else ensureGlyph(g, cp);
+ const y1 = 1.0 - ((py0 + visual_h) / win_h) * 2.0;
const aw_f: f32 = @floatFromInt(atlas_w);
const ah_f: f32 = @floatFromInt(atlas_h);
+ const uv_h: u32 = if (resolved.role == .tagline) g.tagline_height else g.cell_h;
instances[idx] = .{
.x0 = x0,
.y0 = y0,
.x1 = x1,
.y1 = y1,
- .u0 = @as(f32, @floatFromInt(slot.u)) / aw_f,
- .v0 = @as(f32, @floatFromInt(slot.v)) / ah_f,
- .u1 = @as(f32, @floatFromInt(slot.u + g.cell_w)) / aw_f,
- .v1 = @as(f32, @floatFromInt(slot.v + g.cell_h)) / ah_f,
- .fr = @as(f32, @floatFromInt(fg[0])) / 255.0,
- .fg = @as(f32, @floatFromInt(fg[1])) / 255.0,
- .fb = @as(f32, @floatFromInt(fg[2])) / 255.0,
- .br = @as(f32, @floatFromInt(bg[0])) / 255.0,
- .bg = @as(f32, @floatFromInt(bg[1])) / 255.0,
- .bb = @as(f32, @floatFromInt(bg[2])) / 255.0,
+ .u0 = @as(f32, @floatFromInt(resolved.slot.u)) / aw_f,
+ .v0 = @as(f32, @floatFromInt(resolved.slot.v)) / ah_f,
+ .u1 = @as(f32, @floatFromInt(resolved.slot.u + g.cell_w)) / aw_f,
+ .v1 = @as(f32, @floatFromInt(resolved.slot.v + uv_h)) / ah_f,
+ .fr = @as(f32, @floatFromInt(resolved.fg[0])) / 255.0,
+ .fg = @as(f32, @floatFromInt(resolved.fg[1])) / 255.0,
+ .fb = @as(f32, @floatFromInt(resolved.fg[2])) / 255.0,
+ .br = @as(f32, @floatFromInt(resolved.bg[0])) / 255.0,
+ .bg = @as(f32, @floatFromInt(resolved.bg[1])) / 255.0,
+ .bb = @as(f32, @floatFromInt(resolved.bg[2])) / 255.0,
+ .panel_x0 = 0,
+ .panel_y0 = 0,
+ .panel_x1 = 0,
+ .panel_y1 = 0,
+ .present_x0 = 0,
+ .present_y0 = 0,
+ .present_x1 = 0,
+ .present_y1 = 0,
+ .effect = 0,
+ .progress_bits = @bitCast(@as(f32, 1)),
+ .serial = 0,
+ .cell_coord = 0,
};
+ setTransitionFields(
+ &instances[idx],
+ track,
+ layout,
+ win_w,
+ win_h,
+ if (track) |active| panelCellCoord(active, col, row) else 0,
+ );
+ if (old_layer) instances[idx].effect |= old_layer_bit;
}
+// EFFECT_CODE_CELL_INSTANCE_END
fn cellCodepoint(cell: *const pardes.Cell) u32 {
const grapheme = cell.grapheme();
@@ -3851,13 +4352,17 @@ test "insert cursor overlays without replacing the character beneath it" {
.win_w = 100,
.win_h = 100,
};
- addCursorBar(&builder, 2, 3, true, .{
+ addCursorBar(&builder, 2, 3, true, 8, .{
.x_off = 0,
.y_off = 0,
.w = 10,
.h = 20,
});
try std.testing.expectEqual(@as(usize, 6), builder.len);
+ // A tagline caret uses the same centered visual band as its glyph and
+ // background, while its logical row remains the body-sized grid row.
+ try std.testing.expectApproxEqAbs(@as(f32, -0.32), vertices[0].y, 0.0001);
+ try std.testing.expectApproxEqAbs(@as(f32, -0.48), vertices[2].y, 0.0001);
}
fn palColor(idx: u8) [3]u8 {
@@ -3881,9 +4386,9 @@ fn firstCp(s: []const u8) u32 {
///
/// The atlas is the part that must not be skipped, and the reason the whole
/// thing is a function rather than three lines at a call site. It is keyed by
-/// CODEPOINT ALONE — one face, one size, a pen walking rows of cell_w×cell_h
+/// codepoint and font role — two raster sizes sharing a pen of cell_w×cell_h
/// slots — so after a change every slot in it holds the wrong picture at the
-/// wrong metrics, and every codepoint already in the map would keep being
+/// wrong metrics, and every key already in the map would keep being
/// drawn from that slot forever, because ensureGlyph's first line is a cache
/// hit. Clearing the map, zeroing the staging bitmap and rewinding the pen put
/// it back to exactly what init built, and ensureGlyph refills it as the next
@@ -3892,29 +4397,20 @@ fn firstCp(s: []const u8) u32 {
/// bitmap no slot points at any more would still be sampled by whatever new
/// slot overlaps it.
fn refitFont(g: *Gui, core: *pardes.Pardes) void {
- // Native rasters at the size it measures. Web MEASURES at the CSS size and
- // RASTERS web_render_scale bigger, so text stays sharp on a scaled canvas
- // and cellLayout scales the quads back down — the same two scales initGl
- // takes, asked again because a different face answers differently.
- const measure = c.ui_font_scale_for_height(g.font, g.px);
- g.scale = if (is_emscripten) c.ui_font_scale_for_height(g.font, g.px * web_render_scale) else measure;
+ g.scale = c.ui_font_scale_for_height(g.font, g.px);
var cw: c_int = 10;
var chh: c_int = 20;
var asc: c_int = 16;
- c.ui_font_cell_metrics(g.font, measure, &cw, &chh, &asc);
- const grow: f32 = if (is_emscripten) web_render_scale else 1.0;
- g.cell_w = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(cw, 1))) * grow));
- g.cell_h = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(chh, 1))) * grow));
- g.ascent = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(asc, 1))) * grow));
+ c.ui_font_cell_metrics(g.font, g.scale, &cw, &chh, &asc);
+ g.cell_w = @intCast(@max(cw, 1));
+ g.cell_h = @intCast(@max(chh, 1));
+ g.ascent = @intCast(@max(asc, 1));
+ const tagline = taglineRaster(g.font, g.px, g.cell_h, g.tagline_percent);
+ g.tagline_scale = tagline.scale;
+ g.tagline_baseline = tagline.baseline;
+ g.tagline_height = tagline.height;
- g.glyphs.clearRetainingCapacity();
- @memset(g.atlas_stage, 0);
- // slot (0,0) is the space glyph, exactly as init lays it out
- _ = c.ui_font_raster(g.font, g.scale, ' ', g.atlas_stage.ptr, @intCast(atlas_w), @intCast(g.cell_w), @intCast(g.cell_h), g.ascent);
- g.space_slot = .{ .u = 0, .v = 0 };
- g.pen_x = g.cell_w;
- g.pen_y = 0;
- g.atlas_dirty = true;
+ resetGlyphAtlas(g);
// ...and the grid: the same window is a different number of cells now. The
// shells re-derive this every frame anyway, so this is only the frame the
@@ -3939,8 +4435,36 @@ fn refitFont(g: *Gui, core: *pardes.Pardes) void {
resetScroll(g);
}
-fn ensureGlyph(g: *Gui, cp: u32) Slot {
- if (g.glyphs.get(cp)) |s| return s;
+/// Rewind the shared atlas without touching body metrics. TaglineSize uses
+/// this path: body glyphs are lazily reinserted at the same scale, tagline
+/// glyphs at their new scale and band height. Repacking everything avoids an
+/// ever-growing graveyard of old tagline slots when a config file experiments
+/// with several sizes in one session.
+fn resetGlyphAtlas(g: *Gui) void {
+ g.glyphs.clearRetainingCapacity();
+ @memset(g.atlas_stage, 0);
+ // slot (0,0) is the space glyph, exactly as init lays it out
+ _ = c.ui_font_raster(g.font, g.scale, ' ', g.atlas_stage.ptr, @intCast(atlas_w), @intCast(g.cell_w), @intCast(g.cell_h), g.ascent);
+ g.space_slot = .{ .u = 0, .v = 0 };
+ g.pen_x = g.cell_w;
+ g.pen_y = 0;
+ g.atlas_dirty = true;
+}
+
+fn syncTaglineFont(g: *Gui, core: *const pardes.Pardes) void {
+ const percent = core.settings.font.tagline_percent;
+ if (percent == g.tagline_percent) return;
+ g.tagline_percent = percent;
+ const tagline = taglineRaster(g.font, g.px, g.cell_h, percent);
+ g.tagline_scale = tagline.scale;
+ g.tagline_baseline = tagline.baseline;
+ g.tagline_height = tagline.height;
+ resetGlyphAtlas(g);
+}
+
+fn ensureGlyph(g: *Gui, cp: u32, role: pardes.FontRole) Slot {
+ const key: GlyphKey = .{ .codepoint = cp, .role = role };
+ if (g.glyphs.get(key)) |s| return s;
if (g.pen_x + g.cell_w > atlas_w) {
g.pen_x = 0;
g.pen_y += g.cell_h;
@@ -3949,8 +4473,11 @@ fn ensureGlyph(g: *Gui, cp: u32) Slot {
const s = Slot{ .u = g.pen_x, .v = g.pen_y };
const out = g.atlas_stage.ptr + @as(usize, s.v) * atlas_w + s.u;
const face = fontForCodepoint(g, cp);
- _ = c.ui_font_raster(face, g.scale, @intCast(cp), out, @intCast(atlas_w), @intCast(g.cell_w), @intCast(g.cell_h), g.ascent);
- g.glyphs.put(cp, s) catch return g.space_slot;
+ const scale = if (role == .tagline) g.tagline_scale else g.scale;
+ const baseline = if (role == .tagline) g.tagline_baseline else g.ascent;
+ const raster_h = if (role == .tagline) g.tagline_height else g.cell_h;
+ _ = c.ui_font_raster(face, scale, @intCast(cp), out, @intCast(atlas_w), @intCast(g.cell_w), @intCast(raster_h), baseline);
+ g.glyphs.put(key, s) catch return g.space_slot;
g.pen_x += g.cell_w;
g.atlas_dirty = true;
return s;
@@ -3971,23 +4498,35 @@ fn uploadAtlas(g: *Gui, cmd: *c.SDL_GPUCommandBuffer) void {
}
fn ensureVbuf(g: *Gui, cells: u32) !void {
- if (g.vbuf_cells == cells and g.vbuf != null) return;
- if (g.vbuf) |b| c.SDL_ReleaseGPUBuffer(g.device, b);
- if (g.vxfer) |x| c.SDL_ReleaseGPUTransferBuffer(g.device, x);
- g.vbuf = null;
- g.vxfer = null;
- const size = cells * @sizeOf(CellInstance);
+ if (cells <= g.vbuf_cells and g.vbuf != null and g.vxfer != null) return;
+ var capacity = @max(@as(u32, 1), g.vbuf_cells);
+ while (capacity < cells)
+ capacity = std.math.mul(u32, capacity, 2) catch return error.GpuCapacity;
+ const size = std.math.mul(
+ u32,
+ capacity,
+ @as(u32, @intCast(@sizeOf(CellInstance))),
+ ) catch return error.GpuCapacity;
var vb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = size, .props = 0 };
- g.vbuf = c.SDL_CreateGPUBuffer(g.device, &vb_info) orelse return error.GpuCreate;
+ const next_vbuf = c.SDL_CreateGPUBuffer(g.device, &vb_info) orelse return error.GpuCreate;
+ errdefer c.SDL_ReleaseGPUBuffer(g.device, next_vbuf);
var xf_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = size, .props = 0 };
- g.vxfer = c.SDL_CreateGPUTransferBuffer(g.device, &xf_info) orelse return error.GpuCreate;
- g.vbuf_cells = cells;
+ const next_vxfer = c.SDL_CreateGPUTransferBuffer(g.device, &xf_info) orelse return error.GpuCreate;
+
+ // Allocate the pair before retiring either old half. A failed transfer
+ // allocation must not leave a new vertex buffer paired with null (or a
+ // stale vbuf_cells value that makes the next call accept that pair).
+ if (g.vbuf) |buffer| c.SDL_ReleaseGPUBuffer(g.device, buffer);
+ if (g.vxfer) |transfer| c.SDL_ReleaseGPUTransferBuffer(g.device, transfer);
+ g.vbuf = next_vbuf;
+ g.vxfer = next_vxfer;
+ g.vbuf_cells = capacity;
}
fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline {
- const vs = try makeShader(device, vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0);
+ const vs = try makeShader(device, vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0, 0);
defer c.SDL_ReleaseGPUShader(device, vs);
- const fs = try makeShader(device, frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1);
+ const fs = try makeShader(device, frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 0);
defer c.SDL_ReleaseGPUShader(device, fs);
var vbuf_desc = c.SDL_GPUVertexBufferDescription{
@@ -4001,6 +4540,9 @@ fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat)
.{ .location = 1, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "u0") },
.{ .location = 2, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT3, .offset = @offsetOf(CellInstance, "fr") },
.{ .location = 3, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT3, .offset = @offsetOf(CellInstance, "br") },
+ .{ .location = 4, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "panel_x0") },
+ .{ .location = 5, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "present_x0") },
+ .{ .location = 6, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_UINT4, .offset = @offsetOf(CellInstance, "effect") },
};
var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = std.mem.zeroes(c.SDL_GPUColorTargetBlendState) };
@@ -4022,9 +4564,9 @@ fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat)
}
fn makeOverlayPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline {
- const vs = try makeShader(device, overlay_vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0);
+ const vs = try makeShader(device, overlay_vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0, 0);
defer c.SDL_ReleaseGPUShader(device, vs);
- const fs = try makeShader(device, overlay_frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 0);
+ const fs = try makeShader(device, overlay_frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 0, 0);
defer c.SDL_ReleaseGPUShader(device, fs);
var vbuf_desc = c.SDL_GPUVertexBufferDescription{
@@ -4065,9 +4607,9 @@ fn makeOverlayPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureF
}
fn makeImagePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline {
- const vs = try makeShader(device, image_vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0);
+ const vs = try makeShader(device, image_vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0, 0);
defer c.SDL_ReleaseGPUShader(device, vs);
- const fs = try makeShader(device, image_frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1);
+ const fs = try makeShader(device, image_frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 0);
defer c.SDL_ReleaseGPUShader(device, fs);
var vbuf_desc = c.SDL_GPUVertexBufferDescription{
@@ -4079,6 +4621,9 @@ fn makeImagePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFor
var attrs = [_]c.SDL_GPUVertexAttribute{
.{ .location = 0, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(ImageInstance, "x0") },
.{ .location = 1, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(ImageInstance, "u0") },
+ .{ .location = 2, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(ImageInstance, "panel_x0") },
+ .{ .location = 3, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(ImageInstance, "present_x0") },
+ .{ .location = 4, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_UINT4, .offset = @offsetOf(ImageInstance, "effect") },
};
var blend = std.mem.zeroes(c.SDL_GPUColorTargetBlendState);
blend.src_color_blendfactor = c.SDL_GPU_BLENDFACTOR_SRC_ALPHA;
@@ -4108,9 +4653,9 @@ fn makeImagePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFor
// the CRT pass: a fullscreen triangle sampling the scene texture, no vertex
// buffers at all (positions from gl_VertexIndex)
fn makeCrtPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline {
- const vs = try makeShader(device, crt_vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0);
+ const vs = try makeShader(device, crt_vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0, 0);
defer c.SDL_ReleaseGPUShader(device, vs);
- const fs = try makeShader(device, crt_frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1);
+ const fs = try makeShader(device, crt_frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 1);
defer c.SDL_ReleaseGPUShader(device, fs);
var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = std.mem.zeroes(c.SDL_GPUColorTargetBlendState) };
@@ -4125,7 +4670,7 @@ fn makeCrtPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureForma
return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate;
}
-fn makeShader(device: *c.SDL_GPUDevice, code: []const u8, stage: c.SDL_GPUShaderStage, n_samplers: u32) !*c.SDL_GPUShader {
+fn makeShader(device: *c.SDL_GPUDevice, code: []const u8, stage: c.SDL_GPUShaderStage, n_samplers: u32, n_uniform_buffers: u32) !*c.SDL_GPUShader {
var info = std.mem.zeroes(c.SDL_GPUShaderCreateInfo);
info.code = code.ptr;
info.code_size = code.len;
@@ -4133,170 +4678,16 @@ fn makeShader(device: *c.SDL_GPUDevice, code: []const u8, stage: c.SDL_GPUShader
info.format = c.SDL_GPU_SHADERFORMAT_SPIRV;
info.stage = stage;
info.num_samplers = n_samplers;
+ info.num_uniform_buffers = n_uniform_buffers;
return c.SDL_CreateGPUShader(device, &info) orelse error.GpuCreate;
}
// =====================================================================
-// web render: Surface → the same instanced quads → WebGL2. Full instance
-// re-upload each frame; attribs re-specified per draw (no VAO to manage).
-// =====================================================================
-
-fn renderFrameGl(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, theme_bg: ?[3]u8, debug_on: bool) !void {
- var pw: c_int = 0;
- var ph: c_int = 0;
- _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph);
- const sw: u32 = @intCast(@max(pw, 1));
- const sh: u32 = @intCast(@max(ph, 1));
- c.glViewport(0, 0, @intCast(sw), @intCast(sh));
- const page = ground(theme_bg);
- c.glClearColor(
- @as(f32, @floatFromInt(page[0])) / 255.0,
- @as(f32, @floatFromInt(page[1])) / 255.0,
- @as(f32, @floatFromInt(page[2])) / 255.0,
- 1.0,
- );
- c.glClear(c.GL_COLOR_BUFFER_BIT);
-
- const cells: u32 = @as(u32, surface.cols) * surface.rows;
- if (cells == 0) {
- const overlay_count = buildTouchOverlay(g, sw, sh, debug_on);
- drawOverlayGl(g, overlay_count);
- _ = c.SDL_GL_SwapWindow(g.window);
- return;
- }
- // ×2 for the fractional-scroll pane's offset copy, as in renderFrame
- if (g.gl_instances.len < cells * 2) {
- gpa.free(g.gl_instances);
- g.gl_instances = try gpa.alloc(CellInstance, cells * 2);
- }
-
- const win_w: f32 = @floatFromInt(sw);
- const win_h: f32 = @floatFromInt(sh);
- const layout = cellLayout(g, surface.cols, surface.rows, sw, sh);
- const overlay_count = buildOverlay(g, surface, layout, sw, sh, debug_on);
- const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else cells;
- const cursor_bar = if (surface.cursor) |cu| cu.bar else false;
- var row: u16 = 0;
- while (row < surface.rows) : (row += 1) {
- var col: u16 = 0;
- while (col < surface.cols) : (col += 1) {
- const idx: u32 = @as(u32, row) * surface.cols + col;
- emitInstance(g, g.gl_instances.ptr, idx, col, row, layout, win_w, win_h, surface.at(col, row), idx == cursor_idx and !cursor_bar);
- }
- }
- const shifted = emitScrollRows(g, g.gl_instances.ptr, cells, surface, layout, win_w, win_h);
- if (g.atlas_dirty) uploadAtlasGl(g);
-
- c.glUseProgram(g.gl_program);
- c.glActiveTexture(c.GL_TEXTURE0);
- c.glBindTexture(c.GL_TEXTURE_2D, g.gl_atlas_tex);
- c.glUniform1i(g.gl_u_atlas, 0);
- c.glBindBuffer(c.GL_ARRAY_BUFFER, g.gl_vbo);
- c.glBufferData(c.GL_ARRAY_BUFFER, @intCast((cells + shifted) * @sizeOf(CellInstance)), g.gl_instances.ptr, c.GL_STREAM_DRAW);
- const stride: c_int = @intCast(@sizeOf(CellInstance));
- c.glEnableVertexAttribArray(0);
- c.glVertexAttribPointer(0, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "x0")));
- c.glVertexAttribDivisor(0, 1);
- c.glEnableVertexAttribArray(1);
- c.glVertexAttribPointer(1, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "u0")));
- c.glVertexAttribDivisor(1, 1);
- c.glEnableVertexAttribArray(2);
- c.glVertexAttribPointer(2, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "fr")));
- c.glVertexAttribDivisor(2, 1);
- c.glEnableVertexAttribArray(3);
- c.glVertexAttribPointer(3, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "br")));
- c.glVertexAttribDivisor(3, 1);
- c.glDrawArraysInstanced(c.GL_TRIANGLES, 0, 6, @intCast(cells));
- if (shifted != 0) {
- // the same second pass renderFrame does: re-point the attributes at
- // the tail of the buffer, clip to the pane body (GL counts scissor
- // rows from the BOTTOM), draw the offset copy over the flat one
- const tail: usize = cells * @sizeOf(CellInstance);
- c.glVertexAttribPointer(0, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(tail + @offsetOf(CellInstance, "x0")));
- c.glVertexAttribPointer(1, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(tail + @offsetOf(CellInstance, "u0")));
- c.glVertexAttribPointer(2, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(tail + @offsetOf(CellInstance, "fr")));
- c.glVertexAttribPointer(3, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(tail + @offsetOf(CellInstance, "br")));
- const clip = scrollScissor(g, layout, sw, sh);
- c.glEnable(c.GL_SCISSOR_TEST);
- c.glScissor(clip.x, @as(c_int, @intCast(sh)) - (clip.y + clip.h), clip.w, clip.h);
- c.glDrawArraysInstanced(c.GL_TRIANGLES, 0, 6, @intCast(shifted));
- c.glDisable(c.GL_SCISSOR_TEST);
- }
- drawOverlayGl(g, overlay_count);
- _ = c.SDL_GL_SwapWindow(g.window);
-}
-
-fn uploadAtlasGl(g: *Gui) void {
- c.glBindTexture(c.GL_TEXTURE_2D, g.gl_atlas_tex);
- c.glPixelStorei(c.GL_UNPACK_ALIGNMENT, 1);
- c.glTexSubImage2D(c.GL_TEXTURE_2D, 0, 0, 0, atlas_w, atlas_h, c.GL_RED, c.GL_UNSIGNED_BYTE, g.atlas_stage.ptr);
- g.atlas_dirty = false;
-}
-
-fn drawOverlayGl(g: *Gui, vertex_count: u32) void {
- if (vertex_count == 0) return;
- c.glUseProgram(g.gl_overlay_program);
- c.glBindBuffer(c.GL_ARRAY_BUFFER, g.gl_overlay_vbo);
- c.glBufferData(c.GL_ARRAY_BUFFER, @intCast(vertex_count * @sizeOf(OverlayVertex)), g.overlay_vertices.ptr, c.GL_STREAM_DRAW);
- const stride: c_int = @intCast(@sizeOf(OverlayVertex));
- c.glEnableVertexAttribArray(0);
- c.glVertexAttribPointer(0, 2, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(OverlayVertex, "x")));
- c.glVertexAttribDivisor(0, 0);
- c.glEnableVertexAttribArray(1);
- c.glVertexAttribPointer(1, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(OverlayVertex, "r")));
- c.glVertexAttribDivisor(1, 0);
- c.glDisableVertexAttribArray(2);
- c.glDisableVertexAttribArray(3);
- c.glEnable(c.GL_BLEND);
- c.glBlendFuncSeparate(c.GL_SRC_ALPHA, c.GL_ONE_MINUS_SRC_ALPHA, c.GL_ONE, c.GL_ONE_MINUS_SRC_ALPHA);
- c.glDrawArrays(c.GL_TRIANGLES, 0, @intCast(vertex_count));
- c.glDisable(c.GL_BLEND);
-}
-
-fn makeGlProgramFrom(vertex_source: []const u8, fragment_source: []const u8) !c_uint {
- const vs = try makeGlShader(c.GL_VERTEX_SHADER, vertex_source);
- defer c.glDeleteShader(vs);
- const fs = try makeGlShader(c.GL_FRAGMENT_SHADER, fragment_source);
- defer c.glDeleteShader(fs);
- const program = c.glCreateProgram();
- if (program == 0) return error.GpuCreate;
- c.glAttachShader(program, vs);
- c.glAttachShader(program, fs);
- c.glLinkProgram(program);
- var ok: c_int = 0;
- c.glGetProgramiv(program, c.GL_LINK_STATUS, &ok);
- if (ok == 0) {
- log.err("WebGL program link failed", .{});
- c.glDeleteProgram(program);
- return error.GpuCreate;
- }
- return program;
-}
-
-fn makeGlShader(kind: c_uint, source: []const u8) !c_uint {
- const shader = c.glCreateShader(kind);
- if (shader == 0) return error.GpuCreate;
- var src_ptr = [_][*c]const u8{@ptrCast(source.ptr)};
- var src_len = [_]c_int{@intCast(source.len)};
- c.glShaderSource(shader, 1, &src_ptr, &src_len);
- c.glCompileShader(shader);
- var ok: c_int = 0;
- c.glGetShaderiv(shader, c.GL_COMPILE_STATUS, &ok);
- if (ok == 0) {
- log.err("WebGL shader compile failed", .{});
- c.glDeleteShader(shader);
- return error.GpuCreate;
- }
- return shader;
-}
-
-// =====================================================================
// PARDES_TEST frame capture: download the render target, write latest.ppm
// =====================================================================
fn ensureSceneTexture(g: *Gui, width: u32, height: u32) !void {
if (g.scene_tex != null and g.scene_tex_w == width and g.scene_tex_h == height) return;
- if (g.scene_tex) |t| c.SDL_ReleaseGPUTexture(g.device, t);
var info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo);
info.type = c.SDL_GPU_TEXTURETYPE_2D;
info.format = g.swapchain_format;
@@ -4306,14 +4697,15 @@ fn ensureSceneTexture(g: *Gui, width: u32, height: u32) !void {
info.layer_count_or_depth = 1;
info.num_levels = 1;
info.sample_count = c.SDL_GPU_SAMPLECOUNT_1;
- g.scene_tex = c.SDL_CreateGPUTexture(g.device, &info) orelse return error.GpuCreate;
+ const next = c.SDL_CreateGPUTexture(g.device, &info) orelse return error.GpuCreate;
+ if (g.scene_tex) |texture| c.SDL_ReleaseGPUTexture(g.device, texture);
+ g.scene_tex = next;
g.scene_tex_w = width;
g.scene_tex_h = height;
}
fn ensureCaptureTexture(g: *Gui, width: u32, height: u32) !void {
if (g.capture_tex != null and g.capture_tex_w == width and g.capture_tex_h == height) return;
- if (g.capture_tex) |t| c.SDL_ReleaseGPUTexture(g.device, t);
var info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo);
info.type = c.SDL_GPU_TEXTURETYPE_2D;
info.format = g.swapchain_format;
@@ -4323,7 +4715,9 @@ fn ensureCaptureTexture(g: *Gui, width: u32, height: u32) !void {
info.layer_count_or_depth = 1;
info.num_levels = 1;
info.sample_count = c.SDL_GPU_SAMPLECOUNT_1;
- g.capture_tex = c.SDL_CreateGPUTexture(g.device, &info) orelse return error.GpuCreate;
+ const next = c.SDL_CreateGPUTexture(g.device, &info) orelse return error.GpuCreate;
+ if (g.capture_tex) |texture| c.SDL_ReleaseGPUTexture(g.device, texture);
+ g.capture_tex = next;
g.capture_tex_w = width;
g.capture_tex_h = height;
}
@@ -4335,12 +4729,13 @@ fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, t
return error.UnsupportedCaptureFormat;
}
if (g.capture_xfer == null or g.capture_xfer_size < size) {
- if (g.capture_xfer) |x| c.SDL_ReleaseGPUTransferBuffer(g.device, x);
var info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_DOWNLOAD, .size = size, .props = 0 };
- g.capture_xfer = c.SDL_CreateGPUTransferBuffer(g.device, &info) orelse {
+ const next = c.SDL_CreateGPUTransferBuffer(g.device, &info) orelse {
_ = c.SDL_SubmitGPUCommandBuffer(cmd);
return error.GpuCreate;
};
+ if (g.capture_xfer) |transfer| c.SDL_ReleaseGPUTransferBuffer(g.device, transfer);
+ g.capture_xfer = next;
g.capture_xfer_size = size;
}
const xfer = g.capture_xfer.?;
@@ -4405,15 +4800,24 @@ fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u
// flash HUD, alpha-blended over the grid only while the Debug builtin is on.
// =====================================================================
-fn addCursorBar(builder: *OverlayBuilder, x: u16, y: u16, bar: bool, layout: CellLayout) void {
+fn addCursorBar(
+ builder: *OverlayBuilder,
+ x: u16,
+ y: u16,
+ bar: bool,
+ visual_height: f32,
+ layout: CellLayout,
+) void {
if (!bar) return;
const x0 = layout.x_off + @as(f32, @floatFromInt(x)) * layout.w;
- const y0 = layout.y_off + @as(f32, @floatFromInt(y)) * layout.h;
+ const height = std.math.clamp(visual_height, 1.0, layout.h);
+ const y0 = layout.y_off + @as(f32, @floatFromInt(y)) * layout.h +
+ (layout.h - height) * 0.5;
builder.addRect(
x0,
y0,
x0 + @max(1.0, layout.w / 8.0),
- y0 + layout.h,
+ y0 + height,
.{
.r = @as(f32, @floatFromInt(fg_default[0])) / 255.0,
.g = @as(f32, @floatFromInt(fg_default[1])) / 255.0,
@@ -4429,22 +4833,20 @@ fn buildOverlay(g: *Gui, surface: *const pardes.Surface, layout: CellLayout, sw:
const win_h: f32 = @floatFromInt(sh);
var builder = OverlayBuilder{ .vertices = g.overlay_vertices, .win_w = win_w, .win_h = win_h };
if (surface.cursor) |cursor| {
- if (cursor.x < surface.cols and cursor.y < surface.rows)
- addCursorBar(&builder, cursor.x, cursor.y, cursor.bar, layout);
+ if (cursor.x < surface.cols and cursor.y < surface.rows) {
+ const cell = surface.cells[@as(usize, cursor.y) * surface.cols + cursor.x];
+ const role = cell.style.font_role;
+ const visual_height: f32 = if (role == .tagline)
+ @floatFromInt(g.tagline_height)
+ else
+ layout.h;
+ addCursorBar(&builder, cursor.x, cursor.y, cursor.bar, visual_height, layout);
+ }
}
appendTouchOverlay(g, &builder, debug_on);
return @intCast(builder.len);
}
-fn buildTouchOverlay(g: *Gui, sw: u32, sh: u32, debug_on: bool) u32 {
- if (g.overlay_vertices.len == 0 or sw == 0 or sh == 0) return 0;
- const win_w: f32 = @floatFromInt(sw);
- const win_h: f32 = @floatFromInt(sh);
- var builder = OverlayBuilder{ .vertices = g.overlay_vertices, .win_w = win_w, .win_h = win_h };
- appendTouchOverlay(g, &builder, debug_on);
- return @intCast(builder.len);
-}
-
fn appendTouchOverlay(g: *Gui, builder: *OverlayBuilder, debug_on: bool) void {
if (!debug_on) {
g.touch.click_flash_frames = 0;
@@ -4496,9 +4898,9 @@ fn appendTouchOverlay(g: *Gui, builder: *OverlayBuilder, debug_on: bool) void {
}
}
-fn uploadOverlayGpu(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, vertex_count: u32) void {
+fn uploadOverlayGpu(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, vertex_count: u32) bool {
const byte_len: u32 = vertex_count * @sizeOf(OverlayVertex);
- const ptr = c.SDL_MapGPUTransferBuffer(g.device, g.overlay_vxfer, false) orelse return;
+ const ptr = c.SDL_MapGPUTransferBuffer(g.device, g.overlay_vxfer, false) orelse return false;
const dst: [*]u8 = @ptrCast(ptr);
const src: [*]const u8 = @ptrCast(g.overlay_vertices.ptr);
@memcpy(dst[0..byte_len], src[0..byte_len]);
@@ -4508,6 +4910,7 @@ fn uploadOverlayGpu(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, vertex_count: u32) vo
const dst_region = c.SDL_GPUBufferRegion{ .buffer = g.overlay_vbuf, .offset = 0, .size = byte_len };
c.SDL_UploadToGPUBuffer(copy, &src_loc, &dst_region, false);
c.SDL_EndGPUCopyPass(copy);
+ return true;
}
fn drawOverlayGpu(g: *Gui, rp: ?*c.SDL_GPURenderPass, vertex_count: u32) void {
@@ -4718,13 +5121,6 @@ fn envU16(env: *std.process.Environ.Map, name: []const u8) ?u16 {
return std.fmt.parseInt(u16, raw, 10) catch null;
}
-fn writeFile(path: [*:0]const u8, data: []const u8) void {
- const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
- if (fd < 0) return;
- defer _ = libc.close(fd);
- writeFd(fd, data);
-}
-
fn writeFd(fd: c_int, data: []const u8) void {
var off: usize = 0;
while (off < data.len) {
diff --git a/src/image_pane.zig b/src/image_pane.zig
new file mode 100644
index 00000000..71ef2228
--- /dev/null
+++ b/src/image_pane.zig
@@ -0,0 +1,262 @@
+//! Image panes: their owned pixels, PETSCII cache, lifecycle, and two render
+//! paths. Core layout supplies the body rectangle; this module fills it or
+//! attaches one native image to it.
+const std = @import("std");
+const pardes = @import("pardes.zig");
+const image = @import("image.zig");
+const look = @import("look.zig");
+const dump = @import("dump.zig");
+const config = @import("config.zig");
+
+const GridKey = struct {
+ cols: u16 = 0,
+ rows: u16 = 0,
+ palette: image.PaletteMode = .commodore,
+ ascii: bool = true,
+};
+
+/// The decoded, downscaled RGBA is retained so changing the pane size or one
+/// of the glyph-renderer toggles can rebuild the PETSCII projection without
+/// decoding the source again. All slices are owned by Pardes.image_gpa.
+pub const State = struct {
+ path: []u8,
+ petscii: bool = false,
+ pmode: image.PaletteMode = .commodore,
+ ascii: bool = true,
+ tried: bool = false,
+ rgba: []u8 = &.{},
+ iw: usize = 0,
+ ih: usize = 0,
+ /// Dump-loaded bytes, decoded lazily by the same path as a disk image.
+ raw: []u8 = &.{},
+ grid: image.petscii.Grid = .{ .cells = &.{}, .gw = 0, .gh = 0 },
+ grid_key: GridKey = .{},
+
+ pub fn deinit(state: *State, gpa: std.mem.Allocator) void {
+ gpa.free(state.path);
+ if (state.rgba.len > 0) gpa.free(state.rgba);
+ if (state.raw.len > 0) gpa.free(state.raw);
+ if (state.grid.cells.len > 0) gpa.free(state.grid.cells);
+ state.* = undefined;
+ }
+};
+
+/// Construct an image pane from a path and optionally transferred dump bytes.
+/// `raw` must be image_gpa-owned and ownership transfers only on success.
+pub fn create(p: *pardes.Pardes, id: usize, path: []const u8, raw: []u8) !*pardes.Pane {
+ const path_copy = try p.image_gpa.dupe(u8, path);
+ errdefer p.image_gpa.free(path_copy);
+ const pane = try p.newDocPane(id);
+ pane.image = .{ .path = path_copy, .raw = raw };
+ return pane;
+}
+
+/// Serialize the binary image record used by images and, for dump-schema
+/// compatibility, PDFs. Common pane metadata is supplied by the core.
+pub fn dumpPane(
+ arena: std.mem.Allocator,
+ pane: *const pardes.Pane,
+ tag: []const u8,
+ body: []const u8,
+ scroll: usize,
+ path: []const u8,
+ raw: []const u8,
+) !dump.Pane {
+ const bytes = if (raw.len > 0) raw else look.readFile(arena, path) catch "";
+ return .{
+ .kind = .image,
+ .tag = tag,
+ .body = body,
+ .scroll = scroll,
+ .cols = pane.cols,
+ .rows = pane.rows,
+ .vweight = pane.vweight,
+ .image = .{
+ .path = path,
+ .bytes_b64 = if (bytes.len > 0) try dump.encodeBytes(arena, bytes) else "",
+ .petscii = if (pane.image) |state| state.petscii else false,
+ .palette = if (pane.image) |state| switch (state.pmode) {
+ .commodore => .commodore,
+ .terminal => .terminal,
+ } else .commodore,
+ .ascii = if (pane.image) |state| state.ascii else true,
+ },
+ };
+}
+
+/// Rebuild an ordinary raster image from its dump record. PDF records share
+/// the schema for compatibility and are deliberately dispatched by the core
+/// before reaching this function.
+pub fn restore(p: *pardes.Pardes, id: usize, src: dump.Pane) !*pardes.Pane {
+ const saved = src.image.?;
+ var raw: []u8 = if (saved.bytes_b64.len > 0)
+ try dump.decodeBytes(p.image_gpa, saved.bytes_b64)
+ else
+ &.{};
+ errdefer if (raw.len > 0) p.image_gpa.free(raw);
+ const pane = try create(p, id, saved.path, raw);
+ raw = &.{};
+ pane.image.?.petscii = saved.petscii;
+ pane.image.?.pmode = switch (saved.palette) {
+ .commodore => .commodore,
+ .terminal => .terminal,
+ };
+ pane.image.?.ascii = saved.ascii;
+ pane.cols = @max(1, src.cols);
+ pane.rows = @max(1, src.rows);
+ return pane;
+}
+
+pub fn togglePetscii(state: *State) void {
+ state.petscii = !state.petscii;
+}
+
+pub fn togglePalette(state: *State) void {
+ state.pmode = if (state.pmode == .commodore) .terminal else .commodore;
+}
+
+pub fn toggleAscii(state: *State) void {
+ state.ascii = !state.ascii;
+}
+
+/// Image renderer choices are pane-local, not global Config values. Keep them
+/// queryable where they apply: the live tag beside the image's path.
+pub fn tagPrefix(arena: std.mem.Allocator, state: *const State) ![]u8 {
+ return std.fmt.allocPrint(
+ arena,
+ "{s} petscii:{s} palette:{s} ascii:{s} {s}",
+ .{
+ config.tag_image,
+ if (state.petscii) "on" else "off",
+ @tagName(state.pmode),
+ if (state.ascii) "on" else "off",
+ state.path,
+ },
+ );
+}
+
+/// Prefix written by version-1 dumps before renderer choices became visible
+/// in the live tag. Returned as a borrowed slice of saved_tag for one-time
+/// custom-tail migration.
+pub fn legacySavedPrefix(state: *const State, saved_tag: []const u8) ?[]const u8 {
+ const lead = config.tag_image ++ " ";
+ if (!std.mem.startsWith(u8, saved_tag, lead)) return null;
+ const path_at = lead.len;
+ if (!std.mem.startsWith(u8, saved_tag[path_at..], state.path)) return null;
+ return saved_tag[0 .. path_at + state.path.len];
+}
+
+fn ensureDecoded(p: *pardes.Pardes, state: *State) void {
+ if (state.tried) return;
+ state.tried = true;
+ const bytes: []const u8 = if (state.raw.len > 0)
+ state.raw
+ else
+ look.readFile(p.scratch.allocator(), state.path) catch "";
+ if (image.decode(p.image_gpa, bytes)) |decoded| {
+ state.rgba = decoded.rgba;
+ state.iw = decoded.w;
+ state.ih = decoded.h;
+ }
+}
+
+fn ensureGrid(p: *pardes.Pardes, state: *State, cols: u16, rows: u16) void {
+ const wanted = GridKey{ .cols = cols, .rows = rows, .palette = state.pmode, .ascii = state.ascii };
+ if (state.grid.cells.len > 0 and std.meta.eql(state.grid_key, wanted)) return;
+ if (state.grid.cells.len > 0) p.image_gpa.free(state.grid.cells);
+ const palette = switch (state.pmode) {
+ .commodore => image.petscii.commodore,
+ .terminal => image.ansiPalette(),
+ };
+ state.grid = image.petscii.render(
+ p.image_gpa,
+ state.rgba,
+ state.iw,
+ state.ih,
+ cols,
+ rows,
+ palette,
+ state.ascii,
+ ) catch .{ .cells = &.{}, .gw = 0, .gh = 0 };
+ state.grid_key = wanted;
+}
+
+/// Render the image body supplied by core layout. Native-capable hosts receive
+/// a pixel attachment; other hosts and explicit PETSCII mode receive cells.
+pub fn draw(
+ p: *pardes.Pardes,
+ state: *State,
+ pane_id: u8,
+ serial: u32,
+ x: u16,
+ y: u16,
+ cols: u16,
+ rows: u16,
+) void {
+ ensureDecoded(p, state);
+ if (state.rgba.len == 0 or cols == 0 or rows == 0) return;
+ if (!state.petscii and p.native_images) {
+ _ = p.appendImagePlace(.{
+ .pane = pane_id,
+ .serial = serial,
+ .x = x,
+ .y = y,
+ .w = cols,
+ .h = rows,
+ .rgba = state.rgba,
+ .iw = state.iw,
+ .ih = state.ih,
+ });
+ return;
+ }
+
+ ensureGrid(p, state, cols, rows);
+ if (state.grid.cells.len == 0) return;
+ const offx = if (cols > state.grid.gw) (@as(usize, cols) - state.grid.gw) / 2 else 0;
+ const offy = if (rows > state.grid.gh) (@as(usize, rows) - state.grid.gh) / 2 else 0;
+ for (0..state.grid.gh) |cy| for (0..state.grid.gw) |cx| {
+ const cell = &state.grid.cells[cy * state.grid.gw + cx];
+ const fg: pardes.Color = switch (state.pmode) {
+ .commodore => .{ .rgb = image.petscii.commodore[cell.fg] },
+ .terminal => .{ .index = cell.fg },
+ };
+ const bg: pardes.Color = switch (state.pmode) {
+ .commodore => .{ .rgb = image.petscii.commodore[cell.bg] },
+ .terminal => .{ .index = cell.bg },
+ };
+ const sx = x + @as(u16, @intCast(offx + cx));
+ const sy = y + @as(u16, @intCast(offy + cy));
+ if (sx < p.surface.cols and sy < p.surface.rows)
+ p.surface.set(sx, sy, cell.glyph[0..cell.glen], .{ .fg = fg, .bg = bg });
+ };
+}
+
+test "pane-local renderer choices are visible in the image tag" {
+ var state: State = .{ .path = @constCast("/tmp/picture.ppm") };
+ togglePetscii(&state);
+ togglePalette(&state);
+ toggleAscii(&state);
+ const tag = try tagPrefix(std.testing.allocator, &state);
+ defer std.testing.allocator.free(tag);
+ try std.testing.expectEqualStrings(
+ config.tag_image ++ " petscii:on palette:terminal ascii:off /tmp/picture.ppm",
+ tag,
+ );
+ try std.testing.expectEqualStrings(
+ "img /tmp/picture.ppm",
+ legacySavedPrefix(&state, "img /tmp/picture.ppm Keep Del").?,
+ );
+}
+
+test "dump restore propagates malformed embedded image bytes" {
+ const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ try std.testing.expect(p.panes[1] == null);
+ try std.testing.expectError(error.InvalidCharacter, restore(p, 1, .{
+ .kind = .image,
+ .tag = "img /tmp/bad.ppm",
+ .body = "",
+ .image = .{ .path = "/tmp/bad.ppm", .bytes_b64 = "A..A" },
+ }));
+ try std.testing.expect(p.panes[1] == null);
+}
diff --git a/src/look.zig b/src/look.zig
index 49be1646..ece2ee0f 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -601,7 +601,7 @@ pub fn find(arena: std.mem.Allocator, dir: []const u8, pat: []const u8, out: []u
if (platform_has_fs) {
// Zig 0.16 moved the filesystem behind std.Io; the blocking
// single-threaded implementation (the one std.debug itself holds) IS
- // the synchronous walk a sans-IO core wants — no pool, no cancelation.
+ // the synchronous walk the core uses — no pool, no cancelation.
const io = std.Io.Threaded.global_single_threaded.io();
var root = try std.Io.Dir.cwd().openDir(io, dir, .{ .iterate = true });
defer root.close(io);
diff --git a/src/macos.zig b/src/macos.zig
index 100019ef..9f971e5f 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -29,13 +29,12 @@ const temp_file = @import("temp_file.zig");
const shell_bin = @import("shell_bin.zig");
const message = @import("message.zig");
const nested = @import("nested.zig");
+const panel_animation = @import("panel_animation.zig");
+const file_watch = @import("file_watch.zig");
/// The geometry types the pixel-attachment ABI carries. Behind the same
/// comptime gate the placements themselves are: a build without MuPDF emits no
/// attachments, so nothing here is analysed.
const image = if (pardes.pdf_enabled) @import("image.zig") else struct {};
-const fonts = if (pardes.font_picker) @import("fonts.zig") else struct {
- pub const want: ?[]const u8 = null;
-};
const user_config = @import("user_config.zig");
extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int;
@@ -44,6 +43,21 @@ extern "c" fn chdir(path: [*:0]const u8) c_int;
extern "c" fn _exit(status: c_int) noreturn;
extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
+/// Implemented by FileWatcher.swift in the app and e2e host. Zig-only unit
+/// tests have no AppKit runloop and compile this call away; the shipped static
+/// library leaves the symbol for its Swift executable to satisfy directly.
+extern "c" fn pardes_host_watch_file(
+ pane: u8,
+ generation: u32,
+ path: ?[*]const u8,
+ path_len: usize,
+) void;
+
+fn hostWatchFile(pane: u8, generation: u32, path: ?[*]const u8, path_len: usize) void {
+ if (comptime builtin.is_test) return;
+ pardes_host_watch_file(pane, generation, path, path_len);
+}
+
// TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize). Same
// constant the tty and gui shells spell for the same reason.
const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467));
@@ -84,6 +98,20 @@ pub const Cell = extern struct {
flags: u8,
};
+/// Sync with: pardes_scene_s. Persistent full-window effects share one host
+/// postprocess, so one plain snapshot carries both its switches and clock.
+pub const Scene = extern struct {
+ flags: u32 = 0,
+ time_seconds: f32 = 0,
+ frame: u32 = 0,
+};
+
+/// Sync with pardes_panel_{box,track}_s. The core's backend-neutral Track is
+/// already an extern POD record, so the native boundary can publish it without
+/// translating the easing vocabulary into a second representation.
+pub const PanelBox = panel_animation.Box;
+pub const PanelTrack = panel_animation.Track;
+
/// Sync with: pardes_image_s. One rasterized attachment — a PDF page, or an
/// image pane's pixels — and where on the grid it goes.
///
@@ -136,6 +164,14 @@ pub const Runtime = extern struct {
const color_default: u32 = 0x01000000;
const color_indexed: u32 = 0x02000000;
const cell_flag_default: u8 = 1;
+const cell_flag_tagline: u8 = 2;
+const scene_flag_crt: u32 = 1 << 0;
+const scene_flag_ripple: u32 = 1 << 1;
+const scene_flag_glitch: u32 = 1 << 2;
+const scene_frame_hz: u32 = 60;
+/// Keep the float-valued time and noise frame precise, then repeat after a
+/// little over an hour. None of the effects has state across this boundary.
+const scene_frame_wrap: u32 = 4096 * scene_frame_hz;
// ---------------------------------------------------------------- state
@@ -149,6 +185,192 @@ const Pty = struct {
reader: std.Io.Future(anyerror!void),
};
+/// Main-thread ownership for the host's per-pane vnode sources. A path is
+/// copied rather than borrowed from Pane: a queued callback may outlive the
+/// effect which replaced that pane slot, and exact path equality is the final
+/// guard before any bytes reach the core.
+const WatchedFile = struct {
+ path: []u8,
+ serial: u32,
+ generation_on_disk: file_watch.Generation,
+ generation: u32,
+ /// One self-scheduled reconciliation after a transient read/reopen race.
+ /// A real host edge replenishes it; a malformed stable file therefore
+ /// tries twice and then sleeps rather than becoming an idle busy loop.
+ retries_left: u8 = 1,
+};
+
+const FileWatches = struct {
+ entries: [pardes.MAX_PANES]?WatchedFile = @splat(null),
+ generations: [pardes.MAX_PANES]u32 = @splat(0),
+ dirty: [pardes.MAX_PANES]bool = @splat(false),
+
+ fn nextGeneration(watches: *FileWatches, pane: u8) u32 {
+ watches.generations[pane] +%= 1;
+ if (watches.generations[pane] == 0) watches.generations[pane] = 1;
+ return watches.generations[pane];
+ }
+
+ fn replace(
+ watches: *FileWatches,
+ gpa: std.mem.Allocator,
+ pane: u8,
+ path: []const u8,
+ serial: u32,
+ generation_on_disk: file_watch.Generation,
+ ) !u32 {
+ // Allocate first. If memory is tight, the caller can explicitly stop
+ // the old source; silently retaining a watch for a reused pane would be
+ // worse than having no watch at all.
+ const owned = try gpa.dupe(u8, path);
+ if (watches.entries[pane]) |old| gpa.free(old.path);
+ const generation = watches.nextGeneration(pane);
+ watches.entries[pane] = .{
+ .path = owned,
+ .serial = serial,
+ .generation_on_disk = generation_on_disk,
+ .generation = generation,
+ };
+ watches.dirty[pane] = false;
+ return generation;
+ }
+
+ fn stop(watches: *FileWatches, gpa: std.mem.Allocator, pane: u8) u32 {
+ if (watches.entries[pane]) |old| gpa.free(old.path);
+ watches.entries[pane] = null;
+ watches.dirty[pane] = false;
+ return watches.nextGeneration(pane);
+ }
+
+ /// Coalesce any number of vnode events into one main-thread re-read.
+ /// The Swift side already debounces a burst; this bit is the second, cheap
+ /// edge which prevents two queued callbacks from applying one snapshot
+ /// twice. A stale generation can never dirty a reused pane slot.
+ fn notify(watches: *FileWatches, pane: u8, generation: u32) bool {
+ const watched = if (watches.entries[pane]) |*entry| entry else return false;
+ if (watched.generation != generation) return false;
+ watched.retries_left = 1;
+ const first = !watches.dirty[pane];
+ watches.dirty[pane] = true;
+ return first;
+ }
+
+ fn retry(watches: *FileWatches, pane: u8, generation: u32) bool {
+ const watched = if (watches.entries[pane]) |*entry| entry else return false;
+ if (watched.generation != generation or watched.retries_left == 0) return false;
+ watched.retries_left -= 1;
+ const first = !watches.dirty[pane];
+ watches.dirty[pane] = true;
+ return first;
+ }
+
+ fn takeDirty(watches: *FileWatches, pane: u8) bool {
+ const value = watches.dirty[pane];
+ watches.dirty[pane] = false;
+ return value;
+ }
+
+ fn restampText(watches: *FileWatches, pane: u8, path: []const u8, hash: u64) void {
+ const watched = if (watches.entries[pane]) |*entry| entry else return;
+ if (!std.mem.eql(u8, watched.path, path)) return;
+ switch (watched.generation_on_disk) {
+ .text => watched.generation_on_disk = .{ .text = hash },
+ .pdf => {},
+ }
+ }
+
+ fn deinit(watches: *FileWatches, gpa: std.mem.Allocator) void {
+ for (&watches.entries) |*entry| {
+ if (entry.*) |old| gpa.free(old.path);
+ entry.* = null;
+ }
+ watches.dirty = @splat(false);
+ }
+};
+
+test "mac file watch generations own paths, coalesce, and reject stale callbacks" {
+ var watches: FileWatches = .{};
+ defer watches.deinit(std.testing.allocator);
+
+ var first_path = [_]u8{ '/', 't', 'm', 'p', '/', 'a' };
+ const first = try watches.replace(
+ std.testing.allocator,
+ 3,
+ &first_path,
+ 41,
+ .{ .text = 11 },
+ );
+ first_path[first_path.len - 1] = 'x';
+ try std.testing.expectEqualStrings("/tmp/a", watches.entries[3].?.path);
+ try std.testing.expect(watches.notify(3, first));
+ try std.testing.expect(!watches.notify(3, first));
+ try std.testing.expect(watches.takeDirty(3));
+ try std.testing.expect(!watches.takeDirty(3));
+
+ const second = try watches.replace(
+ std.testing.allocator,
+ 3,
+ "/tmp/b",
+ 42,
+ .{ .text = 22 },
+ );
+ try std.testing.expect(second != first);
+ try std.testing.expect(!watches.notify(3, first));
+ try std.testing.expect(watches.notify(3, second));
+ try std.testing.expectEqual(@as(u8, 1), watches.entries[3].?.retries_left);
+ try std.testing.expect(watches.takeDirty(3));
+ try std.testing.expect(watches.retry(3, second));
+ try std.testing.expect(!watches.retry(3, second));
+ try std.testing.expect(watches.takeDirty(3));
+ // A stable malformed file cannot self-wake forever, but a later real vnode
+ // edge replenishes exactly one retry for the new external transaction.
+ try std.testing.expect(watches.notify(3, second));
+ try std.testing.expectEqual(@as(u8, 1), watches.entries[3].?.retries_left);
+ try std.testing.expect(watches.takeDirty(3));
+ watches.restampText(3, "/tmp/not-b", 99);
+ try std.testing.expectEqual(@as(u64, 22), watches.entries[3].?.generation_on_disk.text);
+ watches.restampText(3, "/tmp/b", 33);
+ try std.testing.expectEqual(@as(u64, 33), watches.entries[3].?.generation_on_disk.text);
+
+ const stopped = watches.stop(std.testing.allocator, 3);
+ try std.testing.expect(stopped != second);
+ try std.testing.expect(!watches.notify(3, second));
+ try std.testing.expect(!watches.retry(3, second));
+ try std.testing.expect(watches.entries[3] == null);
+
+ _ = try watches.replace(std.testing.allocator, 4, "/tmp/book.pdf", 77, .{ .pdf = null });
+ watches.restampText(4, "/tmp/book.pdf", 123);
+ try std.testing.expect(watches.entries[4].?.generation_on_disk.pdf == null);
+}
+
+const file_watcher_swift = @embedFile("macos/Sources/FileWatcher.swift");
+
+test "mac host watcher covers file and directory vnode events, debounce, and generation callback" {
+ // Linux cannot compile AppKit/Dispatch Swift. Keep the critical architecture
+ // check reachable there: atomic saves need the parent, in-place writes need
+ // a rearmed file source, and all mutation returns through the generation ABI.
+ try std.testing.expect(std.mem.indexOf(
+ u8,
+ file_watcher_swift,
+ "deletingLastPathComponent()",
+ ) != null);
+ try std.testing.expect(std.mem.indexOf(
+ u8,
+ file_watcher_swift,
+ "self.reopenFileSource()",
+ ) != null);
+ try std.testing.expect(std.mem.indexOf(
+ u8,
+ file_watcher_swift,
+ "DispatchQueue.main.asyncAfter",
+ ) != null);
+ try std.testing.expect(std.mem.indexOf(
+ u8,
+ file_watcher_swift,
+ "pardes_watch_changed(self.pane, self.generation)",
+ ) != null);
+}
+
/// What a reader task hands the main thread. `gen` travels with the message so
/// a shell that was replaced while its read was in flight cannot have its
/// stragglers parsed into the pty that took its slot.
@@ -276,6 +498,12 @@ const State = struct {
arena: std.heap.ArenaAllocator,
runtime: Runtime,
cells: []Cell = &.{},
+ /// Frozen canonical grid paired with `changed_cells` while a content or
+ /// lifecycle transition is active. Both are encoded at frame time so the
+ /// native renderer never borrows core-owned Cell layout across the ABI.
+ previous_cells: []Cell = &.{},
+ changed_cells: []u8 = &.{},
+ panel_diff_len: usize = 0,
frame_len: usize = 0,
/// The grid `cells` actually holds. Not read back off the core: a render
/// can move screen_w/screen_h and then fail, and a host that sized its
@@ -287,8 +515,13 @@ const State = struct {
/// accessors must never describe a different frame than the cell count.
images: []Image = &.{},
images_len: usize = 0,
+ /// This frame's panel transitions, copied out of Surface in deterministic
+ /// paint order: moving, opening, then frozen closing tombstones.
+ panel_tracks: [pardes.MAX_PANES * 2]PanelTrack = undefined,
+ panel_tracks_len: usize = 0,
ptys: [pardes.MAX_PANES]?Pty = @splat(null),
inbox: Inbox = .{},
+ file_watches: FileWatches = .{},
/// Per-slot spawn generation, owned by the main thread. A reader carries a
/// copy in every message it posts; anything that no longer matches belongs
/// to a shell this slot has already replaced.
@@ -313,6 +546,9 @@ const State = struct {
/// velocity because during the gesture that velocity is a MEASUREMENT —
/// spending it then would double every twist under the hand making it.
rotate_coasting: bool = false,
+ /// Display-clock time for the persistent Core Image scene pass. Input and
+ /// pty pumps never spend it; pardes_animation_tick is the only writer.
+ scene_frame: u32 = 0,
/// Panes whose shell has produced output since we last read its cwd.
///
/// The cwd is wanted for pane tags and for resolving a relative Look, and
@@ -329,6 +565,8 @@ const State = struct {
sock_fd: c_int = -1,
/// Owns the bytes of the user config, which Options only borrows.
config_arena: std.heap.ArenaAllocator,
+ /// Private prompt snippets borrowed by every child argv until exec.
+ prompt_rcs: shell_bin.PromptRcs,
};
var state: ?State = null;
@@ -397,10 +635,10 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void {
// capability, because there it is a question rather than a fact.
core.native_images = true;
- // Shells emit OSC 133 prompt marks through these, which is what makes
- // prompt hiding and click-to-move work.
- writeFile(shell_bin.bash_rc_path, shell_bin.bash_rc);
- writeFile(shell_bin.fish_rc_path, shell_bin.fish_rc);
+ // Complete private files before any fork; State retains their path buffers
+ // for every later shell spawn and removes the files at app teardown.
+ var prompt_rcs = shell_bin.PromptRcs.init();
+ errdefer prompt_rcs.deinit();
// Apple's bash 3.2 prints the zsh-deprecation banner into every pane unless
// this is in the environment BEFORE bash starts — the rc file is too late.
if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1);
@@ -412,6 +650,7 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void {
.core = core,
.arena = .init(allocs.frame),
.config_arena = config_arena,
+ .prompt_rcs = prompt_rcs,
.runtime = if (runtime) |r| r.* else .{},
};
const st = &state.?;
@@ -420,8 +659,8 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void {
core.tty_query = .{ .ctx = st, .taken = &ttyTakenAt };
// The real grid, delivered as an EVENT and not as Options.cols/rows: the
- // core defers each shell's greeting until it has seen a resize, and the
- // first forkpty below takes its winsize straight off the core.
+ // core defers an integrated shell's greeting until this resize and OSC
+ // 133 B; the first forkpty below takes its winsize straight off the core.
const cols = @max(1, cols_arg);
const rows = @max(1, rows_arg);
core.update(.{ .resize = .{ .cols = cols, .rows = rows } });
@@ -482,6 +721,14 @@ export fn pardes_deinit() void {
// the process, which is what its detach() already said.
nested.unlisten(st.sock_fd);
st.sock_fd = -1;
+ // Cancel host directory sources while their generation table still exists.
+ // A debounce block already queued on the main runloop may call back later;
+ // state=null below and the bumped generation each make that callback inert.
+ for (0..pardes.MAX_PANES) |pane| if (st.file_watches.entries[pane] != null) {
+ const id: u8 = @intCast(pane);
+ const generation = st.file_watches.stop(st.gpa, id);
+ hostWatchFile(id, generation, null, 0);
+ };
// Every reader is joined here, before anything it touches is freed. The
// runtime joins its tasks on exit, so a reader left parked in read(2) would
// hang the process instead of the app quitting.
@@ -489,7 +736,10 @@ export fn pardes_deinit() void {
// Only now is the inbox quiet. Anything still queued owns gpa bytes and
// would show up as a leak rather than as the shutdown it actually is.
st.inbox.close(st.gpa);
+ st.file_watches.deinit(st.gpa);
if (st.cells.len > 0) st.gpa.free(st.cells);
+ if (st.previous_cells.len > 0) st.gpa.free(st.previous_cells);
+ if (st.changed_cells.len > 0) st.gpa.free(st.changed_cells);
if (st.images.len > 0) st.gpa.free(st.images);
st.arena.deinit();
st.core.deinit();
@@ -497,6 +747,7 @@ export fn pardes_deinit() void {
if (comptime pardes.pdf_enabled) pardes.pdf.stop();
pardes.syntax.stop();
st.config_arena.deinit();
+ st.prompt_rcs.deinit();
st.threaded.deinit();
st.gpa.destroy(st.threaded);
pardes.allocators.deinit();
@@ -508,13 +759,29 @@ export fn pardes_should_quit() bool {
return st.core.quit;
}
-/// Something on screen moves on its own and wants ~60 Hz ticks until it stops:
-/// a theme transition fading, or the rotation dial coasting after a flick.
-/// Both are spent by pardes_tick, so this is the host's only cue to keep
-/// pumping — an idle pardes costs nothing precisely because it says false.
+fn encodeSceneEffects(effects: panel_animation.SceneEffect) u32 {
+ var flags: u32 = 0;
+ if (effects.crt) flags |= scene_flag_crt;
+ if (effects.ripple) flags |= scene_flag_ripple;
+ if (effects.glitch) flags |= scene_flag_glitch;
+ return flags;
+}
+
+fn currentSceneFlags(st: *const State) u32 {
+ return encodeSceneEffects(st.core.settings.scene_effects);
+}
+
+fn advanceSceneFrame(frame: *u32) void {
+ frame.* = (frame.* + 1) % scene_frame_wrap;
+}
+
+/// Something on screen moves on its own and wants ~60 Hz ticks: a finite core
+/// transition, a persistent scene shader, or the rotation dial coasting after
+/// a flick. All are spent only by pardes_animation_tick, so input and pty pumps
+/// cannot make frame-count animation run faster than the display clock.
export fn pardes_animating() bool {
const st = &(state orelse return false);
- return st.core.themeAnimationActive() or st.rotate_coasting;
+ return st.core.animationActive() or st.rotate_coasting;
}
/// The colour the host should paint everything the grid does not: the window
@@ -540,6 +807,60 @@ export fn pardes_theme_bg() u32 {
return @as(u32, bg[0]) << 16 | @as(u32, bg[1]) << 8 | bg[2];
}
+fn taglineFontPercent(core: ?*const pardes.Pardes) u8 {
+ return if (core) |p| p.settings.font.tagline_percent else pardes.config.gui_tagline_font_percent;
+}
+
+/// The smaller face used for pane taglines, as a percentage of the body face.
+/// Grid geometry always comes from the body face. Before init the compiled
+/// default lets the host construct its metrics; afterwards it pulls the live
+/// core value so a TaglineSize command is visible on the next host read.
+export fn pardes_gui_tagline_font_percent() u8 {
+ return taglineFontPercent(if (state) |*st| st.core else null);
+}
+
+test "tagline percent falls back before init and follows live core state" {
+ try std.testing.expectEqual(pardes.config.gui_tagline_font_percent, taglineFontPercent(null));
+
+ const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer core.deinit();
+ const changed: u8 = if (pardes.config.gui_tagline_font_percent == 37) 38 else 37;
+ core.settings.font.tagline_percent = changed;
+ try std.testing.expectEqual(changed, taglineFontPercent(core));
+}
+
+/// One coherent snapshot for the host's single scene postprocess. The clock is
+/// frame based, just like pane/theme transitions: it advances on the scheduled
+/// display callback and never on an input or pty drain.
+export fn pardes_scene() Scene {
+ const st = &(state orelse return .{});
+ return .{
+ .flags = currentSceneFlags(st),
+ .time_seconds = @as(f32, @floatFromInt(st.scene_frame)) / @as(f32, @floatFromInt(scene_frame_hz)),
+ .frame = st.scene_frame,
+ };
+}
+
+/// The host could not construct or repeatedly submit the shared Metal/Core
+/// Image pass. Stop claiming effects are enabled when only the canonical grid
+/// can be presented, stop its otherwise-unbounded display-clock wakeups, and
+/// snap any current panels before the direct canonical fallback is drawn.
+export fn pardes_postprocessor_unavailable() void {
+ const st = &(state orelse return);
+ st.core.disableSceneEffects();
+ st.core.settings.panel_transition = .off;
+ st.core.abandonPanelAnimations();
+ st.scene_frame = 0;
+}
+
+/// One transient postprocess submission failed and the host will draw the
+/// canonical grid for this frame. A later retry may keep scene effects, but it
+/// must not resume a half-finished panel transition after that canonical frame.
+export fn pardes_panel_animation_failed() void {
+ const st = &(state orelse return);
+ st.core.abandonPanelAnimations();
+}
+
/// Re-read the cwd of every shell that just spoke, and only those.
///
/// A pane's tag shows this and a relative `Look` resolves against it, so it has
@@ -562,6 +883,154 @@ fn refreshCwds(st: *State) void {
}
}
+fn paneWatchPath(core: *const pardes.Pardes, pane: u8) ?[]const u8 {
+ const value = core.panes[pane] orelse return null;
+ if (value.file) |file| return file.path;
+ if (comptime pardes.pdf_enabled) if (value.pdfPath()) |path| return path;
+ return null;
+}
+
+fn watchInitialGeneration(st: *State, pane: u8, path: []const u8) ?file_watch.Generation {
+ const value = st.core.panes[pane] orelse return null;
+ if (value.file) |file| if (std.mem.eql(u8, file.path, path))
+ return .{ .text = std.hash.Wyhash.hash(0, file.content) };
+ if (comptime pardes.pdf_enabled) if (value.pdf) |pdf| if (std.mem.eql(u8, pdf.path, path))
+ return .{ .pdf = null };
+ return null;
+}
+
+fn setFileWatch(st: *State, pane: u8, on: bool) void {
+ if (!on) {
+ const generation = st.file_watches.stop(st.gpa, pane);
+ hostWatchFile(pane, generation, null, 0);
+ return;
+ }
+ const path = paneWatchPath(st.core, pane) orelse {
+ const generation = st.file_watches.stop(st.gpa, pane);
+ hostWatchFile(pane, generation, null, 0);
+ return;
+ };
+ const value = st.core.panes[pane] orelse return;
+ const generation_on_disk = watchInitialGeneration(st, pane, path) orelse return;
+ const generation = st.file_watches.replace(
+ st.gpa,
+ pane,
+ path,
+ value.serial,
+ generation_on_disk,
+ ) catch {
+ const stopped = st.file_watches.stop(st.gpa, pane);
+ hostWatchFile(pane, stopped, null, 0);
+ return;
+ };
+ const watched = st.file_watches.entries[pane].?;
+ hostWatchFile(pane, generation, watched.path.ptr, watched.path.len);
+ // The document was opened before this source existed. Reconcile once only
+ // AFTER source.activate() so a replacement in that gap is either observed
+ // here or produces a later directory edge; there is no open-before-watch
+ // window in which both mechanisms can miss it.
+ _ = reloadWatchedFile(st, pane, false);
+}
+
+/// Read and apply on the main thread. Swift only says that this path or its
+/// parent changed. Text hashes an exact bounded snapshot; PDFs may be much
+/// larger than that bound and MuPDF reopens the path itself, so they compare a
+/// cheap inode/size/time identity instead. Both transactions enter through the
+/// same success-reporting core seam and only then advance their baseline.
+const WatchReload = enum { no_change, committed, changed_uncommitted };
+
+fn retryWatchedFile(st: *State, pane: u8, generation: u32) void {
+ if (st.file_watches.retry(pane, generation)) wake(st);
+}
+
+fn reloadWatchedFile(st: *State, pane: u8, announce: bool) bool {
+ const watched = if (st.file_watches.entries[pane]) |*entry| entry else return false;
+ const generation = watched.generation;
+ const current_path = paneWatchPath(st.core, pane) orelse return false;
+ if (!std.mem.eql(u8, watched.path, current_path)) return false;
+ const current = st.core.panes[pane] orelse return false;
+ if (current.serial != watched.serial) return false;
+
+ const result: WatchReload = switch (watched.generation_on_disk) {
+ .text => |old_hash| text: {
+ if (current.file == null) break :text .no_change;
+ const bytes = look.readFile(st.gpa, watched.path) catch {
+ retryWatchedFile(st, pane, generation);
+ break :text .no_change;
+ };
+ defer st.gpa.free(bytes);
+ const hash = std.hash.Wyhash.hash(0, bytes);
+ if (hash == old_hash) break :text .no_change;
+ if (!st.core.reloadWatchedFile(pane, bytes)) {
+ retryWatchedFile(st, pane, generation);
+ break :text .no_change;
+ }
+
+ // The call is synchronous, but retain the same lifetime guards as
+ // the async edge: future refactors cannot bless a reused slot just
+ // because it happens to carry the same pathname.
+ const after = st.core.panes[pane] orelse break :text .no_change;
+ const active = if (st.file_watches.entries[pane]) |*entry| entry else break :text .no_change;
+ if (active.generation != generation or after.serial != active.serial)
+ break :text .no_change;
+ active.generation_on_disk = .{ .text = hash };
+ active.retries_left = 0;
+ break :text .committed;
+ },
+ .pdf => |old_identity| pdf: {
+ if (comptime !pardes.pdf_enabled) break :pdf .no_change;
+ const state_before = if (current.pdf) |*pdf_before| pdf_before else break :pdf .no_change;
+ if (!std.mem.eql(u8, state_before.path, watched.path)) break :pdf .no_change;
+ const before = file_watch.identify(st.io, watched.path) catch {
+ retryWatchedFile(st, pane, generation);
+ break :pdf .no_change;
+ };
+ if (old_identity) |old| if (old.eql(before)) break :pdf .no_change;
+ if (!st.core.reloadWatchedFile(pane, &.{})) {
+ retryWatchedFile(st, pane, generation);
+ break :pdf .no_change;
+ }
+
+ const after = st.core.panes[pane] orelse break :pdf .changed_uncommitted;
+ if (after.serial != watched.serial) break :pdf .changed_uncommitted;
+ const pdf_state = if (after.pdf) |*pdf_after| pdf_after else break :pdf .changed_uncommitted;
+ if (!std.mem.eql(u8, pdf_state.path, watched.path)) break :pdf .changed_uncommitted;
+ const after_identity = file_watch.identify(st.io, watched.path) catch {
+ retryWatchedFile(st, pane, generation);
+ break :pdf .changed_uncommitted;
+ };
+ // The identity must bracket the complete synchronous MuPDF
+ // transaction. If the path moved during it, leave the old baseline
+ // in place and spend one bounded retry from the already-armed
+ // source; correctness does not depend on receiving a second edge.
+ if (!before.eql(after_identity)) {
+ retryWatchedFile(st, pane, generation);
+ break :pdf .changed_uncommitted;
+ }
+ const active = if (st.file_watches.entries[pane]) |*entry| entry else break :pdf .changed_uncommitted;
+ if (active.generation != generation or active.serial != after.serial)
+ break :pdf .changed_uncommitted;
+ active.generation_on_disk = .{ .pdf = after_identity };
+ active.retries_left = 0;
+ break :pdf .committed;
+ },
+ };
+ if (result == .committed and announce) {
+ var mbuf: [256]u8 = undefined;
+ st.core.setMessage(pane, message.stamp(&mbuf, "reloaded", watched.path));
+ }
+ return result != .no_change;
+}
+
+/// FileWatcher.swift calls this from DispatchQueue.main after its short quiet
+/// period. Do not touch the core here: schedule the ordinary pump so all file
+/// IO and state mutation stay in pardes_tick with pty/nested messages.
+export fn pardes_watch_changed(pane: u8, generation: u32) void {
+ const st = &(state orelse return);
+ if (pane >= pardes.MAX_PANES) return;
+ if (st.file_watches.notify(pane, generation)) wake(st);
+}
+
/// The core's `tty_query`, asked only where a command line is about to be typed:
/// is a program holding this pane's tty instead of the prompt we forked?
/// `look.ttyTaken` answers `false` on darwin until it grows a libproc
@@ -609,19 +1078,30 @@ export fn pardes_tick() bool {
.command => |c| st.core.update(.{ .command = c }),
}
}
+ for (0..pardes.MAX_PANES) |pane| {
+ const id: u8 = @intCast(pane);
+ if (!st.file_watches.takeDirty(id)) continue;
+ if (reloadWatchedFile(st, id, true)) changed = true;
+ }
refreshCwds(st);
if (drainEffects(st, true)) changed = true;
- // ...and ADVANCE the transition, which is the whole reason the host keeps
- // ticking. The tty and SDL loops call `core.update(.tick)` on their own
- // clocks; this host has no loop of its own, so the pump IS the clock — and
- // without this the fade never moved: `chromeTheme()` stayed on the OLD
- // theme's chrome forever, so every tagline kept its previous colours until
- // the next launch, and `themeAnimationActive()` never went false, so the
- // 16 ms re-pump in AppDelegate.pump spun for the rest of the session.
- if (st.core.themeAnimationActive()) {
+ return changed;
+}
+
+/// Advance exactly one display-clock frame. Event pumps deliberately never
+/// call this: a burst of key, mouse, or pty notifications is work to drain,
+/// not elapsed animation time.
+export fn pardes_animation_tick() bool {
+ const st = &(state orelse return false);
+ var changed = false;
+ if (st.core.animationActive()) {
st.core.update(.tick);
changed = true;
}
+ if (currentSceneFlags(st) != 0) {
+ advanceSceneFrame(&st.scene_frame);
+ changed = true;
+ }
// ...and the dial, for the same reason and off the same clock: one frame
// of coast per tick, decayed, until it is slower than a notch a second.
if (st.rotate_coasting) {
@@ -693,6 +1173,11 @@ export fn pardes_mouse(button_arg: c_int, kind_arg: c_int, col: u16, row: u16, m
} });
}
+export fn pardes_pointer_leave() void {
+ const st = &(state orelse return);
+ st.core.update(.pointer_leave);
+}
+
export fn pardes_scroll(delta_rows: f32, delta_cols: f32, col: u16, row: u16) void {
const st = &(state orelse return);
var down_left = takeScrollTicks(&st.scroll_lag, delta_rows);
@@ -843,6 +1328,8 @@ export fn pardes_frame() u32 {
st.frame_cols = 0;
st.frame_rows = 0;
st.images_len = 0;
+ st.panel_tracks_len = 0;
+ st.panel_diff_len = 0;
const surface = st.core.render(st.arena.allocator()) catch |err| {
log.err("render failed: {t}", .{err});
return 0;
@@ -863,21 +1350,62 @@ export fn pardes_frame() u32 {
st.frame_len = count;
st.frame_cols = surface.cols;
st.frame_rows = surface.rows;
- for (surface.cells, st.cells[0..count]) |cell, *out| {
- out.* = .{
- .text = @splat(0),
- .fg = encodeColor(cell.style.fg),
- .bg = encodeColor(cell.style.bg),
- .attrs = encodeAttrs(cell.style),
- .len = if (cell.default) 1 else cell.len,
- .flags = @intFromBool(cell.default),
- };
- if (cell.default) out.text[0] = ' ' else @memcpy(out.text[0..cell.len], cell.grapheme());
- }
+ for (surface.cells, st.cells[0..count]) |cell, *out| out.* = encodeCell(cell);
+ collectPanelDiff(st, surface, count);
collectImages(st, surface);
+ collectPanelTracks(st, surface);
return @intCast(count);
}
+/// Flatten tracks into the C-visible order the shader composites them. Pane
+/// slots are stable tie-breakers because Surface publishes them in slot order.
+fn collectPanelTracks(st: *State, surface: *const pardes.Surface) void {
+ st.panel_tracks_len = copyPanelTracksInPaintOrder(surface.panelTracks(), &st.panel_tracks);
+}
+
+fn copyPanelTracksInPaintOrder(source: []const PanelTrack, out: []PanelTrack) usize {
+ var len: usize = 0;
+ for ([_]panel_animation.Phase{ .moving, .opening, .closing }) |phase| {
+ for (source) |track| {
+ if (!track.active() or track.phase != phase) continue;
+ std.debug.assert(len < out.len);
+ out[len] = track;
+ len += 1;
+ }
+ }
+ return len;
+}
+
+/// Copy the old/new semantic transition data as one all-or-nothing snapshot.
+/// A missing allocation disables the optional diff for this frame; it never
+/// leaves a previous grid paired with a mask from another render.
+fn collectPanelDiff(st: *State, surface: *const pardes.Surface, count: usize) void {
+ if (!surface.hasPanelDiff() or count == 0) return;
+ if (st.previous_cells.len != count) {
+ const resized = if (st.previous_cells.len == 0)
+ st.gpa.alloc(Cell, count)
+ else
+ st.gpa.realloc(st.previous_cells, count);
+ st.previous_cells = resized catch return;
+ }
+ if (st.changed_cells.len != count) {
+ const resized = if (st.changed_cells.len == 0)
+ st.gpa.alloc(u8, count)
+ else
+ st.gpa.realloc(st.changed_cells, count);
+ st.changed_cells = resized catch return;
+ }
+ for (surface.previous_cells, st.previous_cells[0..count]) |cell, *out|
+ out.* = encodeCell(cell);
+ for (surface.changed_cells, st.changed_cells[0..count]) |changed, *out|
+ out.* = encodeChanged(changed);
+ st.panel_diff_len = count;
+}
+
+fn encodeChanged(changed: u8) u8 {
+ return if (changed == 0) 0 else 255;
+}
+
/// Flatten Surface.images into the flat C array the host walks.
///
/// A dropped attachment is a page that does not draw, never a wrong one, so
@@ -946,11 +1474,51 @@ export fn pardes_frame_image_list() ?[*]const Image {
return if (st.images_len == 0) null else st.images.ptr;
}
+export fn pardes_frame_panel_tracks() u32 {
+ const st = &(state orelse return 0);
+ return @intCast(st.panel_tracks_len);
+}
+
+export fn pardes_frame_panel_track_list() ?[*]const PanelTrack {
+ const st = &(state orelse return null);
+ return if (st.panel_tracks_len == 0) null else st.panel_tracks[0..].ptr;
+}
+
+/// AppKit calls this only after its destination context has accepted the
+/// frame. The boolean keeps the ABI POD-only: animated presentation uses the
+/// borrowed records from `pardes_frame`, while a direct fallback commits the
+/// canonical grid with an empty snapshot.
+export fn pardes_frame_presented(animated_panels: bool) bool {
+ const st = &(state orelse return false);
+ const was_animating = st.core.animationActive();
+ if (animated_panels)
+ st.core.acknowledgePanelPresentation(st.panel_tracks[0..st.panel_tracks_len])
+ else
+ st.core.acknowledgePanelPresentation(&.{});
+ return !was_animating and st.core.animationActive();
+}
+
export fn pardes_frame_cells() ?[*]const Cell {
const st = &(state orelse return null);
return if (st.frame_len == 0) null else st.cells.ptr;
}
+export fn pardes_frame_previous_cells() ?[*]const Cell {
+ const st = &(state orelse return null);
+ return if (st.panel_diff_len != st.frame_len or st.panel_diff_len == 0)
+ null
+ else
+ st.previous_cells.ptr;
+}
+
+export fn pardes_frame_changed_cells() ?[*]const u8 {
+ const st = &(state orelse return null);
+ return if (st.panel_diff_len != st.frame_len or st.panel_diff_len == 0)
+ null
+ else
+ st.changed_cells.ptr;
+}
+
export fn pardes_frame_cols() u16 {
const st = &(state orelse return 0);
return st.frame_cols;
@@ -992,22 +1560,53 @@ export fn pardes_take_haptic() c_int {
/// shape as the haptic above, and the same one the SDL shell uses on this
/// exact variable.
///
-/// A copy rather than the borrowed slice: `fonts.want` is a length and no
-/// terminator, and C wants a string. One static buffer because there is one
-/// core and the header promises the value only until the next call.
+/// A copy rather than the borrowed State slice: C wants a terminator. One
+/// static buffer because there is one core and the header promises the value
+/// only until the next call.
var font_path_z: [4096:0]u8 = undefined;
export fn pardes_font_take() ?[*:0]const u8 {
- _ = state orelse return null;
+ const st = &(state orelse return null);
if (comptime !pardes.font_picker) return null;
- const want = fonts.want orelse return null;
- fonts.want = null;
+ const want = st.core.takeFontRequest() orelse return null;
if (want.len >= font_path_z.len) return null;
@memcpy(font_path_z[0..want.len], want);
font_path_z[want.len] = 0;
return &font_path_z;
}
+/// Observe the face already on screen without resolving an unrelated Font
+/// request. Initial state, host-only zoom and display-scale changes use this.
+export fn pardes_font_observe(
+ effective_name: ?[*]const u8,
+ len: usize,
+ point_hundredths: u16,
+) bool {
+ const st = &(state orelse return false);
+ const ptr = effective_name orelse return false;
+ if (len == 0 or len > 255 or point_hundredths == 0) return false;
+ return st.core.observeFont(ptr[0..len], point_hundredths, .points);
+}
+
+/// Commit what CoreText accepted for the request returned by font_take.
+export fn pardes_font_ack(
+ effective_name: ?[*]const u8,
+ len: usize,
+ point_hundredths: u16,
+) bool {
+ const st = &(state orelse return false);
+ const ptr = effective_name orelse return false;
+ if (len == 0 or len > 255 or point_hundredths == 0) return false;
+ return st.core.acknowledgeFont(ptr[0..len], point_hundredths, .points);
+}
+
+/// Resolve a taken request which CoreText could not load without claiming the
+/// fallback/previous face was the requested one.
+export fn pardes_font_reject() void {
+ const st = &(state orelse return);
+ st.core.rejectFont();
+}
+
/// The FILE behind the focused pane, or null when there is none — a terminal,
/// an output buffer (`+Search` names a directory, not a document), or nothing
/// focused at all. A PDF and an image both count: they are real paths on disk,
@@ -1052,11 +1651,11 @@ fn activeFilePath(st: *State) ?[]const u8 {
/// Perform the IO the core queued. `threads_ok` is false for the one drain
/// inside pardes_init, which runs before any reader task exists.
///
-/// ponytail: the lsp, pipe and watch effects do no work here. Each wants real
-/// machinery — a worker plus a snapshot of the pane's file for lsp
-/// (src/tty/tty.zig:919), a job copy for pipe, and FSEvents for watch, since
-/// inotify is Linux-only. Lift tty.zig's implementations when the app is past
-/// first light. Pipe and watch may simply be dropped; lsp may NOT, see below.
+/// ponytail: lsp and pipe still do no work here. Each wants real machinery — a
+/// worker plus a snapshot of the pane's file for lsp (src/tty/tty.zig:919), and
+/// a job copy for pipe. Watch is deliberately different: FileWatcher.swift
+/// owns its per-directory DispatchSource and only returns a debounced hint;
+/// this main-thread drain owns the bytes, hash and shared text/PDF core event.
fn drainEffects(st: *State, threads_ok: bool) bool {
const core = st.core;
var did = false;
@@ -1084,7 +1683,7 @@ fn drainEffects(st: *State, threads_ok: bool) bool {
cwd_buf[cwd.len] = 0;
cwd_z = @ptrCast(&cwd_buf);
}
- const child = forkShell(core.shellBin(), cwd_z, core.screen_h, core.screen_w);
+ const child = forkShell(core, sp.pane, &st.prompt_rcs, core.shellBin(), cwd_z, core.screen_h, core.screen_w);
st.ptys[sp.pane] = .{
.file = child.file,
.pid = child.pid,
@@ -1118,6 +1717,14 @@ fn drainEffects(st: *State, threads_ok: bool) bool {
if (fd < 0) continue;
writeFd(fd, f.content);
_ = libc.close(fd);
+ // The directory source will observe our own close. Move its
+ // baseline first so that notification is a hash no-op instead
+ // of manufacturing an external reload and undo boundary.
+ st.file_watches.restampText(
+ sf.pane,
+ f.path,
+ std.hash.Wyhash.hash(0, f.content),
+ );
// After the write, not beside it: every `continue` above is a
// save that did not happen and must not be reported as one.
var mbuf: [256]u8 = undefined;
@@ -1163,7 +1770,8 @@ fn drainEffects(st: *State, threads_ok: bool) bool {
// stays armed, the retroactive indent never fires, and every
// dot-Tab for the rest of the session does nothing at all.
.lsp => |q| core.update(.{ .lsp_resp = .{ .id = q.id, .rows = "" } }),
- .pipe, .watch => {},
+ .watch => |watch| setFileWatch(st, watch.pane, watch.on),
+ .pipe => {},
.quit => {},
}
}
@@ -1231,13 +1839,13 @@ fn wake(st: *State) void {
// ---------------------------------------------------------------- helpers
-fn forkShell(bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) struct { file: std.Io.File, pid: posix.pid_t } {
+fn forkShell(core: *pardes.Pardes, pane: usize, prompt_rcs: *const shell_bin.PromptRcs, bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) struct { file: std.Io.File, pid: posix.pid_t } {
var master: c_int = undefined;
// Resolved BEFORE the fork, into this frame, which the child inherits:
// nothing between fork and exec may allocate, so a PATH search cannot
// happen there.
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const spawn = shell_bin.resolve(bin, &path_buf);
+ const spawn = shell_bin.resolve(bin, &path_buf, prompt_rcs);
const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
const pid = forkpty(&master, null, null, &ws);
if (pid == 0) {
@@ -1245,6 +1853,7 @@ fn forkShell(bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) struct
_ = execv(spawn.path, &spawn.argv);
_exit(127);
}
+ if (pid > 0) core.acknowledgeShell(pane, std.mem.span(spawn.path), spawn.argv[1] != null);
return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid };
}
@@ -1264,13 +1873,6 @@ fn writeFd(fd: c_int, data: []const u8) void {
}
}
-fn writeFile(path: [*:0]const u8, contents: []const u8) void {
- const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
- if (fd < 0) return;
- defer _ = libc.close(fd);
- writeFd(fd, contents);
-}
-
/// Rebuild the process environment as a Map, because a library never sees the
/// std.process.Init that main() gets one from. Only the config-path lookup
/// reads it, and the arena owns the copies for the life of the process.
@@ -1294,6 +1896,22 @@ fn encodeColor(color: pardes.Color) u32 {
};
}
+fn encodeCell(cell: pardes.Cell) Cell {
+ var out: Cell = .{
+ .text = @splat(0),
+ .fg = encodeColor(cell.style.fg),
+ .bg = encodeColor(cell.style.bg),
+ .attrs = encodeAttrs(cell.style),
+ .len = if (cell.default) 1 else cell.len,
+ .flags = encodeCellFlags(cell.default, cell.style.font_role),
+ };
+ if (cell.default)
+ out.text[0] = ' '
+ else
+ @memcpy(out.text[0..cell.len], cell.grapheme());
+ return out;
+}
+
fn encodeAttrs(style: pardes.CellStyle) u16 {
var attrs: u16 = 0;
attrs |= @as(u16, @intFromBool(style.bold)) << 0;
@@ -1307,6 +1925,11 @@ fn encodeAttrs(style: pardes.CellStyle) u16 {
return attrs;
}
+fn encodeCellFlags(default: bool, role: pardes.FontRole) u8 {
+ return @as(u8, @intFromBool(default)) * cell_flag_default |
+ @as(u8, @intFromBool(role == .tagline)) * cell_flag_tagline;
+}
+
/// Spend accumulated sub-row travel as whole wheel notches, keeping the
/// remainder. The core has no fractional scroll — both other shells do this
/// too — and the clamp is so that an absurd delta (a momentum-phase kinetic
@@ -1404,11 +2027,18 @@ test "pardes.h declares every export the way it is defined" {
try expectSameAbi(@TypeOf(c.pardes_init), @TypeOf(pardes_init));
try expectSameAbi(@TypeOf(c.pardes_deinit), @TypeOf(pardes_deinit));
try expectSameAbi(@TypeOf(c.pardes_tick), @TypeOf(pardes_tick));
+ try expectSameAbi(@TypeOf(c.pardes_animation_tick), @TypeOf(pardes_animation_tick));
try expectSameAbi(@TypeOf(c.pardes_should_quit), @TypeOf(pardes_should_quit));
try expectSameAbi(@TypeOf(c.pardes_animating), @TypeOf(pardes_animating));
+ try expectSameAbi(@TypeOf(c.pardes_gui_tagline_font_percent), @TypeOf(pardes_gui_tagline_font_percent));
+ try expectSameAbi(@TypeOf(c.pardes_scene), @TypeOf(pardes_scene));
+ try expectSameAbi(@TypeOf(c.pardes_postprocessor_unavailable), @TypeOf(pardes_postprocessor_unavailable));
+ try expectSameAbi(@TypeOf(c.pardes_panel_animation_failed), @TypeOf(pardes_panel_animation_failed));
try expectSameAbi(@TypeOf(c.pardes_key), @TypeOf(pardes_key));
try expectSameAbi(@TypeOf(c.pardes_paste), @TypeOf(pardes_paste));
try expectSameAbi(@TypeOf(c.pardes_mouse), @TypeOf(pardes_mouse));
+ try expectSameAbi(@TypeOf(c.pardes_pointer_leave), @TypeOf(pardes_pointer_leave));
+ try expectSameAbi(@TypeOf(c.pardes_watch_changed), @TypeOf(pardes_watch_changed));
try expectSameAbi(@TypeOf(c.pardes_scroll), @TypeOf(pardes_scroll));
try expectSameAbi(@TypeOf(c.pardes_rotate), @TypeOf(pardes_rotate));
try expectSameAbi(@TypeOf(c.pardes_rotate_end), @TypeOf(pardes_rotate_end));
@@ -1416,15 +2046,23 @@ test "pardes.h declares every export the way it is defined" {
try expectSameAbi(@TypeOf(c.pardes_resize), @TypeOf(pardes_resize));
try expectSameAbi(@TypeOf(c.pardes_frame), @TypeOf(pardes_frame));
try expectSameAbi(@TypeOf(c.pardes_frame_cells), @TypeOf(pardes_frame_cells));
+ try expectSameAbi(@TypeOf(c.pardes_frame_previous_cells), @TypeOf(pardes_frame_previous_cells));
+ try expectSameAbi(@TypeOf(c.pardes_frame_changed_cells), @TypeOf(pardes_frame_changed_cells));
try expectSameAbi(@TypeOf(c.pardes_frame_cols), @TypeOf(pardes_frame_cols));
try expectSameAbi(@TypeOf(c.pardes_frame_rows), @TypeOf(pardes_frame_rows));
try expectSameAbi(@TypeOf(c.pardes_frame_images), @TypeOf(pardes_frame_images));
try expectSameAbi(@TypeOf(c.pardes_frame_image_list), @TypeOf(pardes_frame_image_list));
+ try expectSameAbi(@TypeOf(c.pardes_frame_panel_tracks), @TypeOf(pardes_frame_panel_tracks));
+ try expectSameAbi(@TypeOf(c.pardes_frame_panel_track_list), @TypeOf(pardes_frame_panel_track_list));
+ try expectSameAbi(@TypeOf(c.pardes_frame_presented), @TypeOf(pardes_frame_presented));
try expectSameAbi(@TypeOf(c.pardes_cursor_x), @TypeOf(pardes_cursor_x));
try expectSameAbi(@TypeOf(c.pardes_cursor_y), @TypeOf(pardes_cursor_y));
try expectSameAbi(@TypeOf(c.pardes_cursor_bar), @TypeOf(pardes_cursor_bar));
try expectSameAbi(@TypeOf(c.pardes_take_haptic), @TypeOf(pardes_take_haptic));
try expectSameAbi(@TypeOf(c.pardes_font_take), @TypeOf(pardes_font_take));
+ try expectSameAbi(@TypeOf(c.pardes_font_observe), @TypeOf(pardes_font_observe));
+ try expectSameAbi(@TypeOf(c.pardes_font_ack), @TypeOf(pardes_font_ack));
+ try expectSameAbi(@TypeOf(c.pardes_font_reject), @TypeOf(pardes_font_reject));
try expectSameAbi(@TypeOf(c.pardes_active_path), @TypeOf(pardes_active_path));
try expectSameAbi(@TypeOf(c.pardes_active_dirty), @TypeOf(pardes_active_dirty));
try expectSameAbi(@TypeOf(c.pardes_theme_bg), @TypeOf(pardes_theme_bg));
@@ -1441,6 +2079,22 @@ test "pardes.h matches the Zig boundary" {
try expectEqual(@offsetOf(c.pardes_cell_s, "attrs"), @offsetOf(Cell, "attrs"));
try expectEqual(@offsetOf(c.pardes_cell_s, "len"), @offsetOf(Cell, "len"));
try expectEqual(@offsetOf(c.pardes_cell_s, "flags"), @offsetOf(Cell, "flags"));
+ try expectEqual(@sizeOf(c.pardes_scene_s), @sizeOf(Scene));
+ inline for (@typeInfo(Scene).@"struct".fields) |field|
+ try expectEqual(@offsetOf(c.pardes_scene_s, field.name), @offsetOf(Scene, field.name));
+ try expectEqual(@sizeOf(c.pardes_panel_box_s), @sizeOf(PanelBox));
+ inline for (@typeInfo(PanelBox).@"struct".fields) |field|
+ try expectEqual(@offsetOf(c.pardes_panel_box_s, field.name), @offsetOf(PanelBox, field.name));
+ try expectEqual(@sizeOf(c.pardes_panel_track_s), @sizeOf(PanelTrack));
+ inline for (@typeInfo(PanelTrack).@"struct".fields) |field| {
+ const c_name = comptime if (std.mem.eql(u8, field.name, "_padding"))
+ "reserved0"
+ else if (std.mem.eql(u8, field.name, "_frame_padding"))
+ "reserved1"
+ else
+ field.name;
+ try expectEqual(@offsetOf(c.pardes_panel_track_s, c_name), @offsetOf(PanelTrack, field.name));
+ }
// The attachment struct is a wide one and every field is read by hand on
// the Swift side, so its layout is checked at both ends rather than at the
// two that happen to be easy.
@@ -1452,6 +2106,19 @@ test "pardes.h matches the Zig boundary" {
try expectEqual(@as(u32, c.PARDES_COLOR_DEFAULT), color_default);
try expectEqual(@as(u32, c.PARDES_COLOR_INDEXED), color_indexed);
try expectEqual(@as(u8, c.PARDES_CELL_DEFAULT), cell_flag_default);
+ try expectEqual(@as(u8, c.PARDES_CELL_TAGLINE), cell_flag_tagline);
+ try expectEqual(@as(u32, c.PARDES_SCENE_CRT), scene_flag_crt);
+ try expectEqual(@as(u32, c.PARDES_SCENE_RIPPLE), scene_flag_ripple);
+ try expectEqual(@as(u32, c.PARDES_SCENE_GLITCH), scene_flag_glitch);
+ try expectEqual(@as(u8, c.PARDES_PANEL_OPENING), @intFromEnum(panel_animation.Phase.opening));
+ try expectEqual(@as(u8, c.PARDES_PANEL_MOVING), @intFromEnum(panel_animation.Phase.moving));
+ try expectEqual(@as(u8, c.PARDES_PANEL_CLOSING), @intFromEnum(panel_animation.Phase.closing));
+ try expectEqual(@as(u8, c.PARDES_PANEL_OFF), @intFromEnum(panel_animation.Transition.off));
+ try expectEqual(@as(u8, c.PARDES_PANEL_SLIDE), @intFromEnum(panel_animation.Transition.slide));
+ try expectEqual(@as(u8, c.PARDES_PANEL_ZOOM), @intFromEnum(panel_animation.Transition.zoom));
+ try expectEqual(@as(u8, c.PARDES_PANEL_DISSOLVE), @intFromEnum(panel_animation.Transition.dissolve));
+ try expectEqual(@as(u8, c.PARDES_PANEL_ASCII), @intFromEnum(panel_animation.Transition.ascii));
+ try expectEqual(@as(u8, c.PARDES_PANEL_VERTICAL), @intFromEnum(panel_animation.Transition.vertical));
// Every key the host has a name for must be the codepoint the core reads.
try expectEqual(@as(u21, c.PARDES_KEY_ENTER), pardes.Key.enter);
@@ -1501,6 +2168,72 @@ test "pardes.h matches the Zig boundary" {
@as(u16, c.PARDES_UL_CURLY) << c.PARDES_ATTR_UL_SHIFT,
encodeAttrs(.{ .ul = .curly }),
);
+
+ // Font role is explicit ABI data, not something the host reconstructs
+ // from tag colours. Default and role occupy independent bits.
+ try expectEqual(@as(u8, 0), encodeCellFlags(false, .body));
+ try expectEqual(cell_flag_tagline, encodeCellFlags(false, .tagline));
+ try expectEqual(cell_flag_default | cell_flag_tagline, encodeCellFlags(true, .tagline));
+ try expectEqual(pardes.config.gui_tagline_font_percent, pardes_gui_tagline_font_percent());
+}
+
+test "scene effect flags and display clock are compact and independent" {
+ const expectEqual = std.testing.expectEqual;
+ try expectEqual(@as(u32, 0), encodeSceneEffects(.{}));
+ try expectEqual(scene_flag_crt, encodeSceneEffects(.{ .crt = true }));
+ try expectEqual(scene_flag_ripple, encodeSceneEffects(.{ .ripple = true }));
+ try expectEqual(scene_flag_glitch, encodeSceneEffects(.{ .glitch = true }));
+ try expectEqual(
+ scene_flag_crt | scene_flag_ripple | scene_flag_glitch,
+ encodeSceneEffects(.{ .crt = true, .ripple = true, .glitch = true }),
+ );
+
+ var frame: u32 = scene_frame_wrap - 1;
+ advanceSceneFrame(&frame);
+ try expectEqual(@as(u32, 0), frame);
+ advanceSceneFrame(&frame);
+ try expectEqual(@as(u32, 1), frame);
+}
+
+test "mac panel ABI paint order includes closing tombstones after live panes" {
+ const source = [_]PanelTrack{
+ .{ .serial = 11, .pane = 3, .phase = .opening, .effect = .slide },
+ .{ .serial = 12, .pane = 1, .phase = .moving, .effect = .zoom },
+ .{ .serial = 13, .pane = 0, .phase = .moving, .effect = .off },
+ .{ .serial = 14, .pane = 5, .phase = .opening, .effect = .ascii },
+ .{ .serial = 15, .pane = 2, .phase = .moving, .effect = .dissolve },
+ .{ .serial = 16, .pane = 2, .phase = .closing, .effect = .vertical },
+ };
+ var ordered: [source.len]PanelTrack = undefined;
+ const len = copyPanelTracksInPaintOrder(&source, &ordered);
+ try std.testing.expectEqual(@as(usize, 5), len);
+ try std.testing.expectEqualSlices(u32, &.{ 12, 15, 11, 14, 16 }, &.{
+ ordered[0].serial,
+ ordered[1].serial,
+ ordered[2].serial,
+ ordered[3].serial,
+ ordered[4].serial,
+ });
+}
+
+test "mac panel mask is a literal normalized grayscale texture" {
+ try std.testing.expectEqual(@as(u8, 0), encodeChanged(0));
+ try std.testing.expectEqual(@as(u8, 255), encodeChanged(1));
+ try std.testing.expectEqual(@as(u8, 255), encodeChanged(255));
+}
+
+test "mac panel ABI has the documented compact byte layout" {
+ try std.testing.expectEqual(@as(usize, 16), @sizeOf(PanelBox));
+ try std.testing.expectEqual(@as(usize, 44), @sizeOf(PanelTrack));
+ try std.testing.expectEqual(@as(usize, 0), @offsetOf(PanelTrack, "serial"));
+ try std.testing.expectEqual(@as(usize, 4), @offsetOf(PanelTrack, "pane"));
+ try std.testing.expectEqual(@as(usize, 5), @offsetOf(PanelTrack, "phase"));
+ try std.testing.expectEqual(@as(usize, 6), @offsetOf(PanelTrack, "effect"));
+ try std.testing.expectEqual(@as(usize, 7), @offsetOf(PanelTrack, "_padding"));
+ try std.testing.expectEqual(@as(usize, 8), @offsetOf(PanelTrack, "frame"));
+ try std.testing.expectEqual(@as(usize, 10), @offsetOf(PanelTrack, "_frame_padding"));
+ try std.testing.expectEqual(@as(usize, 12), @offsetOf(PanelTrack, "from"));
+ try std.testing.expectEqual(@as(usize, 28), @offsetOf(PanelTrack, "to"));
}
test "colors encode to the three tags the host decodes" {
diff --git a/src/macos/Sources/AppDelegate.swift b/src/macos/Sources/AppDelegate.swift
index 077367a4..0cecd5da 100644
--- a/src/macos/Sources/AppDelegate.swift
+++ b/src/macos/Sources/AppDelegate.swift
@@ -18,10 +18,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
/// The blur behind the grid, shown only while the theme declares no
/// background of its own. See applyTheme.
private var backdrop: NSVisualEffectView!
- // One pump chain at a time. pump() re-arms itself while a theme transition
- // is in flight and every input pumps as well, so without this a burst of
- // keys during a fade would leave one 60 Hz chain per keystroke, all of them
- // ticking until the fade ended.
+ // One display-clock chain at a time. Input and pty notifications may all
+ // call pump(), but none advances animation; they only observe this flag and
+ // leave the already-scheduled frame alone.
private var pumpScheduled: Bool = false
// The core is a singleton with no "is it alive" query, and Finder can hand
// us documents before applicationDidFinishLaunching runs. Every entry point
@@ -48,9 +47,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
installMainMenu()
- // ponytail: 14pt, fixed. The SDL shell steps its font on Ctrl+/Ctrl-
- // (gui.zig); doing that here means re-measuring the view's metrics and
- // pushing a resize behind it, so it waits until the font has to move.
+ // Initial body size. PardesView owns later Ctrl+/Ctrl- changes and
+ // rebuilds both body and tagline metrics before publishing the resize.
view = PardesView(fontSize: defaultFontSize)
let want = NSSize(width: 1000, height: 700)
@@ -199,6 +197,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
// core compares the effective pixel viewport, not the resize event, so
// duplicate SIGWINCH-shaped notifications are already a no-op.
pardesViewDidResize(view)
+ // That pump also consumes a Font from startup config, if present. Only
+ // now is the face on screen final enough to seed Config's effective
+ // name and point size; later adopts and zooms report from wear().
+ view.reportEffectiveFont()
// Positional paths, after the first frame's worth of state exists.
// Anything starting with `-` is skipped rather than opened: the core
@@ -534,6 +536,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
// read bytes.
_ = pardes_tick()
view.needsDisplay = true
+ // TaglineSize is host-owned raster state just like Font, but unlike a
+ // body face change it does not resize the grid. Polling the one-byte
+ // core setting keeps startup config and live commands on one path.
+ view.adoptTaglinePercent(pardes_gui_tagline_font_percent())
// After the tick, because a `Theme` command runs inside it and the
// window has to follow the grid in the same frame rather than at the
// next launch.
@@ -571,19 +577,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
NSApp.terminate(nil)
return
}
- // A theme transition is the only thing that moves on its own, and it is
- // ten 16 ms steps (animation.zig). Nothing else re-arms this, which is
- // the point of an event-driven host: an idle pardes costs no CPU, where
- // the SDL shell spins.
- if pardes_animating() && !pumpScheduled {
- pumpScheduled = true
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.016) {
- self.pumpScheduled = false
- self.pump()
- }
+ // Input and pty pumps only drain work. A frame-count animation advances
+ // on this 16 ms clock, and only after the sample dirtied above was
+ // actually presented. A burst of input cannot make it run fast, while
+ // an occluded/coalesced AppKit view cannot consume invisible samples.
+ scheduleAnimationFrame()
+ }
+
+ private func scheduleAnimationFrame() {
+ guard pardes_animating() && !pumpScheduled else { return }
+ let awaitingPresentation = view.presentationSerial
+ pumpScheduled = true
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.016) {
+ self.animationFrame(after: awaitingPresentation)
}
}
+ private func animationFrame(after awaitingPresentation: UInt64) {
+ pumpScheduled = false
+ guard view.presentationSerial != awaitingPresentation else {
+ // Keep the dirty sample pending. AppKit may have coalesced this
+ // draw or the window may be occluded; retry the clock without
+ // advancing until draw(_:) supplies the presentation permit.
+ view.needsDisplay = true
+ scheduleAnimationFrame()
+ return
+ }
+ _ = pardes_animation_tick()
+ pump()
+ }
+
func pardesViewDidResize(_ view: PardesView) {
let grid = view.gridSize
// cell_w/cell_h are physical pixels. The core hands them to the native
diff --git a/src/macos/Sources/FileWatcher.swift b/src/macos/Sources/FileWatcher.swift
new file mode 100644
index 00000000..1fee2fd8
--- /dev/null
+++ b/src/macos/Sources/FileWatcher.swift
@@ -0,0 +1,122 @@
+// Per-path live reload for the native app. One source watches the file for
+// in-place writes; another watches its parent because editors normally save by
+// renaming a new inode over the old one. The file source is rearmed after a
+// quiet edge so it follows that replacement rather than the unlinked old inode.
+
+import Darwin
+import Dispatch
+import Foundation
+
+private final class WatchedPath {
+ let pane: UInt8
+ let generation: UInt32
+ private let path: String
+ private let directorySource: DispatchSourceFileSystemObject
+ private var fileSource: DispatchSourceFileSystemObject?
+ private var active = true
+ private var eventSerial: UInt64 = 0
+
+ init?(pane: UInt8, generation: UInt32, path: String) {
+ let directory = URL(fileURLWithPath: path)
+ .deletingLastPathComponent().path
+ let descriptor = directory.withCString {
+ Darwin.open($0, O_EVTONLY | O_CLOEXEC)
+ }
+ guard descriptor >= 0 else { return nil }
+
+ self.pane = pane
+ self.generation = generation
+ self.path = path
+ directorySource = DispatchSource.makeFileSystemObjectSource(
+ fileDescriptor: descriptor,
+ eventMask: [.write, .delete, .rename, .extend, .attrib, .link, .revoke],
+ queue: .main)
+ directorySource.setCancelHandler { _ = Darwin.close(descriptor) }
+ directorySource.setEventHandler { [weak self] in self?.pathChanged() }
+ reopenFileSource()
+ directorySource.activate()
+ }
+
+ /// The directory source catches rename-over saves, while the file source
+ /// catches programs which truncate/write the existing inode. Reopen the
+ /// latter after every quiet edge because an atomic save leaves it attached
+ /// to the unlinked old inode forever otherwise.
+ private func reopenFileSource() {
+ if let old = fileSource { old.cancel() }
+ fileSource = nil
+ guard active else { return }
+ let descriptor = path.withCString { Darwin.open($0, O_EVTONLY | O_CLOEXEC) }
+ guard descriptor >= 0 else { return }
+ let next = DispatchSource.makeFileSystemObjectSource(
+ fileDescriptor: descriptor,
+ eventMask: [.write, .delete, .rename, .extend, .attrib, .link, .revoke],
+ queue: .main)
+ next.setCancelHandler { _ = Darwin.close(descriptor) }
+ next.setEventHandler { [weak self] in self?.pathChanged() }
+ fileSource = next
+ next.activate()
+ }
+
+ /// Vnode sources can emit several edges for one save. Reset a small quiet
+ /// period on every edge; generation plus exact Zig-side identity checks
+ /// remain authoritative, so this delay is latency control rather than
+ /// correctness state.
+ private func pathChanged() {
+ guard active else { return }
+ eventSerial &+= 1
+ let scheduled = eventSerial
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(45)) { [weak self] in
+ guard let self, self.active, self.eventSerial == scheduled else { return }
+ self.reopenFileSource()
+ pardes_watch_changed(self.pane, self.generation)
+ }
+ }
+
+ func cancel() {
+ guard active else { return }
+ active = false
+ eventSerial &+= 1
+ if let old = fileSource { old.cancel() }
+ fileSource = nil
+ directorySource.cancel()
+ }
+
+ deinit { cancel() }
+}
+
+private final class FileWatchRegistry {
+ static let shared = FileWatchRegistry()
+ private var slots: [UInt8: WatchedPath] = [:]
+
+ func set(pane: UInt8, generation: UInt32, path: String?) {
+ if let old = slots.removeValue(forKey: pane) { old.cancel() }
+ guard let path,
+ let watched = WatchedPath(
+ pane: pane, generation: generation, path: path)
+ else { return }
+ slots[pane] = watched
+ }
+}
+
+/// Called only by libpardes's main-thread effect drain. A nil pointer stops the
+/// slot. The byte count avoids a second sentinel copy in Zig; Foundation paths
+/// are Unicode, so malformed UTF-8 is rejected rather than watched under a
+/// replacement spelling that could name a different file.
+@_cdecl("pardes_host_watch_file")
+func pardesHostWatchFile(
+ _ pane: UInt8,
+ _ generation: UInt32,
+ _ path: UnsafePointer<UInt8>?,
+ _ pathLength: Int
+) {
+ guard let path, pathLength > 0 else {
+ FileWatchRegistry.shared.set(pane: pane, generation: generation, path: nil)
+ return
+ }
+ let bytes = UnsafeBufferPointer(start: path, count: pathLength)
+ guard let value = String(bytes: bytes, encoding: .utf8) else {
+ FileWatchRegistry.shared.set(pane: pane, generation: generation, path: nil)
+ return
+ }
+ FileWatchRegistry.shared.set(pane: pane, generation: generation, path: value)
+}
diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift
index 3d841a60..a22d4d55 100644
--- a/src/macos/Sources/PardesView.swift
+++ b/src/macos/Sources/PardesView.swift
@@ -3,8 +3,8 @@
//
// This file keeps no model of the screen. The core owns the grid and hands it
// over whole through src/macos/pardes.h, so everything here is translation, and
-// the only state worth holding is the font metrics — which are expensive to
-// measure and never change — plus the two things a gesture needs remembered
+// the only state worth holding is the font metrics — expensive to measure and
+// replaced only by Font/TaglineSize/zoom — plus the gesture state remembered
// between events: which button a click started with, and how hard it is being
// pressed.
//
@@ -195,12 +195,14 @@ let defaultFontSize: CGFloat = 14
/// that changing either is one assignment and cannot leave half the numbers
/// describing the old font.
///
-/// Built at init and again for a `Font` command or a zoom. The glyph caches
-/// belong here for the same reason: a CGGlyph is an index into a particular
-/// face, so carrying one across a font change draws the wrong character
-/// rather than none.
+/// Built at init and again for a `Font` command or a zoom. The view clears its
+/// body/tagline glyph caches beside replacing this value: a CGGlyph is an index
+/// into a particular face, so carrying one across a font change is invalid.
private struct Metrics {
let fonts: [CTFont]
+ /// False only when a non-nil requested file could not produce a fixed-pitch
+ /// CoreText face. The caller rejects that request and keeps old Metrics.
+ let acceptedPath: Bool
let ascent: CGFloat
let cellWidth: CGFloat
let cellHeight: CGFloat
@@ -218,7 +220,8 @@ private struct Metrics {
}
init(size: CGFloat, path: String?, scale: CGFloat) {
- let face = Metrics.face(size: size, path: path)
+ let requested = path.flatMap { Metrics.fromFile($0, size) }
+ let face = requested ?? Metrics.defaultFace(size: size)
let scale = max(1, scale)
// UNVERIFIED: CTFontSymbolicTraits member spelling (.traitBold/.traitItalic).
@@ -246,12 +249,16 @@ private struct Metrics {
// UP, because losing a pixel off a descender is clipping.
let snap = Metrics.snap
fonts = faces
+ acceptedPath = path == nil || requested != nil
// The ascent lands on a pixel for a second reason: it is the baseline's
// offset inside the cell, so the rules hung off it are whole-pixel
// fills rather than one-pixel bars smeared across two rows.
- ascent = max(1 / scale, snap(CTFontGetAscent(face), scale, .toNearestOrAwayFromZero))
- cellWidth = max(1 / scale, snap(advance(face, 0x4D), scale, .toNearestOrAwayFromZero))
- cellHeight = max(1 / scale, snap(CTFontGetAscent(face) + CTFontGetDescent(face) + CTFontGetLeading(face), scale, .up))
+ let bodyAscent = max(1 / scale, snap(CTFontGetAscent(face), scale, .toNearestOrAwayFromZero))
+ let bodyWidth = max(1 / scale, snap(advance(face, 0x4D), scale, .toNearestOrAwayFromZero))
+ let bodyHeight = max(1 / scale, snap(CTFontGetAscent(face) + CTFontGetDescent(face) + CTFontGetLeading(face), scale, .up))
+ ascent = bodyAscent
+ cellWidth = bodyWidth
+ cellHeight = bodyHeight
ruleThickness = max(1 / scale, snap(CTFontGetUnderlineThickness(face), scale, .toNearestOrAwayFromZero))
underlineOffset = snap(CTFontGetUnderlinePosition(face), scale, .toNearestOrAwayFromZero)
asciiGlyphs = faces.map { font in
@@ -262,11 +269,10 @@ private struct Metrics {
}
}
- /// The regular cut to build the other three from: the file the core asked
- /// for, or the system monospace face when it asked for nothing — or when
- /// what it asked for turned out not to be wearable.
- private static func face(size: CGFloat, path: String?) -> CTFont {
- if let path, let picked = Metrics.fromFile(path, size) { return picked }
+ /// The regular system face used only when no file was requested. A failed
+ /// request is reported through `acceptedPath`; it never silently replaces
+ /// the face already on screen with this fallback.
+ private static func defaultFace(size: CGFloat) -> CTFont {
let system = NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
// Through the descriptor, not through CTFontCreateWithName(fontName):
// the system monospace face has a dot-prefixed internal name that a
@@ -314,6 +320,48 @@ private struct Metrics {
}
}
+/// The independently replaceable half of font state. A TaglineSize command
+/// rebuilds only this value and its non-ASCII cache; body metrics and therefore
+/// the core grid never move. `height` is also the painted tag-band height, not
+/// merely a smaller glyph centred inside a body-height colour bar.
+private struct TaglineMetrics {
+ let percent: UInt8
+ let fonts: [CTFont]
+ let baseline: CGFloat
+ let xOffset: CGFloat
+ let top: CGFloat
+ let height: CGFloat
+ let asciiGlyphs: [[CGGlyph]]
+
+ init(body: Metrics, percent: UInt8, scale rawScale: CGFloat) {
+ let scale = max(1, rawScale)
+ func snap(_ value: CGFloat, _ rule: FloatingPointRoundingRule) -> CGFloat {
+ (value * scale).rounded(rule) / scale
+ }
+ self.percent = percent
+ let size = CTFontGetSize(body.fonts[0]) * CGFloat(percent) / 100
+ let faces = body.fonts.map { CTFontCreateCopyWithAttributes($0, size, nil, nil) }
+ fonts = faces
+ let face = faces[Face.regular.rawValue]
+ let measuredHeight = max(1 / scale, snap(
+ CTFontGetAscent(face) + CTFontGetDescent(face) + CTFontGetLeading(face), .up))
+ // Runtime configuration caps the percentage at the body face. Keep the
+ // clamp here too: a malformed/custom host value must not paint a tag
+ // over the body row below it.
+ height = min(body.cellHeight, measuredHeight)
+ top = snap(max(0, (body.cellHeight - height) / 2), .toNearestOrAwayFromZero)
+ let ascent = max(1 / scale, snap(CTFontGetAscent(face), .toNearestOrAwayFromZero))
+ baseline = snap(top + ascent, .toNearestOrAwayFromZero)
+ xOffset = snap(max(0, (body.cellWidth - advance(face, 0x4D)) / 2), .toNearestOrAwayFromZero)
+ asciiGlyphs = faces.map { font in
+ var chars = Array(UniChar(0)..<UniChar(128))
+ var glyphs = [CGGlyph](repeating: 0, count: 128)
+ _ = CTFontGetGlyphsForCharacters(font, &chars, &glyphs, 128)
+ return glyphs
+ }
+ }
+}
+
private func modifiers(_ flags: NSEvent.ModifierFlags) -> UInt32 {
var mods: UInt32 = 0
if flags.contains(.control) { mods |= UInt32(PARDES_MOD_CTRL) }
@@ -327,6 +375,7 @@ final class PardesView: NSView {
// The face and the numbers off it, replaced whole by `wear`.
private var metrics: Metrics
+ private var taglines: TaglineMetrics
/// What `metrics` was built from, so a zoom keeps the face and a font
/// change keeps the size.
private var fontSize: CGFloat
@@ -338,13 +387,35 @@ final class PardesView: NSView {
// Glyphs the ASCII table above did not answer for. A stored 0 is .notdef,
// meaning "this face does not have it", which is a cache hit too — the
// CTLine fallback below is far more expensive than the lookup it would
- // repeat. Keyed by face and codepoint, and thrown away with the face.
- private var glyphCache: [UInt32: CGGlyph] = [:]
+ // repeat. Keyed by role, face and codepoint, and thrown away with both
+ // body and tagline variants whenever the body face changes.
+ private var bodyGlyphCache: [UInt32: CGGlyph] = [:]
+ private var taglineGlyphCache: [UInt32: CGGlyph] = [:]
// Scratch for one batched run of glyphs. Held rather than made per row so a
// full redraw does not allocate 24 times.
private var runGlyphs: [CGGlyph] = []
private var runPositions: [CGPoint] = []
+ /// Constructed lazily because the ordinary path is still a direct
+ /// CoreText/CoreGraphics frame. No scene bit and no panel track means no
+ /// bitmap, no Core Image context and no Metal work.
+ private lazy var scenePostprocessor: ScenePostprocessor? = ScenePostprocessor()
+ /// Kernel compilation is permanent for one executable. Runtime allocation
+ /// and device failures get three consecutive attempts before this session
+ /// stops advertising effects its GPU path cannot present.
+ private var postprocessorUnavailable = false
+ private var postprocessRetries = 0
+ /// The scene mapping of the pixels AppKit most recently accepted. Pointer
+ /// events use this snapshot, not a clock that may already be one tick ahead.
+ private var lastPresentedScene: pardes_scene_s?
+ /// Whether the last frame reached the optional Metal owner successfully.
+ /// Ordinary direct draws and a runtime-kernel failure leave this false.
+ private(set) var lastFrameUsedPostprocessor = false
+ /// Monotonic count of frames AppKit actually let this view present. The
+ /// host animation clock uses it as a permit: no new sample is consumed
+ /// while an occluded/coalesced frame is still waiting to be drawn.
+ private(set) var presentationSerial: UInt64 = 0
+
private var reportedCols: UInt16 = 0
private var reportedRows: UInt16 = 0
@@ -379,6 +450,13 @@ final class PardesView: NSView {
// core holding a drag nothing will ever end.
private var latchedButton: pardes_mouse_button_e?
private var latchedCell: GridPoint?
+ /// Last physical point delivered by AppKit and its mapping through the
+ /// accepted scene. Ripple/glitch can move a source cell under a stationary
+ /// hand, so successful scene presentations reconcile this point again.
+ private var pointerPoint: CGPoint?
+ private var pointerCell: GridPoint?
+ private var pointerMapped = false
+ private var pointerModifiers: UInt32 = 0
// Force-click stage, reset per press. AppKit repeats stage-2 events for as
// long as the finger stays down, and only the transition is the gesture.
private var pressureStage: Int = 0
@@ -401,6 +479,8 @@ final class PardesView: NSView {
// 1x case, which a bare guess would otherwise leave wrong forever.
let built = Metrics(size: size, path: nil, scale: 2)
metrics = built
+ taglines = TaglineMetrics(
+ body: built, percent: pardes_gui_tagline_font_percent(), scale: 2)
fontSize = size
fontPath = nil
@@ -434,6 +514,7 @@ final class PardesView: NSView {
/// outside this file can find out which font is being drawn with — the
/// core has no font, so a cell-buffer snapshot cannot see one.
var faceName: String { CTFontCopyPostScriptName(metrics.fonts[0]) as String }
+ var effectivePointSize: CGFloat { CTFontGetSize(metrics.fonts[0]) }
/// Put on a face, or the same face at a different size, and tell the host
/// the grid moved under it.
@@ -441,27 +522,68 @@ final class PardesView: NSView {
/// A cell that changed size means a different number of columns fit the
/// same window, so this is a resize as far as the core is concerned — and
/// the delegate's resize path is already the one that reports both the
- /// grid and the physical cell the PDF placement reads. Nothing here
- /// second-guesses a file it could not load: `Metrics` falls back to the
- /// system face, and asking for a font that is not wearable leaves the
- /// screen exactly as it was rather than blank.
- private func wear(size: CGFloat, path: String?) {
+ /// grid and the physical cell the PDF placement reads. A requested file
+ /// which cannot produce a fixed-pitch face is rejected before assignment,
+ /// leaving the screen exactly as it was rather than substituting a face.
+ private func wear(size: CGFloat, path: String?, resolvesRequest: Bool = false) {
let scale = window?.backingScaleFactor ?? metricsScale
let next = Metrics(size: size, path: path, scale: scale)
+ guard next.acceptedPath else {
+ // The requested file never became the fixed-pitch face the grid
+ // requires. Keep every old metric and resolve the pending request
+ // as rejected rather than claiming the system fallback is it.
+ if resolvesRequest { pardes_font_reject() }
+ return
+ }
// A CGGlyph is an index into a particular face. Kept across a change
// it would draw a different character, not a missing one.
- glyphCache.removeAll(keepingCapacity: true)
+ bodyGlyphCache.removeAll(keepingCapacity: true)
+ taglineGlyphCache.removeAll(keepingCapacity: true)
metrics = next
+ taglines = TaglineMetrics(body: next, percent: taglines.percent, scale: scale)
metricsScale = scale
fontSize = size
fontPath = path
delegate?.pardesViewDidResize(self)
needsDisplay = true
+ reportEffectiveFont(resolvesRequest: resolvesRequest)
+ }
+
+ /// Apply the core's live TaglineSize setting without touching body metrics,
+ /// the window grid, or PDF cell geometry. Called every pump; equality makes
+ /// the ordinary path one byte comparison.
+ @discardableResult
+ func adoptTaglinePercent(_ percent: UInt8) -> Bool {
+ guard percent != taglines.percent else { return false }
+ taglines = TaglineMetrics(body: metrics, percent: percent, scale: metricsScale)
+ taglineGlyphCache.removeAll(keepingCapacity: true)
+ needsDisplay = true
+ return true
+ }
+
+ var taglinePercent: UInt8 { taglines.percent }
+ var taglineBandHeight: CGFloat { taglines.height }
+
+ /// Seed runtime Config after init and update it after every successful
+ /// adopt/zoom. Points are the native owner's unit; the cell's backing-pixel
+ /// dimensions continue to travel through pardes_resize separately.
+ func reportEffectiveFont(resolvesRequest: Bool = false) {
+ let hundredths = UInt16(min(
+ max((effectivePointSize * 100).rounded(), 1),
+ CGFloat(UInt16.max)))
+ let name = faceName
+ name.withCString { pointer in
+ if resolvesRequest {
+ _ = pardes_font_ack(pointer, name.utf8.count, hundredths)
+ } else {
+ _ = pardes_font_observe(pointer, name.utf8.count, hundredths)
+ }
+ }
}
/// The file `Font <name>` resolved to, straight from the core.
func adoptFont(path: String) {
- wear(size: fontSize, path: path)
+ wear(size: fontSize, path: path, resolvesRequest: true)
}
/// Cmd+ and Cmd-. Whole points, because the cell is rounded to whole
@@ -501,11 +623,150 @@ final class PardesView: NSView {
// MARK: - drawing
override func draw(_ dirtyRect: NSRect) {
- guard let ctx = NSGraphicsContext.current?.cgContext else { return }
+ guard let destination = NSGraphicsContext.current?.cgContext else { return }
+ lastFrameUsedPostprocessor = false
// ponytail: dirtyRect is ignored. pardes_frame() re-renders the whole grid
// whatever we do, so clipping would save fills and nothing else. Narrow
// the row loops to the dirty band if that ever shows up in a profile.
let count = pardes_frame()
+ let scene = pardes_scene()
+ let trackCount = Int(pardes_frame_panel_tracks())
+ let tracks: UnsafeBufferPointer<pardes_panel_track_s>
+ if trackCount > 0, let list = pardes_frame_panel_track_list() {
+ tracks = UnsafeBufferPointer(start: list, count: trackCount)
+ } else {
+ tracks = UnsafeBufferPointer(start: nil, count: 0)
+ }
+ let wantsPostprocess = scene.flags != 0 || !tracks.isEmpty
+ if wantsPostprocess {
+ if postprocessorUnavailable {
+ pardes_postprocessor_unavailable()
+ } else if let postprocessor = scenePostprocessor {
+ if let sceneContext = postprocessor.renderContext(
+ size: bounds.size, scale: window?.backingScaleFactor ?? metricsScale
+ ) {
+ drawFrame(sceneContext, count: count, hideCursor: !tracks.isEmpty)
+ let needsPrevious = tracks.contains {
+ $0.effect == UInt8(PARDES_PANEL_DISSOLVE)
+ || $0.effect == UInt8(PARDES_PANEL_ASCII)
+ || $0.effect == UInt8(PARDES_PANEL_VERTICAL)
+ }
+ var oldReady = !needsPrevious
+ let previousCells = pardes_frame_previous_cells()
+ let changedCells = pardes_frame_changed_cells()
+ if needsPrevious, let previousCells, changedCells != nil,
+ let oldContext = postprocessor.previousRenderContext(
+ size: bounds.size,
+ scale: window?.backingScaleFactor ?? metricsScale
+ ) {
+ drawFrame(
+ oldContext, count: count, cells: previousCells,
+ frozenImages: presentedImages, hideCursor: true)
+ oldReady = true
+ }
+ let outcome: ScenePostprocessor.CompositeOutcome = oldReady
+ ? postprocessor.composite(
+ scene: scene, tracks: tracks,
+ changedCells: changedCells,
+ changedAttachmentRects: changedAttachmentRects(),
+ gridSize: (
+ cols: Int(pardes_frame_cols()),
+ rows: Int(pardes_frame_rows())),
+ cellSize: CGSize(width: cellWidth, height: cellHeight),
+ ground: themeBG, size: bounds.size, into: destination)
+ : .unavailable
+ switch outcome {
+ case .presented:
+ postprocessRetries = 0
+ lastPresentedScene = scene
+ lastFrameUsedPostprocessor = true
+ acknowledgePresentation(animatedPanels: !tracks.isEmpty)
+ return
+ case .retry:
+ postprocessRetries += 1
+ case .unavailable:
+ postprocessorUnavailable = true
+ }
+ } else {
+ postprocessRetries += 1
+ }
+ // This draw now falls back to canonical content. Scene effects
+ // may retry, but a panel must not resume from a later sample
+ // after its final geometry has already been shown.
+ if !tracks.isEmpty { pardes_panel_animation_failed() }
+ if postprocessRetries >= 3 { postprocessorUnavailable = true }
+ if postprocessorUnavailable { pardes_postprocessor_unavailable() }
+ } else {
+ // The source, Metal device, or fixed kernel set was absent at
+ // lazy construction. None can recover within this process.
+ postprocessorUnavailable = true
+ pardes_postprocessor_unavailable()
+ }
+ } else {
+ postprocessRetries = 0
+ }
+ lastPresentedScene = nil
+ drawFrame(destination, count: count, hideCursor: false)
+ acknowledgePresentation(animatedPanels: false)
+ }
+
+ /// A presentation can materialize a Look target under a stationary mouse
+ /// after AppDelegate already decided whether to run its 60 Hz clock. Wake
+ /// the ordinary pump only for that null->active edge; repeated frame acks
+ /// remain inert and cannot create a redraw loop.
+ private func acknowledgePresentation(animatedPanels: Bool) {
+ // Only a canonical presentation advances the attachment baseline.
+ // During a transition deleted panes keep their cropped CGImages alive
+ // here even after both the core placement and image cache entry vanish.
+ if !animatedPanels { presentedImages = frameImages }
+ presentationSerial &+= 1
+ let animationWoke = pardes_frame_presented(animatedPanels)
+ let pointerMoved = refreshPresentedPointer()
+ guard animationWoke || pointerMoved else { return }
+ DispatchQueue.main.async {
+ NotificationCenter.default.post(name: pardesDidInputNotification, object: self)
+ }
+ }
+
+ /// Re-feed only a changed mapping. Sending motion on every 60 Hz scene
+ /// frame would keep resetting the core's hover debounce forever.
+ private func refreshPresentedPointer() -> Bool {
+ guard let point = pointerPoint else { return false }
+ guard let at = cellAt(point) else {
+ guard pointerMapped else { return false }
+ pointerMapped = false
+ pardes_pointer_leave()
+ return true
+ }
+ let changed: Bool
+ if let previous = pointerCell {
+ changed = !pointerMapped || previous.col != at.col || previous.row != at.row
+ } else {
+ changed = true
+ }
+ pointerMapped = true
+ pointerCell = at
+ guard changed else { return false }
+ if let button = latchedButton {
+ latchedCell = at
+ pardes_mouse(button, PARDES_MOUSE_DRAG, at.col, at.row, pointerModifiers)
+ } else {
+ pardes_mouse(PARDES_MOUSE_NONE, PARDES_MOUSE_MOTION,
+ at.col, at.row, pointerModifiers)
+ }
+ return true
+ }
+
+ /// The canonical CoreText frame, independent of where it lands. Scene
+ /// effects change only the destination: direct view context when off, the
+ /// postprocessor's same-sized retained bitmap when on.
+ private func drawFrame(
+ _ ctx: CGContext,
+ count: UInt32,
+ cells suppliedCells: UnsafePointer<pardes_cell_s>? = nil,
+ frozenImages: [FrozenImage]? = nil,
+ hideCursor: Bool
+ ) {
let cols = Int(pardes_frame_cols())
let rows = Int(pardes_frame_rows())
// The ground, from the core rather than from a constant agreed by hand.
@@ -515,13 +776,19 @@ final class PardesView: NSView {
let ground = themeBG ?? pardesDefaultBG
let clearGround = themeBG == nil
if clearGround { ctx.clear(bounds) } else { fill(ctx, bounds, ground, 1) }
- guard cols > 0, rows > 0, Int(count) == cols * rows, let cells = pardes_frame_cells() else { return }
+ if frozenImages == nil { frameImages.removeAll(keepingCapacity: true) }
+ guard cols > 0, rows > 0, Int(count) == cols * rows,
+ let cells = suppliedCells ?? pardes_frame_cells()
+ else { return }
// -1 when hidden, which never matches a real cell, so hidden and "bar, so
// not a block" collapse into the same comparison.
- let bar = pardes_cursor_bar()
- let blockX = bar ? -1 : Int(pardes_cursor_x())
- let blockY = bar ? -1 : Int(pardes_cursor_y())
+ // A cursor has no pane/phase record. Match SDL and TTY by hiding it for
+ // the short panel transition instead of guessing whether it should
+ // move, stay fixed, or cover an overlapping opening pane.
+ let bar = !hideCursor && pardes_cursor_bar()
+ let blockX = hideCursor || bar ? -1 : Int(pardes_cursor_x())
+ let blockY = hideCursor || bar ? -1 : Int(pardes_cursor_y())
// Background first, batched into runs of equal color. A full redraw is
// 80x24 cells at the low end and per-cell fills are exactly what makes
@@ -534,6 +801,7 @@ final class PardesView: NSView {
var start = 0
var color = resolve(cells[base], block: blockY == row && blockX == 0,
ground: ground, clearGround: clearGround).bg
+ var tagline = cells[base].flags & UInt8(PARDES_CELL_TAGLINE) != 0
for col in 1...cols {
// A real color is 24 bits, so .max is a sentinel that cannot
// compare equal and therefore always flushes the last run.
@@ -541,16 +809,21 @@ final class PardesView: NSView {
? .max
: resolve(cells[base + col], block: blockY == row && blockX == col,
ground: ground, clearGround: clearGround).bg
- if next == color { continue }
+ let nextTagline = col < cols
+ && cells[base + col].flags & UInt8(PARDES_CELL_TAGLINE) != 0
+ if next == color && nextTagline == tagline { continue }
// bgClear runs are the ground showing through, and the ground is
// already clear — painting them would be painting the hole shut.
if color != bgClear {
- fill(ctx, CGRect(x: CGFloat(start) * cellWidth, y: y,
- width: CGFloat(col - start) * cellWidth, height: cellHeight),
+ let bandY = tagline ? y + taglines.top : y
+ let bandHeight = tagline ? taglines.height : cellHeight
+ fill(ctx, CGRect(x: CGFloat(start) * cellWidth, y: bandY,
+ width: CGFloat(col - start) * cellWidth, height: bandHeight),
color, 1)
}
start = col
color = next
+ tagline = nextTagline
}
}
@@ -589,18 +862,26 @@ final class PardesView: NSView {
// after its cells — a PDF pane's cells are blank, so the order only
// matters for the tag row an attachment must never reach, and the clip
// below is what keeps it off.
- drawImages(ctx)
+ if let frozenImages {
+ drawFrozenImages(ctx, frozenImages)
+ } else {
+ drawImages(ctx)
+ }
if bar {
let x = Int(pardes_cursor_x()), y = Int(pardes_cursor_y())
if x >= 0, y >= 0, x < cols, y < rows {
// gui.zig paints U+258F here. A rect is the same picture without
// asking the font for a glyph it may not carry.
- let fg = resolve(cells[y * cols + x], block: false,
+ let cell = cells[y * cols + x]
+ let fg = resolve(cell, block: false,
ground: themeBG ?? pardesDefaultBG, clearGround: false).fg
+ let tagline = cell.flags & UInt8(PARDES_CELL_TAGLINE) != 0
+ let caretY = CGFloat(y) * cellHeight + (tagline ? taglines.top : 0)
+ let caretHeight = tagline ? taglines.height : cellHeight
ctx.setShouldAntialias(false)
- fill(ctx, CGRect(x: CGFloat(x) * cellWidth, y: CGFloat(y) * cellHeight,
- width: max(1, (cellWidth / 8).rounded(.up)), height: cellHeight), fg, 1)
+ fill(ctx, CGRect(x: CGFloat(x) * cellWidth, y: caretY,
+ width: max(1, (cellWidth / 8).rounded(.up)), height: caretHeight), fg, 1)
}
}
}
@@ -614,11 +895,24 @@ final class PardesView: NSView {
let revision: UInt32
}
+ /// A fully-owned attachment placement from the last canonical frame.
+ /// The cropped CGImage retains its provider bytes, unlike pardes_image_s's
+ /// pointer, so a vertical closing tombstone can outlive pane deletion.
+ private struct FrozenImage {
+ let key: ImageKey
+ let image: CGImage
+ let source: CGRect
+ let body: CGRect
+ let destination: CGRect
+ }
+
/// Rasterized attachments, decoded once each. The bytes the core lends are
/// only valid until the next `pardes_frame`, so the CGImage owns a COPY —
/// which is exactly why the cache has to be keyed well enough that the copy
/// happens when the pixels change and never on an ordinary scroll.
private var imageCache: [ImageKey: CGImage] = [:]
+ private var frameImages: [FrozenImage] = []
+ private var presentedImages: [FrozenImage] = []
private func drawImages(_ ctx: CGContext) {
let count = Int(pardes_frame_images())
@@ -641,9 +935,10 @@ final class PardesView: NSView {
let key = ImageKey(serial: place.serial, page: place.page, revision: place.revision)
live.insert(key)
guard let full = image(for: place, key: key) else { continue }
- guard let crop = full.cropping(to: CGRect(
+ let source = CGRect(
x: Int(place.src_x), y: Int(place.src_y),
- width: Int(place.src_w), height: Int(place.src_h)))
+ width: Int(place.src_w), height: Int(place.src_h))
+ guard let crop = full.cropping(to: source)
else { continue }
// The body is the rectangle nothing may paint past. The core has
@@ -657,20 +952,11 @@ final class PardesView: NSView {
y: body.minY + (CGFloat(place.dst_y) + CGFloat(place.offset_y)) / scale,
width: CGFloat(place.dst_w) / scale,
height: CGFloat(place.dst_h) / scale)
-
- ctx.saveGState()
- ctx.clip(to: body)
- // isFlipped gives us a y-down CTM and CGImage draws +y up, so a
- // plain ctx.draw would land every page upside down. Flip about the
- // destination rather than about the view, so the arithmetic above
- // stays in the grid's own coordinates.
- ctx.translateBy(x: dst.minX, y: dst.maxY)
- ctx.scaleBy(x: 1, y: -1)
- // A page is resampled whenever fit or zoom disagrees with the
- // raster MuPDF last produced; nearest-neighbour text is unreadable.
- ctx.interpolationQuality = .high
- ctx.draw(crop, in: CGRect(x: 0, y: 0, width: dst.width, height: dst.height))
- ctx.restoreGState()
+ let frozen = FrozenImage(
+ key: key, image: crop, source: source,
+ body: body, destination: dst)
+ frameImages.append(frozen)
+ drawFrozenImage(ctx, frozen)
}
// Evict what this frame did not place. Scrolling a document past a page
@@ -681,6 +967,52 @@ final class PardesView: NSView {
}
}
+ private func drawFrozenImages(_ ctx: CGContext, _ images: [FrozenImage]) {
+ ctx.setShouldAntialias(true)
+ for image in images { drawFrozenImage(ctx, image) }
+ }
+
+ /// Native attachments do not occupy Cell graphemes, so the core's semantic
+ /// text/style mask cannot see a page appearing, disappearing, rerasterizing
+ /// or moving. Add only those changed placements to the shader mask; an
+ /// identical key and geometry remains immediate like any unchanged cell.
+ private func changedAttachmentRects() -> [CGRect] {
+ var oldByKey: [ImageKey: FrozenImage] = [:]
+ for image in presentedImages { oldByKey[image.key] = image }
+ var changed: [CGRect] = []
+ changed.reserveCapacity(frameImages.count + presentedImages.count)
+ for image in frameImages {
+ guard let old = oldByKey.removeValue(forKey: image.key) else {
+ changed.append(image.destination.intersection(image.body))
+ continue
+ }
+ if old.source != image.source
+ || old.body != image.body || old.destination != image.destination {
+ changed.append(old.destination.intersection(old.body))
+ changed.append(image.destination.intersection(image.body))
+ }
+ }
+ for old in oldByKey.values {
+ changed.append(old.destination.intersection(old.body))
+ }
+ return changed.filter { !$0.isNull && !$0.isEmpty }
+ }
+
+ private func drawFrozenImage(_ ctx: CGContext, _ placed: FrozenImage) {
+ ctx.saveGState()
+ ctx.clip(to: placed.body)
+ // isFlipped gives us a y-down CTM and CGImage draws +y up, so flip
+ // about this destination rather than changing the grid arithmetic.
+ ctx.translateBy(x: placed.destination.minX, y: placed.destination.maxY)
+ ctx.scaleBy(x: 1, y: -1)
+ ctx.interpolationQuality = .high
+ ctx.draw(placed.image, in: CGRect(
+ x: 0, y: 0,
+ width: placed.destination.width,
+ height: placed.destination.height))
+ ctx.restoreGState()
+ }
+
/// The decoded raster for one attachment, made once per generation.
private func image(for place: pardes_image_s, key: ImageKey) -> CGImage? {
if let cached = imageCache[key] { return cached }
@@ -716,6 +1048,7 @@ final class PardesView: NSView {
blockCol: Int
) {
var runFace = Face.regular
+ var runTagline = false
var runColor: UInt32 = 0
var runAlpha: CGFloat = 1
runGlyphs.removeAll(keepingCapacity: true)
@@ -724,7 +1057,10 @@ final class PardesView: NSView {
func flush() {
guard !runGlyphs.isEmpty else { return }
setFill(ctx, runColor, runAlpha)
- CTFontDrawGlyphs(metrics.fonts[runFace.rawValue], runGlyphs, runPositions, runGlyphs.count, ctx)
+ let font = runTagline
+ ? taglines.fonts[runFace.rawValue]
+ : metrics.fonts[runFace.rawValue]
+ CTFontDrawGlyphs(font, runGlyphs, runPositions, runGlyphs.count, ctx)
runGlyphs.removeAll(keepingCapacity: true)
runPositions.removeAll(keepingCapacity: true)
}
@@ -732,6 +1068,10 @@ final class PardesView: NSView {
for col in 0..<cols {
let cell = cells[base + col]
if cell.flags & UInt8(PARDES_CELL_DEFAULT) != 0 { continue }
+ let tagline = cell.flags & UInt8(PARDES_CELL_TAGLINE) != 0
+ let cellBaseline = tagline
+ ? baseline + metrics.ascent - taglines.baseline
+ : baseline
// clearGround: false — this pass only reads `fg`, and a glyph is
// never the hole in the ground.
let style = resolve(cell, block: blockCol == col,
@@ -744,7 +1084,7 @@ final class PardesView: NSView {
if cell.attrs >> UInt16(PARDES_ATTR_UL_SHIFT) != 0
|| cell.attrs & UInt16(PARDES_ATTR_STRIKETHROUGH) != 0 {
flush()
- drawRules(ctx, cell, style, x: x, baseline: baseline)
+ drawRules(ctx, cell, style, x: x, baseline: cellBaseline)
}
guard style.visible else { continue }
@@ -761,17 +1101,20 @@ final class PardesView: NSView {
let face = Face(bold: cell.attrs & UInt16(PARDES_ATTR_BOLD) != 0,
italic: cell.attrs & UInt16(PARDES_ATTR_ITALIC) != 0)
let units = text.utf16
- let known = units.count == 1 ? glyph(face, units.first!) : 0
+ let known = units.count == 1 ? glyph(face, units.first!, tagline: tagline) : 0
+ let glyphX = x + (tagline ? taglines.xOffset : 0)
if known != 0 {
if !runGlyphs.isEmpty
- && (face != runFace || style.fg != runColor || style.alpha != runAlpha) {
+ && (face != runFace || tagline != runTagline
+ || style.fg != runColor || style.alpha != runAlpha) {
flush()
}
runFace = face
+ runTagline = tagline
runColor = style.fg
runAlpha = style.alpha
runGlyphs.append(known)
- runPositions.append(CGPoint(x: x, y: baseline))
+ runPositions.append(CGPoint(x: glyphX, y: cellBaseline))
continue
}
@@ -781,8 +1124,9 @@ final class PardesView: NSView {
// it.
flush()
setFill(ctx, style.fg, style.alpha)
- let attributed = NSAttributedString(string: text, attributes: [fontAttribute: metrics.fonts[face.rawValue]])
- ctx.textPosition = CGPoint(x: x, y: baseline)
+ let font = tagline ? taglines.fonts[face.rawValue] : metrics.fonts[face.rawValue]
+ let attributed = NSAttributedString(string: text, attributes: [fontAttribute: font])
+ ctx.textPosition = CGPoint(x: glyphX, y: cellBaseline)
CTLineDraw(CTLineCreateWithAttributedString(attributed as CFAttributedString), ctx)
// CTLineDraw leaves the text position at the END of what it drew,
// and textPosition IS the translation of the text matrix, which
@@ -799,14 +1143,23 @@ final class PardesView: NSView {
/// 0 is .notdef, i.e. "this face does not have it" — a real answer, cached
/// like any other, because the CTLine fallback it sends the caller to costs
/// far more than the lookup it would otherwise repeat every frame.
- private func glyph(_ face: Face, _ character: UniChar) -> CGGlyph {
- if character < 128 { return metrics.asciiGlyphs[face.rawValue][Int(character)] }
+ private func glyph(_ face: Face, _ character: UniChar, tagline: Bool) -> CGGlyph {
+ if character < 128 {
+ let ascii = tagline ? taglines.asciiGlyphs : metrics.asciiGlyphs
+ return ascii[face.rawValue][Int(character)]
+ }
let key = UInt32(face.rawValue) << 16 | UInt32(character)
- if let cached = glyphCache[key] { return cached }
+ if tagline, let cached = taglineGlyphCache[key] { return cached }
+ if !tagline, let cached = bodyGlyphCache[key] { return cached }
var input = character
var found = CGGlyph(0)
- _ = CTFontGetGlyphsForCharacters(metrics.fonts[face.rawValue], &input, &found, 1)
- glyphCache[key] = found
+ let font = tagline ? taglines.fonts[face.rawValue] : metrics.fonts[face.rawValue]
+ _ = CTFontGetGlyphsForCharacters(font, &input, &found, 1)
+ if tagline {
+ taglineGlyphCache[key] = found
+ } else {
+ bodyGlyphCache[key] = found
+ }
return found
}
@@ -858,6 +1211,14 @@ final class PardesView: NSView {
NotificationCenter.default.post(name: pardesDidInputNotification, object: self)
}
+ /// A point inside the NSView can still be outside the CRT's curved tube.
+ /// For input that is the same as leaving the grid: discard a pending hover
+ /// preview, but do not manufacture a clamped click or wheel gesture.
+ private func leftGrid() {
+ pardes_pointer_leave()
+ fed()
+ }
+
func typeKey(_ cp: UInt32, text: String, mods: UInt32) {
// The pointer is borrowed for the call and nowhere else, which is the only
// thing the header promises about it.
@@ -1016,7 +1377,10 @@ final class PardesView: NSView {
// The count itself is maintained by the touchesXxx callbacks above; see
// beginClick for why it can only come from there.
private func beginClick(_ stream: pardes_mouse_button_e, _ event: NSEvent) {
- guard let at = cell(for: event) else { return }
+ guard let at = cell(for: event) else {
+ leftGrid()
+ return
+ }
pressureStage = 0
// The count comes from the touch stream and NEVER from the mouse event.
// Asking a mouse event for its touches is not merely unreliable, it
@@ -1035,7 +1399,14 @@ final class PardesView: NSView {
}
private func continueClick(_ event: NSEvent) {
- guard let button = latchedButton, let at = cell(for: event) else { return }
+ guard let button = latchedButton else { return }
+ guard let at = cell(for: event) else {
+ // The drag is still latched, but the curved CRT edge displays no
+ // source cell here. Keep its last real endpoint for mouseUp and
+ // clear hover/seam state without clamping into the grid.
+ leftGrid()
+ return
+ }
latchedCell = at
drag(button, to: at, mods: modifiers(event.modifierFlags))
}
@@ -1046,9 +1417,23 @@ final class PardesView: NSView {
// released the button itself and there is nothing left to end.
guard let button = latchedButton else { return }
latchedButton = nil
- guard let at = cell(for: event) else { return }
- latchedCell = at
- release(button, at: at, mods: modifiers(event.modifierFlags))
+ if let at = cell(for: event) {
+ latchedCell = nil
+ release(button, at: at, mods: modifiers(event.modifierFlags))
+ return
+ }
+ // A release must balance its press even when the hand ends in a black
+ // CRT corner. The last mapped drag point is an observed source cell,
+ // not a clamp of the unmappable release point. Send both state changes
+ // before one pump notification so no frame can observe a held button
+ // whose pointer is already outside the displayed scene.
+ if let last = latchedCell {
+ pardes_mouse(button, PARDES_MOUSE_RELEASE, last.col, last.row,
+ modifiers(event.modifierFlags))
+ }
+ latchedCell = nil
+ pardes_pointer_leave()
+ fed()
}
override func mouseDown(with event: NSEvent) { beginClick(PARDES_MOUSE_LEFT, event) }
@@ -1088,17 +1473,30 @@ final class PardesView: NSView {
guard let at = latchedCell, let current = latchedButton,
current != Trackpad.forceClickButton else { return }
latchedButton = nil
+ latchedCell = nil
release(current, at: at, mods: modifiers(event.modifierFlags))
click(Trackpad.forceClickButton, at: at, mods: modifiers(event.modifierFlags))
}
override func mouseMoved(with event: NSEvent) {
- guard let at = cell(for: event) else { return }
+ guard let at = cell(for: event) else {
+ leftGrid()
+ return
+ }
motion(to: at, mods: modifiers(event.modifierFlags))
}
+ override func mouseExited(with event: NSEvent) {
+ pointerPoint = nil
+ pointerMapped = false
+ leftGrid()
+ }
+
override func scrollWheel(with event: NSEvent) {
- guard let at = cell(for: event) else { return }
+ guard let at = cell(for: event) else {
+ leftGrid()
+ return
+ }
if event.hasPreciseScrollingDeltas {
// The core scrolls a cell at a time, so libpardes accumulates the
// sub-cell travel and spends it as wheel presses — which is why the
@@ -1159,7 +1557,13 @@ final class PardesView: NSView {
private static let tracing = ProcessInfo.processInfo.environment["PARDES_LOG"] != nil
private func cell(for event: NSEvent) -> GridPoint? {
- cellAt(convert(event.locationInWindow, from: nil))
+ let point = convert(event.locationInWindow, from: nil)
+ pointerPoint = point
+ pointerModifiers = modifiers(event.modifierFlags)
+ let at = cellAt(point)
+ pointerMapped = at != nil
+ if let at { pointerCell = at }
+ return at
}
func cellAt(_ point: CGPoint) -> GridPoint? {
@@ -1170,8 +1574,16 @@ final class PardesView: NSView {
let cols = Int(pardes_frame_cols())
let rows = Int(pardes_frame_rows())
guard cols > 0, rows > 0 else { return nil }
- let col = min(max(Int(point.x / cellWidth), 0), cols - 1)
- let row = min(max(Int(point.y / cellHeight), 0), rows - 1)
+ let sampled: CGPoint
+ if let scene = lastPresentedScene {
+ guard let mapped = ScenePostprocessor.sourcePoint(point, size: bounds.size, scene: scene)
+ else { return nil }
+ sampled = mapped
+ } else {
+ sampled = point
+ }
+ let col = min(max(Int(sampled.x / cellWidth), 0), cols - 1)
+ let row = min(max(Int(sampled.y / cellHeight), 0), rows - 1)
return GridPoint(col: UInt16(col), row: UInt16(row))
}
@@ -1242,7 +1654,7 @@ final class PardesView: NSView {
// .inVisibleRect keeps the area correct across resizes on its own, which
// is why the rect argument can be anything.
addTrackingArea(NSTrackingArea(rect: .zero,
- options: [.mouseMoved, .inVisibleRect, .activeInKeyWindow],
+ options: [.mouseMoved, .mouseEnteredAndExited, .inVisibleRect, .activeInKeyWindow],
owner: self,
userInfo: nil))
}
diff --git a/src/macos/Sources/ScenePostprocessor.swift b/src/macos/Sources/ScenePostprocessor.swift
new file mode 100644
index 00000000..cf7892b3
--- /dev/null
+++ b/src/macos/Sources/ScenePostprocessor.swift
@@ -0,0 +1,361 @@
+// The one GPU owner for panel transitions and persistent scene effects.
+// CoreText remains the renderer: while either exists PardesView draws its
+// complete ordinary frame into this retained bitmap, then Core Image runs the
+// standalone shaders/crt.ci.metal kernels over it. With neither, the view
+// bypasses this object entirely.
+
+import CoreGraphics
+import CoreImage
+import Darwin
+import Metal
+
+final class ScenePostprocessor {
+ enum CompositeOutcome {
+ case presented
+ case retry
+ case unavailable
+ }
+
+ private let colorSpace: CGColorSpace
+ private let sceneKernel: CIKernel
+ private let panelClearKernel: CIKernel
+ private let panelKernel: CIKernel
+ private let context: CIContext
+ private var raster: CGContext?
+ private var previousRaster: CGContext?
+ private var rasterWidth = 0
+ private var rasterHeight = 0
+ private var rasterScale: CGFloat = 0
+
+ init?() {
+ let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)
+ ?? CGColorSpaceCreateDeviceRGB()
+ guard let source = ScenePostprocessor.kernelSource(),
+ let device = MTLCreateSystemDefaultDevice(),
+ let made = try? CIKernel.kernels(withMetalString: source),
+ let sceneKernel = made.first(where: { $0.name == "pardesScene" }),
+ let panelClearKernel = made.first(where: { $0.name == "pardesPanelClear" }),
+ let panelKernel = made.first(where: { $0.name == "pardesPanel" })
+ else { return nil }
+ self.colorSpace = colorSpace
+ self.sceneKernel = sceneKernel
+ self.panelClearKernel = panelClearKernel
+ self.panelKernel = panelKernel
+ self.context = CIContext(mtlDevice: device, options: [
+ .workingColorSpace: colorSpace,
+ .outputColorSpace: colorSpace,
+ .cacheIntermediates: false,
+ ])
+ }
+
+ private static func kernelSource() -> String? {
+ var candidates: [URL] = []
+ if let resources = Bundle.main.resourceURL {
+ candidates.append(resources.appendingPathComponent("crt.ci.metal"))
+ }
+ // An unbundled e2e executable has no Contents/Resources. Its build
+ // puts the asset next to the binary, and this explicit candidate keeps
+ // that dev loop independent of Bundle's treatment of command tools.
+ if let executable = Bundle.main.executableURL {
+ candidates.append(executable.deletingLastPathComponent()
+ .appendingPathComponent("crt.ci.metal"))
+ }
+ for url in candidates {
+ if let source = try? String(contentsOf: url, encoding: .utf8) {
+ return source
+ }
+ }
+ return nil
+ }
+
+ /// A body-font-sized, top-left-origin CGContext matching the view's own
+ /// coordinate system. It is retained across frames; only a resize or a
+ /// backing-scale move reallocates the pixel storage.
+ func renderContext(size: CGSize, scale: CGFloat) -> CGContext? {
+ let scale = max(1, scale)
+ let width = max(1, Int((size.width * scale).rounded(.up)))
+ let height = max(1, Int((size.height * scale).rounded(.up)))
+ if raster == nil || width != rasterWidth || height != rasterHeight || scale != rasterScale {
+ let info = CGBitmapInfo.byteOrder32Big.rawValue
+ | CGImageAlphaInfo.premultipliedLast.rawValue
+ guard let next = CGContext(
+ data: nil, width: width, height: height,
+ bitsPerComponent: 8, bytesPerRow: width * 4,
+ space: colorSpace, bitmapInfo: info)
+ else { return nil }
+ next.translateBy(x: 0, y: CGFloat(height))
+ next.scaleBy(x: scale, y: -scale)
+ raster = next
+ // The old and new snapshots must have identical extents. Lazily
+ // rebuild the old owner only if an effect actually needs it.
+ previousRaster = nil
+ rasterWidth = width
+ rasterHeight = height
+ rasterScale = scale
+ }
+ return raster
+ }
+
+ /// A second complete CoreText raster for the frozen old grid. It is
+ /// intentionally a peer of `raster`, not a collection of per-pane image
+ /// pointers: deleted PDF/image panes have already released their core
+ /// bytes, while PardesView's frozen CGImages remain safe to draw here.
+ func previousRenderContext(size: CGSize, scale: CGFloat) -> CGContext? {
+ guard renderContext(size: size, scale: scale) != nil else { return nil }
+ if previousRaster == nil {
+ let info = CGBitmapInfo.byteOrder32Big.rawValue
+ | CGImageAlphaInfo.premultipliedLast.rawValue
+ guard let next = CGContext(
+ data: nil, width: rasterWidth, height: rasterHeight,
+ bitsPerComponent: 8, bytesPerRow: rasterWidth * 4,
+ space: colorSpace, bitmapInfo: info)
+ else { return nil }
+ next.translateBy(x: 0, y: CGFloat(rasterHeight))
+ next.scaleBy(x: rasterScale, y: -rasterScale)
+ previousRaster = next
+ }
+ return previousRaster
+ }
+
+ /// Run the one scene kernel and copy its result into the flipped NSView
+ /// context. Allocation/device failures may be retried briefly. A kernel
+ /// rejecting its fixed argument layout is permanent for this executable,
+ /// so the caller can turn the advertised effects back off instead of
+ /// silently spinning a display clock forever.
+ func composite(
+ scene: pardes_scene_s,
+ tracks: UnsafeBufferPointer<pardes_panel_track_s>,
+ changedCells: UnsafePointer<UInt8>?,
+ changedAttachmentRects: [CGRect],
+ gridSize: (cols: Int, rows: Int),
+ cellSize: CGSize,
+ ground: UInt32?,
+ size: CGSize,
+ into destination: CGContext
+ ) -> CompositeOutcome {
+ guard let raster, let sourceImage = raster.makeImage() else { return .retry }
+ let extent = CGRect(x: 0, y: 0, width: rasterWidth, height: rasterHeight)
+ let current = CIImage(cgImage: sourceImage, options: [.colorSpace: colorSpace])
+ let old: CIImage
+ if let previousImage = previousRaster?.makeImage() {
+ old = CIImage(cgImage: previousImage, options: [.colorSpace: colorSpace])
+ } else {
+ old = current
+ }
+ let mask = changeMask(
+ changedCells, attachmentRects: changedAttachmentRects,
+ gridSize: gridSize, cellSize: cellSize) ??
+ CIImage(color: CIColor(red: 0, green: 0, blue: 0, alpha: 1))
+ .cropped(to: CGRect(x: 0, y: 0, width: 1, height: 1))
+ var filtered = current
+
+ // Geometric targets are prepared before any presented panel is drawn.
+ // Horizontal slide/zoom use the theme ground; a vertical opener uses
+ // the exact old frame inside its fixed clip. Data effects and closing
+ // tombstones leave the canonical current frame untouched underneath.
+ let clear = ground.map { encoded in
+ CIColor(
+ red: CGFloat((encoded >> 16) & 0xFF) / 255,
+ green: CGFloat((encoded >> 8) & 0xFF) / 255,
+ blue: CGFloat(encoded & 0xFF) / 255,
+ alpha: 1)
+ } ?? CIColor(red: 0, green: 0, blue: 0, alpha: 0)
+ for track in tracks {
+ let content = track.phase == UInt8(PARDES_PANEL_CLOSING) ? track.from : track.to
+ guard let next = panelClearKernel.apply(
+ extent: extent,
+ roiCallback: { _, _ in extent },
+ arguments: [
+ filtered, old,
+ pixelBox(content, cellSize: cellSize), clear,
+ Float(track.effect), Float(track.phase),
+ ])
+ else { return .unavailable }
+ filtered = next
+ }
+
+ // libpardes orders moving, opening, then closing tombstones. Each pass
+ // samples immutable old/new rasters and composes over the prior pass,
+ // so a deleted pane can safely outlive its core Pane allocation.
+ let pixelCell = CIVector(
+ x: cellSize.width * rasterScale,
+ y: cellSize.height * rasterScale)
+ for track in tracks {
+ let content = track.phase == UInt8(PARDES_PANEL_CLOSING) ? track.from : track.to
+ guard let next = panelKernel.apply(
+ extent: extent,
+ roiCallback: { _, _ in extent },
+ arguments: [
+ current, old, mask, filtered,
+ pixelBox(content, cellSize: cellSize),
+ pixelBox(track.from, cellSize: cellSize),
+ pixelBox(track.to, cellSize: cellSize),
+ Float(track.effect), Float(track.phase), Float(track.frame),
+ CIVector(
+ x: CGFloat(track.serial & 0xFFFF),
+ y: CGFloat(track.serial >> 16)),
+ pixelCell,
+ CIVector(
+ x: 0,
+ y: extent.height - CGFloat(gridSize.rows) * pixelCell.y),
+ ])
+ else { return .unavailable }
+ filtered = next
+ }
+
+ if scene.flags != 0 {
+ let crt: Float = scene.flags & UInt32(PARDES_SCENE_CRT) != 0 ? 1 : 0
+ let ripple: Float = scene.flags & UInt32(PARDES_SCENE_RIPPLE) != 0 ? 1 : 0
+ let glitch: Float = scene.flags & UInt32(PARDES_SCENE_GLITCH) != 0 ? 1 : 0
+ // SDL samples the scene with CLAMP_TO_EDGE. A finite Core Image
+ // instead supplies transparent black outside its extent, which
+ // would make ripple/glitch tear dark seams at the window edge.
+ // Clamp the input only; the kernel still emits true black before
+ // sampling when a destination lies outside the CRT tube.
+ let sceneInput = filtered.clampedToExtent()
+ guard let next = sceneKernel.apply(
+ extent: extent,
+ roiCallback: { _, _ in extent },
+ arguments: [
+ sceneInput, scene.time_seconds, Float(scene.frame),
+ crt, ripple, glitch,
+ CIVector(x: extent.width, y: extent.height),
+ CIVector(x: extent.minX, y: extent.minY),
+ ])
+ else { return .unavailable }
+ filtered = next
+ }
+
+ guard let presented = context.createCGImage(filtered, from: extent) else { return .retry }
+
+ destination.clear(CGRect(origin: .zero, size: size))
+ destination.saveGState()
+ destination.translateBy(x: 0, y: size.height)
+ destination.scaleBy(x: 1, y: -1)
+ destination.interpolationQuality = .none
+ destination.draw(presented, in: CGRect(origin: .zero, size: size))
+ destination.restoreGState()
+ return .presented
+ }
+
+ /// Byte-per-cell mask from the core. Zig expands true to 255 so the image
+ /// is a literal normalized 0/1 texture; sampling cell centres below keeps
+ /// adjacent values from interpolating at pane/cell boundaries.
+ private func changeMask(
+ _ cells: UnsafePointer<UInt8>?,
+ attachmentRects: [CGRect],
+ gridSize: (cols: Int, rows: Int),
+ cellSize: CGSize
+ ) -> CIImage? {
+ let count = gridSize.cols * gridSize.rows
+ guard count > 0, let cells, cellSize.width > 0, cellSize.height > 0 else { return nil }
+ var bytes = Array(UnsafeBufferPointer(start: cells, count: count))
+ for rect in attachmentRects where !rect.isNull && !rect.isEmpty {
+ let x0 = max(0, Int((rect.minX / cellSize.width).rounded(.down)))
+ let x1 = min(gridSize.cols, Int((rect.maxX / cellSize.width).rounded(.up)))
+ let y0 = max(0, Int((rect.minY / cellSize.height).rounded(.down)))
+ let y1 = min(gridSize.rows, Int((rect.maxY / cellSize.height).rounded(.up)))
+ guard x0 < x1, y0 < y1 else { continue }
+ for row in y0..<y1 {
+ for col in x0..<x1 { bytes[row * gridSize.cols + col] = 255 }
+ }
+ }
+ return bytes.withUnsafeBufferPointer { buffer in
+ guard let base = buffer.baseAddress,
+ let data = CFDataCreate(nil, base, count),
+ let provider = CGDataProvider(data: data),
+ let gray = CGColorSpace(name: CGColorSpace.linearGray),
+ let image = CGImage(
+ width: gridSize.cols, height: gridSize.rows,
+ bitsPerComponent: 8, bitsPerPixel: 8,
+ bytesPerRow: gridSize.cols, space: gray,
+ bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue),
+ provider: provider, decode: nil,
+ shouldInterpolate: false, intent: .defaultIntent)
+ else { return nil }
+ return CIImage(cgImage: image, options: [.colorSpace: gray])
+ }
+ }
+
+ /// Convert the core's top-left logical-cell box to Core Image's
+ /// bottom-left backing-pixel rectangle.
+ private func pixelBox(_ box: pardes_panel_box_s, cellSize: CGSize) -> CIVector {
+ let cellW = cellSize.width * rasterScale
+ let cellH = cellSize.height * rasterScale
+ let x = CGFloat(box.x) * cellW
+ let w = CGFloat(box.w) * cellW
+ let h = CGFloat(box.h) * cellH
+ let y = CGFloat(rasterHeight) - (CGFloat(box.y) + CGFloat(box.h)) * cellH
+ return CIVector(x: x, y: y, z: w, w: h)
+ }
+
+ /// The scene kernel is a destination-to-source mapping. Pointer input must
+ /// apply that same mapping before the grid and its panel tracks see a cell,
+ /// otherwise a visible word under ripple/glitch is not the word Look gets.
+ static func sourcePoint(
+ _ point: CGPoint,
+ size: CGSize,
+ scene: pardes_scene_s
+ ) -> CGPoint? {
+ guard size.width > 0, size.height > 0 else { return nil }
+ if scene.flags == 0 { return point }
+
+ var uvX = Float(point.x / size.width)
+ var uvY = 1 - Float(point.y / size.height) // Core Image is bottom-up
+ var ccX = uvX * 2 - 1
+ var ccY = uvY * 2 - 1
+ let crt: Float = scene.flags & UInt32(PARDES_SCENE_CRT) != 0 ? 1 : 0
+ let radius2 = ccX * ccX + ccY * ccY
+ let barrel = 1 + 0.016 * radius2 * crt
+ ccX *= barrel
+ ccY *= barrel
+ uvX = ccX * 0.5 + 0.5
+ uvY = ccY * 0.5 + 0.5
+ // Only CRT has a black tube. Ripple/glitch without CRT sample through
+ // the texture's clamp-to-edge policy and remain clickable there.
+ if crt > 0.5 && min(min(uvX, uvY), min(1 - uvX, 1 - uvY)) <= 0 { return nil }
+
+ let time = scene.time_seconds
+ if scene.flags & UInt32(PARDES_SCENE_RIPPLE) != 0 {
+ let centeredX = uvX - 0.5
+ let centeredY = uvY - 0.5
+ let radius = sqrtf(centeredX * centeredX + centeredY * centeredY)
+ let envelope = 1 - smoothstep(0.04, 0.72, radius)
+ let wave = sinf(radius * 42 - time * 5.2)
+ let travel = wave * envelope * 0.0032 / max(radius, 0.00001)
+ uvX += centeredX * travel
+ uvY += centeredY * travel
+ }
+ if scene.flags & UInt32(PARDES_SCENE_GLITCH) != 0 {
+ let band = floorf(uvY * 96)
+ let seed = noise(band, floorf(time * 13))
+ let tear = smoothstep(0.955, 1, seed)
+ uvX += (noise(seed, band) - 0.5) * tear * 0.020
+ }
+ uvX = min(max(uvX, 0), max(0, 1 - 1 / Float(size.width)))
+ uvY = min(max(uvY, 0), max(0, 1 - 1 / Float(size.height)))
+ return CGPoint(
+ x: CGFloat(uvX) * size.width,
+ y: CGFloat(1 - uvY) * size.height)
+ }
+
+ private static func smoothstep(_ edge0: Float, _ edge1: Float, _ x: Float) -> Float {
+ let t = min(max((x - edge0) / (edge1 - edge0), 0), 1)
+ return t * t * (3 - 2 * t)
+ }
+
+ /// Scalar spelling of pardesNoise in crt.ci.metal. Float arithmetic is
+ /// deliberate: the shader's band decision must not move at a Double round.
+ private static func noise(_ x: Float, _ y: Float) -> Float {
+ var qx = fract(x * 0.1031)
+ var qy = fract(y * 0.1031)
+ var qz = fract(x * 0.1031)
+ let d = qx * (qy + 33.33) + qy * (qz + 33.33) + qz * (qx + 33.33)
+ qx += d
+ qy += d
+ qz += d
+ return fract((qx + qy) * qz)
+ }
+
+ private static func fract(_ x: Float) -> Float { x - floorf(x) }
+}
diff --git a/src/macos/build-e2e.sh b/src/macos/build-e2e.sh
index c0f9613c..e9ccebcd 100755
--- a/src/macos/build-e2e.sh
+++ b/src/macos/build-e2e.sh
@@ -32,6 +32,10 @@ bin="$out/bin/pardes-macos-e2e"
command -v swiftc >/dev/null || { echo "swiftc not found (needs macOS + Command Line Tools)" >&2; exit 1; }
mkdir -p "$out/bin"
+# Bundle.main.resourceURL is the executable directory for this standalone
+# harness. Put the same committed source there that the real app installs into
+# Contents/Resources, so the shader path is exercised rather than bypassed.
+cp "$root/shaders/crt.ci.metal" "$out/bin/crt.ci.metal"
# -import-objc-header and -lc++ are the app link's, unchanged, and have to stay
# that way: this link exists to exercise the app's link, so anything that
@@ -44,9 +48,12 @@ swiftc -O -target "$(uname -m)-apple-macos$minver" \
-import-objc-header "$root/src/macos/pardes.h" \
-o "$bin" \
"$root/src/macos/Sources/PardesView.swift" \
+ "$root/src/macos/Sources/ScenePostprocessor.swift" \
+ "$root/src/macos/Sources/FileWatcher.swift" \
"$root/src/macos/Sources/AppDelegate.swift" \
"$root/test/macos_e2e.swift" \
"$lib" -lc++ \
- -framework AppKit -framework CoreText -framework CoreGraphics
+ -framework AppKit -framework CoreText -framework CoreGraphics \
+ -framework CoreImage -framework Metal
echo "built $bin"
diff --git a/src/macos/pardes.h b/src/macos/pardes.h
index 9a64501a..cfe83ffa 100644
--- a/src/macos/pardes.h
+++ b/src/macos/pardes.h
@@ -45,6 +45,8 @@ typedef struct {
uint8_t len;
// bit 0: the core never painted this cell — draw it as the default cell
// (background only), which is what makes a partial redraw cheap.
+ // bit 1: rasterize text and its colour band with the tagline face's measured
+ // height. Logical cell geometry stays body-sized; this is a visual role.
uint8_t flags;
} pardes_cell_s;
@@ -73,6 +75,54 @@ typedef struct {
#define PARDES_UL_DASHED 5
#define PARDES_CELL_DEFAULT 0x01u
+#define PARDES_CELL_TAGLINE 0x02u
+
+// One coherent snapshot for the full-window shader pass. The bitmask lets the
+// host run CRT, ripple and glitch in one kernel instead of stacking filters;
+// time/frame advance only on pardes_animation_tick's display clock.
+typedef struct {
+ uint32_t flags;
+ float time_seconds;
+ uint32_t frame;
+} pardes_scene_s;
+
+#define PARDES_SCENE_CRT 0x01u
+#define PARDES_SCENE_RIPPLE 0x02u
+#define PARDES_SCENE_GLITCH 0x04u
+
+// One backend-neutral pane transition. Boxes are logical cell rectangles;
+// the native shader converts them to backing pixels using the same measured
+// cell as the renderer. The list is already in paint order: moving first,
+// opening next, frozen closing tombstones last, and slot order within a phase.
+typedef struct {
+ float x;
+ float y;
+ float w;
+ float h;
+} pardes_panel_box_s;
+
+typedef struct {
+ uint32_t serial;
+ uint8_t pane;
+ uint8_t phase;
+ uint8_t effect;
+ uint8_t reserved0;
+ uint16_t frame;
+ uint16_t reserved1;
+ pardes_panel_box_s from;
+ pardes_panel_box_s to;
+} pardes_panel_track_s;
+
+#define PARDES_PANEL_OPENING 0u
+#define PARDES_PANEL_MOVING 1u
+#define PARDES_PANEL_CLOSING 2u
+
+#define PARDES_PANEL_OFF 0u
+#define PARDES_PANEL_SLIDE 1u
+#define PARDES_PANEL_ZOOM 2u
+#define PARDES_PANEL_DISSOLVE 3u
+#define PARDES_PANEL_ASCII 4u
+#define PARDES_PANEL_VERTICAL 5u
// ---------------------------------------------------------------- input
@@ -173,10 +223,15 @@ void pardes_deinit(void);
// into the core and use this only to decide whether there was work.
bool pardes_tick(void);
+// Advance one frame-count animation step. Call only from the scheduled ~60 Hz
+// callback, never from ordinary input or pty pumps: those drain work but do
+// not represent elapsed display time. Returns whether anything advanced.
+bool pardes_animation_tick(void);
+
// The core asked to exit (the Exit builtin, or the last pane closing).
bool pardes_should_quit(void);
-// A theme transition is mid-flight and wants ~60 Hz ticks until it settles.
+// A theme/pane transition, scene effect, or rotation coast wants ~60 Hz ticks.
bool pardes_animating(void);
// What to paint where the grid does not: the window background behind the
@@ -189,6 +244,25 @@ bool pardes_animating(void);
// wear, so the host should go transparent and show its own backdrop.
uint32_t pardes_theme_bg(void);
+// Pane taglines use this percentage of the current body font size. It is the
+// embedded default before init and the live core setting afterwards; the host
+// polls it and rebuilds only tagline glyph/metric state.
+uint8_t pardes_gui_tagline_font_percent(void);
+
+// Persistent full-window effect switches and their display-clock time. A zero
+// flags word means draw the CoreText frame directly; any nonzero combination
+// is one postprocess pass. Snapshot by value, with no borrowed storage.
+pardes_scene_s pardes_scene(void);
+
+// The shared Metal/Core Image pass is unavailable for this session. Clears
+// scene effects and future panel transitions so Config reflects what the host
+// can actually present and the display clock cannot retry forever.
+void pardes_postprocessor_unavailable(void);
+
+// A transient postprocess failure will be presented as the canonical grid.
+// Drop current panel tracks so a later scene retry cannot resume them midway.
+void pardes_panel_animation_failed(void);
+
// ---------------------------------------------------------------- events in
// `cp` is a codepoint or one of PARDES_KEY_*; `text`/`len` are the host's
@@ -198,6 +272,15 @@ void pardes_key(uint32_t cp, const char *text, size_t len, uint32_t mods);
void pardes_paste(const char *text, size_t len);
void pardes_mouse(pardes_mouse_button_e button, pardes_mouse_kind_e kind,
uint16_t col, uint16_t row, uint32_t mods);
+// The pointer left the view. A clamped motion on its final cell is not the
+// same event: it would keep hover previews and resize hints alive off-window.
+void pardes_pointer_leave(void);
+
+// Host file watchers call this after their debounce interval. `generation`
+// comes from pardes_host_watch_file and makes an event queued before a pane
+// replacement inert. The callback only schedules the ordinary main-thread
+// pump; file IO and the shared text/PDF reload happen inside pardes_tick.
+void pardes_watch_changed(uint8_t pane, uint32_t generation);
// Trackpad/precision wheel distance in CELLS, sign following the grid
// (positive scrolls down and right). The core has no fractional scroll — it
@@ -224,9 +307,10 @@ void pardes_rotate(float degrees);
// how hard it was thrown, and the two are the same curve — momentum ramps up
// from zero rather than switching on at a threshold.
//
-// A coast makes pardes_animating true and is spent by pardes_tick, so a host
-// that already re-pumps for theme transitions needs no new machinery; one that
-// never calls this simply has a dial with no momentum.
+// A coast makes pardes_animating true and is spent by
+// pardes_animation_tick, so a host that already re-pumps for theme transitions
+// needs no new machinery; one that never calls this simply has a dial with no
+// momentum.
void pardes_rotate_end(void);
// Run one builtin command line, exactly as executing the same text in a tag
@@ -246,6 +330,11 @@ void pardes_resize(uint16_t cols, uint16_t rows, uint16_t cell_w,
// The buffer returned by pardes_frame_cells is valid until the next call.
uint32_t pardes_frame(void);
const pardes_cell_s *pardes_frame_cells(void);
+// Frozen old grid and its byte-per-cell semantic change mask. Both are either
+// present for exactly cols*rows cells or NULL together, and are borrowed until
+// the next pardes_frame call just like the canonical cells.
+const pardes_cell_s *pardes_frame_previous_cells(void);
+const uint8_t *pardes_frame_changed_cells(void);
uint16_t pardes_frame_cols(void);
uint16_t pardes_frame_rows(void);
@@ -290,6 +379,16 @@ typedef struct {
uint32_t pardes_frame_images(void);
const pardes_image_s *pardes_frame_image_list(void);
+// Shader transition records for this frame, borrowed until pardes_frame is
+// called again. Empty means draw the canonical grid without a panel pass.
+uint32_t pardes_frame_panel_tracks(void);
+const pardes_panel_track_s *pardes_frame_panel_track_list(void);
+// Call only after the host has put this frame on screen. `true` commits the
+// borrowed panel-track list above; `false` records a direct canonical fallback.
+// Returns true only when remapping a stationary pointer newly started a clock
+// the host was not already running.
+bool pardes_frame_presented(bool animated_panels);
+
// The file behind the FOCUSED pane, or NULL when there is none: a terminal, an
// output buffer, or nothing focused. PDFs and images count — they are real
// paths, and a titlebar proxy icon is about the file, not about who may edit
@@ -323,11 +422,24 @@ pardes_haptic_e pardes_take_haptic(void);
// file is named after.
//
// The host loads it, re-measures its cell, and reports the new grid through
-// pardes_resize. A file the host cannot load is one to ignore: keep wearing
-// the face that works, because a terminal that cannot draw has no way back
-// out of it.
+// pardes_resize. A file the host cannot load must be rejected explicitly while
+// keeping the face that works; otherwise Config would leave the request pending
+// forever or claim a fallback was accepted.
const char *pardes_font_take(void);
+// Boot, zoom and display-scale changes observe the face already on screen;
+// this never consumes a pending request. `point_hundredths` is the effective
+// body size (14pt => 1400).
+bool pardes_font_observe(const char *effective_name, size_t len,
+ uint16_t point_hundredths);
+
+// A taken Font request is not effective until the host says what CoreText
+// actually accepted. Calling this without a request from pardes_font_take is
+// inert, so an unrelated host report cannot consume the request.
+bool pardes_font_ack(const char *effective_name, size_t len,
+ uint16_t point_hundredths);
+void pardes_font_reject(void);
+
#ifdef __cplusplus
}
#endif
diff --git a/src/main.zig b/src/main.zig
index 4a067406..48022db5 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -200,7 +200,7 @@ fn nativeMain(init: std.process.Init) !void {
}
}
}
- // Native shells opt into the user config; the sans-IO core and web keep
+ // Native shells opt into the user config; direct core callers and web keep
// Options' null default. Read it before entering either frontend so every
// builtin has run before that frontend can render its first frame.
const found = @import("user_config.zig").load(init.io, arena, init.environ_map);
@@ -232,14 +232,20 @@ fn parseCtrlKey(raw: []const u8) ?u21 {
}
// The shells are imported inside main(), which a test build never analyses —
-// so their inline tests need naming here to exist at all. gui.zig only
-// compiles when it IS the shell (it @cImports SDL), hence the comptime gate;
-// under -Dplatform=tty this block analyses to nothing. Naming a file gets THAT
-// file's tests and no further: fonts.zig is imported by gui.zig, builtins.zig
-// and the macOS host, and still needs its own line here.
+// so their inline tests need naming here to exist at all. Each shell only
+// compiles when selected (GUI @cImports SDL; TTY imports vaxis), hence the
+// comptime gates. Naming a file gets THAT file's tests and no further:
+// fonts.zig is imported by gui.zig, builtins.zig and the macOS host, and still
+// needs its own line here.
test {
_ = @import("user_config.zig");
_ = @import("allocators.zig");
+ if (comptime pardes.platform == .tty) {
+ _ = @import("tty/tty.zig");
+ // tty.zig calls the compositor only from its runtime loop, so merely
+ // naming the shell does not make Zig analyse the compositor's tests.
+ _ = @import("tty/panel_compositor.zig");
+ }
if (comptime pardes.platform == .gui) _ = @import("gui/gui.zig");
if (comptime pardes.font_picker) _ = @import("fonts.zig");
}
diff --git a/src/modal.zig b/src/modal.zig
index 33c63c52..6ab7ee35 100644
--- a/src/modal.zig
+++ b/src/modal.zig
@@ -204,15 +204,6 @@ pub fn gotoLast(lines: []const []const u8) Cursor {
return .{ .row = r, .col = 0 };
}
-// half/full page: target row, caller clamps + scrolls.
-pub fn pageDown(c: Cursor, page: usize) Cursor {
- return .{ .row = c.row + page, .col = c.col };
-}
-
-pub fn pageUp(c: Cursor, page: usize) Cursor {
- return .{ .row = if (c.row > page) c.row - page else 0, .col = c.col };
-}
-
// the character the cursor sits on; line terminators / EOF read as '\n'.
fn charAt(lines: []const []const u8, c: Cursor) u8 {
if (c.row >= lines.len) return '\n';
@@ -1329,411 +1320,8 @@ test "hx increment" {
try std.testing.expectEqual(@as(?[]u8, null), try hxIncrement(a, "", 1));
}
-// ---- retired standalone file-pane modal prototype ----
-// This old pure-std model has no production or harness callers. It remains
-// only as context for the primitive algorithms around it; normal_input.zig is
-// the sole BODY-NORMAL recognizer and Pardes owns Action execution.
-
-pub const Key = union(enum) {
- ch: u21, // a printable codepoint (insert inserts its UTF-8 bytes)
- esc,
- enter,
- backspace,
- left,
- right,
- up,
- down,
- home,
- end,
- page_down,
- page_up,
- half_down, // Ctrl-d
- half_up, // Ctrl-u
-};
-
-pub const FileMode = enum { normal, insert };
-
-pub const FileState = struct {
- cur: Cursor = .{},
- mode: FileMode = .normal,
- pending: u21 = 0, // prefix codepoint in flight: 'g' or 'z'
- msel_active: bool = false,
- msel_r0: i32 = 0,
- msel_r1: i32 = 0,
-};
-
-// The view the cursor must stay inside. `scroll` is read/written so viewport
-// alignment (zt/zz/zb) and page motions can adjust it; the app syncs it back to
-// the file pane's scroll. The unit-test Sim passes a large rows + a dummy scroll
-// so visibility is a no-op (content is what's asserted).
-pub const View = struct {
- rows: usize,
- scroll: *usize,
-};
-
-fn chEq(key: Key, cp: u21) bool {
- return switch (key) {
- .ch => |c| c == cp,
- else => false,
- };
-}
-
-fn tagOf(key: Key) std.meta.Tag(Key) {
- return std.meta.activeTag(key);
-}
-
-fn splitLines(alloc: std.mem.Allocator, content: []const u8) ![][]const u8 {
- const count = std.mem.count(u8, content, "\n") + 1;
- const lines = try alloc.alloc([]const u8, count);
- var it = std.mem.splitScalar(u8, content, '\n');
- var i: usize = 0;
- while (it.next()) |line| : (i += 1) lines[i] = line;
- return lines;
-}
-
-fn joinedLineRangeAlloc(alloc: std.mem.Allocator, content: []const u8, r0: usize, r1: usize) ![]u8 {
- var len = r1 - r0;
- var r = r0;
- while (true) : (r += 1) {
- len += lineSlice(content, r).len;
- if (r == r1) break;
- }
- const out = try alloc.alloc(u8, len);
- var write: usize = 0;
- r = r0;
- while (true) : (r += 1) {
- if (r > r0) {
- out[write] = '\n';
- write += 1;
- }
- const line = lineSlice(content, r);
- @memcpy(out[write..][0..line.len], line);
- write += line.len;
- if (r == r1) break;
- }
- return out;
-}
-
-fn ensureVisible(view: View, row: usize) void {
- const s = view.scroll.*;
- if (row < s) {
- view.scroll.* = row;
- return;
- }
- const last = s +| (view.rows -| 1);
- if (row > last) view.scroll.* = row -| (view.rows -| 1);
-}
-
-// helix `x`: start a line selection at the cursor, or extend it down by one.
-fn lineSelectFile(st: *FileState, lines: []const []const u8) void {
- if (!st.msel_active) {
- st.msel_active = true;
- st.msel_r0 = @intCast(st.cur.row);
- st.msel_r1 = @intCast(st.cur.row);
- } else {
- const last: i32 = @intCast(if (lines.len == 0) 0 else lines.len - 1);
- st.msel_r1 = @min(st.msel_r1 + 1, last);
- st.cur.row = @intCast(st.msel_r1);
- }
- st.cur.col = 0;
- st.pending = 0;
-}
-
-// `d`: delete the selected lines (yanking them), or the char at the cursor.
-fn deleteFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, lines: []const []const u8, yank: *?[]u8) !?[]u8 {
- if (st.msel_active) {
- const r0: i32 = @min(st.msel_r0, st.msel_r1);
- const r1: i32 = @max(st.msel_r0, st.msel_r1);
- const d = try deleteLines(alloc, content, @intCast(@max(0, r0)), @intCast(@max(0, r1)));
- const n = lineCount(d.content);
- if (yank.*) |old| alloc.free(old);
- yank.* = d.deleted;
- st.cur = .{ .row = @intCast(@min(@as(usize, @intCast(@max(0, r0))), if (n == 0) 0 else n - 1)), .col = 0 };
- st.msel_active = false;
- st.pending = 0;
- return d.content;
- }
- // no selection: delete the char at the cursor (no-op if past end)
- const llen = lineLenOf(lines, st.cur.row);
- if (st.cur.col >= llen) {
- st.pending = 0;
- return null;
- }
- const new = try deleteChar(alloc, content, st.cur);
- st.pending = 0;
- return new;
-}
-
-// `y`: yank the selected lines, or the current line. Cursor moves to the
-// selection start; the selection is collapsed.
-fn yankFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, yank: *?[]u8) !void {
- if (st.msel_active) {
- const r0: usize = @intCast(@max(0, @min(st.msel_r0, st.msel_r1)));
- const r1: usize = @intCast(@max(0, @max(st.msel_r0, st.msel_r1)));
- const new_yank = try joinedLineRangeAlloc(alloc, content, r0, r1);
- if (yank.*) |old| alloc.free(old);
- yank.* = new_yank;
- } else {
- const new_yank = try alloc.dupe(u8, lineSlice(content, st.cur.row));
- if (yank.*) |old| alloc.free(old);
- yank.* = new_yank;
- }
- st.pending = 0;
-}
-
-// `p`: paste the yanked text as a new line below the cursor.
-fn pasteFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, yank: *?[]u8) !?[]u8 {
- const y = yank.* orelse {
- st.pending = 0;
- return null;
- };
- const new = try pasteLineBelow(alloc, content, st.cur.row, y);
- st.cur = .{ .row = st.cur.row + 1, .col = 0 };
- st.pending = 0;
- return new;
-}
-
-// `c`: change the selection (delete + enter INSERT, keeping one empty line), or
-// change the char at the cursor. Yanks the removed text.
-fn changeFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, lines: []const []const u8, yank: *?[]u8) !?[]u8 {
- if (st.msel_active) {
- const r0: usize = @intCast(@max(0, @min(st.msel_r0, st.msel_r1)));
- const r1: usize = @intCast(@max(0, @max(st.msel_r0, st.msel_r1)));
- // yank the original lines first (from the un-mutated content)
- const new_yank = try joinedLineRangeAlloc(alloc, content, r0, r1);
- errdefer alloc.free(new_yank);
- // drop the extra lines (keep one), then empty the kept line
- const d = if (r1 > r0) try deleteLines(alloc, content, r0 + 1, r1) else null;
- defer if (d) |dd| alloc.free(dd.deleted);
- errdefer if (d) |dd| alloc.free(dd.content);
- const after_del: []const u8 = if (d) |dd| dd.content else content;
- const cl = try clearLine(alloc, after_del, r0);
- if (d) |dd| alloc.free(dd.content); // free the intermediate (after_del copy)
- if (yank.*) |old| alloc.free(old);
- yank.* = new_yank;
- st.cur = .{ .row = r0, .col = 0 };
- st.mode = .insert;
- st.msel_active = false;
- st.pending = 0;
- return cl;
- }
- // no selection: delete the char at the cursor (if any), then enter INSERT
- const llen = lineLenOf(lines, st.cur.row);
- if (st.cur.col >= llen) {
- st.mode = .insert;
- st.msel_active = false;
- st.pending = 0;
- return null;
- }
- const new = try deleteChar(alloc, content, st.cur);
- st.mode = .insert;
- st.msel_active = false;
- st.pending = 0;
- return new;
-}
-
-// INSERT-mode key on a file pane. Returns new content or null (no change).
-fn stepInsertFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, lines: []const []const u8, key: Key) !?[]u8 {
- switch (key) {
- .esc => {
- st.mode = .normal;
- st.msel_active = false;
- st.pending = 0;
- return null;
- },
- .ch => |cp| {
- if (cp == '\n') return try stepInsertFile(alloc, content, st, lines, .enter);
- var buf: [4]u8 = undefined;
- const n = std.unicode.utf8Encode(cp, &buf) catch return null;
- const new = try insertAt(alloc, content, st.cur, buf[0..n]);
- st.cur.col += n; // col is a byte offset; advance by the char's byte length
- st.pending = 0;
- return new;
- },
- .enter => {
- const indent = hxNewlineIndentWidth(lineSlice(content, st.cur.row), st.cur.col);
- const block = try alloc.alloc(u8, 1 + indent);
- defer alloc.free(block);
- block[0] = '\n';
- @memset(block[1..], ' ');
- const new = try insertAt(alloc, content, st.cur, block);
- st.cur = .{ .row = st.cur.row + 1, .col = indent };
- st.pending = 0;
- return new;
- },
- .backspace => {
- if (st.cur.col > 0) {
- const new = try deleteChar(alloc, content, .{ .row = st.cur.row, .col = st.cur.col - 1 });
- st.cur.col -= 1;
- return new;
- } else if (st.cur.row > 0) {
- const prevlen = lineSlice(content, st.cur.row - 1).len;
- const new = try deleteChar(alloc, content, .{ .row = st.cur.row - 1, .col = prevlen });
- st.cur = .{ .row = st.cur.row - 1, .col = prevlen };
- return new;
- }
- return null;
- },
- .left => {
- st.cur = charLeft(st.cur);
- return null;
- },
- .right => {
- st.cur = charRight(lines, st.cur);
- return null;
- },
- .up => {
- st.cur = lineUp(lines, st.cur);
- return null;
- },
- .down => {
- st.cur = lineDown(lines, st.cur);
- return null;
- },
- .home => {
- st.cur = lineStart(st.cur);
- return null;
- },
- .end => {
- st.cur = lineEnd(lines, st.cur);
- return null;
- },
- else => return null,
- }
-}
-
-// RETIRED LEGACY REFERENCE INTERPRETER. Nothing in production or the test
-// harness calls this mini-parser; BODY-NORMAL recognition lives exclusively
-// in normal_input.zig and Pardes executes its semantic Actions for text/PDF.
-// Keep this frozen only as old modal-algorithm context; do not add bindings or
-// infer current behavior from it.
-pub fn stepFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, view: View, key: Key, yank: *?[]u8) !?[]u8 {
- const lines = try splitLines(alloc, content);
- defer alloc.free(lines);
- if (st.mode == .insert) return try stepInsertFile(alloc, content, st, lines, key);
-
- var cur = clampToChar(lines, st.cur);
-
- // prefix continuations
- if (st.pending != 0) {
- const p = st.pending;
- st.pending = 0;
- if (p == 'g') {
- if (chEq(key, 'g')) cur = gotoFirst() else if (chEq(key, 'e')) cur = gotoLast(lines) else if (chEq(key, 'h')) cur = lineStart(cur) else if (chEq(key, 'l')) cur = lineEnd(lines, cur) else return null; // unknown continuation: consume, no-op
- st.cur = cur;
- st.msel_active = false;
- return null;
- }
- if (p == 'z') {
- if (chEq(key, 't')) {
- view.scroll.* = cur.row;
- } else if (chEq(key, 'z')) {
- view.scroll.* = @intCast(@max(0, @as(i32, @intCast(cur.row)) - @as(i32, @intCast(view.rows / 2))));
- } else if (chEq(key, 'b')) {
- view.scroll.* = @intCast(@max(0, @as(i32, @intCast(cur.row)) - @as(i32, @intCast(view.rows)) + 1));
- } else return null;
- st.msel_active = false;
- return null;
- }
- }
- if (chEq(key, 'g')) {
- st.pending = 'g';
- return null;
- }
- if (chEq(key, 'z')) {
- st.pending = 'z';
- return null;
- }
-
- // motions -> move cursor, collapse selection, keep visible
- var moved: ?Cursor = null;
- if (chEq(key, 'h') or tagOf(key) == .left) moved = charLeft(cur) else if (chEq(key, 'l') or tagOf(key) == .right) moved = charRight(lines, cur) else if (chEq(key, 'j') or tagOf(key) == .down) moved = lineDown(lines, cur) else if (chEq(key, 'k') or tagOf(key) == .up) moved = lineUp(lines, cur) else if (chEq(key, 'w')) moved = nextWordStart(lines, cur, false) else if (chEq(key, 'W')) moved = nextWordStart(lines, cur, true) else if (chEq(key, 'b')) moved = prevWordStart(lines, cur, false) else if (chEq(key, 'B')) moved = prevWordStart(lines, cur, true) else if (chEq(key, 'e')) moved = nextWordEnd(lines, cur, false) else if (chEq(key, 'E')) moved = nextWordEnd(lines, cur, true) else if (chEq(key, '0') or tagOf(key) == .home) moved = lineStart(cur) else if (chEq(key, '$') or tagOf(key) == .end) moved = lineEnd(lines, cur) else if (chEq(key, '^')) moved = firstNonWsOf(lines, cur) else if (chEq(key, 'G')) moved = gotoLast(lines) else if (tagOf(key) == .half_down) moved = clampToChar(lines, pageDown(cur, @max(1, view.rows / 2))) else if (tagOf(key) == .half_up) moved = clampToChar(lines, pageUp(cur, @max(1, view.rows / 2))) else if (tagOf(key) == .page_down) moved = clampToChar(lines, pageDown(cur, view.rows)) else if (tagOf(key) == .page_up) moved = clampToChar(lines, pageUp(cur, view.rows));
- if (moved) |m| {
- st.cur = m;
- st.msel_active = false;
- st.pending = 0;
- ensureVisible(view, m.row);
- return null;
- }
-
- // insert entry
- if (chEq(key, 'i')) {
- st.mode = .insert;
- st.cur = cur;
- st.msel_active = false;
- st.pending = 0;
- return null;
- }
- if (chEq(key, 'a')) {
- st.mode = .insert;
- st.cur = charRight(lines, cur);
- st.msel_active = false;
- st.pending = 0;
- return null;
- }
- if (chEq(key, 'I')) {
- st.mode = .insert;
- st.cur = firstNonWsOf(lines, cur);
- st.msel_active = false;
- st.pending = 0;
- return null;
- }
- if (chEq(key, 'A')) {
- st.mode = .insert;
- st.cur = .{ .row = cur.row, .col = lineLenOf(lines, cur.row) };
- st.msel_active = false;
- st.pending = 0;
- return null;
- }
- if (chEq(key, 'o')) {
- const new = try insertAt(alloc, content, .{ .row = cur.row, .col = lineLenOf(lines, cur.row) }, "\n");
- st.cur = .{ .row = cur.row + 1, .col = 0 };
- st.mode = .insert;
- st.msel_active = false;
- st.pending = 0;
- ensureVisible(view, st.cur.row);
- return new;
- }
- if (chEq(key, 'O')) {
- const new = try insertAt(alloc, content, .{ .row = cur.row, .col = 0 }, "\n");
- st.cur = .{ .row = cur.row, .col = 0 };
- st.mode = .insert;
- st.msel_active = false;
- st.pending = 0;
- ensureVisible(view, st.cur.row);
- return new;
- }
-
- // select / edit
- if (chEq(key, 'x')) {
- lineSelectFile(st, lines);
- return null;
- }
- if (chEq(key, 'd')) return try deleteFile(alloc, content, st, lines, yank);
- if (chEq(key, 'c')) return try changeFile(alloc, content, st, lines, yank);
- if (chEq(key, 'y')) {
- try yankFile(alloc, content, st, yank);
- return null;
- }
- if (chEq(key, 'p')) return try pasteFile(alloc, content, st, yank);
-
- // Enter / other keys: no-op on a file (no tty mode)
- return null;
-}
-
// ---- tests ----
-const testEq = struct {
- fn cur(lines: []const []const u8, s: []const u8) Cursor {
- var row: usize = 0;
- for (lines) |ln| {
- if (std.mem.eql(u8, ln, s)) return .{ .row = row, .col = 0 };
- row += 1;
- }
- unreachable;
- }
-};
-
test "kindOf" {
try std.testing.expectEqual(Kind.word, kindOf('a'));
try std.testing.expectEqual(Kind.word, kindOf('_'));
diff --git a/src/output_pane.zig b/src/output_pane.zig
index f14d9a51..ecb6f957 100644
--- a/src/output_pane.zig
+++ b/src/output_pane.zig
@@ -18,16 +18,20 @@ const std = @import("std");
const pardes = @import("pardes.zig");
const Pardes = pardes.Pardes;
const Pane = pardes.Pane;
-const File = pardes.File;
const file_pane = @import("file_pane.zig");
const modal = @import("modal.zig");
const builtins = @import("builtins.zig");
+const runtime_config = @import("runtime_config.zig");
+const effect_sources = @import("effect_sources.zig");
const Builtin = builtins.registry.Builtin();
const config = @import("config.zig");
+const dump = @import("dump.zig");
const lsp = @import("lsp/lsp.zig");
+const gui_shader_source_mode = effect_sources.guiShaderSourceMode();
+
/// the installed fonts, for openFonts. GUI only, behind the same comptime
/// branch builtins.zig imports it through — see the note there.
-const fonts = if (pardes.font_picker) @import("fonts.zig") else struct {};
+const fonts = if (builtins.capabilities.font_picker) @import("fonts.zig") else struct {};
/// What opened this buffer — THE field, and the only input to `traits`.
///
@@ -45,83 +49,35 @@ pub const Origin = union(enum) {
search,
};
-/// The cap on a recorded argument, matching the one lspRequest already puts on
-/// an effect payload. ponytail: a longer pattern is TRUNCATED here, because
-/// `arg` is a record of what was asked and never the text anything re-runs; if
-/// something ever re-runs it, this becomes gpa-owned like `content`.
-pub const max_arg = 128;
+/// The exact command identity. A search prompt is bounded by the same one-line
+/// cap as a tag, so retaining that whole bound keeps refill and dump/restore
+/// identity exact without adding a per-output allocation.
+pub const max_arg = dump.max_origin_arg;
-/// An output buffer's own state, hung off `File.output`.
+/// An output buffer's own state, hung off `file_pane.State.output`.
pub const Output = struct {
from: Origin,
/// the command's ARGUMENT: the pattern a Grep matched, the new name a
- /// Rename took, the SPC prefix a Help lists. Inline rather than allocated
- /// — it is a fixed-size record, and an owned slice would buy a free in
- /// deinitPane and an errdefer at every open site for nothing.
+ /// Rename took, the SPC prefix a Help lists. Inline rather than allocated:
+ /// tag input already enforces this exact cap.
arg_buf: [max_arg]u8 = undefined,
- arg_len: u8 = 0,
+ arg_len: u16 = 0,
pub fn arg(o: *const Output) []const u8 {
return o.arg_buf[0..o.arg_len];
}
};
-pub fn setArg(o: *Output, text: []const u8) void {
- o.arg_len = @intCast(@min(text.len, max_arg));
- @memcpy(o.arg_buf[0..o.arg_len], text[0..o.arg_len]);
+pub fn setArg(o: *Output, text: []const u8) error{ArgumentTooLong}!void {
+ if (text.len > max_arg) return error.ArgumentTooLong;
+ o.arg_len = @intCast(text.len);
+ @memcpy(o.arg_buf[0..o.arg_len], text);
}
-/// Every way an output pane differs from a file pane. One struct so the
-/// QUESTIONS are visible even where today every buffer answers alike: a column
-/// that never varies still says "this is decided here", which is what makes
-/// the next kind of buffer a row rather than a hunt.
-pub const Traits = struct {
- /// the buffer's name (`+Search`...). DERIVED from the command, never the
- /// thing that identifies it — that inversion is what this file undoes.
- name: []const u8,
- /// n/N walk the rows: each is a `path:LINE:COL text` location the ordinary
- /// look path resolves, so the buffer IS helix's picker. Prose (a hover
- /// blurb, a formatting diff) has nowhere to step to.
- ///
- /// It is also what makes a step ROW-GRAINED (Grain below): one stop per
- /// row, on the location at its head. A pane whose lines are free text —
- /// a terminal, a file, a PDF, and the prose buffers here — steps every
- /// look-able word instead, several to a line.
- steps: bool = false,
- /// ...and WHAT THE ROWS ARE. Off, each is a LOCATION with a look-able
- /// `path:LINE:COL` word inside it. On, each is a COMMAND LINE — the whole
- /// row, exactly as you would have typed it (ThemeSel's `Theme gruvbox`) —
- /// with no path in it to pick out.
- ///
- /// TWO readers, one fact, which is why the column is named for the fact:
- /// n/N (lookWalk) select the location at the head of a location row,
- /// and THE WHOLE LINE of a command row, since the line
- /// is the unit there. Either way they only select; Enter
- /// looks what they left, Tab runs it.
- /// searchStep Looks a location row's leading word and Execs a
- /// command row whole — still how `]d`/`[d` and acme's
- /// button-3 arrive somewhere in one gesture.
- /// Both go through the ordinary builtins (config.look_cmd / exec_cmd), so
- /// a row does exactly what the matching mouse button on it would.
- ///
- /// Nothing about this is output-pane specific, which is why it is a column
- /// here and not a branch in either reader: `file_row` answers it too, so a
- /// file pane whose lines happen to be commands is one word away from
- /// behaving the same.
- commands: bool = false,
- /// an answer of exactly ONE row jumps straight there instead of opening
- /// this buffer at all — helix: the gotos jump on a single location and
- /// show a picker on several, a symbol list is always a picker.
- jumps: bool = false,
- /// a DOCUMENT for layout purposes: claims a column of its own, is a split
- /// parent, pays for a split. A result list is not — it belongs to the pane
- /// that asked for it, lands directly below it and takes its rows from
- /// there, so opening or closing one never resizes a bystander.
- doc: bool = false,
- /// there is a file behind it to write. Also what its tag says: no Save to
- /// offer means the plain pane tail rather than the file one.
- saves: bool = false,
-};
+/// Every way an output pane differs from a file pane. Manual builtins already
+/// declare this exact row beside their implementation; use that schema here
+/// too instead of copying it into a parallel struct.
+pub const Traits = builtins.OutputTraits;
/// A REAL file pane, as a row of the same table — so kind-agnostic code asks
/// one question and gets one answer whichever it is holding. `name` is unused:
@@ -135,16 +91,7 @@ pub fn traits(o: Origin) Traits {
return switch (o) {
// rows are `location text`, so n/N walk them
.search => .{ .name = config.search_buffer, .steps = true },
- .cmd => |b| blk: {
- const meta = builtins.registry.outputTraits(b) orelse
- break :blk .{ .name = config.search_buffer, .steps = true };
- break :blk .{
- .name = meta.name,
- .steps = meta.steps,
- .jumps = meta.jumps,
- .commands = meta.commands,
- };
- },
+ .cmd => |b| builtins.registry.outputTraits(b) orelse unreachable,
.query => |k| switch (k) {
.hover => .{ .name = config.hover_buffer },
// prose: an action list, a diff, a report about the backend
@@ -218,7 +165,8 @@ pub fn word(o: Origin) []const u8 {
pub fn fromWord(w: []const u8) ?Origin {
if (w.len == 0) return null;
if (std.mem.eql(u8, w, "/")) return .search;
- if (std.meta.stringToEnum(Builtin, w)) |b| return .{ .cmd = b };
+ if (std.meta.stringToEnum(Builtin, w)) |b|
+ if (builtins.registry.outputTraits(b) != null) return .{ .cmd = b };
if (std.meta.stringToEnum(lsp.Kind, w)) |k| return .{ .query = k };
return null;
}
@@ -243,11 +191,10 @@ pub fn open(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u
std.mem.trimEnd(u8, dir, "/"), traits(from).name,
});
errdefer p.gpa.free(path);
- const pane = try p.newDocPane(id);
var out: Output = .{ .from = from };
- setArg(&out, arg);
+ try setArg(&out, arg);
+ const pane = try p.newDocPane(id);
pane.file = .{ .path = path, .content = content, .output = out };
- pane.kind = .file;
pane.cur_pinned = true;
return pane;
}
@@ -285,7 +232,7 @@ pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []
if (!std.meta.eql(o.from, from)) continue;
if (by_arg and !std.mem.eql(u8, o.arg(), arg)) continue;
if (!std.mem.eql(u8, std.fs.path.dirname(rf.path) orelse "", dir)) continue;
- setArg(o, arg);
+ try setArg(o, arg);
// a refill that changes NOTHING keeps its place: a right click on an
// already-armed word is an `n`, and throwing the list back to the top
// only to scroll down to the stepped row is a jump with no information
@@ -297,6 +244,7 @@ pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []
if (traits(from).steps) {
pane.search_pane = i;
pane.search_row = anchor;
+ p.armLookWalk(i);
}
return;
}
@@ -310,14 +258,10 @@ pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []
if (traits(from).steps) {
pane.search_pane = free;
pane.search_row = anchor;
+ p.armLookWalk(free);
}
}
-fn putPrint(dst: []u8, at: *usize, comptime fmt: []const u8, args: anytype) void {
- const text = std.fmt.bufPrint(dst[at.*..], fmt, args) catch unreachable;
- at.* += text.len;
-}
-
/// The Jumplist builtin: the focus history (Pardes.jumps) written out as text,
/// one row per location, oldest first — the same `location text` shape every
/// results buffer here has, which is what buys n/N stepping and Look-on-a-row
@@ -336,35 +280,8 @@ fn putPrint(dst: []u8, at: *usize, comptime fmt: []const u8, args: anytype) void
/// content line for anything holding text, else the pane's directory: enough
/// to recognise the place without opening it.
pub fn openJumps(p: *Pardes, id: usize) !void {
- const arena = p.scratch.allocator();
- var len: usize = 0;
- for (p.jumps[0..p.njumps]) |j| {
- const jp = p.panes[j.pane] orelse continue;
- var idbuf: [16]u8 = undefined;
- const pdf_path: ?[]const u8 = if (comptime pardes.pdf_enabled) jp.pdfPath() else null;
- const has_path = if (jp.file) |f| f.output == null else pdf_path != null;
- const loc: []const u8 = if (has_path)
- (if (jp.file) |f| f.path else pdf_path.?)
- else
- std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{j.pane}) catch unreachable;
- const what: []const u8 = if (jp.file) |f|
- std.mem.trim(u8, modal.lineSlice(f.content, j.line -| 1), " \t\r")
- else if (jp.image) |iv|
- iv.path
- else if (pdf_path) |path|
- path
- else
- jp.cwdSlice();
- var cut = @min(what.len, 120);
- while (cut > 0 and cut < what.len and what[cut] & 0xc0 == 0x80) cut -= 1;
- len += if (j.line == 0)
- std.fmt.count("{s} {s}\n", .{ loc, what[0..cut] })
- else
- std.fmt.count("{s}:{d}:{d} {s}\n", .{ loc, j.line, j.col, what[0..cut] });
- }
-
- const out = try arena.alloc(u8, len);
- var at: usize = 0;
+ var out: std.Io.Writer.Allocating = .init(p.gpa);
+ errdefer out.deinit();
for (p.jumps[0..p.njumps]) |j| {
const jp = p.panes[j.pane] orelse continue;
var idbuf: [16]u8 = undefined;
@@ -385,12 +302,12 @@ pub fn openJumps(p: *Pardes, id: usize) !void {
var cut = @min(what.len, 120);
while (cut > 0 and cut < what.len and what[cut] & 0xc0 == 0x80) cut -= 1;
if (j.line == 0)
- putPrint(out, &at, "{s} {s}\n", .{ loc, what[0..cut] })
+ try out.writer.print("{s} {s}\n", .{ loc, what[0..cut] })
else
- putPrint(out, &at, "{s}:{d}:{d} {s}\n", .{ loc, j.line, j.col, what[0..cut] });
+ try out.writer.print("{s}:{d}:{d} {s}\n", .{ loc, j.line, j.col, what[0..cut] });
}
- std.debug.assert(at == out.len);
- try openStepped(p, id, .{ .cmd = .Jumplist }, out);
+ const content = try out.toOwnedSlice();
+ try openStepped(p, id, .{ .cmd = .Jumplist }, content);
}
/// The ThemeSel builtin: the theme ring written out as one `Theme <name>` row
@@ -400,59 +317,49 @@ pub fn openJumps(p: *Pardes, id: usize) !void {
/// walking the list is trying the themes on and stopping on one is choosing
/// it: no picker mode, no preview state, nothing to commit or cancel.
///
-/// The command's own name comes from the builtin rather than a literal: the
-/// word is derived from the struct in exactly one place (builtins.word), and a
-/// rename there must not leave rows here that name something that is gone.
+/// The command's own name comes from the runtime setting descriptor rather
+/// than a second literal. The descriptor generates the builtin too, so a
+/// rename cannot leave picker rows naming a command that is gone.
pub fn openThemes(p: *Pardes, id: usize) !void {
- const arena = p.scratch.allocator();
- var len: usize = 0;
+ var out: std.Io.Writer.Allocating = .init(p.gpa);
+ errdefer out.deinit();
for (pardes.themes) |t|
- len += std.fmt.count(comptime builtins.word(builtins.Theme) ++ " {s}\n", .{t.name});
- const out = try arena.alloc(u8, len);
- var at: usize = 0;
- for (pardes.themes) |t|
- putPrint(out, &at, comptime builtins.word(builtins.Theme) ++ " {s}\n", .{t.name});
- std.debug.assert(at == out.len);
- try openStepped(p, id, .{ .cmd = .ThemeSel }, out);
+ try out.writer.print(comptime runtime_config.findAction(.theme).?.word ++ " {s}\n", .{t.name});
+ const content = try out.toOwnedSlice();
+ try openStepped(p, id, .{ .cmd = .ThemeSel }, content);
}
/// The FontSel builtin: openThemes over the fonts installed on the machine
/// instead of the themes compiled into the binary — one `Font <name>` row
/// each, in a buffer whose rows n/N RUN, so walking it wears the fonts and
/// stopping picks one. Everything that makes that work is already above; this
-/// is the same eight lines pointed at a different list.
+/// is the same one-pass writer pointed at a different list.
///
-/// Only where the shell draws its own text, and the body says so rather than
-/// the signature: fonts.list and the FontSel origin both exist only there, and
-/// a comptime-false `if` is what keeps the tty build from analysing either.
-/// The dead parameters on that build are the honest shape of "this platform
-/// cannot open one".
+/// Only where the shell draws its own text. The same `font_picker` availability
+/// bit that generates the Font/FontSel builtins keeps non-GUI builds from
+/// analysing font discovery here.
pub fn openFonts(p: *Pardes, id: usize) !void {
- if (pardes.font_picker) {
+ if (builtins.capabilities.font_picker) {
const arena = p.scratch.allocator();
const font_list = fonts.list(arena, null);
- var len: usize = 0;
- for (font_list) |f|
- len += std.fmt.count(comptime builtins.word(builtins.Font) ++ " {s}\n", .{f.name});
- const out = try arena.alloc(u8, len);
- var at: usize = 0;
+ var out: std.Io.Writer.Allocating = .init(p.gpa);
+ errdefer out.deinit();
for (font_list) |f|
- putPrint(out, &at, comptime builtins.word(builtins.Font) ++ " {s}\n", .{f.name});
- std.debug.assert(at == out.len);
- try openStepped(p, id, .{ .cmd = .FontSel }, out);
+ try out.writer.print(comptime runtime_config.findAction(.font).?.word ++ " {s}\n", .{f.name});
+ const content = try out.toOwnedSlice();
+ try openStepped(p, id, .{ .cmd = .FontSel }, content);
}
}
/// Open a buffer n/N will walk, and arm them on it: the shared tail of every
-/// builtin that answers with a list. `text` is borrowed (the callers build it
-/// in the scratch arena) and copied into a gpa buffer the pane adopts.
+/// builtin that answers with a list. `content` is gpa-owned and adopted by the
+/// new pane, or freed here if opening it fails.
///
/// Focus stays with the pane that ASKED, exactly as it does after a search:
/// n/N are read there, and they step the buffer they just armed.
-fn openStepped(p: *Pardes, id: usize, from: Origin, text: []const u8) !void {
- const pane = p.panes[id] orelse return error.MissingPane;
- const content = try p.gpa.dupe(u8, text);
+fn openStepped(p: *Pardes, id: usize, from: Origin, content: []u8) !void {
errdefer p.gpa.free(content);
+ const pane = p.panes[id] orelse return error.MissingPane;
const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice();
const free = p.freeSlot() orelse return error.NoPaneSlots;
const np = try open(p, free, dir, from, "", content);
@@ -460,6 +367,7 @@ fn openStepped(p: *Pardes, id: usize, from: Origin, text: []const u8) !void {
p.active = id;
pane.search_pane = free;
pane.search_row = null;
+ p.armLookWalk(free);
}
/// The Help builtin: THE INDEX of builtins — every one of them, and every way
@@ -474,7 +382,7 @@ fn openStepped(p: *Pardes, id: usize, from: Origin, text: []const u8) !void {
/// SPC cannot reach at all, so the reference page IS the filter's degenerate
/// case. A second builtin would have been a second renderer over a superset of
/// these rows, and the two would have drifted the first time a column moved.
-pub fn openHelp(p: *Pardes, id: usize, prefix: []const u8) !void {
+fn helpContent(gpa: std.mem.Allocator, prefix: []const u8) ![]u8 {
const full_header = "pardes builtins, and how to run each:\nSPC and its keys, a chord, a button, the\ntopbar - or the name, executed anywhere.\n\n";
const group_header = "pardes builtins under SPC";
var len: usize = if (prefix.len == 0)
@@ -487,7 +395,7 @@ pub fn openHelp(p: *Pardes, id: usize, prefix: []const u8) !void {
if (!std.mem.startsWith(u8, row.path orelse "", prefix)) continue;
len += row.line.len + 1;
}
- const content = try p.gpa.alloc(u8, len);
+ const content = try gpa.alloc(u8, len);
var at: usize = 0;
if (prefix.len == 0) {
@memcpy(content[0..full_header.len], full_header);
@@ -512,29 +420,88 @@ pub fn openHelp(p: *Pardes, id: usize, prefix: []const u8) !void {
at += 1;
}
std.debug.assert(at == content.len);
+ return content;
+}
+
+pub fn openHelp(p: *Pardes, id: usize, prefix: []const u8) !void {
+ const content = try helpContent(p.gpa, prefix);
// content is handed off unfreed on purpose: openRead adopts it or frees
// it, and nothing between the alloc above and this line can fail.
return openRead(p, id, .{ .cmd = .Help }, prefix, content);
}
-/// The Config builtin: WHERE the startup config file is, as one line of text.
-///
-/// The PATH and not the file. `Look` on the line opens it when it exists, and
-/// when it does not the path is still the entire answer — "put your Theme and
-/// Font lines HERE" is the question this is asked, and a builtin that opened
-/// an empty buffer instead would have said nothing. The core never resolved
-/// it: the launcher did, before init (Options.startup_config_path), so this
-/// prints what was actually consulted rather than recomputing a guess that
-/// could differ from it.
+test "full Help renders every enabled builtin row" {
+ const content = try helpContent(std.testing.allocator, "");
+ defer std.testing.allocator.free(content);
+
+ const body = content[(std.mem.lastIndexOf(u8, content, "\n\n") orelse
+ return error.MissingHelpHeader) + 2 ..];
+ var lines = std.mem.splitScalar(u8, body, '\n');
+ for (pardes.builtin_rows) |row|
+ try std.testing.expectEqualStrings(row.line, lines.next() orelse
+ return error.MissingBuiltinHelpRow);
+ // The renderer terminates every row with a newline, so only split's empty
+ // trailing field may remain. Any extra non-empty field is an unregistered
+ // Help row and any missing row already failed in the loop above.
+ try std.testing.expectEqualStrings("", lines.next() orelse
+ return error.MissingHelpTerminator);
+ try std.testing.expect(lines.next() == null);
+}
+
+/// The complete live Config report: the generated settings and the host facts
+/// needed to interpret them. The startup path remains ordinary selectable text
+/// in the report, so Look still opens the exact file the launcher consulted.
pub fn openConfig(p: *Pardes, id: usize) !void {
- const content = if (p.opts.startup_config_path) |path|
- try std.fmt.allocPrint(p.gpa, "{s}\n", .{path})
- else
- // the browser, and a native launch with no HOME to build one from
- try p.gpa.dupe(u8, "no per-user config path\n");
+ var out: std.Io.Writer.Allocating = .init(p.gpa);
+ errdefer out.deinit();
+ try runtime_config.writeReport(&out.writer, .{
+ .startup_config_path = p.opts.startup_config_path,
+ .platform = @tagName(pardes.platform),
+ .theme_name = p.theme().name,
+ .compiled_default_shell = config.default_shell,
+ .gui_shader_source_mode = if (gui_shader_source_mode) |mode|
+ mode.label()
+ else
+ null,
+ .hover_delay_frames = config.look_preview_delay_frames,
+ .native_images = p.native_images,
+ .capabilities = builtins.capabilities,
+ .state = &p.settings,
+ });
+ const content = try out.toOwnedSlice();
return openRead(p, id, .{ .cmd = .Config }, "", content);
}
+/// Print the implementation that this build actually uses for one effect.
+/// Sources are build inputs embedded as bytes, so this stays useful from an
+/// installed binary with no repository beside it.
+pub fn openEffectCode(p: *Pardes, id: usize, argument: []const u8) !void {
+ const name = std.mem.trim(u8, argument, " \t\r\n");
+ const setting = runtime_config.find(name) orelse return error.UnknownEffect;
+ switch (setting.action) {
+ .transition, .scene => {},
+ else => return error.NotAnEffect,
+ }
+ if (!setting.enabled(builtins.capabilities)) return error.EffectUnavailable;
+ const segments = effect_sources.forSetting(setting) orelse
+ return error.EffectUnavailable;
+
+ var out: std.Io.Writer.Allocating = .init(p.gpa);
+ errdefer out.deinit();
+ try out.writer.print("EffectCode {s} ({s})\n", .{ setting.word, @tagName(effect_sources.backend) });
+ if (gui_shader_source_mode) |mode|
+ try out.writer.print("GUI shader source: {s}\n", .{mode.label()});
+ try out.writer.writeByte('\n');
+ for (segments) |segment| {
+ try out.writer.print("--- {s} ---\n", .{segment.path});
+ try out.writer.writeAll(segment.source);
+ if (!std.mem.endsWith(u8, segment.source, "\n")) try out.writer.writeByte('\n');
+ try out.writer.writeByte('\n');
+ }
+ const content = try out.toOwnedSlice();
+ return openRead(p, id, .{ .cmd = .EffectCode }, setting.word, content);
+}
+
/// Open a buffer you READ, and go there: the shared tail of every builtin
/// whose answer is a document rather than a list. Asking again REFRESHES the
/// one already open instead of stacking a twin beside it — found by its
@@ -555,8 +522,8 @@ fn openRead(p: *Pardes, id: usize, from: Origin, arg: []const u8, content: []u8)
const hf = if (hp.file) |*f| f else continue;
const ho = if (hf.output) |*o| o else continue;
if (!std.meta.eql(ho.from, from)) continue;
+ try setArg(ho, arg);
file_pane.setContent(p, hf, content);
- setArg(ho, arg);
hf.scroll = 0;
hp.cur_row = 0;
hp.msel.active = false;
@@ -569,3 +536,77 @@ fn openRead(p: *Pardes, id: usize, from: Origin, arg: []const u8, content: []u8)
p.placeDoc(id, free, np);
p.active = free;
}
+
+test "dump origins accept only builtins that actually own output panes" {
+ try std.testing.expectEqual(Origin{ .cmd = .Help }, fromWord("Help").?);
+ try std.testing.expect(fromWord("Kill") == null);
+ try std.testing.expect(fromWord("Theme") == null);
+}
+
+test "result refill identity retains the full bounded argument" {
+ const p = try Pardes.init(std.testing.allocator, .{
+ .tty_only = true,
+ .cols = 80,
+ .rows = 24,
+ });
+ defer p.deinit();
+
+ var first: [max_arg]u8 = @splat('a');
+ var second = first;
+ first[200] = 'x';
+ second[200] = 'y';
+
+ try fillResults(
+ p,
+ 0,
+ "/tmp",
+ .search,
+ &first,
+ try p.gpa.dupe(u8, "first\n"),
+ null,
+ );
+ const first_id = p.panes[0].?.search_pane orelse return error.MissingResults;
+ const next_slot = p.freeSlot();
+
+ try fillResults(
+ p,
+ 0,
+ "/tmp",
+ .search,
+ &first,
+ try p.gpa.dupe(u8, "refilled\n"),
+ null,
+ );
+ try std.testing.expectEqual(first_id, p.panes[0].?.search_pane.?);
+ try std.testing.expectEqual(next_slot, p.freeSlot());
+ try std.testing.expectEqualStrings("refilled\n", p.panes[first_id].?.file.?.content);
+
+ try fillResults(
+ p,
+ 0,
+ "/tmp",
+ .search,
+ &second,
+ try p.gpa.dupe(u8, "second\n"),
+ null,
+ );
+ try std.testing.expect(p.panes[0].?.search_pane.? != first_id);
+
+ var output: Output = .{ .from = .search };
+ var oversized: [max_arg + 1]u8 = @splat('z');
+ try std.testing.expectError(error.ArgumentTooLong, setArg(&output, &oversized));
+ const slot_before_error = p.freeSlot();
+ try std.testing.expectError(
+ error.ArgumentTooLong,
+ fillResults(
+ p,
+ 0,
+ "/tmp",
+ .search,
+ &oversized,
+ try p.gpa.dupe(u8, "must be freed\n"),
+ null,
+ ),
+ );
+ try std.testing.expectEqual(slot_before_error, p.freeSlot());
+}
diff --git a/src/output_pane_integration_test.zig b/src/output_pane_integration_test.zig
new file mode 100644
index 00000000..25acbbbd
--- /dev/null
+++ b/src/output_pane_integration_test.zig
@@ -0,0 +1,336 @@
+//! End-to-end output-pane tests. Production output identity, rendering, and
+//! builders stay in output_pane.zig; this module exercises their direct seam
+//! with Pardes builtins and the generic n/N input walk.
+const std = @import("std");
+const pardes = @import("pardes.zig");
+const output_pane = @import("output_pane.zig");
+const runtime_config = @import("runtime_config.zig");
+const builtins = @import("builtins.zig");
+const config = @import("config.zig");
+const modal = @import("modal.zig");
+
+const Pardes = pardes.Pardes;
+const Key = pardes.Key;
+const platform = pardes.platform;
+const font_picker = pardes.font_picker;
+const fonts = if (font_picker) @import("fonts.zig") else struct {};
+
+test "Config prints the startup path and refreshes its one output" {
+ const path = "/home/pardes-test/.config/pardes";
+ const p = try Pardes.init(std.testing.allocator, .{ .startup_config_path = path });
+ defer p.deinit();
+
+ try std.testing.expect(p.executeBuiltinLine(0, "Config"));
+ const opened = p.active;
+ const out = p.panes[opened].?.file.?;
+ for ([_][]const u8{
+ "Startup config: " ++ path ++ "\n",
+ "Theme: helix\n",
+ "Shell requested (new panes): " ++ config.default_shell ++ " (default)\n",
+ "Shell effective (last spawn): (none)\n",
+ "Shell pending: on\n",
+ }) |line| try std.testing.expect(std.mem.indexOf(u8, out.content, line) != null);
+ try std.testing.expectEqualStrings(config.config_buffer, std.fs.path.basename(out.path));
+ try std.testing.expectEqual(output_pane.Origin{ .cmd = .Config }, out.output.?.from);
+
+ try std.testing.expect(p.executeBuiltinLine(0, "Config"));
+ try std.testing.expectEqual(opened, p.active);
+ var buffers: usize = 0;
+ for (p.panes) |slot| {
+ const f = (slot orelse continue).file orelse continue;
+ const origin = (f.output orelse continue).from;
+ buffers += @intFromBool(std.meta.eql(origin, output_pane.Origin{ .cmd = .Config }));
+ }
+ try std.testing.expectEqual(@as(usize, 1), buffers);
+}
+
+test "Config reports the absence of a per-user config path" {
+ const p = try Pardes.init(std.testing.allocator, .{});
+ defer p.deinit();
+ try std.testing.expect(p.executeBuiltinLine(0, "Config"));
+ const report = p.panes[p.active].?.file.?.content;
+ try std.testing.expect(std.mem.indexOf(u8, report, "no per-user config path") != null);
+}
+
+test "EffectCode opens the embedded implementation used by this backend" {
+ if (comptime platform == .web) return;
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+
+ try std.testing.expect(p.executeBuiltinLine(0, "EffectCode PanelSlide"));
+ const out = p.panes[p.active].?.file.?;
+ try std.testing.expectEqualStrings(config.effect_code_buffer, std.fs.path.basename(out.path));
+ try std.testing.expectEqual(output_pane.Origin{ .cmd = .EffectCode }, out.output.?.from);
+ try std.testing.expectEqualStrings("PanelSlide", out.output.?.arg());
+ try std.testing.expect(std.mem.indexOf(u8, out.content, "pub const Transition = enum") != null);
+ const backend_source = switch (platform) {
+ .tty => "src/tty/panel_compositor.zig",
+ .gui => if (@import("pardes_config").gui_shader_sources_prebuilt)
+ "shaders/prebuilt/ui.vert.glsl"
+ else
+ "shaders/ui.vert.glsl",
+ .macos => "src/macos/Sources/ScenePostprocessor.swift",
+ .web => unreachable,
+ };
+ try std.testing.expect(std.mem.indexOf(u8, out.content, backend_source) != null);
+}
+
+test "every enabled setting builtin mutates the State Config reports" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ try std.testing.expectEqual(config.gui_tagline_font_percent, p.settings.font.tagline_percent);
+
+ var font_arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer font_arena.deinit();
+ var chosen_font: ?[]const u8 = null;
+ var buf: [512]u8 = undefined;
+ for (runtime_config.settings) |setting| {
+ if (!setting.enabled(builtins.capabilities)) continue;
+ const command: []const u8 = switch (setting.action) {
+ .theme => try std.fmt.bufPrint(&buf, "{s} acme", .{setting.word}),
+ .shell => try std.fmt.bufPrint(&buf, "{s} fish", .{setting.word}),
+ .tagline_size => try std.fmt.bufPrint(&buf, "{s} 67", .{setting.word}),
+ .font => continue,
+ else => setting.word,
+ };
+ try std.testing.expect(p.executeBuiltinLine(p.active, command));
+ }
+ if (comptime font_picker) {
+ const before_invalid = p.settings.font.tagline_percent;
+ try std.testing.expect(p.executeBuiltinLine(p.active, "TaglineSize 0"));
+ try std.testing.expectEqual(before_invalid, p.settings.font.tagline_percent);
+ const installed = fonts.list(font_arena.allocator(), null);
+ if (installed.len > 0) {
+ chosen_font = installed[0].name;
+ const command = try std.fmt.bufPrint(&buf, "Font {s}", .{installed[0].name});
+ try std.testing.expect(p.executeBuiltinLine(p.active, command));
+ }
+ }
+
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Config"));
+ const report = p.panes[p.active].?.file.?.content;
+ for ([_][]const u8{
+ "Colors: off\n",
+ "Wrap: off\n",
+ "Tagbottom: on\n",
+ "Debug: on\n",
+ "Theme: acme\n",
+ "Shell requested (new panes): fish\n",
+ }) |line| try std.testing.expect(std.mem.indexOf(u8, report, line) != null);
+ const transition = if (builtins.capabilities.panel_transitions)
+ try std.fmt.bufPrint(&buf, "Panel transition: {s}\n", .{
+ runtime_config.findAction(.{ .transition = p.settings.panel_transition }).?.word,
+ })
+ else
+ "Panel transition: unsupported\n";
+ try std.testing.expect(std.mem.indexOf(u8, report, transition) != null);
+ const scene_status = if (builtins.capabilities.scene_shaders) "on" else "unsupported";
+ for ([_][]const u8{ "Crt", "Ripple", "Glitch" }) |name| {
+ const line = try std.fmt.bufPrint(&buf, "{s}: {s}\n", .{ name, scene_status });
+ try std.testing.expect(std.mem.indexOf(u8, report, line) != null);
+ }
+ const tagline = if (!builtins.capabilities.tagline_font_size)
+ "TaglineSize: unsupported\n"
+ else
+ try std.fmt.bufPrint(&buf, "TaglineSize: {d}%{s}\n", .{
+ p.settings.font.tagline_percent,
+ if (builtins.capabilities.font_picker) "" else " (build-time only)",
+ });
+ try std.testing.expect(std.mem.indexOf(u8, report, tagline) != null);
+ if (chosen_font) |name| {
+ try std.testing.expect(std.mem.indexOf(u8, report, name) != null);
+ try std.testing.expect(std.mem.indexOf(u8, report, "Font pending: on\n") != null);
+ }
+}
+
+fn walkFixture(p: *Pardes, id: usize, cwd: []const u8, pattern: []const u8) !usize {
+ const rows = try p.gpa.dupe(u8,
+ \\build.zig:1:1 first
+ \\(mise.toml) and build.zig.zon:3:2-9 two on one row
+ \\nothing look-able on this row at all
+ \\uucode_config.zig:7:1 last
+ \\
+ );
+ try output_pane.fillResults(p, id, cwd, .search, pattern, rows, null);
+ return p.panes[id].?.search_pane orelse error.MissingResults;
+}
+
+const ProjectPaths = struct { cwd: []const u8, boot: []const u8 };
+
+fn projectPaths(cwd_buf: *[4096]u8, path_buf: *[4096]u8) !ProjectPaths {
+ const raw = std.c.getcwd(cwd_buf, cwd_buf.len) orelse return error.GetCwdFailed;
+ const cwd = std.mem.span(@as([*:0]u8, @ptrCast(raw)));
+ return .{ .cwd = cwd, .boot = try std.fmt.bufPrint(path_buf, "{s}/mise.toml", .{cwd}) };
+}
+
+fn selectedOutputText(pane: *const pardes.Pane) ?[]const u8 {
+ const file = pane.file orelse return null;
+ if (!pane.vsel.active or pane.vsel.row != pane.cur_row or pane.cur_row < 0) return null;
+ const line = modal.lineSlice(file.content, @intCast(pane.cur_row));
+ const lo: usize = @intCast(@max(0, @min(pane.vsel.col, pane.cur_col)));
+ const hi: usize = @intCast(@max(0, @max(pane.vsel.col, pane.cur_col)));
+ if (lo >= line.len) return "";
+ return line[lo..@min(line.len, hi + 1)];
+}
+
+test "n/N selects one output location per row and opens nothing" {
+ if (platform == .web) return;
+ var cwd_buf: [4096]u8 = undefined;
+ var path_buf: [4096]u8 = undefined;
+ const paths = try projectPaths(&cwd_buf, &path_buf);
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 40, .file = paths.boot });
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 100, .rows = 40 } });
+ const rid = try walkFixture(p, p.active, paths.cwd, "one");
+ const results = p.panes[rid].?;
+ const panes_before = p.freeSlot();
+
+ p.update(.{ .key = .{ .cp = 'n' } });
+ try std.testing.expectEqual(rid, p.active);
+ try std.testing.expectEqual(panes_before, p.freeSlot());
+ try std.testing.expect(results.vsel.active and results.vsel.explicit);
+ try std.testing.expectEqualStrings("build.zig:1:1", selectedOutputText(results) orelse "");
+ try std.testing.expectEqual(output_pane.Grain.line, output_pane.grain(results.file.?.output));
+
+ p.update(.{ .key = .{ .cp = 'n' } });
+ try std.testing.expectEqual(@as(i32, 1), results.cur_row);
+ try std.testing.expectEqualStrings("mise.toml", selectedOutputText(results) orelse "");
+ p.update(.{ .key = .{ .cp = 'n' } });
+ try std.testing.expectEqual(@as(i32, 3), results.cur_row); // row 2 is not look-able
+ try std.testing.expectEqualStrings("uucode_config.zig:7:1", selectedOutputText(results) orelse "");
+ p.update(.{ .key = .{ .cp = 'n' } });
+ try std.testing.expectEqual(@as(i32, 0), results.cur_row); // ring seam
+
+ p.update(.{ .key = .{ .cp = Key.enter } });
+ try std.testing.expect(p.freeSlot() != panes_before);
+ try std.testing.expect(std.mem.endsWith(u8, p.panes[p.active].?.file.?.path, "/build.zig"));
+ try std.testing.expectEqual(output_pane.Grain.word, output_pane.grain(p.panes[p.active].?.file.?.output));
+}
+
+test "n/N resumes the result output whose Look moved focus away" {
+ if (platform == .web) return;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(std.testing.io, .{
+ .sub_path = "look-owner.txt",
+ .data = "alpha target\nbeta target\ngamma target\n",
+ });
+ var path_buf: [256]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/look-owner.txt", .{tmp.sub_path});
+ const p = try Pardes.init(std.testing.allocator, .{ .file = path, .cols = 80, .rows = 24 });
+ defer p.deinit();
+
+ try std.testing.expect(p.executeBuiltinLine(0, "Look target"));
+ const rid = p.panes[0].?.search_pane orelse return error.MissingResults;
+ const results = p.panes[rid].?;
+ const first = results.cur_row;
+ try std.testing.expectEqual(@as(usize, 0), p.active);
+ try std.testing.expect(results.look_at != null);
+ try std.testing.expectEqual(results.serial, p.look_walk_owner orelse return error.MissingLookOwner);
+
+ p.update(.{ .key = .{ .cp = 'n' } });
+ try std.testing.expectEqual(rid, p.active);
+ try std.testing.expectEqual(first + 1, results.cur_row);
+ p.update(.{ .key = .{ .cp = 'N' } });
+ try std.testing.expectEqual(first, results.cur_row);
+
+ // A later no-match answer has no position to resume and therefore cannot
+ // steal the provenance established by the Look above.
+ const owner = results.serial;
+ const dir = std.fs.path.dirname(path) orelse ".";
+ try output_pane.fillResults(
+ p,
+ 0,
+ dir,
+ .search,
+ "no-such-result",
+ try p.gpa.dupe(u8, ""),
+ null,
+ );
+ try std.testing.expectEqual(owner, p.look_walk_owner.?);
+ p.update(.{ .key = .{ .cp = 'n' } });
+ try std.testing.expectEqual(rid, p.active);
+ try std.testing.expectEqual(first + 1, results.cur_row);
+
+ // Emptying the owner itself keeps its identity as the ring's starting
+ // point. Deleting it then leaves a stale serial, never an id that can bind
+ // to the unrelated pane subsequently allocated in the same slot.
+ try output_pane.fillResults(p, 0, dir, .search, "target", try p.gpa.dupe(u8, ""), null);
+ try std.testing.expectEqual(owner, p.look_walk_owner.?);
+ try std.testing.expect(p.executeBuiltinLine(rid, "Del"));
+ try std.testing.expect(p.paneBySerial(owner) == null);
+ const replacement = try p.newShell(rid, "");
+ try std.testing.expect(replacement.serial != owner);
+ try std.testing.expect(p.paneBySerial(owner) == null);
+}
+
+test "N exactly reverses n across output panes and the ring seam" {
+ if (platform == .web) return;
+ var cwd_buf: [4096]u8 = undefined;
+ var path_buf: [4096]u8 = undefined;
+ const paths = try projectPaths(&cwd_buf, &path_buf);
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 40, .file = paths.boot });
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 100, .rows = 40 } });
+ const first = try walkFixture(p, p.active, paths.cwd, "one");
+ p.update(.{ .key = .{ .cp = 'n', .alt = true } });
+ const second = try walkFixture(p, p.active, paths.cwd, "two");
+ try std.testing.expect(first != second);
+
+ const Mark = struct { pane: usize, row: i32, col: i32 };
+ const here = struct {
+ fn at(pp: *Pardes) Mark {
+ const pane = pp.panes[pp.active].?;
+ return .{ .pane = pp.active, .row = pane.cur_row, .col = pane.cur_col };
+ }
+ }.at;
+ p.update(.{ .key = .{ .cp = 'n' } });
+ const base = here(p);
+ var trail: [12]Mark = undefined;
+ for (&trail) |*mark| {
+ p.update(.{ .key = .{ .cp = 'n' } });
+ mark.* = here(p);
+ }
+ var i = trail.len;
+ while (i > 0) {
+ i -= 1;
+ p.update(.{ .key = .{ .cp = 'N' } });
+ const want = if (i == 0) base else trail[i - 1];
+ try std.testing.expectEqual(want, here(p));
+ }
+ var saw_first = false;
+ var saw_second = false;
+ for (trail) |mark| {
+ saw_first = saw_first or mark.pane == first;
+ saw_second = saw_second or mark.pane == second;
+ }
+ try std.testing.expect(saw_first and saw_second);
+}
+
+test "n/N selects command outputs by whole row" {
+ if (platform == .web) return;
+ var cwd_buf: [4096]u8 = undefined;
+ var path_buf: [4096]u8 = undefined;
+ const paths = try projectPaths(&cwd_buf, &path_buf);
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 40, .file = paths.boot });
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 100, .rows = 40 } });
+
+ try std.testing.expect(p.executeBuiltinLine(p.active, "ThemeSel"));
+ const tid = p.panes[p.active].?.search_pane orelse return error.MissingThemeList;
+ const themes = p.panes[tid].?;
+ try std.testing.expect(output_pane.fileTraits(themes.file.?.output).commands);
+ p.update(.{ .key = .{ .cp = 'n' } });
+ const row0 = std.mem.trimEnd(u8, modal.lineSlice(themes.file.?.content, 0), " \t\r");
+ try std.testing.expectEqualStrings(row0, selectedOutputText(themes) orelse "");
+ const theme_before = p.settings.theme;
+ p.update(.{ .key = .{ .cp = 'n' } });
+ p.update(.{ .key = .{ .cp = Key.tab } });
+ try std.testing.expect(p.settings.theme != theme_before);
+
+ try std.testing.expect(p.executeBuiltinLine(0, "Look build.zig"));
+ p.update(.tick);
+ try std.testing.expect(p.active != 0);
+ p.update(.{ .key = .{ .cp = 'n' } });
+ try std.testing.expectEqual(@as(usize, 0), p.active);
+}
diff --git a/src/panel_animation.zig b/src/panel_animation.zig
new file mode 100644
index 00000000..045d2a87
--- /dev/null
+++ b/src/panel_animation.zig
@@ -0,0 +1,335 @@
+//! Backend-neutral vocabulary and math for pane transitions.
+//!
+//! The core publishes plain transition data. GUI shells evaluate that data in
+//! shaders; the TTY shell evaluates the same progress over cells in its grid.
+//! There are deliberately no callbacks or backend objects here.
+const std = @import("std");
+
+pub const Easing = enum(u8) {
+ linear,
+ smooth,
+ out_cubic,
+ out_back,
+};
+
+pub const Transition = enum(u8) {
+ // These values cross both GUI shader ABIs. GLSL receives the enum in the
+ // instance uvec4 and the Core Image kernel receives it as a float, so
+ // spelling the numbers here keeps a source reorder from changing pixels.
+ off = 0,
+ slide = 1,
+ zoom = 2,
+ dissolve = 3,
+ ascii = 4,
+ vertical = 5,
+
+ pub fn easing(effect: Transition) Easing {
+ return switch (effect) {
+ .off, .dissolve => .smooth,
+ .slide, .vertical => .out_cubic,
+ .zoom => .out_back,
+ .ascii => .linear,
+ };
+ }
+
+ pub fn frames(effect: Transition) u16 {
+ return switch (effect) {
+ .off => 0,
+ .slide => 12,
+ .zoom => 14,
+ .dissolve => 10,
+ .ascii => 16,
+ .vertical => 12,
+ };
+ }
+
+ pub fn needsPreviousGrid(effect: Transition) bool {
+ return effect == .dissolve or effect == .ascii or effect == .vertical;
+ }
+
+ pub fn lifecycleOnly(effect: Transition) bool {
+ return effect == .vertical;
+ }
+};
+
+/// Full-scene shader effects. CRT is one effect in this vocabulary rather
+/// than a separate renderer switch; only one scene pass is needed even when
+/// more than one bit is enabled.
+pub const SceneEffect = struct {
+ crt: bool = false,
+ ripple: bool = false,
+ glitch: bool = false,
+};
+
+pub const Phase = enum(u8) {
+ opening = 0,
+ moving = 1,
+ /// Presentation-only content whose pane lifetime has already ended.
+ /// It is never a valid input target.
+ closing = 2,
+};
+
+pub const Box = extern struct {
+ x: f32 = 0,
+ y: f32 = 0,
+ w: f32 = 0,
+ h: f32 = 0,
+
+ pub fn eql(a: Box, b: Box) bool {
+ return a.x == b.x and a.y == b.y and a.w == b.w and a.h == b.h;
+ }
+};
+
+/// One POD record is enough for every backend. `from` and `to` are logical
+/// cell boxes; frontends convert them to pixels only at their render edge.
+pub const Track = extern struct {
+ serial: u32 = 0,
+ pane: u8 = 0,
+ phase: Phase = .moving,
+ effect: Transition = .off,
+ _padding: u8 = 0,
+ frame: u16 = 0,
+ _frame_padding: u16 = 0,
+ from: Box = .{},
+ to: Box = .{},
+
+ pub fn active(track: Track) bool {
+ return track.effect != .off and track.frame < track.effect.frames();
+ }
+
+ pub fn amount(track: Track) f32 {
+ return progress(track.effect, track.frame);
+ }
+
+ pub fn presented(track: Track) Box {
+ return lerpBox(track.from, track.to, track.amount());
+ }
+
+ /// Geometry actually painted by every backend. Content transitions reveal
+ /// cells in place; slide, zoom, and vertical transform the panel rectangle.
+ pub fn visualBox(track: Track) Box {
+ return switch (track.effect) {
+ .slide, .zoom, .vertical => track.presented(),
+ .off, .dissolve, .ascii => track.to,
+ };
+ }
+
+ /// The canonical box whose cells a backend samples. Opening and moving
+ /// tracks sample the new frame at `to`; a closing tombstone samples the
+ /// frozen old frame at `from` because its pane no longer exists.
+ pub fn contentBox(track: Track) Box {
+ return if (track.phase == .closing) track.from else track.to;
+ }
+};
+
+/// Preset starting geometry for a newly-visible panel. Dissolve and ASCII
+/// animate content in place; slide, zoom, and vertical animate its rectangle.
+pub fn openingBox(effect: Transition, target: Box, screen_width: u16) Box {
+ return switch (effect) {
+ .slide => blk: {
+ var from = target;
+ const middle = target.x + target.w * 0.5;
+ from.x = if (middle < @as(f32, @floatFromInt(screen_width)) * 0.5)
+ -target.w
+ else
+ @floatFromInt(screen_width);
+ break :blk from;
+ },
+ .zoom => .{
+ .x = target.x + target.w * 0.5,
+ .y = target.y + target.h * 0.5,
+ .w = 0,
+ .h = 0,
+ },
+ // Slide upward into a fixed clip equal to the new pane's own box.
+ // Starting one panel-height below that box keeps the translated
+ // content from travelling across any surviving pane.
+ .vertical => blk: {
+ var from = target;
+ from.y += target.h;
+ break :blk from;
+ },
+ .off, .dissolve, .ascii => target,
+ };
+}
+
+/// Destination for a lifecycle-only closing track. The old panel rises out
+/// through its own fixed clip; surviving panes are already at canonical
+/// geometry underneath it and never receive tracks for this effect.
+pub fn closingBox(effect: Transition, source: Box) Box {
+ return switch (effect) {
+ .vertical => blk: {
+ var to = source;
+ to.y -= source.h;
+ break :blk to;
+ },
+ else => source,
+ };
+}
+
+pub fn sample(easing: Easing, raw: f32) f32 {
+ const t = std.math.clamp(raw, 0.0, 1.0);
+ return switch (easing) {
+ .linear => t,
+ .smooth => t * t * (3.0 - 2.0 * t),
+ .out_cubic => 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t),
+ // Robert Penner's ease-out-back polynomial. It intentionally travels
+ // a little past one before settling exactly on the endpoint.
+ .out_back => blk: {
+ const c1: f32 = 1.70158;
+ const c3 = c1 + 1.0;
+ const u = t - 1.0;
+ break :blk 1.0 + c3 * u * u * u + c1 * u * u;
+ },
+ };
+}
+
+pub fn progress(effect: Transition, frame: u16) f32 {
+ const frames = effect.frames();
+ if (frames <= 1 or frame >= frames - 1) return 1.0;
+ // `frames` is the number of presented samples, including both exact
+ // endpoints. This makes the last active frame the real final image rather
+ // than 15/16 followed by an unrendered snap to canonical content.
+ return sample(effect.easing(), @as(f32, @floatFromInt(frame)) / @as(f32, @floatFromInt(frames - 1)));
+}
+
+pub fn lerpBox(from: Box, to: Box, t: f32) Box {
+ // Keep easing overshoot for opening/moving geometry—the whole visual
+ // distinction of out-back—while preventing any shrinking dimension from
+ // becoming negative and flipping its quad.
+ const u = @max(0.0, t);
+ return .{
+ .x = from.x + (to.x - from.x) * u,
+ .y = from.y + (to.y - from.y) * u,
+ .w = @max(0.0, from.w + (to.w - from.w) * u),
+ .h = @max(0.0, from.h + (to.h - from.h) * u),
+ };
+}
+
+/// Stable cell noise shared by the TTY reveal and shader ports. Integer-only
+/// hashing means resizing or repainting a frame does not make cells flicker.
+pub fn cellNoise(serial: u32, col: u16, row: u16) f32 {
+ var x = serial ^ (@as(u32, col) *% 0x9e37_79b9) ^ (@as(u32, row) *% 0x85eb_ca6b);
+ x ^= x >> 16;
+ x *%= 0x7feb_352d;
+ x ^= x >> 15;
+ x *%= 0x846c_a68b;
+ x ^= x >> 16;
+ return @as(f32, @floatFromInt(x & 0xffff)) / 65535.0;
+}
+
+/// Whether a changed dissolve cell has crossed from the frozen old grid to
+/// the new one. Exact endpoints are part of the presentation contract.
+pub fn dissolveRevealed(serial: u32, col: u16, row: u16, raw_progress: f32) bool {
+ const t = std.math.clamp(raw_progress, 0.0, 1.0);
+ if (t <= 0) return false;
+ if (t >= 1) return true;
+ return cellNoise(serial, col, row) < t;
+}
+
+pub const AsciiCell = struct {
+ /// When true the pane's real cell has arrived.
+ reveal: bool,
+ /// A transient one-byte glyph while `reveal` is false. Space means the
+ /// cell has not entered the animation yet.
+ glyph: u8,
+};
+
+/// ASCII-materialization preset: a diagonal wave perturbed by stable noise.
+/// It is content animation rather than geometry; TTY replaces the cell glyph,
+/// while GUI shaders use the same threshold to dissolve glyph coverage.
+pub fn asciiCell(serial: u32, col: u16, row: u16, raw_progress: f32) AsciiCell {
+ const t = std.math.clamp(raw_progress, 0.0, 1.0);
+ // These endpoints are real presented frames: even a cell whose stable
+ // threshold is zero starts as old data, and every cell ends as new data.
+ if (t <= 0) return .{ .reveal = false, .glyph = ' ' };
+ if (t >= 1) return .{ .reveal = true, .glyph = ' ' };
+ const noise = cellNoise(serial, col, row);
+ const diagonal = @as(f32, @floatFromInt((@as(u32, col) + @as(u32, row) * 2) % 17)) / 17.0;
+ const threshold = noise * 0.72 + diagonal * 0.28;
+ if (t >= threshold) return .{ .reveal = true, .glyph = ' ' };
+ if (t + 0.22 < threshold) return .{ .reveal = false, .glyph = ' ' };
+ const glyphs = ".:+*#%@";
+ const index: usize = @intFromFloat(@floor(noise * @as(f32, @floatFromInt(glyphs.len))));
+ return .{ .reveal = false, .glyph = glyphs[@min(index, glyphs.len - 1)] };
+}
+
+test "easing presets have exact endpoints and intended shapes" {
+ inline for (std.enums.values(Easing)) |easing| {
+ try std.testing.expectEqual(@as(f32, 0), sample(easing, 0));
+ try std.testing.expectEqual(@as(f32, 1), sample(easing, 1));
+ }
+ try std.testing.expectEqual(@as(f32, 0.5), sample(.linear, 0.5));
+ try std.testing.expect(sample(.out_cubic, 0.5) > sample(.linear, 0.5));
+ try std.testing.expect(sample(.out_back, 0.8) > 1.0);
+}
+
+test "transition progress completes exactly" {
+ inline for (std.enums.values(Transition)) |effect| {
+ try std.testing.expectEqual(@as(f32, 1), progress(effect, effect.frames()));
+ if (effect.frames() > 0)
+ try std.testing.expectEqual(@as(f32, 1), progress(effect, effect.frames() - 1));
+ try std.testing.expectEqual(@as(f32, 1), progress(effect, std.math.maxInt(u16)));
+ }
+ try std.testing.expectEqual(@as(f32, 1), progress(.off, 0));
+
+ const shrinking = lerpBox(.{ .w = 100, .h = 40 }, .{}, sample(.out_back, 0.8));
+ try std.testing.expectEqual(@as(f32, 0), shrinking.w);
+ try std.testing.expectEqual(@as(f32, 0), shrinking.h);
+ const opening = lerpBox(.{}, .{ .w = 100, .h = 40 }, sample(.out_back, 0.8));
+ try std.testing.expect(opening.w > 100);
+ try std.testing.expect(opening.h > 40);
+}
+
+test "opening presets separate geometry and content transitions" {
+ const target: Box = .{ .x = 30, .y = 2, .w = 20, .h = 8 };
+ try std.testing.expectEqual(target, openingBox(.ascii, target, 80));
+ try std.testing.expectEqual(@as(f32, 0), openingBox(.zoom, target, 80).w);
+ try std.testing.expectEqual(@as(f32, 80), openingBox(.slide, target, 80).x);
+ try std.testing.expectEqual(@as(f32, target.y + target.h), openingBox(.vertical, target, 80).y);
+ try std.testing.expectEqual(@as(f32, target.y - target.h), closingBox(.vertical, target).y);
+
+ var track: Track = .{ .effect = .slide, .from = target, .to = target };
+ try std.testing.expect(track.active());
+ track.frame = track.effect.frames();
+ try std.testing.expect(!track.active());
+
+ track = .{ .effect = .dissolve, .frame = 3, .from = .{}, .to = target };
+ try std.testing.expectEqual(target, track.visualBox());
+
+ const closing: Track = .{
+ .phase = .closing,
+ .effect = .vertical,
+ .from = target,
+ .to = closingBox(.vertical, target),
+ };
+ try std.testing.expectEqual(target, closing.contentBox());
+}
+
+test "ASCII materialization is stable and finishes as real content" {
+ const a = asciiCell(42, 11, 7, 0.4);
+ const b = asciiCell(42, 11, 7, 0.4);
+ try std.testing.expectEqual(a, b);
+ try std.testing.expect(!asciiCell(42, 11, 7, 0).reveal);
+ try std.testing.expectEqual(@as(u8, ' '), asciiCell(42, 11, 7, 0).glyph);
+ try std.testing.expect(asciiCell(42, 11, 7, 1).reveal);
+
+ var changed = false;
+ for (0..32) |col| {
+ if (!std.meta.eql(asciiCell(42, @intCast(col), 3, 0.35), asciiCell(43, @intCast(col), 3, 0.35))) {
+ changed = true;
+ break;
+ }
+ }
+ try std.testing.expect(changed);
+}
+
+test "dissolve has exact stable endpoints" {
+ for (0..64) |col| {
+ const x: u16 = @intCast(col);
+ try std.testing.expect(!dissolveRevealed(42, x, 7, 0));
+ try std.testing.expect(dissolveRevealed(42, x, 7, 1));
+ if (dissolveRevealed(42, x, 7, 0.25))
+ try std.testing.expect(dissolveRevealed(42, x, 7, 0.75));
+ }
+}
diff --git a/src/pardes.zig b/src/pardes.zig
index 3c236f34..c5d06a43 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -1,10 +1,11 @@
//! The pardes core: a text environment as a library, the way ghostty-vt is a
-//! library. The platform shell owns the event loop and all IO; it feeds this
-//! core events (input, pty bytes, resizes) and reads back two plain values:
+//! library. The platform shell owns the event loop and process-facing IO; it
+//! feeds this core events (input, pty bytes, resizes) and reads back two plain values:
//! a Surface — the canonical cell-grid interface, which the tty shell hands to
//! vaxis nearly verbatim and the SDL shells rasterize — and a queue of Effects,
//! the IO the core wants performed (spawn a shell, write a pty, open a link).
-//! The core never does IO and never blocks; it is a state machine.
+//! Path-backed document and search operations may read synchronously; work
+//! which needs a host or event loop is emitted as an Effect.
//!
//! Platform divergence inside the core is the `platform` comptime tag, used
//! the way the stdlib uses os.tag. Click-on-text semantics live in look.zig.
@@ -20,6 +21,7 @@
//! enumerates the leader.
const std = @import("std");
pub const animation = @import("animation.zig");
+pub const panel_animation = @import("panel_animation.zig");
const ghostty_vt = @import("ghostty-vt");
const uucode = @import("uucode");
const mvzr = @import("mvzr");
@@ -30,13 +32,15 @@ pub const syntax = @import("syntax.zig");
const tracy = @import("tracy.zig");
const term_pane = @import("term_pane.zig");
const file_pane = @import("file_pane.zig");
+const image_pane = @import("image_pane.zig");
+pub const pdf_pane = @import("pdf_pane.zig");
const output_pane = @import("output_pane.zig");
const builtins = @import("builtins.zig");
+const runtime_cfg = @import("runtime_config.zig");
const selection_pipe = @import("selection_pipe.zig");
pub const config = @import("config.zig");
-pub const pdf_enabled = @import("pardes_config").mupdf;
-pub const pdf = if (pdf_enabled) @import("mupdf") else struct {};
-const pdf_impl = pdf;
+pub const pdf_enabled = pdf_pane.enabled;
+pub const pdf = pdf_pane.pdf;
pub const allocators = @import("allocators.zig");
pub const image = @import("image.zig");
pub const dump = @import("dump.zig");
@@ -60,18 +64,12 @@ pub const font_picker = platform == .gui or platform == .macos;
/// resolved, which the Debug overlay prints. Behind the same comptime shim
/// builtins.zig and macos.zig import this file with, so a tty or web binary
/// never analyses a font-directory walk it cannot use.
-const fonts = if (font_picker) @import("fonts.zig") else struct {
- pub const chosen: []const u8 = "";
-};
+const fonts = if (font_picker) @import("fonts.zig") else struct {};
/// Native PDF quality is a shell property, but the core owns MuPDF and the
/// RGBA cache. Kitty favors wire bandwidth; SDL favors physical-pixel text
/// quality and asks the renderer to cover either fit axis without upscaling.
-pub const PdfRasterPolicy = struct {
- dpi: u16,
- max_dimension: u16,
- match_viewport: bool,
-};
+pub const PdfRasterPolicy = pdf_pane.RasterPolicy;
pub const kitty_pdf_raster_policy: PdfRasterPolicy = .{
.dpi = 96,
@@ -91,22 +89,18 @@ pub const pdf_raster_policy: PdfRasterPolicy = switch (platform) {
.web => kitty_pdf_raster_policy,
};
-test "SDL PDF raster policy is materially denser than Kitty" {
- try std.testing.expect(sdl_pdf_raster_policy.dpi >= kitty_pdf_raster_policy.dpi * 2);
- try std.testing.expect(sdl_pdf_raster_policy.max_dimension >
- kitty_pdf_raster_policy.max_dimension * 3);
- try std.testing.expect(!kitty_pdf_raster_policy.match_viewport);
- try std.testing.expect(sdl_pdf_raster_policy.match_viewport);
-}
-
// The capacities and the two heights that are STRUCTURE, not taste: the
// fixed-size pane/column arrays, and the fact that the topbar and a tag are
// one row each (nothing here works at any other value). The layout numbers
// that ARE taste — the gutter, the line-number prefix, scrolloff, the pane
// minimums — live in config.zig with everything else a user retargets.
pub const MAX_PANES = 16;
-pub const PDF_PAGE_GAP_PX: u32 = 8;
+pub const PDF_PAGE_GAP_PX = pdf_pane.page_gap_px;
pub const MAX_COLS = 6;
+/// 32 fractional bits leave ample precision for resize/restored ratios while
+/// allowing every possible column split to divide an initial weight exactly.
+const column_weight_unit: u64 = 1 << 32;
+const max_column_weight: u64 = std.math.maxInt(u64) / MAX_COLS;
/// how far back the jump stack remembers. Vim keeps 100; this is a session of
/// at most sixteen panes, so the depth that matters is "more visits than you
/// can hold in your head" and the oldest entry falls off the bottom.
@@ -135,1232 +129,16 @@ pub const Loc = struct {
col: u32,
};
-test "MuPDF pane renders, navigates, searches, and round-trips its page" {
- if (!pdf_enabled or platform == .web) return;
-
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{
- .file = "docs/design.pdf",
- .cols = 80,
- .rows = 24,
- });
- defer p.deinit();
- const pane = p.panes[0].?;
- try std.testing.expect(hasPdf(pane));
- try std.testing.expect(pane.pdf.?.page_count > 1);
- try std.testing.expectEqual(PdfFitMode.width, pane.pdf.?.fit);
- try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x);
- try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_y);
- try std.testing.expectEqual(@as(u16, 8), p.cell_pixels.w);
- try std.testing.expectEqual(@as(u16, 16), p.cell_pixels.h);
- const initial_tag = try p.tagText(p.scratch.allocator(), pane);
- try std.testing.expect(std.mem.indexOf(u8, initial_tag, "pdf 1/") != null);
- try std.testing.expect(std.mem.indexOf(u8, initial_tag, " width ") != null);
- try std.testing.expect(std.mem.indexOf(u8, initial_tag, " height ") == null);
- try std.testing.expect(std.mem.indexOf(u8, initial_tag, "PdfFit") != null);
- try std.testing.expect(std.mem.endsWith(u8, initial_tag, pane_tail));
-
- p.native_images = true;
- var frame = std.heap.ArenaAllocator.init(gpa);
- defer frame.deinit();
- const first = try p.render(frame.allocator());
- try std.testing.expectEqual(@as(usize, 1), first.nimages);
- const first_place = first.images[0].?;
- try std.testing.expect(first_place.rgba.len == first_place.iw * first_place.ih * 4);
- try std.testing.expectEqual(image.NativeFit.width, first_place.native.fit);
- const request = p.pdfRenderRequest(pane) orelse return error.MissingPdfRenderRequest;
- try std.testing.expectEqual(pdf_raster_policy.dpi, request.dpi);
- try std.testing.expectEqual(pdf_raster_policy.max_dimension, request.max_dimension);
- try std.testing.expectEqual(request, pane.pdf.?.render_request);
- try std.testing.expect(@max(first_place.iw, first_place.ih) <= request.max_dimension);
- if (pdf_raster_policy.match_viewport) {
- const viewport = p.pdfViewport(pane) orelse return error.MissingPdfViewport;
- try std.testing.expectEqual(viewport.pixel_w, request.minimum_width);
- try std.testing.expectEqual(viewport.pixel_h, request.minimum_height);
- try std.testing.expect(first_place.iw >= viewport.pixel_w or
- @max(first_place.iw, first_place.ih) == request.max_dimension);
- try std.testing.expect(first_place.ih >= viewport.pixel_h or
- @max(first_place.iw, first_place.ih) == request.max_dimension);
- } else {
- try std.testing.expectEqual(@as(u32, 0), request.minimum_width);
- try std.testing.expectEqual(@as(u32, 0), request.minimum_height);
- }
-
- // SDL's raw dy path retains fractions in the placement itself; it does
- // not leave native pixels fixed while only the underlying cells slide.
- p.update(.{ .pdf_scroll = .{ .pane = 0, .delta_pixels = 0.25 } });
- try std.testing.expectEqual(@as(f64, 0.25), pane.pdf.?.document_scroll_y);
- _ = frame.reset(.retain_capacity);
- const fractional = try p.render(frame.allocator());
- try std.testing.expectEqual(first_place.native.revision, fractional.images[0].?.native.revision);
- try std.testing.expectEqual(@as(f32, -0.25), fractional.images[0].?.native.pixel_offset_y);
- pane.pdf.?.document_scroll_y = 0;
-
- // The default reading view moves one exact display-cell distance without
- // replacing page pixels. Document placement, not texture identity, moves.
- p.update(.{ .key = .{ .cp = 'j' } });
- try std.testing.expectEqual(@as(usize, 0), pane.pdf.?.page);
- try std.testing.expectEqual(@as(f64, p.cell_pixels.h), pane.pdf.?.document_scroll_y);
- try std.testing.expectEqual(first_place.native.revision, pane.pdf.?.render_revision);
- _ = frame.reset(.retain_capacity);
- const panned = try p.render(frame.allocator());
- try std.testing.expectEqual(first_place.native.revision, panned.images[0].?.native.revision);
- try std.testing.expect(panned.images[0].?.native.geometry.?.src.y > first_place.native.geometry.?.src.y);
-
- const row_scroll = pane.pdf.?.document_scroll_y;
- pane.pdf.?.document_scroll_y = 0;
- p.update(.{ .key = .{ .cp = '3' } });
- p.update(.{ .key = .{ .cp = 'j' } });
- try std.testing.expectEqual(@as(f64, p.cell_pixels.h * 3), pane.pdf.?.document_scroll_y);
-
- // Counts survive a shared multi-key prefix. An invalid continuation is
- // consumed and clears both prefix and count before the following action.
- pane.pdf.?.document_scroll_y = 0;
- p.update(.{ .key = .{ .cp = '2' } });
- p.update(.{ .key = .{ .cp = 'g' } });
- p.update(.{ .key = .{ .cp = 'j' } });
- try std.testing.expectEqual(@as(f64, p.cell_pixels.h * 2), pane.pdf.?.document_scroll_y);
- pane.pdf.?.document_scroll_y = 0;
- p.update(.{ .key = .{ .cp = '4' } });
- p.update(.{ .key = .{ .cp = 'g' } });
- p.update(.{ .key = .{ .cp = '?' } });
- try std.testing.expectEqual(@as(u21, 0), pane.pending);
- try std.testing.expectEqual(@as(u32, 0), pane.count);
- p.update(.{ .key = .{ .cp = 'j' } });
- try std.testing.expectEqual(row_scroll, pane.pdf.?.document_scroll_y);
-
- pane.pdf.?.document_scroll_y = 0;
- p.update(.{ .key = .{ .cp = 'd', .ctrl = true } });
- const half_scroll = pane.pdf.?.document_scroll_y;
- try std.testing.expect(half_scroll > row_scroll);
- pane.pdf.?.document_scroll_y = 0;
- p.update(.{ .key = .{ .cp = '2' } });
- p.update(.{ .key = .{ .cp = 'd', .ctrl = true } });
- const counted_half = pane.pdf.?.document_scroll_y;
- try std.testing.expectEqual(half_scroll * 2, counted_half);
- p.update(.{ .key = .{ .cp = '2' } });
- p.update(.{ .key = .{ .cp = 'u', .ctrl = true } });
- try std.testing.expectEqual(@as(f64, 0), pane.pdf.?.document_scroll_y);
- pane.pdf.?.document_scroll_y = @as(f64, p.cell_pixels.h) * 4;
- p.update(.{ .key = .{ .cp = '3' } });
- p.update(.{ .key = .{ .cp = 'k' } });
- try std.testing.expectEqual(row_scroll, pane.pdf.?.document_scroll_y);
- pane.pdf.?.document_scroll_y = 0;
- p.update(.{ .key = .{ .cp = 'f', .ctrl = true } });
- try std.testing.expect(pane.pdf.?.document_scroll_y >= half_scroll);
- pane.pdf.?.document_scroll_y = 0;
- p.update(.{ .key = .{ .cp = '2' } });
- p.update(.{ .key = .{ .cp = 'f', .ctrl = true } });
- const key_viewport = p.pdfViewport(pane).?;
- const max_key_scroll = @as(f64, @floatFromInt(pane.pdf.?.document_height -| key_viewport.pixel_h));
- const counted_full = @min(@as(f64, @floatFromInt(key_viewport.pixel_h * 2)), max_key_scroll);
- try std.testing.expectEqual(counted_full, pane.pdf.?.document_scroll_y);
- p.update(.{ .key = .{ .cp = '2' } });
- p.update(.{ .key = .{ .cp = 'b', .ctrl = true } });
- try std.testing.expectEqual(@max(@as(f64, 0), counted_full - @as(f64, @floatFromInt(key_viewport.pixel_h * 2))), pane.pdf.?.document_scroll_y);
- try std.testing.expectEqual(first_place.native.revision, pane.pdf.?.render_revision);
-
- // At a page boundary both page rasters coexist, the gap remains uncovered,
- // and a row step crosses it without snapping either page to an edge.
- //
- // This lands by ASSIGNMENT, which is a jump and not a fling — and the
- // travel counter has to say so: the keys above scrolled two screenfuls
- // without any frame in between to spend that distance, which no shell does
- // (every wheel batch is followed by a draw). Left unspent it would make the
- // frame below the first frame of a fling and hand it bands.
- const viewport = p.pdfViewport(pane).?;
- pane.pdf.?.scroll_travel = 0;
- pane.pdf.?.document_scroll_y = @floatFromInt(
- pane.pdf.?.page_starts[1] -| viewport.pixel_h / 2,
- );
- _ = frame.reset(.retain_capacity);
- const boundary = try p.render(frame.allocator());
- try std.testing.expectEqual(@as(usize, 2), boundary.nimages);
- try std.testing.expectEqual(@as(u32, 0), boundary.images[0].?.native.page);
- try std.testing.expectEqual(@as(u32, 1), boundary.images[1].?.native.page);
- const first_bottom = @as(f32, @floatFromInt(
- boundary.images[0].?.native.geometry.?.dst.y +
- boundary.images[0].?.native.geometry.?.dst.h,
- )) + boundary.images[0].?.native.pixel_offset_y;
- const second_top = @as(f32, @floatFromInt(
- boundary.images[1].?.native.geometry.?.dst.y,
- )) + boundary.images[1].?.native.pixel_offset_y;
- const visible_gap = second_top - first_bottom;
- try std.testing.expect(visible_gap >= @as(f32, PDF_PAGE_GAP_PX));
- try std.testing.expect(visible_gap <= @as(f32, PDF_PAGE_GAP_PX + 1));
- const before_boundary_scroll = pane.pdf.?.document_scroll_y;
- const page0_revision = boundary.images[0].?.native.revision;
- const page1_revision = boundary.images[1].?.native.revision;
-
- // A scroll can activate the already-cached neighbor before the shell's
- // next draw. Publish that page's aliases immediately so a queued click in
- // the same input batch cannot use page zero's dimensions.
- pane.pdf.?.document_scroll_y = @as(f64, @floatFromInt(pane.pdf.?.page_starts[1])) - 0.5;
- Pardes.syncPdfRasterAliases(&pane.pdf.?, Pardes.pdfRasterForPage(&pane.pdf.?, 0).?);
- try std.testing.expectEqual(page0_revision, pane.pdf.?.render_revision);
- try std.testing.expect(p.scrollPdfDocument(pane, 1));
- try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page);
- try std.testing.expectEqual(page1_revision, pane.pdf.?.render_revision);
- p.activatePdfPage(pane, 0, false);
- pane.pdf.?.document_scroll_y = before_boundary_scroll;
-
- p.update(.{ .key = .{ .cp = 'j' } });
- try std.testing.expectEqual(before_boundary_scroll + p.cell_pixels.h, pane.pdf.?.document_scroll_y);
- _ = frame.reset(.retain_capacity);
- const second = try p.render(frame.allocator());
- try std.testing.expectEqual(@as(usize, 2), second.nimages);
- try std.testing.expectEqual(page0_revision, second.images[0].?.native.revision);
- try std.testing.expectEqual(page1_revision, second.images[1].?.native.revision);
- p.update(.{ .key = .{ .cp = 'k' } });
- try std.testing.expectEqual(before_boundary_scroll, pane.pdf.?.document_scroll_y);
-
- // Once page zero is wholly outside the viewport, both its owned RGBA and
- // backend placement disappear; returning later renders a new raster. What
- // does NOT go back is the memory: the departing page's bytes are parked in
- // the relay and the arriving page of the same size takes them, so a fling
- // never asks the allocator (or the kernel's fault handler) for megabytes it
- // just gave up.
- p.setPdfPage(pane, 1);
- _ = frame.reset(.retain_capacity);
- const away = try p.render(frame.allocator());
- try std.testing.expect(away.nimages > 0);
- for (away.images[0..away.nimages]) |maybe| if (maybe) |place|
- try std.testing.expect(place.native.page != 0);
- try std.testing.expect(Pardes.pdfRasterForPage(&pane.pdf.?, 0) == null);
- try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.spare_len);
- const retired = pane.pdf.?.spare[0];
- p.setPdfPage(pane, 0);
- _ = frame.reset(.retain_capacity);
- const returned = try p.render(frame.allocator());
- try std.testing.expectEqual(@as(u32, 0), returned.images[0].?.native.page);
- try std.testing.expectEqual(retired.ptr, returned.images[0].?.rgba.ptr);
- try std.testing.expect(returned.images[0].?.native.revision != page0_revision);
-
- // PdfFit exists as a real builtin in this build. It resets placement but
- // preserves the current page pixels; fit-height j/k remains continuous in
- // the same document-pixel coordinate space.
- const fit_builtin = std.meta.stringToEnum(Builtin, "PdfFit") orelse
- return error.MissingPdfFitBuiltin;
- const revision_before_toggle = pane.pdf.?.render_revision;
- p.runBuiltin(fit_builtin, 0, "", null);
- try std.testing.expectEqual(PdfFitMode.height, pane.pdf.?.fit);
- try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x);
- try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_y);
- try std.testing.expectEqual(revision_before_toggle, pane.pdf.?.render_revision);
- const height_tag = try p.tagText(p.scratch.allocator(), pane);
- try std.testing.expect(std.mem.indexOf(u8, height_tag, "pdf 1/") != null);
- try std.testing.expect(std.mem.indexOf(u8, height_tag, " width ") == null);
- try std.testing.expect(std.mem.indexOf(u8, height_tag, " height ") != null);
- try std.testing.expect(std.mem.indexOf(u8, height_tag, "PdfFit") != null);
- for (p.panes) |slot| {
- const other = slot orelse continue;
- if (hasPdf(other)) continue;
- p.togglePdfFit(other); // pane-scoped and deliberately inert here
- try std.testing.expectEqual(PdfFitMode.height, pane.pdf.?.fit);
- break;
- }
-
- // A fit-height landscape page exposes horizontal overflow to a horizontal
- // wheel without rerasterizing. Use synthetic dimensions only for the
- // geometry check; no frame is drawn while they differ from the buffer.
- const active_raster = Pardes.pdfRasterForPage(&pane.pdf.?, pane.pdf.?.page).?;
- const saved_iw = active_raster.iw;
- const saved_ih = active_raster.ih;
- active_raster.iw = 2000;
- active_raster.ih = 500;
- Pardes.syncPdfRasterAliases(&pane.pdf.?, active_raster);
- p.pdfHorizontalWheel(pane, 1);
- try std.testing.expect(pane.pdf.?.pan_x > 0);
- try std.testing.expectEqual(revision_before_toggle, pane.pdf.?.render_revision);
- pane.pdf.?.pan_x = 0;
- p.update(.{ .key = .{ .cp = '2' } });
- p.update(.{ .key = .{ .cp = 'l' } });
- try std.testing.expect(pane.pdf.?.pan_x > 0);
- p.update(.{ .key = .{ .cp = '$' } });
- try std.testing.expectEqual(std.math.maxInt(u16), pane.pdf.?.pan_x);
- p.update(.{ .key = .{ .cp = '2' } });
- p.update(.{ .key = .{ .cp = 'h' } });
- try std.testing.expect(pane.pdf.?.pan_x < std.math.maxInt(u16));
- p.update(.{ .key = .{ .cp = '0' } });
- try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x);
- p.update(.{ .key = .{ .cp = 'g' } });
- p.update(.{ .key = .{ .cp = 'l' } });
- try std.testing.expectEqual(std.math.maxInt(u16), pane.pdf.?.pan_x);
- p.update(.{ .key = .{ .cp = 'g' } });
- p.update(.{ .key = .{ .cp = 'h' } });
- try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x);
- active_raster.iw = saved_iw;
- active_raster.ih = saved_ih;
- Pardes.syncPdfRasterAliases(&pane.pdf.?, active_raster);
- pane.pdf.?.pan_x = 0;
-
- const before_height_scroll = pane.pdf.?.document_scroll_y;
- p.update(.{ .key = .{ .cp = 'j' } });
- try std.testing.expect(pane.pdf.?.document_scroll_y > before_height_scroll);
-
- const revision_before_search = pane.pdf.?.render_revision;
- try p.runSearch(0, "Pardes", .text, .top);
- try std.testing.expectEqualStrings("Pardes", pane.pdf.?.search_query);
- try std.testing.expectEqual(revision_before_search, pane.pdf.?.render_revision);
- const results_id = pane.search_pane orelse return error.MissingPdfSearchResults;
- const results = p.panes[results_id].?.file.?.content;
- try std.testing.expect(std.mem.indexOf(u8, results, "design.pdf:") != null);
- // n SELECTS the first result row and opens nothing: the walk's only list
- // here is the unlooked +Search buffer, so focus lands THERE. Enter is what
- // jumps, and the document query survives both.
- p.update(.{ .key = .{ .cp = 'n' } });
- try std.testing.expectEqual(results_id, p.active);
- const results_pane = p.panes[results_id].?;
- try std.testing.expect(results_pane.vsel.active and results_pane.vsel.explicit);
- try std.testing.expectEqual(@as(i32, 0), results_pane.cur_row);
- try std.testing.expectEqual(@as(i32, 0), results_pane.cur_col);
- p.update(.{ .key = .{ .cp = Key.enter } });
- try std.testing.expectEqual(@as(usize, 0), p.active);
- try std.testing.expectEqualStrings("Pardes", pane.pdf.?.search_query);
-
- // Search state is owned and untruncated, and changing pages invalidates
- // only page-local state while retaining the document query.
- const long_query = "a query deliberately longer than any tag display budget: " ++
- "012345678901234567890123456789012345678901234567890123456789" ++
- "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
- try p.setPdfSearchQuery(&pane.pdf.?, long_query);
- try std.testing.expectEqualStrings(long_query, pane.pdf.?.search_query);
- const next_page = if (pane.pdf.?.page == 0) @as(usize, 1) else 0;
- p.setPdfPage(pane, next_page);
- try std.testing.expectEqualStrings(long_query, pane.pdf.?.search_query);
-
- // PDF dumps intentionally retain the existing image-compatible schema:
- // page/path are restored, while pane-local tint starts from the fresh-PDF
- // default rather than being serialized in the image record.
- p.togglePdfTint(pane);
- try std.testing.expectEqual(PdfTintMode.full, pane.pdf.?.tint);
- try p.dumpState();
- const restored = try Pardes.initFromDump(gpa, .{}, p.dump_out.?);
- defer restored.deinit();
- try std.testing.expect(hasPdf(restored.panes[0].?));
- try std.testing.expectEqual(pane.pdf.?.page, restored.panes[0].?.pdf.?.page);
- try std.testing.expectEqual(PdfTintMode.filtered, restored.panes[0].?.pdf.?.tint);
-}
-
-test "a fling's banded pages show the reader exactly what whole pages would" {
- if (!pdf_enabled or platform == .web) return;
-
- // The contract fast scrolling is allowed to change: HOW pixels are carried
- // (a strip of a page instead of the page) but never WHICH pixels arrive. So
- // the same frame is drawn twice — once at fling speed, once at reading
- // speed — and every pixel inside every source rectangle must match, along
- // with where on screen it goes.
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{ .file = "docs/design.pdf", .cols = 120, .rows = 40 });
- defer p.deinit();
- p.native_images = true;
- const pane = p.panes[0].?;
- const pv = &pane.pdf.?;
- var frame = std.heap.ArenaAllocator.init(gpa);
- defer frame.deinit();
- _ = try p.render(frame.allocator());
-
- const viewport = p.pdfViewport(pane) orelse return error.MissingPdfViewport;
- // Land mid-page-boundary so the frame carries TWO pages, each showing a
- // fraction of itself — the shape a fling actually produces.
- const landing = @as(f64, @floatFromInt(pv.page_starts[1] -| viewport.pixel_h / 3));
- const Shot = struct {
- page: u32,
- dst: image.PixelRect,
- pixels: []u8,
- };
- var shots: [8]Shot = undefined;
- var shots_len: usize = 0;
- defer for (shots[0..shots_len]) |shot| gpa.free(shot.pixels);
-
- // A fling: one frame's worth of wheel travel carrying the viewport more
- // than a screenful, delivered through the real scroll path so the distance
- // is counted the way a wheel batch counts it.
- pv.document_scroll_y = 0;
- pv.scroll_travel = 0;
- try std.testing.expect(p.scrollPdfDocument(pane, landing));
- _ = frame.reset(.retain_capacity);
- const flung = try p.render(frame.allocator());
- try std.testing.expect(flung.nimages >= 2);
- var banded = false;
- for (flung.images[0..flung.nimages]) |maybe| {
- const place = maybe orelse continue;
- const geometry = place.native.geometry orelse return error.MissingPdfGeometry;
- const raster = Pardes.pdfRasterForPage(pv, place.native.page) orelse
- return error.MissingPdfRaster;
- if (raster.band_h < raster.ih) banded = true;
- try std.testing.expectEqual(place.iw * raster.band_h * 4, place.rgba.len);
- shots[shots_len] = .{
- .page = place.native.page,
- .dst = geometry.dst,
- .pixels = try copySourceRect(gpa, place, geometry.src),
- };
- shots_len += 1;
- }
- // ...and it really did band, or the comparison below is two identical
- // whole-page renders agreeing with each other.
- try std.testing.expect(banded);
- // ...at reading speed: no travel at all since the frame above, so every
- // page is rasterized whole again, and that is the picture the banded frame
- // has to have matched.
- _ = frame.reset(.retain_capacity);
- const rested = try p.render(frame.allocator());
- try std.testing.expectEqual(shots_len, rested.nimages);
- for (rested.images[0..rested.nimages], shots[0..shots_len]) |maybe, shot| {
- const place = maybe orelse return error.MissingPdfPlacement;
- const geometry = place.native.geometry orelse return error.MissingPdfGeometry;
- const raster = Pardes.pdfRasterForPage(pv, place.native.page) orelse
- return error.MissingPdfRaster;
- try std.testing.expectEqual(raster.ih, raster.band_h); // promoted at rest
- try std.testing.expectEqual(shot.page, place.native.page);
- try std.testing.expectEqual(shot.dst.x, geometry.dst.x);
- try std.testing.expectEqual(shot.dst.y, geometry.dst.y);
- try std.testing.expectEqual(shot.dst.w, geometry.dst.w);
- try std.testing.expectEqual(shot.dst.h, geometry.dst.h);
- const whole = try copySourceRect(gpa, place, geometry.src);
- defer gpa.free(whole);
- try std.testing.expectEqualSlices(u8, shot.pixels, whole);
- }
-}
-
-/// The pixels a backend samples out of one placement: the source rectangle,
-/// row by row, at the texture's own stride. Test-only, and the one operation
-/// that makes "same picture" mean something when the textures differ in shape.
-fn copySourceRect(
- gpa: std.mem.Allocator,
- place: ImagePlace,
- src: image.PixelRect,
-) ![]u8 {
- const stride = place.iw * 4;
- const row_len = @as(usize, src.w) * 4;
- const out = try gpa.alloc(u8, row_len * src.h);
- errdefer gpa.free(out);
- var row: usize = 0;
- while (row < src.h) : (row += 1) {
- const from = (@as(usize, src.y) + row) * stride + @as(usize, src.x) * 4;
- @memcpy(out[row * row_len ..][0..row_len], place.rgba[from..][0..row_len]);
- }
- return out;
-}
-
-test "PDF normal adapter consumes unsupported actions and navigates page fallback" {
- if (!pdf_enabled or platform == .web) return;
-
- const p = try Pardes.init(std.testing.allocator, .{
- .file = "docs/design.pdf",
- .cols = 80,
- .rows = 24,
- });
- defer p.deinit();
- const pane = p.panes[0].?;
- const pv = &pane.pdf.?;
- try std.testing.expect(pv.page_count > 3);
- p.native_images = false;
-
- // Every vertical vocabulary falls back to counted page changes when the
- // shell cannot place native pixels.
- p.update(.{ .key = .{ .cp = '2' } });
- p.update(.{ .key = .{ .cp = 'j' } });
- try std.testing.expectEqual(@as(usize, 2), pv.page);
- p.update(.{ .key = .{ .cp = '2' } });
- p.update(.{ .key = .{ .cp = 'k' } });
- try std.testing.expectEqual(@as(usize, 0), pv.page);
- p.update(.{ .key = .{ .cp = '2' } });
- p.update(.{ .key = .{ .cp = 'd', .ctrl = true } });
- try std.testing.expectEqual(@as(usize, 2), pv.page);
- p.update(.{ .key = .{ .cp = '2' } });
- p.update(.{ .key = .{ .cp = 'u', .ctrl = true } });
- try std.testing.expectEqual(@as(usize, 0), pv.page);
- p.update(.{ .key = .{ .cp = '2' } });
- p.update(.{ .key = .{ .cp = 'f', .ctrl = true } });
- try std.testing.expectEqual(@as(usize, 2), pv.page);
- p.update(.{ .key = .{ .cp = '2' } });
- p.update(.{ .key = .{ .cp = 'b', .ctrl = true } });
- try std.testing.expectEqual(@as(usize, 0), pv.page);
-
- // Prefix actions and the counted text goto-line action map to pages.
- p.update(.{ .key = .{ .cp = 'g' } });
- p.update(.{ .key = .{ .cp = 'e' } });
- try std.testing.expectEqual(pv.page_count - 1, pv.page);
- p.update(.{ .key = .{ .cp = 'g' } });
- p.update(.{ .key = .{ .cp = 'g' } });
- try std.testing.expectEqual(@as(usize, 0), pv.page);
- p.update(.{ .key = .{ .cp = '3' } });
- p.update(.{ .key = .{ .cp = 'g' } });
- p.update(.{ .key = .{ .cp = 'g' } });
- try std.testing.expectEqual(@as(usize, 2), pv.page);
- p.update(.{ .key = .{ .cp = '2' } });
- p.update(.{ .key = .{ .cp = 'G' } });
- try std.testing.expectEqual(@as(usize, 1), pv.page);
-
- // Editing/selection actions are consumed no-ops: parser state clears,
- // placeholder cells never acquire a range, and PDF state stays intact.
- const page_before_noop = pv.page;
- const revision_before_noop = pv.render_revision;
- p.update(.{ .key = .{ .cp = '4' } });
- p.update(.{ .key = .{ .cp = 'd' } });
- p.update(.{ .key = .{ .cp = 'v' } });
- p.update(.{ .key = .{ .cp = '|' } });
- try std.testing.expectEqual(page_before_noop, pv.page);
- try std.testing.expectEqual(revision_before_noop, pv.render_revision);
- try std.testing.expectEqual(@as(u32, 0), pane.count);
- try std.testing.expectEqual(@as(u21, 0), pane.pending);
- try std.testing.expect(!pane.vsel.active and !pane.msel.active and pane.nsel == 0);
- try std.testing.expect(!pane.hasPipePrompt() and !pane.tag_edit);
-
- // Cross-pane BODY-NORMAL actions keep their established shared paths.
- p.update(.{ .key = .{ .cp = ':' } });
- try std.testing.expect(pane.tag_edit);
- try std.testing.expectEqual(Mode.normal, pane.mode);
- p.update(.{ .key = .{ .cp = Key.escape } });
- try std.testing.expect(!pane.tag_edit);
- p.update(.{ .key = .{ .cp = ' ' } });
- try std.testing.expect(p.leader_on);
- p.update(.{ .key = .{ .cp = '!' } });
- try std.testing.expect(!p.leader_on);
- p.update(.{ .key = .{ .cp = '/' } });
- try std.testing.expect(pane.hasSearchPrompt() and pane.tag_edit);
- p.update(.{ .key = .{ .cp = Key.escape } });
- try std.testing.expect(!pane.hasSearchPrompt() and !pane.tag_edit);
-}
-
-test "PDF continuous strip renders every intersecting short page" {
- if (!pdf_enabled or platform == .web) return;
-
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{
- .file = "docs/design.pdf",
- .cols = 80,
- .rows = 24,
- });
- defer p.deinit();
- const pane = p.panes[0].?;
- const pv = &pane.pdf.?;
- try std.testing.expect(pv.page_count > 3);
- p.native_images = true;
-
- // A legal wide MediaBox can make more than three pages intersect one
- // viewport. Seed tiny matching rasters so this tests transport/cache
- // cardinality without spending the unit test rendering fake page sizes.
- for (pv.page_sizes) |*size| size.* = .{ .width = 100_000, .height = 1 };
- pv.layout_valid = false;
- pv.scroll_to_page_pending = true;
- const viewport = p.ensurePdfLayout(pane, pv).?;
- const visible = Pardes.pdfVisiblePages(pv, viewport);
- try std.testing.expectEqual(pv.page_count, visible.len);
- const request = p.pdfRenderRequest(pane).?;
- try std.testing.expect(pv.page_count <= pv.rasters.len);
- for (0..pv.page_count) |page| {
- const rgba = try gpa.alloc(u8, @as(usize, viewport.pixel_w) * 4);
- @memset(rgba, @intCast(page));
- pv.rasters[pv.rasters_len] = .{
- .valid = true,
- .page = page,
- .rgba = rgba,
- .iw = viewport.pixel_w,
- .ih = 1,
- .request = request,
- .request_valid = true,
- .tried = true,
- .tint_key = p.pdfTintKey(pv),
- .revision = @intCast(page + 1),
- };
- pv.rasters_len += 1;
- }
-
- var frame = std.heap.ArenaAllocator.init(gpa);
- defer frame.deinit();
- const surface = try p.render(frame.allocator());
- try std.testing.expectEqual(pv.page_count, surface.nimages);
- try std.testing.expectEqual(pv.page_count, pv.rasters_len);
- for (surface.images[0..surface.nimages], 0..) |maybe, page| {
- const place = maybe orelse return error.MissingShortPdfPage;
- try std.testing.expectEqual(@as(u32, @intCast(page)), place.native.page);
- try std.testing.expectEqual(@as(u32, 1), place.native.geometry.?.dst.h);
- }
-}
-
-test "PdfTint cycles pane-local state and exposes it in the live PDF tag" {
- if (!pdf_enabled or platform == .web) return;
-
- const p = try Pardes.init(std.testing.allocator, .{
- .file = "docs/design.pdf",
- .cols = 80,
- .rows = 24,
- });
- defer p.deinit();
- const pane = p.panes[0].?;
- const pv = &pane.pdf.?;
- const tint_builtin = std.meta.stringToEnum(Builtin, "PdfTint") orelse
- return error.MissingPdfTintBuiltin;
-
- try std.testing.expectEqual(PdfTintMode.filtered, pv.tint);
- const initial_tag = try p.tagText(p.scratch.allocator(), pane);
- try std.testing.expect(std.mem.indexOf(
- u8,
- initial_tag,
- "width PdfFit filtered PdfTint",
- ) != null);
-
- p.runBuiltin(tint_builtin, 0, "", null);
- try std.testing.expectEqual(PdfTintMode.full, pv.tint);
- const full_tag = try p.tagText(p.scratch.allocator(), pane);
- try std.testing.expect(std.mem.indexOf(u8, full_tag, "full PdfTint") != null);
-
- // Running the same pane-scoped word in a terminal cannot mutate the PDF
- // next to it. A FILE boot is one pane now, so that terminal is asked for
- // here rather than inherited from init.
- _ = try p.newPane(1);
- p.layoutAppendColumn(1);
- p.sync(); // rects for the new column, exactly as the two-pane boot did
- try std.testing.expect(!hasPdf(p.panes[1].?));
- p.runBuiltin(tint_builtin, 1, "", null);
- try std.testing.expectEqual(PdfTintMode.full, pv.tint);
-
- p.runBuiltin(tint_builtin, 0, "", null);
- try std.testing.expectEqual(PdfTintMode.disabled, pv.tint);
- const disabled_tag = try p.tagText(p.scratch.allocator(), pane);
- try std.testing.expect(std.mem.indexOf(u8, disabled_tag, "disabled PdfTint") != null);
- p.runBuiltin(tint_builtin, 0, "", null);
- try std.testing.expectEqual(PdfTintMode.filtered, pv.tint);
- try std.testing.expectEqual(PdfFitMode.width, pv.fit);
-
- // `dark` intentionally leaves page bg/fg null. PDF tint resolves those
- // deterministically to its chrome colors rather than host defaults.
- const dark_index = for (themes, 0..) |th, i| {
- if (std.mem.eql(u8, th.name, "dark")) break i;
- } else return error.MissingDarkTheme;
- p.setThemeIndex(dark_index);
- const colors = p.pdfTintColors();
- try std.testing.expectEqual(p.theme().tag_bg, colors.background);
- try std.testing.expectEqual(p.theme().tag_fg, colors.foreground);
-}
-
-test "PDF tint and tinted theme changes rebuild every visible raster only" {
- if (!pdf_enabled or platform == .web) return;
-
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{
- .file = "docs/design.pdf",
- .cols = 80,
- .rows = 24,
- });
- defer p.deinit();
- const pane = p.panes[0].?;
- const pv = &pane.pdf.?;
- p.native_images = true;
- var frame = std.heap.ArenaAllocator.init(gpa);
- defer frame.deinit();
-
- _ = try p.render(frame.allocator());
- const viewport = p.pdfViewport(pane).?;
- pv.document_scroll_y = @floatFromInt(pv.page_starts[1] -| viewport.pixel_h / 2);
- _ = frame.reset(.retain_capacity);
- const default_surface = try p.render(frame.allocator());
- try std.testing.expectEqual(PdfTintMode.filtered, pv.tint);
- try std.testing.expectEqual(@as(usize, 2), default_surface.nimages);
- try std.testing.expectEqual(@as(usize, 2), pv.rasters_len);
-
- const Snapshot = struct {
- page: u32,
- revision: u32,
- geometry: image.NativeGeometry,
- pixel_offset_y: f32,
- iw: usize,
- ih: usize,
- checksum: u64,
- };
- const Capture = struct {
- fn get(surface: *const Surface) ![2]Snapshot {
- if (surface.nimages != 2) return error.UnexpectedVisiblePdfCount;
- var out: [2]Snapshot = undefined;
- for (&out, 0..) |*snapshot, i| {
- const place = surface.images[i] orelse return error.MissingVisiblePdf;
- snapshot.* = .{
- .page = place.native.page,
- .revision = place.native.revision,
- .geometry = place.native.geometry orelse return error.MissingPdfGeometry,
- .pixel_offset_y = place.native.pixel_offset_y,
- .iw = place.iw,
- .ih = place.ih,
- .checksum = std.hash.Wyhash.hash(0x5044_4654_494e_5421, place.rgba),
- };
- }
- return out;
- }
-
- fn expectGeometry(before: [2]Snapshot, after: [2]Snapshot) !void {
- for (before, after) |old, new| {
- try std.testing.expectEqual(old.page, new.page);
- try std.testing.expectEqual(old.geometry, new.geometry);
- try std.testing.expectEqual(old.pixel_offset_y, new.pixel_offset_y);
- try std.testing.expectEqual(old.iw, new.iw);
- try std.testing.expectEqual(old.ih, new.ih);
- }
- }
- };
- const default_filtered = try Capture.get(default_surface);
- for (pv.rasters[0..pv.rasters_len]) |raster|
- try std.testing.expectEqual(PdfTintMode.filtered, raster.tint_key.?.mode);
-
- const dark_index = for (themes, 0..) |th, i| {
- if (std.mem.eql(u8, th.name, "dark")) break i;
- } else return error.MissingDarkTheme;
- p.setThemeIndex(dark_index);
- for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(!raster.tried);
- _ = frame.reset(.retain_capacity);
- const themed_surface = try p.render(frame.allocator());
- const themed = try Capture.get(themed_surface);
- try Capture.expectGeometry(default_filtered, themed);
- for (default_filtered, themed) |old, new| {
- try std.testing.expect(new.revision > old.revision);
- try std.testing.expect(new.checksum != old.checksum);
- }
-
- // Chrome animation reads a separate palette. Its ticks must never disturb
- // the target-theme tint key or ask MuPDF for the same pixels again.
- try std.testing.expect(p.themeAnimationActive());
- for (0..animation.transition_steps) |_| {
- p.update(.tick);
- _ = frame.reset(.retain_capacity);
- const tick_surface = try p.render(frame.allocator());
- const ticked = try Capture.get(tick_surface);
- try Capture.expectGeometry(themed, ticked);
- for (themed, ticked) |once, after_tick| {
- try std.testing.expectEqual(once.revision, after_tick.revision);
- try std.testing.expectEqual(once.checksum, after_tick.checksum);
- }
- }
- try std.testing.expect(!p.themeAnimationActive());
-
- p.togglePdfTint(pane);
- try std.testing.expectEqual(PdfTintMode.full, pv.tint);
- _ = frame.reset(.retain_capacity);
- const full_surface = try p.render(frame.allocator());
- const full = try Capture.get(full_surface);
- try Capture.expectGeometry(themed, full);
- for (themed, full) |old, new| {
- try std.testing.expect(new.revision > old.revision);
- }
- for (pv.rasters[0..pv.rasters_len]) |raster|
- try std.testing.expectEqual(PdfTintMode.full, raster.tint_key.?.mode);
-
- p.togglePdfTint(pane);
- try std.testing.expectEqual(PdfTintMode.disabled, pv.tint);
- _ = frame.reset(.retain_capacity);
- const disabled_surface = try p.render(frame.allocator());
- const disabled = try Capture.get(disabled_surface);
- try Capture.expectGeometry(full, disabled);
- for (full, disabled) |old, source| {
- try std.testing.expect(source.revision > old.revision);
- try std.testing.expect(source.checksum != old.checksum);
- }
-
- const acme_index = for (themes, 0..) |th, i| {
- if (std.mem.eql(u8, th.name, "acme")) break i;
- } else return error.MissingAcmeTheme;
- for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(raster.tried);
- p.setThemeIndex(acme_index);
- // Disabled keys ignore theme colors, so neither explicit invalidation nor
- // the per-raster key comparison asks MuPDF for replacement pixels.
- for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(raster.tried);
- _ = frame.reset(.retain_capacity);
- const unchanged_surface = try p.render(frame.allocator());
- const unchanged = try Capture.get(unchanged_surface);
- try Capture.expectGeometry(disabled, unchanged);
- for (disabled, unchanged) |old, new| {
- try std.testing.expectEqual(old.revision, new.revision);
- try std.testing.expectEqual(old.checksum, new.checksum);
- }
- try std.testing.expectEqual(@as(usize, 2), pv.rasters_len);
-}
-
-test "PDF resize preserves page-relative document position" {
- if (!pdf_enabled or platform == .web) return;
-
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{
- .file = "docs/design.pdf",
- .cols = 80,
- .rows = 24,
- });
- defer p.deinit();
- const pane = p.panes[0].?;
- const pv = &pane.pdf.?;
- p.native_images = true;
- var frame = std.heap.ArenaAllocator.init(gpa);
- defer frame.deinit();
-
- p.setPdfPage(pane, 1);
- _ = try p.render(frame.allocator());
- const fraction: f64 = 0.375;
- pv.document_scroll_y = @as(f64, @floatFromInt(pv.page_starts[1])) +
- fraction * @as(f64, @floatFromInt(pv.page_heights[1]));
- p.update(.{ .resize = .{
- .cols = 100,
- .rows = 24,
- .cell_pixels = p.cell_pixels,
- } });
- try std.testing.expect(!pv.layout_valid);
- try std.testing.expect(pv.layout_anchor_pending);
- _ = frame.reset(.retain_capacity);
- _ = try p.render(frame.allocator());
- const resized_fraction = (pv.document_scroll_y -
- @as(f64, @floatFromInt(pv.page_starts[1]))) /
- @as(f64, @floatFromInt(pv.page_heights[1]));
- try std.testing.expectApproxEqAbs(fraction, resized_fraction, 0.000_001);
- try std.testing.expectEqual(@as(usize, 1), pv.page);
-
- const held = pv.document_scroll_y;
- p.update(.{ .resize = .{
- .cols = 100,
- .rows = 24,
- .cell_pixels = p.cell_pixels,
- } });
- try std.testing.expectEqual(held, pv.document_scroll_y);
- try std.testing.expect(pv.layout_valid);
-}
-
-test "PDF pane geometry change preserves its page-relative position" {
- if (!pdf_enabled or platform == .web) return;
-
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{
- .file = "docs/design.pdf",
- .cols = 80,
- .rows = 24,
- });
- defer p.deinit();
- // A FILE boot is one pane now and a lone column always fills the window,
- // so the divider drag below needs a second column to take the width from.
- _ = try p.newPane(1);
- p.layoutAppendColumn(1);
- p.sync(); // rects for the new column, exactly as the two-pane boot did
- const pane = p.panes[0].?;
- const pv = &pane.pdf.?;
- p.native_images = true;
- var frame = std.heap.ArenaAllocator.init(gpa);
- defer frame.deinit();
-
- p.setPdfPage(pane, 1);
- _ = try p.render(frame.allocator());
- const fraction: f64 = 0.625;
- pv.document_scroll_y = @as(f64, @floatFromInt(pv.page_starts[1])) +
- fraction * @as(f64, @floatFromInt(pv.page_heights[1]));
- const old_width = pv.layout_viewport_w;
-
- // A divider/split changes rects through computeGeom without emitting a
- // shell resize. ensurePdfLayout must anchor against the old valid layout
- // before rebuilding it for this wider pane.
- p.col_weight[0] = 3;
- p.computeGeom();
- try std.testing.expect(p.pdfViewport(pane).?.pixel_w != old_width);
- try std.testing.expect(pv.layout_valid);
- _ = frame.reset(.retain_capacity);
- _ = try p.render(frame.allocator());
- const changed_fraction = (pv.document_scroll_y -
- @as(f64, @floatFromInt(pv.page_starts[1]))) /
- @as(f64, @floatFromInt(pv.page_heights[1]));
- try std.testing.expectApproxEqAbs(fraction, changed_fraction, 0.000_001);
- try std.testing.expectEqual(@as(usize, 1), pv.page);
-
- // An explicit pending reveal wins over an implicit layout anchor.
- pv.page = 2;
- pv.scroll_to_page_pending = true;
- p.col_weight[0] = 1;
- p.computeGeom();
- _ = frame.reset(.retain_capacity);
- _ = try p.render(frame.allocator());
- try std.testing.expectEqual(
- @as(f64, @floatFromInt(pv.page_starts[2])),
- pv.document_scroll_y,
- );
-}
-
-test "PDF n/N addresses and reveals distinct MuPDF hits on one page" {
- if (!pdf_enabled or platform == .web) return;
-
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{
- .file = "docs/design.pdf",
- .cols = 120,
- .rows = 24,
- });
- defer p.deinit();
- const pane = p.panes[0].?;
- const pv = &pane.pdf.?;
- p.native_images = true;
-
- var page_hits = try pv.document.search(gpa, 0, "Pardes");
- defer page_hits.deinit(gpa);
- try std.testing.expect(page_hits.hit_count >= 3);
-
- try p.runSearch(0, "Pardes", .text, .top);
- const results_id = pane.search_pane orelse return error.MissingPdfSearchResults;
- const rows = p.panes[results_id].?.file.?.content;
- try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:1 Pardes\n") != null);
- try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:2 Pardes\n") != null);
- try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:3 Pardes\n") != null);
-
- // Exaggerate the cell aspect only to make the three fixture hits occupy
- // distinct fit-width crops. The search/reveal math must use the same
- // reported metrics as placement, whatever a backend reports.
- p.update(.{ .resize = .{
- .cols = p.screen_w,
- .rows = p.screen_h,
- .cell_pixels = .{ .w = 16, .h = 4 },
- } });
-
- var frame = std.heap.ArenaAllocator.init(gpa);
- defer frame.deinit();
- try std.testing.expect(p.searchStep(0, 1));
- try std.testing.expectEqual(@as(usize, 0), pv.search_hit);
- const first = try p.render(frame.allocator());
- const revision = first.images[0].?.native.revision;
- const first_scroll = pv.document_scroll_y;
- const first_results = pv.search_results orelse return error.MissingPdfPageSearch;
- const first_quad = for (first_results.quads) |item| {
- if (item.hit == 0) break item.quad;
- } else return error.MissingFirstPdfHit;
- const first_y = Pardes.normalizedPdfPixel(
- (first_quad.ul.y + first_quad.ur.y + first_quad.ll.y + first_quad.lr.y) / 4,
- pv.ih,
- );
- const first_geometry = p.pdfGeometry(pane) orelse return error.MissingPdfGeometry;
- try std.testing.expect(first_y >= first_geometry.src.y and
- first_y < first_geometry.src.y + first_geometry.src.h);
-
- try std.testing.expect(p.searchStep(0, 1));
- try std.testing.expectEqual(@as(usize, 1), pv.search_hit);
- _ = frame.reset(.retain_capacity);
- const second = try p.render(frame.allocator());
- try std.testing.expectEqual(revision, second.images[0].?.native.revision);
-
- try std.testing.expect(p.searchStep(0, 1));
- try std.testing.expectEqual(@as(usize, 2), pv.search_hit);
- _ = frame.reset(.retain_capacity);
- const third = try p.render(frame.allocator());
- try std.testing.expectEqual(revision, third.images[0].?.native.revision);
- try std.testing.expect(pv.document_scroll_y != first_scroll);
- const third_results = pv.search_results orelse return error.MissingPdfPageSearch;
- const third_quad = for (third_results.quads) |item| {
- if (item.hit == 2) break item.quad;
- } else return error.MissingThirdPdfHit;
- const third_y = Pardes.normalizedPdfPixel(
- (third_quad.ul.y + third_quad.ur.y + third_quad.ll.y + third_quad.lr.y) / 4,
- pv.ih,
- );
- const third_geometry = p.pdfGeometry(pane) orelse return error.MissingPdfGeometry;
- try std.testing.expect(third_y >= third_geometry.src.y and
- third_y < third_geometry.src.y + third_geometry.src.h);
-}
-
-test "PDF native mouse selection, Look, and highlights share page geometry" {
- if (!pdf_enabled or platform == .web) return;
-
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{
- .file = "docs/design.pdf",
- .cols = 80,
- .rows = 24,
- });
- defer p.deinit();
- const pane = p.panes[0].?;
- var frame = std.heap.ArenaAllocator.init(gpa);
- defer frame.deinit();
-
- // A host without Kitty/native pixels keeps the projected-text contract:
- // j/k can still change pages and a body drag remains a generic selection.
- const fallback = try p.render(frame.allocator());
- try std.testing.expectEqual(@as(usize, 0), fallback.nimages);
- p.update(.{ .key = .{ .cp = 'j' } });
- try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page);
- p.update(.{ .key = .{ .cp = 'k' } });
- try std.testing.expectEqual(@as(usize, 0), pane.pdf.?.page);
- const fallback_rect = p.rects[0];
- const fallback_col = fallback_rect.x + config.GUTTER + 1;
- const fallback_row = fallback_rect.y + BOX_H + 1;
- p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = fallback_col, .row = fallback_row } });
- p.update(.{ .mouse = .{ .button = config.select_button, .kind = .drag, .col = fallback_col + 4, .row = fallback_row } });
- p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = fallback_col + 4, .row = fallback_row } });
- try std.testing.expectEqual(.done, pane.sel[sel_slot].state);
- try std.testing.expect(pane.pdf.?.selection == null);
- pane.sel[sel_slot].state = .none;
-
- p.native_images = true;
- _ = frame.reset(.retain_capacity);
- const plain = try p.render(frame.allocator());
- const plain_revision = plain.images[0].?.native.revision;
-
- var found = try pane.pdf.?.document.search(gpa, 0, "Pardes");
- defer found.deinit(gpa);
- const quad = found.quads[0].quad;
- const nx = (quad.ul.x + quad.ur.x + quad.ll.x + quad.lr.x) / 4;
- const ny = (quad.ul.y + quad.ur.y + quad.ll.y + quad.lr.y) / 4;
- const pv = &pane.pdf.?;
- const source_x: u32 = @intCast(@min(
- pv.iw - 1,
- @as(usize, @intFromFloat(nx * @as(f32, @floatFromInt(pv.iw)))),
- ));
- const source_y: u32 = @intCast(@min(
- pv.ih - 1,
- @as(usize, @intFromFloat(ny * @as(f32, @floatFromInt(pv.ih)))),
- ));
-
- // Bring the known word into the fit-width crop, then invert the shared
- // source/destination geometry to the nearest body cell.
- var geometry = p.pdfGeometry(pane).?;
- if (source_y < geometry.src.y or source_y >= geometry.src.y + geometry.src.h) {
- const overflow = @as(u32, @intCast(pv.ih)) - geometry.src.h;
- const wanted = @min(overflow, source_y -| geometry.src.h / 2);
- pv.pan_y = if (overflow == 0) 0 else @intCast(
- (@as(u64, wanted) * std.math.maxInt(u16) + overflow / 2) / overflow,
- );
- geometry = p.pdfGeometry(pane).?;
- }
- const pixel_x = geometry.dst.x + @as(u32, @intCast(
- @as(u64, source_x - geometry.src.x) * geometry.dst.w / geometry.src.w,
- ));
- const pixel_y = geometry.dst.y + @as(u32, @intCast(
- @as(u64, source_y - geometry.src.y) * geometry.dst.h / geometry.src.h,
- ));
- const r = p.rects[0];
- const base_col: i32 = @intCast(r.x + config.GUTTER + pixel_x / p.cell_pixels.w);
- const base_row: i32 = @intCast(r.y + BOX_H + pixel_y / p.cell_pixels.h);
-
- var selected_col: ?u16 = null;
- var selected_row: u16 = 0;
- const nearby = [_]i32{ 0, -1, 1, -2, 2 };
- find_word: for (nearby) |dy| for (nearby) |dx| {
- const col: u16 = @intCast(std.math.clamp(
- base_col + dx,
- @as(i32, r.x + config.GUTTER),
- @as(i32, r.x + r.w - 1),
- ));
- const row: u16 = @intCast(std.math.clamp(
- base_row + dy,
- @as(i32, r.y + BOX_H),
- @as(i32, r.y + r.h - 1),
- ));
- if (!p.beginPdfSelection(pane, col, row)) continue;
- if (std.ascii.indexOfIgnoreCase(pv.selection_text, "Pardes") != null) {
- selected_col = col;
- selected_row = row;
- break :find_word;
- }
- };
- const word_col = selected_col orelse return error.PdfMouseMappingMissedWord;
- try std.testing.expect(std.ascii.indexOfIgnoreCase(p.heldSelection(0).?, "Pardes") != null);
-
- _ = frame.reset(.retain_capacity);
- const selected_frame = try p.render(frame.allocator());
- const selected_revision = selected_frame.images[0].?.native.revision;
- try std.testing.expect(selected_revision != plain_revision);
-
- // Repeating an identical drag endpoint is a no-op: Kitty/SDL keep the
- // same texture generation instead of retransmitting identical pixels.
- try std.testing.expect(p.beginPdfSelection(pane, word_col, selected_row));
- try std.testing.expect(pv.render_tried);
- _ = frame.reset(.retain_capacity);
- const duplicate = try p.render(frame.allocator());
- try std.testing.expectEqual(selected_revision, duplicate.images[0].?.native.revision);
-
- // A native right-click resolves the same MuPDF-snapped word and feeds it
- // to Look. Search highlights precede selection highlights so the live
- // selection remains visually authoritative where they overlap.
- p.update(.{ .mouse = .{ .button = config.look_button, .kind = .press, .col = word_col, .row = selected_row } });
- p.update(.{ .mouse = .{ .button = config.look_button, .kind = .release, .col = word_col, .row = selected_row } });
- try std.testing.expect(std.ascii.indexOfIgnoreCase(pv.search_query, "Pardes") != null);
- p.resolvePdfSearch(pv);
- const highlights = try p.pdfHighlights(pv);
- try std.testing.expect(highlights.len > 1);
- try std.testing.expectEqual(pdf_impl.HighlightKind.search, highlights[0].kind);
- try std.testing.expectEqual(pdf_impl.HighlightKind.selection, highlights[highlights.len - 1].kind);
- const revision_before_reveal = pv.render_revision;
- const max_document_scroll = @as(f64, @floatFromInt(
- pv.document_height -| p.pdfViewport(pane).?.pixel_h,
- ));
- pv.document_scroll_y = max_document_scroll;
- p.revealPdfSearch(pane, pv);
- const revealed_scroll = pv.document_scroll_y;
- try std.testing.expect(revealed_scroll != max_document_scroll);
- try std.testing.expectEqual(revision_before_reveal, pv.render_revision);
- pv.document_scroll_y = max_document_scroll;
- p.revealPdfSearch(pane, pv); // one shot: subsequent manual pan stays put
- try std.testing.expectEqual(max_document_scroll, pv.document_scroll_y);
- pv.document_scroll_y = revealed_scroll;
- _ = frame.reset(.retain_capacity);
- const searched = try p.render(frame.allocator());
- try std.testing.expect(searched.images[0].?.native.revision != selected_revision);
-
- // Fit and viewport changes can crop a hit which was already revealed.
- // Fit remains placement-only. SDL's physical-viewport quality request
- // changes with a resize and therefore replaces pixels; Kitty's fixed,
- // bandwidth-conscious request retains them.
- const revision_before_geometry_change = searched.images[0].?.native.revision;
- pv.search_reveal_pending = false;
- p.togglePdfFit(pane);
- try std.testing.expect(pv.search_reveal_pending);
- pv.search_reveal_pending = false;
- p.togglePdfFit(pane); // restore the reading-width geometry
- try std.testing.expect(pv.search_reveal_pending);
- pv.search_reveal_pending = false;
- const original_cell_pixels = p.cell_pixels;
- p.update(.{ .resize = .{
- .cols = p.screen_w,
- .rows = p.screen_h,
- .cell_pixels = p.cell_pixels,
- } });
- try std.testing.expect(!pv.search_reveal_pending);
- p.update(.{ .resize = .{
- .cols = p.screen_w,
- .rows = p.screen_h,
- .cell_pixels = .{
- .w = original_cell_pixels.w,
- .h = original_cell_pixels.h + 1,
- },
- } });
- try std.testing.expect(pv.search_reveal_pending);
- _ = frame.reset(.retain_capacity);
- const geometry_changed = try p.render(frame.allocator());
- if (pdf_raster_policy.match_viewport)
- try std.testing.expect(geometry_changed.images[0].?.native.revision !=
- revision_before_geometry_change)
- else
- try std.testing.expectEqual(
- revision_before_geometry_change,
- geometry_changed.images[0].?.native.revision,
- );
- p.update(.{ .resize = .{
- .cols = p.screen_w,
- .rows = p.screen_h,
- .cell_pixels = original_cell_pixels,
- } });
- _ = frame.reset(.retain_capacity);
- _ = try p.render(frame.allocator());
-
- // Unsupported text-selection actions are consumed by the PDF adapter:
- // they neither invent a range over placeholder cells nor disturb the
- // native MuPDF selection. The select-button's no-drag click still clears.
- p.update(.{ .key = .{ .cp = 'v' } });
- try std.testing.expect(pv.selection != null);
- try std.testing.expect(!pane.vsel.active);
- p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = word_col, .row = selected_row } });
- p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = word_col, .row = selected_row } });
- try std.testing.expect(pv.selection == null);
- try std.testing.expect(p.beginPdfSelection(pane, word_col, selected_row));
-
- // Native drag events update MuPDF quads and copied text immediately, but
- // keep the already transmitted page generation stable until release.
- _ = frame.reset(.retain_capacity);
- const before_drag = try p.render(frame.allocator());
- const before_drag_revision = before_drag.images[0].?.native.revision;
- const drag_col = @min(r.x + r.w - 1, word_col +| 12);
- const drag_row = @min(r.y + r.h - 1, selected_row +| 4);
- p.update(.{ .mouse = .{
- .button = config.select_button,
- .kind = .press,
- .col = word_col,
- .row = selected_row,
- } });
- p.update(.{ .mouse = .{
- .button = config.select_button,
- .kind = .drag,
- .col = @min(drag_col, word_col +| 4),
- .row = @min(drag_row, selected_row +| 2),
- } });
- _ = frame.reset(.retain_capacity);
- const during_first_drag = try p.render(frame.allocator());
- try std.testing.expectEqual(
- before_drag_revision,
- during_first_drag.images[0].?.native.revision,
- );
- p.update(.{ .mouse = .{
- .button = config.select_button,
- .kind = .drag,
- .col = drag_col,
- .row = drag_row,
- } });
- try std.testing.expect(p.drag.select.pdf.selection_changed);
- _ = frame.reset(.retain_capacity);
- const during_second_drag = try p.render(frame.allocator());
- try std.testing.expectEqual(
- before_drag_revision,
- during_second_drag.images[0].?.native.revision,
- );
- p.update(.{ .mouse = .{
- .button = config.select_button,
- .kind = .release,
- .col = drag_col,
- .row = drag_row,
- } });
- try std.testing.expect(!pv.render_tried);
- _ = frame.reset(.retain_capacity);
- const committed_drag = try p.render(frame.allocator());
- try std.testing.expectEqual(
- before_drag_revision + 1,
- committed_drag.images[0].?.native.revision,
- );
- _ = frame.reset(.retain_capacity);
- const stable_drag = try p.render(frame.allocator());
- try std.testing.expectEqual(
- committed_drag.images[0].?.native.revision,
- stable_drag.images[0].?.native.revision,
- );
-
- const saved_query = try gpa.dupe(u8, pv.search_query);
- defer gpa.free(saved_query);
- p.setPdfPage(pane, 1);
- try std.testing.expect(pv.selection == null);
- try std.testing.expectEqual(@as(usize, 0), pv.selection_text.len);
- try std.testing.expectEqualStrings(saved_query, pv.search_query);
-}
-
-test "PDF native Exec release may delete its own pane safely" {
- if (!pdf_enabled or platform == .web) return;
-
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{
- .file = "docs/design.pdf",
- .cols = 80,
- .rows = 24,
- });
- defer p.deinit();
- const pane = p.panes[0].?;
- const pv = &pane.pdf.?;
- pv.selection_text = try gpa.dupe(u8, "Del");
- pv.drag_anchor = .{ .x = 0.5, .y = 0.5 };
- pv.drag_head = pv.drag_anchor;
-
- p.pdfDragRelease(pane, .{
- .id = 0,
- .button = config.exec_button,
- .pdf = .{ .native = true },
- });
- try std.testing.expect(p.panes[0] == null);
-}
-
-test "feature-off .pdf argv opens an ordinary file pane" {
- if (comptime pdf_enabled or platform == .web) return;
-
- const p = try Pardes.init(std.testing.allocator, .{
- .file = "docs/design.pdf",
- .cols = 80,
- .rows = 24,
- });
- defer p.deinit();
- const pane = p.panes[0].?;
- try std.testing.expect(pane.file != null);
- try std.testing.expect(pane.image == null);
- try std.testing.expect(!hasPdf(pane));
+test {
+ _ = @import("pdf_pane_integration_test.zig");
+ _ = @import("output_pane_integration_test.zig");
}
const pane_tail = " " ++ config.pane_builtins_str;
const file_pane_tail = " " ++ config.file_pane_builtins_str;
-// Dumps persist the whole rendered tag. These were the two canonical tails
-// before New joined every pane; restoreTail recognizes exactly these spellings
-// as defaults so an old session upgrades, without mistaking an edited tail for
-// chrome it may replace.
+// Version-1 dumps originally persisted only the whole rendered tag. These were
+// the two canonical tails before New joined every pane; the compatibility
+// parser recognizes them as defaults while new dumps carry an explicit tail.
const legacy_pane_tail = " Del";
const legacy_file_pane_tail = " Save Del";
@@ -1377,338 +155,39 @@ const legacy_file_pane_tail = " Save Del";
// sits with the other two comptime folds (builtin_rows, the topbar check).
const Builtin = builtins.registry.Builtin();
-test "PDF builtins and leader paths follow the MuPDF feature gate" {
- const maybe_fit = std.meta.stringToEnum(Builtin, "PdfFit");
- const maybe_tint = std.meta.stringToEnum(Builtin, "PdfTint");
- const maybe_sections = std.meta.stringToEnum(Builtin, "PdfSections");
- try std.testing.expectEqual(pdf_enabled, maybe_fit != null);
- try std.testing.expectEqual(pdf_enabled, maybe_tint != null);
- try std.testing.expectEqual(pdf_enabled, maybe_sections != null);
- if (pdf_enabled) {
- const fit_path = config.leader_path.get(maybe_fit.?) orelse
- return error.MissingPdfFitLeaderPath;
- const tint_path = config.leader_path.get(maybe_tint.?) orelse
- return error.MissingPdfTintLeaderPath;
- const sections_path = config.leader_path.get(maybe_sections.?) orelse
- return error.MissingPdfSectionsLeaderPath;
- try std.testing.expectEqualStrings("tz", fit_path);
- try std.testing.expectEqualStrings("ti", tint_path);
- try std.testing.expectEqualStrings("ts", sections_path);
- const traits = output_pane.traits(.{ .cmd = maybe_sections.? });
- try std.testing.expectEqualStrings(config.pdf_sections_buffer, traits.name);
- try std.testing.expect(traits.steps);
- try std.testing.expect(!traits.commands);
- const help_row = for (builtin_rows) |row| {
- if (row.cmd == maybe_sections.?) break row;
- } else return error.MissingPdfSectionsHelpRow;
- try std.testing.expectEqualStrings("ts", help_row.path.?);
- try std.testing.expect(std.mem.indexOf(u8, help_row.line, "PdfSections") != null);
+/// The PDF integration suite lives beside the PDF implementation instead of
+/// making the core's first sixteen hundred lines pane-specific. These direct
+/// test-only calls reach the few intentionally private core transactions that
+/// the suite must observe; the declaration is empty in every non-test build.
+pub const pdf_test = if (@import("builtin").is_test) struct {
+ pub fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 {
+ return p.tagText(arena, pane);
}
-}
-test "PDF section rows preserve DFS ordinals and sanitise hierarchy" {
- if (!pdf_enabled) return;
- const E = pdf_impl.OutlineEntry;
- const entries = [_]E{
- .{ .depth = 0, .title = null, .is_open = true, .flags = 0, .color = @splat(0), .destination = .none },
- .{ .depth = 1, .title = " Child\n\tTitle\x01 Café \u{2028} Next ", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 2, .x = 12, .y = 34 } } },
- .{ .depth = 0, .title = "", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 0, .x = null, .y = null } } },
- .{ .depth = 0, .title = "External", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .external = "https://example.com/manual" } },
- .{ .depth = 0, .title = "Dead branch", .is_open = false, .flags = 0, .color = @splat(0), .destination = .none },
- .{ .depth = 0, .title = "Unsafe", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .external = "javascript:alert" } },
- .{ .depth = 0, .title = "Linked branch", .is_open = true, .flags = 0, .color = @splat(0), .destination = .none },
- .{ .depth = 1, .title = "Deep link", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .external = "https://example.com/deep" } },
- };
- const rows = try PdfSectionRows.render(std.testing.allocator, "/tmp/manual.pdf", &entries);
- defer std.testing.allocator.free(rows);
- try std.testing.expectEqualStrings(
- "manual.pdf:3:1 [untitled]\n" ++
- "manual.pdf:3:2 Child Title Café Next\n" ++
- "manual.pdf:1:3 [empty title]\n" ++
- "https://example.com/manual External\n" ++
- "https://example.com/deep Linked branch\n" ++
- "https://example.com/deep Deep link\n",
- rows,
- );
- try std.testing.expect(PdfSectionRows.resolve(&entries, 4) == null);
- try std.testing.expect(PdfSectionRows.resolve(&entries, 5) == null);
- const resolved = try PdfSectionRows.resolveOrdinals(std.testing.allocator, &entries);
- defer std.testing.allocator.free(resolved);
- for (entries, 0..) |_, ordinal| {
- const single = PdfSectionRows.resolve(&entries, ordinal);
- if (resolved[ordinal] == std.math.maxInt(usize)) {
- try std.testing.expect(single == null);
- } else {
- const bulk = PdfSectionRows.usableDestination(entries[resolved[ordinal]].destination) orelse
- return error.MissingBulkPdfSectionDestination;
- try std.testing.expect(single != null);
- try std.testing.expect(std.meta.eql(single.?, bulk));
- }
+ pub fn runBuiltin(
+ p: *Pardes,
+ name: []const u8,
+ id: usize,
+ txt: []const u8,
+ arg: ?[]const u8,
+ ) bool {
+ const command = std.meta.stringToEnum(Builtin, name) orelse return false;
+ p.runBuiltin(command, id, txt, arg);
+ return true;
}
- // Every allocation site in the ordinal table and output growth remains
- // atomic: the testing allocator sees each induced failure cleaned up
- // before the first index at which the whole render can succeed.
- var rendered = false;
- for (0..64) |fail_index| {
- var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{
- .fail_index = fail_index,
- });
- const failure_gpa = failing.allocator();
- const attempt = PdfSectionRows.render(failure_gpa, "/tmp/manual.pdf", &entries) catch |err| {
- try std.testing.expectEqual(error.OutOfMemory, err);
- continue;
- };
- failure_gpa.free(attempt);
- rendered = true;
- break;
+ pub fn sync(p: *Pardes) void {
+ p.sync();
}
- try std.testing.expect(rendered);
-}
-test "bulk PDF section resolution matches single Look policy across DFS boundaries" {
- if (!pdf_enabled) return;
- const E = pdf_impl.OutlineEntry;
- const entries = [_]E{
- .{ .depth = 0, .title = "Resolved root", .is_open = true, .flags = 0, .color = @splat(0), .destination = .none },
- .{ .depth = 1, .title = "Unsafe branch", .is_open = true, .flags = 0, .color = @splat(0), .destination = .{ .external = "javascript:unsafe" } },
- .{ .depth = 2, .title = "Safe grandchild", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 1, .x = 4, .y = 8 } } },
- .{ .depth = 1, .title = "Unresolved child", .is_open = false, .flags = 0, .color = @splat(0), .destination = .none },
- .{ .depth = 1, .title = "Sibling", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 2, .x = null, .y = null } } },
- .{ .depth = 0, .title = "Unresolved root", .is_open = false, .flags = 0, .color = @splat(0), .destination = .none },
- .{ .depth = 0, .title = "Next root", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 3, .x = null, .y = null } } },
- };
- const bulk = try PdfSectionRows.resolveOrdinals(std.testing.allocator, &entries);
- defer std.testing.allocator.free(bulk);
- const none = std.math.maxInt(usize);
- try std.testing.expectEqualSlices(usize, &.{ 2, none, 2, none, 4, none, 6 }, bulk);
- for (entries, 0..) |_, ordinal| {
- const single = PdfSectionRows.resolve(&entries, ordinal);
- if (bulk[ordinal] == none) {
- try std.testing.expect(single == null);
- } else {
- const destination = PdfSectionRows.usableDestination(entries[bulk[ordinal]].destination) orelse
- return error.MissingBoundaryPdfSectionDestination;
- try std.testing.expect(single != null);
- try std.testing.expect(std.meta.eql(single.?, destination));
- }
+ pub fn searchStep(p: *Pardes, id: usize, delta: i32) bool {
+ return p.searchStep(id, delta);
}
-}
-
-test "PdfSections output, Look, and n/N share exact cached outline destinations" {
- if (!pdf_enabled or platform == .web) return;
- const gpa = std.testing.allocator;
- var tmp = std.testing.tmpDir(.{});
- defer tmp.cleanup();
- const fixture = try pdf_impl.makeOutlineTestPdf(gpa);
- defer gpa.free(fixture);
- try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "outline.pdf", .data = fixture });
- var path_buf: [256]u8 = undefined;
- const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/outline.pdf", .{tmp.sub_path});
-
- const p = try Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 });
- defer p.deinit();
- // A FILE boot is one pane now, so the launch shell this leans on twice has
- // to be asked for: an empty slot 1 would satisfy the inertness check below
- // for the wrong reason (runBuiltin drops a null pane before any builtin
- // runs) AND would then be handed to the +PdfSections output, which is the
- // one pane the look at the end must NOT come from.
- _ = try p.newPane(1);
- p.layoutAppendColumn(1);
- p.sync(); // rects for the new column, exactly as the two-pane boot did
- while (p.nextEffect()) |_| {}
- const pdf_pane = p.panes[0].?;
- const sections = std.meta.stringToEnum(Builtin, "PdfSections") orelse
- return error.MissingPdfSectionsBuiltin;
- // Pane-scoped: a shell invocation is inert; the PDF invocation opens one
- // output immediately below it and leaves focus/step ownership on the PDF.
- p.runBuiltin(sections, 1, "", null);
- try std.testing.expect(pdf_pane.search_pane == null);
- p.runBuiltin(sections, 0, "", null);
- const output_id = pdf_pane.search_pane orelse return error.MissingPdfSectionsOutput;
- const output = p.panes[output_id].?;
- try std.testing.expectEqual(@as(usize, 0), p.active);
- const pdf_layout = p.layoutFindTerm(0) orelse return error.MissingPdfLayout;
- const output_layout = p.layoutFindTerm(output_id) orelse return error.MissingPdfSectionsLayout;
- try std.testing.expectEqual(pdf_layout.col, output_layout.col);
- try std.testing.expectEqual(pdf_layout.idx + 1, output_layout.idx);
- try std.testing.expect(Pardes.isPdfSectionsOutput(output));
- try std.testing.expect(std.mem.endsWith(u8, output.file.?.path, "/+PdfSections"));
- const tag = try p.tagText(p.scratch.allocator(), pdf_pane);
- try std.testing.expect(std.mem.indexOf(u8, tag, "PdfSections") != null);
-
- const expected = try std.fmt.allocPrint(
- gpa,
- "{s}:2:1 [untitled]\n" ++
- "{s}:2:2 Café 子\n" ++
- "{s}:3:3 [empty title]\n" ++
- "https://example.com/manual External\n",
- .{ "outline.pdf", "outline.pdf", "outline.pdf" },
- );
- defer gpa.free(expected);
- try std.testing.expectEqualStrings(expected, output.file.?.content);
-
- // The outline is loaded once and a clean live output is reused in place.
- try std.testing.expect(pdf_pane.pdf.?.outline_tried);
- const outline_ptr = pdf_pane.pdf.?.outline.?.entries.ptr;
- const output_serial = output.serial;
- const output_revision = output.file.?.revision;
- p.runBuiltin(sections, 0, "", null);
- try std.testing.expectEqual(output_id, pdf_pane.search_pane.?);
- try std.testing.expectEqual(output_serial, p.panes[output_id].?.serial);
- try std.testing.expectEqual(output_revision, p.panes[output_id].?.file.?.revision);
- try std.testing.expectEqual(outline_ptr, pdf_pane.pdf.?.outline.?.entries.ptr);
-
- // The clean remembered path is a pure re-arm: even an allocator which
- // rejects its first request is never consulted, and the outline/content
- // identities remain untouched.
- var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 });
- const ordinary_gpa = p.gpa;
- p.gpa = failing.allocator();
- p.openPdfSections(0);
- p.gpa = ordinary_gpa;
- try std.testing.expectEqual(@as(usize, 0), failing.alloc_index);
- try std.testing.expect(!failing.has_induced_failure);
- try std.testing.expectEqual(outline_ptr, pdf_pane.pdf.?.outline.?.entries.ptr);
- try std.testing.expectEqual(output_revision, p.panes[output_id].?.file.?.revision);
-
- // n SELECTS the generated row and Enter runs it through ordinary Look, so
- // the pair is one step of what used to be one keypress. The look is made
- // FROM the sections buffer, which is what keeps that buffer at the head of
- // the walk: the next n resumes there even though the jump left focus on
- // the PDF. The root and child intentionally resolve to the same exact XYZ
- // destination.
- p.active = 0;
- p.update(.{ .key = .{ .cp = 'n' } });
- try std.testing.expectEqual(output_id, p.active);
- try std.testing.expectEqual(@as(i32, 0), output.cur_row);
- p.update(.{ .key = .{ .cp = Key.enter } });
- try std.testing.expectEqual(@as(usize, 1), pdf_pane.pdf.?.page);
- const child_destination = pdf_pane.pdf.?.outline.?.entries[1].destination.internal;
- const expected_y = @as(f64, @floatFromInt(pdf_pane.pdf.?.page_starts[1])) +
- @as(f64, child_destination.y.?) / @as(f64, pdf_pane.pdf.?.page_sizes[1].height) *
- @as(f64, @floatFromInt(pdf_pane.pdf.?.page_heights[1]));
- try std.testing.expectApproxEqAbs(expected_y, pdf_pane.pdf.?.document_scroll_y, 0.001);
- p.update(.{ .key = .{ .cp = 'n' } });
- try std.testing.expectEqual(@as(i32, 1), output.cur_row);
- p.update(.{ .key = .{ .cp = Key.enter } });
- try std.testing.expectApproxEqAbs(expected_y, pdf_pane.pdf.?.document_scroll_y, 0.001);
- p.update(.{ .key = .{ .cp = 'n' } });
- p.update(.{ .key = .{ .cp = Key.enter } });
- try std.testing.expectEqual(@as(usize, 2), pdf_pane.pdf.?.page);
- // ...and N is the exact inverse of n: the row it walks back onto is the
- // one n came from, and looking it lands on the same destination again.
- p.update(.{ .key = .{ .cp = 'N' } });
- try std.testing.expectEqual(@as(i32, 1), output.cur_row);
- p.update(.{ .key = .{ .cp = Key.enter } });
- try std.testing.expectEqual(@as(usize, 1), pdf_pane.pdf.?.page);
- try std.testing.expectApproxEqAbs(expected_y, pdf_pane.pdf.?.document_scroll_y, 0.001);
-
- // Re-looking the same ordinal changes placement only, never pixel identity.
- p.native_images = true;
- var frame = std.heap.ArenaAllocator.init(gpa);
- defer frame.deinit();
- _ = try p.render(frame.allocator());
- const revision = pdf_pane.pdf.?.render_revision;
- const child_row = modal.lineSlice(output.file.?.content, 1);
- const child_target = child_row[0 .. std.mem.indexOfScalar(u8, child_row, ' ') orelse child_row.len];
- p.lookAt(output_id, child_target);
- try std.testing.expectEqual(revision, pdf_pane.pdf.?.render_revision);
- _ = frame.reset(.retain_capacity);
- _ = try p.render(frame.allocator());
- try std.testing.expectEqual(revision, pdf_pane.pdf.?.render_revision);
-
- // The same path:page:col token from any non-PdfSections pane retains the
- // established PDF-search meaning for col; no outline reveal is installed.
- try p.setPdfSearchQuery(&pdf_pane.pdf.?, "ordinary search semantics");
- p.lookAt(1, child_target);
- try std.testing.expectEqual(@as(usize, 1), pdf_pane.pdf.?.search_hit);
- try std.testing.expect(pdf_pane.pdf.?.outline_reveal_pending == null);
- try p.setPdfSearchQuery(&pdf_pane.pdf.?, "");
-
- // Step forward onto the external row: two n's reach it, Enter takes the
- // existing open_link effect, and a URL look moves focus nowhere — so the
- // walk is still standing in the buffer, ready for the next n.
- //
- // Asked FROM the sections buffer rather than from the PDF, because the two
- // direct lookAt calls just above put the shell at the head of the ring and
- // this is a test about the rows, not about the ordering.
- p.active = output_id;
- p.update(.{ .key = .{ .cp = 'n' } });
- p.update(.{ .key = .{ .cp = 'n' } });
- try std.testing.expectEqual(@as(i32, 3), output.cur_row);
- p.update(.{ .key = .{ .cp = Key.enter } });
- var opened = false;
- while (p.nextEffect()) |effect| switch (effect) {
- .open_link => |uri| opened = std.mem.eql(u8, uri.slice(), "https://example.com/manual"),
- else => {},
- };
- try std.testing.expect(opened);
- try std.testing.expectEqual(output_id, p.active);
-
- // Missing/hostile coordinates clamp to page space. x affects only a
- // layout with horizontal overflow and still does not touch raster state.
- const saved_size = pdf_pane.pdf.?.page_sizes[1];
- pdf_pane.pdf.?.fit = .width;
- pdf_pane.pdf.?.layout_valid = false;
- pdf_pane.pdf.?.pan_x = 123;
- p.revealPdfOutlineDestination(pdf_pane, .{ .page = 1, .x = 190, .y = null });
- try std.testing.expectEqual(@as(u16, 123), pdf_pane.pdf.?.pan_x);
- try std.testing.expectEqual(@as(f64, @floatFromInt(pdf_pane.pdf.?.page_starts[1])), pdf_pane.pdf.?.document_scroll_y);
- pdf_pane.pdf.?.page_sizes[1] = .{ .width = 400, .height = 100 };
- pdf_pane.pdf.?.fit = .height;
- pdf_pane.pdf.?.layout_valid = false;
- pdf_pane.pdf.?.pan_x = 0;
- p.revealPdfOutlineDestination(pdf_pane, .{ .page = 1, .x = 390, .y = -500 });
- try std.testing.expect(pdf_pane.pdf.?.pan_x > 0);
- try std.testing.expectEqual(@as(f64, @floatFromInt(pdf_pane.pdf.?.page_starts[1])), pdf_pane.pdf.?.document_scroll_y);
- p.revealPdfOutlineDestination(pdf_pane, .{ .page = 1, .x = std.math.inf(f32), .y = std.math.inf(f32) });
- try std.testing.expectEqual(@as(f64, @floatFromInt(pdf_pane.pdf.?.page_starts[1])), pdf_pane.pdf.?.document_scroll_y);
- try std.testing.expectEqual(revision, pdf_pane.pdf.?.render_revision);
- pdf_pane.pdf.?.page_sizes[1] = saved_size;
-
- // An edited output is preserved. Closing the replacement and reusing its
- // slot for an unrelated pane cannot trick the remembered id into a refill.
- file_pane.setContent(p, &output.file.?, try gpa.dupe(u8, "edited by user\n"));
- p.openPdfSections(0);
- const replacement_id = pdf_pane.search_pane orelse return error.MissingReplacementSections;
- try std.testing.expect(replacement_id != output_id);
- try std.testing.expectEqualStrings("edited by user\n", output.file.?.content);
- const replacement_serial = p.panes[replacement_id].?.serial;
- p.runBuiltin(.Del, replacement_id, "", null);
- try std.testing.expect(p.panes[replacement_id] == null);
- const unrelated = try p.openTutorView(replacement_id);
- p.placeDoc(0, replacement_id, unrelated);
- try std.testing.expect(unrelated.serial != replacement_serial);
- p.openPdfSections(0);
- try std.testing.expect(pdf_pane.search_pane.? != replacement_id);
- try std.testing.expect(Pardes.isPdfSectionsOutput(p.panes[pdf_pane.search_pane.?].?));
-}
-
-test "PdfSections opens an empty cached output for a PDF without an outline" {
- if (!pdf_enabled or platform == .web) return;
- const gpa = std.testing.allocator;
- var tmp = std.testing.tmpDir(.{});
- defer tmp.cleanup();
- const fixture = try pdf_impl.makeNoOutlineTestPdf(gpa);
- defer gpa.free(fixture);
- try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "plain.pdf", .data = fixture });
- var path_buf: [256]u8 = undefined;
- const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/plain.pdf", .{tmp.sub_path});
- const p = try Pardes.init(gpa, .{ .file = path });
- defer p.deinit();
- p.openPdfSections(0);
- const pane = p.panes[0].?;
- const output_id = pane.search_pane orelse return error.MissingEmptyPdfSections;
- try std.testing.expectEqual(@as(usize, 0), p.panes[output_id].?.file.?.content.len);
- try std.testing.expect(pane.pdf.?.outline_tried);
- try std.testing.expectEqual(@as(usize, 0), pane.pdf.?.outline.?.entries.len);
- const serial = p.panes[output_id].?.serial;
- p.openPdfSections(0);
- try std.testing.expectEqual(output_id, pane.search_pane.?);
- try std.testing.expectEqual(serial, p.panes[output_id].?.serial);
-}
+ pub fn heldSelection(p: *Pardes, first: usize) ?[]const u8 {
+ return p.heldSelection(first);
+ }
+} else struct {};
fn nextPipeEffect(p: *Pardes) ?u32 {
while (p.nextEffect()) |effect| switch (effect) {
@@ -1896,7 +375,7 @@ test "startup config runs builtin lines in order and isolates bad lines" {
// well-formed-but-failing lines. `Kill trailing-garbage` must not be
// accepted as Kill, nor fall through to the shell from config.
try std.testing.expectEqualStrings("acme", p.theme().name);
- try std.testing.expect(!p.themeAnimationActive());
+ try std.testing.expect(!p.animationActive());
try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*);
try std.testing.expect(!p.quit);
for (0..p.effects_len) |i| switch (p.effects[(p.effects_head + i) % p.effects.len]) {
@@ -1908,48 +387,26 @@ test "startup config runs builtin lines in order and isolates bad lines" {
};
}
-test "Config prints the resolved startup config path and refreshes one buffer" {
- const path = "/home/pardes-test/.config/pardes";
- // `startup_config` stays null: the file is MISSING and the path still
- // resolves, which is the case this builtin exists to answer.
- const p = try Pardes.init(std.testing.allocator, .{ .startup_config_path = path });
- defer p.deinit();
-
- try std.testing.expect(p.executeBuiltinLine(0, "Config"));
- const opened = p.active;
- const out = p.panes[opened].?.file.?;
- try std.testing.expectEqualStrings(path ++ "\n", out.content);
- try std.testing.expectEqualStrings(config.config_buffer, std.fs.path.basename(out.path));
- try std.testing.expectEqual(output_pane.Origin{ .cmd = .Config }, out.output.?.from);
-
- // Asking again refreshes the buffer already open rather than stacking a
- // byte-identical twin beside it — Help's rule, and for the same reason.
- try std.testing.expect(p.executeBuiltinLine(0, "Config"));
- try std.testing.expectEqual(opened, p.active);
- var buffers: usize = 0;
- for (p.panes) |slot| {
- const sp = slot orelse continue;
- const f = sp.file orelse continue;
- const o = f.output orelse continue;
- if (std.meta.eql(o.from, output_pane.Origin{ .cmd = .Config })) buffers += 1;
- }
- try std.testing.expectEqual(@as(usize, 1), buffers);
-}
-
-test "Config says so when there is no per-user config path" {
- const p = try Pardes.init(std.testing.allocator, .{});
+test "scene effects keep the idle frontend animation clock active" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer p.deinit();
+ try std.testing.expect(!p.animationActive());
- try std.testing.expect(p.executeBuiltinLine(0, "Config"));
- const out = p.panes[p.active].?.file.?;
- try std.testing.expect(std.mem.indexOf(u8, out.content, "no per-user config path") != null);
+ p.settings.scene_effects.crt = true;
+ try std.testing.expect(p.animationActive());
+ p.settings.scene_effects = .{ .ripple = true };
+ try std.testing.expect(p.animationActive());
+ p.settings.scene_effects = .{ .glitch = true };
+ try std.testing.expect(p.animationActive());
+ p.settings.scene_effects = .{};
+ try std.testing.expect(!p.animationActive());
}
test "runtime theme changes animate chrome and retarget without a jump" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer p.deinit();
const first = p.chromeTheme().*;
- try std.testing.expect(!p.themeAnimationActive());
+ try std.testing.expect(!p.animationActive());
const dark_index = for (themes, 0..) |th, i| {
if (std.mem.eql(u8, th.name, "dark")) break i;
@@ -1963,7 +420,7 @@ test "runtime theme changes animate chrome and retarget without a jump" {
// the exact previous frame.
try std.testing.expectEqualStrings("dark", p.theme().name);
try std.testing.expectEqual(first, p.chromeTheme().*);
- try std.testing.expect(p.themeAnimationActive());
+ try std.testing.expect(p.animationActive());
p.update(.tick);
p.update(.tick);
p.update(.tick);
@@ -1973,9 +430,9 @@ test "runtime theme changes animate chrome and retarget without a jump" {
p.setThemeIndex(acme_index);
try std.testing.expectEqualStrings("acme", p.theme().name);
try std.testing.expectEqual(midflight, p.chromeTheme().*);
- try std.testing.expect(p.themeAnimationActive());
+ try std.testing.expect(p.animationActive());
for (0..animation.transition_steps) |_| p.update(.tick);
- try std.testing.expect(!p.themeAnimationActive());
+ try std.testing.expect(!p.animationActive());
try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*);
// Extra ticks are inert at the exact endpoint.
p.update(.tick);
@@ -1999,15 +456,30 @@ test "render uses target content colors while chrome is in flight" {
Color{ .rgb = source_chrome.tag_bg },
surface.at(0, 0).style.bg,
);
+ const topbar_cell = surface.at(0, 0);
+ try std.testing.expect(!std.mem.eql(u8, topbar_cell.grapheme(), " "));
+ try std.testing.expectEqual(FontRole.tagline, topbar_cell.style.font_role);
+ const r = p.rects[0];
+ const tag_cell = find_tag_cell: {
+ var x = r.x + config.GUTTER;
+ while (x < r.x + r.w) : (x += 1) {
+ const cell = surface.at(x, r.y);
+ if (!std.mem.eql(u8, cell.grapheme(), " ")) break :find_tag_cell cell;
+ }
+ return error.MissingTagText;
+ };
+ try std.testing.expectEqual(FontRole.tagline, tag_cell.style.font_role);
+ try std.testing.expectEqual(FontRole.tagline, surface.at(r.x, r.y).style.font_role);
+ try std.testing.expectEqual(FontRole.tagline, surface.at(r.x + r.w - 1, r.y).style.font_role);
// The document/terminal page does not ride the chrome animation: acme's
// target background is already present in the same render.
- const r = p.rects[0];
const body = surface.at(r.x + r.w - 1, r.y + r.h - 1);
+ try std.testing.expectEqual(FontRole.body, body.style.font_role);
try std.testing.expectEqual(
Color{ .rgb = p.theme().bg.? },
body.style.bg,
);
- try std.testing.expect(p.themeAnimationActive());
+ try std.testing.expect(p.animationActive());
}
test "pane-tag Exec prefers New and argument builtins before shell fallback" {
@@ -2015,6 +487,7 @@ test "pane-tag Exec prefers New and argument builtins before shell fallback" {
const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30 });
defer p.deinit();
while (p.nextEffect()) |_| {} // initial shell spawn/geometry
+ p.acknowledgeShell(0, "/bin/sh", false);
const pane = p.panes[0].?;
try std.testing.expectEqualStrings(" New Del", Pardes.curTail(pane));
@@ -2165,6 +638,17 @@ test "legacy default tag tails upgrade while custom tails remain owned" {
try std.testing.expect(terminal.tag_init);
try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(terminal));
+ // This happens to be the historical FILE default, but on a terminal it is
+ // user-owned text and must not be swallowed by migration.
+ terminal.tag_tail_len = 0;
+ terminal.tag_init = false;
+ const terminal_fileish = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Save Del", .{
+ try p.tagPrefix(terminal),
+ });
+ p.restoreTail(terminal, terminal_fileish);
+ try std.testing.expect(terminal.tag_init);
+ try std.testing.expectEqualStrings(" Save Del", Pardes.curTail(terminal));
+
// A savable file has a distinct old default. Save remains first after the
// migration so the tag's established `:w<Tab>` route is unchanged.
const file = try p.hxOpenFileContent("");
@@ -2182,6 +666,30 @@ test "legacy default tag tails upgrade while custom tails remain owned" {
p.restoreTail(file, file_custom);
try std.testing.expect(file.tag_init);
try std.testing.expectEqualStrings(" Save Mine Del", Pardes.curTail(file));
+
+ // Conversely the generic pane's current default is a custom edit on a
+ // savable file. Legacy recognition is scoped to the source pane class.
+ file.tag_tail_len = 0;
+ file.tag_init = false;
+ const file_generic = try std.fmt.allocPrint(p.scratch.allocator(), "{s} New Del", .{
+ try p.tagPrefix(file),
+ });
+ p.restoreTail(file, file_generic);
+ try std.testing.expect(file.tag_init);
+ try std.testing.expectEqualStrings(" New Del", Pardes.curTail(file));
+
+ // Before renderer choices appeared in the live prefix, image dumps began
+ // with only `img PATH`. Their custom tails still migrate through the
+ // pane-specific legacy-prefix recognizer.
+ const image_doc = try image_pane.create(p, 1, "/tmp/legacy image.ppm", &.{});
+ p.restoreDumpTail(image_doc, .{
+ .kind = .image,
+ .tag = "img /tmp/legacy image.ppm Keep Del",
+ .body = "",
+ .image = .{ .path = "/tmp/legacy image.ppm" },
+ });
+ try std.testing.expect(image_doc.tag_init);
+ try std.testing.expectEqualStrings(" Keep Del", image_doc.tagSlice());
}
test "unknown Exec from an image writes to a terminal in the image directory" {
@@ -2189,12 +697,12 @@ test "unknown Exec from an image writes to a terminal in the image directory" {
const p = try Pardes.init(gpa, .{});
defer p.deinit();
while (p.nextEffect()) |_| {}
+ p.acknowledgeShell(0, "/bin/sh", false);
p.setCwd(0, "/tmp/pardes-image-dir");
- const image_pane = try p.newDocPane(1);
- image_pane.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-image-dir/pic.ppm") };
- image_pane.kind = .image;
- try std.testing.expectEqualStrings("/tmp/pardes-image-dir", Pardes.paneDir(image_pane));
+ const image_doc = try p.newDocPane(1);
+ image_doc.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-image-dir/pic.ppm") };
+ try std.testing.expectEqualStrings("/tmp/pardes-image-dir", Pardes.paneDir(image_doc));
try std.testing.expectEqual(@as(?usize, 0), p.execute(1, "echo image-fallback"));
var sent: [256]u8 = undefined;
@@ -2266,9 +774,72 @@ test "Exec in a terminal whose tty is taken spawns a shell instead of typing at
try std.testing.expectEqual(@as(u8, @intCast(dst)), sp.pane);
// ...in the directory the command was about, which is the taken pane's own
try std.testing.expectEqualStrings("/tmp/pardes-taken", sp.cwd.slice());
+ // A plain/unsupported shell has no OSC 133 B to wait for. Successful
+ // fork acknowledgement opens the gate and the pty itself buffers input
+ // until that child reads it.
+ try std.testing.expectEqual(@as(usize, 0), sent_len);
+ p.acknowledgeShell(dst, "/bin/sh", false);
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => |w| {
+ try std.testing.expectEqual(@as(u8, @intCast(dst)), w.pane);
+ @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice());
+ sent_len += w.bytes.slice().len;
+ },
+ else => {},
+ };
try std.testing.expectEqualStrings("echo taken-fallback\r", sent[0..sent_len]);
}
+test "image dump restores source bytes renderer choices and exact custom tail" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true });
+ defer p.deinit();
+ p.deinitPane(p.panes[0].?);
+ p.panes[0] = null;
+
+ const source = "\x00embedded image bytes\xff";
+ const raw = try p.image_gpa.dupe(u8, source);
+ const pane = image_pane.create(p, 0, "/missing/restored-image.ppm", raw) catch |err| {
+ p.image_gpa.free(raw);
+ return err;
+ };
+ pane.image.?.petscii = true;
+ pane.image.?.pmode = .terminal;
+ pane.image.?.ascii = false;
+ try std.testing.expect(pane.appendTag(" Keep Del"));
+ pane.tag_init = true;
+ try p.dumpState();
+
+ const first = try dump.readZon(gpa, p.dump_out.?, "image-first-dump");
+ defer dump.free(gpa, first);
+ try std.testing.expect(first.panes[0].image.?.petscii);
+ try std.testing.expectEqual(dump.ImagePalette.terminal, first.panes[0].image.?.palette);
+ try std.testing.expect(!first.panes[0].image.?.ascii);
+ try std.testing.expectEqualStrings(" Keep Del", first.panes[0].tag_tail.?);
+
+ const restored = try Pardes.initFromDump(gpa, .{ .tty_only = true }, p.dump_out.?);
+ defer restored.deinit();
+ const restored_pane = restored.panes[0].?;
+ try std.testing.expect(restored_pane.image.?.petscii);
+ try std.testing.expectEqual(image.PaletteMode.terminal, restored_pane.image.?.pmode);
+ try std.testing.expect(!restored_pane.image.?.ascii);
+ try std.testing.expect(restored_pane.tag_init);
+ try std.testing.expectEqualStrings(" Keep Del", restored_pane.tagSlice());
+ try std.testing.expectEqualSlices(u8, source, restored_pane.image.?.raw);
+
+ try restored.dumpState();
+ const redump = try dump.readZon(gpa, restored.dump_out.?, "image-redump");
+ defer dump.free(gpa, redump);
+ const encoded = redump.panes[0].image.?.bytes_b64;
+ const decoded = try dump.decodeBytes(gpa, encoded);
+ defer gpa.free(decoded);
+ try std.testing.expectEqualSlices(u8, source, decoded);
+ try std.testing.expect(redump.panes[0].image.?.petscii);
+ try std.testing.expectEqual(dump.ImagePalette.terminal, redump.panes[0].image.?.palette);
+ try std.testing.expect(!redump.panes[0].image.?.ascii);
+ try std.testing.expectEqualStrings(" Keep Del", redump.panes[0].tag_tail.?);
+}
+
test "Exec from a document pane skips an occupied terminal in its directory and spawns" {
// The test above this pair ("unknown Exec from an image...") is the same
// setup with the terminal at its prompt, and it reuses pane 0. The single
@@ -2279,9 +850,8 @@ test "Exec from a document pane skips an occupied terminal in its directory and
while (p.nextEffect()) |_| {}
p.setCwd(0, "/tmp/pardes-image-dir");
- const image_pane = try p.newDocPane(1);
- image_pane.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-image-dir/pic.ppm") };
- image_pane.kind = .image;
+ const image_doc = try p.newDocPane(1);
+ image_doc.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-image-dir/pic.ppm") };
var host: FakeTtyQuery = .{};
host.install(p);
host.taken[0] = true;
@@ -2305,6 +875,16 @@ test "Exec from a document pane skips an occupied terminal in its directory and
};
const sp = (spawned orelse return error.NoShellForTheOccupiedTerminal).spawn;
try std.testing.expectEqualStrings("/tmp/pardes-image-dir", sp.cwd.slice());
+ try std.testing.expectEqual(@as(usize, 0), sent_len);
+ p.acknowledgeShell(dst, "/bin/sh", false);
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => |w| {
+ try std.testing.expectEqual(@as(u8, @intCast(dst)), w.pane);
+ @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice());
+ sent_len += w.bytes.slice().len;
+ },
+ else => {},
+ };
try std.testing.expectEqualStrings("echo image-fallback\r", sent[0..sent_len]);
}
@@ -2355,6 +935,7 @@ test "a terminal its program gave back takes command lines again" {
const p = try Pardes.init(gpa, .{});
defer p.deinit();
while (p.nextEffect()) |_| {}
+ p.acknowledgeShell(0, "/bin/sh", false);
p.setCwd(0, "/tmp/pardes-taken-flip");
var host: FakeTtyQuery = .{};
@@ -2599,7 +1180,7 @@ 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 "Font records the face it chose, which is the name the Debug overlay prints" {
+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 });
defer p.deinit();
@@ -2608,25 +1189,61 @@ test "Font records the face it chose, which is the name the Debug overlay prints
const installed = fonts.list(arena.allocator(), null);
if (installed.len == 0) return; // a machine with no monospace face at all
- // module state, and the shell takes it on its next pass: leave neither the
- // request nor the name behind for whatever test runs after this one
- defer {
- fonts.want = null;
- fonts.chosen = "";
- }
var line: [512]u8 = undefined;
const cmd = try std.fmt.bufPrint(&line, "Font {s}", .{installed[0].name});
try std.testing.expect(p.executeBuiltinLine(p.active, cmd));
- // the PATH is what the shell needs and the NAME is what a person reads;
- // one is the other's stem, so the two can never name different files
- try std.testing.expect(fonts.want != null);
- try std.testing.expectEqualStrings(installed[0].name, fonts.chosen);
- try std.testing.expectEqualStrings(fonts.chosen, std.fs.path.stem(fonts.want.?));
+ try std.testing.expect(p.settings.font.pending);
+ try std.testing.expectEqualStrings(installed[0].name, p.settings.font.requested_name.get());
+ try std.testing.expectEqualStrings(installed[0].path, p.settings.font.requested_path.get());
+ try std.testing.expectEqualStrings("", p.settings.font.effective_name.get());
- // ...and a name nothing answers to changes neither, so the overlay goes on
- // naming the face that is actually on
+ // Boot/zoom/display-scale reports describe the face already being drawn;
+ // they must neither answer nor reject the new request waiting beside it.
+ try std.testing.expect(p.observeFont("Embedded Mono", 1200, .pixels));
+ try std.testing.expectEqualStrings("Embedded Mono", p.settings.font.effective_name.get());
+ try std.testing.expect(p.settings.font.pending);
+ try std.testing.expect(!p.acknowledgeFont("Stray Ack", 1250, .pixels));
+ p.rejectFont();
+ try std.testing.expect(p.settings.font.pending);
+
+ const request = p.takeFontRequest() orelse return error.MissingFontRequest;
+ try std.testing.expectEqualStrings(installed[0].path, request);
+ try std.testing.expect(p.settings.font.pending);
+ try std.testing.expect(p.takeFontRequest() == null);
+ const fake_track: panel_animation.Track = .{
+ .serial = p.panes[p.active].?.serial,
+ .pane = @intCast(p.active),
+ .effect = .slide,
+ .from = .{ .w = 20, .h = 10 },
+ .to = .{ .x = 10, .w = 20, .h = 10 },
+ };
+ p.panel_tracks[p.active] = fake_track;
+ p.presented_panel_tracks[p.active] = fake_track;
+ p.panel_presentation_ready = true;
+ try std.testing.expect(p.acknowledgeFont(installed[0].name, 1375, .pixels));
+ try std.testing.expect(!p.settings.font.pending);
+ try std.testing.expectEqualStrings(installed[0].name, p.settings.font.effective_name.get());
+ try std.testing.expectEqual(@as(u16, 1375), p.settings.font.effective_size_hundredths);
+ try std.testing.expectEqual(FontSizeUnit.pixels, p.settings.font.effective_size_unit);
+ try std.testing.expect(p.panel_tracks[p.active] == null);
+ try std.testing.expect(p.panel_presentation_pending);
+ try std.testing.expect(!p.acknowledgeFont("Duplicate Ack", 1400, .pixels));
+
+ // Asking for the face already on screen is still a real host round trip,
+ // but it does not invalidate the frozen layer: its effective tuple did
+ // not change. A rejected request likewise never reaches acknowledgeFont.
+ p.acknowledgePanelPresentation(&.{});
+ try std.testing.expect(p.executeBuiltinLine(p.active, cmd));
+ _ = p.takeFontRequest() orelse return error.MissingRepeatedFontRequest;
+ p.panel_tracks[p.active] = fake_track;
+ try std.testing.expect(p.acknowledgeFont(installed[0].name, 1375, .pixels));
+ try std.testing.expect(p.panel_tracks[p.active] != null);
+ try std.testing.expect(!p.panel_presentation_pending);
+
+ // A name nothing answers to changes neither request nor effective state.
try std.testing.expect(p.executeBuiltinLine(p.active, "Font zzz-no-such-face"));
- try std.testing.expectEqualStrings(installed[0].name, fonts.chosen);
+ try std.testing.expectEqualStrings(installed[0].name, p.settings.font.requested_name.get());
+ try std.testing.expectEqualStrings(installed[0].name, p.settings.font.effective_name.get());
}
test "startup config overrides restored styling before initFromDump returns" {
@@ -2642,12 +1259,72 @@ test "startup config overrides restored styling before initFromDump returns" {
);
defer restored.deinit();
try std.testing.expectEqualStrings("acme", restored.theme().name);
- try std.testing.expect(!source.themeAnimationActive());
- try std.testing.expect(!restored.themeAnimationActive());
+ try std.testing.expect(!source.animationActive());
+ try std.testing.expect(!restored.animationActive());
try std.testing.expectEqual(ChromeTheme.fromTheme(source.theme()), source.chromeTheme().*);
try std.testing.expectEqual(ChromeTheme.fromTheme(restored.theme()), restored.chromeTheme().*);
}
+test "restored sessions animate layout changes after bootstrap" {
+ if (platform == .web) return;
+ const source = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 16 });
+ defer source.deinit();
+ try source.dumpState();
+
+ const restored = try Pardes.initFromDump(
+ std.testing.allocator,
+ .{ .tty_only = true, .cols = 80, .rows = 16, .startup_config = "PanelSlide\n" },
+ source.dump_out.?,
+ );
+ defer restored.deinit();
+ try std.testing.expectEqual(panel_animation.Transition.slide, restored.settings.panel_transition);
+ try std.testing.expect(!restored.animationActive());
+
+ var frame: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer frame.deinit();
+ const initial = try restored.render(frame.allocator());
+ restored.acknowledgePanelPresentation(initial.panelTracks());
+ _ = try restored.newShell(1, "");
+ try std.testing.expect(restored.layoutSplitColumn(0, 1, false));
+ restored.sync();
+
+ try std.testing.expect(restored.panel_tracks[0] != null);
+ try std.testing.expect(restored.panel_tracks[1] != null);
+ try std.testing.expect(restored.animationActive());
+}
+
+test "restored terminals keep monotonic nonzero pane identities" {
+ const gpa = std.testing.allocator;
+ const source = try Pardes.init(gpa, .{ .shells = 3 });
+ defer source.deinit();
+ try source.dumpState();
+
+ const restored = try Pardes.initFromDump(gpa, .{}, source.dump_out.?);
+ defer restored.deinit();
+
+ var ids: [MAX_PANES]usize = undefined;
+ var serials: [MAX_PANES]u32 = undefined;
+ var n: usize = 0;
+ for (restored.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ if (!pane.isTerminal()) continue;
+ try std.testing.expect(pane.serial != 0);
+ for (serials[0..n]) |seen| try std.testing.expect(pane.serial != seen);
+ ids[n] = id;
+ serials[n] = pane.serial;
+ n += 1;
+ }
+ try std.testing.expectEqual(@as(usize, 3), n);
+ for (ids[0..n], serials[0..n]) |id, serial|
+ try std.testing.expectEqual(id, restored.paneBySerial(serial).?);
+
+ const before = restored.next_serial;
+ const free = restored.freeSlot() orelse return error.NoFreePane;
+ const fresh = try restored.newShell(free, "");
+ try std.testing.expectEqual(before + 1, fresh.serial);
+ try std.testing.expectEqual(fresh.serial, restored.next_serial);
+}
+
/// How a `Chord` is SPELLED in the index. The named keys come from Key's OWN
/// declarations rather than a table beside them — a new special key names
/// itself here, and a wrong name is impossible because there is only one. The
@@ -2878,15 +1555,7 @@ fn mix(a: [3]u8, b: [3]u8) [3]u8 {
return out;
}
-const UNDO_MAX = 256;
-const EDIT_UNDO_MAX = 64; // terminal snapshots copy the edit buffer; cap tighter
-const PDF_RASTER_MAX = 256; // enough for every visible page in a pathological tiny-page viewport
-// How many retired page buffers one PDF pane may hold at once while a frame
-// swaps its visible set. Four covers a viewport straddling a page boundary
-// with slack; beyond that the bytes go back to the allocator. See
-// PdfView.spare — this is a within-frame relay, not a cache.
-const PDF_RASTER_SPARE = 4;
-const TAG_TAIL_CAP = 4096; // one editable command line; extra input is refused
+const TAG_TAIL_CAP = dump.max_tag_tail; // one editable command line; extra input is refused
const TTY_REPLAY_CAP = 1024 * 1024; // oldest bytes are evicted from the dump/replay record
const EFFECT_CAP = 4096; // one update may queue 256 KiB of ordered 64-byte writes
@@ -2969,6 +1638,11 @@ fn fold(comptime C: type) [@typeInfo(C).@"struct".decls.len]Theme {
/// exists — NextColor is a browse, not a way to arrive anywhere in particular.
pub const themes = fold(curated) ++ fold(@import("generated_themes"));
+comptime {
+ if (themes.len > std.math.maxInt(u16) + 1)
+ @compileError("runtime config theme index no longer fits u16");
+}
+
// Two themes answering to one name is a bug: `Theme <name>` resolves by name
// and would silently pick whichever came first, and ThemeSel would list the
// loser as a row that does nothing. The generated half cannot collide with
@@ -3023,6 +1697,9 @@ const initial_chrome = ChromeTheme.fromTheme(&themes[0]);
pub const Color = union(enum) { default, index: u8, rgb: [3]u8 };
+pub const FontRole = enum(u8) { body, tagline };
+pub const FontSizeUnit = runtime_cfg.FontSizeUnit;
+
pub const CellStyle = struct {
fg: Color = .default,
bg: Color = .default,
@@ -3034,6 +1711,7 @@ pub const CellStyle = struct {
invisible: bool = false,
strikethrough: bool = false,
ul: enum { off, single, double, curly, dotted, dashed } = .off,
+ font_role: FontRole = .body,
};
/// One surface cell. `default = true` means "never painted this frame": the
@@ -3047,8 +1725,33 @@ pub const Cell = struct {
pub fn grapheme(c: *const Cell) []const u8 {
return c.text[0..c.len];
}
+
+ /// Equality of what a shell can actually present. Bytes past `len` are
+ /// scratch left by earlier graphemes and must never manufacture a panel
+ /// diff; an unpainted default cell likewise has no visible style/text.
+ pub fn visuallyEqual(a: *const Cell, b: *const Cell) bool {
+ if (a.default or b.default) return a.default and b.default;
+ return a.len == b.len and
+ std.mem.eql(u8, a.grapheme(), b.grapheme()) and
+ std.meta.eql(a.style, b.style);
+ }
};
+test "cell visual equality ignores dead grapheme tail bytes" {
+ var a: Cell = .{ .default = false };
+ var b = a;
+ a.text[0] = 'x';
+ b.text[0] = 'x';
+ a.text[4] = 'a';
+ b.text[4] = 'b';
+ try std.testing.expect(a.visuallyEqual(&b));
+ b.style.bold = true;
+ try std.testing.expect(!a.visuallyEqual(&b));
+ a.default = true;
+ b.default = true;
+ try std.testing.expect(a.visuallyEqual(&b));
+}
+
/// One generation of a pixel attachment. `serial` identifies the pane for its
/// whole lifetime; `revision` identifies pixels rendered later by that same
/// pane (for example, a different PDF page or zoom level). Backends must use
@@ -3070,26 +1773,8 @@ pub const ImageCacheKey = if (pdf_enabled) struct {
}
};
-/// Native PDF sizing is deliberately semantic rather than a backend knob.
-/// Width mode is the document-reading view (the page may be panned vertically);
-/// height mode keeps one whole page-height visible and may pan wide pages.
-/// The type remains available to shared core code, while PdfView itself and
-/// the builtin which mutates it are compiled away with `-Dmupdf=false`.
-const PdfFitMode = if (pdf_enabled) enum { width, height } else void;
-const PdfTintMode = if (pdf_enabled) pdf_impl.TintMode else void;
-
-/// Every resident page records the PDF-only pixel state it was rendered for.
-/// Disabled deliberately ignores colors: changing a theme must not replace an
-/// untouched PDF raster. In tinted modes colors are part of the generation.
-const PdfTintKey = if (pdf_enabled) struct {
- mode: PdfTintMode,
- colors: pdf_impl.TintColors,
-
- fn eql(a: @This(), b: @This()) bool {
- return a.mode == b.mode and
- (a.mode == .disabled or std.meta.eql(a.colors, b.colors));
- }
-} else void;
+const PdfFitMode = pdf_pane.FitMode;
+const PdfTintMode = pdf_pane.TintMode;
/// Dynamic placement exists only for native PDF pages. Static image panes need
/// only their pane identity, so feature-off builds carry a zero-bit payload.
@@ -3153,7 +1838,7 @@ test "pixel attachment cache key follows both pane lifetime and rendered revisio
try std.testing.expectEqual(@as(usize, 4), @sizeOf(ImageCacheKey));
try std.testing.expectEqual(@as(usize, 0), @sizeOf(NativePlacement));
try std.testing.expectEqual(@as(usize, 0), @sizeOf(CellPixels));
- try std.testing.expectEqual(@as(usize, 0), @sizeOf(PdfDrag));
+ try std.testing.expectEqual(@as(usize, 0), @sizeOf(pdf_pane.PointerDrag));
}
}
@@ -3191,13 +1876,37 @@ pub const Surface = struct {
/// viewport without occasionally dropping an intersecting page.
images: []?ImagePlace = &.{},
nimages: usize = 0,
+ /// Active pane transitions, keyed by the stable pane serial carried in
+ /// each record. GUI shells evaluate these in shaders; the TTY remaps this
+ /// same frame's cells through its grid compositor.
+ panel_tracks: [MAX_PANES * 2]panel_animation.Track = undefined,
+ npanel_tracks: usize = 0,
+ /// Frozen canonical cells from before the current content/lifecycle
+ /// transition, plus its semantic old/new mask. Both are core-owned and
+ /// remain stable until every associated track has finished.
+ previous_cells: []const Cell = &.{},
+ changed_cells: []const u8 = &.{},
+
+ pub fn panelTracks(s: *const Surface) []const panel_animation.Track {
+ return s.panel_tracks[0..s.npanel_tracks];
+ }
+
+ pub fn hasPanelDiff(s: *const Surface) bool {
+ return s.previous_cells.len == s.cells.len and
+ s.changed_cells.len == s.cells.len;
+ }
+
+ pub fn panelCellChanged(s: *const Surface, x: u16, y: u16) bool {
+ if (!s.hasPanelDiff() or x >= s.cols or y >= s.rows) return false;
+ return s.changed_cells[@as(usize, y) * s.cols + x] != 0;
+ }
pub fn at(s: *Surface, x: u16, y: u16) *Cell {
std.debug.assert(x < s.cols and y < s.rows);
return &s.cells[@as(usize, y) * s.cols + x];
}
- fn set(s: *Surface, x: u16, y: u16, text: []const u8, style: CellStyle) void {
+ pub fn set(s: *Surface, x: u16, y: u16, text: []const u8, style: CellStyle) void {
const c = s.at(x, y);
std.debug.assert(text.len <= c.text.len);
@memcpy(c.text[0..text.len], text);
@@ -3267,7 +1976,7 @@ pub const Surface = struct {
return col;
}
- fn fill(s: *Surface, x: u16, y: u16, w: u16, h: u16, style: CellStyle) void {
+ pub fn fill(s: *Surface, x: u16, y: u16, w: u16, h: u16, style: CellStyle) void {
var yy = y;
while (yy < y + h) : (yy += 1) {
var xx = x;
@@ -3354,10 +2063,156 @@ test "tabbed file aligns syntax cursor and mouse while preserving virtual column
try std.testing.expectEqual(blank_x, virtual_surface.cursor.?.x);
const local_blank: i32 = @intCast(blank_x - rect.x - config.GUTTER);
- var look_sel = Sel{ .state = .dragging, .c0 = local_blank, .c1 = local_blank, .r0 = BOX_H, .r1 = BOX_H };
- p.expandSel(pane, &look_sel);
- try std.testing.expectEqual(local_blank, look_sel.c0);
- try std.testing.expectEqual(local_blank, look_sel.c1);
+ try std.testing.expect(p.expandedSel(pane, .{ .state = .dragging, .c0 = local_blank, .c1 = local_blank, .r0 = BOX_H, .r1 = BOX_H }) == null);
+
+ // The immediately-adjacent EOL cell and an in-line separator are blank
+ // too. Pointer expansion must not lean left into the preceding word.
+ const adjacent: i32 = @intCast(config.PREFIX_W + file_pane.displayWidth(line));
+ try std.testing.expect(p.expandedSel(pane, .{ .state = .dragging, .c0 = adjacent, .c1 = adjacent, .r0 = BOX_H, .r1 = BOX_H }) == null);
+ const separator: i32 = @intCast(config.PREFIX_W + config.tab_width + "return".len);
+ try std.testing.expect(p.expandedSel(pane, .{ .state = .dragging, .c0 = separator, .c1 = separator, .r0 = BOX_H, .r1 = BOX_H }) == null);
+}
+
+test "Look hover waits without mutating the pane and input cancels it" {
+ const delay = config.look_preview_delay_frames orelse return;
+ try std.testing.expect(delay > 0);
+
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12, .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = try p.hxOpenFileContent("alpha beta gamma\n");
+ while (p.nextEffect()) |_| {}
+ const other_id = p.freeSlot() orelse return error.NoSparePaneForHoverTest;
+ _ = try p.newShell(other_id, "");
+ try std.testing.expect(p.layoutSplitColumn(0, other_id, false));
+ p.sync();
+ while (p.nextEffect()) |_| {}
+
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ const rect = p.rects[p.active];
+ const word_x = rect.x + config.GUTTER + config.PREFIX_W + 7;
+ const body_y = if (p.settings.tag_bottom) rect.y else rect.y + BOX_H;
+ const motion = Event{ .mouse = .{ .button = .none, .kind = .motion, .col = word_x, .row = body_y } };
+
+ const selections_before = pane.sel;
+ const active_before = p.active;
+ const cursor_before = .{ pane.cur_row, pane.cur_col };
+ const jumps_before = .{ p.njumps, p.jcur, p.n_look_src };
+ const effects_before = p.effects_len;
+
+ p.update(motion);
+ try std.testing.expect(p.look_hover_wait != null);
+ try std.testing.expect(p.look_hover_preview == null);
+ try std.testing.expect(p.animationActive());
+
+ var ticks: u16 = 0;
+ while (ticks + 1 < delay) : (ticks += 1) {
+ p.update(.tick);
+ // Repeated motion reports for one cell are noise, not a fresh delay.
+ if (ticks == delay / 2) p.update(motion);
+ try std.testing.expect(p.look_hover_preview == null);
+ }
+ p.update(.tick);
+ const preview = p.look_hover_preview orelse return error.MissingLookHoverPreview;
+ const preview_sel = preview.sel orelse return error.MissingLookHoverSelection;
+ try std.testing.expectEqual(.done, preview_sel.state);
+ try std.testing.expectEqual(@as(i32, config.PREFIX_W + 6), preview_sel.c0);
+ try std.testing.expectEqual(@as(i32, config.PREFIX_W + 9), preview_sel.c1);
+ try std.testing.expect(!p.animationActive());
+
+ // Resolving and displaying the operand is observational only.
+ try std.testing.expect(std.meta.eql(selections_before, pane.sel));
+ try std.testing.expectEqual(active_before, p.active);
+ try std.testing.expectEqual(cursor_before, .{ pane.cur_row, pane.cur_col });
+ try std.testing.expectEqual(jumps_before, .{ p.njumps, p.jcur, p.n_look_src });
+ try std.testing.expectEqual(effects_before, p.effects_len);
+
+ _ = frame.reset(.retain_capacity);
+ const surface = try p.render(frame.allocator());
+ const expected_bg = mix(p.theme().bg orelse p.theme().tag_bg, mix(p.theme().bg orelse p.theme().tag_bg, p.theme().sel_bg));
+ try std.testing.expectEqual(Color{ .rgb = expected_bg }, surface.at(word_x, body_y).style.bg);
+
+ // Background traffic in another pane does not change the text or geometry
+ // under this pointer and must not strand a stationary hover unarmed.
+ p.update(.{ .output = .{ .pane = @intCast(other_id), .bytes = "busy\r\n" } });
+ try std.testing.expect(p.look_hover_preview != null);
+
+ // A real leave cannot be represented as an out-of-range motion: motion
+ // coordinates are deliberately clamped for drags. It also clears the
+ // ordinary resize-handle hint, not only this Look-specific preview.
+ const seam_x = p.col_x[0] + p.col_w[0] - 1;
+ p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = seam_x, .row = body_y } });
+ _ = frame.reset(.retain_capacity);
+ const hovered_seam = try p.render(frame.allocator());
+ try std.testing.expectEqualStrings("╎", hovered_seam.at(seam_x, body_y).grapheme());
+ p.update(.pointer_leave);
+ try std.testing.expect(p.look_hover_wait == null);
+ try std.testing.expect(p.look_hover_preview == null);
+ _ = frame.reset(.retain_capacity);
+ const left_seam = try p.render(frame.allocator());
+ try std.testing.expect(!std.mem.eql(u8, "╎", left_seam.at(seam_x, body_y).grapheme()));
+
+ // Blank space after EOL has a pointer cell but no Look operand. Ageing it
+ // to completion must turn the animation clock back off without drawing a
+ // misleading one-cell preview.
+ const blank_x = rect.x + config.GUTTER + config.PREFIX_W + 30;
+ p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = blank_x, .row = body_y } });
+ for (0..delay) |_| p.update(.tick);
+ try std.testing.expect(p.look_hover_wait == null);
+ try std.testing.expect(p.look_hover_preview == null);
+ try std.testing.expect(!p.animationActive());
+
+ // File PREFIX_W belongs only to body line numbers. A preview on the tag's
+ // first character must be paintable all the way at tag column zero.
+ const tag_y = if (p.settings.tag_bottom) rect.y + rect.h - BOX_H else rect.y;
+ p.look_hover_preview = .{
+ .col = rect.x + config.GUTTER,
+ .row = tag_y,
+ .pane = p.active,
+ .serial = pane.serial,
+ .sel = .{ .state = .done, .c0 = 0, .c1 = 0, .r0 = 0, .r1 = 0 },
+ };
+ _ = frame.reset(.retain_capacity);
+ const tag_surface = try p.render(frame.allocator());
+ try std.testing.expectEqual(Color{ .rgb = expected_bg }, tag_surface.at(rect.x + config.GUTTER, tag_y).style.bg);
+
+ p.look_hover_preview = null;
+ p.update(motion);
+ try std.testing.expect(p.look_hover_wait != null);
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ try std.testing.expect(p.look_hover_wait == null);
+ try std.testing.expect(p.look_hover_preview == null);
+ // The following host repaint must not resurrect a hover after keyboard
+ // input cancelled its raw-pointer intent.
+ p.acknowledgePanelPresentation(&.{});
+ try std.testing.expect(p.look_hover_wait == null);
+}
+
+test "cwd changes cancel only the affected pane's Look hover" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .shells = 3 });
+ defer p.deinit();
+
+ const first = p.panes[0] orelse return error.MissingFirstPane;
+ const other = p.panes[1] orelse return error.MissingOtherPane;
+ p.setCwd(0, "/work/first");
+ p.setCwd(1, "/work/other");
+ p.look_hover_wait = .{ .col = 1, .row = 1, .pane = 0, .serial = first.serial };
+
+ // Hosts poll cwd every frame. An unchanged answer is observational and a
+ // change reported for another pane cannot invalidate this pane's operand.
+ p.setCwd(0, "/work/first");
+ try std.testing.expect(p.look_hover_wait != null);
+ p.setCwd(1, "/work/elsewhere");
+ try std.testing.expect(p.look_hover_wait != null);
+ try std.testing.expectEqual(other.serial, p.panes[1].?.serial);
+
+ p.setCwd(0, "/work/changed");
+ try std.testing.expect(p.look_hover_wait == null);
+ try std.testing.expect(p.look_hover_preview == null);
}
test "plain left click clears explicit modal selection" {
@@ -3385,6 +2240,23 @@ test "plain left click clears explicit modal selection" {
try std.testing.expectEqual(.none, pane.sel[@intFromEnum(config.select_button)].state);
}
+test "selection drag and queued release do not enter panel pending state" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 50, .rows = 10, .tty_only = true });
+ defer p.deinit();
+ const pane = try p.hxOpenFileContent("alpha beta\n");
+ p.acknowledgePanelPresentation(&.{});
+ const rect = p.rects[0];
+ const x = rect.x + config.GUTTER + config.PREFIX_W + 1;
+ const y = rect.y + BOX_H;
+
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = x, .row = y } });
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .drag, .col = x + 2, .row = y } });
+ try std.testing.expect(!p.panel_presentation_pending);
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = x + 2, .row = y } });
+ try std.testing.expect(p.drag == .none);
+ try std.testing.expectEqual(.done, pane.sel[sel_slot].state);
+}
+
pub const Mouse = struct {
// sel indexing relies on left/middle/right being 0/1/2
pub const Button = enum { left, middle, right, wheel_up, wheel_down, wheel_left, wheel_right, none };
@@ -3535,10 +2407,11 @@ pub const Event = union(enum) {
/// update only; success is atomic, so a failed/nonzero invocation carries
/// no usable outputs and changes nothing.
pipe_resp: struct { id: u32, success: bool, outputs: []const []const u8 },
- /// a file the shell was asked to watch changed on disk; `bytes` are its
- /// new contents, borrowed for this call exactly like `output`. A shell
- /// with no filesystem (the browser) or no watcher simply never sends one —
- /// nothing in the core waits for it.
+ /// a file the shell was asked to watch changed on disk; `bytes` are the
+ /// exact snapshot the host hashed, borrowed for this call like `output`.
+ /// Text panes adopt a copy; PDF panes reopen the path so MuPDF owns its
+ /// random-access document. A shell with no filesystem (the browser) or no
+ /// watcher simply never sends one — nothing in the core waits for it.
file_changed: struct { pane: u8, bytes: []const u8 },
/// Text from the SYSTEM clipboard, borrowed for this call. Two ways in,
/// one handler (applyPaste): SOLICITED, the answer to a `read_clipboard`
@@ -3559,6 +2432,9 @@ pub const Event = union(enum) {
pdf_scroll: struct { pane: u8, delta_pixels: f32 },
pinch: f32,
touch_scroll: f32,
+ /// The host pointer left the drawable area. Unlike an out-of-range motion,
+ /// this must not clamp onto and preview the final grid cell.
+ pointer_leave,
tick,
};
@@ -3618,7 +2494,8 @@ pub const Effect = union(enum) {
/// shell reads the path off the core exactly like save_file does; stopping
/// carries nothing, because by the time an `off` is drained the pane is
/// already freed — the shell remembers what it watches per pane id.
- /// Only real files ask for this: an output buffer has no file behind it.
+ /// Real text files and PDFs ask for this; an output buffer has no file
+ /// behind it.
watch: struct { pane: u8, on: bool },
quit,
@@ -3629,7 +2506,7 @@ pub const Effect = union(enum) {
pub fn slice(b: *const @This()) []const u8 {
return b.data[0..b.len];
}
- fn from(text: []const u8) @This() {
+ pub fn from(text: []const u8) @This() {
var b: @This() = .{};
std.debug.assert(text.len <= n);
@memcpy(b.data[0..text.len], text);
@@ -3642,23 +2519,9 @@ pub const Effect = union(enum) {
pub const Mode = enum { normal, insert, tty };
-/// A terminal's edit buffer: real gpa-owned text standing in for the `rows`
-/// shell rows starting at ABSOLUTE body row `row`. It is edited by exactly
-/// the machinery File.content is — that is the whole point, a terminal in
-/// insert mode has to behave like a text file — and it rides the scrollback
-/// the way the char-mode insertion runs it replaced did. The vt grid itself
-/// is never written, so output keeps streaming underneath.
-///
-/// Seeded from the rows it stands in for the first time you type, and grown
-/// whenever an edit reaches past either end (absorbing the shell rows it
-/// swallows). When it ends up holding MORE lines than the rows it covers —
-/// which is what pressing Enter does — the surface below simply slides down
-/// by the difference: see Pane.surfRow.
-pub const Ovl = struct {
- row: i32 = 0,
- rows: i32 = 1,
- text: []u8 = &.{},
-};
+/// An owned editable buffer and the absolute surface row of its first line.
+/// Files use row zero; terminal overlays may begin anywhere in scrollback.
+pub const EditText = struct { text: []u8, row0: i32 };
/// One mouse selection (block-shaped), per button. c/r are text-area relative;
/// r counts from the tag row (body starts at BOX_H).
@@ -3670,6 +2533,50 @@ pub const Sel = struct {
r1: i32 = 0,
};
+const LookHoverWait = struct {
+ col: u16,
+ row: u16,
+ pane: usize,
+ serial: u32,
+ frames: u16 = 0,
+};
+
+const LookHoverPreview = struct {
+ col: u16,
+ row: u16,
+ pane: usize,
+ serial: u32,
+ /// Null when the operand is the pane's modal selection. The renderer uses
+ /// that marker to paint the live modal range subtly even while tag/search
+ /// editing would normally hide it. A kept mouse selection carries its
+ /// exact screen-space range; an ordinary word carries the click expansion.
+ sel: ?Sel,
+ /// A wrapped file word is one logical source span, not one rectangular
+ /// screen selection. Keeping it in source coordinates lets the renderer
+ /// paint every visible continuation while Look receives the exact same
+ /// bytes, including a path which crosses a soft-wrap boundary.
+ file_word: ?FileWordSpan = null,
+};
+
+const FileWordSpan = struct {
+ row: i32,
+ lo: i32,
+ hi: i32, // exclusive
+};
+
+const PdfWordPreview = if (pdf_enabled) struct {
+ col: u16,
+ row: u16,
+ pane: usize,
+ serial: u32,
+ probe: pdf_pane.WordProbe,
+
+ fn deinit(preview: *@This(), gpa: std.mem.Allocator) void {
+ preview.probe.deinit(gpa);
+ preview.* = undefined;
+ }
+} else void;
+
/// A modal line selection (helix `x`): whole rows [r0, r1], absolute.
pub const LineSel = struct {
active: bool = false,
@@ -3719,419 +2626,10 @@ pub const SelRange = struct {
sticky: i32 = -1,
};
-/// Undo snapshot of a terminal pane's edit buffer (whole-state, dumb on
-/// purpose). `ovl` is null for a pane nothing has been typed into yet, and
-/// owns its text exactly like FileSnap does.
-pub const EditSnap = struct {
- ovl: ?Ovl,
- cur_row: i32,
- cur_col: i32,
- vsel: CharSel,
-};
-
-/// Undo snapshot of a file pane: content + the selection at commit time —
-/// helix undo restores the selection along with the text.
-/// ponytail: the PRIMARY selection only. With multiple cursors an undo puts
-/// the primary back and leaves the others where they are — which is right
-/// whenever the undone edit is the one they made (the common case, and what
-/// the msel-undo differential case pins), and merely misplaced otherwise.
-/// Storing the whole list would put MAX_SELS ranges in every one of the 256
-/// snapshots a pane keeps, for a case nobody has complained about.
-pub const FileSnap = struct {
- content: []u8,
- cur_row: i32,
- cur_col: i32,
- vsel: CharSel,
-};
-
-/// A file pane's backing: content is gpa-owned; undo/redo are whole snapshots.
-pub const File = struct {
- path: []u8,
- content: []u8,
- /// Monotonic content identity for asynchronous edits. Every content swap
- /// goes through file_pane.setContent, which bumps this; a pipe completion
- /// accepted against another revision would overwrite intervening work.
- revision: u32 = 0,
- /// The `revision` this buffer was last WRITTEN at. Equal means what is on
- /// screen is what is on disk; anything else is unsaved work.
- ///
- /// Bookkeeping only — nothing in the core renders it, and no shell has to
- /// read it. It exists because a windowed host has somewhere to PUT the
- /// answer (macOS puts a dot in the close button, and the proxy icon it sits
- /// beside is the same pane's path), and a shell cannot derive it: revision
- /// counts edits, and only the save knows which edit was the last one
- /// committed. Zero for a fresh buffer, which is why every construction site
- /// gets clean-on-open from the default and none of them mention it.
- ///
- /// Marked at the moment Save is ASKED, not when the write lands: the
- /// save_file effect carries no completion back, so this is as honest as the
- /// rest of that path. A failed write reads as saved, exactly as the tagline
- /// already does.
- saved_revision: u32 = 0,
- /// set = this is an OUTPUT buffer (acme's +Errors): a file pane with no
- /// file behind it, showing text the core produced itself. It records the
- /// COMMAND that opened it, and output_pane.zig's one table turns that into
- /// every way it differs from a file — ask it, never this field's shape.
- /// `path` still names it (and gives its directory) so looks inside it
- /// resolve like anywhere else.
- output: ?output_pane.Output = null,
- scroll: usize = 0,
- /// THE LINE INDEX: `line_starts[i]` is the byte offset where line i begins,
- /// and `line_starts.len` IS the line count. Empty means "not built yet" —
- /// a real index always holds at least one entry, because a file always has
- /// at least one line. Built and read only through file_pane.lineIndex /
- /// nlines / lineStart; see the invalidation contract on lineIndex.
- line_starts: []usize = &.{},
- /// one tree_sitter_gpa-owned syntax.Syn byte per content byte in the highlighted window
- highlights: []u8 = &.{},
- highlight_start: usize = 0,
- syntax_dirty: bool = true,
- undo: [UNDO_MAX]FileSnap = undefined,
- undo_len: usize = 0,
- redo: [UNDO_MAX]FileSnap = undefined,
- redo_len: usize = 0,
-};
-
-/// An image pane's backing: the decoded+downscaled RGBA (kept so the petscii
-/// grid can re-render on resize/toggle) and the cached glyph grid.
-pub const Image = struct {
- path: []u8,
- petscii: bool = false,
- pmode: image.PaletteMode = .commodore,
- ascii: bool = true,
- tried: bool = false, // decode attempted — exactly once
- rgba: []u8 = &.{},
- iw: usize = 0,
- ih: usize = 0,
- /// dump-loaded image bytes (image_gpa-owned), decoded lazily like a file read
- raw: []u8 = &.{},
- grid: []image.petscii.Cell = &.{},
- gw: usize = 0,
- gh: usize = 0,
- grid_w: u16 = 0,
- grid_h: u16 = 0,
- grid_mode: image.PaletteMode = .commodore,
- grid_ascii: bool = true,
-};
-
-const PdfPoint = if (pdf_enabled) pdf_impl.Point else void;
-const PdfHighlight = if (pdf_enabled) pdf_impl.Highlight else void;
-
-const PdfRaster = if (pdf_enabled) struct {
- valid: bool = false,
- page: usize = 0,
- rgba: []u8 = &.{},
- /// The FULL page raster's dimensions, whatever part of it `rgba` holds.
- /// Everything that maps between the document and the screen — placement,
- /// the mouse, panning, a search reveal — is arithmetic about the whole
- /// page, and none of it wants to know that a fling only rasterized a
- /// strip.
- iw: usize = 0,
- ih: usize = 0,
- /// ...and the strip that is actually here: rows [band_y, band_y + band_h)
- /// of that full raster, so `rgba.len == iw * band_h * 4`. A page at rest
- /// is the whole page as one band. See `pdfWantedBand`.
- band_y: usize = 0,
- band_h: usize = 0,
- request: pdf_impl.RenderRequest = .{},
- request_valid: bool = false,
- tried: bool = false,
- decorated: bool = false,
- tint_key: ?PdfTintKey = null,
- revision: u32 = 0,
-} else void;
-
-const PdfSectionsOutput = if (pdf_enabled) struct {
- pane: usize,
- serial: u32,
- /// The generated revision. A mismatch means the user edited the output;
- /// reopening must not overwrite it, so a fresh output is opened instead.
- revision: u32,
-} else void;
-
-/// A PDF pane is compiled out as a zero-bit `void` field when MuPDF is off.
-/// With it enabled, the pane owns one live document, one lazy current-page
-/// raster for SDL/Kitty, and one lazy text projection for non-pixel hosts and
-/// normal Pardes search semantics.
-const PdfView = if (pdf_enabled) struct {
- path: []u8,
- document: pdf_impl.Document,
- page: usize = 0,
- page_count: usize,
- page_sizes: []pdf_impl.PageSize,
- /// Pixel-space layout is recomputed only when fit or physical viewport
- /// changes. These tiny arrays replace any all-document raster.
- page_starts: []u64,
- page_heights: []u32,
- document_height: u64 = 0,
- layout_viewport_w: u32 = 0,
- layout_viewport_h: u32 = 0,
- layout_fit: PdfFitMode = .width,
- layout_valid: bool = false,
- /// Top of the viewport in document display pixels. f64 preserves SDL's
- /// fractional wheel distance while page starts/heights remain integral.
- document_scroll_y: f64 = 0,
- scroll_to_page_pending: bool = true,
- /// At most PDF_RASTER_MAX visible pages are retained. If a viewport can
- /// show more pathological tiny pages, later pages are rendered as space
- /// rather than letting cache memory grow with the document.
- rasters: [PDF_RASTER_MAX]PdfRaster = undefined,
- rasters_len: usize = 0,
- /// Retired page buffers, waiting to become the raster of a page arriving at
- /// the same byte length. Every frame of a fling replaces the visible set
- /// wholesale at IDENTICAL page dimensions, so returning megabytes to the
- /// allocator only to ask for the same bytes back costs an unmap, a map, and
- /// a fresh kernel fault on every 4 KiB of every page flown past — for
- /// nothing. The relay spans one reconcile, where the departures happen
- /// before the arrivals; `trimPdfSpares` then drops it back to a single
- /// buffer, so this is a frame-length relay and not a second cache.
- ///
- /// Retired rather than rendered into in place: a raster is still swapped
- /// only after MuPDF and the tint have both succeeded, so a failed rerender
- /// can never blank a page that was already visible.
- spare: [PDF_RASTER_SPARE][]u8 = @splat(&.{}),
- spare_len: usize = 0,
- /// A physical viewport change rebuilds page heights. Preserve the same
- /// page-relative reading position instead of reinterpreting old pixels or
- /// snapping to the active page's top.
- layout_anchor_pending: bool = false,
- layout_anchor_page: usize = 0,
- layout_anchor_fraction: f64 = 0,
- next_raster_revision: u32 = 0,
- /// Display pixels this view has been SCROLLED by since the last frame it
- /// drew — the fling signal, and the reason the core needs no clock: a wheel
- /// batch that moves a screenful or more inside one frame lands on pages
- /// nobody will see again, and only their visible rows are worth
- /// rasterizing (see `pdfWantedBand`). Deliberately counts SCROLLING only:
- /// a page jump, a search reveal or a resize moves the viewport just as far
- /// but leaves the reader looking at one place, where whole pages are what
- /// makes the frames after it free.
- scroll_travel: f64 = 0,
- fit: PdfFitMode = .width,
- tint: PdfTintMode = .filtered,
- /// Normalized 0..65535 pan positions. NativeGeometry maps the extrema
- /// exactly onto the source overflow, independent of page or cell size.
- pan_x: u16 = 0,
- pan_y: u16 = 0,
- rgba: []u8 = &.{},
- /// Search/selection geometry changed without changing fit or pan. The next
- /// draw transactionally regenerates this one RGBA buffer with MuPDF's baked
- /// highlights; a second full-page buffer is never retained after the swap.
- highlights_dirty: bool = false,
- search_reveal_pending: bool = false,
- search_results: ?pdf_impl.SearchResults = null,
- selection: ?pdf_impl.Selection = null,
- selection_text: []u8 = &.{},
- selection_anchor: ?pdf_impl.Point = null,
- selection_head: ?pdf_impl.Point = null,
- drag_anchor: ?pdf_impl.Point = null,
- drag_head: ?pdf_impl.Point = null,
- iw: usize = 0,
- ih: usize = 0,
- render_tried: bool = false,
- /// Last quality request attempted, successful or not. A viewport change
- /// gets one transactional retry; a capped/failed request does not spin on
- /// every frame. Fit and pan are intentionally absent from this key.
- render_request: pdf_impl.RenderRequest = .{},
- render_request_valid: bool = false,
- render_revision: u32 = 0,
- text: []u8 = &.{},
- text_tried: bool = false,
- text_scroll: usize = 0,
- /// Full, untruncated PDF query. +Search records only a display/Look copy;
- /// this survives page navigation while page-local quads are rebuilt.
- search_query: []u8 = &.{},
- /// Zero-based logical MuPDF hit on the current page. Result rows encode
- /// this as Spot.col + 1, letting same-page occurrences reveal distinctly.
- search_hit: usize = 0,
- /// Last viewport for which search_hit was revealed. Comparing this at
- /// draw time covers layout/border changes without treating every frame
- /// (or a manual pan) as a reason to snap back to the hit.
- reveal_viewport_w: u32 = 0,
- reveal_viewport_h: u32 = 0,
- reveal_fit: PdfFitMode = .width,
- reveal_viewport_valid: bool = false,
- /// Loaded once on first PdfSections. An empty outline is still a loaded
- /// outline; `outline_tried` distinguishes it from the lazy initial state
- /// and makes backend failures repeatably inert instead of retrying every
- /// command invocation.
- outline: ?pdf_impl.Outline = null,
- outline_tried: bool = false,
- sections_output: ?PdfSectionsOutput = null,
- /// Page-space outline coordinates survive a pane split/layout rebuild.
- /// ensurePdfLayout consumes them without invalidating any page pixels.
- outline_reveal_pending: ?pdf_impl.OutlineInternalDestination = null,
-} else void;
-
-/// Pure outline-to-row policy. Keeping destination resolution and title
-/// sanitisation together makes the ordinal written into a row exactly the
-/// ordinal Look later resolves; neither path invents a second flattened tree.
-pub const PdfSectionRows = if (pdf_enabled) struct {
- pub fn usableDestination(destination: pdf_impl.OutlineDestination) ?pdf_impl.OutlineDestination {
- return switch (destination) {
- .internal => destination,
- .external => |uri| if (safeHttpUri(uri)) destination else null,
- .none => null,
- };
- }
-
- fn safeHttpUri(uri: []const u8) bool {
- // Effect.open_link is inline and cannot carry a larger URL. Omitting
- // it here is preferable to rendering a row which can never act.
- if (uri.len > 256) return false;
- var has_scheme = false;
- for (config.url_schemes) |scheme| {
- if (std.mem.startsWith(u8, uri, scheme)) {
- has_scheme = true;
- break;
- }
- }
- if (!has_scheme) return false;
- for (uri) |byte| if (byte <= 0x20 or byte == 0x7f) return false;
- return true;
- }
-
- /// A structural node goes to the first later DFS entry still below it
- /// which carries a usable destination. Nodes with their own unusable URI
- /// are not structural: omit them instead of silently changing their link.
- pub fn resolve(entries: []const pdf_impl.OutlineEntry, ordinal: usize) ?pdf_impl.OutlineDestination {
- if (ordinal >= entries.len) return null;
- const entry = entries[ordinal];
- if (entry.destination != .none) return usableDestination(entry.destination);
- var i = ordinal + 1;
- while (i < entries.len and entries[i].depth > entry.depth) : (i += 1)
- if (usableDestination(entries[i].destination)) |destination| return destination;
- return null;
- }
-
- /// Resolve every rendered row in one DFS pass. `resolve` above stays the
- /// public single-ordinal policy used by Look; bulk materialisation avoids
- /// rescanning the same descendant chain for every structural ancestor.
- /// The one-usize-per-entry table is transient (64 KiB at MuPDF's 8192
- /// outline-item limit) and stores source ordinals, so URI slices continue
- /// to borrow from the document-owned outline instead of being copied.
- fn resolveOrdinals(
- gpa: std.mem.Allocator,
- entries: []const pdf_impl.OutlineEntry,
- ) ![]usize {
- const unresolved = std.math.maxInt(usize);
- const ordinals = try gpa.alloc(usize, entries.len);
- @memset(ordinals, unresolved);
-
- const Pending = struct { depth: u8, ordinal: usize };
- // OutlineEntry.depth is u8. A valid DFS path therefore cannot hold
- // more than 256 simultaneously unresolved ancestors, independent of
- // the tighter limit enforced by the MuPDF bridge.
- var pending: [256]Pending = undefined;
- var pending_len: usize = 0;
- for (entries, 0..) |entry, ordinal| {
- while (pending_len > 0 and pending[pending_len - 1].depth >= entry.depth)
- pending_len -= 1;
-
- if (usableDestination(entry.destination) != null) {
- ordinals[ordinal] = ordinal;
- for (pending[0..pending_len]) |ancestor|
- ordinals[ancestor.ordinal] = ordinal;
- pending_len = 0;
- } else if (entry.destination == .none) {
- pending[pending_len] = .{ .depth = entry.depth, .ordinal = ordinal };
- pending_len += 1;
- }
- }
- return ordinals;
- }
-
- fn cleanTitle(out: ?[]u8, title: ?[]const u8) usize {
- const raw = title orelse {
- if (out) |buf| @memcpy(buf[0..10], "[untitled]");
- return 10;
- };
- var at: usize = 0;
- var i: usize = 0;
- var wrote = false;
- var pending_space = false;
- while (i < raw.len) {
- const n: usize = std.unicode.utf8ByteSequenceLength(raw[i]) catch {
- pending_space = wrote;
- i += 1;
- continue;
- };
- if (i + n > raw.len) {
- pending_space = wrote;
- break;
- }
- const cp = std.unicode.utf8Decode(raw[i .. i + n]) catch {
- pending_space = wrote;
- i += n;
- continue;
- };
- const whitespace_or_control = cp <= 0x20 or cp == 0x7f or
- (cp >= 0x80 and cp <= 0x9f) or cp == 0x2028 or cp == 0x2029;
- if (whitespace_or_control) {
- pending_space = wrote;
- } else {
- if (pending_space) {
- if (out) |buf| buf[at] = ' ';
- at += 1;
- }
- if (out) |buf| @memcpy(buf[at..][0..n], raw[i .. i + n]);
- at += n;
- wrote = true;
- pending_space = false;
- }
- i += n;
- }
- if (!wrote) {
- if (out) |buf| @memcpy(buf[0..13], "[empty title]");
- return 13;
- }
- return at;
- }
-
- pub fn render(gpa: std.mem.Allocator, path: []const u8, entries: []const pdf_impl.OutlineEntry) ![]u8 {
- const target = std.fs.path.basename(path);
- const ordinals = try resolveOrdinals(gpa, entries);
- defer gpa.free(ordinals);
- var total: usize = 0;
- for (entries, 0..) |entry, ordinal| {
- const resolved = ordinals[ordinal];
- if (resolved == std.math.maxInt(usize)) continue;
- const destination = usableDestination(entries[resolved].destination) orelse unreachable;
- total += switch (destination) {
- .internal => |internal| std.fmt.count("{s}:{d}:{d} ", .{ target, internal.page + 1, ordinal + 1 }),
- .external => |uri| std.fmt.count("{s} ", .{uri}),
- .none => unreachable,
- };
- total += @as(usize, entry.depth) * 2 + cleanTitle(null, entry.title) + 1;
- }
- const out = try gpa.alloc(u8, total);
- errdefer gpa.free(out);
- var at: usize = 0;
- for (entries, 0..) |entry, ordinal| {
- const resolved = ordinals[ordinal];
- if (resolved == std.math.maxInt(usize)) continue;
- const destination = usableDestination(entries[resolved].destination) orelse unreachable;
- const prefix = switch (destination) {
- .internal => |internal| try std.fmt.bufPrint(out[at..], "{s}:{d}:{d} ", .{ target, internal.page + 1, ordinal + 1 }),
- .external => |uri| try std.fmt.bufPrint(out[at..], "{s} ", .{uri}),
- .none => unreachable,
- };
- at += prefix.len;
- const indent = @as(usize, entry.depth) * 2;
- @memset(out[at..][0..indent], ' ');
- at += indent;
- at += cleanTitle(out[at..], entry.title);
- out[at] = '\n';
- at += 1;
- }
- return out;
- }
-} else struct {};
-
-const PdfSlot = if (pdf_enabled) ?PdfView else void;
+const PdfSlot = if (pdf_enabled) ?pdf_pane.State else void;
fn hasPdf(pane: *const Pane) bool {
- return pdf_enabled and pane.kind == .pdf;
+ return if (comptime pdf_enabled) pane.pdf != null else false;
}
fn hasPdfSelection(pane: *const Pane) bool {
@@ -4141,42 +2639,21 @@ fn hasPdfSelection(pane: *const Pane) bool {
false;
}
-test "feature-off PDF pane state is zero-sized" {
- if (!pdf_enabled) try std.testing.expectEqual(@as(usize, 0), @sizeOf(PdfSlot));
-}
-pub const PaneKind = enum { terminal, file, image, pdf };
const Prompt = union(enum) {
none,
search: u16,
pipe: u16,
};
-/// File cursor columns may live past EOL. Tabs expand before that boundary;
-/// every virtual column after it is still one screen cell, as it was before
-/// display-cell-aware file coordinates were introduced.
-fn fileRawDisplayCol(line: []const u8, raw_col: usize) usize {
- const bounded = @min(raw_col, line.len);
- return file_pane.displayWidth(line[0..bounded]) +| (raw_col - bounded);
-}
-
-fn fileRawAtDisplay(line: []const u8, display_col: usize) usize {
- const width = file_pane.displayWidth(line);
- if (display_col > width) return line.len +| (display_col - width);
- return file_pane.byteAtDisplay(line, display_col);
-}
-
pub const Pane = struct {
vt: ghostty_vt.Terminal,
stream: ghostty_vt.TerminalStream,
/// The same allocator Pardes holds. A pane already owns heap (its content,
/// its emulator, its undo stacks) and Pardes frees all of it; this is here
- /// so the pane methods that need the file's LINE INDEX — scrollBy,
- /// ensureCursorVisible, lastNavRow — can build it. The alternative was
+ /// so the pane methods that need the file's LINE INDEX — scrollBy and
+ /// ensureCursorVisible — can build it. The alternative was
/// threading an allocator through ensureCursorVisible's 33 call sites.
gpa: std.mem.Allocator,
- /// Active payload discriminator. Constructors set this with the matching
- /// file/image/PDF payload; terminal panes carry none of those payloads.
- kind: PaneKind = .terminal,
/// WHICH pane this is, for anything that outlives the pane: slots are
/// REUSED (freeSlot hands back the lowest free one), so a remembered id
/// alone can silently come to mean an unrelated pane. Handed out by
@@ -4187,8 +2664,9 @@ pub const Pane = struct {
cols: u16,
rows: u16,
greet: bool = false,
- file: ?File = null,
- image: ?Image = null,
+ pending_command: term_pane.PendingCommand = .{},
+ file: ?file_pane.State = null,
+ image: ?image_pane.State = null,
pdf: PdfSlot = if (pdf_enabled) null else {},
msel: LineSel = .{},
vsel: CharSel = .{},
@@ -4268,9 +2746,9 @@ pub const Pane = struct {
/// does nothing left of it.
tag_col: u16 = 0,
tag_anchor: u16 = 0,
- ed_undo: [EDIT_UNDO_MAX]EditSnap = undefined,
+ ed_undo: [term_pane.history_max]term_pane.Snapshot = undefined,
ed_undo_len: usize = 0,
- ed_redo: [EDIT_UNDO_MAX]EditSnap = undefined,
+ ed_redo: [term_pane.history_max]term_pane.Snapshot = undefined,
ed_redo_len: usize = 0,
/// live working directory, shell-reported (setCwd); shown in the tag and
/// the base for look-resolution of relative paths.
@@ -4312,7 +2790,7 @@ pub const Pane = struct {
wrap_n: u16 = 0,
sel: [3]Sel = @splat(.{}),
/// terminals only: the typed-text buffer standing in for shell rows
- ovl: ?Ovl = null,
+ ovl: ?term_pane.EditBuffer = null,
/// every raw pty byte, in order — a bounded dump/replay ring. Once full,
/// new output evicts the oldest bytes while the live terminal still sees
/// every byte.
@@ -4378,50 +2856,13 @@ pub const Pane = struct {
p.tag_tail_len -= 1;
}
- fn appendTtyReplay(p: *Pane, bytes: []const u8) void {
- if (bytes.len >= p.tty_stream.len) {
- const kept = bytes[bytes.len - p.tty_stream.len ..];
- @memcpy(p.tty_stream[0..], kept);
- p.tty_stream_head = 0;
- p.tty_stream_len = p.tty_stream.len;
- return;
- }
- const overflow = bytes.len -| (p.tty_stream.len - p.tty_stream_len);
- p.tty_stream_head = (p.tty_stream_head + overflow) % p.tty_stream.len;
- p.tty_stream_len -= overflow;
- const tail = (p.tty_stream_head + p.tty_stream_len) % p.tty_stream.len;
- const first = @min(bytes.len, p.tty_stream.len - tail);
- @memcpy(p.tty_stream[tail..][0..first], bytes[0..first]);
- @memcpy(p.tty_stream[0 .. bytes.len - first], bytes[first..]);
- p.tty_stream_len += bytes.len;
- }
-
- fn ttyReplayBytes(p: *const Pane, allocator: std.mem.Allocator) ![]const u8 {
- if (p.tty_stream_len == 0) return &.{};
- if (p.tty_stream_head + p.tty_stream_len <= p.tty_stream.len)
- return p.tty_stream[p.tty_stream_head..][0..p.tty_stream_len];
- const out = try allocator.alloc(u8, p.tty_stream_len);
- const first = p.tty_stream.len - p.tty_stream_head;
- @memcpy(out[0..first], p.tty_stream[p.tty_stream_head..]);
- @memcpy(out[first..], p.tty_stream[0 .. p.tty_stream_len - first]);
- return out;
- }
-
pub fn cwdSlice(p: *const Pane) []const u8 {
return p.cwd[0..p.cwd_len];
}
- fn payloadValid(p: *const Pane) bool {
- const no_pdf = if (comptime pdf_enabled) p.pdf == null else true;
- return switch (p.kind) {
- .terminal => p.file == null and p.image == null and no_pdf,
- .file => p.file != null and p.image == null and no_pdf,
- .image => p.file == null and p.image != null and no_pdf,
- .pdf => if (comptime pdf_enabled) p.file == null and p.image == null and p.pdf != null else false,
- };
- }
pub fn isTerminal(pane: *const Pane) bool {
- return pane.kind == .terminal;
+ const no_pdf = if (comptime pdf_enabled) pane.pdf == null else true;
+ return pane.file == null and pane.image == null and no_pdf;
}
pub fn pdfPath(pane: *const Pane) ?[]const u8 {
@@ -4582,24 +3023,17 @@ pub const Pane = struct {
const line = modal.lineSlice(f.content, @intCast(@max(0, pane.cur_row)));
const raw_cur: usize = @intCast(@max(0, pane.cur_col));
const raw_scroll: usize = @intCast(@max(0, pane.hscroll));
- const cur = @as(i32, @intCast(fileRawDisplayCol(line, raw_cur)));
- const visual_scroll = @as(i32, @intCast(fileRawDisplayCol(line, raw_scroll)));
+ const cur = @as(i32, @intCast(file_pane.rawDisplayCol(line, raw_cur)));
+ const visual_scroll = @as(i32, @intCast(file_pane.rawDisplayCol(line, raw_scroll)));
var target = visual_scroll;
if (cur < visual_scroll + hmargin)
target = @max(0, cur - hmargin)
else if (cur > visual_scroll + w - 1 - hmargin)
target = cur - (w - 1 - hmargin);
- if (target != visual_scroll) pane.hscroll = @intCast(fileRawAtDisplay(line, @intCast(target)));
+ if (target != visual_scroll) pane.hscroll = @intCast(file_pane.rawAtDisplay(line, @intCast(target)));
}
}
- /// the last navigable row: file -> last content line; terminal -> viewport bottom
- fn lastNavRow(pane: *Pane) i32 {
- if (pane.file) |*f| return @intCast(file_pane.nlines(pane.gpa, f) -| 1);
- if (hasPdf(pane)) return @intCast(modal.lineCount(if (comptime pdf_enabled) pane.pdf.?.text else "") -| 1);
- return pane.scroll() + @as(i32, pane.rows) - 1;
- }
-
fn pinCursor(pane: *Pane) void {
if (pane.cur_pinned) return;
if (pane.file != null or hasPdf(pane)) {
@@ -4614,13 +3048,6 @@ pub const Pane = struct {
}
};
-const PdfDrag = if (pdf_enabled) struct {
- native: bool = false,
- /// Drag updates keep selection geometry/text live, but their expensive
- /// baked raster highlight is committed once on release.
- selection_changed: bool = false,
-} else struct {};
-
const Drag = union(enum) {
none,
/// `corner` is what makes this a CORNER grab: the press landed on a cell
@@ -4658,8 +3085,20 @@ const Drag = union(enum) {
ctrl: bool = false,
/// This gesture began over a usable native PDF raster. The payload is
/// zero-bit when PDF support is absent.
- pdf: PdfDrag = .{},
+ pdf: pdf_pane.PointerDrag = .{},
},
+
+ /// The physical button whose release balances this gesture. Layout and
+ /// tag drags are all left-button gestures; a text selection remembers its
+ /// own acme button. Deriving this from the gesture keeps multi-button
+ /// chords exact without a parallel held-button mask.
+ fn button(drag: Drag) ?Mouse.Button {
+ return switch (drag) {
+ .none => null,
+ .border_v, .border_h, .move, .tag => config.select_button,
+ .select => |selection| selection.button,
+ };
+ }
};
// The two border clamps, pulled out as plain arithmetic on plain numbers for
@@ -4749,7 +3188,7 @@ test "a corner drag's two axes clamp independently" {
/// lives; the h hit test, the corner search and the hover hint all read it here.
fn seamRowOf(p: *const Pardes, c: usize, k: usize) u16 {
const r = p.rects[p.col_terms[c][k]];
- return if (p.tag_bottom) r.y +| r.h else r.y + r.h -| 1;
+ return if (p.settings.tag_bottom) r.y +| r.h else r.y + r.h -| 1;
}
/// The corner fixture: the classic two-column boot with a SECOND pane added to
@@ -4880,12 +3319,736 @@ test "a RIGHT-column corner's two axes clamp independently" {
try std.testing.expectEqual(handle, p.drag.border_v.cur_x);
const a = p.rects[p.col_terms[1][0]];
const b = p.rects[p.col_terms[1][1]];
- try std.testing.expectEqual(clampBorderRow(a.y, a.h, b.h, 999, p.tag_bottom), p.drag.border_v.cur_y);
+ try std.testing.expectEqual(clampBorderRow(a.y, a.h, b.h, 999, p.settings.tag_bottom), p.drag.border_v.cur_y);
p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle, .row = 999 } });
}
+test "a new column takes width only from the column that created it" {
+ if (platform == .web) return;
+ // 124 deliberately makes the thirty-one-cell source split into unequal
+ // rounded halves. Independent width rounding moved the right column by a
+ // cell here; cumulative boundaries keep it pinned.
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 124, .rows = 24 });
+ defer p.deinit();
+
+ _ = try p.newShell(1, "");
+ try std.testing.expect(p.layoutSplitColumn(0, 1, false));
+ _ = try p.newShell(2, "");
+ try std.testing.expect(p.layoutSplitColumn(0, 2, true));
+ p.sync();
+
+ // ids 2, 0, 1 now own 1/4, 1/4 and 1/2 of the screen. Splitting the
+ // middle column must consume its own thirty cells in place: the columns
+ // on both sides retain their exact rectangles, not merely their weights.
+ const left_before = p.rects[2];
+ const right_before = p.rects[1];
+ _ = try p.newShell(3, "");
+ try std.testing.expect(p.layoutSplitColumn(0, 3, false));
+ p.sync();
+
+ try std.testing.expectEqual(left_before.x, p.rects[2].x);
+ try std.testing.expectEqual(left_before.w, p.rects[2].w);
+ try std.testing.expectEqual(right_before.x, p.rects[1].x);
+ try std.testing.expectEqual(right_before.w, p.rects[1].w);
+ try std.testing.expectEqual(@as(u16, 16), p.rects[0].w);
+ try std.testing.expectEqual(@as(u16, 15), p.rects[3].w);
+
+ const narrow = try Pardes.init(std.testing.allocator, .{ .cols = config.MINW * 2 - 1, .rows = 10 });
+ defer narrow.deinit();
+ try std.testing.expect(!narrow.layoutSplitColumn(0, 1, false));
+ try std.testing.expectEqual(@as(usize, 1), narrow.ncol);
+
+ // Public callers may chain surgery before sync. The first split leaves
+ // two minimum-width columns, so the second must read the freshly-derived
+ // source width and refuse rather than consulting the old full-width rect.
+ const sequential = try Pardes.init(std.testing.allocator, .{ .cols = config.MINW * 2, .rows = 10 });
+ defer sequential.deinit();
+ _ = try sequential.newShell(1, "");
+ try std.testing.expect(sequential.layoutSplitColumn(0, 1, false));
+ _ = try sequential.newShell(2, "");
+ try std.testing.expect(!sequential.layoutSplitColumn(0, 2, false));
+ try std.testing.expectEqual(@as(usize, 2), sequential.ncol);
+
+ // Even a deliberately coarse restored proportion divides into two usable
+ // columns. Rebase preserves every ratio while giving an odd numerator an
+ // exact half instead of rendering weight 1:2 from the value 3.
+ const coarse = try Pardes.init(std.testing.allocator, .{ .cols = 22, .rows = 10 });
+ defer coarse.deinit();
+ coarse.col_weight[0] = 3;
+ _ = try coarse.newShell(1, "");
+ try std.testing.expect(coarse.layoutSplitColumn(0, 1, false));
+ coarse.sync();
+ try std.testing.expectEqual(@as(u16, 11), coarse.col_w[0]);
+ try std.testing.expectEqual(@as(u16, 11), coarse.col_w[1]);
+
+ // A failed rebase is transactional even when the source pane is being
+ // MOVED out of a stack. Previously absorb/remove ran before overflow was
+ // discovered, so false meant the pane had silently vanished.
+ const extreme = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 20 });
+ defer extreme.deinit();
+ _ = try extreme.newShell(1, "");
+ try std.testing.expect(extreme.layoutSplitColumn(0, 1, false));
+ _ = try extreme.newShell(2, "");
+ extreme.layoutInsert(0, 1, 2);
+ extreme.col_weight[0] = std.math.maxInt(u64) / 2 + 2; // odd and cannot double
+ extreme.col_weight[1] = 1;
+ extreme.computeGeom();
+ const terms_before = extreme.col_terms;
+ const counts_before = extreme.col_n;
+ const weights_before = extreme.col_weight;
+ const source_vweight = extreme.panes[0].?.vweight;
+ const sibling_vweight = extreme.panes[2].?.vweight;
+ try std.testing.expect(!extreme.layoutSplitColumn(0, 0, false));
+ try std.testing.expectEqual(terms_before, extreme.col_terms);
+ try std.testing.expectEqual(counts_before, extreme.col_n);
+ try std.testing.expectEqual(weights_before, extreme.col_weight);
+ try std.testing.expectEqual(source_vweight, extreme.panes[0].?.vweight);
+ try std.testing.expectEqual(sibling_vweight, extreme.panes[2].?.vweight);
+}
+
+test "Newcol refuses an unsplittable restored weight before spawning" {
+ if (platform == .web) return;
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 200, .rows = 20 });
+ defer p.deinit();
+
+ // Restore accepts each individual weight up to maxInt/6. Deleting three
+ // one-pane columns can legitimately coalesce four such values into an odd
+ // survivor above maxInt/2, which cannot be globally doubled for a split.
+ for (1..4) |id| {
+ _ = try p.newShell(id, "");
+ try std.testing.expect(p.layoutSplitColumn(id - 1, id, false));
+ }
+ const restored_cap = std.math.maxInt(u64) / 6;
+ for (0..4) |column| p.col_weight[column] = restored_cap;
+ p.col_weight[3] -= 1;
+ for ([_]usize{ 3, 2, 1 }) |id| {
+ const pane = p.panes[id].?;
+ p.layoutRemove(id);
+ p.deinitPane(pane);
+ p.panes[id] = null;
+ }
+ while (p.nextEffect()) |_| {}
+
+ try std.testing.expect(p.col_weight[0] > std.math.maxInt(u64) / 2);
+ try std.testing.expect(p.col_weight[0] % 2 == 1);
+ try std.testing.expect(!p.layoutCanSplitColumn(0));
+ const panes_before = p.panes;
+ const serial_before = p.next_serial;
+ try std.testing.expect(p.executeBuiltinLine(0, "Newcol"));
+ try std.testing.expectEqual(panes_before, p.panes);
+ try std.testing.expectEqual(serial_before, p.next_serial);
+ try std.testing.expect(p.nextEffect() == null);
+}
+
+test "layout commits publish finite tracks only for changed panes" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20 });
+ defer p.deinit();
+
+ _ = try p.newShell(1, "");
+ try std.testing.expect(p.layoutSplitColumn(0, 1, false));
+ p.sync();
+ const untouched = p.rects[1];
+
+ p.settings.panel_transition = .slide;
+ _ = try p.newShell(2, "");
+ try std.testing.expect(p.layoutSplitColumn(0, 2, true));
+ p.sync();
+
+ // Pane 1 belongs to the unrelated right column. Its exact layout stayed
+ // fixed, so it does not receive a presentation record either.
+ try std.testing.expect(p.panel_tracks[1] == null);
+ try std.testing.expect(p.panel_tracks[0] != null);
+ try std.testing.expect(p.panel_tracks[2] != null);
+ try std.testing.expectEqual(panel_animation.Phase.moving, p.panel_tracks[0].?.phase);
+ try std.testing.expectEqual(panel_animation.Phase.opening, p.panel_tracks[2].?.phase);
+ try std.testing.expectEqual(untouched, p.rects[1]);
+ try std.testing.expect(p.animationActive());
+
+ var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer frame.deinit();
+ const surface = try p.render(frame.allocator());
+ try std.testing.expectEqual(@as(usize, 2), surface.panelTracks().len);
+
+ for (0..panel_animation.Transition.slide.frames()) |_| p.update(.tick);
+ try std.testing.expect(!p.animationActive());
+ _ = frame.reset(.retain_capacity);
+ try std.testing.expectEqual(@as(usize, 0), (try p.render(frame.allocator())).panelTracks().len);
+
+ p.settings.panel_transition = .zoom;
+ p.update(.{ .resize = .{ .cols = 101, .rows = 17 } });
+ try std.testing.expect(!p.animationActive());
+ for (p.panel_tracks) |track| try std.testing.expect(track == null);
+}
+
+test "vertical close samples only a canonical baseline containing that pane" {
+ // Deleting an opener before its first canonical frame must not animate
+ // unrelated cells from the older global baseline as though they belonged
+ // to the short-lived pane.
+ {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 16, .tty_only = true });
+ defer p.deinit();
+ var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer frame.deinit();
+ const boot = try p.render(frame.allocator());
+ p.acknowledgePanelPresentation(boot.panelTracks());
+ p.settings.panel_transition = .vertical;
+
+ _ = try p.newShell(1, "");
+ try std.testing.expect(p.layoutSplitColumn(0, 1, false));
+ p.sync();
+ _ = frame.reset(.retain_capacity);
+ const opening = try p.render(frame.allocator());
+ p.acknowledgePanelPresentation(opening.panelTracks());
+ p.removePane(1);
+ p.sync();
+
+ try std.testing.expectEqual(@as(usize, 0), p.nclosing_panel_tracks);
+ }
+
+ // Once the pane itself has reached a canonical acknowledged frame, that
+ // exact box is safe frozen source data for its ordinary closing slide.
+ {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 16, .tty_only = true });
+ defer p.deinit();
+ var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer frame.deinit();
+ const boot = try p.render(frame.allocator());
+ p.acknowledgePanelPresentation(boot.panelTracks());
+ p.settings.panel_transition = .vertical;
+
+ _ = try p.newShell(1, "");
+ try std.testing.expect(p.layoutSplitColumn(0, 1, false));
+ p.sync();
+ for (0..panel_animation.Transition.vertical.frames()) |_| p.update(.tick);
+ _ = frame.reset(.retain_capacity);
+ const canonical = try p.render(frame.allocator());
+ try std.testing.expectEqual(@as(usize, 0), canonical.panelTracks().len);
+ p.acknowledgePanelPresentation(canonical.panelTracks());
+ const serial = p.panes[1].?.serial;
+ p.removePane(1);
+ p.sync();
+
+ try std.testing.expectEqual(@as(usize, 1), p.nclosing_panel_tracks);
+ try std.testing.expectEqual(serial, p.closing_panel_tracks[0].serial);
+ }
+}
+
+test "previous-grid animation refreshes its mask and snaps overlapping layout churn" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true });
+ defer p.deinit();
+ var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer frame.deinit();
+ const boot = try p.render(frame.allocator());
+ p.acknowledgePanelPresentation(boot.panelTracks());
+ p.settings.panel_transition = .vertical;
+
+ _ = try p.newShell(1, "");
+ try std.testing.expect(p.layoutSplitColumn(0, 1, false));
+ p.sync();
+ _ = frame.reset(.retain_capacity);
+ const opening = try p.render(frame.allocator());
+ const changed_index = std.mem.indexOfScalar(u8, opening.changed_cells, 1) orelse
+ return error.MissingPanelDiff;
+ p.acknowledgePanelPresentation(opening.panelTracks());
+
+ // Corrupt one cached bit to prove a later live frame derives it again
+ // from the frozen old cells and freshly rendered new cells.
+ p.panel_changed_cells[changed_index] = 0;
+ _ = frame.reset(.retain_capacity);
+ const refreshed = try p.render(frame.allocator());
+ try std.testing.expectEqual(@as(u8, 1), refreshed.changed_cells[changed_index]);
+ p.acknowledgePanelPresentation(refreshed.panelTracks());
+
+ // A second opener before the first canonical endpoint has no truthful
+ // single old grid. Submit this layout canonically instead of rewinding to
+ // the boot baseline or manufacturing a stale closing pane.
+ _ = try p.newShell(2, "");
+ try std.testing.expect(p.layoutSplitColumn(1, 2, false));
+ p.sync();
+ try std.testing.expect(!p.panel_diff_pending and !p.panel_diff_ready);
+ try std.testing.expectEqual(@as(usize, 0), p.nclosing_panel_tracks);
+ for (p.panel_tracks) |track| try std.testing.expect(track == null);
+ try std.testing.expect(p.panel_presentation_pending);
+
+ _ = frame.reset(.retain_capacity);
+ const snapped = try p.render(frame.allocator());
+ try std.testing.expectEqual(@as(usize, 0), snapped.panelTracks().len);
+}
+
+test "canonical fallback and transition toggle retire unpresented tracks" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 16, .tty_only = true });
+ defer p.deinit();
+ var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer frame.deinit();
+ const boot = try p.render(frame.allocator());
+ p.acknowledgePanelPresentation(boot.panelTracks());
+ p.settings.panel_transition = .slide;
+
+ _ = try p.newShell(1, "");
+ try std.testing.expect(p.layoutSplitColumn(0, 1, false));
+ p.sync();
+ _ = frame.reset(.retain_capacity);
+ const animated = try p.render(frame.allocator());
+ try std.testing.expect(animated.panelTracks().len > 0);
+
+ // A host which rendered the canonical fallback acknowledges exactly that
+ // empty record set; producers cannot resume on its next frame.
+ p.acknowledgePanelPresentation(&.{});
+ try std.testing.expect(!p.animationActive());
+ try std.testing.expect(!p.panel_diff_pending and !p.panel_diff_ready);
+ _ = frame.reset(.retain_capacity);
+ try std.testing.expectEqual(@as(usize, 0), (try p.render(frame.allocator())).panelTracks().len);
+
+ // Recreate a visible sample, then change the selected effect. Actual old
+ // pixels remain on screen until the next submit, so pointer input waits.
+ p.settings.panel_transition = .slide;
+ p.col_weight[0] = column_weight_unit * 2;
+ p.col_weight[1] = column_weight_unit;
+ p.sync();
+ _ = frame.reset(.retain_capacity);
+ const moving = try p.render(frame.allocator());
+ p.acknowledgePanelPresentation(moving.panelTracks());
+ try std.testing.expect(moving.panelTracks().len > 0);
+ p.applySettingBuiltin(runtime_cfg.find("PanelZoom").?, null);
+ try std.testing.expectEqual(panel_animation.Transition.zoom, p.settings.panel_transition);
+ try std.testing.expect(p.panel_presentation_pending);
+ try std.testing.expect(p.presentedPointer(10, 4) == null);
+ for (p.panel_tracks) |track| try std.testing.expect(track == null);
+
+ // Once canonical has replaced that sample, make another real transition.
+ // Re-applying the effective tagline percentage is inert, but changing it
+ // invalidates the frozen raster's metrics and therefore snaps the tracks.
+ p.acknowledgePanelPresentation(&.{});
+ p.col_weight[0] = column_weight_unit;
+ p.col_weight[1] = column_weight_unit * 2;
+ p.sync();
+ _ = frame.reset(.retain_capacity);
+ const before_resize = try p.render(frame.allocator());
+ p.acknowledgePanelPresentation(before_resize.panelTracks());
+ try std.testing.expect(before_resize.panelTracks().len > 0);
+ const old_tagline_percent = p.settings.font.tagline_percent;
+ var percent_buf: [3]u8 = undefined;
+ const same_percent = try std.fmt.bufPrint(&percent_buf, "{d}", .{old_tagline_percent});
+ p.applySettingBuiltin(runtime_cfg.find("TaglineSize").?, same_percent);
+ try std.testing.expect(p.animationActive());
+ const changed_percent: u8 = if (old_tagline_percent == 73) 74 else 73;
+ const changed_text = try std.fmt.bufPrint(&percent_buf, "{d}", .{changed_percent});
+ p.applySettingBuiltin(runtime_cfg.find("TaglineSize").?, changed_text);
+ try std.testing.expectEqual(changed_percent, p.settings.font.tagline_percent);
+ try std.testing.expect(p.panel_presentation_pending);
+ for (p.panel_tracks) |track| try std.testing.expect(track == null);
+}
+
+test "pointer coordinates follow presented panel geometry" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+
+ const moving: panel_animation.Track = .{
+ .serial = pane.serial,
+ .pane = 0,
+ .phase = .moving,
+ .effect = .slide,
+ .from = .{ .x = 20, .y = 5, .w = 40, .h = 10 },
+ .to = .{ .x = 0, .y = 1, .w = 80, .h = 18 },
+ };
+ p.acknowledgePanelPresentation(&.{moving});
+ // One shared physical-to-logical map feeds all pointer gestures. The
+ // selected sample is 26.25% across and 35% down the presented rectangle.
+ try std.testing.expectEqual(Pardes.PointerCell{ .col = 21, .row = 7 }, p.presentedPointer(30, 8).?);
+ // Canonical cells covered only by the not-yet-arrived target are inert.
+ try std.testing.expect(p.presentedPointer(2, 2) == null);
+ // Unrelated screen space remains in the ordinary grid coordinate system.
+ try std.testing.expectEqual(Pardes.PointerCell{ .col = 100, .row = 10 }, p.presentedPointer(100, 10).?);
+
+ p.acknowledgePanelPresentation(&.{panel_animation.Track{
+ .serial = pane.serial,
+ .pane = 0,
+ .phase = .opening,
+ .effect = .zoom,
+ .from = .{ .x = 40, .y = 10, .w = 0, .h = 0 },
+ .to = .{ .x = 0, .y = 1, .w = 80, .h = 18 },
+ }});
+ try std.testing.expect(p.presentedPointer(40, 10) == null);
+
+ // Tracks paint in pane-slot order inside a phase. The later slot is the
+ // top quad and therefore owns an overlap, even though both are moving.
+ const overlap = [_]panel_animation.Track{ .{
+ .serial = pane.serial,
+ .pane = 0,
+ .phase = .moving,
+ .effect = .slide,
+ .from = .{ .x = 20, .y = 2, .w = 20, .h = 4 },
+ .to = .{ .x = 0, .y = 2, .w = 20, .h = 4 },
+ }, .{
+ .serial = pane.serial + 1,
+ .pane = 1,
+ .phase = .moving,
+ .effect = .slide,
+ .from = .{ .x = 20, .y = 2, .w = 20, .h = 4 },
+ .to = .{ .x = 60, .y = 2, .w = 20, .h = 4 },
+ } };
+ // Slot one is deliberately absent from the fixture, so install this
+ // synthetic overlap directly; the production acknowledgement rejects
+ // dead pane lifetimes before they can participate in input.
+ p.presented_panel_tracks = @splat(null);
+ p.presented_panel_tracks[0] = overlap[0];
+ p.presented_panel_tracks[1] = overlap[1];
+ try std.testing.expectEqual(Pardes.PointerCell{ .col = 65, .row = 3 }, p.presentedPointer(25, 3).?);
+}
+
+test "queued pointer input is inert until a changed layout is presented" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 16, .tty_only = true });
+ defer p.deinit();
+ // Establish that a real host has presented the boot layout.
+ p.acknowledgePanelPresentation(&.{});
+ p.settings.panel_transition = .slide;
+
+ _ = try p.newShell(1, "");
+ try std.testing.expect(p.layoutSplitColumn(0, 1, false));
+ p.sync();
+ try std.testing.expect(p.panel_presentation_pending);
+ try std.testing.expect(p.presentedPointer(10, 4) == null);
+
+ var tracks: [MAX_PANES]panel_animation.Track = undefined;
+ var len: usize = 0;
+ for (p.panel_tracks) |maybe| if (maybe) |track| {
+ tracks[len] = track;
+ len += 1;
+ };
+ p.acknowledgePanelPresentation(tracks[0..len]);
+ try std.testing.expect(!p.panel_presentation_pending);
+ // Whether this particular opening sample exposes the chosen cell is the
+ // transition's concern; it is no longer rejected merely as speculative.
+ try std.testing.expect(p.presentedPointer(99, 4) != null);
+}
+
+test "back-to-back layout commits retarget from the last presented boxes" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true });
+ defer p.deinit();
+ _ = try p.newShell(1, "");
+ try std.testing.expect(p.layoutSplitColumn(0, 1, false));
+ p.sync();
+
+ var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer frame.deinit();
+ const initial = try p.render(frame.allocator());
+ p.acknowledgePanelPresentation(initial.panelTracks());
+ const shown_a = Pardes.panelBox(p.rects[0]);
+ p.settings.panel_transition = .slide;
+
+ p.col_weight[0] = column_weight_unit;
+ p.col_weight[1] = column_weight_unit * 2;
+ p.sync();
+ const target_b = p.panel_tracks[0] orelse return error.MissingFirstRetarget;
+ try std.testing.expect(target_b.from.eql(shown_a));
+
+ // No render or acknowledgement of B: the pixels are still A. C must not
+ // jump to the logical-but-unseen B before beginning its own transition.
+ p.col_weight[0] = column_weight_unit * 2;
+ p.col_weight[1] = column_weight_unit;
+ p.sync();
+ const target_c = p.panel_tracks[0] orelse return error.MissingSecondRetarget;
+ try std.testing.expect(target_c.from.eql(shown_a));
+ try std.testing.expect(target_c.to.eql(Pardes.panelBox(p.rects[0])));
+ try std.testing.expectEqual(panel_animation.Phase.moving, target_c.phase);
+
+ _ = frame.reset(.retain_capacity);
+ const latest = try p.render(frame.allocator());
+ var submitted: ?panel_animation.Track = null;
+ for (latest.panelTracks()) |track| if (track.pane == 0) {
+ submitted = track;
+ break;
+ };
+ const track = submitted orelse return error.MissingSubmittedRetarget;
+ p.acknowledgePanelPresentation(latest.panelTracks());
+ try std.testing.expect(p.presented_panel_layout[0].?.box.eql(track.visualBox()));
+}
+
+test "an unpresented opening pane remains in the opening paint phase" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true });
+ defer p.deinit();
+ var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer frame.deinit();
+ const initial = try p.render(frame.allocator());
+ p.acknowledgePanelPresentation(initial.panelTracks());
+ p.settings.panel_transition = .slide;
+
+ _ = try p.newShell(1, "");
+ try std.testing.expect(p.layoutSplitColumn(0, 1, false));
+ p.sync();
+ const first = p.panel_tracks[1] orelse return error.MissingOpeningTrack;
+ try std.testing.expectEqual(panel_animation.Phase.opening, first.phase);
+
+ _ = try p.newShell(2, "");
+ try std.testing.expect(p.layoutSplitColumn(1, 2, true));
+ p.sync();
+ const retargeted = p.panel_tracks[1] orelse return error.MissingOpeningRetarget;
+ try std.testing.expectEqual(panel_animation.Phase.opening, retargeted.phase);
+ try std.testing.expect(retargeted.from.eql(first.from));
+}
+
+test "pointer rejects panel content cells which have not materialized" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 12, .tty_only = true });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const target: panel_animation.Box = .{ .x = 4, .y = 2, .w = 32, .h = 4 };
+ p.panel_changed_cells = try p.gpa.alloc(u8, @as(usize, p.screen_w) * p.screen_h);
+ @memset(p.panel_changed_cells, 0);
+ for (2..6) |row| {
+ for (4..36) |col| {
+ p.panel_changed_cells[row * p.screen_w + col] = 1;
+ }
+ }
+ p.panel_diff_ready = true;
+
+ for ([_]panel_animation.Transition{ .dissolve, .ascii }) |effect| {
+ const frame: u16 = if (effect == .dissolve) 3 else 5;
+ const track: panel_animation.Track = .{
+ .serial = pane.serial,
+ .pane = 0,
+ .phase = .opening,
+ .effect = effect,
+ .frame = frame,
+ .from = target,
+ .to = target,
+ };
+ p.acknowledgePanelPresentation(&.{track});
+
+ var saw_visible = false;
+ var saw_hidden = false;
+ for (0..4) |relative_row| for (0..32) |relative_col| {
+ const rcol: u16 = @intCast(relative_col);
+ const rrow: u16 = @intCast(relative_row);
+ const visible = switch (effect) {
+ .dissolve => panel_animation.dissolveRevealed(
+ pane.serial,
+ rcol,
+ rrow,
+ track.amount(),
+ ),
+ .ascii => panel_animation.asciiCell(
+ pane.serial,
+ rcol,
+ rrow,
+ track.amount(),
+ ).reveal,
+ else => unreachable,
+ };
+ const col: u16 = @intCast(4 + relative_col);
+ const row: u16 = @intCast(2 + relative_row);
+ const mapped = p.presentedPointer(col, row);
+ try std.testing.expectEqual(visible, mapped != null);
+ if (mapped) |point|
+ try std.testing.expectEqual(Pardes.PointerCell{ .col = col, .row = row }, point);
+ saw_visible = saw_visible or visible;
+ saw_hidden = saw_hidden or !visible;
+ };
+ try std.testing.expect(saw_visible);
+ try std.testing.expect(saw_hidden);
+ }
+}
+
+test "unchanged content cells remain clickable through data effects" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 40, .rows = 10, .tty_only = true });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ p.panel_changed_cells = try p.gpa.alloc(u8, @as(usize, p.screen_w) * p.screen_h);
+ @memset(p.panel_changed_cells, 0);
+ p.panel_diff_ready = true;
+ const box: panel_animation.Box = .{ .x = 2, .y = 2, .w = 20, .h = 4 };
+
+ for ([_]panel_animation.Transition{ .dissolve, .ascii }) |effect| {
+ p.acknowledgePanelPresentation(&.{.{
+ .serial = pane.serial,
+ .pane = 0,
+ .phase = .opening,
+ .effect = effect,
+ .from = box,
+ .to = box,
+ }});
+ try std.testing.expectEqual(Pardes.PointerCell{ .col = 8, .row = 3 }, p.presentedPointer(8, 3).?);
+ }
+}
+
+test "stationary Look hover follows only acknowledged panel samples" {
+ _ = config.look_preview_delay_frames orelse return;
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const rect = p.rects[0];
+ const target: panel_animation.Box = .{
+ .x = @floatFromInt(rect.x),
+ .y = @floatFromInt(rect.y),
+ .w = @floatFromInt(rect.w),
+ .h = @floatFromInt(rect.h),
+ };
+ var track: panel_animation.Track = .{
+ .serial = pane.serial,
+ .pane = 0,
+ .phase = .moving,
+ .effect = .slide,
+ .from = .{ .x = 20, .y = 5, .w = 40, .h = 10 },
+ .to = target,
+ };
+ p.acknowledgePanelPresentation(&.{track});
+ p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = 30, .row = 8 } });
+ const first = p.look_hover_wait orelse return error.MissingInitialHoverWait;
+
+ // Advancing the producer-side track alone cannot move the semantic cell
+ // under a stationary pointer.
+ track.frame = track.effect.frames() - 1;
+ p.panel_tracks[0] = track;
+ p.refreshLookHoverFromRaw();
+ try std.testing.expectEqual(first, p.look_hover_wait.?);
+
+ // Once that exact sample is acknowledged, the same raw cell maps through
+ // the new panel rectangle and the debounce starts over for the new target.
+ p.acknowledgePanelPresentation(&.{track});
+ const moved = p.look_hover_wait orelse return error.MissingRemappedHoverWait;
+ try std.testing.expect(first.col != moved.col or first.row != moved.row);
+ try std.testing.expectEqual(@as(u16, 0), moved.frames);
+
+ // Re-acknowledging the same semantic target is host repaint noise, not a
+ // new hover, so it preserves the accumulated delay.
+ p.look_hover_wait.?.frames = 3;
+ p.acknowledgePanelPresentation(&.{track});
+ try std.testing.expectEqual(@as(u16, 3), p.look_hover_wait.?.frames);
+
+ // A zero-sized opening sample covers its future target but exposes no
+ // clickable content. Successful presentation therefore cancels the hover.
+ track.phase = .opening;
+ track.effect = .zoom;
+ track.frame = 0;
+ track.from = .{
+ .x = target.x + target.w * 0.5,
+ .y = target.y + target.h * 0.5,
+ };
+ p.acknowledgePanelPresentation(&.{track});
+ try std.testing.expect(p.look_hover_wait == null);
+ try std.testing.expect(p.look_hover_preview == null);
+
+ // The motion intent itself survives temporary invisibility. When a later
+ // successfully presented sample materializes under the stationary raw
+ // pointer, it can begin a fresh delay without synthetic mouse motion.
+ track.frame = track.effect.frames() - 1;
+ p.acknowledgePanelPresentation(&.{track});
+ try std.testing.expect(p.look_hover_wait != null);
+}
+
+test "held drag follows acknowledged panels and balances an invisible release" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const target = Pardes.panelBox(p.rects[0]);
+ var track: panel_animation.Track = .{
+ .serial = pane.serial,
+ .pane = 0,
+ .phase = .moving,
+ .effect = .slide,
+ .from = .{ .x = 50, .y = 5, .w = 20, .h = 8 },
+ .to = target,
+ };
+ const raw_col: u16 = 55;
+ const raw_row: u16 = 7;
+ p.acknowledgePanelPresentation(&.{track});
+ p.update(.{ .mouse = .{
+ .button = config.select_button,
+ .kind = .press,
+ .col = raw_col,
+ .row = raw_row,
+ } });
+ try std.testing.expect(p.drag == .select);
+ const first_col = pane.sel[sel_slot].c1;
+ const first_row = pane.sel[sel_slot].r1;
+
+ // Advancing producer state alone cannot move input. Only the sample the
+ // host says it actually drew may remap the stationary held endpoint.
+ track.frame = track.effect.frames() - 1;
+ p.panel_tracks[0] = track;
+ try std.testing.expectEqual(first_col, pane.sel[sel_slot].c1);
+ try std.testing.expectEqual(first_row, pane.sel[sel_slot].r1);
+ p.acknowledgePanelPresentation(&.{track});
+ try std.testing.expect(first_col != pane.sel[sel_slot].c1 or
+ first_row != pane.sel[sel_slot].r1);
+
+ const last_col = pane.sel[sel_slot].c1;
+ const last_row = pane.sel[sel_slot].r1;
+ track.phase = .opening;
+ track.effect = .zoom;
+ track.frame = 0;
+ track.from = .{
+ .x = target.x + target.w * 0.5,
+ .y = target.y + target.h * 0.5,
+ };
+ p.acknowledgePanelPresentation(&.{track});
+ try std.testing.expect(p.presentedPointer(raw_col, raw_row) == null);
+ try std.testing.expectEqual(last_col, pane.sel[sel_slot].c1);
+ try std.testing.expectEqual(last_row, pane.sel[sel_slot].r1);
+
+ // An unrelated release cannot consume the left drag. Its own release is
+ // nevertheless balanced at the retained endpoint while the cell is dark.
+ p.update(.{ .mouse = .{
+ .button = config.exec_button,
+ .kind = .release,
+ .col = raw_col,
+ .row = raw_row,
+ } });
+ try std.testing.expect(p.drag == .select);
+ p.update(.{ .mouse = .{
+ .button = config.select_button,
+ .kind = .release,
+ .col = raw_col,
+ .row = raw_row,
+ } });
+ try std.testing.expect(p.drag == .none);
+ try std.testing.expectEqual(.done, pane.sel[sel_slot].state);
+}
+
+test "repeated non-dyadic column splits preserve every unrelated boundary" {
+ if (platform == .web) return;
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 979, .rows = 20 });
+ defer p.deinit();
+
+ _ = try p.newShell(1, "");
+ try std.testing.expect(p.layoutSplitColumn(0, 1, false));
+ p.sync();
+
+ // Make the pair 418/561 through the real divider path. This ratio was a
+ // counterexample for f32 weights: splitting the right side twice changed
+ // the already-created middle boundary by one cell.
+ const handle = p.col_x[0] + p.col_w[0] - 1;
+ const untouched = p.col_weight;
+ p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = TOPBAR_H + 1 } });
+ p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle, .row = TOPBAR_H + 1 } });
+ try std.testing.expectEqual(untouched, p.col_weight);
+
+ p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = TOPBAR_H + 1 } });
+ p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = 417, .row = TOPBAR_H + 1 } });
+ p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = 417, .row = TOPBAR_H + 1 } });
+ p.sync();
+ try std.testing.expectEqual(@as(u16, 418), p.rects[0].w);
+
+ _ = try p.newShell(2, "");
+ try std.testing.expect(p.layoutSplitColumn(1, 2, false));
+ p.sync();
+ const left_before = p.rects[0];
+ const middle_before = p.rects[1];
+
+ _ = try p.newShell(3, "");
+ try std.testing.expect(p.layoutSplitColumn(2, 3, false));
+ p.sync();
+ try std.testing.expectEqual(left_before, p.rects[0]);
+ try std.testing.expectEqual(middle_before, p.rects[1]);
+}
+
pub const Rect = struct { x: u16, y: u16, w: u16, h: u16 };
+const LayoutSnapshot = struct {
+ serial: u32,
+ box: panel_animation.Box,
+};
+
pub const Options = struct {
tty_only: bool = false,
/// initial shell panes: 1 (default) or 3 for the classic two-column boot.
@@ -4919,10 +4082,11 @@ pub const Options = struct {
tree_sitter_allocator: ?std.mem.Allocator = null,
/// Initial grid. Shell contract: for LIVE sessions leave these at the
/// defaults and deliver the real size as the first resize EVENT — the core
- /// defers each shell's greeting until after a resize (so `ls` wraps to the
- /// real pane width); pre-sizing here means that resize never fires and the
- /// greeting never runs. Pre-size only for dump loads (nothing greets, and
- /// it avoids reflowing replayed content twice).
+ /// defers an integrated shell's greeting until after a resize AND its OSC
+ /// 133 B input mark (so `ls` cannot race startup and wraps to the real pane
+ /// width); pre-sizing here means that resize never fires and the greeting
+ /// never runs. Pre-size only for dump loads (nothing greets, and it avoids
+ /// reflowing replayed content twice).
cols: u16 = 80,
rows: u16 = 24,
};
@@ -4983,13 +4147,67 @@ pub const Pardes = struct {
panes: [MAX_PANES]?*Pane = @splat(null),
// layout: columns own x by weight; panes own y by vweight within a column.
ncol: usize = 0,
- col_weight: [MAX_COLS]f32 = @splat(1),
+ /// Fixed-point horizontal proportions. Integer sums make splitting W
+ /// into A+B exactly associative, so an unrelated boundary cannot move
+ /// through floating-point regrouping after repeated source-local splits.
+ col_weight: [MAX_COLS]u64 = @splat(column_weight_unit),
col_terms: [MAX_COLS][MAX_PANES]usize = undefined,
col_n: [MAX_COLS]usize = @splat(0),
// derived each sync
rects: [MAX_PANES]Rect = undefined,
col_x: [MAX_COLS]u16 = undefined,
col_w: [MAX_COLS]u16 = undefined,
+ /// Last committed layout and the finite visual tracks derived from it.
+ /// Both are indexed by pane slot; serial rejects slot reuse.
+ layout_snapshot: [MAX_PANES]?LayoutSnapshot = @splat(null),
+ layout_snapshot_ready: bool = false,
+ /// Boot/config replay is not a visible layout event, and a host resize is
+ /// already continuous physical motion. Those paths snap this presentation
+ /// cache once instead of manufacturing panel transitions.
+ panel_animation_enabled: bool = false,
+ snap_panel_layout_once: bool = false,
+ /// Tracks the core is preparing for the next frame, and the independent
+ /// tracks a backend has actually put on screen. Input must follow the
+ /// latter: a failed GPU submit or a delayed AppKit draw cannot make an
+ /// unpresented animation tick clickable.
+ panel_tracks: [MAX_PANES]?panel_animation.Track = @splat(null),
+ presented_panel_tracks: [MAX_PANES]?panel_animation.Track = @splat(null),
+ /// Deleted pane lifetimes cannot stay in the slot-indexed live table: a
+ /// slot may be reused while its old pixels are still leaving. Tombstones
+ /// are dense plain records and never retain a functional Pane.
+ closing_panel_tracks: [MAX_PANES]panel_animation.Track = undefined,
+ nclosing_panel_tracks: usize = 0,
+ presented_closing_panel_tracks: [MAX_PANES]panel_animation.Track = undefined,
+ npresented_closing_panel_tracks: usize = 0,
+ /// Last canonical grid a backend explicitly acknowledged, and the frozen
+ /// semantic diff from it to the first canonical frame of this transition.
+ /// These buffers are capacity-reused and own no pane resources.
+ presented_cells: []Cell = &.{},
+ presented_cells_cols: u16 = 0,
+ presented_cells_rows: u16 = 0,
+ presented_cells_valid: bool = false,
+ /// Pane lifetimes and boxes represented by `presented_cells`. This is
+ /// deliberately separate from `presented_panel_layout`, which follows
+ /// partially animated pixels. A closing tombstone may sample the frozen
+ /// grid only when that grid actually contains the exact pane box.
+ presented_cells_layout: [MAX_PANES]?LayoutSnapshot = @splat(null),
+ panel_changed_cells: []u8 = &.{},
+ panel_diff_pending: bool = false,
+ panel_diff_ready: bool = false,
+ /// Canonical pane lifetimes/boxes captured by render, and the visual boxes
+ /// from the last successful acknowledgement. Retargeting reads the latter,
+ /// never a logical layout which may not have reached the screen yet.
+ submitted_panel_layout: [MAX_PANES]?LayoutSnapshot = @splat(null),
+ submitted_panel_layout_ready: bool = false,
+ presented_panel_layout: [MAX_PANES]?LayoutSnapshot = @splat(null),
+ /// Becomes true on the first host acknowledgement. Sans-host unit callers
+ /// retain direct canonical pointer semantics; a real frontend thereafter
+ /// gets the strict unpresented interval below.
+ panel_presentation_ready: bool = false,
+ /// A layout mutation has not reached a backend yet. Pointer gestures are
+ /// inert during this normally sub-frame interval; guessing frame-zero
+ /// geometry would make queued input address pixels nobody has presented.
+ panel_presentation_pending: bool = false,
active: usize = 0,
/// THE focus history: where the keyboard has BEEN, oldest first, and `jcur`
@@ -5018,39 +4236,24 @@ pub const Pardes = struct {
/// coincide by accident.
look_src: [MAX_PANES]u32 = undefined,
n_look_src: usize = 0,
+ /// Serial of the pane whose stream n/N is actively walking. Focus may
+ /// leave it when Enter/Look opens a row; provenance does not. Null/stale
+ /// falls back to the ordinary history order.
+ look_walk_owner: ?u32 = null,
/// hands out Pane.serial; monotonic, never reused
next_serial: u32 = 0,
- theme_idx: usize = 0,
+ /// Every user-settable, queryable display/runtime choice in one plain
+ /// authoritative record. Generated setting builtins mutate it directly;
+ /// rendering and the Config report read those same fields.
+ settings: runtime_cfg.State = .{ .font = .{ .tagline_percent = config.gui_tagline_font_percent } },
+ /// Delivery bookkeeping, not configuration: prevents a synchronous host
+ /// pump from taking one still-pending font request more than once before it
+ /// acknowledges success or rejection.
+ font_request_taken: bool = false,
chrome_animation: ChromeAnimation = ChromeAnimation.init(initial_chrome),
/// False only while startup configuration or a dump restore is selecting
/// its first theme. No frame is rendered in that interval.
animate_theme_changes: bool = false,
- colors_on: bool = true,
- /// The binary a NEW terminal execs (the Shell builtin). Inline and fixed
- /// like every other short string the core keeps, and `0` means "the
- /// default" rather than a copy of it — so there is no constructor to
- /// remember and config.default_shell stays the single place the default is
- /// written. Existing panes are not touched: a shell you are already in is
- /// a process, not a setting.
- shell_buf: [255]u8 = undefined,
- shell_len: u8 = 0,
- /// soft line breaks in file panes (the Wrap builtin). ON by default: a line
- /// running off the right edge is text you cannot read without scrolling
- /// sideways for it, and the reason to reach for the toggle is the rare
- /// file — a table, a fixture, a minified blob — whose columns mean
- /// something. Turned off, every row math in here is the one-row-per-line
- /// arithmetic it always was and hscroll comes back.
- wrap_on: bool = true,
- /// every pane's tagline on its LAST row instead of its first (the
- /// Tagbottom builtin). Render and hit-testing derive two locals from it —
- /// the tag row and the first body row — and nothing else in here knows;
- /// the Sel coordinate space is untouched, so row 0 is still the tag and
- /// the body still starts at BOX_H whichever end they are drawn at.
- tag_bottom: bool = false,
- show_debug: bool = false,
- /// the Crt builtin's CRT post-process, rendered by the gui shell only
- /// (the tty and web shells never read it)
- crt_on: bool = false,
/// Native pixel attachments supported by the shell (Kitty graphics in a
/// terminal, GPU textures in SDL). Image panes dynamically fall back to
/// the PETSCII matcher without it.
@@ -5068,6 +4271,19 @@ pub const Pardes = struct {
drag: Drag = .none,
hover_col: u16 = 0,
hover_row: u16 = 0,
+ pointer_raw_col: u16 = 0,
+ pointer_raw_row: u16 = 0,
+ /// True only after buttonless motion. Presentation acknowledgements may
+ /// remap that stationary hover; keyboard/button/wheel input clears the
+ /// intent so an ordinary repaint cannot silently re-arm a cancelled hint.
+ raw_hover_intent: bool = false,
+ /// Hosts explicitly tell us when the pointer leaves. Keeping this bit
+ /// separate from the last coordinates lets a drag retain its endpoint
+ /// while idle resize-handle hints disappear immediately outside a window.
+ pointer_inside: bool = false,
+ look_hover_wait: ?LookHoverWait = null,
+ look_hover_preview: ?LookHoverPreview = null,
+ pdf_hover_preview: if (pdf_enabled) ?PdfWordPreview else void = if (pdf_enabled) null else {},
/// touchpad drift guard, counted down in horizontal wheel ticks — see
/// config.wheelTick, which owns the whole rule. Global, and clock-free on
/// purpose: the core has no clock, so "recently scrolled vertically" can
@@ -5091,7 +4307,7 @@ pub const Pardes = struct {
multi_stop: bool = false,
/// SPC leader in flight, holding the key path typed so far (empty = just
/// SPC). Global like ctrl_w_pending — there is one leader and it acts on
- /// the active pane, whose tag shows the pending path while it waits.
+ /// the active pane, whose transient message row shows the pending path.
leader_on: bool = false,
leader_keys: [4]u8 = undefined,
leader_n: u8 = 0,
@@ -5159,7 +4375,7 @@ pub const Pardes = struct {
/// write_dump); shown in the topbar as `Restore <path>`
last_dump: ?[]const u8 = null,
/// a Restore builtin wants this dump loaded into the live session; the
- /// shell consumes it via takeRestore each frame (core stays sans-IO)
+ /// shell consumes it via takeRestore each frame (restore contents stay host-fed)
restore_req: ?[]const u8 = null,
restore_buf: [1024]u8 = undefined,
@@ -5193,25 +4409,25 @@ pub const Pardes = struct {
// away when it is wanted. Doc, PDF and image all boot the same way.
_ = initial_doc: {
if (comptime pdf_enabled) if (look.isPdfPath(path))
- break :initial_doc try p.openPdfView(0, path, opts.file_line);
+ break :initial_doc try pdf_pane.openPane(p, 0, path, opts.file_line);
if (look.isImagePath(path))
- break :initial_doc try p.openImageView(0, path);
+ break :initial_doc try image_pane.create(p, 0, path, &.{});
break :initial_doc try file_pane.open(p, 0, path, opts.file_line);
};
p.ncol = 1;
p.col_n[0] = 1;
p.col_terms[0][0] = 0;
} else if (opts.tty_only) {
- _ = try p.newPane(0);
+ _ = try p.newShell(0, "");
p.panes[0].?.mode = .tty;
p.ncol = 1;
p.col_n[0] = 1;
p.col_terms[0][0] = 0;
} else if (opts.shells >= 3) {
// classic layout: two columns, the left one split in two.
- _ = try p.newPane(0);
- _ = try p.newPane(1);
- _ = try p.newPane(2);
+ _ = try p.newShell(0, "");
+ _ = try p.newShell(1, "");
+ _ = try p.newShell(2, "");
for (p.panes[0..3]) |slot| slot.?.greet = true;
p.ncol = 2;
p.col_n[0] = 2;
@@ -5221,7 +4437,7 @@ pub const Pardes = struct {
p.col_terms[1][0] = 2;
} else {
// ponytail: 1 and 3 are the only boot layouts; anything else acts as 1
- _ = try p.newPane(0);
+ _ = try p.newShell(0, "");
p.panes[0].?.greet = true;
p.ncol = 1;
p.col_n[0] = 1;
@@ -5231,6 +4447,7 @@ pub const Pardes = struct {
p.applyStartupConfig();
p.finishThemeInitialization();
p.sync();
+ p.panel_animation_enabled = true;
// A config file that opens a file with `Look …` armed the pulse before
// anyone touched anything. Nobody asked for that, so boot is silent.
_ = p.takeHaptic();
@@ -5238,6 +4455,7 @@ pub const Pardes = struct {
}
pub fn deinit(p: *Pardes) void {
+ p.cancelLookHover();
for (&p.panes) |*slot| if (slot.*) |pane| {
p.deinitPane(pane);
slot.* = null;
@@ -5251,6 +4469,8 @@ pub const Pardes = struct {
p.shell_rows.reset(gpa);
p.scratch.deinit();
gpa.free(p.surface.cells);
+ if (p.presented_cells.len > 0) gpa.free(p.presented_cells);
+ if (p.panel_changed_cells.len > 0) gpa.free(p.panel_changed_cells);
if (p.surface.images.len > 0) gpa.free(p.surface.images);
gpa.destroy(p);
}
@@ -5279,50 +4499,17 @@ pub const Pardes = struct {
}
pub fn deinitPane(p: *Pardes, pane: *Pane) void {
- std.debug.assert(pane.payloadValid());
+ if (p.lookHoverPane()) |hovered| {
+ if (hovered < p.panes.len and p.panes[hovered] == pane)
+ p.cancelLookHover();
+ }
p.shell_rows.dropPane(pane);
+ term_pane.deinitPendingCommand(pane);
if (pane.image) |*iv| {
- p.image_gpa.free(iv.path);
- if (iv.rgba.len > 0) p.image_gpa.free(iv.rgba);
- if (iv.raw.len > 0) p.image_gpa.free(iv.raw);
- if (iv.grid.len > 0) p.image_gpa.free(iv.grid);
- }
- if (comptime pdf_enabled) {
- if (pane.pdf) |*pv| {
- p.pdf_gpa.free(pv.path);
- for (pv.rasters[0..pv.rasters_len]) |raster| if (raster.rgba.len > 0)
- p.pdf_gpa.free(raster.rgba);
- for (pv.spare[0..pv.spare_len]) |retired| p.pdf_gpa.free(retired);
- p.pdf_gpa.free(pv.page_sizes);
- p.pdf_gpa.free(pv.page_starts);
- p.pdf_gpa.free(pv.page_heights);
- // `rgba` aliases the active raster for legacy geometry state;
- // its owner was released above.
- if (pv.text.len > 0) p.pdf_gpa.free(pv.text);
- if (pv.search_query.len > 0) p.pdf_gpa.free(pv.search_query);
- if (pv.search_results) |*results| results.deinit(p.pdf_gpa);
- if (pv.selection) |*selection| selection.deinit(p.pdf_gpa);
- if (pv.selection_text.len > 0) p.pdf_gpa.free(pv.selection_text);
- if (pv.outline) |*outline| outline.deinit(p.pdf_gpa);
- pv.document.deinit();
- }
- }
- if (pane.file) |*f| {
- // a watched file is going away: tell the shell to drop it. The id
- // comes from the slot, which every caller still has pointing at
- // `pane` (they null it right after) — deinitPane has never needed
- // one before and threading it through seven call sites for this is
- // worse than the scan.
- if (f.output == null) for (p.panes, 0..) |slot, i| {
- if (slot == pane) p.emit(.{ .watch = .{ .pane = @intCast(i), .on = false } });
- };
- p.gpa.free(f.path);
- p.gpa.free(f.content);
- if (f.line_starts.len > 0) p.gpa.free(f.line_starts);
- if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights);
- for (f.undo[0..f.undo_len]) |s| p.gpa.free(s.content);
- for (f.redo[0..f.redo_len]) |s| p.gpa.free(s.content);
+ iv.deinit(p.image_gpa);
}
+ if (comptime pdf_enabled) if (pane.pdf) |*pv| pdf_pane.deinitPane(p, pane, pv);
+ if (pane.file) |*file| file_pane.deinit(p, pane, file);
if (pane.ovl) |o| p.gpa.free(o.text);
for (pane.ed_undo[0..pane.ed_undo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text);
for (pane.ed_redo[0..pane.ed_redo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text);
@@ -5331,37 +4518,25 @@ pub const Pardes = struct {
p.gpa.destroy(pane);
}
+ /// Put a fully constructed pane in a free slot and give it the monotonic
+ /// identity every slot-reuse guard relies on. Pane kinds construct their
+ /// own payloads; this registration rule remains a core invariant.
+ fn installPane(p: *Pardes, id: usize, pane: *Pane) void {
+ std.debug.assert(p.panes[id] == null);
+ p.next_serial += 1;
+ pane.serial = p.next_serial;
+ p.panes[id] = pane;
+ }
+
/// Allocate a pane slot with a live terminal emulator and queue the spawn
/// effect (optionally in a directory); the shell answers by forking a pty
/// and wiring reads back as Event.output for this pane id.
- pub fn newPane(p: *Pardes, id: usize) !*Pane {
- return p.newShell(id, "");
- }
-
pub fn newShell(p: *Pardes, id: usize, cwd: []const u8) !*Pane {
std.debug.assert(p.panes[id] == null);
if (cwd.len > 256) return error.PathTooLong; // spawn effect cwd is a Buf(256)
- const pane = try p.gpa.create(Pane);
- errdefer p.gpa.destroy(pane);
- pane.* = .{
- .vt = try ghostty_vt.Terminal.init(std.Io.Threaded.global_single_threaded.io(), p.gpa, .{
- .cols = p.screen_w,
- .rows = p.screen_h,
- .max_scrollback = 16 * 1024 * 1024,
- }),
- .stream = undefined,
- .gpa = p.gpa,
- .cols = p.screen_w,
- .rows = p.screen_h,
- };
- pane.stream = pane.vt.vtStream();
- // answer terminal queries (DSR/DA/kitty) back into the pty, else
- // crossterm apps (nushell, helix, fzf) block on the reply forever.
- pane.stream.handler.effects.write_pty = term_pane.ptyReport;
- pane.stream.handler.effects.device_attributes = term_pane.ptyDeviceAttrs;
- p.next_serial += 1;
- pane.serial = p.next_serial;
- p.panes[id] = pane;
+ const pane = try term_pane.create(p.gpa, p.screen_w, p.screen_h);
+ term_pane.armShellSpawn(pane);
+ p.installPane(id, pane);
p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(cwd) } });
return pane;
}
@@ -5373,16 +4548,14 @@ pub const Pardes = struct {
const pane = try p.gpa.create(Pane);
errdefer p.gpa.destroy(pane);
pane.* = .{
- .vt = try ghostty_vt.Terminal.init(std.Io.Threaded.global_single_threaded.io(), p.gpa, .{ .cols = 1, .rows = 1 }),
+ .vt = try ghostty_vt.Terminal.init(term_pane.terminalIo(), p.gpa, .{ .cols = 1, .rows = 1 }),
.stream = undefined,
.gpa = p.gpa,
.cols = p.screen_w,
.rows = p.screen_h,
};
pane.stream = pane.vt.vtStream();
- p.next_serial += 1;
- pane.serial = p.next_serial;
- p.panes[id] = pane;
+ p.installPane(id, pane);
return pane;
}
@@ -5421,6 +4594,18 @@ pub const Pardes = struct {
}
p.look_src[w] = pane.serial;
p.n_look_src = w + 1;
+ p.look_walk_owner = pane.serial;
+ }
+
+ /// Arm n/N on a concrete live pane without pretending that pane has
+ /// already performed a Look. Result producers use this when a fresh or
+ /// refilled list supersedes older walk history. An empty answer cannot
+ /// supersede anything: it has no position to resume, so preserve the pane
+ /// whose prior Look established the walk instead of stealing provenance.
+ pub fn armLookWalk(p: *Pardes, id: usize) void {
+ const pane = p.panes[id] orelse return;
+ if (pane.file) |file| if (file.content.len == 0) return;
+ p.look_walk_owner = pane.serial;
}
/// chunk arbitrary-length bytes into fixed-size write effects (order kept)
@@ -5438,6 +4623,8 @@ pub const Pardes = struct {
pub fn setCwd(p: *Pardes, id: usize, cwd: []const u8) void {
const pane = p.panes[id] orelse return;
const n = @min(cwd.len, pane.cwd.len);
+ if (pane.cwd_len == n and std.mem.eql(u8, pane.cwd[0..n], cwd[0..n])) return;
+ if (p.lookHoverPane() == id) p.cancelLookHover();
@memcpy(pane.cwd[0..n], cwd[0..n]);
pane.cwd_len = @intCast(n);
}
@@ -5492,7 +4679,6 @@ pub const Pardes = struct {
return false;
};
pane.file = .{ .path = path_copy, .content = content };
- pane.kind = .file;
pane.cur_pinned = true;
// sf was captured from SOURCE, not active. Nothing above this point
@@ -5509,7 +4695,80 @@ pub const Pardes = struct {
/// looked up, or an absolute path to be used as it stands. The resolving
/// is the shells' half: the core has no filesystem to ask.
pub fn shellBin(p: *const Pardes) []const u8 {
- return if (p.shell_len == 0) config.default_shell else p.shell_buf[0..p.shell_len];
+ const selected = p.settings.shell.requested.get();
+ return if (selected.len == 0) config.default_shell else selected;
+ }
+
+ /// The native host resolved the request (or chose a fallback) for a real
+ /// spawn. Record the executable that actually ran; web never calls this
+ /// because it has no process backend.
+ pub fn acknowledgeShell(p: *Pardes, id: usize, executable: []const u8, prompt_marks: bool) void {
+ if (id < MAX_PANES) term_pane.shellSpawned(p, id, prompt_marks);
+ if (p.settings.shell.effective.set(executable))
+ p.settings.shell.pending = false;
+ }
+
+ /// Small backend reads over the same plain state the builtins mutate.
+ /// Take the newest unresolved Font request once. `pending` remains true
+ /// until the host explicitly acknowledges success or rejection; requested
+ /// name/path remain queryable either way.
+ pub fn takeFontRequest(p: *Pardes) ?[]const u8 {
+ if (comptime !font_picker) return null;
+ if (!p.settings.font.pending or p.font_request_taken) return null;
+ const path = p.settings.font.requested_path.get();
+ if (path.len == 0) return null;
+ p.font_request_taken = true;
+ return path;
+ }
+
+ /// Record the face a GUI host is currently wearing. Boot, zoom and backing
+ /// scale changes are observations, not answers to a pending Font request:
+ /// only acknowledgeFont may resolve one after takeFontRequest handed it to
+ /// the host.
+ pub fn observeFont(
+ p: *Pardes,
+ effective_name: []const u8,
+ effective_size_hundredths: u16,
+ unit: FontSizeUnit,
+ ) bool {
+ if (comptime !font_picker) return false;
+ if (!p.settings.font.effective_name.set(effective_name)) return false;
+ p.settings.font.effective_size_hundredths = effective_size_hundredths;
+ p.settings.font.effective_size_unit = unit;
+ return true;
+ }
+
+ /// Resolve the request already handed to the GUI host. Requested and
+ /// effective tuples deliberately remain separate so Config can show both.
+ pub fn acknowledgeFont(
+ p: *Pardes,
+ effective_name: []const u8,
+ effective_size_hundredths: u16,
+ unit: FontSizeUnit,
+ ) bool {
+ if (comptime !font_picker) return false;
+ if (!p.settings.font.pending or !p.font_request_taken) return false;
+ const changed = !std.mem.eql(
+ u8,
+ p.settings.font.effective_name.get(),
+ effective_name,
+ ) or p.settings.font.effective_size_hundredths != effective_size_hundredths or
+ p.settings.font.effective_size_unit != unit;
+ if (!p.observeFont(effective_name, effective_size_hundredths, unit)) return false;
+ p.settings.font.pending = false;
+ p.font_request_taken = false;
+ // The host has already replaced its face/atlas at this boundary. Even
+ // when the new face measures to the same grid, a frozen old panel
+ // layer cannot truthfully be rasterized through those new glyphs.
+ if (changed) p.abandonPanelAnimations();
+ return true;
+ }
+
+ pub fn rejectFont(p: *Pardes) void {
+ if (comptime !font_picker) return;
+ if (!p.settings.font.pending or !p.font_request_taken) return;
+ p.settings.font.pending = false;
+ p.font_request_taken = false;
}
/// Post the transient message on `id`'s last row. Called by a SHELL once
@@ -5534,6 +4793,48 @@ pub const Pardes = struct {
p.setMessage(id, text);
}
+ /// Apply one watched-path snapshot to the payload which owns that path.
+ /// True means the pane now represents this successful host transaction;
+ /// native watchers commit their generation only then, so a transient PDF
+ /// reopen/allocation failure remains retryable.
+ fn applyWatchedFileChanged(p: *Pardes, id: u8, bytes: []const u8) bool {
+ if (id >= MAX_PANES) return false;
+ const pane = p.panes[id] orelse return false;
+ if (comptime pdf_enabled) if (pane.pdf != null) {
+ pdf_pane.reloadWatched(p, pane) catch |err| {
+ p.reportError(id, "PDF reload", err);
+ return false;
+ };
+ return true;
+ };
+ if (pane.file == null) return false;
+ file_pane.changed(p, id, bytes);
+ return if (p.panes[id]) |current|
+ if (current.file) |file| std.mem.eql(u8, file.content, bytes) else false
+ else
+ false;
+ }
+
+ /// Synchronous host seam for a watched file. Event.update routes through
+ /// the same payload operation, while native watchers use this spelling to
+ /// learn whether they may commit the observed disk generation.
+ pub fn reloadWatchedFile(p: *Pardes, id: u8, bytes: []const u8) bool {
+ p.invalidateLookHover(id);
+ const applied = p.applyWatchedFileChanged(id, bytes);
+ _ = p.scratch.reset(.retain_capacity);
+ p.sync();
+ return applied;
+ }
+
+ /// Content replacement invalidates a preview whose operand was expanded
+ /// from that pane. Payload modules call this for derived buffers they
+ /// refresh as part of the same transaction.
+ pub fn invalidateLookHover(p: *Pardes, id: usize) void {
+ if (p.lookHoverPane() != id) return;
+ p.raw_hover_intent = false;
+ p.cancelLookHover();
+ }
+
pub fn emit(p: *Pardes, e: Effect) void {
if (p.effects_len == p.effects.len) return;
const tail = (p.effects_head + p.effects_len) % p.effects.len;
@@ -5557,6 +4858,33 @@ pub const Pardes = struct {
// anything in this one can ask for it. Nothing frees it mid-update:
// handlers hand `rows` around for the length of a single update.
p.shell_rows.sweep(p.gpa);
+ // A preview describes the frame under an idle pointer. Any state
+ // change can replace that text or geometry, so it cancels; buttonless
+ // motion is the one event that debounces/re-arms it, and ticks only
+ // age the current candidate.
+ switch (ev) {
+ .tick => {},
+ .mouse => |m| if (!(m.button == .none and m.kind == .motion)) {
+ p.raw_hover_intent = false;
+ p.cancelLookHover();
+ },
+ .output => |o| if (p.lookHoverPane() == o.pane) {
+ p.raw_hover_intent = false;
+ p.cancelLookHover();
+ },
+ .file_changed => |changed| if (p.lookHoverPane() == changed.pane) {
+ p.raw_hover_intent = false;
+ p.cancelLookHover();
+ },
+ .pdf_scroll => |scroll| if (p.lookHoverPane() == scroll.pane) {
+ p.raw_hover_intent = false;
+ p.cancelLookHover();
+ },
+ else => {
+ p.raw_hover_intent = false;
+ p.cancelLookHover();
+ },
+ }
// A transient message is exactly as old as your last input: touch the
// keyboard or the mouse and it is gone, on every pane, because a
// message is a report and you have just proved you are back. Only
@@ -5587,11 +4915,12 @@ pub const Pardes = struct {
}
switch (ev) {
.resize => |sz| {
+ p.snap_panel_layout_once = true;
if (comptime pdf_enabled) {
- var before: [MAX_PANES]?PdfViewport = @splat(null);
+ var before: [MAX_PANES]?pdf_pane.Viewport = @splat(null);
for (p.panes, 0..) |slot, id| {
const pane = slot orelse continue;
- if (pane.pdf != null) before[id] = p.pdfViewport(pane);
+ if (pane.pdf != null) before[id] = pdf_pane.paneViewport(p, pane);
}
p.screen_w = sz.cols;
p.screen_h = sz.rows;
@@ -5604,8 +4933,8 @@ pub const Pardes = struct {
for (p.panes, 0..) |slot, id| {
const pane = slot orelse continue;
if (pane.pdf) |*pv| {
- if (!std.meta.eql(before[id], p.pdfViewport(pane))) {
- capturePdfLayoutAnchor(pv);
+ if (!std.meta.eql(before[id], pdf_pane.paneViewport(p, pane))) {
+ pdf_pane.captureLayoutAnchor(pv);
pv.layout_valid = false;
pv.search_reveal_pending = pv.search_query.len > 0;
}
@@ -5619,29 +4948,29 @@ pub const Pardes = struct {
},
.output => |o| {
const pane = p.panes[o.pane] orelse return;
- pane.appendTtyReplay(o.bytes);
- pane.stream.nextSlice(o.bytes);
- p.shell_rows.markStale(pane);
- // follow new output to the bottom only in tty mode; in
- // normal/insert the user is reading scrollback and the view
- // holds still while output streams in. Tag edits hijack
- // pane.mode to insert — the body mode sits in tag_mode.
- const bmode = if (pane.tag_edit) pane.tag_mode else pane.mode;
- if (bmode == .tty)
- pane.vt.screens.active.scroll(.active);
+ term_pane.feedOutput(p, pane, o.bytes);
},
.eof => |e| p.removePane(e.pane),
.lsp_resp => |r| p.lspResponse(r.id, r.rows),
.pipe_resp => |r| p.pipeResponse(r.id, r.success, r.outputs),
- .file_changed => |fc| p.fileChanged(fc.pane, fc.bytes),
+ .file_changed => |fc| _ = p.applyWatchedFileChanged(fc.pane, fc.bytes),
.key => |key| p.handleKey(key),
- .mouse => |m| p.handleMouse(m),
+ .mouse => |m| {
+ // Layout is only committed on RELEASE. Mark that one frame as
+ // a direct-manipulation snap before dragRelease mutates the
+ // weights; text/tag/PDF drags never touch panel presentation.
+ if (m.kind == .release) switch (p.drag) {
+ .border_v, .border_h, .move => p.snap_panel_layout_once = true,
+ else => {},
+ };
+ p.handleMouse(m);
+ },
.pdf_scroll => |scroll| {
if (comptime pdf_enabled) {
if (scroll.pane < MAX_PANES) {
if (p.panes[scroll.pane]) |pane| {
if (hasPdf(pane) and p.native_images)
- _ = p.scrollPdfDocument(pane, @floatCast(scroll.delta_pixels));
+ _ = pdf_pane.scrollPane(p, pane, @floatCast(scroll.delta_pixels));
}
}
}
@@ -5650,7 +4979,12 @@ pub const Pardes = struct {
.command => |line| _ = p.executeBuiltinLine(p.active, line),
.pinch => |scale| p.ov_pinch_scale = scale,
.touch_scroll => |delta| p.ov_touch_scroll_delta = delta,
- .tick => p.chrome_animation.advance(),
+ .pointer_leave => p.pointer_inside = false,
+ .tick => {
+ p.chrome_animation.advance();
+ p.advancePanelAnimations();
+ p.advanceLookHover();
+ },
}
_ = p.scratch.reset(.retain_capacity);
p.sync();
@@ -5774,9 +5108,9 @@ pub const Pardes = struct {
// ---- tag + selection text (chord sources) ----
- /// the live tag prefix: the pane's cwd/path, and nothing else (an image
- /// still names its kind — the renderer toggles it used to spell out are
- /// builtins now, under SPC t). The mode used to lead this as a word; it is
+ /// the live tag prefix: the pane's cwd/path, plus pane-local state whose
+ /// owner reports it (PDF view and image renderer choices). Those choices
+ /// mutate only through builtins under SPC t. The mode used to lead this as a word; it is
/// the one character in the layout box now (renderPane), so every tagline
/// starts four columns further left and spends them on the path instead.
///
@@ -5791,7 +5125,7 @@ pub const Pardes = struct {
"pdf {d}/{d} {s} PdfFit {s} PdfTint PdfSections {s}",
.{ pv.page + 1, pv.page_count, @tagName(pv.fit), @tagName(pv.tint), pv.path },
);
- if (pane.image) |iv| return std.fmt.allocPrint(arena, config.tag_image ++ " {s}", .{iv.path});
+ if (pane.image) |*state| return image_pane.tagPrefix(arena, state);
if (pane.file) |f| return arena.dupe(u8, f.path);
return arena.dupe(u8, pane.cwdSlice());
}
@@ -6234,52 +5568,6 @@ pub const Pardes = struct {
if (col) |c| p.topbar_col = @intCast(@min(c, modal.prevGrapheme(bar, bar.len)));
}
- fn fileLine(pane: *const Pane, row: i32) []const u8 {
- const f = pane.file orelse return "";
- if (row < 0) return "";
- return modal.lineSlice(f.content, @intCast(row));
- }
-
- fn fileDisplayOffset(pane: *const Pane, row: i32, from_raw: i32, to_raw: i32) i32 {
- const line = fileLine(pane, row);
- const from: usize = @intCast(@max(0, from_raw));
- const to: usize = @intCast(@max(0, to_raw));
- const from_display = fileRawDisplayCol(line, from);
- const to_display = fileRawDisplayCol(line, to);
- if (to_display >= from_display) return @intCast(to_display - from_display);
- return -@as(i32, @intCast(from_display - to_display));
- }
-
- fn fileDisplayEndOffset(pane: *const Pane, row: i32, from_raw: i32, at_raw: i32) i32 {
- const start = fileDisplayOffset(pane, row, from_raw, at_raw);
- const line = fileLine(pane, row);
- const at: usize = @intCast(@max(0, at_raw));
- if (at >= line.len) return start;
- return start + @as(i32, @intCast(file_pane.byteDisplayWidth(line[at]))) - 1;
- }
-
- fn fileByteAtDisplay(pane: *const Pane, row: i32, from_raw: i32, display_col: i32) i32 {
- const line = fileLine(pane, row);
- const from: usize = @intCast(@max(0, from_raw));
- const display: usize = @intCast(@max(0, display_col));
- if (from >= line.len) return @intCast(from +| display);
- return @intCast(from +| fileRawAtDisplay(line[from..], display));
- }
-
- fn renderedLineByteCol(pane: *const Pane, row: i32, line: []const u8, display_col: usize) usize {
- if (pane.file == null or row < BOX_H) return display_col;
- const prefix = @min(@as(usize, config.PREFIX_W), line.len);
- if (display_col <= prefix) return display_col;
- return prefix +| fileRawAtDisplay(line[prefix..], display_col - prefix);
- }
-
- fn renderedLineDisplayCol(pane: *const Pane, row: i32, line: []const u8, byte_col: usize) usize {
- if (pane.file == null or row < BOX_H) return byte_col;
- const prefix = @min(@as(usize, config.PREFIX_W), line.len);
- if (byte_col <= prefix) return byte_col;
- return prefix +| fileRawDisplayCol(line[prefix..], byte_col - prefix);
- }
-
/// tag + '\n' + body: the full selectable pane text (row 0 = the tag)
fn paneText(p: *Pardes, pane: *Pane) ![]u8 {
const arena = p.scratch.allocator();
@@ -6301,8 +5589,8 @@ pub const Pardes = struct {
var count_row: i32 = 0;
while (count_it.next()) |line| : (count_row += 1) {
if (count_row < r0 or count_row > r1) continue;
- const b0 = @min(renderedLineByteCol(pane, count_row, line, c0), line.len);
- const b1 = @min(renderedLineByteCol(pane, count_row, line, c1) + 1, line.len);
+ const b0 = @min(file_pane.renderedLineByteCol(pane, count_row, line, c0), line.len);
+ const b1 = @min(file_pane.renderedLineByteCol(pane, count_row, line, c1) + 1, line.len);
total += b1 - b0 + @intFromBool(selected > 0);
selected += 1;
}
@@ -6318,8 +5606,8 @@ pub const Pardes = struct {
at += 1;
}
first = false;
- const b0 = @min(renderedLineByteCol(pane, v, line, c0), line.len);
- const b1 = @min(renderedLineByteCol(pane, v, line, c1) + 1, line.len);
+ const b0 = @min(file_pane.renderedLineByteCol(pane, v, line, c0), line.len);
+ const b1 = @min(file_pane.renderedLineByteCol(pane, v, line, c1) + 1, line.len);
@memcpy(out[at..][0 .. b1 - b0], line[b0..b1]);
at += b1 - b0;
}
@@ -6337,25 +5625,39 @@ pub const Pardes = struct {
return (r > sr or (r == sr and c >= sc)) and (r < er or (r == er and c <= ec));
}
+ const ExpandedWord = struct { lo: usize, hi: usize };
+
+ /// The single spelling rule used by pointer and keyboard expansion.
+ /// Spaces inside an explicit @`command run` belong to that run; every
+ /// other separator is no operand. In particular, do not let wordBounds'
+ /// left scan make a blank cell borrow its neighbour.
+ fn expandedWord(line: []const u8, col: usize) ?ExpandedWord {
+ if (col >= line.len) return null;
+ const b = config.wordBounds(line, col);
+ const command_run = b.hi > b.lo and config.commandWord(line[b.lo..b.hi]) != null;
+ if (!config.isFileChar(line[col]) and !command_run) return null;
+ if (b.hi <= b.lo) return null;
+ return .{ .lo = b.lo, .hi = b.hi };
+ }
+
/// acme: a no-drag middle/right click expands to the word under it —
/// file-ish, or a whole `` @`...` `` run (config.wordBounds is the spelling)
- fn expandSel(p: *Pardes, pane: *Pane, sl: *Sel) void {
- if (sl.c0 != sl.c1 or sl.r0 != sl.r1) return;
- const text = p.paneText(pane) catch return;
+ fn expandedSel(p: *Pardes, pane: *Pane, at: Sel) ?Sel {
+ var sl = at;
+ if (sl.c0 != sl.c1 or sl.r0 != sl.r1) return sl;
+ const text = p.paneText(pane) catch return null;
var it = std.mem.splitAny(u8, text, "\n");
var v: i32 = 0;
while (it.next()) |line| : (v += 1) {
if (v != sl.r0) continue;
const display_col: usize = @intCast(@max(0, sl.c0));
- const col = renderedLineByteCol(pane, v, line, display_col);
- if (col > line.len) return;
- const b = config.wordBounds(line, col);
- if (b.hi > b.lo) {
- sl.c0 = @intCast(renderedLineDisplayCol(pane, v, line, b.lo));
- sl.c1 = @intCast(renderedLineDisplayCol(pane, v, line, b.hi) - 1);
- }
- return;
+ const col = file_pane.renderedLineByteCol(pane, v, line, display_col);
+ const b = expandedWord(line, col) orelse return null;
+ sl.c0 = @intCast(file_pane.renderedLineDisplayCol(pane, v, line, b.lo));
+ sl.c1 = @intCast(file_pane.renderedLineDisplayCol(pane, v, line, b.hi) - 1);
+ return sl;
}
+ return null;
}
/// the word under the modal cursor as a pane-local selection (paneText
@@ -6363,7 +5665,7 @@ pub const Pardes = struct {
fn cursorWordSel(pane: *Pane) Sel {
const w = pane.wrapRow(pane.cur_row, pane.cur_col);
const vrow = w.row + @as(i32, BOX_H);
- const vcol = if (pane.file != null) fileDisplayOffset(pane, pane.cur_row, w.at, pane.cur_col) + @as(i32, config.PREFIX_W) else pane.cur_col;
+ const vcol = if (pane.file != null) file_pane.displayOffset(pane, pane.cur_row, w.at, pane.cur_col) + @as(i32, config.PREFIX_W) else pane.cur_col;
return .{ .state = .done, .c0 = vcol, .c1 = vcol, .r0 = vrow, .r1 = vrow };
}
@@ -6376,6 +5678,90 @@ pub const Pardes = struct {
return p.yankRows(pane, @min(pane.msel.r0, pane.msel.r1), @max(pane.msel.r0, pane.msel.r1));
}
+ const PointerOperand = struct {
+ /// Absolute body position corresponding to the pointed screen cell.
+ row: i32,
+ col: i32,
+ /// Exactly what a no-drag middle/right click will dispatch.
+ text: ?[]const u8 = null,
+ /// What hover paints. Null names the pane's live modal selection.
+ preview: ?Sel = null,
+ /// File words stay logical so a soft-wrapped operand is not cut into
+ /// unrelated rendered fragments. Null for tags and non-file panes.
+ file_word: ?FileWordSpan = null,
+ /// A newly expanded word is installed in the gesture's button slot;
+ /// an existing selection is only borrowed and must not replace it.
+ expanded: ?Sel = null,
+ };
+
+ /// Resolve a no-drag pointer gesture without mutating Pane. Click release
+ /// and delayed Look hover call this same production primitive, so existing
+ /// selection precedence and word expansion cannot drift into two policies.
+ fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Sel) PointerOperand {
+ const visible = clicked.r0 - @as(i32, BOX_H);
+ const wrapped = pane.wrapAt(visible);
+ const row = wrapped.line;
+ const col = if (pane.file != null)
+ file_pane.byteAtRowDisplay(pane, wrapped.line, wrapped.at, clicked.c0 - @as(i32, config.PREFIX_W))
+ else
+ clicked.c0;
+ var result: PointerOperand = .{ .row = row, .col = col };
+
+ const kept = pane.sel[sel_slot];
+ if (kept.state == .done and
+ spanHas(clicked.r0, clicked.c0, kept.r0, kept.c0, kept.r1, kept.c1))
+ {
+ result.text = p.selectionText(pane, kept) catch null;
+ result.preview = kept;
+ return result;
+ }
+ if (pane.vsel.active and pane.vsel.explicit and
+ spanHas(row, col, pane.vsel.row, pane.vsel.col, pane.cur_row, pane.cur_col))
+ {
+ result.text = p.currentSelText(pane);
+ return result;
+ }
+ if (pane.msel.active and row >= @min(pane.msel.r0, pane.msel.r1) and
+ row <= @max(pane.msel.r0, pane.msel.r1))
+ {
+ result.text = p.currentSelText(pane);
+ return result;
+ }
+ if (pane.file != null and clicked.r0 >= BOX_H and clicked.r0 == clicked.r1 and clicked.c0 == clicked.c1) {
+ const line = file_pane.sourceLine(pane, row);
+ const source_col: usize = @intCast(@max(0, col));
+ const b = expandedWord(line, source_col) orelse return result;
+ const lo = std.math.cast(i32, b.lo) orelse return result;
+ const hi = std.math.cast(i32, b.hi) orelse return result;
+ result.text = line[b.lo..b.hi];
+ result.file_word = .{ .row = row, .lo = lo, .hi = hi };
+
+ // The transient middle/right slot still carries screen endpoints,
+ // but no text is reconstructed from this rectangular legacy
+ // shape. The logical span above is authoritative for dispatch and
+ // hover painting.
+ var expanded = clicked;
+ const first = pane.wrapRow(row, lo);
+ const last = pane.wrapRow(row, hi - 1);
+ if (first.row >= 0 and last.row >= 0) {
+ expanded.r0 = first.row + @as(i32, BOX_H);
+ expanded.c0 = @as(i32, config.PREFIX_W) + file_pane.displayOffset(pane, row, first.at, lo);
+ expanded.r1 = last.row + @as(i32, BOX_H);
+ expanded.c1 = @as(i32, config.PREFIX_W) + file_pane.displayEndOffset(pane, row, last.at, hi - 1);
+ }
+ result.preview = expanded;
+ result.expanded = expanded;
+ return result;
+ }
+
+ const expanded = p.expandedSel(pane, clicked) orelse return result;
+ result.text = p.selectionText(pane, expanded) catch null;
+ if (result.text == null or result.text.?.len == 0) return result;
+ result.preview = expanded;
+ result.expanded = expanded;
+ return result;
+ }
+
/// What an execute takes as its ARGUMENT: text selected ANYWHERE (acme —
/// the chord argument is whatever is selected, in any window), searched
/// `first` (the pane the execute happened in), then the active pane (making
@@ -6474,9 +5860,7 @@ pub const Pardes = struct {
if (hit(key, config.pane_to_new_column)) {
const f = p.layoutFindTerm(p.active).?;
if (p.ncol < MAX_COLS and p.col_n[f.col] > 1) {
- p.absorbVWeight(p.active);
- p.layoutRemove(p.active);
- p.layoutAppendColumn(p.active);
+ _ = p.layoutSplitColumn(p.active, p.active, false);
}
return;
}
@@ -6497,7 +5881,7 @@ pub const Pardes = struct {
if (tty_alt) return p.runBuiltin(.Last, p.active, "", null);
pane.mode = .normal;
pane.pending = 0;
- } else p.enterTty(p.active);
+ } else term_pane.enterTty(p, p.active);
return;
}
// `|` owns the same visible one-line tag input as search, but Enter
@@ -6556,8 +5940,7 @@ pub const Pardes = struct {
return;
}
}
- var sel = cursorWordSel(pane);
- p.expandSel(pane, &sel);
+ const sel = p.expandedSel(pane, cursorWordSel(pane)) orelse return;
const word = p.selectionText(pane, sel) catch return;
p.runBuiltin(cmd, p.active, "", word);
return;
@@ -6618,7 +6001,7 @@ pub const Pardes = struct {
}
p.handleInsert(pane, key);
},
- .tty => p.forwardKey(p.active, key),
+ .tty => term_pane.forwardKey(p, p.active, key),
}
}
@@ -6655,65 +6038,6 @@ pub const Pardes = struct {
}
}
- /// Enter raw tty. If the shell is at a prompt and the modal cursor sits on
- /// the input line, move the shell's REAL cursor there via ghostty's
- /// promptClickMove (arrow keys the shell understands) — dropping into tty
- /// lands where you navigated. Requires OSC 133 cl=line (our bash rc).
- fn enterTty(p: *Pardes, id: usize) void {
- const pane = p.panes[id] orelse return;
- const screen = pane.vt.screens.active;
- if (pane.cur_pinned and pane.vt.cursorIsAtPrompt()) {
- // the pin is a GRID position: convert the modal cursor's surface
- // row back through the edit buffer
- const goff: i32 = @intCast(screen.pages.scrollbar().offset);
- const vp_row: i32 = pane.gridRow(pane.cur_row) - goff;
- if (vp_row >= 0) {
- // ...and its COLUMN back through the hidden prompt. Outside
- // tty the prompt row shows the typed command left-hugged at
- // column 0 (term_pane.promptRow), so a cursor three cells into
- // what you can see is three cells past the prompt's END on the
- // real grid — and it is the real grid this pin addresses.
- var grid_col: i32 = @max(0, pane.cur_col);
- if (screen.pages.pin(.{ .viewport = .{ .x = 0, .y = @intCast(vp_row) } })) |row_pin| {
- if (row_pin.rowAndCell().row.semantic_prompt != .none) switch (term_pane.promptCut(row_pin)) {
- .cut => |cols| grid_col += @intCast(cols),
- .keep, .blank => {},
- };
- }
- if (screen.pages.pin(.{ .viewport = .{ .x = @intCast(grid_col), .y = @intCast(vp_row) } })) |click_pin| {
- const cursor_pin = screen.cursor.page_pin.*;
- var pit = cursor_pin.promptIterator(.left_up, null);
- if (pit.next()) |prompt_pin| {
- if (!click_pin.before(prompt_pin)) {
- const m = screen.promptClickMove(click_pin);
- var i: usize = 0;
- while (i < m.left) : (i += 1) p.emitWrite(id, "\x1b[D");
- i = 0;
- while (i < m.right) : (i += 1) p.emitWrite(id, "\x1b[C");
- }
- }
- }
- }
- }
- pane.mode = .tty;
- pane.msel.active = false;
- pane.vsel.active = false;
- pane.nsel = 0; // raw tty: the program owns the pane, and its cursor is the shell's
- // ...so hand the CURSOR over too. A pin is a row of the surface, and a
- // shell's rows scroll out from under one: pinned at the prompt, fifty
- // lines of output later the pin names scrollback nobody is looking at.
- // Unpinned it tracks the shell again, which is the only thing that
- // stays right — and it is what trackJump already calls "a place, not a
- // spot", so Last stops dragging the view back to that stale row on the
- // way in (it sat there until the next keystroke's echo scrolled it
- // down). Leaving tty re-pins wherever the shell left the cursor.
- pane.cur_pinned = false;
- pane.select = false;
- pane.append_at = null;
- pane.sticky_col = -1;
- pane.pending = 0;
- }
-
/// move focus to the nearest pane in `dir` of `from` (overlap-aware
/// nearest edge). `from` is the pane the builtin ran on, which is the
/// active one for a key but the CLICKED one for a name executed in a tag.
@@ -6867,16 +6191,6 @@ pub const Pardes = struct {
row0: i32, // absolute row of lines[0]
};
- fn ensurePdfText(p: *Pardes, pane: *Pane) ![]const u8 {
- if (comptime !pdf_enabled) return error.PdfDisabled;
- const pv = &(pane.pdf orelse return error.NotPdf);
- if (!pv.text_tried) {
- pv.text_tried = true;
- pv.text = pv.document.pageText(p.pdf_gpa, pv.page) catch &.{};
- }
- return pv.text;
- }
-
/// The lines the cursor moves over, absolute rows. File: all content lines.
/// Terminal: the whole history+active grid with the edit buffer's lines
/// standing in for the rows it covers, so motions ride the scrollback and
@@ -6885,60 +6199,15 @@ pub const Pardes = struct {
/// this surface as a tty case's final text.)
pub fn paneCursorLines(p: *Pardes, pane: *Pane) !PaneLines {
const arena = p.scratch.allocator();
- if (pane.file) |*f| {
- // Straight off the line index: exact size, no repeated growth or
- // second scan for newlines the index already found. A file's line
- // count is its newline count plus one.
- const idx = try file_pane.lineIndex(pane.gpa, f);
- const ls = try arena.alloc([]const u8, idx.len);
- for (idx, 0..) |s, i| {
- const e = if (i + 1 < idx.len) idx[i + 1] - 1 else f.content.len;
- ls[i] = f.content[s..e];
- }
- return .{ .lines = ls, .row0 = 0 };
- }
+ if (pane.file) |*f| return .{ .lines = try file_pane.cursorLines(arena, pane, f), .row0 = 0 };
if (hasPdf(pane)) {
- const text = try p.ensurePdfText(pane);
- const count = std.mem.count(u8, text, "\n") + 1;
- const ls = try arena.alloc([]const u8, count);
- var it = std.mem.splitScalar(u8, text, '\n');
- var n: usize = 0;
- while (it.next()) |line| : (n += 1) ls[n] = line;
- return .{ .lines = ls, .row0 = 0 };
- }
- const rows = try term_pane.shellRows(p, pane);
- // No edit buffer: the loop below would copy `rows` into a second array
- // one slice at a time and return exactly what it was given. On a
- // scrollback of any size that copy is the keystroke.
- if (pane.ovl == null) return .{ .lines = rows, .row0 = 0 };
- // the buffer may sit below the dump's last row (the grid's trailing
- // blanks are trimmed off), so run the loop to whichever ends later
- var last = rows.len;
- if (pane.ovl) |o| last = @max(last, @as(usize, @intCast(@max(0, o.row + o.rows))));
- var count = last;
- if (pane.ovl) |o| {
- // The split below writes ONE line for an empty buffer, but
- // modal.lineCount("") is 0 — the same floor `n - 1` needs at the
- // paste site. Without it, killing a whole line (`A<C-u>`, `d%`)
- // sizes `ls` one short of what the loop writes, and on a buffer
- // covering the last row that is a length of zero.
- if (o.row >= 0 and @as(usize, @intCast(o.row)) < last)
- count = count - @min(@as(usize, @intCast(o.rows)), last - @as(usize, @intCast(o.row))) + @max(1, modal.lineCount(o.text));
- }
- const ls = try arena.alloc([]const u8, count);
- var n: usize = 0;
- var g: usize = 0;
- while (g < last) : (g += 1) {
- if (pane.ovl) |o| if (o.row >= 0 and g == @as(usize, @intCast(o.row))) {
- var bit = std.mem.splitScalar(u8, o.text, '\n');
- while (bit.next()) |ln| : (n += 1) ls[n] = ln;
- g += @intCast(o.rows - 1);
- continue;
+ if (comptime pdf_enabled) return .{
+ .lines = try pdf_pane.textLines(&pane.pdf.?, p.pdf_gpa, arena),
+ .row0 = 0,
};
- ls[n] = if (g < rows.len) rows[g] else "";
- n += 1;
+ unreachable;
}
- return .{ .lines = ls[0..n], .row0 = 0 };
+ return .{ .lines = try term_pane.cursorLines(p, pane), .row0 = 0 };
}
fn toModalCursor(pane: *Pane, pl: PaneLines) modal.Cursor {
@@ -6964,84 +6233,31 @@ pub const Pardes = struct {
/// cursor-lines dump (scratch-arena backed, same lifetime as pl)
fn flatSurface(p: *Pardes, pane: *Pane, pl: PaneLines) ![]const u8 {
if (pane.file) |f| return f.content;
- if (hasPdf(pane)) return p.ensurePdfText(pane);
- // The memo already holds these rows joined by '\n' — that is what its
- // `text` IS. The identity check is the same argument hxOff makes below:
- // `lines` came out of the cache untouched (no edit buffer stood in for
- // any row), so the join cannot have drifted from it.
- const c = &p.shell_rows;
- if (!c.stale and c.pane == pane and
- pl.lines.ptr == c.rows.ptr and pl.lines.len == c.rows.len) return c.text;
- const arena = p.scratch.allocator();
- var total: usize = if (pl.lines.len > 0) pl.lines.len - 1 else 0;
- for (pl.lines) |ln| total += ln.len;
- const buf = try arena.alloc(u8, total);
- var at: usize = 0;
- for (pl.lines, 0..) |ln, i| {
- if (i > 0) {
- buf[at] = '\n';
- at += 1;
- }
- @memcpy(buf[at..][0..ln.len], ln);
- at += ln.len;
+ if (hasPdf(pane)) {
+ if (comptime pdf_enabled) return pane.pdf.?.ensureText(p.pdf_gpa);
+ unreachable;
}
- return buf;
- }
-
- /// modal.hxOff and modal.hxPos, answered from the file's LINE INDEX when
- /// `text` is that file's content — which is exactly what flatSurface hands
- /// back for a file pane, and so what every motion in a document goes
- /// through. The modal pair has to scan the buffer for the same two numbers
- /// (a newline count and a walk to the line start), and on a multi-MB file
- /// those scans were the bulk of a keystroke.
- ///
- /// The identity check is the whole safety argument: anything that is NOT
- /// this file's content — a terminal's joined motion surface, an edit
- /// buffer, a search haystack — falls through to the scan, where it is O(a
- /// screenful) anyway. The two branches must agree exactly or the 481
- /// differential and 561 parity cases say so; the arithmetic below is
- /// hxOff/hxPos transcribed, with line i's terminator read off idx[i+1]-1
- /// instead of searched for.
- ///
- /// ponytail: a terminal's motion surface is NOT one of the things with no
- /// index any more — shell_rows.rows are slices into shell_rows.text, so
- /// their offsets ARE the line starts, for free. Claiming them here needs
- /// `p`, which means threading it through paneOff/panePos/paneLineStart and
- /// their callers (paneRange, setPaneRange, the three motion helpers). Left
- /// undone deliberately: measured, that is a terminal key-down of 615us
- /// against 140us, and 0.6ms is not a keystroke anybody feels. Do it the
- /// day one of those scans shows up next to something that matters.
- fn indexOf(pane: *Pane, text: []const u8) ?[]const usize {
- const f = if (pane.file) |*ff| ff else return null;
- if (text.ptr != f.content.ptr or text.len != f.content.len) return null;
- return file_pane.lineIndex(pane.gpa, f) catch null;
+ return term_pane.flatSurface(p, pane, pl.lines);
}
fn paneOff(pane: *Pane, text: []const u8, c: modal.Cursor) usize {
- const idx = indexOf(pane, text) orelse return modal.hxOff(text, c);
- const row = @min(c.row, idx.len - 1);
- const s = idx[row];
- const e = if (row + 1 < idx.len) idx[row + 1] - 1 else text.len;
- return @min(s + c.col, e);
+ if (pane.file != null) return file_pane.textOffset(pane, text, c);
+ return modal.hxOff(text, c);
}
- /// modal.lineStartOffset, same contract (row past the end -> text.len)
fn paneLineStart(pane: *Pane, text: []const u8, row: usize) usize {
- const idx = indexOf(pane, text) orelse return modal.lineStartOffset(text, row);
- return if (row >= idx.len) text.len else idx[row];
+ if (pane.file != null) return file_pane.textLineStart(pane, text, row);
+ return modal.lineStartOffset(text, row);
+ }
+
+ fn paneLineCount(pane: *Pane, text: []const u8) usize {
+ if (pane.file != null) return file_pane.textLineCount(pane, text);
+ return modal.hxLineCount(text);
}
fn panePos(pane: *Pane, text: []const u8, off: usize) modal.Cursor {
- const idx = indexOf(pane, text) orelse return modal.hxPos(text, off);
- const o = @min(off, text.len);
- // the last line start at or before `o`; idx is sorted and idx[0] == 0,
- // so upperBound never returns 0
- const row = std.sort.upperBound(usize, idx, o, struct {
- fn cmp(key: usize, item: usize) std.math.Order {
- return std.math.order(key, item);
- }
- }.cmp) - 1;
- return .{ .row = row, .col = o - idx[row] };
+ if (pane.file != null) return file_pane.textPosition(pane, text, off);
+ return modal.hxPos(text, off);
}
/// the current selection as a helix gap range over `text`, whose first
@@ -7795,10 +7011,9 @@ pub const Pardes = struct {
// modal.hxVertTarget with the row we already have and the indexed
// offset conversion — it would otherwise recount the buffer's newlines
// and walk to the target line, two more full passes per j/k
- const t = if (indexOf(pane, text)) |idx| blk: {
- const nline = if (down) @min(pos.row + @max(1, cnt), idx.len - 1) else pos.row -| @max(1, cnt);
- break :blk paneOff(pane, text, .{ .row = nline, .col = goal });
- } else modal.hxVertTarget(text, cur, down, cnt, goal);
+ const last_row = paneLineCount(pane, text) - 1;
+ const nline = if (down) @min(pos.row + @max(1, cnt), last_row) else pos.row -| @max(1, cnt);
+ const t = paneOff(pane, text, .{ .row = nline, .col = goal });
// extend mode never walks onto the empty trailing line (helix)
if (pane.select and t == text.len and text.len > 0 and text[text.len - 1] == '\n') return;
setPaneRange(pane, pl, text, modal.hxPutCursor(text, range, t, pane.select), false);
@@ -7818,7 +7033,7 @@ pub const Pardes = struct {
const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2));
pane.scrollBy(delta);
const top = pane.scroll();
- const last_row: i32 = @intCast((if (indexOf(pane, text)) |idx| idx.len else modal.hxLineCount(text)) - 1);
+ const last_row: i32 = @intCast(paneLineCount(pane, text) - 1);
const cur = modal.hxCursor(text, range);
if (delta > 0) {
const snap: i32 = @max(0, @min(top + margin, last_row));
@@ -7839,7 +7054,7 @@ pub const Pardes = struct {
fn gotoWindow(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, which: enum { top, center, bottom }, cnt: usize) void {
const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2));
const top = pane.scroll();
- const last_row: i32 = @intCast((if (indexOf(pane, text)) |idx| idx.len else modal.hxLineCount(text)) - 1);
+ const last_row: i32 = @intCast(paneLineCount(pane, text) - 1);
const last_vis: i32 = @min(@as(i32, pane.rows) - 1, last_row - top);
const n: i32 = @intCast(cnt - 1);
var vline: i32 = switch (which) {
@@ -8358,70 +7573,6 @@ pub const Pardes = struct {
return pane.cwdSlice();
}
- const PdfSearchResult = struct { bytes: usize = 0, rows: usize = 0, anchor: ?usize = null };
-
- /// Ask MuPDF for each page's logical hits, so the result list and the
- /// quads used to highlight/reveal can never disagree. Locations spell
- /// `path:PAGE:HIT`, reusing Spot.col as a one-based page-local hit id.
- /// The compact snippet is the query itself: pairing MuPDF geometry back to
- /// extracted lines would be heuristic, and a plausible wrong line is less
- /// honest than the exact occurrence identity already in the address.
- fn searchPdf(
- p: *Pardes,
- pane: *Pane,
- pat: []const u8,
- start: SearchStart,
- out: []u8,
- ) !PdfSearchResult {
- if (comptime !pdf_enabled) return .{};
- const pv = &(pane.pdf orelse return .{});
- try p.setPdfSearchQuery(pv, pat);
- const arena = p.scratch.allocator();
- const shown = std.fs.path.basename(pv.path);
- var result: PdfSearchResult = .{};
- const max_hits = 512;
- var snippet_len = @min(pat.len, 200);
- while (snippet_len > 0 and snippet_len < pat.len and pat[snippet_len] & 0xc0 == 0x80)
- snippet_len -= 1;
- const snippet = pat[0..snippet_len];
- for (0..pv.page_count) |page| {
- if (result.rows >= max_hits) break;
- var found = try pv.document.search(p.pdf_gpa, page, pat);
- defer found.deinit(p.pdf_gpa);
-
- // A native right-click selection identifies the occurrence the
- // user is already on. Compare hit centers against its oriented
- // selection quads; the next step then advances past that hit.
- const cursor_hit: ?usize = if (start == .cursor and page == pv.page) cursor: {
- const selection = pv.selection orelse break :cursor null;
- for (found.quads) |item| {
- const q = item.quad;
- const center: PdfPoint = .{
- .x = (q.ul.x + q.ur.x + q.ll.x + q.lr.x) / 4,
- .y = (q.ul.y + q.ur.y + q.ll.y + q.lr.y) / 4,
- };
- if (selection.contains(center)) break :cursor item.hit;
- }
- break :cursor null;
- } else null;
-
- for (0..found.hit_count) |hit_index| {
- if (result.rows >= max_hits) break;
- const row = try std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{
- shown, page + 1, hit_index + 1, snippet,
- });
- if (row.len > out.len - result.bytes) return result;
- if (start == .cursor and
- (page < pv.page or (page == pv.page and cursor_hit != null and hit_index <= cursor_hit.?)))
- result.anchor = result.rows;
- @memcpy(out[result.bytes..][0..row.len], row);
- result.bytes += row.len;
- result.rows += 1;
- }
- }
- return result;
- }
-
/// `/` (and the Find builtin) on any pane: type the pattern into the tag
/// tail after `marker` — the existing modal one-line editor, visible while
/// typing, nothing that disappears. Enter/Esc are intercepted in handleKey.
@@ -8571,10 +7722,19 @@ pub const Pardes = struct {
} else if (kind == .find) {
out_len = try look.find(arena, dir, pat, out);
} else if (hasPdf(pane)) {
- const found = try p.searchPdf(pane, pat, start, out);
- out_len = found.bytes;
- nrows = found.rows;
- anchor = found.anchor;
+ if (comptime pdf_enabled) {
+ const found = try pdf_pane.searchRows(
+ &pane.pdf.?,
+ p.pdf_gpa,
+ arena,
+ pat,
+ start == .cursor,
+ out,
+ );
+ out_len = found.bytes;
+ nrows = found.rows;
+ anchor = found.anchor;
+ }
} else {
const pl = try p.paneCursorLines(pane);
// a real file names itself; a terminal or an output buffer has no path
@@ -8651,7 +7811,6 @@ pub const Pardes = struct {
rp.vsel.active = false;
rp.nsel = 0;
rp.cur_row = r;
- rp.cur_col = 0;
rp.cur_pinned = true;
// in view, but WITHOUT scrolloff: a results pane is short, and a
// three-row margin on a seven-row one means every single n scrolls the
@@ -8660,6 +7819,22 @@ pub const Pardes = struct {
const last = off + @as(i32, rp.rows) - 1;
if (r < off) rp.scrollBy(r - off) else if (r > last) rp.scrollBy(r - last);
const ln = modal.lineSlice(rf.content, @intCast(step));
+ var realbuf: [4096]u8 = undefined;
+ const span = if (tr.commands)
+ wholeRowSpan(ln)
+ else
+ look.lookableLineSpan(ln, paneDir(rp), &realbuf);
+ if (span) |selected| {
+ rp.cur_col = @intCast(selected.start);
+ rp.look_at = .{
+ .row = r,
+ .col0 = @intCast(selected.start),
+ .col1 = @intCast(selected.end - 1),
+ };
+ } else {
+ rp.cur_col = 0;
+ rp.look_at = null;
+ }
// Both arms are the BUILTIN, run on the results pane — the same call a
// middle or right click on that row would make, so a stepped row and a
// clicked row can never drift apart. A command row goes whole (its
@@ -9072,9 +8247,10 @@ pub const Pardes = struct {
/// never stood on, the first step ACQUIRES a position rather than moving
/// one — see lookStand. Every press after that is exact.)
///
- /// No walk state outside the panes themselves, which is what makes this
- /// robust where the old stepper was not: nothing to go stale when a search
- /// refills, a pane closes, or you move the cursor and step on from there.
+ /// Position stays in each pane (`look_at`); the one global serial records
+ /// only WHICH stream owns the next step after a Look moves focus away.
+ /// Serials make deletion/reuse stale safely, and refilling a list simply
+ /// re-arms that list without manufacturing a second cursor.
///
/// ONE MOTION, EVERYWHERE. Not a pane kind, not a buffer kind, not a mode:
/// n/N are this walk in all of them, which is the other half of making
@@ -9094,12 +8270,15 @@ pub const Pardes = struct {
var buf: [MAX_PANES]usize = undefined;
const order = p.lookWalkPanes(&buf);
if (order.len == 0) return;
- // Start where you ARE when that is somewhere the walk goes; otherwise
- // at its head, which after a look is the pane you looked FROM — the
- // look focused what it OPENED, and that is not a walk pane.
- const at: usize = for (order, 0..) |id, i| {
- if (id == p.active) break i;
- } else 0;
+ // A Look may move focus away from the list it came from. Continue the
+ // explicitly armed origin first; only a stale/missing owner falls back
+ // to the pane under focus and then the history head.
+ const owner = if (p.look_walk_owner) |serial| p.paneBySerial(serial) else null;
+ const active_at = std.mem.indexOfScalar(usize, order, p.active) orelse 0;
+ const at = if (owner) |wanted|
+ std.mem.indexOfScalar(usize, order, wanted) orelse active_at
+ else
+ active_at;
var from = lookStand(p.panes[order[at]] orelse return);
// ...then every OTHER pane once, in the direction of travel, entered
// at its edge — and `k == order.len` brings the starting pane round a
@@ -9131,79 +8310,16 @@ pub const Pardes = struct {
pane.cur_col = spot.col0;
pane.cur_pinned = true;
pane.look_at = spot;
+ p.look_walk_owner = pane.serial;
p.active = id;
pane.ensureCursorVisible();
}
- const EditText = struct { text: []u8, row0: i32 };
-
- /// Materialize or extend a terminal edit buffer with one exact allocation.
+ /// Route shared edit operations to a file's content or a terminal overlay.
fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText {
if (pane.file) |f| return .{ .text = f.content, .row0 = 0 };
if (pane.image != null or hasPdf(pane)) return null;
- const want_lo = @max(0, @min(lo, hi));
- const want_hi = @max(want_lo, @max(lo, hi));
- const fresh = pane.ovl == null;
- const old: Ovl = pane.ovl orelse .{ .row = want_lo, .rows = 1, .text = &.{} };
- const lines: i32 = if (fresh) 1 else @intCast(modal.lineCount(old.text));
- const up = old.row - want_lo;
- const down = want_hi - (old.row + lines - 1);
- if (fresh or up > 0 or down > 0) {
- const rows = term_pane.shellRows(p, pane) catch return null;
- const row0 = old.row - @max(0, up);
- var total: usize = 0;
- var g: i32 = row0;
- while (g < old.row) : (g += 1)
- total += (if (@as(usize, @intCast(g)) < rows.len) rows[@intCast(g)].len else 0) + 1;
- total += if (fresh)
- (if (@as(usize, @intCast(old.row)) < rows.len) rows[@intCast(old.row)].len else 0)
- else
- old.text.len;
- var k: i32 = 0;
- while (k < down) : (k += 1) {
- const src = old.row + old.rows + k;
- total += 1 + if (@as(usize, @intCast(src)) < rows.len) rows[@intCast(src)].len else 0;
- }
- const text = p.gpa.alloc(u8, total) catch return null;
- var at: usize = 0;
- g = row0;
- while (g < old.row) : (g += 1) {
- const src = if (@as(usize, @intCast(g)) < rows.len) rows[@intCast(g)] else "";
- @memcpy(text[at..][0..src.len], src);
- at += src.len;
- text[at] = '\n';
- at += 1;
- }
- const middle = if (fresh)
- (if (@as(usize, @intCast(old.row)) < rows.len) rows[@intCast(old.row)] else "")
- else
- old.text;
- @memcpy(text[at..][0..middle.len], middle);
- at += middle.len;
- k = 0;
- while (k < down) : (k += 1) {
- const src_row = old.row + old.rows + k;
- const src = if (@as(usize, @intCast(src_row)) < rows.len) rows[@intCast(src_row)] else "";
- text[at] = '\n';
- at += 1;
- @memcpy(text[at..][0..src.len], src);
- at += src.len;
- }
- if (pane.ovl) |o| p.gpa.free(o.text);
- pane.ovl = .{ .row = row0, .rows = old.rows + @max(0, up) + @max(0, down), .text = text };
- }
- const o = &pane.ovl.?;
- const r: usize = @intCast(@max(0, want_lo - o.row));
- const llen: i32 = @intCast(modal.lineSlice(o.text, r).len);
- if (col > llen) {
- const spaces = p.gpa.alloc(u8, @intCast(col - llen)) catch return null;
- defer p.gpa.free(spaces);
- @memset(spaces, ' ');
- const text = modal.insertAt(p.gpa, o.text, .{ .row = r, .col = @intCast(llen) }, spaces) catch return null;
- p.gpa.free(o.text);
- o.text = text;
- }
- return .{ .text = o.text, .row0 = o.row };
+ return term_pane.editText(p, pane, lo, hi, col);
}
/// editText for an op whose selection can END on a line's newline cell:
@@ -9220,10 +8336,7 @@ pub const Pardes = struct {
/// install a rewritten editable text (frees the old one)
fn setEditText(p: *Pardes, pane: *Pane, new: []u8) void {
if (pane.file) |*f| return file_pane.setContent(p, f, new);
- if (pane.ovl) |*o| {
- p.gpa.free(o.text);
- o.text = new;
- } else p.gpa.free(new);
+ term_pane.setEditText(p, pane, new);
}
const InsertAt = enum { at, append, line_start, line_end, open_below, open_above };
@@ -9597,7 +8710,10 @@ pub const Pardes = struct {
fn vselText(p: *Pardes, pane: *Pane) []const u8 {
const arena = p.scratch.allocator();
const b = vselBounds(pane);
- const text = p.surfaceText(pane) orelse return "";
+ const text = if (pane.file) |f| f.content else surface: {
+ const pl = p.paneCursorLines(pane) catch return "";
+ break :surface p.flatSurface(pane, pl) catch return "";
+ };
return modal.rangeText(arena, text, .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row)), .col = @intCast(@max(0, b.hi_col)) }) catch "";
}
@@ -9629,14 +8745,6 @@ pub const Pardes = struct {
return out;
}
- /// the whole pane surface as flat text, absolute rows from 0 (read-only:
- /// unlike editText it never materializes a terminal's edit buffer)
- fn surfaceText(p: *Pardes, pane: *Pane) ?[]const u8 {
- if (pane.file) |f| return f.content;
- const pl = p.paneCursorLines(pane) catch return null;
- return p.flatSurface(pane, pl) catch null;
- }
-
// ---- edit operations (d / c / y / p) ----
fn normalDelete(p: *Pardes, pane: *Pane, yank: bool) void {
@@ -10479,131 +9587,21 @@ pub const Pardes = struct {
pane.ensureCursorVisible();
}
- fn pushFileHistory(p: *Pardes, slots: *[UNDO_MAX]FileSnap, len: *usize, snap: FileSnap) void {
- if (len.* == slots.len) {
- p.gpa.free(slots[0].content);
- std.mem.copyForwards(FileSnap, slots[0 .. slots.len - 1], slots[1..]);
- len.* -= 1;
- }
- slots[len.*] = snap;
- len.* += 1;
- }
-
- fn pushEditHistory(p: *Pardes, slots: *[EDIT_UNDO_MAX]EditSnap, len: *usize, snap: EditSnap) void {
- if (len.* == slots.len) {
- if (slots[0].ovl) |o| p.gpa.free(o.text);
- std.mem.copyForwards(EditSnap, slots[0 .. slots.len - 1], slots[1..]);
- len.* -= 1;
- }
- slots[len.*] = snap;
- len.* += 1;
- }
-
fn pushUndo(p: *Pardes, pane: *Pane) void {
// one keystroke, one undo step — even when it edited at ten cursors.
if (p.multi_on and !p.multi_first) return;
- if (pane.file) |*f| {
- if (f.undo_len > 0 and std.mem.eql(u8, f.undo[f.undo_len - 1].content, f.content)) return;
- const snap: FileSnap = .{
- .content = p.gpa.dupe(u8, f.content) catch return,
- .cur_row = pane.cur_row,
- .cur_col = pane.cur_col,
- .vsel = pane.vsel,
- };
- p.pushFileHistory(&f.undo, &f.undo_len, snap);
- for (f.redo[0..f.redo_len]) |s| p.gpa.free(s.content);
- f.redo_len = 0;
- } else {
- const cur = pane.ovl orelse Ovl{ .rows = 0 };
- if (pane.ed_undo_len > 0) {
- const top = pane.ed_undo[pane.ed_undo_len - 1];
- const same = if (top.ovl) |o| pane.ovl != null and o.row == cur.row and
- o.rows == cur.rows and std.mem.eql(u8, o.text, cur.text) else pane.ovl == null;
- if (same) return;
- }
- const snap = term_pane.snap(p, pane) orelse return;
- p.pushEditHistory(&pane.ed_undo, &pane.ed_undo_len, snap);
- for (pane.ed_redo[0..pane.ed_redo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text);
- pane.ed_redo_len = 0;
- }
+ if (pane.file != null) return file_pane.pushUndo(p, pane);
+ term_pane.pushUndo(p, pane);
}
fn doUndo(p: *Pardes, pane: *Pane) void {
- if (pane.file) |*f| {
- if (f.undo_len == 0) return;
- const cur: FileSnap = .{
- .content = p.gpa.dupe(u8, f.content) catch return,
- .cur_row = pane.cur_row,
- .cur_col = pane.cur_col,
- .vsel = pane.vsel,
- };
- p.pushFileHistory(&f.redo, &f.redo_len, cur);
- f.undo_len -= 1;
- const prev = f.undo[f.undo_len];
- file_pane.setContent(p, f, prev.content);
- file_pane.restoreSnap(pane, f, prev);
- return;
- }
- if (pane.ed_undo_len == 0) return;
- const cur = term_pane.snap(p, pane) orelse return;
- p.pushEditHistory(&pane.ed_redo, &pane.ed_redo_len, cur);
- pane.ed_undo_len -= 1;
- term_pane.restoreSnap(p, pane, pane.ed_undo[pane.ed_undo_len]);
+ if (pane.file != null) return file_pane.undo(p, pane);
+ term_pane.undo(p, pane);
}
fn doRedo(p: *Pardes, pane: *Pane) void {
- if (pane.file) |*f| {
- if (f.redo_len == 0) return;
- const cur: FileSnap = .{
- .content = p.gpa.dupe(u8, f.content) catch return,
- .cur_row = pane.cur_row,
- .cur_col = pane.cur_col,
- .vsel = pane.vsel,
- };
- p.pushFileHistory(&f.undo, &f.undo_len, cur);
- f.redo_len -= 1;
- const next = f.redo[f.redo_len];
- file_pane.setContent(p, f, next.content);
- file_pane.restoreSnap(pane, f, next);
- return;
- }
- if (pane.ed_redo_len == 0) return;
- const cur = term_pane.snap(p, pane) orelse return;
- p.pushEditHistory(&pane.ed_undo, &pane.ed_undo_len, cur);
- pane.ed_redo_len -= 1;
- term_pane.restoreSnap(p, pane, pane.ed_redo[pane.ed_redo_len]);
- }
-
- /// Something outside pardes rewrote a file we have open. Commit the buffer
- /// we are holding — unsaved edits and all — onto the undo stack, then swap
- /// in the bytes from disk.
- ///
- /// THAT IS THE WHOLE DESIGN, not an implementation detail. An external
- /// update is pushed exactly like an edit the user typed, so `u` walks back
- /// to the buffer as it was a keystroke ago and unsaved work is never lost.
- /// It is also why the feature is nearly free: pardes never has to merge its
- /// buffer against the new file, never has to ask "reload? keep? diff?", and
- /// there is no third state between "what is on disk" and "what I typed" —
- /// the undo stack, which already exists, IS the history of both.
- fn fileChanged(p: *Pardes, id: u8, bytes: []const u8) void {
- const pane = p.panes[id] orelse return;
- if (pane.file == null) return;
- const f = &pane.file.?;
- // the disk arrived at what we already hold: an undo step that changes
- // nothing is worse than no undo step
- if (std.mem.eql(u8, f.content, bytes)) return;
- const new = p.gpa.dupe(u8, bytes) catch return;
- p.pushUndo(pane);
- file_pane.setContent(p, f, new);
- // keep the cursor where it was, clamped onto the new text: restoreSnap
- // reads only the cursor and selection out of the snapshot it is handed,
- // so handing it the pane's own is exactly "clamp what you have".
- file_pane.restoreSnap(pane, f, .{
- .content = undefined,
- .cur_row = pane.cur_row,
- .cur_col = pane.cur_col,
- .vsel = pane.vsel,
- });
+ if (pane.file != null) return file_pane.redo(p, pane);
+ term_pane.redo(p, pane);
}
pub const ChromeTarget = struct { col: u16, row: u16 };
@@ -10631,7 +9629,7 @@ pub const Pardes = struct {
for (p.panes, 0..) |slot, id| {
if (slot == null) continue;
const rect = p.rects[id];
- const tag = if (p.tag_bottom) rect.y + rect.h -| BOX_H else rect.y;
+ const tag = if (p.settings.tag_bottom) rect.y + rect.h -| BOX_H else rect.y;
if (row == tag and col >= rect.x and col < rect.x + rect.w and col < rect.x + config.GUTTER)
return .{ .col = col, .row = tag };
}
@@ -10643,14 +9641,14 @@ pub const Pardes = struct {
if (col < p.col_x[column] or col >= p.col_x[column] + p.col_w[column]) continue;
for (0..p.col_n[column] -| 1) |index| {
const rect = p.rects[p.col_terms[column][index]];
- const handle = if (p.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1;
+ const handle = if (p.settings.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1;
if (row == handle) return .{ .col = col, .row = handle };
}
}
for (p.panes, 0..) |slot, id| {
if (slot == null) continue;
const rect = p.rects[id];
- const tag = if (p.tag_bottom) rect.y + rect.h -| BOX_H else rect.y;
+ const tag = if (p.settings.tag_bottom) rect.y + rect.h -| BOX_H else rect.y;
if (row == tag and col >= rect.x and col < rect.x + rect.w)
return .{ .col = col, .row = tag };
}
@@ -10662,24 +9660,397 @@ pub const Pardes = struct {
if (col < p.col_x[column] or col >= p.col_x[column] + p.col_w[column]) continue;
for (0..p.col_n[column] -| 1) |index| {
const rect = p.rects[p.col_terms[column][index]];
- const handle = if (p.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1;
+ const handle = if (p.settings.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1;
if (@max(row, handle) - @min(row, handle) == 1) return .{ .col = col, .row = handle };
}
}
return null;
}
+ fn paneAt(p: *const Pardes, col: u16, row: u16) ?usize {
+ for (p.panes, 0..) |slot, id| {
+ if (slot == null) continue;
+ const r = p.rects[id];
+ if (col >= r.x and col < r.x + r.w and row >= r.y and row < r.y + r.h) return id;
+ }
+ return null;
+ }
+
+ const PointerTextSelection = struct {
+ sel: Sel,
+ on_tag: bool,
+ };
+
+ /// Map one physical grid cell into the selection coordinate space shared
+ /// by Look, Exec and the hover preview. Geometry lives here once: the tag
+ /// is always selection row zero and body rows start at BOX_H, even when
+ /// Tagbottom swaps their physical positions.
+ fn pointerTextSelection(p: *const Pardes, id: usize, col: u16, row: u16) ?PointerTextSelection {
+ if (p.panes[id] == null) return null;
+ const r = p.rects[id];
+ if (col < r.x + config.GUTTER or col >= r.x + r.w or row < r.y or row >= r.y + r.h) return null;
+ const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H;
+ const on_tag = row >= tag_y and row < tag_y + BOX_H;
+ const c: i32 = @as(i32, col) - @as(i32, r.x + config.GUTTER);
+ const v: i32 = if (on_tag) 0 else @as(i32, row) - @as(i32, body_y) + @as(i32, BOX_H);
+ return .{
+ .sel = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v },
+ .on_tag = on_tag,
+ };
+ }
+
+ fn cancelLookHover(p: *Pardes) void {
+ if (comptime pdf_enabled) if (p.pdf_hover_preview) |*preview| {
+ if (preview.pane < p.panes.len) if (p.panes[preview.pane]) |pane| {
+ if (pane.serial == preview.serial) if (pane.pdf) |*pv|
+ pv.invalidateRaster(preview.probe.page);
+ };
+ preview.deinit(p.pdf_gpa);
+ p.pdf_hover_preview = null;
+ };
+ p.look_hover_wait = null;
+ p.look_hover_preview = null;
+ }
+
+ fn lookHoverPane(p: *const Pardes) ?usize {
+ if (comptime pdf_enabled) if (p.pdf_hover_preview) |preview| return preview.pane;
+ if (p.look_hover_preview) |preview| return preview.pane;
+ if (p.look_hover_wait) |waiting| return waiting.pane;
+ return null;
+ }
+
+ /// Debounce by semantic grid cell rather than raw motion events. A host
+ /// may report the same pixel position every frame; those reports must not
+ /// postpone the preview forever.
+ fn noteLookHover(p: *Pardes, col: u16, row: u16) void {
+ const delay = config.look_preview_delay_frames orelse return;
+ _ = delay;
+ const id = p.paneAt(col, row) orelse return p.cancelLookHover();
+ const pane = p.panes[id] orelse return p.cancelLookHover();
+ _ = p.pointerTextSelection(id, col, row) orelse return p.cancelLookHover();
+
+ if (comptime pdf_enabled) if (p.pdf_hover_preview) |shown|
+ if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial) return;
+ if (p.look_hover_preview) |shown|
+ if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial) return;
+ if (p.look_hover_wait) |waiting|
+ if (waiting.col == col and waiting.row == row and waiting.pane == id and waiting.serial == pane.serial) return;
+
+ p.cancelLookHover();
+ p.look_hover_wait = .{ .col = col, .row = row, .pane = id, .serial = pane.serial };
+ }
+
+ /// Re-evaluate a stationary pointer against the frame the host just
+ /// presented. `noteLookHover` preserves an existing delay or preview only
+ /// when this still resolves to the same canonical cell and pane lifetime;
+ /// moving to another semantic target re-arms it, and an invisible panel
+ /// cell cancels it.
+ fn refreshLookHoverFromRaw(p: *Pardes) void {
+ if (!p.pointer_inside or !p.raw_hover_intent) return;
+ const mapped = p.presentedPointer(p.pointer_raw_col, p.pointer_raw_row) orelse
+ return p.cancelLookHover();
+ p.hover_col = mapped.col;
+ p.hover_row = mapped.row;
+ p.noteLookHover(mapped.col, mapped.row);
+ }
+
+ fn advanceLookHover(p: *Pardes) void {
+ const delay = config.look_preview_delay_frames orelse return;
+ const waiting = if (p.look_hover_wait) |*w| w else return;
+ waiting.frames +|= 1;
+ if (waiting.frames < delay) return;
+ const pane = p.panes[waiting.pane] orelse return p.cancelLookHover();
+ if (pane.serial != waiting.serial) return p.cancelLookHover();
+ const pointed = p.pointerTextSelection(waiting.pane, waiting.col, waiting.row) orelse
+ return p.cancelLookHover();
+ if (comptime pdf_enabled) if (!pointed.on_tag and pdf_pane.paneNativeReady(p, pane)) {
+ const probe = pdf_pane.probeAt(p, pane, waiting.col, waiting.row) orelse
+ return p.cancelLookHover();
+ const ready = waiting.*;
+ p.look_hover_wait = null;
+ p.pdf_hover_preview = .{
+ .col = ready.col,
+ .row = ready.row,
+ .pane = ready.pane,
+ .serial = ready.serial,
+ .probe = probe,
+ };
+ pane.pdf.?.invalidateRaster(probe.page);
+ return;
+ };
+ const operand = p.pointerOperand(pane, pointed.sel);
+ if (operand.text == null or operand.text.?.len == 0) return p.cancelLookHover();
+ var preview = operand.preview;
+ if (preview) |*sel| sel.state = .done;
+ p.look_hover_preview = .{
+ .col = waiting.col,
+ .row = waiting.row,
+ .pane = waiting.pane,
+ .serial = waiting.serial,
+ .sel = preview,
+ .file_word = operand.file_word,
+ };
+ p.look_hover_wait = null;
+ }
+
+ const PointerCell = struct { col: u16, row: u16 };
+
+ fn boxContainsCell(box: panel_animation.Box, col: u16, row: u16) bool {
+ const x = @as(f32, @floatFromInt(col)) + 0.5;
+ const y = @as(f32, @floatFromInt(row)) + 0.5;
+ return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h;
+ }
+
+ /// Input follows the panel pixels the shaders/TTY compositor present.
+ /// Opening panels are drawn last, so they are probed first here too. A
+ /// cell in final geometry which has not appeared yet is deliberately not
+ /// clickable; otherwise a user could act on invisible content.
+ fn presentedPointer(p: *const Pardes, col: u16, row: u16) ?PointerCell {
+ if (p.panel_presentation_pending) return null;
+ // Closing pixels belong to a dead pane lifetime. They occlude the
+ // canonical survivor underneath while visible, but can never dispatch
+ // into either that survivor or a slot which reused the old pane id.
+ var closing = p.npresented_closing_panel_tracks;
+ while (closing > 0) {
+ closing -= 1;
+ const track = p.presented_closing_panel_tracks[closing];
+ if (!track.active()) continue;
+ if (boxContainsCell(track.contentBox(), col, row) and
+ boxContainsCell(track.presented(), col, row)) return null;
+ }
+ const phases = [_]panel_animation.Phase{ .opening, .moving };
+ for (phases) |phase| {
+ // Backends paint pane slots forward within a phase. Probe them in
+ // reverse so overlapping transition quads address the top pixel.
+ var index = p.presented_panel_tracks.len;
+ while (index > 0) {
+ index -= 1;
+ const track = p.presented_panel_tracks[index] orelse continue;
+ if (!track.active() or track.phase != phase) continue;
+ switch (track.effect) {
+ .slide, .zoom => {
+ const shown = track.presented();
+ if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row)) continue;
+ const x = @as(f32, @floatFromInt(col)) + 0.5;
+ const y = @as(f32, @floatFromInt(row)) + 0.5;
+ const u = std.math.clamp((x - shown.x) / shown.w, 0, 0.999_999);
+ const v = std.math.clamp((y - shown.y) / shown.h, 0, 0.999_999);
+ const logical_x: i32 = @intFromFloat(@floor(track.to.x + u * track.to.w));
+ const logical_y: i32 = @intFromFloat(@floor(track.to.y + v * track.to.h));
+ return .{
+ .col = @intCast(std.math.clamp(logical_x, 0, @as(i32, p.screen_w -| 1))),
+ .row = @intCast(std.math.clamp(logical_y, 0, @as(i32, p.screen_h -| 1))),
+ };
+ },
+ .vertical => {
+ const clip = track.contentBox();
+ if (!boxContainsCell(clip, col, row)) continue;
+ const shown = track.presented();
+ if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row))
+ return null;
+ const x = @as(f32, @floatFromInt(col)) + 0.5;
+ const y = @as(f32, @floatFromInt(row)) + 0.5;
+ const u = std.math.clamp((x - shown.x) / shown.w, 0, 0.999_999);
+ const v = std.math.clamp((y - shown.y) / shown.h, 0, 0.999_999);
+ return .{
+ .col = @intFromFloat(@floor(clip.x + u * clip.w)),
+ .row = @intFromFloat(@floor(clip.y + v * clip.h)),
+ };
+ },
+ .dissolve, .ascii => {
+ if (!boxContainsCell(track.to, col, row)) continue;
+ if (!p.panelCellChanged(col, row))
+ return .{ .col = col, .row = row };
+ const relative_col: u16 = @intFromFloat(@floor(
+ @as(f32, @floatFromInt(col)) + 0.5 - track.to.x,
+ ));
+ const relative_row: u16 = @intFromFloat(@floor(
+ @as(f32, @floatFromInt(row)) + 0.5 - track.to.y,
+ ));
+ const visible = switch (track.effect) {
+ .dissolve => panel_animation.dissolveRevealed(
+ track.serial,
+ relative_col,
+ relative_row,
+ track.amount(),
+ ),
+ .ascii => panel_animation.asciiCell(
+ track.serial,
+ relative_col,
+ relative_row,
+ track.amount(),
+ ).reveal,
+ else => unreachable,
+ };
+ return if (visible) .{ .col = col, .row = row } else null;
+ },
+ .off => {},
+ }
+ }
+ }
+ for (p.presented_panel_tracks) |maybe| {
+ const track = maybe orelse continue;
+ if (!track.active() or (track.effect != .slide and track.effect != .zoom and
+ track.effect != .vertical)) continue;
+ if (boxContainsCell(track.to, col, row)) return null;
+ }
+ return .{ .col = col, .row = row };
+ }
+
+ /// Commit the exact panel samples a backend successfully presented. An
+ /// empty slice means that backend drew the canonical grid directly.
+ /// Records are keyed by pane slot so hit-test order stays identical to the
+ /// renderer even when a native ABI publishes them in paint order.
+ pub fn acknowledgePanelPresentation(p: *Pardes, tracks: []const panel_animation.Track) void {
+ var presented: [MAX_PANES]?panel_animation.Track = @splat(null);
+ var presented_closing: [MAX_PANES]panel_animation.Track = undefined;
+ var nclosing: usize = 0;
+ var layout: [MAX_PANES]?LayoutSnapshot = if (p.submitted_panel_layout_ready)
+ p.submitted_panel_layout
+ else
+ @splat(null);
+ if (!p.submitted_panel_layout_ready) for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ layout[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) };
+ };
+ for (&layout, 0..) |*snapshot, id| if (snapshot.*) |saved| {
+ const pane = p.panes[id] orelse {
+ snapshot.* = null;
+ continue;
+ };
+ if (pane.serial != saved.serial) snapshot.* = null;
+ };
+ for (tracks) |track| {
+ if (track.phase == .closing) {
+ const current = for (p.closing_panel_tracks[0..p.nclosing_panel_tracks]) |candidate| {
+ if (candidate.serial == track.serial and candidate.pane == track.pane and
+ candidate.effect == track.effect) break true;
+ } else false;
+ if (!current or !track.active() or nclosing == presented_closing.len) continue;
+ presented_closing[nclosing] = track;
+ nclosing += 1;
+ continue;
+ }
+ const id: usize = track.pane;
+ if (id >= p.panes.len or !track.active()) continue;
+ const pane = p.panes[id] orelse continue;
+ if (pane.serial != track.serial) continue;
+ presented[id] = track;
+ layout[id] = .{ .serial = track.serial, .box = track.visualBox() };
+ }
+ p.presented_panel_tracks = presented;
+ p.presented_closing_panel_tracks = presented_closing;
+ p.npresented_closing_panel_tracks = nclosing;
+ p.presented_panel_layout = layout;
+ p.panel_presentation_ready = true;
+ p.panel_presentation_pending = false;
+ // An empty acknowledgement means canonical cells, not an invisible
+ // transition. Retire any producer records a direct/headless fallback
+ // deliberately did not present, then make this exact frame the future
+ // old-grid baseline. Animated acknowledgements keep their baseline
+ // frozen until the canonical endpoint is actually presented.
+ if (tracks.len == 0) {
+ p.panel_tracks = @splat(null);
+ p.nclosing_panel_tracks = 0;
+ p.panel_diff_pending = false;
+ p.panel_diff_ready = false;
+ p.rememberPresentedCells();
+ }
+ // A held gesture follows the pixels just acknowledged even when the
+ // physical pointer stayed still. Its payload already is the last
+ // successfully mapped endpoint, so an invisible sample simply leaves
+ // that endpoint intact for a later frame or balanced release.
+ if (p.pointer_inside and p.drag != .none) {
+ if (p.presentedPointer(p.pointer_raw_col, p.pointer_raw_row)) |mapped| {
+ p.hover_col = mapped.col;
+ p.hover_row = mapped.row;
+ p.dragUpdate(mapped.col, mapped.row);
+ }
+ }
+ p.refreshLookHoverFromRaw();
+ }
+
+ fn rememberPresentedCells(p: *Pardes) void {
+ p.panel_diff_pending = false;
+ p.panel_diff_ready = false;
+ const cells = p.surface.cells;
+ if (cells.len == 0) {
+ p.presented_cells_valid = false;
+ p.presented_cells_layout = @splat(null);
+ return;
+ }
+ if (p.presented_cells.len != cells.len) {
+ const next = p.gpa.alloc(Cell, cells.len) catch {
+ p.presented_cells_valid = false;
+ p.presented_cells_layout = @splat(null);
+ return;
+ };
+ if (p.presented_cells.len > 0) p.gpa.free(p.presented_cells);
+ p.presented_cells = next;
+ }
+ @memcpy(p.presented_cells, cells);
+ p.presented_cells_cols = p.surface.cols;
+ p.presented_cells_rows = p.surface.rows;
+ p.presented_cells_valid = true;
+ p.presented_cells_layout = if (p.submitted_panel_layout_ready)
+ p.submitted_panel_layout
+ else
+ @splat(null);
+ }
+
+ fn panelCellChanged(p: *const Pardes, col: u16, row: u16) bool {
+ if (!p.panel_diff_ready or col >= p.screen_w or row >= p.screen_h or
+ p.panel_changed_cells.len != @as(usize, p.screen_w) * p.screen_h) return false;
+ return p.panel_changed_cells[@as(usize, row) * p.screen_w + col] != 0;
+ }
+
+ /// A backend is about to replace an unpresentable animated frame with the
+ /// canonical grid. Stop producer tracks too, so a later successful retry
+ /// cannot resume halfway through an animation after canonical was shown.
+ pub fn abandonPanelAnimations(p: *Pardes) void {
+ p.panel_tracks = @splat(null);
+ p.nclosing_panel_tracks = 0;
+ p.presented_panel_tracks = @splat(null);
+ p.npresented_closing_panel_tracks = 0;
+ p.panel_diff_pending = false;
+ p.panel_diff_ready = false;
+ p.panel_presentation_pending = p.panel_presentation_ready;
+ p.cancelLookHover();
+ }
+
+ /// A native scene pass proved unavailable. Config reads these exact bits,
+ /// and the idle-clock predicate stops immediately once they are clear.
+ pub fn disableSceneEffects(p: *Pardes) void {
+ p.settings.scene_effects = .{};
+ }
+
fn handleMouse(p: *Pardes, m: Mouse) void {
- const mcol = @min(m.col, p.screen_w -| 1);
- const mrow = @min(m.row, p.screen_h -| 1);
+ const raw_col = @min(m.col, p.screen_w -| 1);
+ const raw_row = @min(m.row, p.screen_h -| 1);
+ p.pointer_raw_col = raw_col;
+ p.pointer_raw_row = raw_row;
+ p.pointer_inside = true;
+ if (m.button == .none and m.kind == .motion) {
+ p.raw_hover_intent = true;
+ p.refreshLookHoverFromRaw();
+ return;
+ }
+ p.raw_hover_intent = false;
+ const mapped = p.presentedPointer(raw_col, raw_row) orelse {
+ p.cancelLookHover();
+ // A disappearing transition cell may hide the release, but the
+ // press it balances already has a last valid endpoint in Drag.
+ // End only the gesture owned by this exact physical button.
+ if (m.kind == .release) p.dragRelease(m.button);
+ return;
+ };
+ const mcol = mapped.col;
+ const mrow = mapped.row;
p.hover_col = mcol;
p.hover_row = mrow;
- const hovered: ?usize = for (p.panes, 0..) |slot, i| {
- if (slot == null) continue;
- const r = p.rects[i];
- if (mcol >= r.x and mcol < r.x + r.w and mrow >= r.y and mrow < r.y + r.h) break i;
- } else null;
+ const hovered = p.paneAt(mcol, mrow);
// any press that is NOT the execute+select chord abandons a pending
// chord argument
@@ -10706,7 +10077,7 @@ pub const Pardes = struct {
const id = hovered orelse return;
const pane = p.panes[id].?;
if (hasPdf(pane))
- p.pdfVerticalWheel(pane, if (m.button == .wheel_up) -1 else 1)
+ pdf_pane.verticalWheel(p, pane, if (m.button == .wheel_up) -1 else 1)
else
pane.scrollBy(if (m.button == .wheel_up) -config.wheel_rows else config.wheel_rows);
},
@@ -10720,10 +10091,10 @@ pub const Pardes = struct {
const id = hovered orelse return;
const pane = p.panes[id].?;
if (hasPdf(pane)) {
- p.pdfHorizontalWheel(pane, if (m.button == .wheel_right) 1 else -1);
+ pdf_pane.horizontalWheel(p, pane, if (m.button == .wheel_right) 1 else -1);
// ponytail: no right clamp — overscroll shows blank and the
// next cursor move or left wheel pulls it back
- } else if (pane.file != null and !p.wrap_on) {
+ } else if (pane.file != null and !p.settings.wrap) {
// wrapped there is nothing off to the right to reach
pane.hscroll = @max(0, pane.hscroll + (if (m.button == .wheel_right) config.wheel_cols else -config.wheel_cols));
}
@@ -10744,7 +10115,11 @@ pub const Pardes = struct {
// acme 3-1: a select tap during a LOOK drag cancels the
// look; the rest of the gesture is inert
if (p.drag == .select and p.drag.select.button == config.look_button) {
- if (p.panes[p.drag.select.id]) |t| t.sel[@intFromEnum(config.look_button)].state = .none;
+ if (p.panes[p.drag.select.id]) |t| {
+ t.sel[@intFromEnum(config.look_button)].state = .none;
+ if (comptime pdf_enabled)
+ pdf_pane.pointerCancel(t, p.drag.select.pdf);
+ }
p.drag = .none;
return;
}
@@ -10754,7 +10129,7 @@ pub const Pardes = struct {
// pair's h-handle, and the box must stay grabbable to move
if (hovered) |mid| {
const mr = p.rects[mid];
- const mtag = if (p.tag_bottom) mr.y + mr.h -| BOX_H else mr.y;
+ const mtag = if (p.settings.tag_bottom) mr.y + mr.h -| BOX_H else mr.y;
if (mcol < mr.x + config.GUTTER and mrow >= mtag and mrow < mtag + BOX_H) {
p.drag = .{ .move = .{ .id = mid, .cur_x = mcol, .cur_y = mrow } };
return;
@@ -10798,8 +10173,8 @@ pub const Pardes = struct {
// the tag and BOX_H upward for the body whichever end they
// are at, so the mapping happens HERE and everything
// downstream of a Sel is untouched.
- const tag_y = if (p.tag_bottom) r.y + r.h -| BOX_H else r.y;
- const body_y = if (p.tag_bottom) r.y else r.y + BOX_H;
+ const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H;
if (mcol < r.x + config.GUTTER) {
// gutter scrollbar: a left click scrolls UP to that row
p.active = id;
@@ -10819,8 +10194,6 @@ pub const Pardes = struct {
const sc: i32 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER);
const v: i32 = @as(i32, mrow) - @as(i32, body_y) + @as(i32, BOX_H);
pane.sel[sel_slot] = .{ .state = .dragging, .c0 = sc, .c1 = sc, .r0 = v, .r1 = v };
- const pdf_native = if (comptime pdf_enabled) p.pdfNativeReady(pane) else false;
- if (pdf_native) _ = p.beginPdfDrag(pane, mcol, mrow, false);
// Ctrl rides on the drag rather than firing here: the
// click does not place the modal cursor until RELEASE
// (dragRelease), and a goto asked at press time would
@@ -10829,7 +10202,14 @@ pub const Pardes = struct {
.id = id,
.button = config.select_button,
.ctrl = m.ctrl,
- .pdf = if (comptime pdf_enabled) .{ .native = pdf_native } else .{},
+ .pdf = pdf_pane.pointerStart(
+ p,
+ pane,
+ config.select_button,
+ mcol,
+ mrow,
+ false,
+ ),
} };
}
},
@@ -10848,7 +10228,11 @@ pub const Pardes = struct {
// acme 2-3 / 3-2: tapping the OTHER of execute/look during
// an execute or look drag cancels it (textselect23's mask)
if (p.drag == .select) {
- if (p.panes[p.drag.select.id]) |t| t.sel[@intFromEnum(p.drag.select.button)].state = .none;
+ if (p.panes[p.drag.select.id]) |t| {
+ t.sel[@intFromEnum(p.drag.select.button)].state = .none;
+ if (comptime pdf_enabled)
+ pdf_pane.pointerCancel(t, p.drag.select.pdf);
+ }
p.drag = .none;
return;
}
@@ -10868,7 +10252,7 @@ pub const Pardes = struct {
// no builtin and therefore went to a shell.
const named = std.meta.stringToEnum(Builtin, word);
const held = if (named) |b|
- (if (takesArg(b)) p.heldSelection(p.active) else null)
+ (if (builtins.registry.takesArg(b)) p.heldSelection(p.active) else null)
else
p.heldSelection(p.active); // not a builtin: an ordinary acme 2-1
if (word.len > 0) _ = p.execute(p.active, p.withArg(word, held));
@@ -10880,8 +10264,8 @@ pub const Pardes = struct {
const pane = p.panes[id] orelse return;
// the tag row and the body's first, exactly as the select
// arm above derives them
- const tag_y = if (p.tag_bottom) r.y + r.h -| BOX_H else r.y;
- const body_y = if (p.tag_bottom) r.y else r.y + BOX_H;
+ const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H;
const on_tag = mrow >= tag_y and mrow < tag_y + BOX_H;
if (mcol < r.x + config.GUTTER and !on_tag) {
// gutter: right scrolls DOWN to here, mirroring left's up;
@@ -10890,31 +10274,28 @@ pub const Pardes = struct {
const local = @as(i32, mrow) - @as(i32, body_y);
if (m.button == config.look_button) p.active = id;
pane.scrollBy(if (m.button == config.look_button) local else -local);
- } else if (mcol >= r.x + config.GUTTER) {
+ } else if (p.pointerTextSelection(id, mcol, mrow)) |pointed| {
if (m.button == config.look_button) p.active = id;
- const c: i32 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER);
- // unlike the select button, execute and look sweep the
- // TAG through this same Sel — that is how middle-clicking
- // Del on a tagline runs it — so the tag row is row 0 and
- // everything else counts from BOX_H, wherever the tag is
- // physically drawn.
- const v: i32 = if (on_tag) 0 else @as(i32, mrow) - @as(i32, body_y) + @as(i32, BOX_H);
- pane.sel[@intFromEnum(m.button)] = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v };
- // Once a body gesture starts over a native PDF, keep
- // it native even when the point misses a letterbox or
- // selection fails. pdfDragRelease makes a null anchor
- // inert instead of executing hidden projected text.
- // Tag clicks remain ordinary text/builtin gestures.
- const pdf_native = if (comptime pdf_enabled)
- !on_tag and p.pdfNativeReady(pane)
- else
- false;
- if (pdf_native) _ = p.beginPdfDrag(pane, mcol, mrow, true);
- p.drag = .{ .select = .{
- .id = id,
- .button = m.button,
- .pdf = if (comptime pdf_enabled) .{ .native = pdf_native } else .{},
- } };
+ // Click and delayed hover enter through the same physical
+ // cell mapper. From here on both also share expandedSel;
+ // hover merely stores the result outside Pane.sel.
+ pane.sel[@intFromEnum(m.button)] = pointed.sel;
+ p.drag = .{
+ .select = .{
+ .id = id,
+ .button = m.button,
+ // A native body gesture stays native even when its
+ // point later misses a letterbox or selection.
+ .pdf = pdf_pane.pointerStart(
+ p,
+ pane,
+ m.button,
+ mcol,
+ mrow,
+ pointed.on_tag,
+ ),
+ },
+ };
}
},
.drag => p.dragUpdate(mcol, mrow),
@@ -10948,7 +10329,7 @@ pub const Pardes = struct {
if (d.corner) |k| if (k.idx + 1 < p.col_n[k.col]) {
const a = p.rects[p.col_terms[k.col][k.idx]];
const b = p.rects[p.col_terms[k.col][k.idx + 1]];
- d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow, p.tag_bottom);
+ d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow, p.settings.tag_bottom);
} else {
d.cur_y = mrow;
};
@@ -10959,7 +10340,7 @@ pub const Pardes = struct {
if (k + 1 < p.col_n[cc]) {
const a = p.rects[p.col_terms[cc][k]];
const b = p.rects[p.col_terms[cc][k + 1]];
- d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow, p.tag_bottom);
+ d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow, p.settings.tag_bottom);
} else d.cur_y = mrow;
},
.move => |*d| {
@@ -10985,19 +10366,15 @@ pub const Pardes = struct {
// .tag drag) stays on the tag, which is one line anyway.
// The cost is that a body sweep can no longer be extended
// onto the bottom tagline to pick up the tag text.
- if (p.tag_bottom) {
+ if (p.settings.tag_bottom) {
const body_h = r.h -| BOX_H;
pane.sel[b].r1 = if (pane.sel[b].r0 < BOX_H or body_h == 0)
0
else
@as(i32, @min(@max(mrow, r.y), r.y + body_h - 1)) - @as(i32, r.y) + @as(i32, BOX_H);
} else pane.sel[b].r1 = @as(i32, mrow) - @as(i32, r.y);
- if (comptime pdf_enabled) {
- if (s.pdf.native) {
- if (p.updatePdfSelection(pane, mcol, mrow, false))
- s.pdf.selection_changed = true;
- }
- }
+ if (comptime pdf_enabled)
+ pdf_pane.pointerUpdate(&s.pdf, p, pane, mcol, mrow);
}
},
.tag => |d| {
@@ -11014,75 +10391,53 @@ pub const Pardes = struct {
}
}
- fn pdfDragRelease(p: *Pardes, pane: *Pane, s: @FieldType(Drag, "select")) void {
- if (comptime !pdf_enabled) return;
- const pv = &(pane.pdf orelse return);
- const b = @intFromEnum(s.button);
- const sl = pane.sel[b];
- pane.sel[b].state = .none; // native quads, not the projected text grid
- // Every native drag keeps its geometry/text live while holding the
- // baked highlight stable. Commit before chord/Exec/Look dispatch;
- // those paths may clear or even delete the pane.
- if (s.pdf.selection_changed) p.invalidatePdfRaster(pv);
- if (s.chorded) {
- clearPdfDrag(pv);
- return;
- }
- if (s.button == config.select_button) {
- const dragged = sl.c0 != sl.c1 or sl.r0 != sl.r1;
- if (!dragged) {
- p.clearPdfSelection(pane);
- }
- pane.cur_pinned = true;
- pane.mode = .normal;
- pane.msel.active = false;
- pane.vsel.active = false;
- pane.pending = 0;
- pane.nsel = 0;
- clearPdfDrag(pv);
- return;
- }
-
- if (pv.drag_anchor == null or pv.selection_text.len == 0) {
- clearPdfDrag(pv);
- return;
- }
- const text = pv.selection_text;
- // Exec can run Del and free `pane`; end every pv access before the
- // builtin dispatch so no deferred cleanup writes through dead state.
- clearPdfDrag(pv);
+ fn dispatchPointerBuiltin(
+ p: *Pardes,
+ id: usize,
+ cmd: Builtin,
+ text: ?[]const u8,
+ ) void {
const arg = p.chord_arg;
p.chord_arg = null;
defer if (arg) |a| p.gpa.free(a);
- const cmd = if (s.button == config.look_button) config.look_cmd else config.exec_cmd;
- p.runBuiltin(cmd, s.id, "", p.withArg(text, arg));
+ const operand = text orelse return;
+ p.runBuiltin(cmd, id, "", p.withArg(operand, arg));
}
fn dragRelease(p: *Pardes, button: Mouse.Button) void {
- // a release of a button OTHER than the one driving a select drag is
- // the middle+left chord's left release: the middle drag keeps going
- if (p.drag == .select and button != p.drag.select.button) return;
+ // A different button's release belongs to a chord or another host
+ // stream; only the button which began this gesture may end it.
+ if (p.drag.button() != button) return;
defer p.drag = .none;
switch (p.drag) {
.border_v => |d| {
const c = d.left_col;
- if (c + 1 < p.ncol) {
- const combined: f32 = @floatFromInt(p.col_w[c] + p.col_w[c + 1]);
+ const old_seam = p.col_x[c] + p.col_w[c] -| 1;
+ if (c + 1 < p.ncol and d.cur_x != old_seam) {
+ const combined: u64 = p.col_w[c] + p.col_w[c + 1];
// the handle is the left pane's LAST column (inclusive), so
// the new width includes it — a no-drag click changes nothing
- var nl: f32 = @floatFromInt((d.cur_x + 1) -| p.col_x[c]);
- nl = std.math.clamp(nl, @as(f32, config.MINW), @max(@as(f32, config.MINW), combined - config.MINW));
+ var nl: u64 = (d.cur_x + 1) -| p.col_x[c];
+ nl = std.math.clamp(nl, config.MINW, @max(@as(u64, config.MINW), combined -| config.MINW));
const pair = p.col_weight[c] + p.col_weight[c + 1];
- p.col_weight[c] = pair * (nl / combined);
- p.col_weight[c + 1] = pair - p.col_weight[c];
+ if (pair > 1 and combined > 0) {
+ const numerator = @as(u128, pair) * nl + combined / 2;
+ const left = std.math.clamp(@as(u64, @intCast(numerator / combined)), 1, pair - 1);
+ p.col_weight[c] = left;
+ p.col_weight[c + 1] = pair - left;
+ }
}
// and the corner's other half. The two commits are independent
// — column weights are widths, pane vweights are heights, and
// neither reads the other — so the order here does not matter
// and a failed one cannot lose the other.
- if (d.corner) |k| p.applyRowSplit(k.col, k.idx, d.cur_y);
+ if (d.corner) |k| {
+ if (d.cur_y != seamRowOf(p, k.col, k.idx))
+ p.applyRowSplit(k.col, k.idx, d.cur_y);
+ }
},
- .border_h => |d| p.applyRowSplit(d.col, d.top_idx, d.cur_y),
+ .border_h => |d| if (d.cur_y != seamRowOf(p, d.col, d.top_idx))
+ p.applyRowSplit(d.col, d.top_idx, d.cur_y),
.move => |d| {
p.moveTerm(d.id, d.cur_x, d.cur_y);
// a file moved into the left column evicts a lone unused shell
@@ -11094,7 +10449,22 @@ pub const Pardes = struct {
.select => |s| {
const pane = p.panes[s.id] orelse return;
if (comptime pdf_enabled) {
- if (s.pdf.native) return p.pdfDragRelease(pane, s);
+ if (s.pdf.native) {
+ var release = pdf_pane.pointerRelease(
+ p,
+ pane,
+ s.pdf,
+ s.button,
+ s.chorded,
+ );
+ defer release.deinit(p.pdf_gpa);
+ if (release.action) |action| p.dispatchPointerBuiltin(
+ s.id,
+ if (action == .look) config.look_cmd else config.exec_cmd,
+ release.text,
+ );
+ return;
+ }
}
if (s.chorded) {
// a cut/paste chord consumed this drag; the release is inert
@@ -11120,7 +10490,7 @@ pub const Pardes = struct {
const w = pane.wrapAt(body_vis);
pane.cur_row = w.line;
pane.cur_col = if (pane.file != null)
- fileByteAtDisplay(pane, w.line, w.at, sl.c1 - @as(i32, config.PREFIX_W))
+ file_pane.byteAtRowDisplay(pane, w.line, w.at, sl.c1 - @as(i32, config.PREFIX_W))
else
sl.c1;
pane.cur_pinned = true;
@@ -11146,55 +10516,23 @@ pub const Pardes = struct {
// WHERE THE CLICK LANDED, before any expansion — as a body
// position, the way the left button converts its drag end.
const clk = pane.sel[b];
- const cvis = clk.r0 - @as(i32, BOX_H);
- const cw = pane.wrapAt(cvis);
- const crow = cw.line;
- const ccol = if (pane.file != null)
- fileByteAtDisplay(pane, cw.line, cw.at, clk.c0 - @as(i32, config.PREFIX_W))
- else
- clk.c0;
- // A click INSIDE a live selection takes the WHOLE selection
- // and expands nothing: you already said what you meant, and
- // the file-ish word heuristic can only cut it down — a path
- // with a space in it, `git log -p`, half a URL. This is the
- // rule the keyboard chords follow (an explicit modal
- // selection wins over the word under the cursor), asked of
- // where the click landed rather than where the cursor is,
- // so a selection left lying in a pane never traps the
- // buttons: click OUTSIDE it and the ordinary word expansion
- // is back. A kept left sweep is screen-coordinate; the
- // modal one (v/x, n/N) is in absolute rows, hence the two
- // spellings of the same question.
- const ls = pane.sel[sel_slot];
- const held: ?[]const u8 = if (ls.state == .done and
- spanHas(clk.r0, clk.c0, ls.r0, ls.c0, ls.r1, ls.c1))
- (p.selectionText(pane, ls) catch null)
- else if (pane.vsel.active and pane.vsel.explicit)
- (if (spanHas(crow, ccol, pane.vsel.row, pane.vsel.col, pane.cur_row, pane.cur_col))
- p.currentSelText(pane)
- else
- null)
- else if (pane.msel.active and crow >= @min(pane.msel.r0, pane.msel.r1) and
- crow <= @max(pane.msel.r0, pane.msel.r1))
- p.currentSelText(pane)
- else
- null;
- if (held == null) p.expandSel(pane, &pane.sel[b]);
+ const operand = p.pointerOperand(pane, clk);
+ if (operand.expanded) |expanded|
+ pane.sel[b] = expanded
+ else if (operand.text == null)
+ pane.sel[b].state = .none;
// A look that names no file SEARCHES from where you are, so
// the click has to say where that is and the search walks
// forward from there. Not PINNED — a shell's cursor still
// belongs to the shell, and the search reads this in the
// same event.
- if (s.button == config.look_button and cvis >= 0 and pane.mode != .tty) {
- pane.cur_row = crow;
- pane.cur_col = ccol;
+ if (s.button == config.look_button and clk.r0 >= BOX_H and pane.mode != .tty) {
+ pane.cur_row = operand.row;
+ pane.cur_col = operand.col;
}
- const txt = held orelse (p.selectionText(pane, pane.sel[b]) catch null);
- const arg = p.chord_arg;
- p.chord_arg = null;
- defer if (arg) |a| p.gpa.free(a);
+ const txt = operand.text;
const cmd = if (s.button == config.look_button) config.look_cmd else config.exec_cmd;
- if (txt) |tx| p.runBuiltin(cmd, s.id, "", p.withArg(tx, arg));
+ p.dispatchPointerBuiltin(s.id, cmd, txt);
}
},
.tag => {}, // dragUpdate already left the tag cursor + selection set
@@ -11218,7 +10556,7 @@ pub const Pardes = struct {
// with Tagbottom the lower pane's FIRST — which is that row's height
// already, with nothing to add. Either way a no-drag click hands back
// exactly ra.h and nothing moves.
- var nt: f32 = @floatFromInt(if (p.tag_bottom) cur_y -| ra.y else (cur_y + 1) -| ra.y);
+ var nt: f32 = @floatFromInt(if (p.settings.tag_bottom) cur_y -| ra.y else (cur_y + 1) -| ra.y);
nt = std.math.clamp(nt, @as(f32, BOX_H), @max(@as(f32, BOX_H), combined - BOX_H));
const pair = a.vweight + b.vweight;
a.vweight = pair * (nt / combined);
@@ -11343,45 +10681,6 @@ pub const Pardes = struct {
} else p.normalPaste(pane, true); // terminal: splice run text at the cursor
}
- /// tty mode: encode the key the way a terminal would and send it to the pty.
- fn forwardKey(p: *Pardes, id: usize, key: Key) void {
- var cbuf: [1]u8 = undefined;
- const bytes: ?[]const u8 = blk: {
- if (key.ctrl) {
- if (key.cp >= 'a' and key.cp <= 'z') {
- cbuf[0] = @intCast(key.cp - 0x60);
- break :blk cbuf[0..1];
- }
- if (key.cp == '@') {
- cbuf[0] = 0;
- break :blk cbuf[0..1];
- }
- if (key.cp >= '[' and key.cp <= '_') {
- cbuf[0] = @intCast(key.cp - 0x40);
- break :blk cbuf[0..1];
- }
- if (key.cp >= 'A' and key.cp <= 'Z') {
- cbuf[0] = @intCast(key.cp - 0x40);
- break :blk cbuf[0..1];
- }
- }
- if (key.text.len > 0) break :blk key.text;
- break :blk switch (key.cp) {
- Key.enter => "\r",
- Key.backspace => "\x7f",
- Key.tab => "\t",
- Key.escape => "\x1b",
- Key.up => "\x1b[A",
- Key.down => "\x1b[B",
- Key.right => "\x1b[C",
- Key.left => "\x1b[D",
- Key.delete => "\x1b[3~",
- else => null,
- };
- };
- if (bytes) |b| p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(b) } });
- }
-
// ---- layout surgery ----
pub fn layoutFindTerm(p: *Pardes, id: usize) ?struct { col: usize, idx: usize } {
@@ -11409,7 +10708,7 @@ pub const Pardes = struct {
while (k + 1 < p.col_n[c]) : (k += 1) p.col_terms[c][k] = p.col_terms[c][k + 1];
p.col_n[c] -= 1;
if (p.col_n[c] == 0) {
- if (p.ncol > 1) p.col_weight[if (c > 0) c - 1 else c + 1] += p.col_weight[c];
+ if (p.ncol > 1) p.col_weight[if (c > 0) c - 1 else c + 1] +|= p.col_weight[c];
var j = c;
while (j + 1 < p.ncol) : (j += 1) {
p.col_terms[j] = p.col_terms[j + 1];
@@ -11420,25 +10719,68 @@ pub const Pardes = struct {
}
}
- pub fn layoutAppendColumn(p: *Pardes, id: usize) void {
- p.layoutInsertColumn(p.ncol, id);
+ /// Whether `source_id` can donate half its width to a new column. This is
+ /// public so callers that must create a pane first can reject before that
+ /// creation emits any native-side work.
+ pub fn layoutCanSplitColumn(p: *Pardes, source_id: usize) bool {
+ if (p.ncol >= MAX_COLS or source_id >= MAX_PANES or p.panes[source_id] == null) return false;
+ const source = p.layoutFindTerm(source_id) orelse return false;
+ // Refresh derived widths: public layout surgery may be chained between
+ // syncs, and a cached width must never admit a now-too-narrow split.
+ p.computeGeom();
+ if (p.col_w[source.col] < config.MINW * 2) return false;
+
+ const weight = p.col_weight[source.col];
+ if (weight >= 2 and weight % 2 == 0) return true;
+ for (0..p.ncol) |column| if (p.col_weight[column] > std.math.maxInt(u64) / 2)
+ return false;
+ return weight > 0;
}
- /// a fresh single-pane column at index `at` (the columns from there on
- /// shift right); an equal weight, so the others narrow proportionally
- fn layoutInsertColumn(p: *Pardes, at: usize, id: usize) void {
- if (p.ncol >= MAX_COLS) return;
- const c = @min(at, p.ncol);
+ /// Put `id` in a fresh column immediately beside `source_id`, taking the
+ /// new column's width only from the source column. `before` is used by a
+ /// first document, which belongs to the left of the shell that opened it;
+ /// Newcol and Alt-c put the new column on the right.
+ ///
+ /// `id == source_id` moves one pane out of a stack. Its vertical weight is
+ /// absorbed before removal, while the column's horizontal weight remains
+ /// in place to be split. In either form the total column weight is exactly
+ /// unchanged, so every unrelated column keeps both its width and its x.
+ pub fn layoutSplitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool {
+ if (id >= MAX_PANES or p.panes[id] == null) return false;
+ if (!p.layoutCanSplitColumn(source_id)) return false;
+ const source = p.layoutFindTerm(source_id) orelse return false;
+ const source_col = source.col;
+ var old_weight = p.col_weight[source_col];
+ const needs_rebase = old_weight < 2 or old_weight % 2 != 0;
+ if (needs_rebase) old_weight *= 2;
+ if (id == source_id) {
+ if (p.col_n[source_col] <= 1) return false;
+ p.absorbVWeight(id);
+ p.layoutRemove(id);
+ } else if (p.layoutFindTerm(id) != null) return false;
+
+ // A pathological hand-written dump can restore a positive proportion
+ // below one fixed-point quantum. Rebase every column together before
+ // splitting; ratios and therefore geometry remain identical.
+ if (needs_rebase) {
+ for (0..p.ncol) |column| p.col_weight[column] *= 2;
+ }
+ const source_weight = old_weight / 2;
+ const new_weight = old_weight - source_weight;
+ p.col_weight[source_col] = source_weight;
+ const c = source_col + @intFromBool(!before);
var j = p.ncol;
while (j > c) : (j -= 1) {
p.col_terms[j] = p.col_terms[j - 1];
p.col_n[j] = p.col_n[j - 1];
p.col_weight[j] = p.col_weight[j - 1];
}
- p.col_weight[c] = 1;
+ p.col_weight[c] = new_weight;
p.col_terms[c][0] = id;
p.col_n[c] = 1;
p.ncol += 1;
+ return true;
}
/// Snap every pane in column `c` to its on-screen row count so later
@@ -11526,15 +10868,6 @@ pub const Pardes = struct {
};
}
- /// a shell nobody has touched: no typing, cursor on the first prompt line,
- /// no scrollback — a throwaway placeholder a file may replace
- fn isPristineTty(pane: *Pane) bool {
- if (!pane.isTerminal()) return false;
- if (pane.ovl != null) return false;
- if (pane.vt.screens.active.cursor.y != 0) return false;
- return pane.vt.screens.active.pages.scrollbar().total <= pane.rows;
- }
-
/// when a doc lands in `col`, a lone pristine shell there is clutter — drop
/// it; absorbVWeight hands its space to the doc
fn evictLonePristineTty(p: *Pardes, col: usize, keep_id: usize) void {
@@ -11549,7 +10882,10 @@ pub const Pardes = struct {
}
if (n_tty != 1) return;
const tt = p.panes[tty_id] orelse return;
- if (!isPristineTty(tt)) return;
+ // No typing, cursor on the first prompt line, no scrollback: this is
+ // the throwaway boot placeholder a document may replace.
+ if (tt.ovl != null or tt.vt.screens.active.cursor.y != 0 or
+ tt.vt.screens.active.pages.scrollbar().total > tt.rows) return;
p.computeGeom(); // a just-stacked doc has no rect yet; absorb snaps to rows
p.absorbVWeight(tty_id);
p.layoutRemove(tty_id);
@@ -11596,7 +10932,6 @@ pub const Pardes = struct {
errdefer p.gpa.free(path);
const pane = try p.newDocPane(id);
pane.file = .{ .path = path, .content = content };
- pane.kind = .file;
pane.cur_pinned = true;
return pane;
}
@@ -11614,1091 +10949,12 @@ pub const Pardes = struct {
p.panes[0] = null;
const pane = try p.newDocPane(0);
pane.file = .{ .path = path, .content = copy };
- pane.kind = .file;
pane.cur_pinned = true;
p.active = 0;
p.sync();
return pane;
}
- fn openImageView(p: *Pardes, id: usize, path: []const u8) !*Pane {
- const path_copy = try p.image_gpa.dupe(u8, path);
- errdefer p.image_gpa.free(path_copy);
- const pane = try p.newDocPane(id);
- // Native pixels are the preference, not a capability observed at open
- // time. drawImage dynamically falls back while a shell has no native
- // image support; this matters because kitty detection finishes after
- // argv images and restored panes have already been constructed.
- pane.image = .{ .path = path_copy };
- pane.kind = .image;
- return pane;
- }
-
- fn openPdfView(p: *Pardes, id: usize, path: []const u8, page_one_based: usize) !*Pane {
- if (comptime !pdf_enabled) return error.PdfDisabled;
- var document = try pdf_impl.Document.open(path);
- errdefer document.deinit();
- const page_sizes = try p.pdf_gpa.alloc(pdf_impl.PageSize, document.pages);
- errdefer p.pdf_gpa.free(page_sizes);
- for (page_sizes, 0..) |*size, page_number|
- size.* = try document.pageSize(page_number);
- const page_starts = try p.pdf_gpa.alloc(u64, document.pages);
- errdefer p.pdf_gpa.free(page_starts);
- const page_heights = try p.pdf_gpa.alloc(u32, document.pages);
- errdefer p.pdf_gpa.free(page_heights);
- const path_copy = try p.pdf_gpa.dupe(u8, path);
- errdefer p.pdf_gpa.free(path_copy);
- const pane = try p.newDocPane(id);
- const page = if (page_one_based > 0)
- @min(page_one_based - 1, document.pages - 1)
- else
- 0;
- pane.pdf = .{
- .path = path_copy,
- .document = document,
- .page = page,
- .page_count = document.pages,
- .page_sizes = page_sizes,
- .page_starts = page_starts,
- .page_heights = page_heights,
- };
- pane.kind = .pdf;
- pane.cur_pinned = true;
- return pane;
- }
-
- /// Mark same-page pixels stale while retaining the one visible RGBA and
- /// its dimensions. drawPdf swaps in a replacement only after MuPDF and the
- /// allocation both succeed, so a failed highlight rerender never blanks a
- /// page which was already visible.
- fn invalidatePdfRaster(p: *Pardes, pv: *PdfView) void {
- if (comptime !pdf_enabled) return;
- _ = p;
- pv.render_tried = false;
- for (pv.rasters[0..pv.rasters_len]) |*raster| if (raster.valid and raster.page == pv.page) {
- raster.tried = false;
- break;
- };
- }
-
- /// Tint and theme changes affect every page currently carried to a native
- /// backend, including both sides of a continuous-strip page boundary.
- /// Retain their old buffers until each replacement succeeds.
- fn invalidateAllPdfRasters(p: *Pardes, pv: *PdfView) void {
- if (comptime !pdf_enabled) return;
- _ = p;
- pv.render_tried = false;
- for (pv.rasters[0..pv.rasters_len]) |*raster| {
- if (raster.valid) raster.tried = false;
- }
- }
-
- fn releasePdfRaster(p: *Pardes, raster: *PdfRaster) void {
- if (comptime !pdf_enabled) return;
- if (raster.rgba.len > 0) p.pdf_gpa.free(raster.rgba);
- raster.* = .{};
- }
-
- /// Park one page buffer in the relay instead of handing it to the
- /// allocator. A full relay means this frame retired more pages than any
- /// arrival can want, so those bytes do go back.
- fn retirePdfRgba(p: *Pardes, pv: *PdfView, rgba: []u8) void {
- if (comptime !pdf_enabled) return;
- if (rgba.len == 0) return;
- if (pv.spare_len == pv.spare.len) {
- p.pdf_gpa.free(rgba);
- return;
- }
- pv.spare[pv.spare_len] = rgba;
- pv.spare_len += 1;
- }
-
- /// Eviction path: the slot's pixels are no longer reachable, but its bytes
- /// are exactly what the page replacing it needs.
- fn retirePdfRaster(p: *Pardes, pv: *PdfView, raster: *PdfRaster) void {
- if (comptime !pdf_enabled) return;
- p.retirePdfRgba(pv, raster.rgba);
- raster.* = .{};
- }
-
- /// A buffer of exactly `bytes`, out of the relay when one fits, so a steady
- /// fling stops touching the allocator at all. Uniform page sizes are the
- /// overwhelming case, and an inexact match is worth nothing: the render
- /// needs this length precisely.
- fn claimPdfRgba(p: *Pardes, pv: *PdfView, bytes: usize) ?[]u8 {
- if (comptime !pdf_enabled) return null;
- for (pv.spare[0..pv.spare_len], 0..) |candidate, index| {
- if (candidate.len != bytes) continue;
- pv.spare_len -= 1;
- pv.spare[index] = pv.spare[pv.spare_len];
- return candidate;
- }
- return p.pdf_gpa.alloc(u8, bytes) catch null;
- }
-
- /// Close the relay at the end of a reconcile. One buffer survives, because
- /// the next frame of a fling opens by retiring one and asking for one; more
- /// than that would be idle megabytes pretending to be a cache.
- fn trimPdfSpares(p: *Pardes, pv: *PdfView) void {
- if (comptime !pdf_enabled) return;
- while (pv.spare_len > 1) {
- pv.spare_len -= 1;
- p.pdf_gpa.free(pv.spare[pv.spare_len]);
- }
- }
-
- fn dropPdfRaster(p: *Pardes, pv: *PdfView) void {
- if (comptime !pdf_enabled) return;
- for (pv.rasters[0..pv.rasters_len]) |*raster| p.releasePdfRaster(raster);
- pv.rasters_len = 0;
- pv.rgba = &.{};
- pv.iw = 0;
- pv.ih = 0;
- pv.render_tried = false;
- pv.render_request_valid = false;
- }
-
- fn dropPdfSearchResults(p: *Pardes, pv: *PdfView) void {
- if (comptime !pdf_enabled) return;
- if (pv.search_results) |*results| results.deinit(p.pdf_gpa);
- pv.search_results = null;
- }
-
- fn dropPdfSelection(p: *Pardes, pv: *PdfView) void {
- if (comptime !pdf_enabled) return;
- if (pv.selection) |*selection| selection.deinit(p.pdf_gpa);
- pv.selection = null;
- if (pv.selection_text.len > 0) p.pdf_gpa.free(pv.selection_text);
- pv.selection_text = &.{};
- pv.selection_anchor = null;
- pv.selection_head = null;
- }
-
- fn clearPdfDrag(pv: *PdfView) void {
- if (comptime !pdf_enabled) return;
- pv.drag_anchor = null;
- pv.drag_head = null;
- }
-
- fn clearPdfSelection(p: *Pardes, pane: *Pane) void {
- if (comptime !pdf_enabled) return;
- const pv = &(pane.pdf orelse return);
- const changed = pv.selection != null or pv.selection_text.len > 0;
- p.dropPdfSelection(pv);
- if (changed) p.invalidatePdfRaster(pv);
- }
-
- /// Drop semantic state tied to the active page. Plain cached pixels remain
- /// reusable if that page is still adjacent in the continuous viewport.
- fn invalidatePdfPage(p: *Pardes, pv: *PdfView) void {
- if (comptime !pdf_enabled) return;
- p.dropPdfSearchResults(pv);
- p.dropPdfSelection(pv);
- clearPdfDrag(pv);
- if (pv.text.len > 0) p.pdf_gpa.free(pv.text);
- pv.text = &.{};
- pv.text_tried = false;
- pv.text_scroll = 0;
- pv.highlights_dirty = pv.search_query.len > 0;
- pv.search_reveal_pending = pv.search_query.len > 0;
- pv.search_hit = 0;
- }
-
- /// Replace the full PDF query and discard only query-derived page state.
- /// Resolution is lazy in drawPdf; fit and pan remain placement-only state.
- fn setPdfSearchQuery(p: *Pardes, pv: *PdfView, query: []const u8) !void {
- if (comptime !pdf_enabled) return;
- if (std.mem.eql(u8, pv.search_query, query)) return;
- const owned = try p.pdf_gpa.dupe(u8, query);
- if (pv.search_query.len > 0) p.pdf_gpa.free(pv.search_query);
- pv.search_query = owned;
- pv.search_hit = 0;
- p.dropPdfSearchResults(pv);
- pv.highlights_dirty = query.len > 0;
- pv.search_reveal_pending = query.len > 0;
- p.invalidatePdfRaster(pv);
- }
-
- fn resolvePdfSearch(p: *Pardes, pv: *PdfView) void {
- if (comptime !pdf_enabled) return;
- if (!pv.highlights_dirty) return;
- pv.highlights_dirty = false;
- p.dropPdfSearchResults(pv);
- if (pv.search_query.len == 0) return;
- const results = pv.document.search(p.pdf_gpa, pv.page, pv.search_query) catch return;
- pv.search_hit = if (results.hit_count == 0)
- 0
- else
- @min(pv.search_hit, results.hit_count - 1);
- pv.search_results = results;
- }
-
- fn normalizedPdfPixel(value: f32, dimension: usize) u32 {
- const scaled = std.math.clamp(value, 0, 1) *
- @as(f32, @floatFromInt(dimension));
- return @intCast(@min(dimension - 1, @as(usize, @intFromFloat(scaled))));
- }
-
- fn rearmPdfRevealForViewport(p: *Pardes, pane: *Pane, pv: *PdfView) void {
- if (comptime !pdf_enabled) return;
- if (pv.search_query.len == 0) return;
- const viewport = p.pdfViewport(pane) orelse return;
- if (!pv.reveal_viewport_valid or
- pv.reveal_viewport_w != viewport.pixel_w or
- pv.reveal_viewport_h != viewport.pixel_h or
- pv.reveal_fit != pv.fit)
- pv.search_reveal_pending = true;
- }
-
- fn revealPdfSearch(p: *Pardes, pane: *Pane, pv: *PdfView) void {
- if (comptime !pdf_enabled) return;
- if (!pv.search_reveal_pending) return;
- const viewport = p.pdfViewport(pane) orelse return;
- pv.reveal_viewport_w = viewport.pixel_w;
- pv.reveal_viewport_h = viewport.pixel_h;
- pv.reveal_fit = pv.fit;
- pv.reveal_viewport_valid = true;
- const results = pv.search_results orelse {
- pv.search_reveal_pending = false;
- return;
- };
- if (results.hit_count == 0 or results.quads.len == 0) {
- pv.search_reveal_pending = false;
- return;
- }
- pv.search_hit = @min(pv.search_hit, results.hit_count - 1);
- const q = for (results.quads) |item| {
- if (item.hit == pv.search_hit) break item.quad;
- } else {
- pv.search_reveal_pending = false;
- return;
- };
- pv.search_reveal_pending = false;
- const center_x = (q.ul.x + q.ur.x + q.ll.x + q.lr.x) / 4;
- const center_y = (q.ul.y + q.ur.y + q.ll.y + q.lr.y) / 4;
- if (pv.fit == .width) {
- const laid_out = p.ensurePdfLayout(pane, pv) orelse return;
- const page_y = @as(f64, @floatFromInt(pv.page_starts[pv.page])) +
- @as(f64, center_y) * @as(f64, @floatFromInt(pv.page_heights[pv.page]));
- const wanted = page_y - @as(f64, @floatFromInt(laid_out.pixel_h)) / 2;
- const max_scroll = @as(f64, @floatFromInt(pv.document_height -| laid_out.pixel_h));
- pv.document_scroll_y = std.math.clamp(wanted, 0, max_scroll);
- return;
- }
- const geometry = p.pdfGeometry(pane) orelse return;
- const full: u32 = @intCast(pv.iw);
- const at = normalizedPdfPixel(center_x, pv.iw);
- if (at >= geometry.src.x and at < geometry.src.x + geometry.src.w) return;
- const overflow = full -| geometry.src.w;
- if (overflow == 0) return;
- const wanted = @min(overflow, at -| geometry.src.w / 2);
- pv.pan_x = @intCast(
- (@as(u64, wanted) * std.math.maxInt(u16) + overflow / 2) / overflow,
- );
- }
-
- fn activatePdfPage(p: *Pardes, pane: *Pane, page: usize, reveal: bool) void {
- if (comptime !pdf_enabled) return;
- const pv = &(pane.pdf orelse return);
- // Every ordinary page/navigation action supersedes an outline reveal.
- // The outline path calls this first and installs its destination after.
- pv.outline_reveal_pending = null;
- const next = @min(page, pv.page_count -| 1);
- if (next != pv.page) {
- p.invalidatePdfPage(pv);
- pv.page = next;
- pane.cur_row = 0;
- pane.cur_col = 0;
- pane.vsel.active = false;
- pane.msel.active = false;
- pane.nsel = 0;
- }
- // Input events can be queued behind a scroll in the same shell batch.
- // If the newly active adjacent page is already resident, publish its
- // dimensions immediately so the following click/pan cannot combine
- // its geometry with the prior page's aliases.
- if (pdfRasterForPage(pv, next)) |raster| syncPdfRasterAliases(pv, raster);
- if (reveal) {
- pv.scroll_to_page_pending = true;
- if (pv.layout_valid) {
- pv.document_scroll_y = @floatFromInt(pv.page_starts[next]);
- pv.scroll_to_page_pending = false;
- }
- } else {
- // Scrolling into another page must not turn a pending search into
- // a snap back to its center; manual document motion stays manual.
- pv.search_reveal_pending = false;
- }
- }
-
- /// Reveal one page in the continuous strip without reparsing the document.
- fn setPdfPage(p: *Pardes, pane: *Pane, page: usize) void {
- p.activatePdfPage(pane, page, true);
- }
-
- /// Builtin entry point. Public only because builtins.zig is the command
- /// registry; the PdfView representation remains private to this module.
- pub fn togglePdfFit(p: *Pardes, pane: *Pane) void {
- if (comptime !pdf_enabled) return;
- _ = p;
- const pv = &(pane.pdf orelse return);
- pv.fit = if (pv.fit == .width) .height else .width;
- pv.pan_x = 0;
- pv.pan_y = 0;
- pv.layout_valid = false;
- pv.scroll_to_page_pending = true;
- pv.search_reveal_pending = pv.search_query.len > 0;
- }
-
- /// Builtin entry point for the exact disabled -> filtered -> full ring.
- /// State belongs to one PDF pane; other document and terminal panes are
- /// deliberately inert.
- pub fn togglePdfTint(p: *Pardes, pane: *Pane) void {
- if (comptime !pdf_enabled) return;
- const pv = &(pane.pdf orelse return);
- pv.tint = pv.tint.next();
- p.invalidateAllPdfRasters(pv);
- }
-
- fn ensurePdfOutline(p: *Pardes, pv: *PdfView) ?*const pdf_impl.Outline {
- if (comptime !pdf_enabled) return null;
- if (!pv.outline_tried) {
- pv.outline_tried = true;
- pv.outline = pv.document.outline(p.pdf_gpa) catch null;
- }
- return if (pv.outline) |*outline| outline else null;
- }
-
- fn isPdfSectionsOutput(pane: *const Pane) bool {
- if (comptime !pdf_enabled) return false;
- const file = pane.file orelse return false;
- const output = file.output orelse return false;
- return switch (output.from) {
- .cmd => |builtin| builtin == .PdfSections,
- else => false,
- };
- }
-
- /// Re-arm a remembered, unedited +PdfSections pane without consulting
- /// MuPDF or rebuilding bytes which cannot have changed while its owning
- /// document remains open. Serial + generated revision are the two cache
- /// guards: a closed/reused slot or user edit falls through to a fresh
- /// materialisation below.
- fn rearmCleanPdfSections(p: *Pardes, id: usize, pane: *Pane, pv: *PdfView) bool {
- const remembered = pv.sections_output orelse return false;
- if (remembered.pane >= p.panes.len) return false;
- const result = p.panes[remembered.pane] orelse return false;
- if (result.serial != remembered.serial or !isPdfSectionsOutput(result)) return false;
- const file = &result.file.?;
- if (file.revision != remembered.revision) return false;
-
- file.scroll = 0;
- result.cur_row = 0;
- result.cur_col = 0;
- result.msel.active = false;
- result.vsel.active = false;
- result.nsel = 0;
- pane.search_pane = remembered.pane;
- pane.search_row = null;
- p.active = id;
- return true;
- }
-
- /// Lazily materialise this pane's cached MuPDF outline as a location list.
- /// A clean live output is refreshed in place; an edited, closed, or reused
- /// slot is never overwritten. Focus and n/N ownership stay on the PDF.
- pub fn openPdfSections(p: *Pardes, id: usize) void {
- if (comptime pdf_enabled) {
- const pane = p.panes[id] orelse return;
- const pv = &(pane.pdf orelse return);
- if (p.rearmCleanPdfSections(id, pane, pv)) return;
- const entries: []const pdf_impl.OutlineEntry = if (p.ensurePdfOutline(pv)) |outline|
- outline.entries
- else
- &.{};
- const content = PdfSectionRows.render(p.gpa, pv.path, entries) catch return;
-
- const free = p.freeSlot() orelse {
- p.gpa.free(content);
- return;
- };
- const dir = std.fs.path.dirname(pv.path) orelse "/";
- const result = output_pane.open(
- p,
- free,
- dir,
- .{ .cmd = .PdfSections },
- "",
- content,
- ) catch {
- p.gpa.free(content);
- return;
- };
- pv.sections_output = .{
- .pane = free,
- .serial = result.serial,
- .revision = result.file.?.revision,
- };
- p.placeDoc(id, free, result);
- p.computeGeom();
- p.active = id;
- pane.search_pane = free;
- pane.search_row = null;
- }
- }
-
- fn stepPdfPage(p: *Pardes, pane: *Pane, delta: i64) void {
- if (comptime !pdf_enabled) return;
- const pv = pane.pdf orelse return;
- const current: i64 = @intCast(pv.page);
- const last: i64 = @intCast(pv.page_count -| 1);
- p.setPdfPage(pane, @intCast(std.math.clamp(current + delta, 0, last)));
- }
-
- const PdfViewport = struct {
- pixel_w: u32,
- pixel_h: u32,
- };
- const PdfVisiblePages = struct { first: usize = 0, len: usize = 0 };
-
- const PdfPanAxis = enum { horizontal, vertical };
- const PdfPanResult = enum { moved, edge, unavailable };
-
- /// The image shells receive a cell rectangle while NativeGeometry works
- /// in pixels. Keep this conversion in core state so navigation and drawing
- /// agree even when the font metrics change at runtime.
- fn pdfViewport(p: *const Pardes, pane: *const Pane) ?PdfViewport {
- const r = for (p.panes, 0..) |slot, i| {
- if (slot == pane) break p.rects[i];
- } else return null;
- const cols = r.w -| config.GUTTER;
- const rows = r.h -| BOX_H;
- if (cols == 0 or rows == 0) return null;
- return .{
- .pixel_w = @as(u32, cols) * @as(u32, p.cell_pixels.w),
- .pixel_h = @as(u32, rows) * @as(u32, p.cell_pixels.h),
- };
- }
-
- fn pdfPageHeight(size: pdf_impl.PageSize, viewport: PdfViewport, fit: PdfFitMode) u32 {
- if (comptime !pdf_enabled) return 0;
- if (fit == .height) return viewport.pixel_h;
- const scaled = @as(f64, @floatFromInt(viewport.pixel_w)) *
- @as(f64, size.height) / @as(f64, size.width);
- return @max(1, @as(u32, @intFromFloat(@min(
- @as(f64, @floatFromInt(std.math.maxInt(u32))),
- @round(scaled),
- ))));
- }
-
- fn consumePdfOutlineReveal(pv: *PdfView, viewport: PdfViewport) void {
- if (comptime !pdf_enabled) return;
- const destination = pv.outline_reveal_pending orelse return;
- pv.outline_reveal_pending = null;
- const page = @min(destination.page, pv.page_count -| 1);
- const size = pv.page_sizes[page];
- const raw_y = destination.y orelse 0;
- const y = if (std.math.isFinite(raw_y))
- std.math.clamp(raw_y, 0, size.height)
- else
- 0;
- const y_fraction = @as(f64, y) / @as(f64, size.height);
- pv.document_scroll_y = @as(f64, @floatFromInt(pv.page_starts[page])) +
- y_fraction * @as(f64, @floatFromInt(pv.page_heights[page]));
-
- // Fit-width has no horizontal overflow. In fit-height, derive the
- // display-space crop from page geometry so x works before this page
- // has ever been rasterised and does not force a render merely to pan.
- if (destination.x) |raw_x| if (pv.fit == .height and std.math.isFinite(raw_x)) {
- const display_width = @as(f64, @floatFromInt(viewport.pixel_h)) *
- @as(f64, size.width) / @as(f64, size.height);
- const viewport_width: f64 = @floatFromInt(viewport.pixel_w);
- if (display_width > viewport_width) {
- const x = std.math.clamp(raw_x, 0, size.width);
- const target = @as(f64, x) / @as(f64, size.width) * display_width;
- const overflow = display_width - viewport_width;
- const wanted = std.math.clamp(target - viewport_width / 2, 0, overflow);
- pv.pan_x = @intFromFloat(@round(
- wanted / overflow * @as(f64, std.math.maxInt(u16)),
- ));
- }
- };
-
- // The outline, not an older search hit, owns this explicit reveal.
- // Record the current viewport so drawPdf's resize rearm agrees.
- pv.search_reveal_pending = false;
- pv.reveal_viewport_w = viewport.pixel_w;
- pv.reveal_viewport_h = viewport.pixel_h;
- pv.reveal_fit = pv.fit;
- pv.reveal_viewport_valid = true;
- }
-
- /// Rebuild only O(page-count) point-size layout metadata. No page pixels
- /// are touched, and ordinary scrolling never enters this path.
- fn ensurePdfLayout(p: *Pardes, pane: *Pane, pv: *PdfView) ?PdfViewport {
- if (comptime !pdf_enabled) return null;
- const tz = tracy.zone(@src(), "pdf.ensure_layout");
- defer tz.end();
- const viewport = p.pdfViewport(pane) orelse return null;
- // Pane-local geometry changes (divider drags, splits, closes) do not
- // pass through Event.resize. Capture against the still-valid OLD
- // layout here, immediately before replacing its page heights. A shell
- // resize has already invalidated the layout after capturing, while an
- // explicit page reveal owns scroll_to_page_pending; neither may be
- // captured a second time or overridden by this implicit anchor.
- if (pv.layout_valid and !pv.scroll_to_page_pending and
- !pv.layout_anchor_pending and
- (pv.layout_viewport_w != viewport.pixel_w or
- pv.layout_viewport_h != viewport.pixel_h))
- {
- capturePdfLayoutAnchor(pv);
- }
- if (!pv.layout_valid or pv.layout_viewport_w != viewport.pixel_w or
- pv.layout_viewport_h != viewport.pixel_h or pv.layout_fit != pv.fit)
- {
- var at: u64 = 0;
- for (pv.page_sizes, 0..) |size, page| {
- pv.page_starts[page] = at;
- const height = pdfPageHeight(size, viewport, pv.fit);
- pv.page_heights[page] = height;
- at = std.math.add(u64, at, height) catch std.math.maxInt(u64);
- if (page + 1 < pv.page_count)
- at = std.math.add(u64, at, PDF_PAGE_GAP_PX) catch std.math.maxInt(u64);
- }
- pv.document_height = at;
- pv.layout_viewport_w = viewport.pixel_w;
- pv.layout_viewport_h = viewport.pixel_h;
- pv.layout_fit = pv.fit;
- pv.layout_valid = true;
- if (pv.scroll_to_page_pending) {
- pv.document_scroll_y = @floatFromInt(pv.page_starts[pv.page]);
- pv.scroll_to_page_pending = false;
- pv.layout_anchor_pending = false;
- } else if (pv.layout_anchor_pending) {
- const anchor_page = @min(pv.layout_anchor_page, pv.page_count -| 1);
- pv.document_scroll_y = @as(f64, @floatFromInt(pv.page_starts[anchor_page])) +
- pv.layout_anchor_fraction * @as(f64, @floatFromInt(pv.page_heights[anchor_page]));
- pv.layout_anchor_pending = false;
- }
- }
- consumePdfOutlineReveal(pv, viewport);
- const max_scroll = @as(f64, @floatFromInt(pv.document_height -| viewport.pixel_h));
- pv.document_scroll_y = std.math.clamp(pv.document_scroll_y, 0, max_scroll);
- return viewport;
- }
-
- fn capturePdfLayoutAnchor(pv: *PdfView) void {
- if (comptime !pdf_enabled) return;
- if (!pv.layout_valid or pv.page_count == 0 or pv.document_height == 0) return;
- const page = pdfPageAtOffset(pv, pv.document_scroll_y);
- const start: f64 = @floatFromInt(pv.page_starts[page]);
- const height: f64 = @floatFromInt(@max(@as(u32, 1), pv.page_heights[page]));
- pv.layout_anchor_page = page;
- pv.layout_anchor_fraction = std.math.clamp((pv.document_scroll_y - start) / height, 0, 1);
- pv.layout_anchor_pending = true;
- }
-
- fn pdfPageAtOffset(pv: *const PdfView, offset: f64) usize {
- if (comptime !pdf_enabled) return 0;
- const y: u64 = @intFromFloat(std.math.clamp(
- @floor(offset),
- 0,
- @as(f64, @floatFromInt(pv.document_height -| 1)),
- ));
- var lo: usize = 0;
- var hi: usize = pv.page_count;
- while (lo + 1 < hi) {
- const mid = lo + (hi - lo) / 2;
- if (pv.page_starts[mid] <= y) lo = mid else hi = mid;
- }
- const end = pv.page_starts[lo] + pv.page_heights[lo];
- return if (y >= end and lo + 1 < pv.page_count) lo + 1 else lo;
- }
-
- fn pdfPageVisible(pv: *const PdfView, page: usize, viewport: PdfViewport) bool {
- if (comptime !pdf_enabled) return false;
- const top = @as(f64, @floatFromInt(pv.page_starts[page])) - pv.document_scroll_y;
- const bottom = top + @as(f64, @floatFromInt(pv.page_heights[page]));
- return bottom > 0 and top < @as(f64, @floatFromInt(viewport.pixel_h));
- }
-
- fn pdfVisiblePages(pv: *const PdfView, viewport: PdfViewport) PdfVisiblePages {
- var out: PdfVisiblePages = .{};
- var page = pdfPageAtOffset(pv, pv.document_scroll_y);
- if (page > 0 and pdfPageVisible(pv, page - 1, viewport)) page -= 1;
- out.first = page;
- while (page < pv.page_count) : (page += 1) {
- const top = @as(f64, @floatFromInt(pv.page_starts[page])) - pv.document_scroll_y;
- if (top >= @as(f64, @floatFromInt(viewport.pixel_h))) break;
- if (!pdfPageVisible(pv, page, viewport)) continue;
- out.len += 1;
- }
- return out;
- }
-
- fn pdfRenderRequest(p: *const Pardes, pane: *const Pane) ?pdf_impl.RenderRequest {
- if (comptime !pdf_enabled) return null;
- const viewport = p.pdfViewport(pane) orelse return null;
- return .{
- .dpi = pdf_raster_policy.dpi,
- .minimum_width = if (pdf_raster_policy.match_viewport) viewport.pixel_w else 0,
- .minimum_height = if (pdf_raster_policy.match_viewport) viewport.pixel_h else 0,
- .max_dimension = pdf_raster_policy.max_dimension,
- };
- }
-
- fn pdfTintColors(p: *const Pardes) pdf_impl.TintColors {
- if (comptime !pdf_enabled) return undefined;
- const th = p.theme();
- return .{
- .background = th.bg orelse th.tag_bg,
- .foreground = th.fg orelse th.tag_fg,
- };
- }
-
- fn pdfTintKey(p: *const Pardes, pv: *const PdfView) PdfTintKey {
- if (comptime !pdf_enabled) return undefined;
- return .{ .mode = pv.tint, .colors = p.pdfTintColors() };
- }
-
- const PdfPlacedGeometry = struct {
- geometry: image.NativeGeometry,
- pixel_offset_y: f32,
- };
-
- fn pdfRasterForPage(pv: *PdfView, page: usize) ?*PdfRaster {
- if (comptime !pdf_enabled) return null;
- for (pv.rasters[0..pv.rasters_len]) |*raster|
- if (raster.valid and raster.page == page) return raster;
- return null;
- }
-
- fn syncPdfRasterAliases(pv: *PdfView, raster: *const PdfRaster) void {
- if (comptime !pdf_enabled) return;
- pv.rgba = raster.rgba;
- pv.iw = raster.iw;
- pv.ih = raster.ih;
- pv.render_tried = raster.tried;
- pv.render_request = raster.request;
- pv.render_request_valid = raster.request_valid;
- pv.render_revision = raster.revision;
- }
-
- /// Which rows of a page's FULL raster the viewport shows, and where they
- /// land on screen. Split out of `pdfPlacedGeometry` because the reconcile
- /// needs the same answer BEFORE a raster exists — it is what decides how
- /// much of the page is worth rasterizing at all (see `pdfWantedBand`), and
- /// two copies of this arithmetic would be two chances to place a band one
- /// row away from the rows it rendered.
- const PdfVisibleRows = struct {
- base: image.NativeGeometry,
- /// rows [y0, y1) of the full page raster
- y0: u32,
- y1: u32,
- dst_y: u32,
- dst_h: u32,
- pixel_offset_y: f32,
- };
-
- fn pdfVisibleRows(
- pv: *const PdfView,
- viewport: PdfViewport,
- page: usize,
- iw: usize,
- ih: usize,
- ) ?PdfVisibleRows {
- if (comptime !pdf_enabled) return null;
- const page_h = pv.page_heights[page];
- const base = image.nativeGeometry(
- iw,
- ih,
- viewport.pixel_w,
- page_h,
- switch (pv.fit) {
- .width => .width,
- .height => .height,
- },
- pv.pan_x,
- 0,
- ) orelse return null;
- if (base.dst.h == 0 or base.src.h == 0) return null;
-
- const scroll_floor = @floor(pv.document_scroll_y);
- const fractional: f32 = @floatCast(pv.document_scroll_y - scroll_floor);
- const scroll_i: i64 = @intFromFloat(@min(
- scroll_floor,
- @as(f64, @floatFromInt(std.math.maxInt(i64))),
- ));
- const start_i: i64 = @intCast(@min(
- pv.page_starts[page],
- @as(u64, std.math.maxInt(i64)),
- ));
- const full_y = start_i - scroll_i + @as(i64, base.dst.y);
- const full_bottom = full_y + @as(i64, base.dst.h);
- const visible_y = @max(@as(i64, 0), full_y);
- const visible_bottom = @min(@as(i64, viewport.pixel_h), full_bottom);
- if (visible_bottom <= visible_y) return null;
-
- const rel_y0: u64 = @intCast(visible_y - full_y);
- const rel_y1: u64 = @intCast(visible_bottom - full_y);
- const src_y0: u32 = base.src.y + @as(u32, @intCast(
- rel_y0 * base.src.h / base.dst.h,
- ));
- const src_y1: u32 = base.src.y + @as(u32, @intCast(@min(
- @as(u64, base.src.h),
- (rel_y1 * base.src.h + base.dst.h - 1) / base.dst.h,
- )));
- if (src_y1 <= src_y0) return null;
- return .{
- .base = base,
- .y0 = src_y0,
- .y1 = src_y1,
- .dst_y = @intCast(visible_y),
- .dst_h = @intCast(visible_bottom - visible_y),
- .pixel_offset_y = -fractional,
- };
- }
-
- /// The same rows, expressed against the raster that actually exists: a
- /// band-rendered page holds only rows [band_y, band_y+band_h), so the
- /// source rectangle a backend samples is shifted by the band's origin.
- /// A raster that does not cover the visible rows places NOTHING rather
- /// than sampling rows it never rendered — the reconcile is what keeps that
- /// from happening, and this is the assertion that it did.
- fn pdfPlacedGeometry(
- pv: *const PdfView,
- raster: *const PdfRaster,
- viewport: PdfViewport,
- page: usize,
- ) ?PdfPlacedGeometry {
- if (comptime !pdf_enabled) return null;
- const rows = pdfVisibleRows(pv, viewport, page, raster.iw, raster.ih) orelse return null;
- const band_y: u32 = @intCast(raster.band_y);
- const band_end: u32 = @intCast(raster.band_y + raster.band_h);
- if (rows.y0 < band_y or rows.y1 > band_end) return null;
- return .{
- .geometry = .{
- .src = .{
- .x = rows.base.src.x,
- .y = rows.y0 - band_y,
- .w = rows.base.src.w,
- .h = rows.y1 - rows.y0,
- },
- .dst = .{
- .x = rows.base.dst.x,
- .y = rows.dst_y,
- .w = rows.base.dst.w,
- .h = rows.dst_h,
- },
- },
- .pixel_offset_y = rows.pixel_offset_y,
- };
- }
-
- fn pdfGeometry(p: *const Pardes, pane: *const Pane) ?image.NativeGeometry {
- if (comptime !pdf_enabled) return null;
- const mutable = @constCast(pane);
- const pv = if (mutable.pdf) |*view| view else return null;
- const viewport = p.pdfViewport(pane) orelse return null;
- const raster = pdfRasterForPage(pv, pv.page) orelse return null;
- const placed = pdfPlacedGeometry(pv, raster, viewport, pv.page) orelse return null;
- return placed.geometry;
- }
-
- fn pdfPageAtGridPoint(p: *const Pardes, pane: *const Pane, mrow: u16) ?usize {
- if (comptime !pdf_enabled) return null;
- const pv = pane.pdf orelse return null;
- if (!pv.layout_valid) return null;
- const r = for (p.panes, 0..) |slot, i| {
- if (slot == pane) break p.rects[i];
- } else return null;
- const body_y = if (p.tag_bottom) r.y else r.y + BOX_H;
- if (mrow < body_y or mrow >= body_y + (r.h -| BOX_H)) return null;
- const local_y = @as(f64, @floatFromInt(
- @as(u32, mrow - body_y) * p.cell_pixels.h + p.cell_pixels.h / 2,
- ));
- const page = pdfPageAtOffset(&pv, pv.document_scroll_y + local_y);
- const document_y = pv.document_scroll_y + local_y;
- const start: f64 = @floatFromInt(pv.page_starts[page]);
- if (document_y < start or
- document_y >= start + @as(f64, @floatFromInt(pv.page_heights[page]))) return null;
- return page;
- }
-
- fn pdfNativeReady(p: *const Pardes, pane: *const Pane) bool {
- return p.native_images and p.pdfGeometry(pane) != null;
- }
-
- /// Convert a grid mouse position to normalized page space through the
- /// exact crop/destination transform used by both native backends. Presses
- /// in a letterbox return null; a live drag clamps to the page edge so a
- /// sweep can finish naturally outside the image.
- fn pdfPointAt(
- p: *const Pardes,
- pane: *const Pane,
- mcol: u16,
- mrow: u16,
- clamp_to_page: bool,
- ) ?PdfPoint {
- if (comptime !pdf_enabled) return null;
- const pv = pane.pdf orelse return null;
- const geometry = p.pdfGeometry(pane) orelse return null;
- const r = for (p.panes, 0..) |slot, i| {
- if (slot == pane) break p.rects[i];
- } else return null;
- const body_x = @as(i64, r.x + config.GUTTER);
- const body_y = @as(i64, if (p.tag_bottom) r.y else r.y + BOX_H);
- var px = (@as(i64, mcol) - body_x) * p.cell_pixels.w + p.cell_pixels.w / 2;
- var py = (@as(i64, mrow) - body_y) * p.cell_pixels.h + p.cell_pixels.h / 2;
- if (clamp_to_page) {
- px = std.math.clamp(
- px,
- @as(i64, geometry.dst.x),
- @as(i64, geometry.dst.x + geometry.dst.w - 1),
- );
- py = std.math.clamp(
- py,
- @as(i64, geometry.dst.y),
- @as(i64, geometry.dst.y + geometry.dst.h - 1),
- );
- } else if (px < 0 or py < 0 or
- px > std.math.maxInt(u32) or py > std.math.maxInt(u32)) return null;
- const source = geometry.sourcePoint(@intCast(px), @intCast(py)) orelse return null;
- const fw: f32 = @floatFromInt(pv.iw);
- const fh: f32 = @floatFromInt(pv.ih);
- return .{
- .x = (@as(f32, @floatFromInt(source.x)) + 0.5) / fw,
- .y = (@as(f32, @floatFromInt(source.y)) + 0.5) / fh,
- };
- }
-
- const PdfSelectionUpdate = enum { failed, unchanged, changed };
-
- /// Transactionally replace the word-snapped selection and its owned text.
- /// Drags defer the expensive baked-highlight raster until release;
- /// snap-word execute/look presses may still request an immediate update.
- fn setPdfSelection(
- p: *Pardes,
- pane: *Pane,
- start: PdfPoint,
- end: PdfPoint,
- invalidate_raster: bool,
- ) PdfSelectionUpdate {
- if (comptime !pdf_enabled) return .failed;
- const pv = &(pane.pdf orelse return .failed);
- if (pv.selection != null and
- pv.selection_anchor != null and pv.selection_head != null and
- pv.selection_anchor.?.x == start.x and pv.selection_anchor.?.y == start.y and
- pv.selection_head.?.x == end.x and pv.selection_head.?.y == end.y) return .unchanged;
- var selection = pv.document.select(p.pdf_gpa, pv.page, start, end) catch return .failed;
- const text = pv.document.copySelection(
- p.pdf_gpa,
- pv.page,
- selection.start,
- selection.end,
- ) catch {
- selection.deinit(p.pdf_gpa);
- return .failed;
- };
-
- p.dropPdfSelection(pv);
- pv.selection = selection;
- pv.selection_text = text;
- pv.selection_anchor = start;
- pv.selection_head = end;
- if (invalidate_raster) p.invalidatePdfRaster(pv);
- return .changed;
- }
-
- fn beginPdfDrag(
- p: *Pardes,
- pane: *Pane,
- mcol: u16,
- mrow: u16,
- snap_word: bool,
- ) bool {
- if (comptime !pdf_enabled) return false;
- const hit_page = p.pdfPageAtGridPoint(pane, mrow) orelse return false;
- if (hit_page != pane.pdf.?.page) {
- p.activatePdfPage(pane, hit_page, false);
- if (pdfRasterForPage(&pane.pdf.?, hit_page)) |raster|
- syncPdfRasterAliases(&pane.pdf.?, raster);
- }
- const pv = &pane.pdf.?;
- clearPdfDrag(pv);
- const point = p.pdfPointAt(pane, mcol, mrow, false) orelse {
- return false;
- };
- pv.drag_anchor = point;
- pv.drag_head = point;
- if (!snap_word) return true;
- if (pv.selection) |selection| if (selection.contains(point)) {
- return true;
- };
- if (p.setPdfSelection(pane, point, point, true) == .failed) {
- // The old selection is intentionally transactional and survives,
- // but this outside click must not execute that stale text.
- clearPdfDrag(pv);
- return false;
- }
- return true;
- }
-
- fn beginPdfSelection(p: *Pardes, pane: *Pane, mcol: u16, mrow: u16) bool {
- return p.beginPdfDrag(pane, mcol, mrow, true);
- }
-
- fn updatePdfSelection(
- p: *Pardes,
- pane: *Pane,
- mcol: u16,
- mrow: u16,
- invalidate_raster: bool,
- ) bool {
- if (comptime !pdf_enabled) return false;
- const pv = &(pane.pdf orelse return false);
- const anchor = pv.drag_anchor orelse return false;
- const point = p.pdfPointAt(pane, mcol, mrow, true) orelse return false;
- if (pv.drag_head) |head| if (head.x == point.x and head.y == point.y) return false;
- const result = p.setPdfSelection(pane, anchor, point, invalidate_raster);
- if (result != .failed) {
- pv.drag_head = point;
- }
- return result == .changed;
- }
-
- fn pdfHighlights(p: *Pardes, pv: *PdfView) ![]const PdfHighlight {
- if (comptime !pdf_enabled) return &.{};
- const arena = p.scratch.allocator();
- const search_len = if (pv.search_results) |results| results.quads.len else 0;
- const selection_len = if (pv.selection) |selection| selection.quads.len else 0;
- const highlights = try arena.alloc(PdfHighlight, search_len + selection_len);
- var n: usize = 0;
- if (pv.search_results) |results| {
- for (results.quads) |item| {
- highlights[n] = pdf_impl.Highlight.init(item.quad, .{ 0xff, 0xd5, 0x4f, 0x70 }, .search);
- n += 1;
- }
- }
- if (pv.selection) |selection| {
- const sel = p.theme().sel_bg;
- for (selection.quads) |quad| {
- highlights[n] = pdf_impl.Highlight.init(quad, .{ sel[0], sel[1], sel[2], 0x78 }, .selection);
- n += 1;
- }
- }
- return highlights;
- }
-
- /// Move one normalized pan axis by a distance measured in DISPLAY pixels.
- /// NativeGeometry supplies both the displayed and cropped extents, so this
- /// remains exact for non-square cells and rounded page scaling.
- fn panPdfPixels(
- p: *Pardes,
- pane: *Pane,
- axis: PdfPanAxis,
- direction: i8,
- display_pixels: u32,
- ) PdfPanResult {
- if (comptime !pdf_enabled) return .unavailable;
- const geometry = p.pdfGeometry(pane) orelse return .unavailable;
- const pv = &(pane.pdf orelse return .unavailable);
- const source_full: u32 = @intCast(switch (axis) {
- .horizontal => pv.iw,
- .vertical => pv.ih,
- });
- const crop = switch (axis) {
- .horizontal => geometry.src.w,
- .vertical => geometry.src.h,
- };
- const source_at = switch (axis) {
- .horizontal => geometry.src.x,
- .vertical => geometry.src.y,
- };
- const displayed = @max(@as(u32, 1), switch (axis) {
- .horizontal => geometry.dst.w,
- .vertical => geometry.dst.h,
- });
- const overflow = source_full -| crop;
- if (overflow == 0 or
- (direction < 0 and source_at == 0) or
- (direction > 0 and source_at >= overflow)) return .edge;
-
- const source_step = @max(
- @as(u64, 1),
- (@as(u64, display_pixels) * @as(u64, crop) + displayed - 1) / displayed,
- );
- const normalized_step: u32 = @intCast(@min(
- @as(u64, std.math.maxInt(u16)),
- @max(
- @as(u64, 1),
- (source_step * std.math.maxInt(u16) + overflow - 1) / overflow,
- ),
- ));
- const position = switch (axis) {
- .horizontal => &pv.pan_x,
- .vertical => &pv.pan_y,
- };
- if (direction > 0) {
- position.* = @intCast(@min(
- @as(u32, std.math.maxInt(u16)),
- @as(u32, position.*) + normalized_step,
- ));
- } else {
- position.* -|= @intCast(normalized_step);
- }
- return .moved;
- }
-
- fn scaledPdfStep(base: u32, count: u32) u32 {
- return @intCast(@min(
- @as(u64, std.math.maxInt(u32)),
- @as(u64, base) * @max(@as(u64, 1), count),
- ));
- }
-
- /// Move through the strip by exact display pixels. Page changes update
- /// semantic search/selection state, but never evict unchanged page pixels.
- fn scrollPdfDocument(p: *Pardes, pane: *Pane, delta_pixels: f64) bool {
- if (comptime !pdf_enabled) return false;
- const tz = tracy.zone(@src(), "pdf.scroll_notch");
- defer tz.end();
- if (!std.math.isFinite(delta_pixels) or delta_pixels == 0) return false;
- const pv = &(pane.pdf orelse return false);
- const viewport = p.ensurePdfLayout(pane, pv) orelse return false;
- const max_scroll = @as(f64, @floatFromInt(pv.document_height -| viewport.pixel_h));
- const next = std.math.clamp(pv.document_scroll_y + delta_pixels, 0, max_scroll);
- if (next == pv.document_scroll_y) return false;
- // Travel this frame, for the fling test in drawPdf. Accumulated rather
- // than compared against the last position because a wheel batch arrives
- // as a hundred separate notches and it is their SUM inside one frame
- // that says how fast the reader is going.
- pv.scroll_travel += @abs(next - pv.document_scroll_y);
- pv.document_scroll_y = next;
- const active_page = pdfPageAtOffset(pv, next);
- if (active_page != pv.page) p.activatePdfPage(pane, active_page, false);
- return true;
- }
-
- fn pdfVerticalWheel(p: *Pardes, pane: *Pane, direction: i8) void {
- if (comptime !pdf_enabled) return;
- if (!p.native_images) {
- p.stepPdfPage(pane, direction);
- return;
- }
- const rows: u32 = @intCast(@max(1, config.wheel_rows));
- const pixels = scaledPdfStep(p.cell_pixels.h, rows);
- _ = p.scrollPdfDocument(pane, @as(f64, @floatFromInt(pixels)) * direction);
- }
-
- fn pdfHorizontalWheel(p: *Pardes, pane: *Pane, direction: i8) void {
- if (comptime !pdf_enabled) return;
- const pv = pane.pdf orelse return;
- if (!p.native_images or pv.fit != .height) return;
- const cols: u32 = @intCast(@max(1, config.wheel_cols));
- _ = p.panPdfPixels(pane, .horizontal, direction, scaledPdfStep(p.cell_pixels.w, cols));
- }
-
fn handlePdfNormal(p: *Pardes, pane: *Pane, key: Key) void {
if (comptime !pdf_enabled) return;
if (pane.pdf == null) return;
@@ -12707,111 +10963,36 @@ pub const Pardes = struct {
putPaneNormalState(pane, state);
switch (parsed) {
.pending, .ignored, .unbound => {},
- .action => |semantic| p.executePdfNormalAction(pane, semantic),
- }
- }
-
- /// PDF vertical actions use exact display pixels in the continuous native
- /// strip. The cell, half-view and full-view bases are multiplied by the
- /// parsed count. A non-native shell has no pixel strip and deliberately
- /// falls back to counted page steps for all three.
- fn movePdfVertical(p: *Pardes, pane: *Pane, direction: i8, kind: normal_input.Page, count: u32) void {
- if (comptime !pdf_enabled) return;
- if (!p.native_images) {
- const pages: i64 = @intCast(@max(@as(u32, 1), count));
- p.stepPdfPage(pane, if (direction > 0) pages else -pages);
- return;
- }
- const viewport = p.pdfViewport(pane);
- const base: u32 = switch (kind) {
- .half_down, .half_up => if (viewport) |v| @max(@as(u32, 1), v.pixel_h / 2) else p.cell_pixels.h,
- .down, .up => if (viewport) |v| v.pixel_h else p.cell_pixels.h,
- };
- const pixels = scaledPdfStep(base, count);
- _ = p.scrollPdfDocument(pane, @as(f64, @floatFromInt(pixels)) * direction);
- }
-
- fn movePdfRows(p: *Pardes, pane: *Pane, direction: i8, count: u32) void {
- if (comptime !pdf_enabled) return;
- if (!p.native_images) {
- const pages: i64 = @intCast(@max(@as(u32, 1), count));
- return p.stepPdfPage(pane, if (direction > 0) pages else -pages);
- }
- const pixels = scaledPdfStep(p.cell_pixels.h, count);
- _ = p.scrollPdfDocument(pane, @as(f64, @floatFromInt(pixels)) * direction);
- }
-
- fn pdfHorizontalEdge(p: *Pardes, pane: *Pane, end: bool) void {
- if (comptime !pdf_enabled) return;
- if (!p.native_images) return;
- const geometry = p.pdfGeometry(pane) orelse return;
- const pv = &(pane.pdf orelse return);
- const full: u32 = @intCast(pv.iw);
- if (full <= geometry.src.w) return;
- pv.pan_x = if (end) std.math.maxInt(u16) else 0;
- }
-
- /// Deliberately incomplete PDF adapter. Unsupported edit/selection/text
- /// actions have already consumed their parser state and are safe no-ops.
- fn executePdfNormalAction(p: *Pardes, pane: *Pane, semantic: normal_input.Action) void {
- if (comptime !pdf_enabled) return;
- const pv = &(pane.pdf orelse return);
- switch (semantic) {
- .escape => {
- pane.select = false;
- return p.runBuiltin(.Last, p.active, "", null);
- },
- .move => |move| switch (move.motion) {
- .down => p.movePdfRows(pane, 1, move.count),
- .up => p.movePdfRows(pane, -1, move.count),
- .left => if (p.native_images) {
- _ = p.panPdfPixels(pane, .horizontal, -1, scaledPdfStep(p.cell_pixels.w, move.count));
- },
- .right => if (p.native_images) {
- _ = p.panPdfPixels(pane, .horizontal, 1, scaledPdfStep(p.cell_pixels.w, move.count));
- },
- else => {},
- },
- .goto => |go| switch (go.target) {
- .file_start => p.setPdfPage(pane, if (go.explicit_count) go.count -| 1 else 0),
- .last_line => p.setPdfPage(pane, pv.page_count -| 1),
- .line_start, .first_nonws => p.pdfHorizontalEdge(pane, false),
- .line_end => p.pdfHorizontalEdge(pane, true),
- .line_down => p.movePdfRows(pane, 1, go.count),
- .line_up => p.movePdfRows(pane, -1, go.count),
- else => {},
- },
- .line => |line| switch (line) {
- .start, .first_nonws => p.pdfHorizontalEdge(pane, false),
- .end => p.pdfHorizontalEdge(pane, true),
- },
- .goto_line => |go| if (go.explicit) p.setPdfPage(pane, go.count -| 1),
- .page => |page| switch (page.kind) {
- .half_down, .down => p.movePdfVertical(pane, 1, page.kind, page.count),
- .half_up, .up => p.movePdfVertical(pane, -1, page.kind, page.count),
- },
- .view => |view| switch (view) {
- .scroll_down => p.movePdfRows(pane, 1, 1),
- .scroll_up => p.movePdfRows(pane, -1, 1),
- else => {},
- },
- .leader => {
- p.leader_on = true;
- p.leader_n = 0;
- },
- .command_line => {
- p.enterTagEdit(pane, -1);
- if (pane.tag_edit) pane.mode = .normal;
+ .action => |semantic| {
+ const result = pdf_pane.applyNormal(
+ &pane.pdf.?,
+ p.pdf_gpa,
+ semantic,
+ p.native_images,
+ .{ .w = p.cell_pixels.w, .h = p.cell_pixels.h },
+ pdf_pane.paneViewport(p, pane),
+ pdf_pane.paneGeometry(p, pane),
+ );
+ if (result.page_changed) pdf_pane.resetPageChrome(pane);
+ switch (result.host) {
+ .none => {},
+ .last => {
+ pane.select = false;
+ p.runBuiltin(.Last, p.active, "", null);
+ },
+ .leader => {
+ p.leader_on = true;
+ p.leader_n = 0;
+ },
+ .command_line => {
+ p.enterTagEdit(pane, -1);
+ if (pane.tag_edit) pane.mode = .normal;
+ },
+ .search => p.startSearch(pane, config.search_marker),
+ .search_forward => p.lookWalk(1),
+ .search_backward => p.lookWalk(-1),
+ }
},
- .search => p.startSearch(pane, config.search_marker),
- // A PDF is not a special case: it steps the same ring every other
- // pane does, which in practice is the +Search or +PdfSections
- // buffer its own search filled. n selects the row, Enter jumps to
- // the page.
- .search_step => |direction| return p.lookWalk(
- if (direction == .forward) @as(i32, 1) else -1,
- ),
- else => {},
}
}
@@ -12847,15 +11028,8 @@ pub const Pardes = struct {
p.active = id;
if (hasPdf(pane)) {
if (comptime pdf_enabled) {
- if (at.line > 0) p.setPdfPage(pane, at.line - 1);
const pv = &pane.pdf.?;
- // PDF search result rows use Spot.col as a one-based MuPDF
- // logical hit id, not a text column. Rearming reveal moves
- // only fit/pan; the page raster already contains every hit.
- if (at.col > 0 and pv.search_query.len > 0) {
- pv.search_hit = at.col - 1;
- pv.search_reveal_pending = true;
- }
+ if (pv.focusLocation(p.pdf_gpa, at.line, at.col)) pdf_pane.resetPageChrome(pane);
}
return;
}
@@ -12927,74 +11101,6 @@ pub const Pardes = struct {
return false;
}
- fn revealPdfOutlineDestination(
- p: *Pardes,
- pane: *Pane,
- destination: pdf_impl.OutlineInternalDestination,
- ) void {
- if (comptime !pdf_enabled) return;
- p.activatePdfPage(pane, destination.page, false);
- const pv = &pane.pdf.?;
- pv.scroll_to_page_pending = false;
- pv.layout_anchor_pending = false;
- pv.outline_reveal_pending = destination;
- // Consume immediately when geometry already exists; otherwise drawPdf
- // consumes the same pending value after the next layout transaction.
- _ = p.ensurePdfLayout(pane, pv);
- }
-
- /// PdfSections alone gives Spot.col a second meaning: the one-based DFS
- /// ordinal written into its generated leading token. It is checked before
- /// ordinary PDF focus so search-result columns everywhere else retain
- /// their existing logical-hit meaning.
- fn lookPdfSection(p: *Pardes, output_id: usize, path: []const u8, at: look.Spot) bool {
- if (comptime !pdf_enabled) return false;
- const output = p.panes[output_id] orelse return false;
- if (!isPdfSectionsOutput(output)) return false;
- if (at.line == 0 or at.col == 0 or at.end_line != 0 or
- !look.isPdfPath(path)) return false;
-
- // Internal section rows use the basename so their titles remain
- // visible. Resolve that token from +PdfSections' document directory
- // before matching an open PDF or opening it again.
- var joined_path: [4096]u8 = undefined;
- const output_dir = paneDir(output);
- const separator = if (std.mem.endsWith(u8, output_dir, "/")) "" else "/";
- const target_path = if (std.fs.path.isAbsolute(path))
- path
- else
- std.fmt.bufPrint(&joined_path, "{s}{s}{s}", .{ output_dir, separator, path }) catch return true;
-
- var target_id: ?usize = null;
- for (p.panes, 0..) |slot, i| {
- const candidate = slot orelse continue;
- if (candidate.pdf) |pv| if (std.mem.eql(u8, pv.path, target_path)) {
- target_id = i;
- break;
- };
- }
- if (target_id == null) {
- const free = p.freeSlot() orelse return true;
- const fresh = p.openPdfView(free, target_path, 0) catch return true;
- p.placeDoc(output_id, free, fresh);
- p.computeGeom();
- target_id = free;
- }
-
- const id = target_id.?;
- const pane = p.panes[id] orelse return true;
- const pv = &pane.pdf.?;
- const outline = p.ensurePdfOutline(pv) orelse return true;
- const destination = PdfSectionRows.resolve(outline.entries, at.col - 1) orelse return true;
- switch (destination) {
- .internal => |internal| p.revealPdfOutlineDestination(pane, internal),
- .external => |uri| if (uri.len <= 256) p.emit(.{ .open_link = .from(uri) }),
- .none => unreachable,
- }
- p.active = id;
- return true;
- }
-
/// LOOK — the Look builtin's body, and so what a right click, an Enter and
/// the word `Look` all end at. Resolve `txt` against the panes' directories
/// and open (or focus) whatever it names; a word that names nothing is a
@@ -13024,7 +11130,7 @@ pub const Pardes = struct {
// canonical (realpath'd or dump-given), so match the word as-is
// here and joined onto each directory below.
const pl = look.parsePathLine(trimmed);
- if (comptime pdf_enabled) if (p.lookPdfSection(id, pl.path, pl.at)) return;
+ if (comptime pdf_enabled) if (pdf_pane.lookSection(p, id, pl.path, pl.at)) return;
if (pl.path.len > 0 and p.focusPaneByPath(pl.path, pl.at)) return;
// The word is resolved against the pane DIRECTORIES in access
// order: the pane the click came from FIRST — its answer is the
@@ -13121,7 +11227,7 @@ pub const Pardes = struct {
if (comptime pdf_enabled) if (target.kind == .pdf) {
if (p.focusPaneByPath(target.path, target.at)) return;
const free = p.freeSlot() orelse return;
- const nt = p.openPdfView(free, target.path, target.at.line) catch return;
+ const nt = pdf_pane.openPane(p, free, target.path, target.at.line) catch return;
p.placeDoc(id, free, nt);
return;
};
@@ -13150,7 +11256,7 @@ pub const Pardes = struct {
.image => |target| {
if (p.focusPaneByPath(target.path, .{})) return;
const free = p.freeSlot() orelse return;
- const nt = p.openImageView(free, target.path) catch return;
+ const nt = image_pane.create(p, free, target.path, &.{}) catch return;
p.placeDoc(id, free, nt);
},
}
@@ -13194,6 +11300,10 @@ pub const Pardes = struct {
// image's no-PTY pane slot, and never type a command line at vim.
const dst = (if (p.takesCommandLine(id)) id else p.ttyForDir(paneDir(pane))) orelse return null;
term_pane.padOutputBelowEdits(p, dst);
+ if (term_pane.queuePendingCommand(p.panes[dst].?, cmd) catch |err| {
+ p.reportError(id, "queue command", err);
+ return dst;
+ }) return dst;
p.emitWrite(dst, cmd);
p.emitWrite(dst, "\r");
return dst;
@@ -13224,7 +11334,7 @@ pub const Pardes = struct {
const sp = std.mem.indexOfAny(u8, cmd, " \t");
const bi: ?Builtin = std.meta.stringToEnum(Builtin, cmd) orelse blk: {
const head = std.meta.stringToEnum(Builtin, cmd[0 .. sp orelse break :blk null]) orelse break :blk null;
- break :blk if (takesArg(head)) head else null;
+ break :blk if (builtins.registry.takesArg(head)) head else null;
};
const b = bi orelse return false;
p.exec_depth += 1;
@@ -13234,23 +11344,6 @@ pub const Pardes = struct {
return true;
}
- /// Which builtins match their name with a TAIL — `Restore <path>`,
- /// `Grep <pat>`, `Theme <name>`, and the two verbs, `Look <word>` /
- /// `Exec <cmd>`, which is what makes `` @`Look .` `` nest. Every other
- /// name must match WHOLE, so `Kill foo` is a shell command and not Kill.
- ///
- /// Asked in two places and so written in one. The second asker is the
- /// topbar's execute, and it is why this is a function: a topbar click runs
- /// the word under it WITH whatever happens to be selected (select a word,
- /// click Grep, it greps that word), and a builtin that takes no argument
- /// was getting one anyway — `Kill` with a stale sweep somewhere became
- /// `Kill <word>`, which is not Kill, so it fell through to the shell and
- /// ran as a command nobody typed. A selection is only an argument to
- /// something that has somewhere to put it.
- fn takesArg(b: Builtin) bool {
- return builtins.registry.takesArg(b);
- }
-
fn applyStartupConfig(p: *Pardes) void {
var lines = std.mem.splitScalar(u8, p.opts.startup_config orelse return, '\n');
while (lines.next()) |line| _ = p.executeBuiltinLine(p.active, line);
@@ -13271,7 +11364,45 @@ pub const Pardes = struct {
if (!p.multiOnce()) return; // a builtin is per-keystroke, never per-cursor
const pane = p.panes[id] orelse return;
const c: builtins.Ctx = .{ .p = p, .pane = pane, .id = id, .txt = txt, .arg = arg };
- inline for (comptime builtins.registry.all(), 0..) |B, i| if (@intFromEnum(b) == i) return B.run(c);
+ builtins.registry.dispatch(b, c);
+ }
+
+ /// Generated setting builtins converge here. The descriptor is compile-
+ /// time data but the state is ordinary owned data; no vtable or callback
+ /// layer sits between the command enum and these fields.
+ pub fn applySettingBuiltin(p: *Pardes, setting: runtime_cfg.Setting, arg: ?[]const u8) void {
+ const previous_transition = p.settings.panel_transition;
+ const previous_tagline_percent = p.settings.font.tagline_percent;
+ switch (setting.action) {
+ .theme => {
+ const want = std.mem.trim(u8, arg orelse return, " \t\r\n");
+ for (themes, 0..) |t, i| if (std.mem.eql(u8, t.name, want)) {
+ p.setThemeIndex(i);
+ return;
+ };
+ },
+ .font => {
+ if (comptime !font_picker) return;
+ const want = std.mem.trim(u8, arg orelse return, " \t\r\n");
+ const matches = fonts.list(p.scratch.allocator(), want);
+ if (matches.len == 0) return;
+ if (!runtime_cfg.requestFont(&p.settings, matches[0].path, matches[0].name)) return;
+ p.font_request_taken = false;
+ },
+ else => _ = runtime_cfg.applySimple(&p.settings, setting, arg),
+ }
+ const transition_changed = setting.action == .transition and
+ p.settings.panel_transition != previous_transition;
+ const tagline_metrics_changed = setting.action == .tagline_size and
+ p.settings.font.tagline_percent != previous_tagline_percent;
+ if (transition_changed or tagline_metrics_changed) {
+ // The backend may still show the last acknowledged sample. Keep
+ // input inert until it has replaced that sample with canonical
+ // pixels instead of merely forgetting the producer-side tracks.
+ // A tagline-size change also invalidates the raster metrics used
+ // by a frozen old frame; snapping is the only honest old/new pair.
+ p.abandonPanelAnimations();
+ }
}
/// place a fresh doc pane. An OUTPUT buffer (+Search/+Help) is NOT a
@@ -13283,9 +11414,9 @@ pub const Pardes = struct {
/// source if it IS one, else the one most recently worked in) and the
/// newcomer lands right below it, so docs share a column. The FIRST doc of
/// the session instead gets a column of its own on the left (acme: files
- /// left, shells right) — nothing is displaced, the other columns just
- /// narrow. A full column bar, or a result list whose source died, stacks
- /// into the leftmost.
+ /// left, shells right). Only the calling/source column donates half its
+ /// width; every other column keeps its boundary. A full column bar, or a
+ /// result list whose source died, stacks into the leftmost.
pub fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void {
const doc = if (nt.file) |f| output_pane.fileTraits(f.output).doc else true; // an image is a doc
var src_id: ?usize = null;
@@ -13348,7 +11479,7 @@ pub const Pardes = struct {
return;
}
if (doc and p.ncol < MAX_COLS) {
- p.layoutInsertColumn(0, free);
+ if (!p.layoutSplitColumn(from_id, free, true)) return p.stackDocLeft(free, nt);
p.active = free;
return;
}
@@ -13371,117 +11502,28 @@ pub const Pardes = struct {
// BY POINTER: `origin_arg` is a slice into the File's own inline
// buffer, and a by-value capture would leave it pointing at a
// stack copy that dies before the ZON is written.
- const dp: dump.Pane = if (pane.file) |*f| .{
- .kind = .file,
- .tag = tag,
- .body = body,
- .scroll = scroll,
- .cols = pane.cols,
- .rows = pane.rows,
- .vweight = pane.vweight,
- .file = .{
- .path = f.path,
- .content = f.content,
- .content_b64 = try dump.encodeBytes(arena, f.content),
- .origin = if (f.output) |o| output_pane.word(o.from) else "",
- .origin_arg = if (f.output) |*o| o.arg() else "",
- },
- } else if (hasPdf(pane)) blk: {
+ var dp: dump.Pane = if (pane.file) |*f| try file_pane.dumpPane(
+ arena,
+ pane,
+ f,
+ tag,
+ body,
+ scroll,
+ if (f.output) |o| output_pane.word(o.from) else "",
+ if (f.output) |*o| o.arg() else "",
+ ) else if (hasPdf(pane)) blk: {
if (comptime !pdf_enabled) unreachable;
const pv = &pane.pdf.?;
- const bytes = look.readFile(arena, pv.path) catch "";
// Keep the dump schema backwards-compatible: a raster-backed
// document rides the existing binary image record, while its
// `scroll` field is the zero-based PDF page. Restore inspects
// the extension and reconstructs the semantic PDF pane.
- break :blk .{
- .kind = .image,
- .tag = tag,
- .body = body,
- .scroll = pv.page,
- .cols = pane.cols,
- .rows = pane.rows,
- .vweight = pane.vweight,
- .image = .{
- .path = pv.path,
- .bytes_b64 = if (bytes.len > 0) try dump.encodeBytes(arena, bytes) else "",
- },
- };
- } else if (pane.image) |iv| blk: {
- const bytes = look.readFile(arena, iv.path) catch "";
- break :blk .{
- .kind = .image,
- .tag = tag,
- .body = body,
- .scroll = scroll,
- .cols = pane.cols,
- .rows = pane.rows,
- .vweight = pane.vweight,
- .image = .{
- .path = iv.path,
- .bytes_b64 = if (bytes.len > 0) try dump.encodeBytes(arena, bytes) else "",
- },
- };
- } else blk: {
- // the stream text: whole history, prompts hidden outside tty
- // mode, the edit buffer standing in for the rows it covers
- const full = try pane.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} });
- const extra = if (pane.ovl) |o| o.text.len else 0;
- const stream_buf = try arena.alloc(u8, full.len + extra);
- var stream_len: usize = 0;
- var lines = std.mem.splitAny(u8, full, "\n");
- var prompts = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null);
- var row: i32 = 0;
- var skip: i32 = 0;
- while (lines.next()) |raw| : (row += 1) {
- // stepped for EVERY line, before the skip below, or the
- // row iterator falls out of step with the dump's lines
- const prompt_pin = if (pane.mode != .tty) prompts.next() else null;
- if (skip > 0) {
- skip -= 1;
- continue;
- }
- if (row > 0) {
- stream_buf[stream_len] = '\n';
- stream_len += 1;
- }
- if (pane.mode != .tty) if (pane.ovl) |o| if (row == o.row) {
- @memcpy(stream_buf[stream_len..][0..o.text.len], o.text);
- stream_len += o.text.len;
- skip = o.rows - 1;
- continue;
- };
- // same rule the motion surface and the body use, so a dump
- // reloads showing exactly what the pane showed
- const shown = if (prompt_pin) |pin|
- if (pin.rowAndCell().row.semantic_prompt != .none)
- term_pane.promptRow(pin, raw)
- else
- raw
- else
- raw;
- @memcpy(stream_buf[stream_len..][0..shown.len], shown);
- stream_len += shown.len;
- }
- break :blk .{
- .kind = .terminal,
- .tag = tag,
- .body = body,
- .scroll = scroll,
- .cols = pane.cols,
- .rows = pane.rows,
- .vweight = pane.vweight,
- .terminal = .{
- .cwd = try arena.dupe(u8, pane.cwdSlice()),
- .stream = stream_buf[0..stream_len],
- .stream_b64 = try dump.encodeBytes(arena, try pane.ttyReplayBytes(arena)),
- .cursor = .{
- .col = pane.vt.screens.active.cursor.x,
- .row = pane.vt.screens.active.cursor.y,
- },
- },
- };
- };
+ break :blk try image_pane.dumpPane(arena, pane, tag, body, pv.page, pv.path, &.{});
+ } else if (pane.image) |iv|
+ try image_pane.dumpPane(arena, pane, tag, body, scroll, iv.path, iv.raw)
+ else
+ try term_pane.dumpPane(pane, arena, tag, body, scroll);
+ dp.tag_tail = if (pane.tag_init) pane.tagSlice() else null;
panes[panes_len] = dp;
panes_len += 1;
}
@@ -13496,7 +11538,10 @@ pub const Pardes = struct {
ids_len += 1;
};
if (ids_len > 0) {
- columns[columns_len] = .{ .weight = p.col_weight[c], .panes = column_ids[c][0..ids_len] };
+ columns[columns_len] = .{
+ .weight = @as(f32, @floatFromInt(p.col_weight[c])) / @as(f32, @floatFromInt(column_weight_unit)),
+ .panes = column_ids[c][0..ids_len],
+ };
columns_len += 1;
}
}
@@ -13541,87 +11586,36 @@ pub const Pardes = struct {
defer dump.free(gpa, st);
for (themes, 0..) |t, i| {
- if (std.mem.eql(u8, t.name, st.theme)) p.theme_idx = i;
+ if (std.mem.eql(u8, t.name, st.theme)) p.settings.theme = @intCast(i);
}
for (st.panes, 0..) |src, i| {
if (i >= MAX_PANES) break;
- switch (src.kind) {
- .terminal => {
+ const pane: *Pane = switch (src.kind) {
+ .terminal => terminal: {
const t = src.terminal.?;
- const pane = try gpa.create(Pane);
- pane.* = .{
- .vt = try ghostty_vt.Terminal.init(std.Io.Threaded.global_single_threaded.io(), gpa, .{
- .cols = @max(1, src.cols),
- .rows = @max(1, src.rows),
- .max_scrollback = 16 * 1024 * 1024,
- }),
- .stream = undefined,
- .gpa = gpa,
- .cols = @max(1, src.cols),
- .rows = @max(1, src.rows),
- };
- pane.stream = pane.vt.vtStream();
- pane.stream.handler.effects.write_pty = term_pane.ptyReport;
- pane.stream.handler.effects.device_attributes = term_pane.ptyDeviceAttrs;
- p.panes[i] = pane;
- if (t.stream_b64.len > 0) {
- const bytes = try dump.decodeBytes(p.scratch.allocator(), t.stream_b64);
- pane.appendTtyReplay(bytes);
- pane.stream.nextSlice(bytes);
- pane.vt.screens.active.scroll(.active);
- if (src.scroll > 0) pane.vt.screens.active.scroll(.{ .delta_row = -@as(isize, @intCast(src.scroll)) });
- }
+ const restored = try term_pane.restore(p, src);
+ p.installPane(i, restored);
p.setCwd(i, t.cwd);
- if (std.mem.startsWith(u8, src.tag, "TTY ")) pane.mode = .tty;
- p.restoreTail(pane, src.tag);
- },
- .file => {
- const f = src.file.?;
- const content: []u8 = if (f.content_b64.len > 0)
- try dump.decodeBytes(gpa, f.content_b64)
- else
- try gpa.dupe(u8, f.content);
- errdefer gpa.free(content);
- const path = try gpa.dupe(u8, f.path);
- errdefer gpa.free(path);
- const pane = try p.newDocPane(i);
- // an unknown origin word is a dump from another build, not
- // a reason to refuse the load: it comes back as a file
- const out: ?output_pane.Output = if (output_pane.fromWord(f.origin)) |o| blk: {
- var ov: output_pane.Output = .{ .from = o };
- output_pane.setArg(&ov, f.origin_arg);
- break :blk ov;
- } else null;
- pane.file = .{ .path = path, .content = content, .output = out, .scroll = src.scroll };
- pane.kind = .file;
- pane.cur_pinned = true;
- pane.cur_row = @intCast(src.scroll);
- pane.cols = @max(1, src.cols);
- pane.rows = @max(1, src.rows);
- // a restored file pane is watched like an opened one. Note
- // its content is the DUMP's, which may already differ from
- // disk; the shell stamps the watch from what it has, so the
- // first external write reconciles the pane to disk and the
- // dump's version lands one `u` away — which is the same
- // bargain as any other external update.
- if (out == null) p.emit(.{ .watch = .{ .pane = @intCast(i), .on = true } });
- p.restoreTail(pane, src.tag);
+ if (std.mem.startsWith(u8, src.tag, "TTY ")) restored.mode = .tty;
+ break :terminal restored;
},
+ .file => try file_pane.restore(p, i, src),
.image => restore_image: {
const im = src.image.?;
- const raw_gpa = if (pdf_enabled and look.isPdfPath(im.path)) gpa else p.image_gpa;
- var raw: []u8 = if (im.bytes_b64.len > 0)
- dump.decodeBytes(raw_gpa, im.bytes_b64) catch &.{}
- else
- &.{};
- errdefer if (raw.len > 0) raw_gpa.free(raw);
- if (comptime pdf_enabled) if (look.isPdfPath(im.path)) {
- if (p.openPdfView(i, im.path, src.scroll + 1) catch null) |pane| {
- if (raw.len > 0) gpa.free(raw);
- raw = &.{};
- pane.cols = @max(1, src.cols);
- pane.rows = @max(1, src.rows);
- break :restore_image;
+ if (pdf_pane.isPath(im.path)) {
+ var raw: []u8 = if (im.bytes_b64.len > 0)
+ try dump.decodeBytes(gpa, im.bytes_b64)
+ else
+ &.{};
+ errdefer if (raw.len > 0) gpa.free(raw);
+ if (comptime pdf_enabled) {
+ if (pdf_pane.openPane(p, i, im.path, src.scroll + 1) catch null) |restored| {
+ if (raw.len > 0) gpa.free(raw);
+ raw = &.{};
+ restored.cols = @max(1, src.cols);
+ restored.rows = @max(1, src.rows);
+ break :restore_image restored;
+ }
}
// If the source path is unavailable, retain its bytes
// as an ordinary file rather than dropping the pane.
@@ -13630,30 +11624,25 @@ pub const Pardes = struct {
const content = if (raw.len > 0) raw else try gpa.dupe(u8, "");
raw = &.{};
errdefer if (content.len > 0) gpa.free(content);
- const pane = try p.newDocPane(i);
- pane.file = .{ .path = path, .content = content };
- pane.kind = .file;
- pane.cur_pinned = true;
- pane.cols = @max(1, src.cols);
- pane.rows = @max(1, src.rows);
+ const restored = try p.newDocPane(i);
+ restored.file = .{ .path = path, .content = content };
+ restored.cur_pinned = true;
+ restored.cols = @max(1, src.cols);
+ restored.rows = @max(1, src.rows);
p.emit(.{ .watch = .{ .pane = @intCast(i), .on = true } });
- break :restore_image;
- };
- const path = try p.image_gpa.dupe(u8, im.path);
- errdefer p.image_gpa.free(path);
- const pane = try p.newDocPane(i);
- pane.image = .{ .path = path, .raw = raw };
- raw = &.{};
- pane.kind = .image;
- pane.cols = @max(1, src.cols);
- pane.rows = @max(1, src.rows);
+ break :restore_image restored;
+ }
+ break :restore_image try image_pane.restore(p, i, src);
},
- }
- p.panes[i].?.vweight = src.vweight;
+ };
+ p.restoreDumpTail(pane, src);
+ pane.vweight = src.vweight;
}
p.ncol = @min(st.columns.len, MAX_COLS);
for (st.columns[0..p.ncol], 0..) |col, c| {
- p.col_weight[c] = col.weight;
+ const scaled = @as(f64, @floatCast(col.weight)) * @as(f64, @floatFromInt(column_weight_unit));
+ const bounded = @min(scaled, @as(f64, @floatFromInt(max_column_weight)));
+ p.col_weight[c] = @max(1, @as(u64, @intFromFloat(@round(bounded))));
p.col_n[c] = @min(col.panes.len, MAX_PANES);
for (col.panes[0..p.col_n[c]], 0..) |pid, k| p.col_terms[c][k] = pid;
}
@@ -13662,30 +11651,68 @@ pub const Pardes = struct {
p.applyStartupConfig();
p.finishThemeInitialization();
p.sync();
+ p.panel_animation_enabled = true;
_ = p.takeHaptic(); // see init: a restored session is not a gesture
return p;
}
- /// Restore a custom tag tail from a saved tag: whatever follows the live
- /// prefix, unless it is this build's default or either exact legacy
- /// default from before New appeared in every pane. Legacy defaults upgrade
- /// to the live default; genuinely edited tails remain byte-for-byte owned.
+ fn restoreDumpTail(p: *Pardes, pane: *Pane, src: dump.Pane) void {
+ if (src.tag_tail) |tail| {
+ if (!pane.appendTag(tail)) return;
+ pane.tag_init = true;
+ return;
+ }
+ const tail_class: TailClass = if (src.kind == .file)
+ tailClass(pane)
+ else
+ .generic;
+ const current = p.tagPrefix(pane) catch return;
+ if (std.mem.startsWith(u8, src.tag, current))
+ return p.restoreTailAt(pane, src.tag, current, tail_class);
+ if (src.kind != .image) return;
+ const saved = src.image.?;
+ if (pane.image) |*state| if (image_pane.legacySavedPrefix(state, src.tag)) |legacy|
+ return p.restoreTailAt(pane, src.tag, legacy, .generic);
+ if (pdf_pane.isPath(saved.path)) if (pdf_pane.legacySavedPrefix(saved.path, src.tag)) |legacy|
+ p.restoreTailAt(pane, src.tag, legacy, .generic);
+ }
+
+ /// Compatibility path for dumps without explicit tag_tail: recover what
+ /// followed the rendered prefix, upgrading every historical default while
+ /// retaining genuinely edited bytes.
fn restoreTail(p: *Pardes, pane: *Pane, saved_tag: []const u8) void {
const pfx = p.tagPrefix(pane) catch return;
+ p.restoreTailAt(pane, saved_tag, pfx, tailClass(pane));
+ }
+
+ const TailClass = enum { generic, savable_file };
+
+ fn tailClass(pane: *const Pane) TailClass {
+ if (pane.file) |f| if (output_pane.fileTraits(f.output).saves) return .savable_file;
+ return .generic;
+ }
+
+ fn restoreTailAt(
+ _: *Pardes,
+ pane: *Pane,
+ saved_tag: []const u8,
+ pfx: []const u8,
+ class: TailClass,
+ ) void {
if (!std.mem.startsWith(u8, saved_tag, pfx)) return;
const rest = saved_tag[pfx.len..];
- const legacy = if (pane.file) |f|
- if (output_pane.fileTraits(f.output).saves) legacy_file_pane_tail else legacy_pane_tail
- else
- legacy_pane_tail;
// A saved tag carries its layout gap, because the padding is real
// characters — and the pane it is restored into is very often a
// different width than the one it was dumped from. So compare what the
// tail SAYS and not where it sat: leading spaces are layout, never
// content, and a default that came back padded is still a default.
const said = std.mem.trimStart(u8, rest, " ");
- if (std.mem.eql(u8, said, std.mem.trimStart(u8, curTail(pane), " ")) or
- std.mem.eql(u8, said, std.mem.trimStart(u8, legacy, " "))) return;
+ const defaults = switch (class) {
+ .generic => [2][]const u8{ pane_tail, legacy_pane_tail },
+ .savable_file => [2][]const u8{ file_pane_tail, legacy_file_pane_tail },
+ };
+ for (defaults) |default|
+ if (std.mem.eql(u8, said, std.mem.trimStart(u8, default, " "))) return;
if (!pane.appendTag(rest)) return;
pane.tag_init = true;
}
@@ -13832,6 +11859,7 @@ pub const Pardes = struct {
/// deferred greetings. The mirror of the prototype's loop epilogue.
fn sync(p: *Pardes) void {
p.computeGeom();
+ p.syncPanelAnimations();
p.trackJump();
for (&p.panes, 0..) |*slot, id| {
const pane = slot.* orelse continue;
@@ -13861,12 +11889,11 @@ pub const Pardes = struct {
pane.cols = cols;
pane.rows = rows;
}
- // greet a fresh shell with `ls` once it has its real size (after
- // the first winsize) AND has drawn its first prompt (the pty has
- // produced output — bash sourced the rc and readline owns echo, so
- // the greeting echoes on an OSC 133-marked row and hides cleanly;
- // earlier, the kernel would echo it onto an unmarked row).
- if (pane.greet and pane.isTerminal() and p.resize_count > 0 and pane.tty_stream_len > 0) {
+ term_pane.releasePendingCommandIfReady(p, id, pane);
+ // Greet only after the real size AND OSC 133 B: arbitrary startup
+ // output (or OSC A plus a prompt drawn in pieces) does not prove
+ // readline owns echo, and injecting there leaves `ls` unmarked.
+ if (pane.greet and pane.isTerminal() and p.resize_count > 0 and term_pane.promptInputReady(pane)) {
p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = pane.cols, .rows = pane.rows } });
p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from("ls\r") } });
pane.greet = false;
@@ -13874,18 +11901,250 @@ pub const Pardes = struct {
}
}
- fn computeGeom(p: *Pardes) void {
+ fn panelBox(rect: Rect) panel_animation.Box {
+ return .{
+ .x = @floatFromInt(rect.x),
+ .y = @floatFromInt(rect.y),
+ .w = @floatFromInt(rect.w),
+ .h = @floatFromInt(rect.h),
+ };
+ }
+
+ /// Turn one committed layout into backend-neutral transition records.
+ /// Layout remains authoritative and takes effect immediately; these tracks
+ /// are presentation data only, so disabling an effect cannot strand stale
+ /// geometry or alter hit testing.
+ fn syncPanelAnimations(p: *Pardes) void {
+ const initializing = !p.layout_snapshot_ready or !p.panel_animation_enabled;
+ if (initializing or p.snap_panel_layout_once) {
+ const had_presented_layout = p.layout_snapshot_ready;
+ p.layout_snapshot = @splat(null);
+ p.panel_tracks = @splat(null);
+ p.nclosing_panel_tracks = 0;
+ p.panel_diff_pending = false;
+ p.panel_diff_ready = false;
+ // Resize/direct-manipulation snaps still wait for their backend
+ // presentation acknowledgement. Clearing here would make input
+ // follow canonical geometry while the last submitted pixels were
+ // still animated. Initialization has no prior frame to preserve.
+ if (initializing) {
+ p.presented_panel_tracks = @splat(null);
+ p.npresented_closing_panel_tracks = 0;
+ }
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ p.layout_snapshot[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) };
+ }
+ p.layout_snapshot_ready = true;
+ p.snap_panel_layout_once = false;
+ if (had_presented_layout and p.panel_presentation_ready)
+ p.panel_presentation_pending = true;
+ return;
+ }
+
+ const effect = p.settings.panel_transition;
+ if (effect.needsPreviousGrid() and (p.panel_diff_pending or p.panel_diff_ready)) {
+ var second_change = false;
+ for (p.panes, p.layout_snapshot, 0..) |slot, snapshot, id| {
+ const pane = slot orelse {
+ second_change = second_change or snapshot != null;
+ continue;
+ };
+ const target = panelBox(p.rects[id]);
+ second_change = second_change or snapshot == null or
+ snapshot.?.serial != pane.serial or !snapshot.?.box.eql(target);
+ }
+ if (second_change) {
+ // One frozen old grid cannot honestly describe two overlapping
+ // generations. Snap rapid layout churn instead of rewinding a
+ // new opener or sliding stale survivor cells as a tombstone.
+ p.abandonPanelAnimations();
+ p.layout_snapshot = @splat(null);
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ p.layout_snapshot[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) };
+ }
+ return;
+ }
+ }
+ var changed = false;
+ var animated_change = false;
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse {
+ if (p.layout_snapshot[id]) |old| {
+ changed = true;
+ if (effect.lifecycleOnly()) {
+ if (p.appendClosingPanelTrack(.{
+ .serial = old.serial,
+ .pane = @intCast(id),
+ .phase = .closing,
+ .effect = effect,
+ .from = old.box,
+ .to = panel_animation.closingBox(effect, old.box),
+ })) animated_change = true;
+ }
+ }
+ p.layout_snapshot[id] = null;
+ p.panel_tracks[id] = null;
+ // Never let pixels from a dead pane address a reused slot.
+ p.presented_panel_tracks[id] = null;
+ continue;
+ };
+ const target = panelBox(p.rects[id]);
+ const previous = p.layout_snapshot[id];
+ p.layout_snapshot[id] = .{ .serial = pane.serial, .box = target };
+
+ if (effect == .off) {
+ changed = changed or previous == null or previous.?.serial != pane.serial or
+ !previous.?.box.eql(target);
+ p.panel_tracks[id] = null;
+ continue;
+ }
+ if (previous) |old| {
+ if (old.serial == pane.serial and old.box.eql(target)) continue;
+ const same_lifetime = old.serial == pane.serial;
+ const prior = if (p.panel_tracks[id]) |track|
+ if (track.serial == pane.serial and track.active()) track else null
+ else
+ null;
+ if (effect.lifecycleOnly() and same_lifetime) {
+ // A vertical lifecycle transition deliberately leaves
+ // every survivor at canonical geometry. A still-opening
+ // lifetime remains an opener when another layout commit
+ // retargets it; an established survivor gets no record.
+ if (prior) |active| if (active.phase == .opening) {
+ var next = active;
+ next.from = panel_animation.openingBox(effect, target, p.screen_w);
+ next.to = target;
+ p.panel_tracks[id] = next;
+ changed = true;
+ animated_change = true;
+ continue;
+ };
+ p.panel_tracks[id] = null;
+ changed = true;
+ continue;
+ }
+ if (effect.lifecycleOnly() and !same_lifetime) {
+ _ = p.appendClosingPanelTrack(.{
+ .serial = old.serial,
+ .pane = @intCast(id),
+ .phase = .closing,
+ .effect = effect,
+ .from = old.box,
+ .to = panel_animation.closingBox(effect, old.box),
+ });
+ }
+ const shown = if (p.presented_panel_layout[id]) |snapshot|
+ if (snapshot.serial == pane.serial) snapshot.box else null
+ else
+ null;
+ const from = if (!same_lifetime)
+ panel_animation.openingBox(effect, target, p.screen_w)
+ else if (shown) |box|
+ box
+ else if (p.panel_presentation_ready and prior != null)
+ prior.?.from
+ else
+ old.box;
+ const next: panel_animation.Track = .{
+ .serial = pane.serial,
+ .pane = @intCast(id),
+ .phase = if (!same_lifetime or
+ (prior != null and prior.?.phase == .opening)) .opening else .moving,
+ .effect = effect,
+ .from = from,
+ .to = target,
+ };
+ p.panel_tracks[id] = next;
+ changed = true;
+ animated_change = true;
+ } else {
+ const next: panel_animation.Track = .{
+ .serial = pane.serial,
+ .pane = @intCast(id),
+ .phase = .opening,
+ .effect = effect,
+ .from = panel_animation.openingBox(effect, target, p.screen_w),
+ .to = target,
+ };
+ p.panel_tracks[id] = next;
+ changed = true;
+ animated_change = true;
+ }
+ }
+ if (animated_change and effect.needsPreviousGrid()) {
+ p.panel_diff_pending = true;
+ p.panel_diff_ready = false;
+ }
+ if (changed and p.panel_presentation_ready) p.panel_presentation_pending = true;
+ }
+
+ fn appendClosingPanelTrack(p: *Pardes, track: panel_animation.Track) bool {
+ std.debug.assert(track.phase == .closing);
+ const baseline = p.presented_cells_layout[track.pane] orelse return false;
+ if (!p.presented_cells_valid or baseline.serial != track.serial or
+ !baseline.box.eql(track.from)) return false;
+ if (p.nclosing_panel_tracks == p.closing_panel_tracks.len) {
+ // Bounded presentation history: under pathological delete/reuse
+ // churn, retire the oldest (and therefore furthest-progressed)
+ // tombstone rather than retaining a pane or allocating per Del.
+ std.mem.copyForwards(
+ panel_animation.Track,
+ p.closing_panel_tracks[0 .. p.closing_panel_tracks.len - 1],
+ p.closing_panel_tracks[1..],
+ );
+ p.nclosing_panel_tracks -= 1;
+ }
+ p.closing_panel_tracks[p.nclosing_panel_tracks] = track;
+ p.nclosing_panel_tracks += 1;
+ return true;
+ }
+
+ fn advancePanelAnimations(p: *Pardes) void {
+ for (&p.panel_tracks) |*slot| {
+ const track = if (slot.*) |*track| track else continue;
+ track.frame +|= 1;
+ if (!track.active()) slot.* = null;
+ }
+ var out: usize = 0;
+ for (p.closing_panel_tracks[0..p.nclosing_panel_tracks]) |value| {
+ var track = value;
+ track.frame +|= 1;
+ if (!track.active()) continue;
+ p.closing_panel_tracks[out] = track;
+ out += 1;
+ }
+ p.nclosing_panel_tracks = out;
+ }
+
+ fn columnBoundary(width: u16, prefix: u128, total: u128) u16 {
+ if (total == 0) return 0;
+ const pixels = (@as(u128, width) * prefix + total / 2) / total;
+ return @intCast(@min(@as(u128, width), pixels));
+ }
+
+ pub fn computeGeom(p: *Pardes) void {
if (p.ncol == 0) return;
- var wsum: f32 = 0;
+ var wsum: u128 = 0;
for (0..p.ncol) |c| wsum += p.col_weight[c];
- if (wsum <= 0) wsum = 1;
+ if (wsum == 0) wsum = 1;
- var x: u16 = 0;
+ // Round cumulative boundaries, not each width independently. Splitting
+ // one weight W into A+B=W then leaves every boundary before A and
+ // after B bit-identical, at every screen width; independent rounding
+ // can move a later column by one cell even though its own weight and
+ // the total did not change.
+ var prefix: u128 = 0;
for (0..p.ncol) |c| {
const last = c + 1 == p.ncol;
- const fw = @as(f32, @floatFromInt(p.screen_w)) * p.col_weight[c] / wsum;
- const wroom = p.screen_w -| x; // same clamp as the rows below
- const cw: u16 = if (last) wroom else @min(wroom, @max(1, @as(u16, @intFromFloat(@round(fw)))));
+ const x = columnBoundary(p.screen_w, prefix, wsum);
+ prefix += p.col_weight[c];
+ const end: u16 = if (last)
+ p.screen_w
+ else
+ columnBoundary(p.screen_w, prefix, wsum);
+ const cw = end -| x;
p.col_x[c] = x;
p.col_w[c] = cw;
@@ -13913,12 +12172,11 @@ pub const Pardes = struct {
p.rects[id] = .{ .x = x, .y = y, .w = cw, .h = ch };
y +|= ch;
}
- x +|= cw;
}
}
pub fn theme(p: *const Pardes) *const Theme {
- return &themes[p.theme_idx];
+ return &themes[p.settings.theme];
}
/// Displayed colors for anchored UI chrome. Unlike `theme()`, this may be
@@ -13927,8 +12185,16 @@ pub const Pardes = struct {
return &p.chrome_animation.displayed;
}
- pub fn themeAnimationActive(p: *const Pardes) bool {
- return p.chrome_animation.isActive();
+ /// Whether an idle frontend should schedule another tick. All frame-based
+ /// 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;
+ const scene = p.settings.scene_effects;
+ if (scene.crt or scene.ripple or scene.glitch) return true;
+ for (p.panel_tracks) |track| if (track != null and track.?.active()) return true;
+ for (p.closing_panel_tracks[0..p.nclosing_panel_tracks]) |track|
+ if (track.active()) return true;
+ return false;
}
/// Arm the pulse. Look wins a tie because a Look that runs a command
@@ -13958,17 +12224,17 @@ pub const Pardes = struct {
/// PDFs whose pixels depend on target theme colors are marked stale, once;
/// untinted source rasters remain byte-for-byte resident.
pub fn setThemeIndex(p: *Pardes, index: usize) void {
- if (index >= themes.len or index == p.theme_idx) return;
+ if (index >= themes.len or index == @as(usize, p.settings.theme)) return;
const target_chrome = ChromeTheme.fromTheme(&themes[index]);
if (p.animate_theme_changes)
p.chrome_animation.retarget(target_chrome)
else
p.chrome_animation.snap(target_chrome);
- p.theme_idx = index;
+ p.settings.theme = @intCast(index);
if (comptime pdf_enabled) for (p.panes) |slot| {
const pane = slot orelse continue;
const pv = &(pane.pdf orelse continue);
- if (pv.tint != .disabled) p.invalidateAllPdfRasters(pv);
+ if (pv.tint != .disabled) pv.invalidateAllRasters();
};
}
@@ -13986,6 +12252,9 @@ pub const Pardes = struct {
s.rows = p.screen_h;
s.cursor = null;
s.nimages = 0;
+ s.npanel_tracks = 0;
+ s.previous_cells = &.{};
+ s.changed_cells = &.{};
const chrome = p.chromeTheme();
const th = p.theme(); // the message row paints in the editor's colours
// gaps between panes read as chrome (scrollbar track), not raw default
@@ -13993,9 +12262,8 @@ pub const Pardes = struct {
for (&p.panes, 0..) |*slot, id| {
const pane = slot.* orelse continue;
- try p.renderPane(arena, pane, p.rects[id], id == p.active);
+ try p.renderPane(arena, pane, p.rects[id], id, id == p.active);
}
-
// ---- the transient message row: the pane's last body row ----
//
// An OVERLAY, not geometry: no rect moves, no pane shrinks, and a pane
@@ -14050,7 +12318,7 @@ pub const Pardes = struct {
// far end, symmetric with the tag — which put the prompt you are
// typing as far as the pane allows from the tag you are typing
// into. Beside the tagline is where it is read, so it stays there.
- const row = if (p.tag_bottom) r.y + r.h - 1 - BOX_H else r.y + r.h - 1;
+ const row = if (p.settings.tag_bottom) r.y + r.h - 1 - BOX_H else r.y + r.h - 1;
// In the EDITOR's colours, not the tag bar's: this row is the one
// place the program talks back to you about the buffer you are in,
// and it reads as part of that buffer rather than as another strip
@@ -14097,9 +12365,19 @@ pub const Pardes = struct {
}
// global tagbar: full width, top row
- s.fill(0, 0, s.cols, TOPBAR_H, .{ .bg = .{ .rgb = chrome.tag_bg } });
+ s.fill(0, 0, s.cols, TOPBAR_H, .{
+ .bg = .{ .rgb = chrome.tag_bg },
+ // The blank tail is the same visible band as the printed words.
+ // SDL used to repair this at draw time by special-casing row zero,
+ // but native hosts consume the role carried by each cell.
+ .font_role = .tagline,
+ });
var tb_buf: [1200]u8 = undefined;
- _ = s.print(0, 0, s.cols, p.topbar(&tb_buf), .{ .fg = .{ .rgb = chrome.tag_fg }, .bg = .{ .rgb = chrome.tag_bg } });
+ _ = s.print(0, 0, s.cols, p.topbar(&tb_buf), .{
+ .fg = .{ .rgb = chrome.tag_fg },
+ .bg = .{ .rgb = chrome.tag_bg },
+ .font_role = .tagline,
+ });
// the topbar's cursor, if it has the keyboard. AFTER the pane loop on
// purpose: there is exactly one Surface cursor and the bar's must beat
// the active pane's. Always a block — the bar has no insert mode.
@@ -14144,7 +12422,7 @@ pub const Pardes = struct {
s.set(d.cur_x, placement.row, "▌", .{ .fg = .{ .rgb = chrome.lineno } });
}
},
- .none, .select, .tag => {
+ .none, .select, .tag => if (p.pointer_inside) {
// hover hints on a pane's own trailing edge
var c: usize = 0;
while (c + 1 < p.ncol) : (c += 1) {
@@ -14178,7 +12456,7 @@ pub const Pardes = struct {
}
// debug overlay: a small stats box drawn last, top-right ("Debug")
- if (p.show_debug) if (p.panes[p.active]) |at| {
+ if (p.settings.debug) if (p.panes[p.active]) |at| {
var nlive: usize = 0;
for (p.panes) |slot| {
if (slot != null) nlive += 1;
@@ -14200,12 +12478,13 @@ pub const Pardes = struct {
// it was asked for; nothing has asked when this is empty and the
// shell is still in whatever it booted in. Clamped because a name
// is a file stem and a path component can be as long as a path.
- const font_name = if (fonts.chosen.len == 0) "default" else fonts.chosen;
+ const effective_font = p.settings.font.effective_name.get();
+ const font_name = if (effective_font.len == 0) "default" else effective_font;
var ov_buf: [256]u8 = undefined;
const text = std.fmt.bufPrint(
&ov_buf,
"pardes resizes={d}\ntheme {s}\nfont {s}\nterms {d} cols {d}\nactive #{d} {d}x{d}\nscroll {d}/{d}\npinch {d:6.4}\ntscroll {d:7.4}",
- .{ p.resize_count, themes[p.theme_idx].name, font_name[0..@min(font_name.len, 64)], nlive, p.ncol, p.active, at.cols, at.rows, sb_off, sb_total, p.ov_pinch_scale, p.ov_touch_scroll_delta },
+ .{ p.resize_count, p.theme().name, font_name[0..@min(font_name.len, 64)], nlive, p.ncol, p.active, at.cols, at.rows, sb_off, sb_total, p.ov_pinch_scale, p.ov_touch_scroll_delta },
) catch "";
// wide enough for the longest theme name compiled in, which is the
// only row here whose width is not ours to choose
@@ -14237,10 +12516,109 @@ pub const Pardes = struct {
}
}
};
+ p.submitted_panel_layout = @splat(null);
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ p.submitted_panel_layout[id] = .{
+ .serial = pane.serial,
+ .box = panelBox(p.rects[id]),
+ };
+ }
+ p.submitted_panel_layout_ready = true;
+ // The old grid stays frozen for the transition, while terminals,
+ // watches, hover chrome, and other live data may change the new grid
+ // between samples. Rebuild the cheap byte mask every frame so the
+ // published (old, new, changed) triple is always coherent.
+ if ((p.panel_diff_pending or p.panel_diff_ready) and !try p.preparePanelDiff()) {
+ // There was no successfully presented same-sized old grid. A
+ // content effect cannot guess one: snap this transition rather
+ // than animating uninitialised/stale cells.
+ for (&p.panel_tracks) |*slot| {
+ const track = slot.* orelse continue;
+ if (track.effect.needsPreviousGrid()) slot.* = null;
+ }
+ p.nclosing_panel_tracks = 0;
+ p.panel_diff_pending = false;
+ p.panel_diff_ready = false;
+ }
+ if (p.panel_diff_ready) {
+ s.previous_cells = p.presented_cells;
+ s.changed_cells = p.panel_changed_cells;
+ }
+ // Moving panes first, then new panes, then inert closing tombstones on
+ // top. Native GUI paint planners may regroup by phase, but every host
+ // receives this same deterministic dense record set.
+ for ([_]panel_animation.Phase{ .moving, .opening }) |phase| for (p.panel_tracks) |maybe| {
+ const track = maybe orelse continue;
+ if (!track.active() or track.phase != phase) continue;
+ s.panel_tracks[s.npanel_tracks] = track;
+ s.npanel_tracks += 1;
+ };
+ for (p.closing_panel_tracks[0..p.nclosing_panel_tracks]) |track| {
+ if (!track.active()) continue;
+ s.panel_tracks[s.npanel_tracks] = track;
+ s.npanel_tracks += 1;
+ }
return s;
}
- fn renderPane(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, active: bool) !void {
+ fn preparePanelDiff(p: *Pardes) !bool {
+ const count = p.surface.cells.len;
+ if (!p.presented_cells_valid or
+ p.presented_cells_cols != p.surface.cols or
+ p.presented_cells_rows != p.surface.rows or
+ p.presented_cells.len != count) return false;
+ if (p.panel_changed_cells.len != count) {
+ const next = try p.gpa.alloc(u8, count);
+ if (p.panel_changed_cells.len > 0) p.gpa.free(p.panel_changed_cells);
+ p.panel_changed_cells = next;
+ }
+ for (p.panel_changed_cells, p.presented_cells, p.surface.cells) |*changed, *old, *new|
+ changed.* = @intFromBool(!old.visuallyEqual(new));
+ p.panel_diff_pending = false;
+ p.panel_diff_ready = true;
+ return true;
+ }
+
+ /// Paint a selection expressed in the coordinate space used by pointer
+ /// gestures. Both the persistent mouse selections and the delayed Look
+ /// preview come through this one clipping/mapping path.
+ fn paintPointerSelection(
+ s: *Surface,
+ pane: *const Pane,
+ r: Rect,
+ tx: u16,
+ tw: u16,
+ tag_y: u16,
+ body_y: u16,
+ sl: Sel,
+ bg: [3]u8,
+ fg: ?[3]u8,
+ ) void {
+ const r0 = @min(sl.r0, sl.r1);
+ const r1 = @max(sl.r0, sl.r1);
+ const c0 = @max(0, @min(sl.c0, sl.c1));
+ const c1 = @max(0, @max(sl.c0, sl.c1));
+ var row: u16 = 0;
+ while (row < r.h) : (row += 1) {
+ if (@as(i32, row) < r0 or @as(i32, row) > r1) continue;
+ // A Sel row is independent of Tagbottom: zero is the tag and
+ // BOX_H upward is the body. This is the render-side inverse of
+ // pointerTextSelection.
+ const sy = if (row < BOX_H) tag_y else body_y + row - BOX_H;
+ // File line numbers occupy PREFIX_W only in the body. The tag is
+ // row zero in Sel space and starts at its real first text cell.
+ var col: i32 = if (pane.file != null and row >= BOX_H) @max(c0, @as(i32, config.PREFIX_W)) else c0;
+ while (col <= c1 and col < tw) : (col += 1) {
+ const cell = s.at(tx + @as(u16, @intCast(col)), sy);
+ cell.default = false;
+ cell.style.bg = .{ .rgb = bg };
+ if (fg) |ink| cell.style.fg = .{ .rgb = ink };
+ }
+ }
+ }
+
+ fn renderPane(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool) !void {
const tz = tracy.zone(@src(), "renderPane");
defer tz.end();
if (r.w <= config.GUTTER or r.h == 0) return;
@@ -14253,8 +12631,8 @@ pub const Pardes = struct {
// body's first. The Tagbottom builtin swaps which end each is at and
// NOTHING else in here reads r.y — that is the whole of the feature on
// the render side. r.h == 0 returned above, so the bottom row exists.
- const tag_y = if (p.tag_bottom) r.y + r.h -| BOX_H else r.y;
- const body_y = if (p.tag_bottom) r.y else r.y + BOX_H;
+ const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H;
// the pane's own background, for everything that has to read as "no
// chrome here": the body text, and the blank right half of the
// scrollbar's second column. `.default` means the host terminal's own
@@ -14263,7 +12641,7 @@ pub const Pardes = struct {
// ...and the same background as a colour to do arithmetic on, which the
// dimmed selections below need. A theme with a null bg cannot say what
// the host's own cell looks like, so its tag bar stands in — the
- // substitution pdfTintColors already makes.
+ // substitution pdf_pane.tintColors already makes.
const page_rgb = th.bg orelse th.tag_bg;
// text area resets to terminal-default cells (vaxis clear semantics);
@@ -14299,7 +12677,13 @@ pub const Pardes = struct {
const box_bg = if (active) chrome.box else chrome.box_dim;
const box_lum = (@as(u16, box_bg[0]) * 3 + @as(u16, box_bg[1]) * 6 + @as(u16, box_bg[2])) / 10;
const box_ink: [3]u8 = if (box_lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff };
- const box_style: CellStyle = .{ .fg = .{ .rgb = box_ink }, .bg = .{ .rgb = box_bg } };
+ const box_style: CellStyle = .{
+ .fg = .{ .rgb = box_ink },
+ .bg = .{ .rgb = box_bg },
+ // The mode badge is the left end of the tagline, not body text.
+ // Carry the same role so every GUI backend scales the whole row.
+ .font_role = .tagline,
+ };
s.fill(r.x, tag_y, config.GUTTER, BOX_H, box_style);
const box_mode = if (pane.tag_edit) pane.tag_mode else pane.mode;
s.set(r.x, tag_y, switch (box_mode) {
@@ -14309,8 +12693,13 @@ pub const Pardes = struct {
}, box_style);
// tag: live prefix (cwd/path) + editable tail, on a dim bar
- const tag_style: CellStyle = .{ .fg = .{ .rgb = chrome.tag_fg }, .bg = .{ .rgb = chrome.tag_bg } };
- s.fill(tx, tag_y, tw, BOX_H, .{ .bg = .{ .rgb = chrome.tag_bg } });
+ const tag_style: CellStyle = .{ .fg = .{ .rgb = chrome.tag_fg }, .bg = .{ .rgb = chrome.tag_bg }, .font_role = .tagline };
+ // Padding belongs to the same font row but keeps its default ink: a
+ // role must not manufacture a foreground color for blank cells.
+ s.fill(tx, tag_y, tw, BOX_H, .{
+ .bg = .{ .rgb = chrome.tag_bg },
+ .font_role = .tagline,
+ });
const tag = try p.tagText(arena, pane);
// An armed input — `/`, Find, Grep, Rename, WsSymbols, Select/Split,
// `|` — is still TYPED INTO the tag tail: same buffer, same offsets,
@@ -14328,6 +12717,15 @@ pub const Pardes = struct {
else
tag.len;
_ = s.print(tx, tag_y, tw, tag[0..tag_cut], tag_style);
+ // Paint tag hover before every real tag selection. This common point
+ // is above the native-PDF/image early returns, so their tag operands
+ // are no longer hidden, while an explicit sweep remains authoritative.
+ if (p.look_hover_preview) |preview| {
+ if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < BOX_H) {
+ const preview_bg = mix(page_rgb, mix(page_rgb, th.sel_bg));
+ paintPointerSelection(s, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null);
+ };
+ }
// tag char selection highlight (helix v/x, or a tagline sweep),
// inclusive [lo, hi] — rendered-tag columns, so no conversion.
//
@@ -14356,40 +12754,14 @@ pub const Pardes = struct {
// A native PDF page uses the same backend-neutral pixel attachment as
// an image. Without native pixels it deliberately falls through: its
// extracted text projection becomes an ordinary readable body.
- if (comptime pdf_enabled) {
- if (hasPdf(pane) and p.native_images and p.drawPdf(pane, r, tx, tw)) {
- s.fill(r.x, body_y, 1, r.h -| BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } });
- s.fill(r.x + 1, body_y, 1, r.h -| BOX_H, .{ .bg = pane_bg });
- if (r.h > BOX_H) {
- const pv = &pane.pdf.?;
- const track_h: usize = r.h - BOX_H;
- const viewport = p.pdfViewport(pane).?;
- const total = @max(@as(u64, 1), pv.document_height);
- const len = @max(
- @as(usize, 1),
- @as(usize, @intCast(@min(
- @as(u64, track_h),
- @as(u64, track_h) * viewport.pixel_h / total,
- ))),
- );
- const offset: u64 = @intFromFloat(@floor(pv.document_scroll_y));
- const pos: usize = @intCast(@min(
- @as(u64, track_h -| 1),
- @as(u64, track_h) * offset / total,
- ));
- var sy = pos;
- while (sy < track_h and sy < pos + len) : (sy += 1)
- s.fill(r.x, body_y + @as(u16, @intCast(sy)), 1, 1, .{ .bg = .{ .rgb = chrome.scroll_thumb } });
- }
- return;
- }
- }
+ if (comptime pdf_enabled)
+ if (hasPdf(pane) and pdf_pane.draw(p, pane, r, id, tx, tw)) return;
// image pane: the picture fills the body — petscii glyph art into the
// cells, or a pixel attachment the shell places (kitty). Plain
// thumbless gutter so it reads like any other pane.
- if (pane.image != null) {
- p.drawImage(pane, r, tx, tw);
+ if (pane.image) |*iv| {
+ image_pane.draw(p, iv, @intCast(id), pane.serial, tx, body_y, tw, r.h -| BOX_H);
// thumbless, but the same one column as the real scrollbar below —
// that is the whole point of drawing it
s.fill(r.x, body_y, 1, r.h -| BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } });
@@ -14413,37 +12785,18 @@ pub const Pardes = struct {
_ = s.print(tx, body_y + i, tw, line, body_style);
}
- // the two file-only passes and the terminal-only one: each is the
+ // the file-only passes and the terminal-only one: each is the
// whole reason its pane kind renders differently at all, so each lives
// with its kind. Order is load-bearing — gutter, then syntax over the
// content, then the emulator's own colors — and the selection/cursor
// passes below still win over all three.
if (pane.file) |*f| {
file_pane.drawGutter(p, pane, r, tx, tw, body_h, active);
- if (p.colors_on) file_pane.recolorSyntax(p, pane, f, r, tx, tw, body_h);
- // the break marker, in the column bodyText kept free for it: a row
- // that continues says so where it broke. Drawn after the syntax
- // pass so nothing recolors it (it is chrome, not file bytes) and
- // before the selection passes, which still win over it — a swept
- // wrapped line should highlight whole.
- //
- // ponytail: a row continues iff the NEXT row shows the same line,
- // so the bottom row of a pane never carries one — what it continues
- // into is off screen anyway. Record the cut in bodyText next to the
- // map the day that reads as a lie.
- if (tw > config.PREFIX_W + 1) {
- var wr: u16 = 0;
- while (wr + 1 < pane.wrap_n and wr + 1 < body_h) : (wr += 1) {
- if (pane.wrap_line[wr + 1] != pane.wrap_line[wr]) continue;
- s.set(tx + tw - 1, body_y + wr, config.wrap_marker, .{
- .fg = .{ .rgb = chrome.lineno },
- .bg = pane_bg,
- });
- }
- }
+ if (p.settings.colors) file_pane.recolorSyntax(p, pane, f, r, tx, tw, body_h);
+ file_pane.drawWrapMarkers(p, pane, r, tx, tw, body_h, pane_bg);
}
const off = pane.scroll();
- if (p.colors_on and pane.isTerminal()) term_pane.recolorAnsi(p, pane, r, tx, tw, body_h, off);
+ if (p.settings.colors and pane.isTerminal()) term_pane.recolorAnsi(p, pane, r, tx, tw, body_h, off);
// mouse selections (pane-local coords), one pass per button — later
// buttons win on overlap. A left .done stays highlighted after release;
@@ -14470,30 +12823,34 @@ pub const Pardes = struct {
mix(th.sel_bg, mix(th.sel_bg, th.str)),
mix(th.sel_bg, mix(th.sel_bg, accent2)),
};
+ if (p.look_hover_preview) |preview| {
+ if (preview.pane == id and preview.serial == pane.serial) {
+ // Preserve body ink, then let every real user selection below
+ // win over this quiet affordance.
+ const preview_bg = mix(page_rgb, mix(page_rgb, th.sel_bg));
+ if (preview.file_word) |word|
+ file_pane.paintWordSelection(p, pane, r, word.row, word.lo, word.hi, preview_bg)
+ else if (preview.sel) |sel|
+ if (@max(sel.r0, sel.r1) >= BOX_H)
+ paintPointerSelection(s, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null);
+ }
+ }
+ const modal_hover = if (p.look_hover_preview) |preview|
+ preview.pane == id and preview.serial == pane.serial and preview.sel == null and preview.file_word == null
+ else
+ false;
for (pane.sel, 0..) |sl, b| {
if (sl.state == .none) continue;
if (sl.state == .done and b != 0) continue;
- const r0 = @min(sl.r0, sl.r1);
- const r1 = @max(sl.r0, sl.r1);
- const c0 = @max(0, @min(sl.c0, sl.c1));
- const c1 = @max(0, @max(sl.c0, sl.c1));
- var row: u16 = 0;
- while (row < r.h) : (row += 1) {
- if (@as(i32, row) < r0 or @as(i32, row) > r1) continue;
- // a Sel row, so 0 is the tag and BOX_H upward is the body —
- // the one place the two coordinate spaces meet on the render side
- const sy = if (row < BOX_H) tag_y else body_y + row - BOX_H;
- var col: i32 = if (pane.file != null) @max(c0, @as(i32, config.PREFIX_W)) else c0;
- while (col <= c1 and col < tw) : (col += 1) {
- const cell = s.at(tx + @as(u16, @intCast(col)), sy);
- cell.default = false;
- cell.style.bg = .{ .rgb = sel_btn[b] };
- cell.style.fg = .{ .rgb = th.sel_fg };
- }
- }
+ paintPointerSelection(s, pane, r, tx, tw, tag_y, body_y, sl, sel_btn[b], th.sel_fg);
}
// modal line selection (helix `x`): whole body rows, normal mode only
- if (pane.mode == .normal and pane.msel.active) {
+ if ((pane.mode == .normal or modal_hover) and pane.msel.active) {
+ const hover_only = modal_hover and pane.mode != .normal;
+ const modal_bg = if (hover_only)
+ mix(page_rgb, mix(page_rgb, th.sel_bg))
+ else
+ th.sel_bg;
const lo = @min(pane.msel.r0, pane.msel.r1);
const hi = @max(pane.msel.r0, pane.msel.r1);
var row: u16 = BOX_H; // never paint the tag row
@@ -14507,8 +12864,8 @@ pub const Pardes = struct {
while (col < tw) : (col += 1) {
const cell = s.at(tx + col, body_y + row - BOX_H);
cell.default = false;
- cell.style.bg = .{ .rgb = th.sel_bg };
- cell.style.fg = .{ .rgb = th.sel_fg };
+ cell.style.bg = .{ .rgb = modal_bg };
+ if (!hover_only) cell.style.fg = .{ .rgb = th.sel_fg };
}
}
}
@@ -14525,7 +12882,8 @@ pub const Pardes = struct {
// is a single cell: the hardware cursor is off in the tag, so a preview
// that leans on it shows every match except the one you are on.
const preview = selRegexArmed(pane) != null;
- const show_prim = (pane.mode == .normal and pane.vsel.active) or preview;
+ const show_prim = (pane.mode == .normal and pane.vsel.active) or preview or
+ (modal_hover and pane.vsel.active);
const show_extra = pane.mode != .tty and pane.nsel > 0;
if (show_prim or show_extra) {
const vpfx: i32 = if (pane.file != null) config.PREFIX_W else 0;
@@ -14540,6 +12898,7 @@ pub const Pardes = struct {
.acol = if (pane.vsel.active) pane.vsel.col else pane.cur_col,
} else pane.sels[si];
const bnd = cellBounds(sr);
+ const hover_only = primary and modal_hover and pane.mode != .normal and !preview;
// an extra cursor is the selection colour turned down: the
// highlight pulled halfway back to the page, so the primary is
// the one that reads as "here" at a glance. On a light theme
@@ -14549,7 +12908,12 @@ pub const Pardes = struct {
// walks the text and its cell toward each other until neither
// is readable on the themes whose selection is already close to
// the page.
- const bg = if (primary) th.sel_bg else mix(th.sel_bg, page_rgb);
+ const bg = if (hover_only)
+ mix(page_rgb, mix(page_rgb, th.sel_bg))
+ else if (primary)
+ th.sel_bg
+ else
+ mix(th.sel_bg, page_rgb);
// ...and this walks SCREEN rows too, asking the map which line
// and which byte column of it each one shows. Unwrapped that is
// `off + vr` / `hscroll`, i.e. the arithmetic this was, and it
@@ -14561,11 +12925,11 @@ pub const Pardes = struct {
const w = pane.wrapAt(vr);
if (w.line < bnd.lo_row or w.line > bnd.hi_row) continue;
const cstart: i32 = if (w.line == bnd.lo_row)
- (if (pane.file != null) fileDisplayOffset(pane, w.line, w.at, bnd.lo_col) else bnd.lo_col - w.at) + vpfx
+ (if (pane.file != null) file_pane.displayOffset(pane, w.line, w.at, bnd.lo_col) else bnd.lo_col - w.at) + vpfx
else
vpfx;
const cend: i32 = if (w.line == bnd.hi_row)
- (if (pane.file != null) fileDisplayEndOffset(pane, w.line, w.at, bnd.hi_col) else bnd.hi_col - w.at) + vpfx
+ (if (pane.file != null) file_pane.displayEndOffset(pane, w.line, w.at, bnd.hi_col) else bnd.hi_col - w.at) + vpfx
else
@as(i32, tw) - 1;
var col: i32 = @max(cstart, vpfx);
@@ -14573,13 +12937,14 @@ pub const Pardes = struct {
const cell = s.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(vr)));
cell.default = false;
cell.style.bg = .{ .rgb = bg };
- cell.style.fg = .{ .rgb = th.sel_fg };
+ if (!hover_only) cell.style.fg = .{ .rgb = th.sel_fg };
}
}
- if (primary and !preview) continue; // the hardware cursor IS the primary's
+ if (primary and !preview and !hover_only) continue; // the hardware cursor IS the primary's
+ if (hover_only) continue; // quiet preview preserves the source ink
const cw = pane.wrapRow(sr.row, sr.col);
const crow = cw.row + @as(i32, BOX_H);
- const ccol = (if (pane.file != null) fileDisplayOffset(pane, sr.row, cw.at, sr.col) else sr.col - cw.at) + vpfx;
+ const ccol = (if (pane.file != null) file_pane.displayOffset(pane, sr.row, cw.at, sr.col) else sr.col - cw.at) + vpfx;
if (crow >= BOX_H and crow < @as(i32, r.h) and ccol >= vpfx and ccol < tw) {
const cell = s.at(tx + @as(u16, @intCast(ccol)), body_y + @as(u16, @intCast(crow - BOX_H)));
cell.default = false;
@@ -14605,7 +12970,7 @@ pub const Pardes = struct {
// Files store source-byte columns; the Surface stores display
// cells, so account for every expanded tab before the cursor.
const cx = if (pane.file != null)
- @as(i32, config.PREFIX_W) + fileDisplayOffset(pane, crow, cwp.at, ccol)
+ @as(i32, config.PREFIX_W) + file_pane.displayOffset(pane, crow, cwp.at, ccol)
else
ccol;
if (prow >= BOX_H and cx >= 0 and prow < r.h and cx < tw)
@@ -14660,7 +13025,7 @@ pub const Pardes = struct {
}
}
- fn appendImagePlace(p: *Pardes, place: ImagePlace) bool {
+ pub fn appendImagePlace(p: *Pardes, place: ImagePlace) bool {
const s = &p.surface;
if (s.nimages == s.images.len) {
const old_len = s.images.len;
@@ -14677,333 +13042,6 @@ pub const Pardes = struct {
return true;
}
- fn pdfVisibleContains(visible: PdfVisiblePages, page: usize) bool {
- return page >= visible.first and page - visible.first < visible.len;
- }
-
- /// How far a frame must travel before its pages are worth only their
- /// visible rows. One screenful: at that speed a page is on screen for this
- /// frame and gone by the next, so the rows outside the viewport are pixels
- /// nobody will ever see. Below it, nothing changes — a reader nudging
- /// through a document keeps getting whole pages, which is what makes the
- /// following frames free.
- fn pdfFlinging(pv: *const PdfView, viewport: PdfViewport) bool {
- if (comptime !pdf_enabled) return false;
- return pv.scroll_travel >= @as(f64, @floatFromInt(viewport.pixel_h));
- }
-
- /// Band heights are rounded out to this many rows, so that consecutive
- /// pages of a fling ask for buffers of the SAME length and the retired-
- /// buffer relay can hand one page's bytes to the next instead of going back
- /// to the allocator every frame. Rounding out also makes the covered rows
- /// outlast a few frames of ordinary scrolling.
- ///
- /// 64 by measurement, not by taste: on the 5363-page Intel manual, grain 64
- /// runs a fling at a median 2.38-2.43 ms per frame against 2.49-2.52 at 128
- /// and 2.93-3.03 at 256 (rounding out pixels nobody sees), while 32 matches
- /// 64's median with a visibly worse tail — the buffer lengths stop repeating
- /// and the relay starts missing. 48 and 96 are indistinguishable from 64.
- const pdf_band_grain: usize = 64;
-
- /// The rows of `page` worth rasterizing this frame: the whole page at
- /// reading speed, and the visible strip (grain-rounded) during a fling.
- fn pdfWantedBand(
- pv: *const PdfView,
- viewport: PdfViewport,
- page: usize,
- shape: pdf_impl.Raster,
- flinging: bool,
- ) pdf_impl.Raster.Band {
- if (!flinging) return shape.wholePage();
- const rows = pdfVisibleRows(pv, viewport, page, shape.width, shape.height) orelse
- return shape.wholePage();
- const grain = @max(@as(usize, 1), pdf_band_grain);
- const first = (@as(usize, rows.y0) / grain) * grain;
- const last = std.math.divCeil(usize, @as(usize, rows.y1), grain) catch
- return shape.wholePage();
- return shape.band(first, last * grain - first);
- }
-
- /// The raster shape a retained slot was rendered against. Identical to what
- /// `measureRenderAt` would answer for the same request — which is exactly
- /// why the reconcile may use it instead of asking, as long as the request
- /// has not changed (a changed request is `stale` and re-measures).
- fn pdfSlotShape(slot: *const PdfRaster) pdf_impl.Raster {
- const stride = slot.iw * 4;
- return .{
- .width = slot.iw,
- .height = slot.ih,
- .stride = stride,
- .len = stride * slot.ih,
- };
- }
-
- fn reconcilePdfRasters(
- p: *Pardes,
- pv: *PdfView,
- request: pdf_impl.RenderRequest,
- highlights: []const PdfHighlight,
- visible: PdfVisiblePages,
- viewport: PdfViewport,
- ) void {
- if (comptime !pdf_enabled) return;
- const tz = tracy.zone(@src(), "pdf.reconcile");
- defer tz.end();
- const tint_key = p.pdfTintKey(pv);
-
- // Remove first, and remove EVERYTHING outside the visible set: owned
- // RGBA never accumulates with document length, and backends see stale
- // keys disappear in this same frame. Removing before rendering is also
- // what fills the buffer relay, so the pages arriving below take the
- // departing pages' bytes rather than the allocator's.
- //
- // Only the end-of-frame visible set can ever be SEEN: a page the
- // viewport merely swept across inside one event batch is never drawn,
- // so a fling rasterizes what it lands on, not what it passed through.
- var index: usize = 0;
- while (index < pv.rasters_len) {
- if (pdfVisibleContains(visible, pv.rasters[index].page)) {
- index += 1;
- continue;
- }
- p.retirePdfRaster(pv, &pv.rasters[index]);
- pv.rasters_len -= 1;
- if (index != pv.rasters_len) pv.rasters[index] = pv.rasters[pv.rasters_len];
- }
-
- const flinging = pdfFlinging(pv, viewport);
- var page = visible.first;
- const end = visible.first + visible.len;
- while (page < end) : (page += 1) {
- var raster = pdfRasterForPage(pv, page);
- if (raster == null) {
- if (pv.rasters_len == pv.rasters.len) continue;
- pv.rasters[pv.rasters_len] = .{ .valid = true, .page = page };
- pv.rasters_len += 1;
- raster = &pv.rasters[pv.rasters_len - 1];
- }
- const slot = raster.?;
- const decorated = page == pv.page and highlights.len > 0;
- // Everything that makes the pixels wrong rather than merely
- // insufficient. Checked first and cheaply: a request or tint change
- // means the raster's very dimensions may differ, so nothing below
- // may reason about the band it holds.
- const stale = !slot.tried or !slot.request_valid or
- !slot.request.eql(request) or slot.decorated != decorated or
- slot.tint_key == null or !slot.tint_key.?.eql(tint_key) or
- slot.rgba.len == 0 or slot.band_h == 0;
- // ...and then coverage, against the raster ALREADY here — its
- // full-page dimensions are this request's, or `stale` would be
- // true. Asking MuPDF to measure again per frame per page is what
- // this avoids: at reading speed a covered page must cost nothing.
- const uncovered = !stale and uncovered: {
- const want = pdfWantedBand(pv, viewport, page, pdfSlotShape(slot), flinging);
- break :uncovered slot.band_y > want.y or
- slot.band_y + slot.band_h < want.y + want.height;
- };
- if (stale or uncovered) {
- slot.tried = true;
- slot.request = request;
- slot.request_valid = true;
- // Measure first so the band is a range of rows of THIS
- // request's raster and a retired buffer can be reclaimed
- // against a length that is already known.
- const shape_or_null = shape: {
- const tz_measure = tracy.zone(@src(), "pdf.measure");
- defer tz_measure.end();
- break :shape pv.document.measureRenderAt(page, request) catch null;
- };
- const shape = shape_or_null orelse continue;
- const want = pdfWantedBand(pv, viewport, page, shape, flinging);
- // Render into a separate buffer and swap only on complete
- // success; the old pixels stay presentable until then and then
- // become the next page's buffer.
- if (p.claimPdfRgba(pv, want.len)) |fresh| {
- const filled = filled: {
- {
- const tz_render = tracy.zone(@src(), "pdf.render_into");
- defer tz_render.end();
- pv.document.renderIntoAt(
- page,
- request,
- shape,
- want,
- if (decorated) highlights else &.{},
- fresh,
- ) catch break :filled false;
- }
- const tz_tint = tracy.zone(@src(), "pdf.tint");
- defer tz_tint.end();
- pdf_impl.tintRgba(fresh, tint_key.mode, tint_key.colors) catch
- break :filled false;
- break :filled true;
- };
- if (!filled) {
- p.retirePdfRgba(pv, fresh);
- continue;
- }
- p.retirePdfRgba(pv, slot.rgba);
- slot.rgba = fresh;
- slot.iw = shape.width;
- slot.ih = shape.height;
- slot.band_y = want.y;
- slot.band_h = want.height;
- slot.decorated = decorated;
- slot.tint_key = tint_key;
- pv.next_raster_revision +%= 1;
- if (pv.next_raster_revision == 0) pv.next_raster_revision = 1;
- slot.revision = pv.next_raster_revision;
- }
- }
- if (page == pv.page and slot.rgba.len > 0) syncPdfRasterAliases(pv, slot);
- }
- p.trimPdfSpares(pv);
- }
-
- /// Attach every page intersecting the document viewport. Raster entries
- /// are released as soon as their page leaves that exact visible set;
- /// scrolling a retained page changes geometry only.
- fn drawPdf(p: *Pardes, pane: *Pane, r: Rect, tx: u16, tw: u16) bool {
- if (comptime !pdf_enabled) return false;
- const tz = tracy.zone(@src(), "pdf.draw");
- defer tz.end();
- const pv = &(pane.pdf orelse return false);
- if (r.h <= BOX_H) return false;
- const viewport = p.ensurePdfLayout(pane, pv) orelse return false;
- const request = p.pdfRenderRequest(pane) orelse return false;
- p.resolvePdfSearch(pv);
- const highlights = p.pdfHighlights(pv) catch &.{};
- var visible = pdfVisiblePages(pv, viewport);
- p.reconcilePdfRasters(pv, request, highlights, visible, viewport);
-
- p.rearmPdfRevealForViewport(pane, pv);
- p.revealPdfSearch(pane, pv);
- // Reveal can move within the current page; placements below use its
- // final document coordinate, but unchanged raster pixels are reused.
- visible = pdfVisiblePages(pv, viewport);
- p.reconcilePdfRasters(pv, request, highlights, visible, viewport);
- const id: u8 = @intCast(for (p.panes, 0..) |slot, i| {
- if (slot == pane) break i;
- } else 0);
- var placed_any = false;
- var page = visible.first;
- const visible_end = visible.first + visible.len;
- while (page < visible_end) : (page += 1) {
- const slot = pdfRasterForPage(pv, page) orelse continue;
- if (slot.rgba.len == 0) continue;
- const placed = pdfPlacedGeometry(pv, slot, viewport, page) orelse continue;
- if (!p.appendImagePlace(.{
- .pane = id,
- .serial = pane.serial,
- .native = .{
- .revision = slot.revision,
- .page = @intCast(page),
- .fit = switch (pv.fit) {
- .width => .width,
- .height => .height,
- },
- .pan_x = pv.pan_x,
- .geometry = placed.geometry,
- .pixel_offset_y = placed.pixel_offset_y,
- },
- .x = tx,
- .y = if (p.tag_bottom) r.y else r.y + BOX_H,
- .w = tw,
- .h = r.h - BOX_H,
- .rgba = slot.rgba,
- .iw = slot.iw,
- // the TEXTURE is the band that is here, not the page it is cut
- // from: rgba.len == iw * band_h * 4, and the placement's source
- // rectangle is already band-local
- .ih = slot.band_h,
- })) break;
- placed_any = true;
- }
- // This frame has now shown whatever its travel earned; the next frame's
- // speed is its own. Reset only when something was actually drawn: a pane
- // too short to place a page has not spent the reader's scrolling.
- if (placed_any) pv.scroll_travel = 0;
- return placed_any;
- }
-
- /// Image pane body: decode once (path via look, or dump-loaded raw bytes),
- /// rebuild the petscii grid when the size/toggles changed, then either
- /// blit the glyph art into cells or attach the pixels for the shell.
- fn drawImage(p: *Pardes, pane: *Pane, r: Rect, tx: u16, tw: u16) void {
- const iv = &pane.image.?;
- const s = &p.surface;
- if (!iv.tried) {
- iv.tried = true;
- const bytes: []const u8 = if (iv.raw.len > 0) iv.raw else (look.readFile(p.scratch.allocator(), iv.path) catch "");
- if (image.decode(p.image_gpa, bytes)) |d| {
- iv.rgba = d.rgba;
- iv.iw = d.w;
- iv.ih = d.h;
- }
- }
- if (iv.rgba.len == 0 or r.h <= BOX_H) return;
- const body_cols = tw;
- const body_rows = r.h - BOX_H;
- const body_y = if (p.tag_bottom) r.y else r.y + BOX_H;
- const use_petscii = iv.petscii or !p.native_images;
- if (!use_petscii) {
- if (!p.appendImagePlace(.{
- .pane = @intCast(for (p.panes, 0..) |slot, i| {
- if (slot == pane) break i;
- } else 0),
- .serial = pane.serial,
- .x = tx,
- .y = body_y,
- .w = body_cols,
- .h = body_rows,
- .rgba = iv.rgba,
- .iw = iv.iw,
- .ih = iv.ih,
- })) return;
- return;
- }
- // (re)build the cached glyph grid if the size or toggles changed
- if (iv.grid.len == 0 or iv.grid_w != body_cols or iv.grid_h != body_rows or
- iv.grid_mode != iv.pmode or iv.grid_ascii != iv.ascii)
- {
- if (iv.grid.len > 0) p.image_gpa.free(iv.grid);
- const pal = switch (iv.pmode) {
- .commodore => image.petscii.commodore,
- .terminal => image.ansiPalette(),
- };
- const g = image.petscii.render(p.image_gpa, iv.rgba, iv.iw, iv.ih, body_cols, body_rows, pal, iv.ascii) catch image.petscii.Grid{ .cells = &.{}, .gw = 0, .gh = 0 };
- iv.grid = g.cells;
- iv.gw = g.gw;
- iv.gh = g.gh;
- iv.grid_w = body_cols;
- iv.grid_h = body_rows;
- iv.grid_mode = iv.pmode;
- iv.grid_ascii = iv.ascii;
- }
- if (iv.grid.len == 0) return;
- const offx = if (body_cols > iv.gw) (@as(usize, body_cols) - iv.gw) / 2 else 0;
- const offy = if (body_rows > iv.gh) (@as(usize, body_rows) - iv.gh) / 2 else 0;
- var cy: usize = 0;
- while (cy < iv.gh) : (cy += 1) {
- var cx: usize = 0;
- while (cx < iv.gw) : (cx += 1) {
- const c = &iv.grid[cy * iv.gw + cx];
- const fg: Color = switch (iv.pmode) {
- .commodore => .{ .rgb = image.petscii.commodore[c.fg] },
- .terminal => .{ .index = c.fg },
- };
- const bg: Color = switch (iv.pmode) {
- .commodore => .{ .rgb = image.petscii.commodore[c.bg] },
- .terminal => .{ .index = c.bg },
- };
- const sx = tx + @as(u16, @intCast(offx + cx));
- const sy = body_y + @as(u16, @intCast(offy + cy));
- if (sx < s.cols and sy < s.rows)
- s.set(sx, sy, c.glyph[0..c.glen], .{ .fg = fg, .bg = bg });
- }
- }
- }
-
/// The pane body as text. Image: blank rows (the picture draws over it).
/// File: line-numbered content from f.scroll.
/// Terminal: viewport rows, padded to the grid height, prompt rows blanked
@@ -15017,38 +13055,15 @@ pub const Pardes = struct {
return buf;
}
if (hasPdf(pane)) {
- const text = try p.ensurePdfText(pane);
- const scroll = if (comptime pdf_enabled) pane.pdf.?.text_scroll else 0;
- const max_rows = pane.rows -| BOX_H;
- var total: usize = 0;
- var visible: usize = 0;
- var count_lines = std.mem.splitScalar(u8, text, '\n');
- var count_row: usize = 0;
- while (count_lines.next()) |line| : (count_row += 1) {
- if (count_row < scroll) continue;
- if (visible >= max_rows) break;
- total += line.len + @intFromBool(visible > 0);
- visible += 1;
- }
- const out = try arena.alloc(u8, total);
- var at: usize = 0;
- var lines = std.mem.splitScalar(u8, text, '\n');
- var row: usize = 0;
- var wrote: usize = 0;
- while (lines.next()) |line| : (row += 1) {
- if (row < scroll) continue;
- if (wrote >= max_rows) break;
- if (wrote > 0) {
- out[at] = '\n';
- at += 1;
- }
- @memcpy(out[at..][0..line.len], line);
- at += line.len;
- wrote += 1;
- }
- return out;
+ if (comptime pdf_enabled) return pdf_pane.visibleText(
+ &pane.pdf.?,
+ p.pdf_gpa,
+ arena,
+ pane.rows -| BOX_H,
+ );
+ unreachable;
}
- if (pane.file) |*f| return file_pane.bodyText(arena, pane, f, p.wrap_on);
+ if (pane.file) |*f| return file_pane.bodyText(arena, pane, f, p.settings.wrap);
return term_pane.bodyText(arena, pane);
}
};
@@ -15210,184 +13225,6 @@ test "hopping between two panes does not grow the jump stack" {
try std.testing.expectEqual(depth, p.njumps);
}
-/// A results buffer with rows we control: three rows whose HEAD is a look-able
-/// location, one of them carrying a second location further along (which a
-/// row-grained walk must not stop on), and one row with nothing look-able on
-/// it at all. Returns its slot. `pat` is the recorded pattern and is what
-/// keeps two of these APART — fillResults refills a buffer whose origin,
-/// argument and directory all match.
-fn walkFixture(p: *Pardes, id: usize, cwd: []const u8, pat: []const u8) !usize {
- const rows = try std.fmt.allocPrint(p.gpa,
- \\build.zig:1:1 first
- \\(mise.toml) and build.zig.zon:3:2-9 two on one row
- \\nothing look-able on this row at all
- \\uucode_config.zig:7:1 last
- \\
- , .{});
- try output_pane.fillResults(p, id, cwd, .search, pat, rows, null);
- return p.panes[id].?.search_pane orelse error.MissingResults;
-}
-
-test "n/N select look-able text and open nothing" {
- if (platform == .web) return;
- const gpa = std.testing.allocator;
- var cwdbuf: [4096]u8 = undefined;
- const cwd = std.mem.span(@as([*:0]u8, @ptrCast(std.c.getcwd(&cwdbuf, cwdbuf.len) orelse return)));
- var pathbuf: [4096]u8 = undefined;
- const boot = try std.fmt.bufPrint(&pathbuf, "{s}/mise.toml", .{cwd});
-
- const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 40, .file = boot });
- defer p.deinit();
- p.update(.{ .resize = .{ .cols = 100, .rows = 40 } });
- const rid = try walkFixture(p, p.active, cwd, "one");
- const rp = p.panes[rid].?;
- const panes_before = p.freeSlot();
-
- // Nothing has looked yet, so the walk's list is the one unvisited output
- // buffer. The first n lands on row 0's location, FOCUSES that buffer, and
- // opens nothing whatsoever.
- p.update(.{ .key = .{ .cp = 'n' } });
- try std.testing.expectEqual(rid, p.active);
- try std.testing.expectEqual(panes_before, p.freeSlot());
- try std.testing.expectEqual(@as(i32, 0), rp.cur_row);
- try std.testing.expectEqual(@as(i32, 0), rp.cur_col);
- // EXPLICIT, anchored on the span's last cell: what Enter's look acts on
- try std.testing.expect(rp.vsel.active and rp.vsel.explicit);
- try std.testing.expectEqualStrings("build.zig:1:1", p.currentSelText(rp) orelse "");
-
- // ONE STOP PER ROW, at its head: this is a results LIST (Grain.line), so
- // row 1 gives its parenthesised leading path — wrappers peeled off the
- // selection — and the `build.zig.zon:3:2-9` further along it is part of
- // the same hit, not a second place to stand.
- try std.testing.expectEqual(output_pane.Grain.line, output_pane.grain(rp.file.?.output));
- p.update(.{ .key = .{ .cp = 'n' } });
- try std.testing.expectEqual(@as(i32, 1), rp.cur_row);
- try std.testing.expectEqualStrings("mise.toml", p.currentSelText(rp) orelse "");
-
- // Row 2 has nothing look-able at all and is skipped entirely.
- p.update(.{ .key = .{ .cp = 'n' } });
- try std.testing.expectEqual(@as(i32, 3), rp.cur_row);
- try std.testing.expectEqualStrings("uucode_config.zig:7:1", p.currentSelText(rp) orelse "");
-
- // Past the last span the ring comes round to the first.
- p.update(.{ .key = .{ .cp = 'n' } });
- try std.testing.expectEqual(@as(i32, 0), rp.cur_row);
-
- // ...and Enter on a selection is what finally opens something.
- p.update(.{ .key = .{ .cp = Key.enter } });
- try std.testing.expect(p.freeSlot() != panes_before);
- try std.testing.expect(std.mem.endsWith(u8, p.panes[p.active].?.file.?.path, "/build.zig"));
-
- // The FILE that opened is free text, not a list, so it keeps the word
- // grain — the breaking change is the results buffer's alone.
- try std.testing.expectEqual(
- output_pane.Grain.word,
- output_pane.grain(p.panes[p.active].?.file.?.output),
- );
-}
-
-test "N is the exact inverse of n, across panes and the ring's seam" {
- if (platform == .web) return;
- const gpa = std.testing.allocator;
- var cwdbuf: [4096]u8 = undefined;
- const cwd = std.mem.span(@as([*:0]u8, @ptrCast(std.c.getcwd(&cwdbuf, cwdbuf.len) orelse return)));
- var pathbuf: [4096]u8 = undefined;
- const boot = try std.fmt.bufPrint(&pathbuf, "{s}/mise.toml", .{cwd});
-
- const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 40, .file = boot });
- defer p.deinit();
- p.update(.{ .resize = .{ .cols = 100, .rows = 40 } });
-
- // TWO buffers, so the walk has a pane boundary to cross and a seam to wrap
- // over — the two places a hand-rolled inverse gets it wrong.
- const first = try walkFixture(p, p.active, cwd, "one");
- p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell to hang the second off
- p.sync();
- const second = try walkFixture(p, p.active, cwd, "two");
- try std.testing.expect(first != second);
-
- const Mark = struct { pane: usize, row: i32, col: i32 };
- const here = struct {
- fn at(pp: *Pardes) Mark {
- const pane = pp.panes[pp.active].?;
- return .{ .pane = pp.active, .row = pane.cur_row, .col = pane.cur_col };
- }
- }.at;
-
- // The FIRST press only acquires a position — before it the cursor is
- // wherever the shell left it, which is not a place on the ring — so the
- // symmetry claim starts one step in. Everything after that is exact.
- p.update(.{ .key = .{ .cp = 'n' } });
- const base = here(p);
-
- // Twelve more forward is further than either buffer holds, so the trail
- // crosses both pane boundaries and wraps over the seam; record every
- // position it passes through.
- var trail: [12]Mark = undefined;
- for (&trail) |*m| {
- p.update(.{ .key = .{ .cp = 'n' } });
- m.* = here(p);
- }
- // ...and twelve back must retrace it exactly, position by position, ending
- // on the one the count started from.
- var i = trail.len;
- while (i > 0) {
- i -= 1;
- p.update(.{ .key = .{ .cp = 'N' } });
- const want = if (i == 0) base else trail[i - 1];
- try std.testing.expectEqual(want.pane, p.active);
- try std.testing.expectEqual(want.row, p.panes[p.active].?.cur_row);
- try std.testing.expectEqual(want.col, p.panes[p.active].?.cur_col);
- }
-
- // The walk visited both buffers rather than circling inside one.
- var saw_first = false;
- var saw_second = false;
- for (trail) |m| {
- if (m.pane == first) saw_first = true;
- if (m.pane == second) saw_second = true;
- }
- try std.testing.expect(saw_first and saw_second);
-}
-
-test "n/N select a command row whole, and a look puts its pane at the head" {
- if (platform == .web) return;
- const gpa = std.testing.allocator;
- var cwdbuf: [4096]u8 = undefined;
- const cwd = std.mem.span(@as([*:0]u8, @ptrCast(std.c.getcwd(&cwdbuf, cwdbuf.len) orelse return)));
- var pathbuf: [4096]u8 = undefined;
- const boot = try std.fmt.bufPrint(&pathbuf, "{s}/mise.toml", .{cwd});
-
- const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 40, .file = boot });
- defer p.deinit();
- p.update(.{ .resize = .{ .cols = 100, .rows = 40 } });
-
- // A COMMAND list: its rows are words to run, so a step takes the whole
- // line — there is no path inside `Theme acme` to pick out.
- p.runBuiltin(.ThemeSel, p.active, "", null);
- const tid = p.panes[p.active].?.search_pane orelse return error.MissingThemeList;
- const tp = p.panes[tid].?;
- try std.testing.expect(output_pane.fileTraits(tp.file.?.output).commands);
- p.update(.{ .key = .{ .cp = 'n' } });
- try std.testing.expectEqual(tid, p.active);
- const row0 = std.mem.trimEnd(u8, modal.lineSlice(tp.file.?.content, 0), " \t\r");
- try std.testing.expectEqualStrings(row0, p.currentSelText(tp) orelse "");
- // ...and Tab RUNS what n selected, which is how a theme is worn now.
- const theme_before = p.theme_idx;
- p.update(.{ .key = .{ .cp = 'n' } });
- p.update(.{ .key = .{ .cp = Key.tab } });
- try std.testing.expect(p.theme_idx != theme_before);
-
- // A LOOK is what puts a pane at the head of the walk. Look from the boot
- // pane and the next n steps THERE, not in the theme list, even though the
- // look moved focus into whatever it opened.
- p.runBuiltin(.Look, 0, "", "build.zig");
- p.sync();
- try std.testing.expect(p.active != 0);
- p.update(.{ .key = .{ .cp = 'n' } });
- try std.testing.expectEqual(@as(usize, 0), p.active);
-}
-
/// Drain the queue and say whether the shell was asked to do `want`.
fn drainedEffect(p: *Pardes, want: std.meta.Tag(Effect)) bool {
var seen = false;
@@ -15559,7 +13396,7 @@ test "entering tty walks the shell cursor to the column clicked past the prompt"
sp.cur_col = 3;
sp.cur_pinned = true;
while (p.nextEffect()) |_| {}
- p.enterTty(shell);
+ term_pane.enterTty(p, shell);
// The walk is arrow keys the shell understands. Seven lefts: readline's
// cursor sits past the '9' and the click was on the '3'.
diff --git a/src/pdf_pane.zig b/src/pdf_pane.zig
new file mode 100644
index 00000000..f34a457d
--- /dev/null
+++ b/src/pdf_pane.zig
@@ -0,0 +1,2738 @@
+//! PDF panes: MuPDF-owned document state, continuous layout, navigation,
+//! search/selection/outline behavior, raster reconciliation, native placement,
+//! and the direct input/render seam to the shared Pardes grid.
+//! Cross-pane placement and output ownership remain in pardes.zig.
+const std = @import("std");
+const pardes = @import("pardes.zig");
+const image = @import("image.zig");
+const config = @import("config.zig");
+const file_pane = @import("file_pane.zig");
+const look = @import("look.zig");
+const modal = @import("modal.zig");
+const output_pane = @import("output_pane.zig");
+const tracy = @import("tracy.zig");
+const normal_input = @import("normal_input.zig");
+
+pub const enabled = @import("pardes_config").mupdf;
+pub const pdf = if (enabled) @import("mupdf") else struct {
+ pub const PageSize = struct { width: f32, height: f32 };
+ pub const Raster = struct {
+ width: usize = 0,
+ height: usize = 0,
+ stride: usize = 0,
+ len: usize = 0,
+ pub const Band = struct { y: usize = 0, height: usize = 0, len: usize = 0 };
+ pub fn wholePage(_: @This()) Band {
+ return .{};
+ }
+ pub fn band(_: @This(), _: usize, _: usize) Band {
+ return .{};
+ }
+ };
+};
+pub const Point = if (enabled) pdf.Point else void;
+pub const Quad = if (enabled) pdf.Quad else void;
+pub const Document = if (enabled) pdf.Document else opaque {};
+pub const OutlineInternalDestination = if (enabled) pdf.OutlineInternalDestination else void;
+const raster_max = 256;
+const raster_spare = 4;
+pub const page_gap_px: u32 = 8;
+const band_grain: usize = 64;
+
+pub const FitMode = if (enabled) enum { width, height } else void;
+pub const TintMode = if (enabled) pdf.TintMode else void;
+pub const TintColors = if (enabled) pdf.TintColors else void;
+pub const RenderRequest = if (enabled) pdf.RenderRequest else void;
+
+/// Dump records must remain recognizable as PDFs even in a build without
+/// MuPDF, where Look deliberately treats them as ordinary files.
+pub fn isPath(path: []const u8) bool {
+ return std.ascii.endsWithIgnoreCase(path, ".pdf");
+}
+
+test "PDF dump paths are recognized independent of MuPDF support" {
+ try std.testing.expect(isPath("manual.pdf"));
+ try std.testing.expect(isPath("MANUAL.PDF"));
+ try std.testing.expect(!isPath("manual.pdf.txt"));
+ try std.testing.expect(!isPath("pdf"));
+}
+
+pub const RasterPolicy = struct {
+ dpi: u16,
+ max_dimension: u16,
+ match_viewport: bool,
+};
+
+/// Recover the dynamic PDF prefix written by older version-1 dumps. Fit and
+/// tint intentionally start fresh on restore, so matching the newly generated
+/// prefix cannot recover a custom tail; the stable PdfSections marker and the
+/// exact path delimit the old prefix without parsing renderer state.
+pub fn legacySavedPrefix(path: []const u8, saved_tag: []const u8) ?[]const u8 {
+ if (!std.mem.startsWith(u8, saved_tag, "pdf ")) return null;
+ const marker = " PdfSections ";
+ const marker_at = std.mem.indexOf(u8, saved_tag, marker) orelse return null;
+ const path_at = marker_at + marker.len;
+ if (!std.mem.startsWith(u8, saved_tag[path_at..], path)) return null;
+ return saved_tag[0 .. path_at + path.len];
+}
+
+test "legacy PDF prefix is delimited by its stable marker and exact path" {
+ const path = "/tmp/a document.pdf";
+ const tag = "pdf 2/9 height PdfFit full PdfTint PdfSections " ++ path ++ " Keep Del";
+ try std.testing.expectEqualStrings(
+ "pdf 2/9 height PdfFit full PdfTint PdfSections " ++ path,
+ legacySavedPrefix(path, tag).?,
+ );
+}
+
+pub const TintKey = if (enabled) struct {
+ mode: TintMode,
+ colors: pdf.TintColors,
+
+ pub fn eql(a: @This(), b: @This()) bool {
+ return a.mode == b.mode and
+ (a.mode == .disabled or std.meta.eql(a.colors, b.colors));
+ }
+} else void;
+
+pub const Highlight = if (enabled) pdf.Highlight else void;
+pub const Highlights = if (enabled) struct {
+ items: []const Highlight,
+ /// Hover items occupy [0..active_start); search/selection follow them.
+ active_start: usize,
+ hover_page: ?usize,
+
+ pub fn forPage(highlights: @This(), page: usize, active_page: usize) []const Highlight {
+ const hover = highlights.items[0..highlights.active_start];
+ if (page == active_page)
+ return if (highlights.hover_page == page)
+ highlights.items
+ else
+ highlights.items[highlights.active_start..];
+ return if (highlights.hover_page == page) hover else &.{};
+ }
+} else void;
+
+pub const HighlightInput = if (enabled) struct {
+ hover_quads: []const Quad = &.{},
+ hover_page: ?usize = null,
+ hover_color: [3]u8,
+ selection_color: [3]u8,
+} else void;
+
+pub fn buildHighlights(
+ state: *const State,
+ arena: std.mem.Allocator,
+ input: HighlightInput,
+) !Highlights {
+ if (comptime !enabled) return;
+ 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 active_start = input.hover_quads.len;
+ const highlights = try arena.alloc(Highlight, active_start + search_len + selection_len);
+ var n: usize = 0;
+ for (input.hover_quads) |quad| {
+ highlights[n] = pdf.Highlight.init(
+ quad,
+ .{ input.hover_color[0], input.hover_color[1], input.hover_color[2], 0x2c },
+ .custom,
+ );
+ n += 1;
+ }
+ if (state.search_results) |results| {
+ for (results.quads) |item| {
+ highlights[n] = pdf.Highlight.init(
+ item.quad,
+ .{ 0xff, 0xd5, 0x4f, 0x70 },
+ .search,
+ );
+ n += 1;
+ }
+ }
+ if (state.selection) |selection| {
+ for (selection.quads) |quad| {
+ highlights[n] = pdf.Highlight.init(
+ quad,
+ .{ input.selection_color[0], input.selection_color[1], input.selection_color[2], 0x78 },
+ .selection,
+ );
+ n += 1;
+ }
+ }
+ return .{ .items = highlights, .active_start = active_start, .hover_page = input.hover_page };
+}
+
+pub const Raster = if (enabled) struct {
+ valid: bool = false,
+ page: usize = 0,
+ rgba: []u8 = &.{},
+ /// Full page shape; rgba contains only the band below.
+ iw: usize = 0,
+ ih: usize = 0,
+ band_y: usize = 0,
+ band_h: usize = 0,
+ request: pdf.RenderRequest = .{},
+ request_valid: bool = false,
+ tried: bool = false,
+ decorated: bool = false,
+ tint_key: ?TintKey = null,
+ revision: u32 = 0,
+} else void;
+
+pub const SectionsOutput = if (enabled) struct {
+ pane: usize,
+ serial: u32,
+ revision: u32,
+} else void;
+
+pub const SelectionUpdate = enum { failed, stationary, unchanged, changed };
+
+/// Plain owned state for one PDF pane. Document navigation, search, selection,
+/// layout and raster behavior live beside it; Pardes retains only cross-pane
+/// focus/dispatch, cell rectangles and surface attachment.
+pub const State = if (enabled) struct {
+ path: []u8,
+ document: Document,
+ page: usize = 0,
+ page_count: usize,
+ page_sizes: []pdf.PageSize,
+ page_starts: []u64,
+ page_heights: []u32,
+ document_height: u64 = 0,
+ layout_viewport_w: u32 = 0,
+ layout_viewport_h: u32 = 0,
+ layout_fit: FitMode = .width,
+ layout_valid: bool = false,
+ document_scroll_y: f64 = 0,
+ scroll_to_page_pending: bool = true,
+ rasters: [raster_max]Raster = undefined,
+ rasters_len: usize = 0,
+ spare: [raster_spare][]u8 = @splat(&.{}),
+ spare_len: usize = 0,
+ layout_anchor_pending: bool = false,
+ layout_anchor_page: usize = 0,
+ layout_anchor_fraction: f64 = 0,
+ next_raster_revision: u32 = 0,
+ scroll_travel: f64 = 0,
+ fit: FitMode = .width,
+ tint: TintMode = .filtered,
+ pan_x: u16 = 0,
+ pan_y: u16 = 0,
+ highlights_dirty: bool = false,
+ search_reveal_pending: bool = false,
+ search_results: ?pdf.SearchResults = null,
+ selection: ?pdf.Selection = null,
+ selection_text: []u8 = &.{},
+ selection_anchor: ?Point = null,
+ selection_head: ?Point = null,
+ drag_anchor: ?Point = null,
+ drag_head: ?Point = null,
+ text: []u8 = &.{},
+ text_tried: bool = false,
+ text_scroll: usize = 0,
+ text_scroll_clamp_pending: bool = false,
+ search_query: []u8 = &.{},
+ search_hit: usize = 0,
+ reveal_viewport_w: u32 = 0,
+ reveal_viewport_h: u32 = 0,
+ reveal_fit: FitMode = .width,
+ reveal_viewport_valid: bool = false,
+ outline: ?pdf.Outline = null,
+ outline_tried: bool = false,
+ sections_output: ?SectionsOutput = null,
+ outline_reveal_pending: ?pdf.OutlineInternalDestination = null,
+
+ pub fn open(gpa: std.mem.Allocator, path: []const u8, page_one_based: usize) !@This() {
+ var document = try Document.open(path);
+ errdefer document.deinit();
+ const page_sizes = try gpa.alloc(pdf.PageSize, document.pages);
+ errdefer gpa.free(page_sizes);
+ for (page_sizes, 0..) |*size, page| size.* = try document.pageSize(page);
+ const page_starts = try gpa.alloc(u64, document.pages);
+ errdefer gpa.free(page_starts);
+ const page_heights = try gpa.alloc(u32, document.pages);
+ errdefer gpa.free(page_heights);
+ const owned_path = try gpa.dupe(u8, path);
+ errdefer gpa.free(owned_path);
+ return .{
+ .path = owned_path,
+ .document = document,
+ .page = if (page_one_based > 0)
+ @min(page_one_based - 1, document.pages - 1)
+ else
+ 0,
+ .page_count = document.pages,
+ .page_sizes = page_sizes,
+ .page_starts = page_starts,
+ .page_heights = page_heights,
+ };
+ }
+
+ /// Reopen the file behind this pane without exposing a half-reloaded
+ /// document. MuPDF owns document-derived objects (pages, text, outlines,
+ /// selections and rendered pixels), so a live reload replaces the whole
+ /// State and carries over only user-facing view/configuration data.
+ pub fn reload(state: *@This(), gpa: std.mem.Allocator) !void {
+ const preserve_anchor = !state.scroll_to_page_pending and
+ (state.layout_anchor_pending or
+ (state.layout_valid and state.page_count > 0 and state.document_height > 0));
+ var anchor_page: usize = state.layout_anchor_page;
+ var anchor_fraction: f64 = state.layout_anchor_fraction;
+ if (preserve_anchor and !state.layout_anchor_pending) {
+ anchor_page = pageAtOffset(state, state.document_scroll_y);
+ const start: f64 = @floatFromInt(state.page_starts[anchor_page]);
+ const height: f64 = @floatFromInt(@max(@as(u32, 1), state.page_heights[anchor_page]));
+ anchor_fraction = std.math.clamp(
+ (state.document_scroll_y - start) / height,
+ 0,
+ 1,
+ );
+ }
+
+ var fresh = try @This().open(gpa, state.path, state.page + 1);
+ errdefer fresh.deinit(gpa);
+ if (state.search_query.len > 0)
+ fresh.search_query = try gpa.dupe(u8, state.search_query);
+
+ fresh.fit = state.fit;
+ fresh.tint = state.tint;
+ fresh.pan_x = state.pan_x;
+ fresh.pan_y = state.pan_y;
+ fresh.text_scroll = state.text_scroll;
+ fresh.text_scroll_clamp_pending = true;
+ fresh.search_hit = state.search_hit;
+ fresh.highlights_dirty = fresh.search_query.len > 0;
+ fresh.search_reveal_pending = state.search_reveal_pending;
+ // A retained query is still active, but a reload is not a new request
+ // to center its hit. Preserve the viewport observation so only a real
+ // viewport/fit change re-arms revealSearch on the next frame.
+ fresh.reveal_viewport_w = state.reveal_viewport_w;
+ fresh.reveal_viewport_h = state.reveal_viewport_h;
+ fresh.reveal_fit = state.reveal_fit;
+ fresh.reveal_viewport_valid = state.reveal_viewport_valid;
+ // Revisions are part of the backend texture key. Resetting this counter
+ // while the pane serial stays live can alias a cached pre-reload page.
+ fresh.next_raster_revision = state.next_raster_revision;
+ fresh.sections_output = state.sections_output;
+ if (preserve_anchor) {
+ fresh.scroll_to_page_pending = false;
+ fresh.layout_anchor_pending = true;
+ fresh.layout_anchor_page = anchor_page;
+ fresh.layout_anchor_fraction = anchor_fraction;
+ }
+
+ var old = state.*;
+ state.* = fresh;
+ old.deinit(gpa);
+ }
+
+ pub fn invalidateRaster(state: *@This(), page: usize) void {
+ if (rasterForPage(state, page)) |raster| raster.tried = false;
+ }
+
+ pub fn invalidateAllRasters(state: *@This()) void {
+ for (state.rasters[0..state.rasters_len]) |*raster| {
+ if (raster.valid) raster.tried = false;
+ }
+ }
+
+ fn retireRgba(state: *@This(), gpa: std.mem.Allocator, rgba: []u8) void {
+ if (rgba.len == 0) return;
+ if (state.spare_len == state.spare.len) return gpa.free(rgba);
+ state.spare[state.spare_len] = rgba;
+ state.spare_len += 1;
+ }
+
+ fn retireRaster(state: *@This(), gpa: std.mem.Allocator, raster: *Raster) void {
+ state.retireRgba(gpa, raster.rgba);
+ raster.* = .{};
+ }
+
+ fn claimRgba(state: *@This(), gpa: std.mem.Allocator, bytes: usize) ?[]u8 {
+ for (state.spare[0..state.spare_len], 0..) |candidate, index| {
+ if (candidate.len != bytes) continue;
+ state.spare_len -= 1;
+ state.spare[index] = state.spare[state.spare_len];
+ return candidate;
+ }
+ return gpa.alloc(u8, bytes) catch null;
+ }
+
+ fn trimSpares(state: *@This(), gpa: std.mem.Allocator) void {
+ while (state.spare_len > 1) {
+ state.spare_len -= 1;
+ gpa.free(state.spare[state.spare_len]);
+ }
+ }
+
+ fn dropSearchResults(state: *@This(), gpa: std.mem.Allocator) void {
+ if (state.search_results) |*results| results.deinit(gpa);
+ state.search_results = null;
+ }
+
+ fn dropSelection(state: *@This(), gpa: std.mem.Allocator) void {
+ if (state.selection) |*selection| selection.deinit(gpa);
+ state.selection = null;
+ if (state.selection_text.len > 0) gpa.free(state.selection_text);
+ state.selection_text = &.{};
+ state.selection_anchor = null;
+ state.selection_head = null;
+ }
+
+ /// Transactionally replace the word-snapped selection and its owned text.
+ /// `stationary` is reserved for the UI's pre-probe drag check; this state
+ /// operation returns only failed, unchanged, or changed.
+ pub fn setSelection(
+ state: *@This(),
+ gpa: std.mem.Allocator,
+ start: Point,
+ end: Point,
+ invalidate_raster: bool,
+ ) SelectionUpdate {
+ if (state.selection != null and
+ state.selection_anchor != null and state.selection_head != null and
+ state.selection_anchor.?.x == start.x and state.selection_anchor.?.y == start.y and
+ state.selection_head.?.x == end.x and state.selection_head.?.y == end.y) return .unchanged;
+ var selection = state.document.select(gpa, state.page, start, end) catch return .failed;
+ const text = state.document.copySelection(
+ gpa,
+ state.page,
+ selection.start,
+ selection.end,
+ ) catch {
+ selection.deinit(gpa);
+ return .failed;
+ };
+
+ state.dropSelection(gpa);
+ state.selection = selection;
+ state.selection_text = text;
+ state.selection_anchor = start;
+ state.selection_head = end;
+ if (invalidate_raster) state.invalidateRaster(state.page);
+ return .changed;
+ }
+
+ pub fn clearDrag(state: *@This()) void {
+ state.drag_anchor = null;
+ state.drag_head = null;
+ }
+
+ pub fn clearSelection(state: *@This(), gpa: std.mem.Allocator) void {
+ const changed = state.selection != null or state.selection_text.len > 0;
+ state.dropSelection(gpa);
+ if (changed) state.invalidateRaster(state.page);
+ }
+
+ fn invalidatePage(state: *@This(), gpa: std.mem.Allocator) void {
+ state.dropSearchResults(gpa);
+ state.dropSelection(gpa);
+ state.clearDrag();
+ if (state.text.len > 0) gpa.free(state.text);
+ state.text = &.{};
+ state.text_tried = false;
+ state.text_scroll = 0;
+ state.text_scroll_clamp_pending = false;
+ state.highlights_dirty = state.search_query.len > 0;
+ state.search_reveal_pending = state.search_query.len > 0;
+ state.search_hit = 0;
+ }
+
+ pub fn setSearchQuery(state: *@This(), gpa: std.mem.Allocator, query: []const u8) !void {
+ if (std.mem.eql(u8, state.search_query, query)) return;
+ const owned = try gpa.dupe(u8, query);
+ if (state.search_query.len > 0) gpa.free(state.search_query);
+ state.search_query = owned;
+ state.search_hit = 0;
+ state.dropSearchResults(gpa);
+ state.highlights_dirty = query.len > 0;
+ state.search_reveal_pending = query.len > 0;
+ state.invalidateRaster(state.page);
+ }
+
+ pub fn ensureText(state: *@This(), gpa: std.mem.Allocator) []const u8 {
+ if (!state.text_tried) {
+ state.text_tried = true;
+ state.text = state.document.pageText(gpa, state.page) catch &.{};
+ }
+ if (state.text_scroll_clamp_pending) {
+ const lines = std.mem.count(u8, state.text, "\n") + 1;
+ state.text_scroll = @min(state.text_scroll, lines - 1);
+ state.text_scroll_clamp_pending = false;
+ }
+ return state.text;
+ }
+
+ pub fn resolveSearch(state: *@This(), gpa: std.mem.Allocator) void {
+ if (!state.highlights_dirty) return;
+ state.highlights_dirty = false;
+ state.dropSearchResults(gpa);
+ if (state.search_query.len == 0) return;
+ const results = state.document.search(gpa, state.page, state.search_query) catch return;
+ state.search_hit = if (results.hit_count == 0)
+ 0
+ else
+ @min(state.search_hit, results.hit_count - 1);
+ state.search_results = results;
+ }
+
+ pub fn ensureOutline(state: *@This(), gpa: std.mem.Allocator) ?*const pdf.Outline {
+ if (!state.outline_tried) {
+ state.outline_tried = true;
+ state.outline = state.document.outline(gpa) catch null;
+ }
+ return if (state.outline) |*outline| outline else null;
+ }
+
+ pub fn renderSections(
+ state: *@This(),
+ pdf_gpa: std.mem.Allocator,
+ output_gpa: std.mem.Allocator,
+ ) ![]u8 {
+ const entries: []const pdf.OutlineEntry = if (state.ensureOutline(pdf_gpa)) |outline|
+ outline.entries
+ else
+ &.{};
+ return SectionRows.render(output_gpa, state.path, entries);
+ }
+
+ pub fn sectionDestination(
+ state: *@This(),
+ gpa: std.mem.Allocator,
+ ordinal: usize,
+ ) ?pdf.OutlineDestination {
+ const outline = state.ensureOutline(gpa) orelse return null;
+ return SectionRows.resolve(outline.entries, ordinal);
+ }
+
+ /// Apply the one-based page/hit location encoded in a PDF search row.
+ /// Returns whether host pane cursor chrome must be reset.
+ pub fn focusLocation(
+ state: *@This(),
+ gpa: std.mem.Allocator,
+ line: usize,
+ column: usize,
+ ) bool {
+ const changed = line > 0 and state.activatePage(gpa, line - 1, true);
+ if (column > 0 and state.search_query.len > 0) {
+ state.search_hit = column - 1;
+ state.search_reveal_pending = true;
+ }
+ return changed;
+ }
+
+ /// Change the document page while leaving pane cursor/selection chrome to
+ /// the UI adapter. Returns whether that pane-local chrome must be reset.
+ pub fn activatePage(
+ state: *@This(),
+ gpa: std.mem.Allocator,
+ page: usize,
+ reveal: bool,
+ ) bool {
+ state.outline_reveal_pending = null;
+ const next = @min(page, state.page_count -| 1);
+ const changed = next != state.page;
+ if (changed) {
+ state.invalidatePage(gpa);
+ state.page = next;
+ }
+ if (reveal) {
+ state.scroll_to_page_pending = true;
+ if (state.layout_valid) {
+ state.document_scroll_y = @floatFromInt(state.page_starts[next]);
+ state.scroll_to_page_pending = false;
+ }
+ } else {
+ state.search_reveal_pending = false;
+ }
+ return changed;
+ }
+
+ pub fn toggleFit(state: *@This()) void {
+ state.fit = if (state.fit == .width) .height else .width;
+ state.pan_x = 0;
+ state.pan_y = 0;
+ state.layout_valid = false;
+ state.scroll_to_page_pending = true;
+ state.search_reveal_pending = state.search_query.len > 0;
+ }
+
+ pub fn toggleTint(state: *@This()) void {
+ state.tint = state.tint.next();
+ state.invalidateAllRasters();
+ }
+
+ pub fn queueOutlineReveal(
+ state: *@This(),
+ destination: pdf.OutlineInternalDestination,
+ ) void {
+ state.scroll_to_page_pending = false;
+ state.layout_anchor_pending = false;
+ state.outline_reveal_pending = destination;
+ }
+
+ pub fn deinit(state: *@This(), gpa: std.mem.Allocator) void {
+ gpa.free(state.path);
+ for (state.rasters[0..state.rasters_len]) |raster|
+ if (raster.rgba.len > 0) gpa.free(raster.rgba);
+ for (state.spare[0..state.spare_len]) |rgba| gpa.free(rgba);
+ gpa.free(state.page_sizes);
+ gpa.free(state.page_starts);
+ gpa.free(state.page_heights);
+ if (state.text.len > 0) gpa.free(state.text);
+ if (state.search_query.len > 0) gpa.free(state.search_query);
+ if (state.search_results) |*results| results.deinit(gpa);
+ if (state.selection) |*selection| selection.deinit(gpa);
+ if (state.selection_text.len > 0) gpa.free(state.selection_text);
+ if (state.outline) |*outline| outline.deinit(gpa);
+ state.document.deinit();
+ state.* = undefined;
+ }
+} else void;
+
+test "feature-off PDF state is zero-sized" {
+ if (!enabled) try std.testing.expectEqual(@as(usize, 0), @sizeOf(State));
+}
+
+pub const SearchOutput = struct {
+ bytes: usize = 0,
+ rows: usize = 0,
+ anchor: ?usize = null,
+};
+
+pub fn textLines(
+ state: *State,
+ gpa: std.mem.Allocator,
+ arena: std.mem.Allocator,
+) ![]const []const u8 {
+ if (comptime !enabled) return &.{};
+ const page_text = state.ensureText(gpa);
+ const lines = try arena.alloc([]const u8, std.mem.count(u8, page_text, "\n") + 1);
+ var it = std.mem.splitScalar(u8, page_text, '\n');
+ var n: usize = 0;
+ while (it.next()) |line| : (n += 1) lines[n] = line;
+ return lines;
+}
+
+pub fn visibleText(
+ state: *State,
+ gpa: std.mem.Allocator,
+ arena: std.mem.Allocator,
+ max_rows: usize,
+) ![]const u8 {
+ if (comptime !enabled) return "";
+ const page_text = state.ensureText(gpa);
+ var start: usize = 0;
+ for (0..state.text_scroll) |_| {
+ const newline = std.mem.indexOfScalarPos(u8, page_text, start, '\n') orelse
+ return arena.dupe(u8, "");
+ start = newline + 1;
+ }
+ if (max_rows == 0) return arena.dupe(u8, "");
+
+ var end = start;
+ var row: usize = 0;
+ while (row < max_rows) : (row += 1) {
+ const newline = std.mem.indexOfScalarPos(u8, page_text, end, '\n') orelse {
+ end = page_text.len;
+ break;
+ };
+ if (row + 1 == max_rows) {
+ end = newline;
+ break;
+ }
+ end = newline + 1;
+ }
+ return arena.dupe(u8, page_text[start..end]);
+}
+
+/// Materialize exact MuPDF logical hits as `path:PAGE:HIT query` rows. The
+/// same hit numbering drives persistent highlights and later reveal actions.
+pub fn searchRows(
+ state: *State,
+ gpa: std.mem.Allocator,
+ arena: std.mem.Allocator,
+ pattern: []const u8,
+ from_cursor: bool,
+ out: []u8,
+) !SearchOutput {
+ if (comptime !enabled) return .{};
+ try state.setSearchQuery(gpa, pattern);
+ const shown = std.fs.path.basename(state.path);
+ var result: SearchOutput = .{};
+ const max_hits = 512;
+ var snippet_len = @min(pattern.len, 200);
+ while (snippet_len > 0 and snippet_len < pattern.len and pattern[snippet_len] & 0xc0 == 0x80)
+ snippet_len -= 1;
+ const snippet = pattern[0..snippet_len];
+ for (0..state.page_count) |page| {
+ if (result.rows >= max_hits) break;
+ var found = try state.document.search(gpa, page, pattern);
+ defer found.deinit(gpa);
+
+ const cursor_hit: ?usize = if (from_cursor and page == state.page) cursor: {
+ const selection = state.selection orelse break :cursor null;
+ for (found.quads) |item| {
+ const q = item.quad;
+ const center: Point = .{
+ .x = (q.ul.x + q.ur.x + q.ll.x + q.lr.x) / 4,
+ .y = (q.ul.y + q.ur.y + q.ll.y + q.lr.y) / 4,
+ };
+ if (selection.contains(center)) break :cursor item.hit;
+ }
+ break :cursor null;
+ } else null;
+
+ for (0..found.hit_count) |hit_index| {
+ if (result.rows >= max_hits) break;
+ const line = try std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{
+ shown, page + 1, hit_index + 1, snippet,
+ });
+ if (line.len > out.len - result.bytes) return result;
+ if (from_cursor and
+ (page < state.page or
+ (page == state.page and cursor_hit != null and hit_index <= cursor_hit.?)))
+ result.anchor = result.rows;
+ @memcpy(out[result.bytes..][0..line.len], line);
+ result.bytes += line.len;
+ result.rows += 1;
+ }
+ }
+ return result;
+}
+
+pub const Viewport = struct { pixel_w: u32, pixel_h: u32 };
+pub const VisiblePages = struct { first: usize = 0, len: usize = 0 };
+pub const PanAxis = enum { horizontal, vertical };
+pub const PanResult = enum { moved, edge, unavailable };
+pub const ScrollResult = struct { active_page: usize };
+pub const CellPixels = struct { w: u16, h: u16 };
+pub const NormalHost = enum {
+ none,
+ last,
+ leader,
+ command_line,
+ search,
+ search_forward,
+ search_backward,
+};
+pub const NormalResult = struct {
+ host: NormalHost = .none,
+ page_changed: bool = false,
+};
+
+const PlacedGeometry = struct {
+ geometry: image.NativeGeometry,
+ pixel_offset_y: f32,
+};
+
+pub const PlacedRaster = if (enabled) struct {
+ page: usize,
+ revision: u32,
+ fit: FitMode,
+ pan_x: u16,
+ geometry: image.NativeGeometry,
+ pixel_offset_y: f32,
+ rgba: []const u8,
+ width: usize,
+ band_height: usize,
+} else void;
+
+const VisibleRows = struct {
+ base: image.NativeGeometry,
+ y0: u32,
+ y1: u32,
+ dst_y: u32,
+ dst_h: u32,
+ pixel_offset_y: f32,
+};
+
+pub fn renderRequest(viewport: Viewport, policy: RasterPolicy) RenderRequest {
+ if (comptime !enabled) return;
+ return .{
+ .dpi = policy.dpi,
+ .minimum_width = if (policy.match_viewport) viewport.pixel_w else 0,
+ .minimum_height = if (policy.match_viewport) viewport.pixel_h else 0,
+ .max_dimension = policy.max_dimension,
+ };
+}
+
+fn pageHeight(size: pdf.PageSize, viewport: Viewport, fit: FitMode) u32 {
+ if (comptime !enabled) return 0;
+ if (fit == .height) return viewport.pixel_h;
+ const scaled = @as(f64, @floatFromInt(viewport.pixel_w)) *
+ @as(f64, size.height) / @as(f64, size.width);
+ return @max(1, @as(u32, @intFromFloat(@min(
+ @as(f64, @floatFromInt(std.math.maxInt(u32))),
+ @round(scaled),
+ ))));
+}
+
+fn pageAtOffset(state: *const State, offset: f64) usize {
+ if (comptime !enabled) return 0;
+ const y: u64 = @intFromFloat(std.math.clamp(
+ @floor(offset),
+ 0,
+ @as(f64, @floatFromInt(state.document_height -| 1)),
+ ));
+ var lo: usize = 0;
+ var hi: usize = state.page_count;
+ while (lo + 1 < hi) {
+ const mid = lo + (hi - lo) / 2;
+ if (state.page_starts[mid] <= y) lo = mid else hi = mid;
+ }
+ const end = state.page_starts[lo] + state.page_heights[lo];
+ return if (y >= end and lo + 1 < state.page_count) lo + 1 else lo;
+}
+
+fn pageVisible(state: *const State, page: usize, viewport: Viewport) bool {
+ if (comptime !enabled) return false;
+ const top = @as(f64, @floatFromInt(state.page_starts[page])) - state.document_scroll_y;
+ const bottom = top + @as(f64, @floatFromInt(state.page_heights[page]));
+ return bottom > 0 and top < @as(f64, @floatFromInt(viewport.pixel_h));
+}
+
+pub fn visiblePages(state: *const State, viewport: Viewport) VisiblePages {
+ if (comptime !enabled) return .{};
+ var out: VisiblePages = .{};
+ var page = pageAtOffset(state, state.document_scroll_y);
+ if (page > 0 and pageVisible(state, page - 1, viewport)) page -= 1;
+ out.first = page;
+ while (page < state.page_count) : (page += 1) {
+ const top = @as(f64, @floatFromInt(state.page_starts[page])) - state.document_scroll_y;
+ if (top >= @as(f64, @floatFromInt(viewport.pixel_h))) break;
+ if (pageVisible(state, page, viewport)) out.len += 1;
+ }
+ return out;
+}
+
+fn visibleContains(visible: VisiblePages, page: usize) bool {
+ return page >= visible.first and page - visible.first < visible.len;
+}
+
+pub fn rasterForPage(state: *State, page: usize) ?*Raster {
+ if (comptime !enabled) return null;
+ for (state.rasters[0..state.rasters_len]) |*raster|
+ if (raster.valid and raster.page == page) return raster;
+ return null;
+}
+
+fn rasterForPageConst(state: *const State, page: usize) ?*const Raster {
+ if (comptime !enabled) return null;
+ for (state.rasters[0..state.rasters_len]) |*raster|
+ if (raster.valid and raster.page == page) return raster;
+ return null;
+}
+
+fn visibleRows(
+ state: *const State,
+ viewport: Viewport,
+ page: usize,
+ iw: usize,
+ ih: usize,
+) ?VisibleRows {
+ if (comptime !enabled) return null;
+ const page_h = state.page_heights[page];
+ const base = image.nativeGeometry(
+ iw,
+ ih,
+ viewport.pixel_w,
+ page_h,
+ switch (state.fit) {
+ .width => .width,
+ .height => .height,
+ },
+ state.pan_x,
+ 0,
+ ) orelse return null;
+ if (base.dst.h == 0 or base.src.h == 0) return null;
+
+ const scroll_floor = @floor(state.document_scroll_y);
+ const fractional: f32 = @floatCast(state.document_scroll_y - scroll_floor);
+ const scroll_i: i64 = @intFromFloat(@min(
+ scroll_floor,
+ @as(f64, @floatFromInt(std.math.maxInt(i64))),
+ ));
+ const start_i: i64 = @intCast(@min(
+ state.page_starts[page],
+ @as(u64, std.math.maxInt(i64)),
+ ));
+ const full_y = start_i - scroll_i + @as(i64, base.dst.y);
+ const full_bottom = full_y + @as(i64, base.dst.h);
+ const visible_y = @max(@as(i64, 0), full_y);
+ const visible_bottom = @min(@as(i64, viewport.pixel_h), full_bottom);
+ if (visible_bottom <= visible_y) return null;
+
+ const rel_y0: u64 = @intCast(visible_y - full_y);
+ const rel_y1: u64 = @intCast(visible_bottom - full_y);
+ const src_y0: u32 = base.src.y + @as(u32, @intCast(
+ rel_y0 * base.src.h / base.dst.h,
+ ));
+ const src_y1: u32 = base.src.y + @as(u32, @intCast(@min(
+ @as(u64, base.src.h),
+ (rel_y1 * base.src.h + base.dst.h - 1) / base.dst.h,
+ )));
+ if (src_y1 <= src_y0) return null;
+ return .{
+ .base = base,
+ .y0 = src_y0,
+ .y1 = src_y1,
+ .dst_y = @intCast(visible_y),
+ .dst_h = @intCast(visible_bottom - visible_y),
+ .pixel_offset_y = -fractional,
+ };
+}
+
+fn placedGeometry(
+ state: *const State,
+ raster: *const Raster,
+ viewport: Viewport,
+ page: usize,
+) ?PlacedGeometry {
+ if (comptime !enabled) return null;
+ const rows = visibleRows(state, viewport, page, raster.iw, raster.ih) orelse return null;
+ const band_y: u32 = @intCast(raster.band_y);
+ const band_end: u32 = @intCast(raster.band_y + raster.band_h);
+ if (rows.y0 < band_y or rows.y1 > band_end) return null;
+ return .{
+ .geometry = .{
+ .src = .{
+ .x = rows.base.src.x,
+ .y = rows.y0 - band_y,
+ .w = rows.base.src.w,
+ .h = rows.y1 - rows.y0,
+ },
+ .dst = .{
+ .x = rows.base.dst.x,
+ .y = rows.dst_y,
+ .w = rows.base.dst.w,
+ .h = rows.dst_h,
+ },
+ },
+ .pixel_offset_y = rows.pixel_offset_y,
+ };
+}
+
+pub fn placedRaster(state: *const State, viewport: Viewport, page: usize) ?PlacedRaster {
+ if (comptime !enabled) return null;
+ const raster = rasterForPageConst(state, page) orelse return null;
+ if (raster.rgba.len == 0) return null;
+ const placed = placedGeometry(state, raster, viewport, page) orelse return null;
+ return .{
+ .page = page,
+ .revision = raster.revision,
+ .fit = state.fit,
+ .pan_x = state.pan_x,
+ .geometry = placed.geometry,
+ .pixel_offset_y = placed.pixel_offset_y,
+ .rgba = raster.rgba,
+ .width = raster.iw,
+ .band_height = raster.band_h,
+ };
+}
+
+pub fn activeGeometry(state: *const State, viewport: Viewport) ?image.NativeGeometry {
+ if (comptime !enabled) return null;
+ const raster = rasterForPageConst(state, state.page) orelse return null;
+ const placed = placedGeometry(state, raster, viewport, state.page) orelse return null;
+ return placed.geometry;
+}
+
+pub fn pageAtViewportY(state: *const State, local_y: f64) ?usize {
+ if (comptime !enabled) return null;
+ if (!state.layout_valid or !std.math.isFinite(local_y) or local_y < 0) return null;
+ const document_y = state.document_scroll_y + local_y;
+ const page = pageAtOffset(state, document_y);
+ const start: f64 = @floatFromInt(state.page_starts[page]);
+ if (document_y < start or
+ document_y >= start + @as(f64, @floatFromInt(state.page_heights[page]))) return null;
+ return page;
+}
+
+pub fn pageReady(state: *const State, viewport: Viewport, page: usize) bool {
+ if (comptime !enabled) return false;
+ return placedRaster(state, viewport, page) != null;
+}
+
+pub fn nativeReady(state: *const State, viewport: Viewport) bool {
+ if (comptime !enabled) return false;
+ for (state.rasters[0..state.rasters_len]) |*raster| {
+ if (raster.valid and raster.rgba.len > 0 and
+ placedGeometry(state, raster, viewport, raster.page) != null) return true;
+ }
+ return false;
+}
+
+pub fn pointAtPage(
+ state: *const State,
+ viewport: Viewport,
+ page: usize,
+ px: i64,
+ py: i64,
+ clamp_to_page: bool,
+) ?Point {
+ if (comptime !enabled) return null;
+ const raster = rasterForPageConst(state, page) orelse return null;
+ if (raster.rgba.len == 0) return null;
+ const placed = placedGeometry(state, raster, viewport, page) orelse return null;
+ return pointAtGeometry(
+ placed.geometry,
+ placed.pixel_offset_y,
+ raster.iw,
+ raster.ih,
+ raster.band_y,
+ px,
+ py,
+ clamp_to_page,
+ );
+}
+
+fn slotShape(slot: *const Raster) pdf.Raster {
+ const stride = slot.iw * 4;
+ return .{ .width = slot.iw, .height = slot.ih, .stride = stride, .len = stride * slot.ih };
+}
+
+fn flinging(state: *const State, viewport: Viewport) bool {
+ if (comptime !enabled) return false;
+ return state.scroll_travel >= @as(f64, @floatFromInt(viewport.pixel_h));
+}
+
+fn wantedBand(
+ state: *const State,
+ viewport: Viewport,
+ page: usize,
+ shape: pdf.Raster,
+ is_flinging: bool,
+) pdf.Raster.Band {
+ if (!is_flinging) return shape.wholePage();
+ const rows = visibleRows(state, viewport, page, shape.width, shape.height) orelse
+ return shape.wholePage();
+ const first = (@as(usize, rows.y0) / band_grain) * band_grain;
+ const last = std.math.divCeil(usize, @as(usize, rows.y1), band_grain) catch
+ return shape.wholePage();
+ return shape.band(first, last * band_grain - first);
+}
+
+/// Keep exactly the visible page rasters resident and refresh only stale or
+/// uncovered bands. Rendering is transactional: existing pixels remain
+/// presentable until a replacement is fully rendered and tinted.
+fn reconcile(
+ state: *State,
+ gpa: std.mem.Allocator,
+ request: RenderRequest,
+ tint_key: TintKey,
+ highlights: Highlights,
+ visible: VisiblePages,
+ viewport: Viewport,
+) void {
+ if (comptime !enabled) return;
+ const tz = tracy.zone(@src(), "pdf.reconcile");
+ defer tz.end();
+
+ // Remove first so arriving pages can claim departing page buffers. Only
+ // the final visible set can be seen, so a fling skips crossed-over pages.
+ var index: usize = 0;
+ while (index < state.rasters_len) {
+ if (visibleContains(visible, state.rasters[index].page)) {
+ index += 1;
+ continue;
+ }
+ state.retireRaster(gpa, &state.rasters[index]);
+ state.rasters_len -= 1;
+ if (index != state.rasters_len)
+ state.rasters[index] = state.rasters[state.rasters_len];
+ }
+
+ const is_flinging = flinging(state, viewport);
+ var page = visible.first;
+ const end = visible.first + visible.len;
+ while (page < end) : (page += 1) {
+ var raster = rasterForPage(state, page);
+ if (raster == null) {
+ if (state.rasters_len == state.rasters.len) continue;
+ state.rasters[state.rasters_len] = .{ .valid = true, .page = page };
+ state.rasters_len += 1;
+ raster = &state.rasters[state.rasters_len - 1];
+ }
+ const slot = raster.?;
+ const page_highlights = highlights.forPage(page, state.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
+ 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 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;
+
+ slot.tried = true;
+ slot.request = request;
+ slot.request_valid = true;
+ const shape_or_null = shape: {
+ const tz_measure = tracy.zone(@src(), "pdf.measure");
+ defer tz_measure.end();
+ break :shape state.document.measureRenderAt(page, request) catch null;
+ };
+ const shape = shape_or_null orelse continue;
+ const want = wantedBand(state, viewport, page, shape, is_flinging);
+ const fresh = state.claimRgba(gpa, want.len) orelse continue;
+ const filled = filled: {
+ {
+ const tz_render = tracy.zone(@src(), "pdf.render_into");
+ defer tz_render.end();
+ state.document.renderIntoAt(
+ page,
+ request,
+ shape,
+ want,
+ page_highlights,
+ fresh,
+ ) catch break :filled false;
+ }
+ const tz_tint = tracy.zone(@src(), "pdf.tint");
+ defer tz_tint.end();
+ pdf.tintRgba(fresh, tint_key.mode, tint_key.colors) catch
+ break :filled false;
+ break :filled true;
+ };
+ if (!filled) {
+ state.retireRgba(gpa, fresh);
+ continue;
+ }
+
+ state.retireRgba(gpa, slot.rgba);
+ slot.rgba = fresh;
+ slot.iw = shape.width;
+ slot.ih = shape.height;
+ slot.band_y = want.y;
+ slot.band_h = want.height;
+ slot.decorated = decorated;
+ slot.tint_key = tint_key;
+ state.next_raster_revision +%= 1;
+ if (state.next_raster_revision == 0) state.next_raster_revision = 1;
+ slot.revision = state.next_raster_revision;
+ }
+ state.trimSpares(gpa);
+}
+
+/// Resolve all pane-owned render decisions for one surface frame. The caller
+/// supplies only backend policy, theme-derived tint/highlight colors, and the
+/// viewport; it then transports the returned visible placements to Surface.
+pub fn renderFrame(
+ state: *State,
+ gpa: std.mem.Allocator,
+ arena: std.mem.Allocator,
+ viewport: Viewport,
+ policy: RasterPolicy,
+ tint_key: TintKey,
+ highlight_input: HighlightInput,
+) VisiblePages {
+ if (comptime !enabled) return .{};
+ ensureLayout(state, viewport);
+ state.resolveSearch(gpa);
+ const highlights = buildHighlights(state, arena, highlight_input) catch Highlights{
+ .items = &.{},
+ .active_start = 0,
+ .hover_page = null,
+ };
+ const request = renderRequest(viewport, policy);
+ var visible = visiblePages(state, viewport);
+ reconcile(state, gpa, request, tint_key, highlights, visible, viewport);
+
+ rearmSearchReveal(state, viewport);
+ revealSearch(state, viewport, activeGeometry(state, viewport));
+ visible = visiblePages(state, viewport);
+ reconcile(state, gpa, request, tint_key, highlights, visible, viewport);
+ return visible;
+}
+
+pub fn normalizedPixel(value: f32, dimension: usize) u32 {
+ const scaled = std.math.clamp(value, 0, 1) * @as(f32, @floatFromInt(dimension));
+ return @intCast(@min(dimension - 1, @as(usize, @intFromFloat(scaled))));
+}
+
+pub fn scaledStep(base: u32, count: u32) u32 {
+ return @intCast(@min(
+ @as(u64, std.math.maxInt(u32)),
+ @as(u64, base) * @max(@as(u64, 1), count),
+ ));
+}
+
+pub fn scrollDocument(state: *State, viewport: Viewport, delta_pixels: f64) ?ScrollResult {
+ if (comptime !enabled) return null;
+ if (!std.math.isFinite(delta_pixels) or delta_pixels == 0) return null;
+ const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h));
+ const next = std.math.clamp(state.document_scroll_y + delta_pixels, 0, max_scroll);
+ if (next == state.document_scroll_y) return null;
+ state.scroll_travel += @abs(next - state.document_scroll_y);
+ state.document_scroll_y = next;
+ return .{ .active_page = pageAtOffset(state, next) };
+}
+
+pub fn panPixels(
+ state: *State,
+ geometry: image.NativeGeometry,
+ axis: PanAxis,
+ direction: i8,
+ display_pixels: u32,
+) PanResult {
+ if (comptime !enabled) return .unavailable;
+ const raster = rasterForPage(state, state.page) orelse return .unavailable;
+ const source_full: u32 = @intCast(switch (axis) {
+ .horizontal => raster.iw,
+ .vertical => raster.ih,
+ });
+ const crop = switch (axis) {
+ .horizontal => geometry.src.w,
+ .vertical => geometry.src.h,
+ };
+ const source_at = switch (axis) {
+ .horizontal => geometry.src.x,
+ .vertical => geometry.src.y,
+ };
+ const displayed = @max(@as(u32, 1), switch (axis) {
+ .horizontal => geometry.dst.w,
+ .vertical => geometry.dst.h,
+ });
+ const overflow = source_full -| crop;
+ if (overflow == 0 or
+ (direction < 0 and source_at == 0) or
+ (direction > 0 and source_at >= overflow)) return .edge;
+
+ const source_step = @max(
+ @as(u64, 1),
+ (@as(u64, display_pixels) * @as(u64, crop) + displayed - 1) / displayed,
+ );
+ const normalized_step: u32 = @intCast(@min(
+ @as(u64, std.math.maxInt(u16)),
+ @max(
+ @as(u64, 1),
+ (source_step * std.math.maxInt(u16) + overflow - 1) / overflow,
+ ),
+ ));
+ const position = switch (axis) {
+ .horizontal => &state.pan_x,
+ .vertical => &state.pan_y,
+ };
+ if (direction > 0) {
+ position.* = @intCast(@min(
+ @as(u32, std.math.maxInt(u16)),
+ @as(u32, position.*) + normalized_step,
+ ));
+ } else {
+ position.* -|= @intCast(normalized_step);
+ }
+ return .moved;
+}
+
+fn setHorizontalEdge(state: *State, geometry: image.NativeGeometry, end: bool) void {
+ if (comptime !enabled) return;
+ const raster = rasterForPage(state, state.page) orelse return;
+ if (raster.iw <= geometry.src.w) return;
+ state.pan_x = if (end) std.math.maxInt(u16) else 0;
+}
+
+fn rearmSearchReveal(state: *State, viewport: Viewport) void {
+ if (comptime !enabled) return;
+ if (state.search_query.len == 0) return;
+ if (!state.reveal_viewport_valid or
+ state.reveal_viewport_w != viewport.pixel_w or
+ state.reveal_viewport_h != viewport.pixel_h or
+ state.reveal_fit != state.fit)
+ state.search_reveal_pending = true;
+}
+
+pub fn revealSearch(
+ state: *State,
+ viewport: Viewport,
+ geometry: ?image.NativeGeometry,
+) void {
+ if (comptime !enabled) return;
+ if (!state.search_reveal_pending) return;
+ state.reveal_viewport_w = viewport.pixel_w;
+ state.reveal_viewport_h = viewport.pixel_h;
+ state.reveal_fit = state.fit;
+ state.reveal_viewport_valid = true;
+ const results = state.search_results orelse {
+ state.search_reveal_pending = false;
+ return;
+ };
+ if (results.hit_count == 0 or results.quads.len == 0) {
+ state.search_reveal_pending = false;
+ return;
+ }
+ state.search_hit = @min(state.search_hit, results.hit_count - 1);
+ const q = for (results.quads) |item| {
+ if (item.hit == state.search_hit) break item.quad;
+ } else {
+ state.search_reveal_pending = false;
+ return;
+ };
+ state.search_reveal_pending = false;
+ const center_x = (q.ul.x + q.ur.x + q.ll.x + q.lr.x) / 4;
+ const center_y = (q.ul.y + q.ur.y + q.ll.y + q.lr.y) / 4;
+ if (state.fit == .width) {
+ ensureLayout(state, viewport);
+ const page_y = @as(f64, @floatFromInt(state.page_starts[state.page])) +
+ @as(f64, center_y) * @as(f64, @floatFromInt(state.page_heights[state.page]));
+ const wanted = page_y - @as(f64, @floatFromInt(viewport.pixel_h)) / 2;
+ const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h));
+ state.document_scroll_y = std.math.clamp(wanted, 0, max_scroll);
+ return;
+ }
+ const placed = geometry orelse return;
+ const raster = rasterForPage(state, state.page) orelse return;
+ const full: u32 = @intCast(raster.iw);
+ const at = normalizedPixel(center_x, raster.iw);
+ if (at >= placed.src.x and at < placed.src.x + placed.src.w) return;
+ const overflow = full -| placed.src.w;
+ if (overflow == 0) return;
+ const wanted = @min(overflow, at -| placed.src.w / 2);
+ state.pan_x = @intCast(
+ (@as(u64, wanted) * std.math.maxInt(u16) + overflow / 2) / overflow,
+ );
+}
+
+pub fn stepPage(state: *State, gpa: std.mem.Allocator, delta: i64) bool {
+ const current: i64 = @intCast(state.page);
+ const last: i64 = @intCast(state.page_count -| 1);
+ return state.activatePage(
+ gpa,
+ @intCast(std.math.clamp(current + delta, 0, last)),
+ true,
+ );
+}
+
+fn scrollNormal(
+ state: *State,
+ gpa: std.mem.Allocator,
+ viewport: ?Viewport,
+ delta_pixels: f64,
+) bool {
+ const view = viewport orelse return false;
+ ensureLayout(state, view);
+ const result = scrollDocument(state, view, delta_pixels) orelse return false;
+ return result.active_page != state.page and
+ state.activatePage(gpa, result.active_page, false);
+}
+
+fn moveRows(
+ state: *State,
+ gpa: std.mem.Allocator,
+ native_images: bool,
+ viewport: ?Viewport,
+ cell_pixels: CellPixels,
+ direction: i8,
+ count: u32,
+) bool {
+ if (!native_images) {
+ const pages: i64 = @intCast(@max(@as(u32, 1), count));
+ return stepPage(state, gpa, if (direction > 0) pages else -pages);
+ }
+ return scrollNormal(
+ state,
+ gpa,
+ viewport,
+ @as(f64, @floatFromInt(scaledStep(cell_pixels.h, count))) * direction,
+ );
+}
+
+fn movePage(
+ state: *State,
+ gpa: std.mem.Allocator,
+ native_images: bool,
+ viewport: ?Viewport,
+ cell_pixels: CellPixels,
+ direction: i8,
+ kind: normal_input.Page,
+ count: u32,
+) bool {
+ if (!native_images) {
+ const pages: i64 = @intCast(@max(@as(u32, 1), count));
+ return stepPage(state, gpa, if (direction > 0) pages else -pages);
+ }
+ const base: u32 = switch (kind) {
+ .half_down, .half_up => if (viewport) |view|
+ @max(@as(u32, 1), view.pixel_h / 2)
+ else
+ cell_pixels.h,
+ .down, .up => if (viewport) |view| view.pixel_h else cell_pixels.h,
+ };
+ return scrollNormal(
+ state,
+ gpa,
+ viewport,
+ @as(f64, @floatFromInt(scaledStep(base, count))) * direction,
+ );
+}
+
+/// Apply the PDF-owned portion of one parsed normal-mode action. The result
+/// carries only host UI work; document page/search/pan/scroll state is updated
+/// here directly from plain inputs.
+pub fn applyNormal(
+ state: *State,
+ gpa: std.mem.Allocator,
+ semantic: normal_input.Action,
+ native_images: bool,
+ cell_pixels: CellPixels,
+ viewport: ?Viewport,
+ geometry: ?image.NativeGeometry,
+) NormalResult {
+ if (comptime !enabled) return .{};
+ var result: NormalResult = .{};
+ switch (semantic) {
+ .escape => result.host = .last,
+ .move => |move| switch (move.motion) {
+ .down => result.page_changed = moveRows(
+ state,
+ gpa,
+ native_images,
+ viewport,
+ cell_pixels,
+ 1,
+ move.count,
+ ),
+ .up => result.page_changed = moveRows(
+ state,
+ gpa,
+ native_images,
+ viewport,
+ cell_pixels,
+ -1,
+ move.count,
+ ),
+ .left => if (native_images) if (geometry) |placed| {
+ _ = panPixels(state, placed, .horizontal, -1, scaledStep(cell_pixels.w, move.count));
+ },
+ .right => if (native_images) if (geometry) |placed| {
+ _ = panPixels(state, placed, .horizontal, 1, scaledStep(cell_pixels.w, move.count));
+ },
+ else => {},
+ },
+ .goto => |go| switch (go.target) {
+ .file_start => result.page_changed = state.activatePage(
+ gpa,
+ if (go.explicit_count) go.count -| 1 else 0,
+ true,
+ ),
+ .last_line => result.page_changed = state.activatePage(gpa, state.page_count -| 1, true),
+ .line_start, .first_nonws => if (native_images) if (geometry) |placed|
+ setHorizontalEdge(state, placed, false),
+ .line_end => if (native_images) if (geometry) |placed|
+ setHorizontalEdge(state, placed, true),
+ .line_down => result.page_changed = moveRows(
+ state,
+ gpa,
+ native_images,
+ viewport,
+ cell_pixels,
+ 1,
+ go.count,
+ ),
+ .line_up => result.page_changed = moveRows(
+ state,
+ gpa,
+ native_images,
+ viewport,
+ cell_pixels,
+ -1,
+ go.count,
+ ),
+ else => {},
+ },
+ .line => |line| switch (line) {
+ .start, .first_nonws => if (native_images) if (geometry) |placed|
+ setHorizontalEdge(state, placed, false),
+ .end => if (native_images) if (geometry) |placed|
+ setHorizontalEdge(state, placed, true),
+ },
+ .goto_line => |go| {
+ if (go.explicit)
+ result.page_changed = state.activatePage(gpa, go.count -| 1, true);
+ },
+ .page => |page| result.page_changed = switch (page.kind) {
+ .half_down, .down => movePage(
+ state,
+ gpa,
+ native_images,
+ viewport,
+ cell_pixels,
+ 1,
+ page.kind,
+ page.count,
+ ),
+ .half_up, .up => movePage(
+ state,
+ gpa,
+ native_images,
+ viewport,
+ cell_pixels,
+ -1,
+ page.kind,
+ page.count,
+ ),
+ },
+ .view => |view| result.page_changed = switch (view) {
+ .scroll_down => moveRows(state, gpa, native_images, viewport, cell_pixels, 1, 1),
+ .scroll_up => moveRows(state, gpa, native_images, viewport, cell_pixels, -1, 1),
+ else => false,
+ },
+ .leader => result.host = .leader,
+ .command_line => result.host = .command_line,
+ .search => result.host = .search,
+ .search_step => |direction| result.host = if (direction == .forward)
+ .search_forward
+ else
+ .search_backward,
+ else => {},
+ }
+ return result;
+}
+
+pub fn captureLayoutAnchor(state: *State) void {
+ if (comptime !enabled) return;
+ if (!state.layout_valid or state.page_count == 0 or state.document_height == 0) return;
+ const page = pageAtOffset(state, state.document_scroll_y);
+ const start: f64 = @floatFromInt(state.page_starts[page]);
+ const height: f64 = @floatFromInt(@max(@as(u32, 1), state.page_heights[page]));
+ state.layout_anchor_page = page;
+ state.layout_anchor_fraction = std.math.clamp((state.document_scroll_y - start) / height, 0, 1);
+ state.layout_anchor_pending = true;
+}
+
+fn consumeOutlineReveal(state: *State, viewport: Viewport) void {
+ const destination = state.outline_reveal_pending orelse return;
+ state.outline_reveal_pending = null;
+ const page = @min(destination.page, state.page_count -| 1);
+ const size = state.page_sizes[page];
+ const raw_y = destination.y orelse 0;
+ const y = if (std.math.isFinite(raw_y)) std.math.clamp(raw_y, 0, size.height) else 0;
+ state.document_scroll_y = @as(f64, @floatFromInt(state.page_starts[page])) +
+ @as(f64, y) / @as(f64, size.height) * @as(f64, @floatFromInt(state.page_heights[page]));
+
+ if (destination.x) |raw_x| if (state.fit == .height and std.math.isFinite(raw_x)) {
+ const display_width = @as(f64, @floatFromInt(viewport.pixel_h)) *
+ @as(f64, size.width) / @as(f64, size.height);
+ const viewport_width: f64 = @floatFromInt(viewport.pixel_w);
+ if (display_width > viewport_width) {
+ const x = std.math.clamp(raw_x, 0, size.width);
+ const target = @as(f64, x) / @as(f64, size.width) * display_width;
+ const overflow = display_width - viewport_width;
+ const wanted = std.math.clamp(target - viewport_width / 2, 0, overflow);
+ state.pan_x = @intFromFloat(@round(
+ wanted / overflow * @as(f64, std.math.maxInt(u16)),
+ ));
+ }
+ };
+ state.search_reveal_pending = false;
+ state.reveal_viewport_w = viewport.pixel_w;
+ state.reveal_viewport_h = viewport.pixel_h;
+ state.reveal_fit = state.fit;
+ state.reveal_viewport_valid = true;
+}
+
+pub fn ensureLayout(state: *State, viewport: Viewport) void {
+ if (comptime !enabled) return;
+ const tz = tracy.zone(@src(), "pdf.ensure_layout");
+ defer tz.end();
+ if (state.layout_valid and !state.scroll_to_page_pending and
+ !state.layout_anchor_pending and
+ (state.layout_viewport_w != viewport.pixel_w or
+ state.layout_viewport_h != viewport.pixel_h)) captureLayoutAnchor(state);
+ if (!state.layout_valid or state.layout_viewport_w != viewport.pixel_w or
+ state.layout_viewport_h != viewport.pixel_h or state.layout_fit != state.fit)
+ {
+ var at: u64 = 0;
+ for (state.page_sizes, 0..) |size, page| {
+ state.page_starts[page] = at;
+ const height = pageHeight(size, viewport, state.fit);
+ state.page_heights[page] = height;
+ at = std.math.add(u64, at, height) catch std.math.maxInt(u64);
+ if (page + 1 < state.page_count)
+ at = std.math.add(u64, at, page_gap_px) catch std.math.maxInt(u64);
+ }
+ state.document_height = at;
+ state.layout_viewport_w = viewport.pixel_w;
+ state.layout_viewport_h = viewport.pixel_h;
+ state.layout_fit = state.fit;
+ state.layout_valid = true;
+ if (state.scroll_to_page_pending) {
+ state.document_scroll_y = @floatFromInt(state.page_starts[state.page]);
+ state.scroll_to_page_pending = false;
+ state.layout_anchor_pending = false;
+ } else if (state.layout_anchor_pending) {
+ const page = @min(state.layout_anchor_page, state.page_count -| 1);
+ state.document_scroll_y = @as(f64, @floatFromInt(state.page_starts[page])) +
+ state.layout_anchor_fraction * @as(f64, @floatFromInt(state.page_heights[page]));
+ state.layout_anchor_pending = false;
+ }
+ }
+ consumeOutlineReveal(state, viewport);
+ const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h));
+ state.document_scroll_y = std.math.clamp(state.document_scroll_y, 0, max_scroll);
+}
+
+/// The owned result of asking MuPDF for the word at one page-space point.
+/// It deliberately carries no pane state: callers may retain it for a hover
+/// or consume it for Look without installing a user selection or activating a
+/// page. Quads and text have separate lifetimes in MuPDF, and remain separate
+/// here rather than making a transient hover impersonate `pdf.Selection`.
+pub const WordProbe = if (enabled) struct {
+ page: usize,
+ quads: []pdf.Quad,
+ text: []u8,
+
+ pub fn deinit(probe: *@This(), gpa: std.mem.Allocator) void {
+ gpa.free(probe.quads);
+ gpa.free(probe.text);
+ probe.* = undefined;
+ }
+} else void;
+
+/// Complete state of one native PDF pointer gesture. Core stores this beside
+/// its generic drag routing; every PDF-specific transition is implemented by
+/// pointerStart/pointerUpdate/pointerRelease below. The feature-off spelling
+/// is deliberately empty so a build without MuPDF carries no latent UI state.
+pub const PointerDrag = if (enabled) struct {
+ native: bool = false,
+ /// Right-button Look probes this cell on release without borrowing or
+ /// replacing the pane's persistent MuPDF selection.
+ word_at: ?struct { col: u16, row: u16 } = null,
+ /// Drag updates keep selection geometry/text live, but their expensive
+ /// baked raster highlight is committed once on release.
+ selection_changed: bool = false,
+} else struct {};
+
+pub const PointerAction = enum { look, exec };
+
+/// Plain post-transaction work for the core. `text` is either pane-owned
+/// selection text or a slice owned by `probe`; both stay valid through the
+/// immediate builtin dispatch. Only the independent probe allocation is
+/// released afterward, so Exec may delete the source pane safely.
+pub const PointerRelease = if (enabled) struct {
+ action: ?PointerAction = null,
+ text: []const u8 = &.{},
+ probe: ?WordProbe = null,
+
+ pub fn deinit(release: *@This(), gpa: std.mem.Allocator) void {
+ if (release.probe) |*probe| probe.deinit(gpa);
+ release.* = undefined;
+ }
+} else struct {
+ pub fn deinit(_: *@This(), _: std.mem.Allocator) void {}
+};
+
+/// Word-snap a point and copy its text without changing document or UI state.
+/// Empty MuPDF selections are reported as `null`; every non-null result owns
+/// both slices and must be deinitialized by the caller.
+pub fn probeWord(
+ document: *Document,
+ gpa: std.mem.Allocator,
+ page: usize,
+ point: if (enabled) pdf.Point else void,
+) !?WordProbe {
+ if (comptime !enabled) return null;
+ var selection = try document.select(gpa, page, point, point);
+ errdefer selection.deinit(gpa);
+ const text = try document.copySelection(gpa, page, selection.start, selection.end);
+ errdefer gpa.free(text);
+ if (selection.quads.len == 0 or text.len == 0) {
+ selection.deinit(gpa);
+ gpa.free(text);
+ return null;
+ }
+ // Transfer the quad allocation out of Selection. There is intentionally
+ // no Selection.deinit on this success path: WordProbe is now its owner.
+ return .{ .page = page, .quads = selection.quads, .text = text };
+}
+
+/// Invert the exact native placement of one retained raster band. The
+/// destination is shifted by `pixel_offset_y` at presentation time, while its
+/// source y is local to the retained band; both adjustments happen here before
+/// returning normalized full-page coordinates.
+pub fn pointAtGeometry(
+ geometry: image.NativeGeometry,
+ pixel_offset_y: f32,
+ full_width: usize,
+ full_height: usize,
+ band_y: usize,
+ pixel_x: i64,
+ pixel_y: i64,
+ clamp_to_page: bool,
+) ?Point {
+ if (comptime !enabled) return null;
+ if (full_width == 0 or full_height == 0 or
+ geometry.src.w == 0 or geometry.src.h == 0 or
+ geometry.dst.w == 0 or geometry.dst.h == 0) return null;
+
+ const left: f64 = @floatFromInt(geometry.dst.x);
+ const top: f64 = @floatFromInt(geometry.dst.y);
+ const right = left + @as(f64, @floatFromInt(geometry.dst.w));
+ const bottom = top + @as(f64, @floatFromInt(geometry.dst.h));
+ var x: f64 = @floatFromInt(pixel_x);
+ var y: f64 = @as(f64, @floatFromInt(pixel_y)) - @as(f64, pixel_offset_y);
+ if (clamp_to_page) {
+ // Half-open rectangles: keep a clamped point infinitesimally inside
+ // the last presented pixel instead of letting it become `right`.
+ const epsilon = 1.0 / 1024.0;
+ x = std.math.clamp(x, left, @max(left, right - epsilon));
+ y = std.math.clamp(y, top, @max(top, bottom - epsilon));
+ } else if (x < left or x >= right or y < top or y >= bottom) {
+ return null;
+ }
+
+ const source_x_f = @as(f64, @floatFromInt(geometry.src.x)) +
+ (x - left) * @as(f64, @floatFromInt(geometry.src.w)) /
+ @as(f64, @floatFromInt(geometry.dst.w));
+ const source_y_f = @as(f64, @floatFromInt(geometry.src.y)) +
+ (y - top) * @as(f64, @floatFromInt(geometry.src.h)) /
+ @as(f64, @floatFromInt(geometry.dst.h));
+ const source_x: usize = @min(full_width - 1, @as(usize, @intFromFloat(@floor(source_x_f))));
+ const band_source_y: usize = @intFromFloat(@floor(source_y_f));
+ const source_y = @min(full_height - 1, band_y + band_source_y);
+ return .{
+ .x = (@as(f32, @floatFromInt(source_x)) + 0.5) /
+ @as(f32, @floatFromInt(full_width)),
+ .y = (@as(f32, @floatFromInt(source_y)) + 0.5) /
+ @as(f32, @floatFromInt(full_height)),
+ };
+}
+
+// ---- core seam ----------------------------------------------------------
+//
+// These functions own everything a pane does because its payload is a PDF.
+// Pardes supplies the shared layout rectangle, allocators and Surface; this
+// module changes the PDF state and paints its native attachments directly.
+// Cross-pane placement remains a tiny Pardes primitive; the PDF-specific
+// +PdfSections ownership and Look adapter live here beside their state.
+
+pub fn openPane(
+ core: *pardes.Pardes,
+ id: usize,
+ path: []const u8,
+ page_one_based: usize,
+) !*pardes.Pane {
+ if (comptime !enabled) return error.PdfDisabled;
+ var state = try State.open(core.pdf_gpa, path, page_one_based);
+ errdefer state.deinit(core.pdf_gpa);
+ const pane = try core.newDocPane(id);
+ pane.pdf = state;
+ pane.cur_pinned = true;
+ core.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } });
+ return pane;
+}
+
+/// Release a PDF payload while its pane slot is still installed, so the host
+/// can retire the corresponding directory watch before that id is reused.
+pub fn deinitPane(core: *pardes.Pardes, pane: *pardes.Pane, state: *State) void {
+ if (comptime !enabled) return;
+ for (core.panes, 0..) |slot, id| {
+ if (slot == pane) core.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } });
+ }
+ state.deinit(core.pdf_gpa);
+}
+
+/// Reopen one live pane after its watched path changed. MuPDF deliberately
+/// reopens the pathname so it can retain random access without requiring a
+/// native watcher to allocate and copy the whole document first.
+pub fn reloadPane(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+) !void {
+ if (comptime !enabled) return error.PdfDisabled;
+ const state = &(pane.pdf orelse return error.MissingPdfState);
+ try state.reload(core.pdf_gpa);
+ resetPageChrome(pane);
+}
+
+pub fn isSectionsOutput(pane: *const pardes.Pane) bool {
+ if (comptime !enabled) return false;
+ const file = pane.file orelse return false;
+ const output = file.output orelse return false;
+ return switch (output.from) {
+ .cmd => |builtin| builtin == .PdfSections,
+ else => false,
+ };
+}
+
+/// Refresh the exact generated outline buffer owned by this document. Content
+/// revision is the ownership boundary: once a user edits it, live reload must
+/// never overwrite it even if the slot and origin still look familiar.
+fn refreshCleanSections(core: *pardes.Pardes, state: *State) void {
+ if (comptime !enabled) return;
+ const remembered = state.sections_output orelse return;
+ if (remembered.pane >= core.panes.len) return;
+ const result = core.panes[remembered.pane] orelse return;
+ if (result.serial != remembered.serial or !isSectionsOutput(result)) return;
+ const file = &result.file.?;
+ if (file.revision != remembered.revision) return;
+
+ const content = state.renderSections(core.pdf_gpa, core.gpa) catch {
+ state.sections_output = null;
+ return;
+ };
+ if (std.mem.eql(u8, file.content, content)) {
+ core.gpa.free(content);
+ return;
+ }
+ core.invalidateLookHover(remembered.pane);
+ file_pane.setContent(core, file, content);
+ const rows = file_pane.lineCount(file.content);
+ file.scroll = @min(file.scroll, rows - 1);
+ const row = @min(@as(usize, @intCast(@max(0, result.cur_row))), rows - 1);
+ result.cur_row = @intCast(row);
+ result.cur_col = @intCast(@min(
+ @as(usize, @intCast(@max(0, result.cur_col))),
+ modal.lineSlice(file.content, row).len,
+ ));
+ result.msel.active = false;
+ result.vsel.active = false;
+ result.nsel = 0;
+ state.sections_output.?.revision = file.revision;
+}
+
+/// One successful watched-path transaction, including every piece of derived
+/// PDF output. The caller owns generic update bookkeeping and status text.
+pub fn reloadWatched(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+) !void {
+ try reloadPane(core, pane);
+ refreshCleanSections(core, &pane.pdf.?);
+}
+
+fn rearmCleanSections(
+ core: *pardes.Pardes,
+ id: usize,
+ pane: *pardes.Pane,
+ state: *State,
+) bool {
+ const remembered = state.sections_output orelse return false;
+ if (remembered.pane >= core.panes.len) return false;
+ const result = core.panes[remembered.pane] orelse return false;
+ if (result.serial != remembered.serial or !isSectionsOutput(result)) return false;
+ const file = &result.file.?;
+ if (file.revision != remembered.revision) return false;
+
+ file.scroll = 0;
+ result.cur_row = 0;
+ result.cur_col = 0;
+ result.msel.active = false;
+ result.vsel.active = false;
+ result.nsel = 0;
+ pane.search_pane = remembered.pane;
+ pane.search_row = null;
+ core.armLookWalk(remembered.pane);
+ core.active = id;
+ return true;
+}
+
+/// Lazily materialise this pane's cached outline as a location list. All
+/// PDF-specific ownership stays here; Pardes contributes only placement.
+pub fn openSections(core: *pardes.Pardes, id: usize) void {
+ if (comptime !enabled) return;
+ const pane = core.panes[id] orelse return;
+ const state = &(pane.pdf orelse return);
+ if (rearmCleanSections(core, id, pane, state)) return;
+ const content = state.renderSections(core.pdf_gpa, core.gpa) catch return;
+
+ const free = core.freeSlot() orelse {
+ core.gpa.free(content);
+ return;
+ };
+ const dir = std.fs.path.dirname(state.path) orelse "/";
+ const result = output_pane.open(
+ core,
+ free,
+ dir,
+ .{ .cmd = .PdfSections },
+ "",
+ content,
+ ) catch {
+ core.gpa.free(content);
+ return;
+ };
+ state.sections_output = .{
+ .pane = free,
+ .serial = result.serial,
+ .revision = result.file.?.revision,
+ };
+ core.placeDoc(id, free, result);
+ core.computeGeom();
+ core.active = id;
+ pane.search_pane = free;
+ pane.search_row = null;
+ core.armLookWalk(free);
+}
+
+const SectionsOwner = struct {
+ id: usize,
+ pane: *pardes.Pane,
+ state: *State,
+};
+
+/// Reverse the state-side ownership token. Merely having +PdfSections origin
+/// is insufficient: an output may outlive its document, and duplicate panes
+/// may legitimately have the same pathname.
+fn sectionsOwner(core: *pardes.Pardes, output_id: usize) ?SectionsOwner {
+ const output = core.panes[output_id] orelse return null;
+ if (!isSectionsOutput(output)) return null;
+ const file = output.file.?;
+ for (core.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ const state = if (pane.pdf) |*pdf_state| pdf_state else continue;
+ const token = state.sections_output orelse continue;
+ if (token.pane == output_id and token.serial == output.serial and
+ token.revision == file.revision)
+ return .{ .id = id, .pane = pane, .state = state };
+ }
+ return null;
+}
+
+/// Interpret the ordinal column used only by a live, exact +PdfSections
+/// output. Stale/orphaned outputs are consumed inertly rather than resolving
+/// their old ordinal against a different document generation.
+pub fn lookSection(
+ core: *pardes.Pardes,
+ output_id: usize,
+ path: []const u8,
+ at: look.Spot,
+) bool {
+ if (comptime !enabled) return false;
+ const output = core.panes[output_id] orelse return false;
+ if (!isSectionsOutput(output)) return false;
+ if (at.line == 0 or at.col == 0 or at.end_line != 0 or
+ !look.isPdfPath(path)) return false;
+ const owner = sectionsOwner(core, output_id) orelse return true;
+
+ var joined_path: [4096]u8 = undefined;
+ const output_dir = std.fs.path.dirname(output.file.?.path) orelse "/";
+ const separator = if (std.mem.endsWith(u8, output_dir, "/")) "" else "/";
+ const target_path = if (std.fs.path.isAbsolute(path))
+ path
+ else
+ std.fmt.bufPrint(&joined_path, "{s}{s}{s}", .{ output_dir, separator, path }) catch return true;
+ if (!std.mem.eql(u8, owner.state.path, target_path)) return true;
+
+ const destination = owner.state.sectionDestination(core.pdf_gpa, at.col - 1) orelse return true;
+ switch (destination) {
+ .internal => |internal| revealOutlineDestination(core, owner.pane, internal),
+ .external => |uri| if (uri.len <= 256) core.emit(.{ .open_link = .from(uri) }),
+ .none => unreachable,
+ }
+ core.active = owner.id;
+ return true;
+}
+
+pub fn resetPageChrome(pane: *pardes.Pane) void {
+ pane.cur_row = 0;
+ pane.cur_col = 0;
+ pane.vsel.active = false;
+ pane.msel.active = false;
+ pane.nsel = 0;
+}
+
+pub fn activatePage(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ page: usize,
+ reveal: bool,
+) void {
+ if (comptime !enabled) return;
+ const state = &(pane.pdf orelse return);
+ if (state.activatePage(core.pdf_gpa, page, reveal)) resetPageChrome(pane);
+}
+
+pub fn revealOutlineDestination(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ destination: OutlineInternalDestination,
+) void {
+ if (comptime !enabled) return;
+ activatePage(core, pane, destination.page, false);
+ const state = &pane.pdf.?;
+ state.queueOutlineReveal(destination);
+ // Consume immediately when geometry already exists; otherwise the PDF
+ // draw pass consumes the same value after the next layout transaction.
+ _ = ensurePaneLayout(core, pane);
+}
+
+pub fn setPage(core: *pardes.Pardes, pane: *pardes.Pane, page: usize) void {
+ activatePage(core, pane, page, true);
+}
+
+pub fn toggleFit(pane: *pardes.Pane) void {
+ if (comptime !enabled) return;
+ const state = &(pane.pdf orelse return);
+ state.toggleFit();
+}
+
+pub fn toggleTint(pane: *pardes.Pane) void {
+ if (comptime !enabled) return;
+ const state = &(pane.pdf orelse return);
+ state.toggleTint();
+}
+
+pub fn stepPanePage(core: *pardes.Pardes, pane: *pardes.Pane, delta: i64) void {
+ if (comptime !enabled) return;
+ const state = &(pane.pdf orelse return);
+ if (stepPage(state, core.pdf_gpa, delta)) resetPageChrome(pane);
+}
+
+pub fn paneViewport(core: *const pardes.Pardes, pane: *const pardes.Pane) ?Viewport {
+ if (comptime !enabled) return null;
+ const rect = for (core.panes, 0..) |slot, id| {
+ if (slot == pane) break core.rects[id];
+ } else return null;
+ const cols = rect.w -| config.GUTTER;
+ const rows = rect.h -| pardes.BOX_H;
+ if (cols == 0 or rows == 0) return null;
+ return .{
+ .pixel_w = @as(u32, cols) * @as(u32, core.cell_pixels.w),
+ .pixel_h = @as(u32, rows) * @as(u32, core.cell_pixels.h),
+ };
+}
+
+pub fn ensurePaneLayout(core: *pardes.Pardes, pane: *pardes.Pane) ?Viewport {
+ if (comptime !enabled) return null;
+ const state = &(pane.pdf orelse return null);
+ const view = paneViewport(core, pane) orelse return null;
+ ensureLayout(state, view);
+ return view;
+}
+
+pub fn tintColors(core: *const pardes.Pardes) TintColors {
+ if (comptime !enabled) return;
+ const theme = core.theme();
+ return .{
+ .background = theme.bg orelse theme.tag_bg,
+ .foreground = theme.fg orelse theme.tag_fg,
+ };
+}
+
+pub fn paneGeometry(core: *const pardes.Pardes, pane: *const pardes.Pane) ?image.NativeGeometry {
+ if (comptime !enabled) return null;
+ const state = if (pane.pdf) |*view| view else return null;
+ const view = paneViewport(core, pane) orelse return null;
+ return activeGeometry(state, view);
+}
+
+pub fn pageAtGridRow(core: *const pardes.Pardes, pane: *const pardes.Pane, row: u16) ?usize {
+ if (comptime !enabled) return null;
+ const state = pane.pdf orelse return null;
+ const rect = for (core.panes, 0..) |slot, id| {
+ if (slot == pane) break core.rects[id];
+ } else return null;
+ const body_y = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H;
+ if (row < body_y or row >= body_y + (rect.h -| pardes.BOX_H)) return null;
+ const local_y = @as(f64, @floatFromInt(
+ @as(u32, row - body_y) * core.cell_pixels.h + core.cell_pixels.h / 2,
+ ));
+ return pageAtViewportY(&state, local_y);
+}
+
+pub fn paneNativeReady(core: *const pardes.Pardes, pane: *const pardes.Pane) bool {
+ if (comptime !enabled) return false;
+ if (!core.native_images) return false;
+ const state = if (pane.pdf) |*view| view else return false;
+ const view = paneViewport(core, pane) orelse return false;
+ return nativeReady(state, view);
+}
+
+pub fn nativePageAtGridRow(
+ core: *const pardes.Pardes,
+ pane: *const pardes.Pane,
+ row: u16,
+) ?usize {
+ if (comptime !enabled) return null;
+ if (!core.native_images) return null;
+ const state = if (pane.pdf) |*view| view else return null;
+ const page = pageAtGridRow(core, pane, row) orelse return null;
+ const view = paneViewport(core, pane) orelse return null;
+ if (!pageReady(state, view, page)) return null;
+ return page;
+}
+
+pub fn pointAt(
+ core: *const pardes.Pardes,
+ pane: *const pardes.Pane,
+ col: u16,
+ row: u16,
+ clamp_to_page: bool,
+) ?Point {
+ if (comptime !enabled) return null;
+ const state = pane.pdf orelse return null;
+ return panePointAtPage(core, pane, state.page, col, row, clamp_to_page);
+}
+
+pub fn panePointAtPage(
+ core: *const pardes.Pardes,
+ pane: *const pardes.Pane,
+ page: usize,
+ col: u16,
+ row: u16,
+ clamp_to_page: bool,
+) ?Point {
+ if (comptime !enabled) return null;
+ const state = if (pane.pdf) |*view| view else return null;
+ const view = paneViewport(core, pane) orelse return null;
+ const rect = for (core.panes, 0..) |slot, id| {
+ if (slot == pane) break core.rects[id];
+ } else return null;
+ const body_x = @as(i64, rect.x + config.GUTTER);
+ const body_y = @as(i64, if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H);
+ const px = (@as(i64, col) - body_x) * core.cell_pixels.w + core.cell_pixels.w / 2;
+ const py = (@as(i64, row) - body_y) * core.cell_pixels.h + core.cell_pixels.h / 2;
+ return pointAtPage(state, view, page, px, py, clamp_to_page);
+}
+
+pub fn probeAt(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ col: u16,
+ row: u16,
+) ?WordProbe {
+ if (comptime !enabled) return null;
+ const page = nativePageAtGridRow(core, pane, row) orelse return null;
+ const point = panePointAtPage(core, pane, page, col, row, false) orelse return null;
+ const state = &(pane.pdf orelse return null);
+ return probeWord(&state.document, core.pdf_gpa, page, point) catch null;
+}
+
+pub fn beginDrag(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ col: u16,
+ row: u16,
+ snap_word: bool,
+) bool {
+ if (comptime !enabled) return false;
+ const hit_page = pageAtGridRow(core, pane, row) orelse return false;
+ if (hit_page != pane.pdf.?.page) activatePage(core, pane, hit_page, false);
+ const state = &pane.pdf.?;
+ state.clearDrag();
+ const point = pointAt(core, pane, col, row, false) orelse return false;
+ state.drag_anchor = point;
+ state.drag_head = point;
+ if (!snap_word) return true;
+ if (state.selection) |selection| if (selection.contains(point)) return true;
+ if (state.setSelection(core.pdf_gpa, point, point, true) == .failed) {
+ // Preserve the old selection transactionally, but never let an
+ // outside click execute its stale text.
+ state.clearDrag();
+ return false;
+ }
+ return true;
+}
+
+pub fn beginSelection(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ col: u16,
+ row: u16,
+) bool {
+ return beginDrag(core, pane, col, row, true);
+}
+
+pub fn updateSelection(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ col: u16,
+ row: u16,
+ invalidate_raster: bool,
+) SelectionUpdate {
+ if (comptime !enabled) return .failed;
+ const state = &(pane.pdf orelse return .failed);
+ const anchor = state.drag_anchor orelse return .failed;
+ const point = pointAt(core, pane, col, row, true) orelse return .failed;
+ if (state.drag_head) |head| if (head.x == point.x and head.y == point.y) return .stationary;
+ const result = state.setSelection(core.pdf_gpa, anchor, point, invalidate_raster);
+ if (result != .failed) state.drag_head = point;
+ return result;
+}
+
+/// Begin the native portion of a generic pointer gesture. Tag clicks and PDF
+/// panes without a presentable raster remain ordinary grid gestures. A Look
+/// click defers its side-effect-free word probe until release; select/Exec
+/// establish the persistent selection transaction immediately.
+pub fn pointerStart(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ button: pardes.Mouse.Button,
+ col: u16,
+ row: u16,
+ on_tag: bool,
+) PointerDrag {
+ if (comptime !enabled) return .{};
+ if (on_tag or !paneNativeReady(core, pane)) return .{};
+ var drag: PointerDrag = .{ .native = true };
+ if (button == config.look_button) {
+ drag.word_at = .{ .col = col, .row = row };
+ } else if (button == config.select_button) {
+ _ = beginDrag(core, pane, col, row, false);
+ } else if (button == config.exec_button) {
+ _ = beginDrag(core, pane, col, row, true);
+ }
+ return drag;
+}
+
+/// Advance selection state without baking a new raster. A right-click stays a
+/// pure word probe until it crosses into a second grid cell; at that point it
+/// becomes the same native selection drag as Exec.
+pub fn pointerUpdate(
+ drag: *PointerDrag,
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ col: u16,
+ row: u16,
+) void {
+ if (comptime !enabled) return;
+ if (!drag.native) return;
+ if (drag.word_at) |at| {
+ if (col == at.col and row == at.row) return;
+ if (!beginDrag(core, pane, at.col, at.row, false)) {
+ drag.word_at = null;
+ return;
+ }
+ switch (updateSelection(core, pane, col, row, false)) {
+ .failed => {
+ drag.word_at = null;
+ pane.pdf.?.clearDrag();
+ },
+ // Adjacent grid cells can still address one raster pixel. Keep
+ // click mode and retry farther out.
+ .stationary => {},
+ .unchanged => drag.word_at = null,
+ .changed => {
+ drag.word_at = null;
+ drag.selection_changed = true;
+ },
+ }
+ return;
+ }
+ switch (updateSelection(core, pane, col, row, false)) {
+ .failed => pane.pdf.?.clearDrag(),
+ .stationary, .unchanged => {},
+ .changed => drag.selection_changed = true,
+ }
+}
+
+/// Cancel a native gesture before release (the acme 2-3 / 3-2 chord). Word
+/// probes own nothing until release; changed selections still need their one
+/// raster commit before their transient anchors are dropped.
+pub fn pointerCancel(pane: *pardes.Pane, drag: PointerDrag) void {
+ if (comptime !enabled) return;
+ if (drag.native) if (pane.pdf) |*state| {
+ if (drag.selection_changed) state.invalidateRaster(state.page);
+ state.clearDrag();
+ };
+}
+
+/// Finalize every pane mutation before returning command work to the core.
+/// The caller may dispatch `action` immediately and then call deinit; no path
+/// after this function needs the source pane to remain alive.
+pub fn pointerRelease(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ drag: PointerDrag,
+ button: pardes.Mouse.Button,
+ chorded: bool,
+) PointerRelease {
+ if (comptime !enabled) return .{};
+ const state = &(pane.pdf orelse return .{});
+ const slot = @intFromEnum(button);
+ const grid_selection = pane.sel[slot];
+ pane.sel[slot].state = .none; // native quads, not projected text cells
+
+ // Drag updates changed live geometry/text while leaving baked pixels
+ // stable. Commit exactly once before any chord/Exec/Look can clear state.
+ if (drag.selection_changed) state.invalidateRaster(state.page);
+ if (chorded) {
+ state.clearDrag();
+ return .{};
+ }
+ if (drag.word_at) |at| {
+ const maybe_probe = probeAt(core, pane, at.col, at.row);
+ state.clearDrag();
+ const probe = maybe_probe orelse return .{};
+ // A neighboring visible page becomes current before Look dispatch.
+ // WordProbe owns its text/quads independently of that state change.
+ if (probe.page != state.page) activatePage(core, pane, probe.page, false);
+ const text = probe.text;
+ return .{ .action = .look, .text = text, .probe = probe };
+ }
+ if (button == config.select_button) {
+ const dragged = grid_selection.c0 != grid_selection.c1 or
+ grid_selection.r0 != grid_selection.r1;
+ if (!dragged) state.clearSelection(core.pdf_gpa);
+ pane.cur_pinned = true;
+ pane.mode = .normal;
+ pane.msel.active = false;
+ pane.vsel.active = false;
+ pane.pending = 0;
+ pane.nsel = 0;
+ state.clearDrag();
+ return .{};
+ }
+ if (state.drag_anchor == null or state.selection_text.len == 0) {
+ state.clearDrag();
+ return .{};
+ }
+ const text = state.selection_text;
+ state.clearDrag();
+ return .{
+ .action = if (button == config.look_button) .look else .exec,
+ .text = text,
+ };
+}
+
+pub fn highlightInput(
+ core: *pardes.Pardes,
+ pane_id: usize,
+ pane: *const pardes.Pane,
+) HighlightInput {
+ if (comptime !enabled) return;
+ const hover_quads: []const Quad = if (core.pdf_hover_preview) |preview|
+ if (preview.pane == pane_id and preview.serial == pane.serial)
+ preview.probe.quads
+ else
+ &.{}
+ else
+ &.{};
+ const hover_page = if (hover_quads.len > 0) core.pdf_hover_preview.?.probe.page else null;
+ const selection_color = core.theme().sel_bg;
+ return .{
+ .hover_quads = hover_quads,
+ .hover_page = hover_page,
+ .hover_color = selection_color,
+ .selection_color = selection_color,
+ };
+}
+
+pub fn panPane(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ axis: PanAxis,
+ direction: i8,
+ display_pixels: u32,
+) PanResult {
+ if (comptime !enabled) return .unavailable;
+ const placed = paneGeometry(core, pane) orelse return .unavailable;
+ const state = &(pane.pdf orelse return .unavailable);
+ return panPixels(state, placed, axis, direction, display_pixels);
+}
+
+pub fn scrollPane(core: *pardes.Pardes, pane: *pardes.Pane, delta_pixels: f64) bool {
+ if (comptime !enabled) return false;
+ const tz = tracy.zone(@src(), "pdf.scroll_notch");
+ defer tz.end();
+ const state = &(pane.pdf orelse return false);
+ const view = ensurePaneLayout(core, pane) orelse return false;
+ const result = scrollDocument(state, view, delta_pixels) orelse return false;
+ if (result.active_page != state.page) activatePage(core, pane, result.active_page, false);
+ return true;
+}
+
+pub fn verticalWheel(core: *pardes.Pardes, pane: *pardes.Pane, direction: i8) void {
+ if (comptime !enabled) return;
+ if (!core.native_images) return stepPanePage(core, pane, direction);
+ const rows: u32 = @intCast(@max(1, config.wheel_rows));
+ const pixels = scaledStep(core.cell_pixels.h, rows);
+ _ = scrollPane(core, pane, @as(f64, @floatFromInt(pixels)) * direction);
+}
+
+pub fn horizontalWheel(core: *pardes.Pardes, pane: *pardes.Pane, direction: i8) void {
+ if (comptime !enabled) return;
+ const state = pane.pdf orelse return;
+ if (!core.native_images or state.fit != .height) return;
+ const cols: u32 = @intCast(@max(1, config.wheel_cols));
+ _ = panPane(core, pane, .horizontal, direction, scaledStep(core.cell_pixels.w, cols));
+}
+
+/// Render every visible page attachment and the PDF-owned scrollbar. The
+/// canonical text body remains the core's fallback when native pixels are
+/// unavailable, so false means renderPane should continue normally.
+pub fn draw(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ rect: pardes.Rect,
+ pane_id: usize,
+ text_x: u16,
+ text_width: u16,
+) bool {
+ if (comptime !enabled) return false;
+ if (!core.native_images or rect.h <= pardes.BOX_H) return false;
+ const state = &(pane.pdf orelse return false);
+ const view = paneViewport(core, pane) orelse return false;
+ const key = TintKey{ .mode = state.tint, .colors = tintColors(core) };
+ const visible = renderFrame(
+ state,
+ core.pdf_gpa,
+ core.scratch.allocator(),
+ view,
+ pardes.pdf_raster_policy,
+ key,
+ highlightInput(core, pane_id, pane),
+ );
+ var placed_any = false;
+ var page = visible.first;
+ const visible_end = visible.first + visible.len;
+ while (page < visible_end) : (page += 1) {
+ const placed = placedRaster(state, view, page) orelse continue;
+ if (!core.appendImagePlace(.{
+ .pane = @intCast(pane_id),
+ .serial = pane.serial,
+ .native = .{
+ .revision = placed.revision,
+ .page = @intCast(placed.page),
+ .fit = switch (placed.fit) {
+ .width => .width,
+ .height => .height,
+ },
+ .pan_x = placed.pan_x,
+ .geometry = placed.geometry,
+ .pixel_offset_y = placed.pixel_offset_y,
+ },
+ .x = text_x,
+ .y = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H,
+ .w = text_width,
+ .h = rect.h - pardes.BOX_H,
+ .rgba = placed.rgba,
+ .iw = placed.width,
+ // The texture contains the retained band, not the full page.
+ .ih = placed.band_height,
+ })) break;
+ placed_any = true;
+ }
+ if (!placed_any) return false;
+
+ // This frame has spent the motion used to choose its raster band.
+ state.scroll_travel = 0;
+ const body_y = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H;
+ const chrome = core.chromeTheme();
+ const theme = core.theme();
+ const pane_bg: pardes.Color = if (theme.bg) |color| .{ .rgb = color } else .default;
+ core.surface.fill(rect.x, body_y, 1, rect.h -| pardes.BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } });
+ core.surface.fill(rect.x + 1, body_y, 1, rect.h -| pardes.BOX_H, .{ .bg = pane_bg });
+
+ const track_h: usize = rect.h - pardes.BOX_H;
+ const total = @max(@as(u64, 1), state.document_height);
+ const len = @max(
+ @as(usize, 1),
+ @as(usize, @intCast(@min(
+ @as(u64, track_h),
+ @as(u64, track_h) * view.pixel_h / total,
+ ))),
+ );
+ const offset: u64 = @intFromFloat(@floor(state.document_scroll_y));
+ const pos: usize = @intCast(@min(
+ @as(u64, track_h -| 1),
+ @as(u64, track_h) * offset / total,
+ ));
+ var y = pos;
+ while (y < track_h and y < pos + len) : (y += 1)
+ core.surface.fill(
+ rect.x,
+ body_y + @as(u16, @intCast(y)),
+ 1,
+ 1,
+ .{ .bg = .{ .rgb = chrome.scroll_thumb } },
+ );
+ return true;
+}
+
+pub const SectionRows = if (enabled) struct {
+ pub fn usableDestination(destination: pdf.OutlineDestination) ?pdf.OutlineDestination {
+ return switch (destination) {
+ .internal => destination,
+ .external => |uri| if (safeHttpUri(uri)) destination else null,
+ .none => null,
+ };
+ }
+
+ fn safeHttpUri(uri: []const u8) bool {
+ // Effect.open_link is inline and cannot carry a larger URL. Omitting
+ // it here is preferable to rendering a row which can never act.
+ if (uri.len > 256) return false;
+ var has_scheme = false;
+ for (config.url_schemes) |scheme| {
+ if (std.mem.startsWith(u8, uri, scheme)) {
+ has_scheme = true;
+ break;
+ }
+ }
+ if (!has_scheme) return false;
+ for (uri) |byte| if (byte <= 0x20 or byte == 0x7f) return false;
+ return true;
+ }
+
+ /// A structural node goes to the first later DFS entry still below it
+ /// which carries a usable destination. Nodes with their own unusable URI
+ /// are not structural: omit them instead of silently changing their link.
+ pub fn resolve(entries: []const pdf.OutlineEntry, ordinal: usize) ?pdf.OutlineDestination {
+ if (ordinal >= entries.len) return null;
+ const entry = entries[ordinal];
+ if (entry.destination != .none) return usableDestination(entry.destination);
+ var i = ordinal + 1;
+ while (i < entries.len and entries[i].depth > entry.depth) : (i += 1)
+ if (usableDestination(entries[i].destination)) |destination| return destination;
+ return null;
+ }
+
+ /// Resolve every rendered row in one DFS pass. `resolve` above stays the
+ /// public single-ordinal policy used by Look; bulk materialisation avoids
+ /// rescanning the same descendant chain for every structural ancestor.
+ /// The one-usize-per-entry table is transient (64 KiB at MuPDF's 8192
+ /// outline-item limit) and stores source ordinals, so URI slices continue
+ /// to borrow from the document-owned outline instead of being copied.
+ pub fn resolveOrdinals(
+ gpa: std.mem.Allocator,
+ entries: []const pdf.OutlineEntry,
+ ) ![]usize {
+ const unresolved = std.math.maxInt(usize);
+ const ordinals = try gpa.alloc(usize, entries.len);
+ @memset(ordinals, unresolved);
+
+ const Pending = struct { depth: u8, ordinal: usize };
+ // OutlineEntry.depth is u8. A valid DFS path therefore cannot hold
+ // more than 256 simultaneously unresolved ancestors, independent of
+ // the tighter limit enforced by the MuPDF bridge.
+ var pending: [256]Pending = undefined;
+ var pending_len: usize = 0;
+ for (entries, 0..) |entry, ordinal| {
+ while (pending_len > 0 and pending[pending_len - 1].depth >= entry.depth)
+ pending_len -= 1;
+
+ if (usableDestination(entry.destination) != null) {
+ ordinals[ordinal] = ordinal;
+ for (pending[0..pending_len]) |ancestor|
+ ordinals[ancestor.ordinal] = ordinal;
+ pending_len = 0;
+ } else if (entry.destination == .none) {
+ pending[pending_len] = .{ .depth = entry.depth, .ordinal = ordinal };
+ pending_len += 1;
+ }
+ }
+ return ordinals;
+ }
+
+ fn cleanTitle(out: ?[]u8, title: ?[]const u8) usize {
+ const raw = title orelse {
+ if (out) |buf| @memcpy(buf[0..10], "[untitled]");
+ return 10;
+ };
+ var at: usize = 0;
+ var i: usize = 0;
+ var wrote = false;
+ var pending_space = false;
+ while (i < raw.len) {
+ const n: usize = std.unicode.utf8ByteSequenceLength(raw[i]) catch {
+ pending_space = wrote;
+ i += 1;
+ continue;
+ };
+ if (i + n > raw.len) {
+ pending_space = wrote;
+ break;
+ }
+ const cp = std.unicode.utf8Decode(raw[i .. i + n]) catch {
+ pending_space = wrote;
+ i += n;
+ continue;
+ };
+ const whitespace_or_control = cp <= 0x20 or cp == 0x7f or
+ (cp >= 0x80 and cp <= 0x9f) or cp == 0x2028 or cp == 0x2029;
+ if (whitespace_or_control) {
+ pending_space = wrote;
+ } else {
+ if (pending_space) {
+ if (out) |buf| buf[at] = ' ';
+ at += 1;
+ }
+ if (out) |buf| @memcpy(buf[at..][0..n], raw[i .. i + n]);
+ at += n;
+ wrote = true;
+ pending_space = false;
+ }
+ i += n;
+ }
+ if (!wrote) {
+ if (out) |buf| @memcpy(buf[0..13], "[empty title]");
+ return 13;
+ }
+ return at;
+ }
+
+ pub fn render(gpa: std.mem.Allocator, path: []const u8, entries: []const pdf.OutlineEntry) ![]u8 {
+ const target = std.fs.path.basename(path);
+ const ordinals = try resolveOrdinals(gpa, entries);
+ defer gpa.free(ordinals);
+ var total: usize = 0;
+ for (entries, 0..) |entry, ordinal| {
+ const resolved = ordinals[ordinal];
+ if (resolved == std.math.maxInt(usize)) continue;
+ const destination = usableDestination(entries[resolved].destination) orelse unreachable;
+ total += switch (destination) {
+ .internal => |internal| std.fmt.count("{s}:{d}:{d} ", .{ target, internal.page + 1, ordinal + 1 }),
+ .external => |uri| std.fmt.count("{s} ", .{uri}),
+ .none => unreachable,
+ };
+ total += @as(usize, entry.depth) * 2 + cleanTitle(null, entry.title) + 1;
+ }
+ const out = try gpa.alloc(u8, total);
+ errdefer gpa.free(out);
+ var at: usize = 0;
+ for (entries, 0..) |entry, ordinal| {
+ const resolved = ordinals[ordinal];
+ if (resolved == std.math.maxInt(usize)) continue;
+ const destination = usableDestination(entries[resolved].destination) orelse unreachable;
+ const prefix = switch (destination) {
+ .internal => |internal| try std.fmt.bufPrint(out[at..], "{s}:{d}:{d} ", .{ target, internal.page + 1, ordinal + 1 }),
+ .external => |uri| try std.fmt.bufPrint(out[at..], "{s} ", .{uri}),
+ .none => unreachable,
+ };
+ at += prefix.len;
+ const indent = @as(usize, entry.depth) * 2;
+ @memset(out[at..][0..indent], ' ');
+ at += indent;
+ at += cleanTitle(out[at..], entry.title);
+ out[at] = '\n';
+ at += 1;
+ }
+ return out;
+ }
+} else struct {};
+
+test "PDF section rows preserve DFS ordinals and sanitise hierarchy" {
+ if (!enabled) return;
+ const entries = [_]pdf.OutlineEntry{
+ .{ .depth = 0, .title = null, .is_open = true, .flags = 0, .color = @splat(0), .destination = .none },
+ .{ .depth = 1, .title = " Child\n\tTitle\x01 Café \u{2028} Next ", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 2, .x = 12, .y = 34 } } },
+ .{ .depth = 0, .title = "", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 0, .x = null, .y = null } } },
+ .{ .depth = 0, .title = "External", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .external = "https://example.com/manual" } },
+ .{ .depth = 0, .title = "Dead branch", .is_open = false, .flags = 0, .color = @splat(0), .destination = .none },
+ .{ .depth = 0, .title = "Unsafe", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .external = "javascript:alert" } },
+ .{ .depth = 0, .title = "Linked branch", .is_open = true, .flags = 0, .color = @splat(0), .destination = .none },
+ .{ .depth = 1, .title = "Deep link", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .external = "https://example.com/deep" } },
+ };
+ const rows = try SectionRows.render(std.testing.allocator, "/tmp/manual.pdf", &entries);
+ defer std.testing.allocator.free(rows);
+ try std.testing.expectEqualStrings(
+ "manual.pdf:3:1 [untitled]\n" ++
+ "manual.pdf:3:2 Child Title Café Next\n" ++
+ "manual.pdf:1:3 [empty title]\n" ++
+ "https://example.com/manual External\n" ++
+ "https://example.com/deep Linked branch\n" ++
+ "https://example.com/deep Deep link\n",
+ rows,
+ );
+ try std.testing.expect(SectionRows.resolve(&entries, 4) == null);
+ try std.testing.expect(SectionRows.resolve(&entries, 5) == null);
+ const resolved = try SectionRows.resolveOrdinals(std.testing.allocator, &entries);
+ defer std.testing.allocator.free(resolved);
+ for (entries, 0..) |_, ordinal| {
+ const single = SectionRows.resolve(&entries, ordinal);
+ if (resolved[ordinal] == std.math.maxInt(usize)) {
+ try std.testing.expect(single == null);
+ } else {
+ const bulk = SectionRows.usableDestination(entries[resolved[ordinal]].destination) orelse
+ return error.MissingBulkPdfSectionDestination;
+ try std.testing.expect(single != null);
+ try std.testing.expect(std.meta.eql(single.?, bulk));
+ }
+ }
+
+ // Every allocation site in the ordinal table and output growth remains
+ // atomic: the testing allocator sees each induced failure cleaned up
+ // before the first index at which the whole render can succeed.
+ var rendered = false;
+ for (0..64) |fail_index| {
+ var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{
+ .fail_index = fail_index,
+ });
+ const failure_gpa = failing.allocator();
+ const attempt = SectionRows.render(failure_gpa, "/tmp/manual.pdf", &entries) catch |err| {
+ try std.testing.expectEqual(error.OutOfMemory, err);
+ continue;
+ };
+ failure_gpa.free(attempt);
+ rendered = true;
+ break;
+ }
+ try std.testing.expect(rendered);
+}
+
+test "bulk PDF section resolution matches single Look policy across DFS boundaries" {
+ if (!enabled) return;
+ const entries = [_]pdf.OutlineEntry{
+ .{ .depth = 0, .title = "Resolved root", .is_open = true, .flags = 0, .color = @splat(0), .destination = .none },
+ .{ .depth = 1, .title = "Unsafe branch", .is_open = true, .flags = 0, .color = @splat(0), .destination = .{ .external = "javascript:unsafe" } },
+ .{ .depth = 2, .title = "Safe grandchild", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 1, .x = 4, .y = 8 } } },
+ .{ .depth = 1, .title = "Unresolved child", .is_open = false, .flags = 0, .color = @splat(0), .destination = .none },
+ .{ .depth = 1, .title = "Sibling", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 2, .x = null, .y = null } } },
+ .{ .depth = 0, .title = "Unresolved root", .is_open = false, .flags = 0, .color = @splat(0), .destination = .none },
+ .{ .depth = 0, .title = "Next root", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 3, .x = null, .y = null } } },
+ };
+ const bulk = try SectionRows.resolveOrdinals(std.testing.allocator, &entries);
+ defer std.testing.allocator.free(bulk);
+ const none = std.math.maxInt(usize);
+ try std.testing.expectEqualSlices(usize, &.{ 2, none, 2, none, 4, none, 6 }, bulk);
+ for (entries, 0..) |_, ordinal| {
+ const single = SectionRows.resolve(&entries, ordinal);
+ if (bulk[ordinal] == none) {
+ try std.testing.expect(single == null);
+ } else {
+ const destination = SectionRows.usableDestination(entries[bulk[ordinal]].destination) orelse
+ return error.MissingBoundaryPdfSectionDestination;
+ try std.testing.expect(single != null);
+ try std.testing.expect(std.meta.eql(single.?, destination));
+ }
+ }
+}
+
+test "PDF word probe owns quads and text without a selection object" {
+ if (comptime !enabled) return;
+ var document = try Document.open("docs/design.pdf");
+ defer document.deinit();
+ var found = try document.search(std.testing.allocator, 0, "Pardes");
+ defer found.deinit(std.testing.allocator);
+ const quad = found.quads[0].quad;
+ const point = Point{
+ .x = (quad.ul.x + quad.ur.x + quad.ll.x + quad.lr.x) / 4,
+ .y = (quad.ul.y + quad.ur.y + quad.ll.y + quad.lr.y) / 4,
+ };
+
+ // Exactly one shared probe exercises the primitive used by both UI paths;
+ // click-vs-hover equivalence belongs to production structure, not a second
+ // test that can only restate this MuPDF result.
+ var probe = (try probeWord(&document, std.testing.allocator, 0, point)) orelse
+ return error.MissingPdfWordProbe;
+ defer probe.deinit(std.testing.allocator);
+ try std.testing.expectEqual(@as(usize, 0), probe.page);
+ try std.testing.expect(probe.quads.len > 0);
+ try std.testing.expect(std.ascii.indexOfIgnoreCase(probe.text, "Pardes") != null);
+}
+
+test "PDF point mapping restores band origin and fractional placement" {
+ if (comptime !enabled) return;
+ const point = pointAtGeometry(
+ .{
+ .src = .{ .x = 20, .y = 10, .w = 40, .h = 20 },
+ .dst = .{ .x = 100, .y = 30, .w = 80, .h = 20 },
+ },
+ -0.5,
+ 200,
+ 200,
+ 80,
+ 120,
+ 34,
+ false,
+ ) orelse return error.MissingPdfMappedPoint;
+ // x: src 30; y: band 80 + local src 14. The returned coordinates address
+ // pixel centers, matching MuPDF's normalized page-space contract.
+ try std.testing.expectApproxEqAbs(@as(f32, 30.5 / 200.0), point.x, 0.000_001);
+ try std.testing.expectApproxEqAbs(@as(f32, 94.5 / 200.0), point.y, 0.000_001);
+}
diff --git a/src/pdf_pane_integration_test.zig b/src/pdf_pane_integration_test.zig
new file mode 100644
index 00000000..eaebae5e
--- /dev/null
+++ b/src/pdf_pane_integration_test.zig
@@ -0,0 +1,1714 @@
+//! End-to-end PDF pane tests. Production state and behavior stay in pdf_pane.zig;
+//! this module exercises their direct seam with Pardes layout, input, and output.
+const std = @import("std");
+const pardes = @import("pardes.zig");
+const pdf_pane = @import("pdf_pane.zig");
+const file_pane = @import("file_pane.zig");
+const look = @import("look.zig");
+const pdf_impl = pdf_pane.pdf;
+const image = @import("image.zig");
+const config = @import("config.zig");
+const builtins = @import("builtins.zig");
+const animation = pardes.animation;
+const dump = pardes.dump;
+
+const Pardes = pardes.Pardes;
+const Builtin = builtins.registry.Builtin();
+const Key = pardes.Key;
+const Event = pardes.Event;
+const Mode = pardes.Mode;
+const Surface = pardes.Surface;
+const ImagePlace = pardes.ImagePlace;
+const PdfFitMode = pdf_pane.FitMode;
+const PdfTintMode = pdf_pane.TintMode;
+const pdf_enabled = pdf_pane.enabled;
+const platform = pardes.platform;
+const themes = pardes.themes;
+const pdf_raster_policy = pardes.pdf_raster_policy;
+const PDF_PAGE_GAP_PX = pardes.PDF_PAGE_GAP_PX;
+const BOX_H = pardes.BOX_H;
+const sel_slot = @intFromEnum(config.select_button);
+const pane_tail = " " ++ config.pane_builtins_str;
+
+fn hasPdf(pane: *const pardes.Pane) bool {
+ return if (comptime pdf_enabled) pane.pdf != null else false;
+}
+
+test "PDF feature gates keep argv and builtin behavior coherent" {
+ const maybe_fit = std.meta.stringToEnum(Builtin, "PdfFit");
+ const maybe_tint = std.meta.stringToEnum(Builtin, "PdfTint");
+ const maybe_sections = std.meta.stringToEnum(Builtin, "PdfSections");
+ try std.testing.expectEqual(pdf_enabled, maybe_fit != null);
+ try std.testing.expectEqual(pdf_enabled, maybe_tint != null);
+ try std.testing.expectEqual(pdf_enabled, maybe_sections != null);
+ if (pdf_enabled) {
+ try std.testing.expectEqualStrings("tz", config.leader_path.get(maybe_fit.?).?);
+ try std.testing.expectEqualStrings("ti", config.leader_path.get(maybe_tint.?).?);
+ try std.testing.expectEqualStrings("ts", config.leader_path.get(maybe_sections.?).?);
+ } else if (platform != .web) {
+ const p = try Pardes.init(std.testing.allocator, .{
+ .file = "docs/design.pdf",
+ .cols = 80,
+ .rows = 24,
+ });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ try std.testing.expect(pane.file != null);
+ try std.testing.expect(pane.image == null);
+ try std.testing.expect(!hasPdf(pane));
+ }
+}
+
+test "PDF dump fallback remains a byte-preserving file" {
+ const gpa = std.testing.allocator;
+ const path = "/definitely/missing/pardes-dump-fallback.PDF";
+ const source = "%PDF embedded fallback bytes\x00\xff";
+ const encoded = try dump.encodeBytes(gpa, source);
+ defer gpa.free(encoded);
+ const ids = [_]usize{0};
+ const columns = [_]dump.Column{.{ .panes = &ids }};
+ const panes = [_]dump.Pane{.{
+ .kind = .image,
+ .tag = "pdf 3/9 height PdfFit full PdfTint PdfSections " ++ path ++ " Keep Del",
+ .body = "",
+ .scroll = 2,
+ .cols = 80,
+ .rows = 24,
+ .image = .{ .path = path, .bytes_b64 = encoded },
+ }};
+ const state = dump.State{
+ .screen = .{ .cols = 80, .rows = 24 },
+ .columns = &columns,
+ .panes = &panes,
+ };
+ var out: std.Io.Writer.Allocating = .init(gpa);
+ defer out.deinit();
+ try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer);
+
+ const restored = try Pardes.initFromDump(gpa, .{ .tty_only = true }, out.written());
+ defer restored.deinit();
+ const pane = restored.panes[0].?;
+ try std.testing.expect(pane.file != null);
+ try std.testing.expect(pane.image == null);
+ try std.testing.expect(!hasPdf(pane));
+ try std.testing.expectEqualStrings(path, pane.file.?.path);
+ try std.testing.expectEqualSlices(u8, source, pane.file.?.content);
+ try std.testing.expect(pane.tag_init);
+ try std.testing.expectEqualStrings(" Keep Del", pane.tag_tail[0..pane.tag_tail_len]);
+}
+
+test "PdfSections Look follows the exact owning PDF, not an equal path" {
+ if (!pdf_enabled or platform == .web) return;
+ const gpa = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const fixture = try pdf_impl.makeOutlineTestPdf(gpa);
+ defer gpa.free(fixture);
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "outline.pdf", .data = fixture });
+ var path_buf: [256]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/outline.pdf", .{tmp.sub_path});
+
+ const p = try Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 });
+ defer p.deinit();
+ const first = p.panes[0].?;
+ pdf_pane.openSections(p, 0);
+ const first_output_id = first.search_pane orelse return error.MissingPdfSectionsOutput;
+ const first_output = p.panes[first_output_id].?;
+ try std.testing.expect(pdf_pane.isSectionsOutput(first_output));
+ try std.testing.expect(first_output.file.?.content.len > 0);
+
+ // A clean result is re-armed in place without rebuilding it.
+ const first_revision = first_output.file.?.revision;
+ pdf_pane.openSections(p, 0);
+ try std.testing.expectEqual(first_output_id, first.search_pane.?);
+ try std.testing.expectEqual(first_revision, first_output.file.?.revision);
+
+ const duplicate_id = p.freeSlot() orelse return error.NoDuplicatePdfSlot;
+ const duplicate = try pdf_pane.openPane(p, duplicate_id, path, 0);
+ p.placeDoc(0, duplicate_id, duplicate);
+ p.computeGeom();
+ pdf_pane.openSections(p, duplicate_id);
+ const duplicate_output_id = duplicate.search_pane orelse return error.MissingDuplicatePdfSections;
+
+ const first_row = std.mem.sliceTo(first_output.file.?.content, '\n');
+ const target = first_row[0 .. std.mem.indexOfScalar(u8, first_row, ' ') orelse first_row.len];
+ p.lookAt(first_output_id, target);
+ try std.testing.expectEqual(@as(usize, 0), p.active);
+ p.lookAt(duplicate_output_id, target);
+ try std.testing.expectEqual(duplicate_id, p.active);
+
+ // Once the original document is gone, its output cannot reinterpret an
+ // old ordinal against the still-open equal-path duplicate.
+ try std.testing.expect(pardes.pdf_test.runBuiltin(p, "Del", 0, "", null));
+ p.active = first_output_id;
+ p.lookAt(first_output_id, target);
+ try std.testing.expectEqual(first_output_id, p.active);
+}
+
+test "PdfSections caches an empty outline output" {
+ if (!pdf_enabled or platform == .web) return;
+ const gpa = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const fixture = try pdf_impl.makeNoOutlineTestPdf(gpa);
+ defer gpa.free(fixture);
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "plain.pdf", .data = fixture });
+ var path_buf: [256]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/plain.pdf", .{tmp.sub_path});
+ const p = try Pardes.init(gpa, .{ .file = path });
+ defer p.deinit();
+ pdf_pane.openSections(p, 0);
+ const pane = p.panes[0].?;
+ const output_id = pane.search_pane orelse return error.MissingEmptyPdfSections;
+ try std.testing.expectEqual(@as(usize, 0), p.panes[output_id].?.file.?.content.len);
+ const serial = p.panes[output_id].?.serial;
+ pdf_pane.openSections(p, 0);
+ try std.testing.expectEqual(output_id, pane.search_pane.?);
+ try std.testing.expectEqual(serial, p.panes[output_id].?.serial);
+}
+
+test "SDL PDF raster policy is materially denser than Kitty" {
+ try std.testing.expect(
+ pardes.sdl_pdf_raster_policy.dpi >= pardes.kitty_pdf_raster_policy.dpi * 2,
+ );
+ try std.testing.expect(
+ pardes.sdl_pdf_raster_policy.max_dimension >
+ pardes.kitty_pdf_raster_policy.max_dimension * 3,
+ );
+ try std.testing.expect(!pardes.kitty_pdf_raster_policy.match_viewport);
+ try std.testing.expect(pardes.sdl_pdf_raster_policy.match_viewport);
+}
+
+test "watched PDF reload replaces MuPDF state and preserves the reading view" {
+ if (!pdf_enabled or platform == .web) return;
+
+ const gpa = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const original = try pdf_impl.makeOutlineTestPdf(gpa);
+ defer gpa.free(original);
+ const replacement = try pdf_impl.makeNoOutlineTestPdf(gpa);
+ defer gpa.free(replacement);
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "live.pdf", .data = original });
+ var path_buf: [256]u8 = undefined;
+ const path = try std.fmt.bufPrint(
+ &path_buf,
+ ".zig-cache/tmp/{s}/live.pdf",
+ .{tmp.sub_path},
+ );
+
+ const p = try Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 });
+ defer p.deinit();
+ var watched = false;
+ while (p.nextEffect()) |effect| switch (effect) {
+ .watch => |watch| if (watch.pane == 0 and watch.on) {
+ watched = true;
+ },
+ else => {},
+ };
+ try std.testing.expect(watched);
+
+ const pane = p.panes[0].?;
+ const state = &pane.pdf.?;
+ try std.testing.expectEqual(@as(usize, 3), state.page_count);
+ pdf_pane.openSections(p, 0);
+ const sections_id = pane.search_pane orelse return error.MissingPdfSectionsOutput;
+ const sections = p.panes[sections_id].?;
+ try std.testing.expect(sections.file.?.content.len > 0);
+
+ p.native_images = true;
+ state.fit = .height;
+ state.tint = .full;
+ state.pan_x = 1234;
+ state.pan_y = 4321;
+ try state.setSearchQuery(p.pdf_gpa, "needle");
+ pdf_pane.setPage(p, pane, 2);
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ state.text_scroll = 7;
+ state.search_hit = 4;
+ state.search_reveal_pending = false;
+ const reveal_viewport_w = state.reveal_viewport_w;
+ const reveal_viewport_h = state.reveal_viewport_h;
+ const reveal_fit = state.reveal_fit;
+ const reveal_viewport_valid = state.reveal_viewport_valid;
+ const old_revision = pdf_pane.rasterForPage(state, 2).?.revision;
+ const old_revision_counter = state.next_raster_revision;
+ const anchor_fraction: f64 = 0.375;
+ state.document_scroll_y = @as(f64, @floatFromInt(state.page_starts[2])) +
+ anchor_fraction * @as(f64, @floatFromInt(state.page_heights[2]));
+ state.scroll_to_page_pending = false;
+ state.layout_anchor_pending = false;
+ pane.cur_row = 9;
+ pane.cur_col = 8;
+
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "live.pdf", .data = replacement });
+ try std.testing.expect(p.reloadWatchedFile(0, &.{}));
+
+ try std.testing.expectEqual(@as(usize, 1), state.page_count);
+ try std.testing.expectEqual(@as(usize, 0), state.page);
+ try std.testing.expectEqual(PdfFitMode.height, state.fit);
+ try std.testing.expectEqual(PdfTintMode.full, state.tint);
+ try std.testing.expectEqual(@as(u16, 1234), state.pan_x);
+ try std.testing.expectEqual(@as(u16, 4321), state.pan_y);
+ try std.testing.expectEqual(@as(usize, 7), state.text_scroll);
+ try std.testing.expect(state.text_scroll_clamp_pending);
+ try std.testing.expectEqualStrings("needle", state.search_query);
+ try std.testing.expectEqual(@as(usize, 4), state.search_hit);
+ try std.testing.expectEqual(reveal_viewport_w, state.reveal_viewport_w);
+ try std.testing.expectEqual(reveal_viewport_h, state.reveal_viewport_h);
+ try std.testing.expectEqual(reveal_fit, state.reveal_fit);
+ try std.testing.expectEqual(reveal_viewport_valid, state.reveal_viewport_valid);
+ try std.testing.expectEqual(old_revision_counter, state.next_raster_revision);
+ try std.testing.expectEqual(@as(usize, 0), state.rasters_len);
+ try std.testing.expect(state.layout_anchor_pending);
+ try std.testing.expectEqual(@as(usize, 2), state.layout_anchor_page);
+ try std.testing.expectApproxEqAbs(anchor_fraction, state.layout_anchor_fraction, 0.0001);
+ try std.testing.expectEqual(@as(i32, 0), pane.cur_row);
+ try std.testing.expectEqual(@as(i32, 0), pane.cur_col);
+ _ = state.ensureText(p.pdf_gpa);
+ const text_lines = std.mem.count(u8, state.text, "\n") + 1;
+ try std.testing.expect(state.text_scroll < text_lines);
+ try std.testing.expect(!state.text_scroll_clamp_pending);
+
+ // A clean generated outline is derived data: it refreshes in place and
+ // remains the remembered output. This replacement deliberately has none.
+ try std.testing.expectEqual(@as(usize, 0), sections.file.?.content.len);
+ try std.testing.expectEqual(sections_id, state.sections_output.?.pane);
+ try std.testing.expectEqual(sections.file.?.revision, state.sections_output.?.revision);
+
+ _ = frame.reset(.retain_capacity);
+ _ = try p.render(frame.allocator());
+ const fresh_raster = pdf_pane.rasterForPage(state, 0).?;
+ try std.testing.expect(fresh_raster.revision != old_revision);
+ try std.testing.expect(fresh_raster.revision > old_revision_counter);
+ const expected_scroll = anchor_fraction *
+ @as(f64, @floatFromInt(state.page_heights[0]));
+ const viewport = pdf_pane.paneViewport(p, pane).?;
+ const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h));
+ try std.testing.expectApproxEqAbs(@min(expected_scroll, max_scroll), state.document_scroll_y, 0.001);
+
+ // Reopen is transactional: malformed replacement bytes leave the last
+ // good MuPDF handle and every durable setting untouched.
+ const good_handle = state.document.handle;
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "live.pdf", .data = "not a PDF" });
+ try std.testing.expect(!p.reloadWatchedFile(0, &.{}));
+ try std.testing.expectEqual(good_handle, state.document.handle);
+ try std.testing.expectEqual(@as(usize, 1), state.page_count);
+ try std.testing.expectEqualStrings("needle", state.search_query);
+ try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "PDF reload") != null);
+
+ // A user edit breaks the generated-revision token. A later valid reload
+ // updates the document but leaves those user-owned output bytes alone.
+ file_pane.setContent(p, &sections.file.?, try gpa.dupe(u8, "edited sections\n"));
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "live.pdf", .data = original });
+ try std.testing.expect(p.reloadWatchedFile(0, &.{}));
+ try std.testing.expectEqual(@as(usize, 3), state.page_count);
+ try std.testing.expectEqualStrings("edited sections\n", sections.file.?.content);
+
+ // The watch follows the PDF payload's lifetime just like a text file's;
+ // emit the stop while the slot still identifies the disappearing pane.
+ p.deinitPane(pane);
+ p.panes[0] = null;
+ var unwatched = false;
+ while (p.nextEffect()) |effect| switch (effect) {
+ .watch => |watch| if (watch.pane == 0 and !watch.on) {
+ unwatched = true;
+ },
+ else => {},
+ };
+ try std.testing.expect(unwatched);
+}
+
+test "PDF reload does not re-center an already revealed matching search" {
+ if (!pdf_enabled or platform == .web) return;
+ const gpa = std.testing.allocator;
+ const fixture = try look.readFile(gpa, "docs/design.pdf");
+ defer gpa.free(fixture);
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "search.pdf", .data = fixture });
+ var path_buf: [256]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/search.pdf", .{tmp.sub_path});
+ const p = try Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 });
+ defer p.deinit();
+ p.native_images = true;
+ const pane = p.panes[0].?;
+ const state = &pane.pdf.?;
+ try state.setSearchQuery(p.pdf_gpa, "Pardes");
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ try std.testing.expect(state.search_results != null);
+ try std.testing.expect(state.search_results.?.hit_count > 0);
+ try std.testing.expect(state.reveal_viewport_valid);
+
+ // Reading moved on after the original reveal. A reload retains the query
+ // and rebuilds its quads, but it must not mistake that for a new request to
+ // jump back to the hit.
+ const anchor_page = state.page;
+ const anchor_fraction: f64 = 0.82;
+ state.document_scroll_y = @as(f64, @floatFromInt(state.page_starts[anchor_page])) +
+ anchor_fraction * @as(f64, @floatFromInt(state.page_heights[anchor_page]));
+ state.scroll_to_page_pending = false;
+ state.layout_anchor_pending = false;
+ state.search_reveal_pending = false;
+ const reveal_w = state.reveal_viewport_w;
+ const reveal_h = state.reveal_viewport_h;
+ const reveal_fit = state.reveal_fit;
+
+ // Same semantic document on a fresh generation keeps the expected anchor
+ // easy to state while still rebuilding every MuPDF-owned search object.
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "search.pdf", .data = fixture });
+ try std.testing.expect(p.reloadWatchedFile(0, &.{}));
+ try std.testing.expectEqual(reveal_w, state.reveal_viewport_w);
+ try std.testing.expectEqual(reveal_h, state.reveal_viewport_h);
+ try std.testing.expectEqual(reveal_fit, state.reveal_fit);
+ try std.testing.expect(!state.search_reveal_pending);
+ _ = frame.reset(.retain_capacity);
+ _ = try p.render(frame.allocator());
+
+ const page = @min(anchor_page, state.page_count - 1);
+ const anchored = @as(f64, @floatFromInt(state.page_starts[page])) +
+ anchor_fraction * @as(f64, @floatFromInt(state.page_heights[page]));
+ const viewport = pdf_pane.paneViewport(p, pane).?;
+ const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h));
+ try std.testing.expectApproxEqAbs(@min(anchored, max_scroll), state.document_scroll_y, 0.001);
+}
+
+test "MuPDF pane renders, navigates, searches, and round-trips its page" {
+ if (!pdf_enabled or platform == .web) return;
+
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{
+ .file = "docs/design.pdf",
+ .cols = 80,
+ .rows = 24,
+ });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ try std.testing.expect(hasPdf(pane));
+ try std.testing.expect(pane.pdf.?.page_count > 1);
+ try std.testing.expectEqual(PdfFitMode.width, pane.pdf.?.fit);
+ try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x);
+ try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_y);
+ try std.testing.expectEqual(@as(u16, 8), p.cell_pixels.w);
+ try std.testing.expectEqual(@as(u16, 16), p.cell_pixels.h);
+ const initial_tag = try pardes.pdf_test.tagText(p, p.scratch.allocator(), pane);
+ try std.testing.expect(std.mem.indexOf(u8, initial_tag, "pdf 1/") != null);
+ try std.testing.expect(std.mem.indexOf(u8, initial_tag, " width ") != null);
+ try std.testing.expect(std.mem.indexOf(u8, initial_tag, " height ") == null);
+ try std.testing.expect(std.mem.indexOf(u8, initial_tag, "PdfFit") != null);
+ try std.testing.expect(std.mem.endsWith(u8, initial_tag, pane_tail));
+
+ p.native_images = true;
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ const first = try p.render(frame.allocator());
+ try std.testing.expectEqual(@as(usize, 1), first.nimages);
+ const first_place = first.images[0].?;
+ try std.testing.expect(first_place.rgba.len == first_place.iw * first_place.ih * 4);
+ try std.testing.expectEqual(image.NativeFit.width, first_place.native.fit);
+ const request = pdf_pane.renderRequest(
+ pdf_pane.paneViewport(p, pane) orelse return error.MissingPdfViewport,
+ pdf_raster_policy,
+ );
+ try std.testing.expectEqual(pdf_raster_policy.dpi, request.dpi);
+ try std.testing.expectEqual(pdf_raster_policy.max_dimension, request.max_dimension);
+ try std.testing.expectEqual(
+ request,
+ pdf_pane.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.request,
+ );
+ try std.testing.expect(@max(first_place.iw, first_place.ih) <= request.max_dimension);
+ if (pdf_raster_policy.match_viewport) {
+ const viewport = pdf_pane.paneViewport(p, pane) orelse return error.MissingPdfViewport;
+ try std.testing.expectEqual(viewport.pixel_w, request.minimum_width);
+ try std.testing.expectEqual(viewport.pixel_h, request.minimum_height);
+ try std.testing.expect(first_place.iw >= viewport.pixel_w or
+ @max(first_place.iw, first_place.ih) == request.max_dimension);
+ try std.testing.expect(first_place.ih >= viewport.pixel_h or
+ @max(first_place.iw, first_place.ih) == request.max_dimension);
+ } else {
+ try std.testing.expectEqual(@as(u32, 0), request.minimum_width);
+ try std.testing.expectEqual(@as(u32, 0), request.minimum_height);
+ }
+
+ // SDL's raw dy path retains fractions in the placement itself; it does
+ // not leave native pixels fixed while only the underlying cells slide.
+ p.update(.{ .pdf_scroll = .{ .pane = 0, .delta_pixels = 0.25 } });
+ try std.testing.expectEqual(@as(f64, 0.25), pane.pdf.?.document_scroll_y);
+ _ = frame.reset(.retain_capacity);
+ const fractional = try p.render(frame.allocator());
+ try std.testing.expectEqual(first_place.native.revision, fractional.images[0].?.native.revision);
+ try std.testing.expectEqual(@as(f32, -0.25), fractional.images[0].?.native.pixel_offset_y);
+ pane.pdf.?.document_scroll_y = 0;
+
+ // The default reading view moves one exact display-cell distance without
+ // replacing page pixels. Document placement, not texture identity, moves.
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expectEqual(@as(usize, 0), pane.pdf.?.page);
+ try std.testing.expectEqual(@as(f64, p.cell_pixels.h), pane.pdf.?.document_scroll_y);
+ try std.testing.expectEqual(
+ first_place.native.revision,
+ pdf_pane.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.revision,
+ );
+ _ = frame.reset(.retain_capacity);
+ const panned = try p.render(frame.allocator());
+ try std.testing.expectEqual(first_place.native.revision, panned.images[0].?.native.revision);
+ try std.testing.expect(panned.images[0].?.native.geometry.?.src.y > first_place.native.geometry.?.src.y);
+
+ const row_scroll = pane.pdf.?.document_scroll_y;
+ pane.pdf.?.document_scroll_y = 0;
+ p.update(.{ .key = .{ .cp = '3' } });
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expectEqual(@as(f64, p.cell_pixels.h * 3), pane.pdf.?.document_scroll_y);
+
+ // Counts survive a shared multi-key prefix. An invalid continuation is
+ // consumed and clears both prefix and count before the following action.
+ pane.pdf.?.document_scroll_y = 0;
+ p.update(.{ .key = .{ .cp = '2' } });
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expectEqual(@as(f64, p.cell_pixels.h * 2), pane.pdf.?.document_scroll_y);
+ pane.pdf.?.document_scroll_y = 0;
+ p.update(.{ .key = .{ .cp = '4' } });
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = '?' } });
+ try std.testing.expectEqual(@as(u21, 0), pane.pending);
+ try std.testing.expectEqual(@as(u32, 0), pane.count);
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expectEqual(row_scroll, pane.pdf.?.document_scroll_y);
+
+ pane.pdf.?.document_scroll_y = 0;
+ p.update(.{ .key = .{ .cp = 'd', .ctrl = true } });
+ const half_scroll = pane.pdf.?.document_scroll_y;
+ try std.testing.expect(half_scroll > row_scroll);
+ pane.pdf.?.document_scroll_y = 0;
+ p.update(.{ .key = .{ .cp = '2' } });
+ p.update(.{ .key = .{ .cp = 'd', .ctrl = true } });
+ const counted_half = pane.pdf.?.document_scroll_y;
+ try std.testing.expectEqual(half_scroll * 2, counted_half);
+ p.update(.{ .key = .{ .cp = '2' } });
+ p.update(.{ .key = .{ .cp = 'u', .ctrl = true } });
+ try std.testing.expectEqual(@as(f64, 0), pane.pdf.?.document_scroll_y);
+ pane.pdf.?.document_scroll_y = @as(f64, p.cell_pixels.h) * 4;
+ p.update(.{ .key = .{ .cp = '3' } });
+ p.update(.{ .key = .{ .cp = 'k' } });
+ try std.testing.expectEqual(row_scroll, pane.pdf.?.document_scroll_y);
+ pane.pdf.?.document_scroll_y = 0;
+ p.update(.{ .key = .{ .cp = 'f', .ctrl = true } });
+ try std.testing.expect(pane.pdf.?.document_scroll_y >= half_scroll);
+ pane.pdf.?.document_scroll_y = 0;
+ p.update(.{ .key = .{ .cp = '2' } });
+ p.update(.{ .key = .{ .cp = 'f', .ctrl = true } });
+ const key_viewport = pdf_pane.paneViewport(p, pane).?;
+ const max_key_scroll = @as(f64, @floatFromInt(pane.pdf.?.document_height -| key_viewport.pixel_h));
+ const counted_full = @min(@as(f64, @floatFromInt(key_viewport.pixel_h * 2)), max_key_scroll);
+ try std.testing.expectEqual(counted_full, pane.pdf.?.document_scroll_y);
+ p.update(.{ .key = .{ .cp = '2' } });
+ p.update(.{ .key = .{ .cp = 'b', .ctrl = true } });
+ try std.testing.expectEqual(@max(@as(f64, 0), counted_full - @as(f64, @floatFromInt(key_viewport.pixel_h * 2))), pane.pdf.?.document_scroll_y);
+ try std.testing.expectEqual(
+ first_place.native.revision,
+ pdf_pane.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.revision,
+ );
+
+ // At a page boundary both page rasters coexist, the gap remains uncovered,
+ // and a row step crosses it without snapping either page to an edge.
+ //
+ // This lands by ASSIGNMENT, which is a jump and not a fling — and the
+ // travel counter has to say so: the keys above scrolled two screenfuls
+ // without any frame in between to spend that distance, which no shell does
+ // (every wheel batch is followed by a draw). Left unspent it would make the
+ // frame below the first frame of a fling and hand it bands.
+ const viewport = pdf_pane.paneViewport(p, pane).?;
+ pane.pdf.?.scroll_travel = 0;
+ pane.pdf.?.document_scroll_y = @floatFromInt(
+ pane.pdf.?.page_starts[1] -| viewport.pixel_h / 2,
+ );
+ _ = frame.reset(.retain_capacity);
+ const boundary = try p.render(frame.allocator());
+ try std.testing.expectEqual(@as(usize, 2), boundary.nimages);
+ try std.testing.expectEqual(@as(u32, 0), boundary.images[0].?.native.page);
+ try std.testing.expectEqual(@as(u32, 1), boundary.images[1].?.native.page);
+ const first_bottom = @as(f32, @floatFromInt(
+ boundary.images[0].?.native.geometry.?.dst.y +
+ boundary.images[0].?.native.geometry.?.dst.h,
+ )) + boundary.images[0].?.native.pixel_offset_y;
+ const second_top = @as(f32, @floatFromInt(
+ boundary.images[1].?.native.geometry.?.dst.y,
+ )) + boundary.images[1].?.native.pixel_offset_y;
+ const visible_gap = second_top - first_bottom;
+ try std.testing.expect(visible_gap >= @as(f32, PDF_PAGE_GAP_PX));
+ try std.testing.expect(visible_gap <= @as(f32, PDF_PAGE_GAP_PX + 1));
+ const before_boundary_scroll = pane.pdf.?.document_scroll_y;
+ const page0_revision = boundary.images[0].?.native.revision;
+ const page1_revision = boundary.images[1].?.native.revision;
+
+ // A scroll can activate the already-cached neighbor before the shell's
+ // next draw. Every consumer resolves the active page's resident raster,
+ // so a queued click cannot accidentally use page zero's dimensions.
+ pane.pdf.?.document_scroll_y = @as(f64, @floatFromInt(pane.pdf.?.page_starts[1])) - 0.5;
+ try std.testing.expectEqual(
+ page0_revision,
+ pdf_pane.rasterForPage(&pane.pdf.?, 0).?.revision,
+ );
+ try std.testing.expect(pdf_pane.scrollPane(p, pane, 1));
+ try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page);
+ try std.testing.expectEqual(
+ page1_revision,
+ pdf_pane.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.revision,
+ );
+ pdf_pane.activatePage(p, pane, 0, false);
+ pane.pdf.?.document_scroll_y = before_boundary_scroll;
+
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expectEqual(before_boundary_scroll + p.cell_pixels.h, pane.pdf.?.document_scroll_y);
+ _ = frame.reset(.retain_capacity);
+ const second = try p.render(frame.allocator());
+ try std.testing.expectEqual(@as(usize, 2), second.nimages);
+ try std.testing.expectEqual(page0_revision, second.images[0].?.native.revision);
+ try std.testing.expectEqual(page1_revision, second.images[1].?.native.revision);
+ p.update(.{ .key = .{ .cp = 'k' } });
+ try std.testing.expectEqual(before_boundary_scroll, pane.pdf.?.document_scroll_y);
+
+ // Once page zero is wholly outside the viewport, both its owned RGBA and
+ // backend placement disappear; returning later renders a new raster. What
+ // does NOT go back is the memory: the departing page's bytes are parked in
+ // the relay and the arriving page of the same size takes them, so a fling
+ // never asks the allocator (or the kernel's fault handler) for megabytes it
+ // just gave up.
+ pdf_pane.setPage(p, pane, 1);
+ _ = frame.reset(.retain_capacity);
+ const away = try p.render(frame.allocator());
+ try std.testing.expect(away.nimages > 0);
+ for (away.images[0..away.nimages]) |maybe| if (maybe) |place|
+ try std.testing.expect(place.native.page != 0);
+ try std.testing.expect(pdf_pane.rasterForPage(&pane.pdf.?, 0) == null);
+ try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.spare_len);
+ const retired = pane.pdf.?.spare[0];
+ pdf_pane.setPage(p, pane, 0);
+ _ = frame.reset(.retain_capacity);
+ const returned = try p.render(frame.allocator());
+ try std.testing.expectEqual(@as(u32, 0), returned.images[0].?.native.page);
+ try std.testing.expectEqual(retired.ptr, returned.images[0].?.rgba.ptr);
+ try std.testing.expect(returned.images[0].?.native.revision != page0_revision);
+
+ // PdfFit exists as a real builtin in this build. It resets placement but
+ // preserves the current page pixels; fit-height j/k remains continuous in
+ // the same document-pixel coordinate space.
+ const fit_builtin = std.meta.stringToEnum(Builtin, "PdfFit") orelse
+ return error.MissingPdfFitBuiltin;
+ const revision_before_toggle = pdf_pane.rasterForPage(
+ &pane.pdf.?,
+ pane.pdf.?.page,
+ ).?.revision;
+ try std.testing.expect(pardes.pdf_test.runBuiltin(
+ p,
+ @tagName(fit_builtin),
+ 0,
+ "",
+ null,
+ ));
+ try std.testing.expectEqual(PdfFitMode.height, pane.pdf.?.fit);
+ try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x);
+ try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_y);
+ try std.testing.expectEqual(
+ revision_before_toggle,
+ pdf_pane.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.revision,
+ );
+ const height_tag = try pardes.pdf_test.tagText(p, p.scratch.allocator(), pane);
+ try std.testing.expect(std.mem.indexOf(u8, height_tag, "pdf 1/") != null);
+ try std.testing.expect(std.mem.indexOf(u8, height_tag, " width ") == null);
+ try std.testing.expect(std.mem.indexOf(u8, height_tag, " height ") != null);
+ try std.testing.expect(std.mem.indexOf(u8, height_tag, "PdfFit") != null);
+ for (p.panes) |slot| {
+ const other = slot orelse continue;
+ if (hasPdf(other)) continue;
+ pdf_pane.toggleFit(other); // pane-scoped and deliberately inert here
+ try std.testing.expectEqual(PdfFitMode.height, pane.pdf.?.fit);
+ break;
+ }
+
+ // A fit-height landscape page exposes horizontal overflow to a horizontal
+ // wheel without rerasterizing. Use synthetic dimensions only for the
+ // geometry check; no frame is drawn while they differ from the buffer.
+ const active_raster = pdf_pane.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?;
+ const saved_iw = active_raster.iw;
+ const saved_ih = active_raster.ih;
+ active_raster.iw = 2000;
+ active_raster.ih = 500;
+ pdf_pane.horizontalWheel(p, pane, 1);
+ try std.testing.expect(pane.pdf.?.pan_x > 0);
+ try std.testing.expectEqual(revision_before_toggle, active_raster.revision);
+ pane.pdf.?.pan_x = 0;
+ p.update(.{ .key = .{ .cp = '2' } });
+ p.update(.{ .key = .{ .cp = 'l' } });
+ try std.testing.expect(pane.pdf.?.pan_x > 0);
+ p.update(.{ .key = .{ .cp = '$' } });
+ try std.testing.expectEqual(std.math.maxInt(u16), pane.pdf.?.pan_x);
+ p.update(.{ .key = .{ .cp = '2' } });
+ p.update(.{ .key = .{ .cp = 'h' } });
+ try std.testing.expect(pane.pdf.?.pan_x < std.math.maxInt(u16));
+ p.update(.{ .key = .{ .cp = '0' } });
+ try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x);
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'l' } });
+ try std.testing.expectEqual(std.math.maxInt(u16), pane.pdf.?.pan_x);
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'h' } });
+ try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x);
+ active_raster.iw = saved_iw;
+ active_raster.ih = saved_ih;
+ pane.pdf.?.pan_x = 0;
+
+ const before_height_scroll = pane.pdf.?.document_scroll_y;
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expect(pane.pdf.?.document_scroll_y > before_height_scroll);
+
+ const revision_before_search = active_raster.revision;
+ try p.runSearch(0, "Pardes", .text, .top);
+ try std.testing.expectEqualStrings("Pardes", pane.pdf.?.search_query);
+ try std.testing.expectEqual(revision_before_search, active_raster.revision);
+ const results_id = pane.search_pane orelse return error.MissingPdfSearchResults;
+ const results = p.panes[results_id].?.file.?.content;
+ try std.testing.expect(std.mem.indexOf(u8, results, "design.pdf:") != null);
+ // n SELECTS the first result row and opens nothing: the walk's only list
+ // here is the unlooked +Search buffer, so focus lands THERE. Enter is what
+ // jumps, and the document query survives both.
+ p.update(.{ .key = .{ .cp = 'n' } });
+ try std.testing.expectEqual(results_id, p.active);
+ const results_pane = p.panes[results_id].?;
+ try std.testing.expect(results_pane.vsel.active and results_pane.vsel.explicit);
+ try std.testing.expectEqual(@as(i32, 0), results_pane.cur_row);
+ try std.testing.expectEqual(@as(i32, 0), results_pane.cur_col);
+ p.update(.{ .key = .{ .cp = Key.enter } });
+ try std.testing.expectEqual(@as(usize, 0), p.active);
+ try std.testing.expectEqualStrings("Pardes", pane.pdf.?.search_query);
+
+ // Search state is owned and untruncated, and changing pages invalidates
+ // only page-local state while retaining the document query.
+ const long_query = "a query deliberately longer than any tag display budget: " ++
+ "012345678901234567890123456789012345678901234567890123456789" ++
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
+ try pane.pdf.?.setSearchQuery(p.pdf_gpa, long_query);
+ try std.testing.expectEqualStrings(long_query, pane.pdf.?.search_query);
+ const next_page = if (pane.pdf.?.page == 0) @as(usize, 1) else 0;
+ pdf_pane.setPage(p, pane, next_page);
+ try std.testing.expectEqualStrings(long_query, pane.pdf.?.search_query);
+
+ // PDF dumps intentionally retain the existing image-compatible schema:
+ // page/path and the exact editable tail are restored, while pane-local
+ // tint starts from the fresh-PDF default rather than being serialized.
+ pdf_pane.toggleTint(pane);
+ try std.testing.expectEqual(PdfTintMode.full, pane.pdf.?.tint);
+ pane.pdf.?.fit = .height;
+ const custom_tail = " Keep Del";
+ @memcpy(pane.tag_tail[0..custom_tail.len], custom_tail);
+ pane.tag_tail_len = custom_tail.len;
+ pane.tag_init = true;
+ try p.dumpState();
+ const restored = try Pardes.initFromDump(gpa, .{}, p.dump_out.?);
+ defer restored.deinit();
+ try std.testing.expect(hasPdf(restored.panes[0].?));
+ try std.testing.expectEqual(pane.pdf.?.page, restored.panes[0].?.pdf.?.page);
+ try std.testing.expectEqual(PdfFitMode.width, restored.panes[0].?.pdf.?.fit);
+ try std.testing.expectEqual(PdfTintMode.filtered, restored.panes[0].?.pdf.?.tint);
+ const restored_pane = restored.panes[0].?;
+ try std.testing.expect(restored_pane.tag_init);
+ try std.testing.expectEqualStrings(
+ custom_tail,
+ restored_pane.tag_tail[0..restored_pane.tag_tail_len],
+ );
+}
+
+test "a fling's banded pages show the reader exactly what whole pages would" {
+ if (!pdf_enabled or platform == .web) return;
+
+ // The contract fast scrolling is allowed to change: HOW pixels are carried
+ // (a strip of a page instead of the page) but never WHICH pixels arrive. So
+ // the same frame is drawn twice — once at fling speed, once at reading
+ // speed — and every pixel inside every source rectangle must match, along
+ // with where on screen it goes.
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .file = "docs/design.pdf", .cols = 120, .rows = 40 });
+ defer p.deinit();
+ p.native_images = true;
+ const pane = p.panes[0].?;
+ const pv = &pane.pdf.?;
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+
+ const viewport = pdf_pane.paneViewport(p, pane) orelse return error.MissingPdfViewport;
+ // Land mid-page-boundary so the frame carries TWO pages, each showing a
+ // fraction of itself — the shape a fling actually produces.
+ const landing = @as(f64, @floatFromInt(pv.page_starts[1] -| viewport.pixel_h / 3));
+ const Shot = struct {
+ page: u32,
+ dst: image.PixelRect,
+ pixels: []u8,
+ };
+ var shots: [8]Shot = undefined;
+ var shots_len: usize = 0;
+ defer for (shots[0..shots_len]) |shot| gpa.free(shot.pixels);
+
+ // A fling: one frame's worth of wheel travel carrying the viewport more
+ // than a screenful, delivered through the real scroll path so the distance
+ // is counted the way a wheel batch counts it.
+ pv.document_scroll_y = 0;
+ pv.scroll_travel = 0;
+ try std.testing.expect(pdf_pane.scrollPane(p, pane, landing));
+ _ = frame.reset(.retain_capacity);
+ const flung = try p.render(frame.allocator());
+ try std.testing.expect(flung.nimages >= 2);
+ var banded = false;
+ for (flung.images[0..flung.nimages]) |maybe| {
+ const place = maybe orelse continue;
+ const geometry = place.native.geometry orelse return error.MissingPdfGeometry;
+ const raster = pdf_pane.rasterForPage(pv, place.native.page) orelse
+ return error.MissingPdfRaster;
+ if (raster.band_h < raster.ih) banded = true;
+ try std.testing.expectEqual(place.iw * raster.band_h * 4, place.rgba.len);
+ shots[shots_len] = .{
+ .page = place.native.page,
+ .dst = geometry.dst,
+ .pixels = try copySourceRect(gpa, place, geometry.src),
+ };
+ shots_len += 1;
+ }
+ // ...and it really did band, or the comparison below is two identical
+ // whole-page renders agreeing with each other.
+ try std.testing.expect(banded);
+ // ...at reading speed: no travel at all since the frame above, so every
+ // page is rasterized whole again, and that is the picture the banded frame
+ // has to have matched.
+ _ = frame.reset(.retain_capacity);
+ const rested = try p.render(frame.allocator());
+ try std.testing.expectEqual(shots_len, rested.nimages);
+ for (rested.images[0..rested.nimages], shots[0..shots_len]) |maybe, shot| {
+ const place = maybe orelse return error.MissingPdfPlacement;
+ const geometry = place.native.geometry orelse return error.MissingPdfGeometry;
+ const raster = pdf_pane.rasterForPage(pv, place.native.page) orelse
+ return error.MissingPdfRaster;
+ try std.testing.expectEqual(raster.ih, raster.band_h); // promoted at rest
+ try std.testing.expectEqual(shot.page, place.native.page);
+ try std.testing.expectEqual(shot.dst.x, geometry.dst.x);
+ try std.testing.expectEqual(shot.dst.y, geometry.dst.y);
+ try std.testing.expectEqual(shot.dst.w, geometry.dst.w);
+ try std.testing.expectEqual(shot.dst.h, geometry.dst.h);
+ const whole = try copySourceRect(gpa, place, geometry.src);
+ defer gpa.free(whole);
+ try std.testing.expectEqualSlices(u8, shot.pixels, whole);
+ }
+}
+
+/// The pixels a backend samples out of one placement: the source rectangle,
+/// row by row, at the texture's own stride. Test-only, and the one operation
+/// that makes "same picture" mean something when the textures differ in shape.
+fn copySourceRect(
+ gpa: std.mem.Allocator,
+ place: ImagePlace,
+ src: image.PixelRect,
+) ![]u8 {
+ const stride = place.iw * 4;
+ const row_len = @as(usize, src.w) * 4;
+ const out = try gpa.alloc(u8, row_len * src.h);
+ errdefer gpa.free(out);
+ var row: usize = 0;
+ while (row < src.h) : (row += 1) {
+ const from = (@as(usize, src.y) + row) * stride + @as(usize, src.x) * 4;
+ @memcpy(out[row * row_len ..][0..row_len], place.rgba[from..][0..row_len]);
+ }
+ return out;
+}
+
+test "PDF normal adapter consumes unsupported actions and navigates page fallback" {
+ if (!pdf_enabled or platform == .web) return;
+
+ const p = try Pardes.init(std.testing.allocator, .{
+ .file = "docs/design.pdf",
+ .cols = 80,
+ .rows = 24,
+ });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const pv = &pane.pdf.?;
+ try std.testing.expect(pv.page_count > 3);
+ p.native_images = false;
+
+ // Every vertical vocabulary falls back to counted page changes when the
+ // shell cannot place native pixels.
+ p.update(.{ .key = .{ .cp = '2' } });
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expectEqual(@as(usize, 2), pv.page);
+ p.update(.{ .key = .{ .cp = '2' } });
+ p.update(.{ .key = .{ .cp = 'k' } });
+ try std.testing.expectEqual(@as(usize, 0), pv.page);
+ p.update(.{ .key = .{ .cp = '2' } });
+ p.update(.{ .key = .{ .cp = 'd', .ctrl = true } });
+ try std.testing.expectEqual(@as(usize, 2), pv.page);
+ p.update(.{ .key = .{ .cp = '2' } });
+ p.update(.{ .key = .{ .cp = 'u', .ctrl = true } });
+ try std.testing.expectEqual(@as(usize, 0), pv.page);
+ p.update(.{ .key = .{ .cp = '2' } });
+ p.update(.{ .key = .{ .cp = 'f', .ctrl = true } });
+ try std.testing.expectEqual(@as(usize, 2), pv.page);
+ p.update(.{ .key = .{ .cp = '2' } });
+ p.update(.{ .key = .{ .cp = 'b', .ctrl = true } });
+ try std.testing.expectEqual(@as(usize, 0), pv.page);
+
+ // Prefix actions and the counted text goto-line action map to pages.
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'e' } });
+ try std.testing.expectEqual(pv.page_count - 1, pv.page);
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'g' } });
+ try std.testing.expectEqual(@as(usize, 0), pv.page);
+ p.update(.{ .key = .{ .cp = '3' } });
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'g' } });
+ try std.testing.expectEqual(@as(usize, 2), pv.page);
+ p.update(.{ .key = .{ .cp = '2' } });
+ p.update(.{ .key = .{ .cp = 'G' } });
+ try std.testing.expectEqual(@as(usize, 1), pv.page);
+
+ // Editing/selection actions are consumed no-ops: parser state clears,
+ // placeholder cells never acquire a range, and PDF state stays intact.
+ const page_before_noop = pv.page;
+ const revision_before_noop = pv.next_raster_revision;
+ p.update(.{ .key = .{ .cp = '4' } });
+ p.update(.{ .key = .{ .cp = 'd' } });
+ p.update(.{ .key = .{ .cp = 'v' } });
+ p.update(.{ .key = .{ .cp = '|' } });
+ try std.testing.expectEqual(page_before_noop, pv.page);
+ try std.testing.expectEqual(revision_before_noop, pv.next_raster_revision);
+ try std.testing.expectEqual(@as(u32, 0), pane.count);
+ try std.testing.expectEqual(@as(u21, 0), pane.pending);
+ try std.testing.expect(!pane.vsel.active and !pane.msel.active and pane.nsel == 0);
+ try std.testing.expect(!pane.tag_edit);
+
+ // Cross-pane BODY-NORMAL actions keep their established shared paths.
+ p.update(.{ .key = .{ .cp = ':' } });
+ try std.testing.expect(pane.tag_edit);
+ try std.testing.expectEqual(Mode.normal, pane.mode);
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ try std.testing.expect(!pane.tag_edit);
+ p.update(.{ .key = .{ .cp = ' ' } });
+ try std.testing.expect(p.leader_on);
+ p.update(.{ .key = .{ .cp = '!' } });
+ try std.testing.expect(!p.leader_on);
+ p.update(.{ .key = .{ .cp = '/' } });
+ try std.testing.expect(pane.tag_edit);
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ try std.testing.expect(!pane.tag_edit);
+}
+
+test "PDF continuous strip renders every intersecting short page" {
+ if (!pdf_enabled or platform == .web) return;
+
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{
+ .file = "docs/design.pdf",
+ .cols = 80,
+ .rows = 24,
+ });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const pv = &pane.pdf.?;
+ try std.testing.expect(pv.page_count > 3);
+ p.native_images = true;
+
+ // A legal wide MediaBox can make more than three pages intersect one
+ // viewport. Seed tiny matching rasters so this tests transport/cache
+ // cardinality without spending the unit test rendering fake page sizes.
+ for (pv.page_sizes) |*size| size.* = .{ .width = 100_000, .height = 1 };
+ pv.layout_valid = false;
+ pv.scroll_to_page_pending = true;
+ const viewport = pdf_pane.ensurePaneLayout(p, pane).?;
+ const visible = pdf_pane.visiblePages(pv, viewport);
+ try std.testing.expectEqual(pv.page_count, visible.len);
+ const request = pdf_pane.renderRequest(viewport, pdf_raster_policy);
+ try std.testing.expect(pv.page_count <= pv.rasters.len);
+ for (0..pv.page_count) |page| {
+ const rgba = try gpa.alloc(u8, @as(usize, viewport.pixel_w) * 4);
+ @memset(rgba, @intCast(page));
+ pv.rasters[pv.rasters_len] = .{
+ .valid = true,
+ .page = page,
+ .rgba = rgba,
+ .iw = viewport.pixel_w,
+ .ih = 1,
+ .request = request,
+ .request_valid = true,
+ .tried = true,
+ .tint_key = .{ .mode = pv.tint, .colors = pdf_pane.tintColors(p) },
+ .revision = @intCast(page + 1),
+ };
+ pv.rasters_len += 1;
+ }
+
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ const surface = try p.render(frame.allocator());
+ try std.testing.expectEqual(pv.page_count, surface.nimages);
+ try std.testing.expectEqual(pv.page_count, pv.rasters_len);
+ for (surface.images[0..surface.nimages], 0..) |maybe, page| {
+ const place = maybe orelse return error.MissingShortPdfPage;
+ try std.testing.expectEqual(@as(u32, @intCast(page)), place.native.page);
+ try std.testing.expectEqual(@as(u32, 1), place.native.geometry.?.dst.h);
+ }
+}
+
+test "PdfTint cycles pane-local state and exposes it in the live PDF tag" {
+ if (!pdf_enabled or platform == .web) return;
+
+ const p = try Pardes.init(std.testing.allocator, .{
+ .file = "docs/design.pdf",
+ .cols = 80,
+ .rows = 24,
+ });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const pv = &pane.pdf.?;
+ const tint_builtin = std.meta.stringToEnum(Builtin, "PdfTint") orelse
+ return error.MissingPdfTintBuiltin;
+
+ try std.testing.expectEqual(PdfTintMode.filtered, pv.tint);
+ const initial_tag = try pardes.pdf_test.tagText(p, p.scratch.allocator(), pane);
+ try std.testing.expect(std.mem.indexOf(
+ u8,
+ initial_tag,
+ "width PdfFit filtered PdfTint",
+ ) != null);
+
+ try std.testing.expect(pardes.pdf_test.runBuiltin(
+ p,
+ @tagName(tint_builtin),
+ 0,
+ "",
+ null,
+ ));
+ try std.testing.expectEqual(PdfTintMode.full, pv.tint);
+ const full_tag = try pardes.pdf_test.tagText(p, p.scratch.allocator(), pane);
+ try std.testing.expect(std.mem.indexOf(u8, full_tag, "full PdfTint") != null);
+
+ // Running the same pane-scoped word in a terminal cannot mutate the PDF
+ // next to it. A FILE boot is one pane now, so that terminal is asked for
+ // here rather than inherited from init.
+ _ = try p.newShell(1, "");
+ _ = p.layoutSplitColumn(0, 1, false);
+ pardes.pdf_test.sync(p); // rects for the new column, exactly as the two-pane boot did
+ try std.testing.expect(!hasPdf(p.panes[1].?));
+ try std.testing.expect(pardes.pdf_test.runBuiltin(
+ p,
+ @tagName(tint_builtin),
+ 1,
+ "",
+ null,
+ ));
+ try std.testing.expectEqual(PdfTintMode.full, pv.tint);
+
+ try std.testing.expect(pardes.pdf_test.runBuiltin(
+ p,
+ @tagName(tint_builtin),
+ 0,
+ "",
+ null,
+ ));
+ try std.testing.expectEqual(PdfTintMode.disabled, pv.tint);
+ const disabled_tag = try pardes.pdf_test.tagText(p, p.scratch.allocator(), pane);
+ try std.testing.expect(std.mem.indexOf(u8, disabled_tag, "disabled PdfTint") != null);
+ try std.testing.expect(pardes.pdf_test.runBuiltin(
+ p,
+ @tagName(tint_builtin),
+ 0,
+ "",
+ null,
+ ));
+ try std.testing.expectEqual(PdfTintMode.filtered, pv.tint);
+ try std.testing.expectEqual(PdfFitMode.width, pv.fit);
+
+ // `dark` intentionally leaves page bg/fg null. PDF tint resolves those
+ // deterministically to its chrome colors rather than host defaults.
+ const dark_index = for (themes, 0..) |th, i| {
+ if (std.mem.eql(u8, th.name, "dark")) break i;
+ } else return error.MissingDarkTheme;
+ p.setThemeIndex(dark_index);
+ const colors = pdf_pane.tintColors(p);
+ try std.testing.expectEqual(p.theme().tag_bg, colors.background);
+ try std.testing.expectEqual(p.theme().tag_fg, colors.foreground);
+}
+
+test "PDF tint and tinted theme changes rebuild every visible raster only" {
+ if (!pdf_enabled or platform == .web) return;
+
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{
+ .file = "docs/design.pdf",
+ .cols = 80,
+ .rows = 24,
+ });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const pv = &pane.pdf.?;
+ p.native_images = true;
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+
+ _ = try p.render(frame.allocator());
+ const viewport = pdf_pane.paneViewport(p, pane).?;
+ pv.document_scroll_y = @floatFromInt(pv.page_starts[1] -| viewport.pixel_h / 2);
+ _ = frame.reset(.retain_capacity);
+ const default_surface = try p.render(frame.allocator());
+ try std.testing.expectEqual(PdfTintMode.filtered, pv.tint);
+ try std.testing.expectEqual(@as(usize, 2), default_surface.nimages);
+ try std.testing.expectEqual(@as(usize, 2), pv.rasters_len);
+
+ const Snapshot = struct {
+ page: u32,
+ revision: u32,
+ geometry: image.NativeGeometry,
+ pixel_offset_y: f32,
+ iw: usize,
+ ih: usize,
+ checksum: u64,
+ };
+ const Capture = struct {
+ fn get(surface: *const Surface) ![2]Snapshot {
+ if (surface.nimages != 2) return error.UnexpectedVisiblePdfCount;
+ var out: [2]Snapshot = undefined;
+ for (&out, 0..) |*snapshot, i| {
+ const place = surface.images[i] orelse return error.MissingVisiblePdf;
+ snapshot.* = .{
+ .page = place.native.page,
+ .revision = place.native.revision,
+ .geometry = place.native.geometry orelse return error.MissingPdfGeometry,
+ .pixel_offset_y = place.native.pixel_offset_y,
+ .iw = place.iw,
+ .ih = place.ih,
+ .checksum = std.hash.Wyhash.hash(0x5044_4654_494e_5421, place.rgba),
+ };
+ }
+ return out;
+ }
+
+ fn expectGeometry(before: [2]Snapshot, after: [2]Snapshot) !void {
+ for (before, after) |old, new| {
+ try std.testing.expectEqual(old.page, new.page);
+ try std.testing.expectEqual(old.geometry, new.geometry);
+ try std.testing.expectEqual(old.pixel_offset_y, new.pixel_offset_y);
+ try std.testing.expectEqual(old.iw, new.iw);
+ try std.testing.expectEqual(old.ih, new.ih);
+ }
+ }
+ };
+ const default_filtered = try Capture.get(default_surface);
+ for (pv.rasters[0..pv.rasters_len]) |raster|
+ try std.testing.expectEqual(PdfTintMode.filtered, raster.tint_key.?.mode);
+
+ const dark_index = for (themes, 0..) |th, i| {
+ if (std.mem.eql(u8, th.name, "dark")) break i;
+ } else return error.MissingDarkTheme;
+ p.setThemeIndex(dark_index);
+ for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(!raster.tried);
+ _ = frame.reset(.retain_capacity);
+ const themed_surface = try p.render(frame.allocator());
+ const themed = try Capture.get(themed_surface);
+ try Capture.expectGeometry(default_filtered, themed);
+ for (default_filtered, themed) |old, new| {
+ try std.testing.expect(new.revision > old.revision);
+ try std.testing.expect(new.checksum != old.checksum);
+ }
+
+ // Chrome animation reads a separate palette. Its ticks must never disturb
+ // the target-theme tint key or ask MuPDF for the same pixels again.
+ try std.testing.expect(p.animationActive());
+ for (0..animation.transition_steps) |_| {
+ p.update(.tick);
+ _ = frame.reset(.retain_capacity);
+ const tick_surface = try p.render(frame.allocator());
+ const ticked = try Capture.get(tick_surface);
+ try Capture.expectGeometry(themed, ticked);
+ for (themed, ticked) |once, after_tick| {
+ try std.testing.expectEqual(once.revision, after_tick.revision);
+ try std.testing.expectEqual(once.checksum, after_tick.checksum);
+ }
+ }
+ try std.testing.expect(!p.animationActive());
+
+ pdf_pane.toggleTint(pane);
+ try std.testing.expectEqual(PdfTintMode.full, pv.tint);
+ _ = frame.reset(.retain_capacity);
+ const full_surface = try p.render(frame.allocator());
+ const full = try Capture.get(full_surface);
+ try Capture.expectGeometry(themed, full);
+ for (themed, full) |old, new| {
+ try std.testing.expect(new.revision > old.revision);
+ }
+ for (pv.rasters[0..pv.rasters_len]) |raster|
+ try std.testing.expectEqual(PdfTintMode.full, raster.tint_key.?.mode);
+
+ pdf_pane.toggleTint(pane);
+ try std.testing.expectEqual(PdfTintMode.disabled, pv.tint);
+ _ = frame.reset(.retain_capacity);
+ const disabled_surface = try p.render(frame.allocator());
+ const disabled = try Capture.get(disabled_surface);
+ try Capture.expectGeometry(full, disabled);
+ for (full, disabled) |old, source| {
+ try std.testing.expect(source.revision > old.revision);
+ try std.testing.expect(source.checksum != old.checksum);
+ }
+
+ const acme_index = for (themes, 0..) |th, i| {
+ if (std.mem.eql(u8, th.name, "acme")) break i;
+ } else return error.MissingAcmeTheme;
+ for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(raster.tried);
+ p.setThemeIndex(acme_index);
+ // Disabled keys ignore theme colors, so neither explicit invalidation nor
+ // the per-raster key comparison asks MuPDF for replacement pixels.
+ for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(raster.tried);
+ _ = frame.reset(.retain_capacity);
+ const unchanged_surface = try p.render(frame.allocator());
+ const unchanged = try Capture.get(unchanged_surface);
+ try Capture.expectGeometry(disabled, unchanged);
+ for (disabled, unchanged) |old, new| {
+ try std.testing.expectEqual(old.revision, new.revision);
+ try std.testing.expectEqual(old.checksum, new.checksum);
+ }
+ try std.testing.expectEqual(@as(usize, 2), pv.rasters_len);
+}
+
+test "PDF resize preserves page-relative document position" {
+ if (!pdf_enabled or platform == .web) return;
+
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{
+ .file = "docs/design.pdf",
+ .cols = 80,
+ .rows = 24,
+ });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const pv = &pane.pdf.?;
+ p.native_images = true;
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+
+ pdf_pane.setPage(p, pane, 1);
+ _ = try p.render(frame.allocator());
+ const fraction: f64 = 0.375;
+ pv.document_scroll_y = @as(f64, @floatFromInt(pv.page_starts[1])) +
+ fraction * @as(f64, @floatFromInt(pv.page_heights[1]));
+ p.update(.{ .resize = .{
+ .cols = 100,
+ .rows = 24,
+ .cell_pixels = p.cell_pixels,
+ } });
+ try std.testing.expect(!pv.layout_valid);
+ try std.testing.expect(pv.layout_anchor_pending);
+ _ = frame.reset(.retain_capacity);
+ _ = try p.render(frame.allocator());
+ const resized_fraction = (pv.document_scroll_y -
+ @as(f64, @floatFromInt(pv.page_starts[1]))) /
+ @as(f64, @floatFromInt(pv.page_heights[1]));
+ try std.testing.expectApproxEqAbs(fraction, resized_fraction, 0.000_001);
+ try std.testing.expectEqual(@as(usize, 1), pv.page);
+
+ const held = pv.document_scroll_y;
+ p.update(.{ .resize = .{
+ .cols = 100,
+ .rows = 24,
+ .cell_pixels = p.cell_pixels,
+ } });
+ try std.testing.expectEqual(held, pv.document_scroll_y);
+ try std.testing.expect(pv.layout_valid);
+}
+
+test "PDF pane geometry change preserves its page-relative position" {
+ if (!pdf_enabled or platform == .web) return;
+
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{
+ .file = "docs/design.pdf",
+ .cols = 80,
+ .rows = 24,
+ });
+ defer p.deinit();
+ // A FILE boot is one pane now and a lone column always fills the window,
+ // so the divider drag below needs a second column to take the width from.
+ _ = try p.newShell(1, "");
+ _ = p.layoutSplitColumn(0, 1, false);
+ pardes.pdf_test.sync(p); // rects for the new column, exactly as the two-pane boot did
+ const pane = p.panes[0].?;
+ const pv = &pane.pdf.?;
+ p.native_images = true;
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+
+ pdf_pane.setPage(p, pane, 1);
+ _ = try p.render(frame.allocator());
+ const fraction: f64 = 0.625;
+ pv.document_scroll_y = @as(f64, @floatFromInt(pv.page_starts[1])) +
+ fraction * @as(f64, @floatFromInt(pv.page_heights[1]));
+ const old_width = pv.layout_viewport_w;
+
+ // A divider/split changes rects through computeGeom without emitting a
+ // shell resize. pdf_pane.ensurePaneLayout anchors against the old layout
+ // before rebuilding it for this wider pane.
+ const sibling_weight = p.col_weight[1];
+ p.col_weight[0] = sibling_weight * 6;
+ p.computeGeom();
+ try std.testing.expect(pdf_pane.paneViewport(p, pane).?.pixel_w != old_width);
+ try std.testing.expect(pv.layout_valid);
+ _ = frame.reset(.retain_capacity);
+ _ = try p.render(frame.allocator());
+ const changed_fraction = (pv.document_scroll_y -
+ @as(f64, @floatFromInt(pv.page_starts[1]))) /
+ @as(f64, @floatFromInt(pv.page_heights[1]));
+ try std.testing.expectApproxEqAbs(fraction, changed_fraction, 0.000_001);
+ try std.testing.expectEqual(@as(usize, 1), pv.page);
+
+ // An explicit pending reveal wins over an implicit layout anchor.
+ pv.page = 2;
+ pv.scroll_to_page_pending = true;
+ p.col_weight[0] = sibling_weight * 2;
+ p.computeGeom();
+ _ = frame.reset(.retain_capacity);
+ _ = try p.render(frame.allocator());
+ try std.testing.expectEqual(
+ @as(f64, @floatFromInt(pv.page_starts[2])),
+ pv.document_scroll_y,
+ );
+}
+
+test "PDF n/N addresses and reveals distinct MuPDF hits on one page" {
+ if (!pdf_enabled or platform == .web) return;
+
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{
+ .file = "docs/design.pdf",
+ .cols = 120,
+ .rows = 24,
+ });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const pv = &pane.pdf.?;
+ p.native_images = true;
+
+ var page_hits = try pv.document.search(gpa, 0, "Pardes");
+ defer page_hits.deinit(gpa);
+ try std.testing.expect(page_hits.hit_count >= 3);
+
+ try p.runSearch(0, "Pardes", .text, .top);
+ const results_id = pane.search_pane orelse return error.MissingPdfSearchResults;
+ const rows = p.panes[results_id].?.file.?.content;
+ try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:1 Pardes\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:2 Pardes\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:3 Pardes\n") != null);
+
+ // Exaggerate the cell aspect only to make the three fixture hits occupy
+ // distinct fit-width crops. The search/reveal math must use the same
+ // reported metrics as placement, whatever a backend reports.
+ p.update(.{ .resize = .{
+ .cols = p.screen_w,
+ .rows = p.screen_h,
+ .cell_pixels = .{ .w = 16, .h = 4 },
+ } });
+
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ try std.testing.expect(pardes.pdf_test.searchStep(p, 0, 1));
+ try std.testing.expectEqual(@as(usize, 0), pv.search_hit);
+ const first = try p.render(frame.allocator());
+ const revision = first.images[0].?.native.revision;
+ const raster = pdf_pane.rasterForPage(pv, pv.page).?;
+ const first_scroll = pv.document_scroll_y;
+ const first_results = pv.search_results orelse return error.MissingPdfPageSearch;
+ const first_quad = for (first_results.quads) |item| {
+ if (item.hit == 0) break item.quad;
+ } else return error.MissingFirstPdfHit;
+ const first_y = pdf_pane.normalizedPixel(
+ (first_quad.ul.y + first_quad.ur.y + first_quad.ll.y + first_quad.lr.y) / 4,
+ raster.ih,
+ );
+ const first_geometry = pdf_pane.paneGeometry(p, pane) orelse return error.MissingPdfGeometry;
+ try std.testing.expect(first_y >= first_geometry.src.y and
+ first_y < first_geometry.src.y + first_geometry.src.h);
+
+ try std.testing.expect(pardes.pdf_test.searchStep(p, 0, 1));
+ try std.testing.expectEqual(@as(usize, 1), pv.search_hit);
+ _ = frame.reset(.retain_capacity);
+ const second = try p.render(frame.allocator());
+ try std.testing.expectEqual(revision, second.images[0].?.native.revision);
+
+ try std.testing.expect(pardes.pdf_test.searchStep(p, 0, 1));
+ try std.testing.expectEqual(@as(usize, 2), pv.search_hit);
+ _ = frame.reset(.retain_capacity);
+ const third = try p.render(frame.allocator());
+ try std.testing.expectEqual(revision, third.images[0].?.native.revision);
+ try std.testing.expect(pv.document_scroll_y != first_scroll);
+ const third_results = pv.search_results orelse return error.MissingPdfPageSearch;
+ const third_quad = for (third_results.quads) |item| {
+ if (item.hit == 2) break item.quad;
+ } else return error.MissingThirdPdfHit;
+ const third_y = pdf_pane.normalizedPixel(
+ (third_quad.ul.y + third_quad.ur.y + third_quad.ll.y + third_quad.lr.y) / 4,
+ raster.ih,
+ );
+ const third_geometry = pdf_pane.paneGeometry(p, pane) orelse return error.MissingPdfGeometry;
+ try std.testing.expect(third_y >= third_geometry.src.y and
+ third_y < third_geometry.src.y + third_geometry.src.h);
+}
+
+test "PDF native mouse selection, Look, and highlights share page geometry" {
+ if (!pdf_enabled or platform == .web) return;
+
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{
+ .file = "docs/design.pdf",
+ .cols = 80,
+ .rows = 24,
+ });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+
+ // A host without Kitty/native pixels keeps the projected-text contract:
+ // j/k can still change pages and a body drag remains a generic selection.
+ const fallback = try p.render(frame.allocator());
+ try std.testing.expectEqual(@as(usize, 0), fallback.nimages);
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page);
+ p.update(.{ .key = .{ .cp = 'k' } });
+ try std.testing.expectEqual(@as(usize, 0), pane.pdf.?.page);
+ const fallback_rect = p.rects[0];
+ const fallback_col = fallback_rect.x + config.GUTTER + 1;
+ const fallback_row = fallback_rect.y + BOX_H + 1;
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = fallback_col, .row = fallback_row } });
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .drag, .col = fallback_col + 4, .row = fallback_row } });
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = fallback_col + 4, .row = fallback_row } });
+ try std.testing.expectEqual(.done, pane.sel[sel_slot].state);
+ try std.testing.expect(pane.pdf.?.selection == null);
+ pane.sel[sel_slot].state = .none;
+
+ p.native_images = true;
+ _ = frame.reset(.retain_capacity);
+ const plain = try p.render(frame.allocator());
+ const plain_revision = plain.images[0].?.native.revision;
+
+ var found = try pane.pdf.?.document.search(gpa, 0, "Pardes");
+ defer found.deinit(gpa);
+ const quad = found.quads[0].quad;
+ const nx = (quad.ul.x + quad.ur.x + quad.ll.x + quad.lr.x) / 4;
+ const ny = (quad.ul.y + quad.ur.y + quad.ll.y + quad.lr.y) / 4;
+ const pv = &pane.pdf.?;
+ const raster = pdf_pane.rasterForPage(pv, pv.page).?;
+ const source_x: u32 = @intCast(@min(
+ raster.iw - 1,
+ @as(usize, @intFromFloat(nx * @as(f32, @floatFromInt(raster.iw)))),
+ ));
+ const source_y: u32 = @intCast(@min(
+ raster.ih - 1,
+ @as(usize, @intFromFloat(ny * @as(f32, @floatFromInt(raster.ih)))),
+ ));
+
+ // Bring the known word into the fit-width crop, then invert the shared
+ // source/destination geometry to the nearest body cell.
+ var geometry = pdf_pane.paneGeometry(p, pane).?;
+ if (source_y < geometry.src.y or source_y >= geometry.src.y + geometry.src.h) {
+ const overflow = @as(u32, @intCast(raster.ih)) - geometry.src.h;
+ const wanted = @min(overflow, source_y -| geometry.src.h / 2);
+ pv.pan_y = if (overflow == 0) 0 else @intCast(
+ (@as(u64, wanted) * std.math.maxInt(u16) + overflow / 2) / overflow,
+ );
+ geometry = pdf_pane.paneGeometry(p, pane).?;
+ }
+ const pixel_x = geometry.dst.x + @as(u32, @intCast(
+ @as(u64, source_x - geometry.src.x) * geometry.dst.w / geometry.src.w,
+ ));
+ const pixel_y = geometry.dst.y + @as(u32, @intCast(
+ @as(u64, source_y - geometry.src.y) * geometry.dst.h / geometry.src.h,
+ ));
+ const r = p.rects[0];
+ const base_col: i32 = @intCast(r.x + config.GUTTER + pixel_x / p.cell_pixels.w);
+ const base_row: i32 = @intCast(r.y + BOX_H + pixel_y / p.cell_pixels.h);
+
+ var selected_col: ?u16 = null;
+ var selected_row: u16 = 0;
+ const nearby = [_]i32{ 0, -1, 1, -2, 2 };
+ find_word: for (nearby) |dy| for (nearby) |dx| {
+ const col: u16 = @intCast(std.math.clamp(
+ base_col + dx,
+ @as(i32, r.x + config.GUTTER),
+ @as(i32, r.x + r.w - 1),
+ ));
+ const row: u16 = @intCast(std.math.clamp(
+ base_row + dy,
+ @as(i32, r.y + BOX_H),
+ @as(i32, r.y + r.h - 1),
+ ));
+ if (!pdf_pane.beginSelection(p, pane, col, row)) continue;
+ if (std.ascii.indexOfIgnoreCase(pv.selection_text, "Pardes") != null) {
+ selected_col = col;
+ selected_row = row;
+ break :find_word;
+ }
+ };
+ const word_col = selected_col orelse return error.PdfMouseMappingMissedWord;
+ try std.testing.expect(std.ascii.indexOfIgnoreCase(
+ pardes.pdf_test.heldSelection(p, 0).?,
+ "Pardes",
+ ) != null);
+
+ _ = frame.reset(.retain_capacity);
+ const selected_frame = try p.render(frame.allocator());
+ const selected_revision = selected_frame.images[0].?.native.revision;
+ try std.testing.expect(selected_revision != plain_revision);
+
+ // Repeating an identical drag endpoint is a no-op: Kitty/SDL keep the
+ // same texture generation instead of retransmitting identical pixels.
+ try std.testing.expect(pdf_pane.beginSelection(p, pane, word_col, selected_row));
+ try std.testing.expect(raster.tried);
+ _ = frame.reset(.retain_capacity);
+ const duplicate = try p.render(frame.allocator());
+ try std.testing.expectEqual(selected_revision, duplicate.images[0].?.native.revision);
+
+ // Delayed native hover retains independent quads/text and changes only
+ // presentation state. In particular it must not borrow the click path,
+ // whose page activation and persistent selection are intentional effects
+ // of a gesture rather than observation.
+ if (config.look_preview_delay_frames) |delay| {
+ const active_before = p.active;
+ const page_before = pv.page;
+ const scroll_before = pv.document_scroll_y;
+ const sels_before = pane.sel;
+ const cursor_before = .{ pane.cur_row, pane.cur_col };
+ const modal_before = .{ pane.msel, pane.vsel, pane.nsel };
+ const selection_before = pv.selection orelse return error.MissingPdfSelection;
+ const selection_quads_before = try gpa.dupe(pdf_impl.Quad, selection_before.quads);
+ defer gpa.free(selection_quads_before);
+ const selection_text_before = try gpa.dupe(u8, pv.selection_text);
+ defer gpa.free(selection_text_before);
+ const selection_anchor_before = pv.selection_anchor;
+ const selection_head_before = pv.selection_head;
+ const query_before = try gpa.dupe(u8, pv.search_query);
+ defer gpa.free(query_before);
+ const search_hit_before = pv.search_hit;
+ const jumps_before = .{ p.njumps, p.jcur, p.n_look_src };
+ const effects_before = p.effects_len;
+ try std.testing.expect(p.drag == .none);
+
+ const hover_motion = Event{ .mouse = .{
+ .button = .none,
+ .kind = .motion,
+ .col = word_col,
+ .row = selected_row,
+ } };
+ p.update(hover_motion);
+ try std.testing.expect(p.look_hover_wait != null);
+ for (0..delay) |_| p.update(.tick);
+ const hover = p.pdf_hover_preview orelse return error.MissingPdfHoverPreview;
+ try std.testing.expectEqual(@as(usize, 0), hover.probe.page);
+ try std.testing.expect(hover.probe.quads.len > 0);
+ try std.testing.expect(std.ascii.indexOfIgnoreCase(hover.probe.text, "Pardes") != null);
+ try std.testing.expect(p.look_hover_preview == null);
+ try std.testing.expect(p.look_hover_wait == null);
+
+ const decorated = try pdf_pane.buildHighlights(
+ pv,
+ p.scratch.allocator(),
+ pdf_pane.highlightInput(p, 0, pane),
+ );
+ const page_highlights = decorated.forPage(pv.page, pv.page);
+ try std.testing.expectEqual(pdf_impl.HighlightKind.custom, page_highlights[0].kind);
+ try std.testing.expectEqual(pdf_impl.HighlightKind.selection, page_highlights[page_highlights.len - 1].kind);
+ _ = frame.reset(.retain_capacity);
+ const hovered_frame = try p.render(frame.allocator());
+ const hovered_revision = hovered_frame.images[0].?.native.revision;
+ try std.testing.expect(hovered_revision != selected_revision);
+
+ p.update(.pointer_leave);
+ try std.testing.expect(p.pdf_hover_preview == null);
+ _ = frame.reset(.retain_capacity);
+ const unhovered_frame = try p.render(frame.allocator());
+ try std.testing.expect(unhovered_frame.images[0].?.native.revision != hovered_revision);
+
+ try std.testing.expectEqual(active_before, p.active);
+ try std.testing.expectEqual(page_before, pv.page);
+ try std.testing.expectEqual(scroll_before, pv.document_scroll_y);
+ try std.testing.expect(std.meta.eql(sels_before, pane.sel));
+ try std.testing.expectEqual(cursor_before, .{ pane.cur_row, pane.cur_col });
+ try std.testing.expect(std.meta.eql(modal_before, .{ pane.msel, pane.vsel, pane.nsel }));
+ const selection_after = pv.selection orelse return error.HoverDroppedPdfSelection;
+ try std.testing.expectEqualSlices(pdf_impl.Quad, selection_quads_before, selection_after.quads);
+ try std.testing.expectEqualStrings(selection_text_before, pv.selection_text);
+ try std.testing.expect(std.meta.eql(selection_anchor_before, pv.selection_anchor));
+ try std.testing.expect(std.meta.eql(selection_head_before, pv.selection_head));
+ try std.testing.expectEqualStrings(query_before, pv.search_query);
+ try std.testing.expectEqual(search_hit_before, pv.search_hit);
+ try std.testing.expectEqual(jumps_before, .{ p.njumps, p.jcur, p.n_look_src });
+ try std.testing.expectEqual(effects_before, p.effects_len);
+ try std.testing.expect(p.drag == .none);
+ }
+
+ // A native right-click resolves the same MuPDF-snapped word and feeds it
+ // to Look. Search highlights precede selection highlights so the live
+ // selection remains visually authoritative where they overlap.
+ p.update(.{ .mouse = .{ .button = config.look_button, .kind = .press, .col = word_col, .row = selected_row } });
+ p.update(.{ .mouse = .{ .button = config.look_button, .kind = .release, .col = word_col, .row = selected_row } });
+ try std.testing.expect(std.ascii.indexOfIgnoreCase(pv.search_query, "Pardes") != null);
+ pv.resolveSearch(p.pdf_gpa);
+ const highlights = (try pdf_pane.buildHighlights(
+ pv,
+ p.scratch.allocator(),
+ pdf_pane.highlightInput(p, 0, pane),
+ )).items;
+ try std.testing.expect(highlights.len > 1);
+ try std.testing.expectEqual(pdf_impl.HighlightKind.search, highlights[0].kind);
+ try std.testing.expectEqual(pdf_impl.HighlightKind.selection, highlights[highlights.len - 1].kind);
+ const revision_before_reveal = raster.revision;
+ const max_document_scroll = @as(f64, @floatFromInt(
+ pv.document_height -| pdf_pane.paneViewport(p, pane).?.pixel_h,
+ ));
+ pv.document_scroll_y = max_document_scroll;
+ pdf_pane.revealSearch(pv, pdf_pane.paneViewport(p, pane).?, pdf_pane.paneGeometry(p, pane));
+ const revealed_scroll = pv.document_scroll_y;
+ try std.testing.expect(revealed_scroll != max_document_scroll);
+ try std.testing.expectEqual(revision_before_reveal, raster.revision);
+ pv.document_scroll_y = max_document_scroll;
+ pdf_pane.revealSearch(
+ pv,
+ pdf_pane.paneViewport(p, pane).?,
+ pdf_pane.paneGeometry(p, pane),
+ ); // one shot: subsequent manual pan stays put
+ try std.testing.expectEqual(max_document_scroll, pv.document_scroll_y);
+ pv.document_scroll_y = revealed_scroll;
+ _ = frame.reset(.retain_capacity);
+ const searched = try p.render(frame.allocator());
+ try std.testing.expect(searched.images[0].?.native.revision != selected_revision);
+
+ // Fit and viewport changes can crop a hit which was already revealed.
+ // Fit remains placement-only. SDL's physical-viewport quality request
+ // changes with a resize and therefore replaces pixels; Kitty's fixed,
+ // bandwidth-conscious request retains them.
+ const revision_before_geometry_change = searched.images[0].?.native.revision;
+ pv.search_reveal_pending = false;
+ pdf_pane.toggleFit(pane);
+ try std.testing.expect(pv.search_reveal_pending);
+ pv.search_reveal_pending = false;
+ pdf_pane.toggleFit(pane); // restore the reading-width geometry
+ try std.testing.expect(pv.search_reveal_pending);
+ pv.search_reveal_pending = false;
+ const original_cell_pixels = p.cell_pixels;
+ p.update(.{ .resize = .{
+ .cols = p.screen_w,
+ .rows = p.screen_h,
+ .cell_pixels = p.cell_pixels,
+ } });
+ try std.testing.expect(!pv.search_reveal_pending);
+ p.update(.{ .resize = .{
+ .cols = p.screen_w,
+ .rows = p.screen_h,
+ .cell_pixels = .{
+ .w = original_cell_pixels.w,
+ .h = original_cell_pixels.h + 1,
+ },
+ } });
+ try std.testing.expect(pv.search_reveal_pending);
+ _ = frame.reset(.retain_capacity);
+ const geometry_changed = try p.render(frame.allocator());
+ if (pdf_raster_policy.match_viewport)
+ try std.testing.expect(geometry_changed.images[0].?.native.revision !=
+ revision_before_geometry_change)
+ else
+ try std.testing.expectEqual(
+ revision_before_geometry_change,
+ geometry_changed.images[0].?.native.revision,
+ );
+ p.update(.{ .resize = .{
+ .cols = p.screen_w,
+ .rows = p.screen_h,
+ .cell_pixels = original_cell_pixels,
+ } });
+ _ = frame.reset(.retain_capacity);
+ _ = try p.render(frame.allocator());
+
+ // Unsupported text-selection actions are consumed by the PDF adapter:
+ // they neither invent a range over placeholder cells nor disturb the
+ // native MuPDF selection. The select-button's no-drag click still clears.
+ p.update(.{ .key = .{ .cp = 'v' } });
+ try std.testing.expect(pv.selection != null);
+ try std.testing.expect(!pane.vsel.active);
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = word_col, .row = selected_row } });
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = word_col, .row = selected_row } });
+ try std.testing.expect(pv.selection == null);
+ try std.testing.expect(pdf_pane.beginSelection(p, pane, word_col, selected_row));
+
+ // Native drag events update MuPDF quads and copied text immediately, but
+ // keep the already transmitted page generation stable until release.
+ _ = frame.reset(.retain_capacity);
+ const before_drag = try p.render(frame.allocator());
+ const before_drag_revision = before_drag.images[0].?.native.revision;
+ const drag_col = @min(r.x + r.w - 1, word_col +| 12);
+ const drag_row = @min(r.y + r.h - 1, selected_row +| 4);
+ p.update(.{ .mouse = .{
+ .button = config.select_button,
+ .kind = .press,
+ .col = word_col,
+ .row = selected_row,
+ } });
+ p.update(.{ .mouse = .{
+ .button = config.select_button,
+ .kind = .drag,
+ .col = @min(drag_col, word_col +| 4),
+ .row = @min(drag_row, selected_row +| 2),
+ } });
+ _ = frame.reset(.retain_capacity);
+ const during_first_drag = try p.render(frame.allocator());
+ try std.testing.expectEqual(
+ before_drag_revision,
+ during_first_drag.images[0].?.native.revision,
+ );
+ p.update(.{ .mouse = .{
+ .button = config.select_button,
+ .kind = .drag,
+ .col = drag_col,
+ .row = drag_row,
+ } });
+ try std.testing.expect(p.drag.select.pdf.selection_changed);
+ _ = frame.reset(.retain_capacity);
+ const during_second_drag = try p.render(frame.allocator());
+ try std.testing.expectEqual(
+ before_drag_revision,
+ during_second_drag.images[0].?.native.revision,
+ );
+ p.update(.{ .mouse = .{
+ .button = config.select_button,
+ .kind = .release,
+ .col = drag_col,
+ .row = drag_row,
+ } });
+ try std.testing.expect(!raster.tried);
+ _ = frame.reset(.retain_capacity);
+ const committed_drag = try p.render(frame.allocator());
+ try std.testing.expectEqual(
+ before_drag_revision + 1,
+ committed_drag.images[0].?.native.revision,
+ );
+ _ = frame.reset(.retain_capacity);
+ const stable_drag = try p.render(frame.allocator());
+ try std.testing.expectEqual(
+ committed_drag.images[0].?.native.revision,
+ stable_drag.images[0].?.native.revision,
+ );
+
+ const saved_query = try gpa.dupe(u8, pv.search_query);
+ defer gpa.free(saved_query);
+ pdf_pane.setPage(p, pane, 1);
+ try std.testing.expect(pv.selection == null);
+ try std.testing.expectEqual(@as(usize, 0), pv.selection_text.len);
+ try std.testing.expectEqualStrings(saved_query, pv.search_query);
+}
diff --git a/src/runtime_config.zig b/src/runtime_config.zig
new file mode 100644
index 00000000..806cb766
--- /dev/null
+++ b/src/runtime_config.zig
@@ -0,0 +1,568 @@
+//! Runtime choices in one plain, owned record.
+//!
+//! `Setting` is compile-time metadata for the builtin registry; `State` is the
+//! data it mutates and the Config report reads. Neither contains callbacks.
+const std = @import("std");
+const panel_animation = @import("panel_animation.zig");
+
+/// Tagline glyphs retain body-cell geometry, so allowing a face larger than
+/// the body would clip into neighbouring cells. Zero would make the role
+/// invisible. Keep the runtime command on the same 1...100 contract as the
+/// build-time default in config.zig.
+pub const tagline_percent_min: u8 = 1;
+pub const tagline_percent_max: u8 = 100;
+
+pub fn Text(comptime capacity: usize) type {
+ return struct {
+ bytes: [capacity]u8 = @splat(0),
+ len: std.math.IntFittingRange(0, capacity) = 0,
+
+ pub fn get(value: *const @This()) []const u8 {
+ return value.bytes[0..value.len];
+ }
+
+ pub fn set(value: *@This(), text: []const u8) bool {
+ if (text.len > capacity) return false;
+ @memcpy(value.bytes[0..text.len], text);
+ value.len = @intCast(text.len);
+ return true;
+ }
+
+ pub fn clear(value: *@This()) void {
+ value.len = 0;
+ }
+ };
+}
+
+pub const State = struct {
+ theme: usize = 0,
+ colors: bool = true,
+ wrap: bool = true,
+ tag_bottom: bool = false,
+ debug: bool = false,
+
+ /// Empty requested text means config.default_shell. Resolution belongs to
+ /// the native host (the core deliberately has no filesystem), so retain
+ /// the executable it actually chose separately and mark a changed request
+ /// pending until the next terminal spawn acknowledges it.
+ shell: struct {
+ requested: Text(255) = .{},
+ // Fixed across native and freestanding builds: runtime State is one
+ // data schema, and web has no std.fs PATH_MAX to derive this from.
+ effective: Text(4095) = .{},
+ pending: bool = true,
+ } = .{},
+
+ /// The shell acknowledges a font before `effective` changes. A rejected
+ /// request therefore remains queryable without claiming it is on screen.
+ font: struct {
+ // 4095 leaves room for the NUL in the macOS C bridge without a
+ // second, subtly different limit at that boundary.
+ requested_path: Text(4095) = .{},
+ requested_name: Text(255) = .{},
+ effective_name: Text(255) = .{},
+ pending: bool = false,
+ effective_size_hundredths: u16 = 0,
+ effective_size_unit: FontSizeUnit = .unknown,
+ // Pardes.init copies config.gui_tagline_font_percent here. Keeping
+ // the compiled choice in the live record makes Config truthful while
+ // avoiding the config -> builtins -> runtime_config import cycle.
+ tagline_percent: u8 = 100,
+ } = .{},
+
+ panel_transition: panel_animation.Transition = .off,
+ scene_effects: panel_animation.SceneEffect = .{},
+
+ pub fn toggleTransition(state: *State, effect: panel_animation.Transition) void {
+ std.debug.assert(effect != .off);
+ state.panel_transition = if (state.panel_transition == effect) .off else effect;
+ }
+};
+
+pub const FontSizeUnit = enum { unknown, pixels, points };
+
+/// Replace the requested font tuple atomically. Both fixed strings are
+/// preflighted before either changes, so a rejected long name cannot leave a
+/// new path paired with stale metadata.
+pub fn requestFont(state: *State, path: []const u8, name: []const u8) bool {
+ if (path.len > state.font.requested_path.bytes.len or
+ name.len > state.font.requested_name.bytes.len) return false;
+ std.debug.assert(state.font.requested_path.set(path));
+ std.debug.assert(state.font.requested_name.set(name));
+ state.font.pending = true;
+ return true;
+}
+
+/// Backend facilities are plain data supplied once by the platform-facing
+/// registry. The same value gates command generation, leader paths, source
+/// queries, and the Config report, so "unsupported" cannot mean four subtly
+/// different things at those four call sites.
+pub const Capabilities = struct {
+ font_picker: bool,
+ panel_transitions: bool,
+ scene_shaders: bool,
+ /// A smaller tagline face is also supported by the browser, which does
+ /// not own a native font picker. Keep this fact deliberately independent.
+ tagline_font_size: bool,
+};
+
+pub const Capability = std.meta.FieldEnum(Capabilities);
+
+pub const Toggle = enum { colors, wrap, tag_bottom, debug };
+pub const Scene = std.meta.FieldEnum(panel_animation.SceneEffect);
+
+pub const Action = union(enum) {
+ toggle: Toggle,
+ shell,
+ theme,
+ font,
+ tagline_size,
+ transition: panel_animation.Transition,
+ scene: Scene,
+};
+
+pub const Setting = struct {
+ word: []const u8,
+ action: Action,
+ availability: ?Capability = null,
+
+ pub fn takesArg(setting: Setting) bool {
+ return switch (setting.action) {
+ .shell, .theme, .font, .tagline_size => true,
+ else => false,
+ };
+ }
+
+ pub fn enabled(setting: Setting, capabilities: Capabilities) bool {
+ const capability = setting.availability orelse return true;
+ return switch (capability) {
+ inline else => |field| @field(capabilities, @tagName(field)),
+ };
+ }
+};
+
+/// This table is both the generated-setting builtin input and the Config
+/// report order. Adding mutable config without adding a query row is therefore
+/// impossible unless it is deliberately kept out of this user-facing state.
+pub const settings = [_]Setting{
+ .{ .word = "Colors", .action = .{ .toggle = .colors } },
+ .{ .word = "Wrap", .action = .{ .toggle = .wrap } },
+ .{ .word = "Tagbottom", .action = .{ .toggle = .tag_bottom } },
+ .{ .word = "Debug", .action = .{ .toggle = .debug } },
+ .{ .word = "Theme", .action = .theme },
+ .{ .word = "Shell", .action = .shell },
+ .{ .word = "Font", .action = .font, .availability = .font_picker },
+ .{ .word = "TaglineSize", .action = .tagline_size, .availability = .font_picker },
+ .{ .word = "PanelSlide", .action = .{ .transition = .slide }, .availability = .panel_transitions },
+ .{ .word = "PanelZoom", .action = .{ .transition = .zoom }, .availability = .panel_transitions },
+ .{ .word = "PanelDissolve", .action = .{ .transition = .dissolve }, .availability = .panel_transitions },
+ .{ .word = "PanelAscii", .action = .{ .transition = .ascii }, .availability = .panel_transitions },
+ .{ .word = "PanelVertical", .action = .{ .transition = .vertical }, .availability = .panel_transitions },
+ .{ .word = "Crt", .action = .{ .scene = .crt }, .availability = .scene_shaders },
+ .{ .word = "Ripple", .action = .{ .scene = .ripple }, .availability = .scene_shaders },
+ .{ .word = "Glitch", .action = .{ .scene = .glitch }, .availability = .scene_shaders },
+};
+
+pub fn find(name: []const u8) ?Setting {
+ for (settings) |setting| if (std.mem.eql(u8, setting.word, name)) return setting;
+ return null;
+}
+
+/// The table is also the sole action-to-command vocabulary. Compile-time
+/// callers use this for picker rows; the Config report uses it for the active
+/// transition. No parallel enum or transition-name switch can drift.
+pub fn findAction(action: Action) ?Setting {
+ for (settings) |setting| if (std.meta.eql(setting.action, action)) return setting;
+ return null;
+}
+
+fn actionCount(comptime action: Action) comptime_int {
+ var count = 0;
+ for (settings) |setting| count += @intFromBool(std.meta.eql(setting.action, action));
+ return count;
+}
+
+comptime {
+ @setEvalBranchQuota(20_000);
+ for (settings, 0..) |setting, i| {
+ if (setting.word.len == 0) @compileError("runtime setting has an empty command word");
+ for (settings[i + 1 ..]) |later| if (std.mem.eql(u8, setting.word, later.word))
+ @compileError("duplicate runtime setting command word: " ++ setting.word);
+ switch (setting.action) {
+ .font, .tagline_size => if (setting.availability != .font_picker)
+ @compileError("native font settings must use the font-picker capability"),
+ .transition => if (setting.availability != .panel_transitions)
+ @compileError("panel effects must use the panel-transition capability"),
+ .scene => if (setting.availability != .scene_shaders)
+ @compileError("scene effects must use the scene-shader capability"),
+ else => if (setting.availability != null)
+ @compileError("unconditional settings cannot carry a backend capability"),
+ }
+ }
+ for (std.enums.values(Toggle)) |field| if (actionCount(.{ .toggle = field }) != 1)
+ @compileError("runtime toggle must occur exactly once: " ++ @tagName(field));
+ if (actionCount(.shell) != 1 or actionCount(.theme) != 1 or actionCount(.font) != 1 or
+ actionCount(.tagline_size) != 1)
+ @compileError("Shell, Theme, Font, and TaglineSize actions must each occur exactly once");
+ for (std.enums.values(panel_animation.Transition)) |effect| {
+ const expected: comptime_int = @intFromBool(effect != .off);
+ if (actionCount(.{ .transition = effect }) != expected)
+ @compileError("non-off panel transition must occur exactly once: " ++ @tagName(effect));
+ }
+ for (std.enums.values(Scene)) |effect| {
+ if (actionCount(.{ .scene = effect }) != 1)
+ @compileError("scene effect must occur exactly once: " ++ @tagName(effect));
+ if (@FieldType(panel_animation.SceneEffect, @tagName(effect)) != bool)
+ @compileError("scene effect fields must be booleans: " ++ @tagName(effect));
+ }
+}
+
+/// Apply settings whose operation is independent of themes, font discovery,
+/// or a shell. Those three remain explicit at the generated builtin's edge.
+pub fn applySimple(state: *State, setting: Setting, argument: ?[]const u8) bool {
+ switch (setting.action) {
+ .toggle => |field| switch (field) {
+ .colors => state.colors = !state.colors,
+ .wrap => state.wrap = !state.wrap,
+ .tag_bottom => state.tag_bottom = !state.tag_bottom,
+ .debug => state.debug = !state.debug,
+ },
+ .shell => {
+ const value = std.mem.trim(u8, argument orelse return false, " \t\r\n");
+ if (value.len == 0 or !state.shell.requested.set(value)) return false;
+ state.shell.pending = true;
+ },
+ .tagline_size => {
+ const text = std.mem.trim(u8, argument orelse return false, " \t\r\n");
+ const percent = std.fmt.parseInt(u16, text, 10) catch return false;
+ if (percent < tagline_percent_min or percent > tagline_percent_max) return false;
+ // Parse and validate before the sole write: malformed commands
+ // cannot partially alter the live configuration.
+ state.font.tagline_percent = @intCast(percent);
+ },
+ .transition => |effect| state.toggleTransition(effect),
+ .scene => |effect| switch (effect) {
+ inline else => |field| {
+ const value = &@field(state.scene_effects, @tagName(field));
+ value.* = !value.*;
+ },
+ },
+ .theme, .font => return false,
+ }
+ return true;
+}
+
+/// The runtime facts which do not belong to mutable `State`, supplied by the
+/// core when it materialises +Config. Slices are borrowed for this one write.
+pub const ReportContext = struct {
+ startup_config_path: ?[]const u8,
+ platform: []const u8,
+ theme_name: []const u8,
+ compiled_default_shell: []const u8,
+ /// Null outside the SDL GUI. The borrowed label comes from effect_sources,
+ /// which knows whether its embedded GLSL import is live or paired prebuilt.
+ gui_shader_source_mode: ?[]const u8 = null,
+ hover_delay_frames: ?u16,
+ native_images: bool,
+ capabilities: Capabilities,
+ state: *const State,
+};
+
+fn onOff(value: bool) []const u8 {
+ return if (value) "on" else "off";
+}
+
+fn shown(text: []const u8) []const u8 {
+ return if (text.len == 0) "(none)" else text;
+}
+
+/// Name the setting which selected the one active transition. This keeps the
+/// report vocabulary identical to the generated builtin registry.
+fn transitionSettingName(transition: panel_animation.Transition) []const u8 {
+ if (transition == .off) return "off";
+ return findAction(.{ .transition = transition }).?.word;
+}
+
+/// Write the complete live Config report without allocation. Setting-backed
+/// rows follow `settings` order; host facts follow them as a compact footer.
+pub fn writeReport(out: *std.Io.Writer, context: ReportContext) !void {
+ const state = context.state;
+ var wrote_transition = false;
+ for (settings) |setting| switch (setting.action) {
+ .toggle => |field| {
+ const value = switch (field) {
+ .colors => state.colors,
+ .wrap => state.wrap,
+ .tag_bottom => state.tag_bottom,
+ .debug => state.debug,
+ };
+ try out.print("{s}: {s}\n", .{ setting.word, onOff(value) });
+ },
+ .theme => try out.print("{s}: {s}\n", .{ setting.word, context.theme_name }),
+ .shell => {
+ const chosen = state.shell.requested.get();
+ try out.print(
+ "{s} requested (new panes): {s}{s}\n" ++
+ "{s} effective (last spawn): {s}\n" ++
+ "{s} pending: {s}\n",
+ .{
+ setting.word,
+ if (chosen.len == 0) context.compiled_default_shell else chosen,
+ if (chosen.len == 0) " (default)" else "",
+ setting.word,
+ shown(state.shell.effective.get()),
+ setting.word,
+ onOff(state.shell.pending),
+ },
+ );
+ },
+ .font => if (!setting.enabled(context.capabilities))
+ try out.print("{s}: unsupported\n", .{setting.word})
+ else
+ try out.print(
+ "{s} requested: {s}\n" ++
+ "{s} requested path: {s}\n" ++
+ "{s} effective: {s}\n" ++
+ "{s} pending: {s}\n" ++
+ "{s} effective size: {d}.{d:0>2} {s}\n",
+ .{
+ setting.word,
+ shown(state.font.requested_name.get()),
+ setting.word,
+ shown(state.font.requested_path.get()),
+ setting.word,
+ shown(state.font.effective_name.get()),
+ setting.word,
+ onOff(state.font.pending),
+ setting.word,
+ state.font.effective_size_hundredths / 100,
+ state.font.effective_size_hundredths % 100,
+ @tagName(state.font.effective_size_unit),
+ },
+ ),
+ .tagline_size => if (!context.capabilities.tagline_font_size)
+ try out.print("{s}: unsupported\n", .{setting.word})
+ else if (!setting.enabled(context.capabilities))
+ try out.print("{s}: {d}% (build-time only)\n", .{ setting.word, state.font.tagline_percent })
+ else
+ try out.print("{s}: {d}%\n", .{ setting.word, state.font.tagline_percent }),
+ .transition => {
+ if (wrote_transition) continue;
+ wrote_transition = true;
+ if (!setting.enabled(context.capabilities))
+ try out.writeAll("Panel transition: unsupported\n")
+ else
+ try out.print("Panel transition: {s}\n", .{transitionSettingName(state.panel_transition)});
+ },
+ .scene => |effect| {
+ if (!setting.enabled(context.capabilities)) {
+ try out.print("{s}: unsupported\n", .{setting.word});
+ continue;
+ }
+ const enabled = switch (effect) {
+ inline else => |field| @field(state.scene_effects, @tagName(field)),
+ };
+ try out.print("{s}: {s}\n", .{ setting.word, onOff(enabled) });
+ },
+ };
+
+ if (context.startup_config_path) |path|
+ try out.print("Startup config: {s}\n", .{path})
+ else
+ try out.writeAll("Startup config: no per-user config path\n");
+ try out.print(
+ "Platform: {s}\n" ++
+ "Compiled default shell: {s}\n",
+ .{ context.platform, context.compiled_default_shell },
+ );
+ if (context.gui_shader_source_mode) |mode|
+ try out.print("GUI shader source: {s}\n", .{mode});
+ if (context.hover_delay_frames) |frames|
+ try out.print("Look hover delay: {d} frames\n", .{frames})
+ else
+ try out.writeAll("Look hover delay: off\n");
+ try out.print("Native images: {s}\n", .{onOff(context.native_images)});
+}
+
+test "setting names are unique and argument metadata follows actions" {
+ for (settings, 0..) |setting, i| {
+ try std.testing.expect(setting.word.len > 0);
+ 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 => true,
+ else => false,
+ }, setting.takesArg());
+ }
+}
+
+test "simple setting application mutates only its plain field" {
+ var state: State = .{};
+ try std.testing.expect(applySimple(&state, find("Colors").?, null));
+ try std.testing.expect(!state.colors);
+ try std.testing.expect(applySimple(&state, find("Shell").?, " fish\n"));
+ try std.testing.expectEqualStrings("fish", state.shell.requested.get());
+ try std.testing.expect(state.shell.pending);
+ try std.testing.expect(applySimple(&state, find("PanelAscii").?, null));
+ try std.testing.expectEqual(panel_animation.Transition.ascii, state.panel_transition);
+ try std.testing.expect(applySimple(&state, find("PanelAscii").?, null));
+ try std.testing.expectEqual(panel_animation.Transition.off, state.panel_transition);
+ try std.testing.expect(applySimple(&state, find("Crt").?, null));
+ try std.testing.expect(state.scene_effects.crt);
+}
+
+test "tagline size validates before mutating live state" {
+ const setting = find("TaglineSize").?;
+ var state: State = .{};
+
+ for ([_][]const u8{ "1", " 82\n", "100" }) |argument| {
+ try std.testing.expect(applySimple(&state, setting, argument));
+ try std.testing.expectEqual(try std.fmt.parseInt(u8, std.mem.trim(u8, argument, " \t\r\n"), 10), state.font.tagline_percent);
+ }
+
+ state.font.tagline_percent = 67;
+ for ([_]?[]const u8{ null, "", "0", "101", "-1", "50%", "999999999999999999999" }) |argument| {
+ try std.testing.expect(!applySimple(&state, setting, argument));
+ try std.testing.expectEqual(@as(u8, 67), state.font.tagline_percent);
+ }
+}
+
+test "font request tuple rejects atomically" {
+ var state: State = .{};
+ try std.testing.expect(requestFont(&state, "/fonts/old.ttf", "Old"));
+ var too_long: [256]u8 = @splat('x');
+ try std.testing.expect(!requestFont(&state, "/fonts/new.ttf", &too_long));
+ try std.testing.expectEqualStrings("/fonts/old.ttf", state.font.requested_path.get());
+ try std.testing.expectEqualStrings("Old", state.font.requested_name.get());
+}
+
+test "Config report observes every simple setting and all live context" {
+ var state: State = .{};
+ var storage: [4096]u8 = undefined;
+ const context: ReportContext = .{
+ .startup_config_path = "/tmp/pardes/config",
+ .platform = "gui",
+ .theme_name = "acme",
+ .compiled_default_shell = "/bin/sh",
+ .gui_shader_source_mode = "live GLSL compiled during this build",
+ .hover_delay_frames = 18,
+ .native_images = true,
+ .capabilities = .{
+ .font_picker = true,
+ .panel_transitions = true,
+ .scene_shaders = true,
+ .tagline_font_size = true,
+ },
+ .state = &state,
+ };
+
+ for (settings) |setting| {
+ switch (setting.action) {
+ .theme, .font => continue,
+ else => {},
+ }
+ const argument: ?[]const u8 = switch (setting.action) {
+ .shell => "fish",
+ .tagline_size => "73",
+ else => null,
+ };
+ try std.testing.expect(applySimple(&state, setting, argument));
+
+ var out: std.Io.Writer = .fixed(&storage);
+ try writeReport(&out, context);
+ const report = storage[0..out.end];
+ const expected = switch (setting.action) {
+ .toggle => |field| switch (field) {
+ .colors => "Colors: off\n",
+ .wrap => "Wrap: off\n",
+ .tag_bottom => "Tagbottom: on\n",
+ .debug => "Debug: on\n",
+ },
+ .shell => "Shell requested (new panes): fish\n",
+ .tagline_size => "TaglineSize: 73%\n",
+ .transition => |transition| switch (transition) {
+ .off => unreachable,
+ .slide => "Panel transition: PanelSlide\n",
+ .zoom => "Panel transition: PanelZoom\n",
+ .dissolve => "Panel transition: PanelDissolve\n",
+ .ascii => "Panel transition: PanelAscii\n",
+ .vertical => "Panel transition: PanelVertical\n",
+ },
+ .scene => |effect| switch (effect) {
+ .crt => "Crt: on\n",
+ .ripple => "Ripple: on\n",
+ .glitch => "Glitch: on\n",
+ },
+ .theme, .font => unreachable,
+ };
+ try std.testing.expect(std.mem.indexOf(u8, report, expected) != null);
+ }
+
+ try std.testing.expect(state.font.requested_name.set("Wanted Mono"));
+ try std.testing.expect(state.font.requested_path.set("/fonts/wanted.ttf"));
+ try std.testing.expect(state.font.effective_name.set("Effective Mono"));
+ state.font.pending = true;
+ state.font.effective_size_hundredths = 1375;
+ state.font.effective_size_unit = .points;
+ state.font.tagline_percent = 82;
+
+ var out: std.Io.Writer = .fixed(&storage);
+ try writeReport(&out, context);
+ const report = storage[0..out.end];
+ for ([_][]const u8{
+ "Theme: acme\n",
+ "Font requested: Wanted Mono\n",
+ "Font requested path: /fonts/wanted.ttf\n",
+ "Font effective: Effective Mono\n",
+ "Font pending: on\n",
+ "Font effective size: 13.75 points\n",
+ "TaglineSize: 82%\n",
+ "Startup config: /tmp/pardes/config\n",
+ "Platform: gui\n",
+ "Compiled default shell: /bin/sh\n",
+ "GUI shader source: live GLSL compiled during this build\n",
+ "Look hover delay: 18 frames\n",
+ "Native images: on\n",
+ }) |expected| try std.testing.expect(std.mem.indexOf(u8, report, expected) != null);
+
+ var defaults: State = .{};
+ var defaults_context = context;
+ defaults_context.startup_config_path = null;
+ defaults_context.platform = "tty";
+ defaults_context.gui_shader_source_mode = null;
+ defaults_context.hover_delay_frames = null;
+ defaults_context.native_images = false;
+ defaults_context.capabilities = .{
+ .font_picker = false,
+ .panel_transitions = true,
+ .scene_shaders = false,
+ .tagline_font_size = false,
+ };
+ defaults_context.state = &defaults;
+ out = .fixed(&storage);
+ try writeReport(&out, defaults_context);
+ const defaults_report = storage[0..out.end];
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell requested (new panes): /bin/sh (default)\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell effective (last spawn): (none)\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell pending: on\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Startup config: no per-user config path\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "GUI shader source:") == null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font: unsupported\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font requested:") == null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Panel transition: off\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Crt: unsupported\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Ripple: unsupported\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Glitch: unsupported\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "TaglineSize: unsupported\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Look hover delay: off\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Native images: off\n") != null);
+
+ defaults_context.platform = "web";
+ defaults_context.capabilities.panel_transitions = false;
+ defaults_context.capabilities.tagline_font_size = true;
+ out = .fixed(&storage);
+ try writeReport(&out, defaults_context);
+ const web_report = storage[0..out.end];
+ try std.testing.expect(std.mem.indexOf(u8, web_report, "Panel transition: unsupported\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, web_report, "TaglineSize: 100% (build-time only)\n") != null);
+}
diff --git a/src/shell_bin.zig b/src/shell_bin.zig
index ecd80937..81ff90fa 100644
--- a/src/shell_bin.zig
+++ b/src/shell_bin.zig
@@ -9,6 +9,11 @@
//! keeping a copy each. Nothing here imports the core, which is also what lets
//! it be its own std-only test module.
//!
+//! Each host owns one `PromptRcs` for its lifetime. Its files are private
+//! `mkstemp` names, completely written and closed before resolve can expose
+//! them to a child. Concurrent launches therefore share neither a pathname nor
+//! an inode, and a shell can never source another user's predictable /tmp file.
+//!
//! ALL OF THIS RUNS IN THE PARENT. Between fork and exec a process may not
//! allocate, and a $PATH search does — which is the same reason the exec is
//! `execv` on an absolute path and never `execvp`. So the lookup is a handful
@@ -21,6 +26,8 @@ const builtin = @import("builtin");
const libc = std.c;
const X_OK: c_int = 1;
+extern "c" fn mkstemp(template: [*:0]u8) c_int;
+
/// Prompt integration, per shell FAMILY rather than per binary: pardes hides
/// prompt rows, moves the cursor by clicking one, and tells a command's output
/// from the line that asked for it, and all three read the OSC 133 marks a
@@ -54,10 +61,7 @@ pub fn shellRc(bin: []const u8) ShellRc {
return .none;
}
-pub const bash_rc_path = "/tmp/pardes-osc133.bash";
-pub const fish_rc_path = "/tmp/pardes-osc133.fish";
-
-pub const bash_rc =
+const bash_rc =
\\[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc"
\\PS1='\[\e]133;A;cl=line\a\]'"$PS1"'\[\e]133;B\a\]'
\\PROMPT_COMMAND='printf "\e]133;D\a"'"${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
@@ -78,7 +82,7 @@ pub const bash_rc =
/// C and D come off fish's own `fish_preexec`/`fish_postexec` events rather
/// than being spliced into the prompt, which is what bash's DEBUG trap is
/// working around.
-pub const fish_rc =
+const fish_rc =
\\functions -c fish_prompt __pardes_user_prompt
\\function fish_prompt
\\ printf '\e]133;A;cl=line\a'
@@ -94,6 +98,87 @@ pub const fish_rc =
\\
;
+const rc_path_capacity = 64;
+
+/// The two complete, private prompt files a native host lends to every shell
+/// it spawns. No allocation and no global name: moving this value is safe
+/// because it stores lengths, never pointers into its own buffers.
+pub const PromptRcs = struct {
+ bash_path: [rc_path_capacity:0]u8 = @splat(0),
+ bash_len: u8 = 0,
+ fish_path: [rc_path_capacity:0]u8 = @splat(0),
+ fish_len: u8 = 0,
+ fish_command: [rc_path_capacity + "source ".len:0]u8 = @splat(0),
+ fish_command_len: u8 = 0,
+
+ pub fn init() PromptRcs {
+ var rcs: PromptRcs = .{};
+ rcs.bash_len = stage(&rcs.bash_path, "/tmp/pardes-osc133-bash-XXXXXX", bash_rc);
+ rcs.fish_len = stage(&rcs.fish_path, "/tmp/pardes-osc133-fish-XXXXXX", fish_rc);
+ if (rcs.fishPath()) |path| {
+ const command = std.fmt.bufPrintSentinel(&rcs.fish_command, "source {s}", .{path}, 0) catch {
+ _ = libc.unlink(path.ptr);
+ rcs.fish_len = 0;
+ return rcs;
+ };
+ rcs.fish_command_len = @intCast(command.len);
+ }
+ return rcs;
+ }
+
+ pub fn deinit(rcs: *PromptRcs) void {
+ if (rcs.bashPath()) |path| _ = libc.unlink(path.ptr);
+ if (rcs.fishPath()) |path| _ = libc.unlink(path.ptr);
+ rcs.bash_len = 0;
+ rcs.fish_len = 0;
+ rcs.fish_command_len = 0;
+ }
+
+ fn bashPath(rcs: *const PromptRcs) ?[:0]const u8 {
+ if (rcs.bash_len == 0) return null;
+ return rcs.bash_path[0..rcs.bash_len :0];
+ }
+
+ fn fishPath(rcs: *const PromptRcs) ?[:0]const u8 {
+ if (rcs.fish_len == 0) return null;
+ return rcs.fish_path[0..rcs.fish_len :0];
+ }
+
+ fn fishCommand(rcs: *const PromptRcs) ?[:0]const u8 {
+ if (rcs.fish_command_len == 0) return null;
+ return rcs.fish_command[0..rcs.fish_command_len :0];
+ }
+};
+
+/// Create one private 0600 file and reveal its length only after the complete
+/// write and close. Failure leaves no pathname for resolve to hand to a shell.
+fn stage(path_buf: *[rc_path_capacity:0]u8, template: []const u8, contents: []const u8) u8 {
+ const path = std.fmt.bufPrintSentinel(path_buf, "{s}", .{template}, 0) catch return 0;
+ const fd = mkstemp(path.ptr);
+ if (fd < 0) return 0;
+ var off: usize = 0;
+ while (off < contents.len) {
+ const n = libc.write(fd, contents[off..].ptr, contents.len - off);
+ if (n < 0) {
+ if (libc.errno(n) == .INTR) continue;
+ _ = libc.close(fd);
+ _ = libc.unlink(path.ptr);
+ return 0;
+ }
+ if (n == 0) {
+ _ = libc.close(fd);
+ _ = libc.unlink(path.ptr);
+ return 0;
+ }
+ off += @intCast(n);
+ }
+ if (libc.close(fd) != 0) {
+ _ = libc.unlink(path.ptr);
+ return 0;
+ }
+ return @intCast(path.len);
+}
+
test "shell family is the basename's prefix, and anything else runs unadorned" {
try std.testing.expectEqual(ShellRc.fish, shellRc("fish"));
try std.testing.expectEqual(ShellRc.fish, shellRc("/usr/bin/fish"));
@@ -143,17 +228,18 @@ pub const Spawn = struct {
/// `bin` is whatever the Shell builtin was given — a bare name to look up, or
/// a path (anything with a `/`) to take at its word. `buf` holds the resolved
/// path for as long as the returned Spawn is used, which for a caller that is
-/// about to fork means: until the child execs.
-pub fn resolve(bin: []const u8, buf: *[std.fs.max_path_bytes]u8) Spawn {
+/// about to fork means: until the child execs. `prompt_rcs` is host-lifetime
+/// storage and must likewise remain alive through that exec.
+pub fn resolve(bin: []const u8, buf: *[std.fs.max_path_bytes]u8, prompt_rcs: *const PromptRcs) Spawn {
const path = find(bin, buf) orelse fallback(buf);
// the family comes off the path that will ACTUALLY be executed, not the
// name that was asked for — `Shell sh` on a system where that is a symlink
// to bash still has no `--rcfile` promise attached to it, and a resolved
// /usr/bin/fish reads as fish whether it was reached by name or by path
const marks: [2]?[*:0]const u8 = switch (shellRc(std.mem.span(path))) {
- .bash => .{ "--rcfile", bash_rc_path },
+ .bash => if (prompt_rcs.bashPath()) |rc| .{ "--rcfile", rc.ptr } else .{ null, null },
// -C runs AFTER config.fish, which is the whole point (see fish_rc)
- .fish => .{ "-C", "source " ++ fish_rc_path },
+ .fish => if (prompt_rcs.fishCommand()) |command| .{ "-C", command.ptr } else .{ null, null },
.none => .{ null, null },
};
return .{ .path = path, .argv = .{ path, marks[0], marks[1], null } };
@@ -194,28 +280,67 @@ fn fallback(buf: *[std.fs.max_path_bytes]u8) [*:0]const u8 {
test "a path is taken at its word, a name is looked up, and both pick their own marks" {
if (builtin.os.tag == .windows) return;
var buf: [std.fs.max_path_bytes]u8 = undefined;
+ var prompt_rcs = PromptRcs.init();
+ defer prompt_rcs.deinit();
// /bin/sh exists on every unix this builds for and is in no family, so it
// pins the resolve-by-path arm AND the unadorned argv
- const sh = resolve("/bin/sh", &buf);
+ const sh = resolve("/bin/sh", &buf, &prompt_rcs);
try std.testing.expectEqualStrings("/bin/sh", std.mem.span(sh.path));
try std.testing.expect(sh.argv[1] == null);
// a name with no slash is searched for; whatever it resolves to, it is a
// bash and so carries --rcfile pointing at the rc the shells write
- const bash = resolve("bash", &buf);
+ const bash = resolve("bash", &buf, &prompt_rcs);
try std.testing.expect(shellRc(std.mem.span(bash.path)) == .bash);
try std.testing.expectEqualStrings("--rcfile", std.mem.span(bash.argv[1].?));
- try std.testing.expectEqualStrings(bash_rc_path, std.mem.span(bash.argv[2].?));
+ try std.testing.expectEqualStrings(prompt_rcs.bashPath().?, std.mem.span(bash.argv[2].?));
// nothing is installed under this name, so the fallback answers — and the
// fallback is a real executable, not the name that failed
- const missing = resolve("zznosuchshell", &buf);
+ const missing = resolve("zznosuchshell", &buf, &prompt_rcs);
try std.testing.expect(!std.mem.eql(u8, "zznosuchshell", std.mem.span(missing.path)));
try std.testing.expect(libc.access(missing.path, X_OK) == 0);
// an absolute path that does not exist falls back too, rather than being
// handed to exec to fail on
- const gone = resolve("/zz/no/such/shell", &buf);
+ const gone = resolve("/zz/no/such/shell", &buf, &prompt_rcs);
try std.testing.expect(libc.access(gone.path, X_OK) == 0);
}
+
+test "prompt rc owners have private complete files and clean them up" {
+ if (builtin.os.tag == .windows) return;
+ var a = PromptRcs.init();
+ defer a.deinit();
+ var b = PromptRcs.init();
+ defer b.deinit();
+ const a_bash = a.bashPath() orelse return error.TempCreateFailed;
+ const b_bash = b.bashPath() orelse return error.TempCreateFailed;
+ const a_fish = a.fishPath() orelse return error.TempCreateFailed;
+ try std.testing.expect(!std.mem.eql(u8, a_bash, b_bash));
+ const fish_command = a.fishCommand() orelse return error.MissingFishCommand;
+ try std.testing.expectEqualStrings("source ", fish_command[0.."source ".len]);
+ try std.testing.expectEqualStrings(a_fish, fish_command["source ".len..]);
+
+ var buf: [bash_rc.len]u8 = undefined;
+ const fd = libc.open(a_bash.ptr, .{ .ACCMODE = .RDONLY });
+ if (fd < 0) return error.OpenFailed;
+ defer _ = libc.close(fd);
+ var len: usize = 0;
+ while (len < buf.len) {
+ const n = libc.read(fd, buf[len..].ptr, buf.len - len);
+ if (n < 0) {
+ if (libc.errno(n) == .INTR) continue;
+ return error.ReadFailed;
+ }
+ if (n == 0) break;
+ len += @intCast(n);
+ }
+ try std.testing.expectEqualStrings(bash_rc, buf[0..len]);
+
+ var removed: [rc_path_capacity:0]u8 = @splat(0);
+ @memcpy(removed[0..a_bash.len], a_bash);
+ removed[a_bash.len] = 0;
+ a.deinit();
+ try std.testing.expect(libc.access(&removed, 0) < 0);
+}
diff --git a/src/syntax.zig b/src/syntax.zig
index a745b678..b7b9f32f 100644
--- a/src/syntax.zig
+++ b/src/syntax.zig
@@ -185,10 +185,6 @@ fn synFor(name: []const u8) Syn {
return .none;
}
-pub fn highlightFile(gpa: std.mem.Allocator, path: []const u8, content: []const u8) ![]u8 {
- return highlightFileRange(gpa, path, content, 0, content.len);
-}
-
/// One Syn byte per content byte in [start, end). Caller frees.
pub fn highlightFileRange(gpa: std.mem.Allocator, path: []const u8, content: []const u8, start_byte_raw: usize, end_byte_raw: usize) ![]u8 {
const tz = tracy.zone(@src(), "highlightFileRange");
diff --git a/src/term_pane.zig b/src/term_pane.zig
index 1243dfa1..adba1cd6 100644
--- a/src/term_pane.zig
+++ b/src/term_pane.zig
@@ -1,24 +1,440 @@
//! Terminal panes: everything a Pane does BECAUSE it owns a ghostty-vt
-//! emulator — reading the grid back out as text (for motions and for the
-//! body), translating the emulator's own cell styles into ours, the pty
-//! replies ghostty hands back through a callback, and the edit-buffer undo
-//! snapshots that only exist because a terminal's "content" is a live grid
-//! rather than a []u8.
+//! emulator — constructing and replaying the emulator, reading its grid back
+//! out as text (for motions and for the body), translating its cell styles
+//! into ours, handling the pty replies it hands back through a callback, and
+//! keeping the edit-buffer undo snapshots that only exist because a terminal's
+//! "content" is a live grid rather than a []u8.
//!
-//! What is NOT here: the edit buffer's SEMANTICS (Pane.ovl, surfRow/gridRow)
-//! live on Pane in pardes.zig, because the motion, insert and undo machinery
-//! is shared with file panes verbatim — a terminal in insert mode behaving
-//! exactly like a text file is the whole design, and splitting the row math
-//! away from the code that uses it would only hide that.
+//! Shared modal edit semantics and the pane-wide screen/grid coordinate
+//! invariants remain on Pane in pardes.zig. Terminal-only projection, history
+//! snapshots, and cell styling live here, so the core does not need to know
+//! how a live terminal becomes an editable text surface.
const std = @import("std");
const ghostty_vt = @import("ghostty-vt");
const pardes = @import("pardes.zig");
const Pardes = pardes.Pardes;
const Pane = pardes.Pane;
-const EditSnap = pardes.EditSnap;
-const Ovl = pardes.Ovl;
+const Key = pardes.Key;
+const EditText = pardes.EditText;
const modal = @import("modal.zig");
const config = @import("config.zig");
+const dump = @import("dump.zig");
+
+pub const history_max = 64; // snapshots copy the whole edit buffer; keep this tighter than files
+
+/// The owned text standing in for `rows` live terminal rows, beginning at
+/// absolute surface row `row`. The emulator grid remains untouched underneath.
+pub const EditBuffer = struct {
+ row: i32 = 0,
+ rows: i32 = 1,
+ text: []u8 = &.{},
+};
+
+/// One whole-state terminal edit boundary. Null means the pane has not yet
+/// materialized an edit buffer; non-null snapshots own their text.
+pub const Snapshot = struct {
+ ovl: ?EditBuffer,
+ cur_row: i32,
+ cur_col: i32,
+ vsel: pardes.CharSel,
+};
+
+/// Command bytes aimed at a shell whose prompt does not exist yet. The host
+/// resolves the actual executable after the core has already queued `.spawn`,
+/// so `spawn` is deliberately an UNKNOWN phase: shells with prompt integration
+/// advance to `input` and wait for OSC 133 B; unadorned/unsupported shells are
+/// opened immediately by `shellSpawned` and let the pty buffer input until the
+/// child reads it.
+///
+/// This is pane state, not a Pardes-wide job table. A reused pane slot can
+/// therefore never inherit a command intended for the shell it replaced.
+pub const PendingCommand = struct {
+ bytes: []u8 = &.{},
+ wait: enum { none, spawn, input } = .none,
+};
+
+/// A terminal constructed by `newShell` cannot safely receive a command until
+/// the native host has at least completed forkpty. Dump-replay terminals do
+/// not call this: they are dead grids, not half-spawned children.
+pub fn armShellSpawn(pane: *Pane) void {
+ std.debug.assert(pane.pending_command.bytes.len == 0);
+ pane.pending_command.wait = .spawn;
+}
+
+/// Own `command` until this pane's new child can accept it. False means the
+/// gate is already open and the caller should emit in the ordinary way.
+/// Multiple gestures before the prompt appears retain their byte order in one
+/// flat allocation; each command gets exactly the CR execute normally emits.
+pub fn queuePendingCommand(pane: *Pane, command: []const u8) !bool {
+ if (pane.pending_command.wait == .none) return false;
+ const old_len = pane.pending_command.bytes.len;
+ const new_len = try std.math.add(usize, old_len, try std.math.add(usize, command.len, 1));
+ const bytes = if (old_len == 0)
+ try pane.gpa.alloc(u8, new_len)
+ else
+ try pane.gpa.realloc(pane.pending_command.bytes, new_len);
+ @memcpy(bytes[old_len..][0..command.len], command);
+ bytes[new_len - 1] = '\r';
+ pane.pending_command.bytes = bytes;
+ // An explicit command replaces the automatic greeting. Otherwise both
+ // would be released by the same first prompt and `ls` would follow what
+ // the user actually asked to run.
+ pane.greet = false;
+ return true;
+}
+
+/// The host successfully forked `pane`. `prompt_marks` describes the argv it
+/// ACTUALLY used, not the configured shell name: a staged-rc failure or an
+/// unsupported family is an honest unmarked shell and must not wait forever
+/// for an OSC sequence it cannot produce.
+pub fn shellSpawned(p: *Pardes, id: usize, prompt_marks: bool) void {
+ const pane = p.panes[id] orelse return;
+ if (!pane.isTerminal() or pane.pending_command.wait != .spawn) return;
+ if (prompt_marks) {
+ pane.pending_command.wait = .input;
+ releasePendingCommand(p, id, pane, false);
+ } else {
+ // There is no semantic event on which an automatic greeting can be
+ // safely based. Explicit commands still release below (the pty owns
+ // their buffering); silently omit the cosmetic `ls` rather than race
+ // an unknown shell's startup and possibly type into its rc program.
+ pane.greet = false;
+ releasePendingCommand(p, id, pane, true);
+ }
+}
+
+/// Called from the ordinary sync after terminal output has been parsed. The
+/// semantic cursor is ghostty-vt's parsed OSC state, so this and the greeting
+/// share the exact same definition of "readline owns input".
+pub fn releasePendingCommandIfReady(p: *Pardes, id: usize, pane: *Pane) void {
+ if (pane.pending_command.wait == .input)
+ releasePendingCommand(p, id, pane, promptInputReady(pane));
+}
+
+fn releasePendingCommand(p: *Pardes, id: usize, pane: *Pane, ready: bool) void {
+ if (!ready) return;
+ const bytes = pane.pending_command.bytes;
+ pane.pending_command = .{};
+ if (bytes.len > 0) {
+ p.emitWrite(id, bytes);
+ pane.gpa.free(bytes);
+ }
+}
+
+pub fn deinitPendingCommand(pane: *Pane) void {
+ if (pane.pending_command.bytes.len > 0)
+ pane.gpa.free(pane.pending_command.bytes);
+ pane.pending_command = .{};
+}
+
+/// The emulator stores an Io value for optional kitty-image work. The browser
+/// has no host IO and must not instantiate std.Io.Threaded's POSIX backend
+/// merely to construct a replay-only terminal.
+pub fn terminalIo() std.Io {
+ return if (comptime pardes.platform == .web)
+ std.Io.failing
+ else
+ std.Io.Threaded.global_single_threaded.io();
+}
+
+/// Allocate the live emulator half of a terminal pane. Slot ownership, serial
+/// assignment, and spawn effects remain core lifecycle invariants.
+pub fn create(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane {
+ const pane = try gpa.create(Pane);
+ errdefer gpa.destroy(pane);
+ pane.* = .{
+ .vt = try ghostty_vt.Terminal.init(terminalIo(), gpa, .{
+ .cols = cols,
+ .rows = rows,
+ .max_scrollback = 16 * 1024 * 1024,
+ }),
+ .stream = undefined,
+ .gpa = gpa,
+ .cols = cols,
+ .rows = rows,
+ };
+ pane.stream = pane.vt.vtStream();
+ // Answer terminal queries (DSR/DA/kitty) back into the pty, else
+ // crossterm apps (nushell, helix, fzf) block on the reply forever.
+ pane.stream.handler.effects.write_pty = ptyReport;
+ pane.stream.handler.effects.device_attributes = ptyDeviceAttrs;
+ 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 {
+ const terminal = src.terminal.?;
+ const bytes = 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) {
+ ingest(pane, bytes);
+ pane.vt.screens.active.scroll(.active);
+ if (src.scroll > 0)
+ pane.vt.screens.active.scroll(.{ .delta_row = -@as(isize, @intCast(src.scroll)) });
+ }
+ return pane;
+}
+
+/// Feed the emulator and retain the bounded suffix a dump can replay. Live
+/// output and restoration share this byte path, then apply different views.
+fn ingest(pane: *Pane, bytes: []const u8) void {
+ if (bytes.len >= pane.tty_stream.len) {
+ const kept = bytes[bytes.len - pane.tty_stream.len ..];
+ @memcpy(pane.tty_stream[0..], kept);
+ pane.tty_stream_head = 0;
+ pane.tty_stream_len = pane.tty_stream.len;
+ } else {
+ const overflow = bytes.len -| (pane.tty_stream.len - pane.tty_stream_len);
+ pane.tty_stream_head = (pane.tty_stream_head + overflow) % pane.tty_stream.len;
+ pane.tty_stream_len -= overflow;
+ const tail = (pane.tty_stream_head + pane.tty_stream_len) % pane.tty_stream.len;
+ const first = @min(bytes.len, pane.tty_stream.len - tail);
+ @memcpy(pane.tty_stream[tail..][0..first], bytes[0..first]);
+ @memcpy(pane.tty_stream[0 .. bytes.len - first], bytes[first..]);
+ pane.tty_stream_len += bytes.len;
+ }
+ pane.stream.nextSlice(bytes);
+}
+
+/// Return the replay ring in chronological order. Wrapped records are copied
+/// into `allocator`; contiguous records remain a borrowed slice of the pane.
+fn replayBytes(pane: *const Pane, allocator: std.mem.Allocator) ![]const u8 {
+ if (pane.tty_stream_len == 0) return &.{};
+ if (pane.tty_stream_head + pane.tty_stream_len <= pane.tty_stream.len)
+ return pane.tty_stream[pane.tty_stream_head..][0..pane.tty_stream_len];
+ const out = try allocator.alloc(u8, pane.tty_stream_len);
+ const first = pane.tty_stream.len - pane.tty_stream_head;
+ @memcpy(out[0..first], pane.tty_stream[pane.tty_stream_head..]);
+ @memcpy(out[first..], pane.tty_stream[0 .. pane.tty_stream_len - first]);
+ return out;
+}
+
+/// 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 {
+ ingest(pane, bytes);
+ p.shell_rows.markStale(pane);
+ const body_mode = if (pane.tag_edit) pane.tag_mode else pane.mode;
+ if (body_mode == .tty) pane.vt.screens.active.scroll(.active);
+}
+
+/// True only after OSC 133 B ended the prompt and handed the cursor to shell
+/// input. `cursorIsAtPrompt` deliberately also accepts OSC A's `.prompt`
+/// phase; that is right for navigation but too early to inject a greeting —
+/// readline may not own echo yet and would leave `ls` on an unmarked row.
+pub fn promptInputReady(pane: *const Pane) bool {
+ return pane.vt.screens.active_key != .alternate and
+ pane.vt.screens.active.cursor.semantic_content == .input;
+}
+
+test "fresh-shell greeting waits for OSC 133 B input phase" {
+ if (pardes.platform == .web) return;
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ while (p.nextEffect()) |_| {} // initial spawn
+
+ p.update(.{ .resize = .{ .cols = 80, .rows = 24 } });
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => return error.GreetedBeforeOutput,
+ else => {},
+ };
+ p.update(.{ .output = .{ .pane = 0, .bytes = "startup banner\r\n" } });
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => return error.GreetedBeforePrompt,
+ else => {},
+ };
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x07prompt$ " } });
+ try std.testing.expect(!promptInputReady(pane));
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => return error.GreetedDuringPrompt,
+ else => {},
+ };
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;B\x07" } });
+ try std.testing.expect(promptInputReady(pane));
+ var greeted = false;
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => |write| greeted = greeted or std.mem.eql(u8, write.bytes.slice(), "ls\r"),
+ else => {},
+ };
+ try std.testing.expect(greeted);
+ try std.testing.expect(!pane.greet);
+}
+
+test "fresh-shell commands preserve order and wait for OSC 133 B" {
+ if (pardes.platform == .web) return;
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {} // the host has not acknowledged spawn yet
+
+ try std.testing.expectEqual(@as(?usize, 0), p.execute(0, "echo first"));
+ try std.testing.expectEqual(@as(?usize, 0), p.execute(0, "echo second"));
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => return error.CommandEscapedBeforeFork,
+ else => {},
+ };
+
+ p.acknowledgeShell(0, "/bin/bash", true);
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => return error.CommandEscapedBeforePrompt,
+ else => {},
+ };
+ p.update(.{ .output = .{ .pane = 0, .bytes = "startup\r\n\x1b]133;A\x07prompt$ " } });
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => return error.CommandEscapedDuringPrompt,
+ else => {},
+ };
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;B\x07" } });
+ var sent: [64]u8 = undefined;
+ var sent_len: usize = 0;
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => |write| {
+ const bytes = write.bytes.slice();
+ @memcpy(sent[sent_len..][0..bytes.len], bytes);
+ sent_len += bytes.len;
+ },
+ else => {},
+ };
+ try std.testing.expectEqualStrings("echo first\recho second\r", sent[0..sent_len]);
+ try std.testing.expectEqual(.none, p.panes[0].?.pending_command.wait);
+}
+
+test "unmarked fresh shells omit the automatic greeting" {
+ if (pardes.platform == .web) return;
+ const p = try Pardes.init(std.testing.allocator, .{});
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ while (p.nextEffect()) |_| {}
+ try std.testing.expect(pane.greet);
+
+ p.acknowledgeShell(0, "/bin/sh", false);
+ try std.testing.expect(!pane.greet);
+ try std.testing.expectEqual(.none, pane.pending_command.wait);
+ p.update(.{ .output = .{ .pane = 0, .bytes = "plain prompt$ " } });
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => return error.UnmarkedGreetingEscaped,
+ else => {},
+ };
+}
+
+/// Encode one key for the program that owns a raw terminal and queue its pty
+/// write. Global chords and mode routing have already been handled by core.
+pub fn forwardKey(p: *Pardes, id: usize, key: Key) void {
+ var control: [1]u8 = undefined;
+ const bytes: ?[]const u8 = blk: {
+ if (key.ctrl) {
+ if (key.cp >= 'a' and key.cp <= 'z') {
+ control[0] = @intCast(key.cp - 0x60);
+ break :blk control[0..1];
+ }
+ // ASCII @, A-Z, [, \, ], ^ and _ are one contiguous control range.
+ if (key.cp >= '@' and key.cp <= '_') {
+ control[0] = @intCast(key.cp - 0x40);
+ break :blk control[0..1];
+ }
+ }
+ if (key.text.len > 0) break :blk key.text;
+ break :blk switch (key.cp) {
+ Key.enter => "\r",
+ Key.backspace => "\x7f",
+ Key.tab => "\t",
+ Key.escape => "\x1b",
+ Key.up => "\x1b[A",
+ Key.down => "\x1b[B",
+ Key.right => "\x1b[C",
+ Key.left => "\x1b[D",
+ Key.delete => "\x1b[3~",
+ else => null,
+ };
+ };
+ if (bytes) |encoded|
+ p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(encoded) } });
+}
+
+/// Enter raw tty, handing a pinned modal cursor back to the shell prompt when
+/// OSC 133 marks one. The visible prompt row is left-hugged outside tty mode,
+/// so translate its column through the hidden prompt before asking ghostty for
+/// the arrow-key movement the child understands.
+pub fn enterTty(p: *Pardes, id: usize) void {
+ const pane = p.panes[id] orelse return;
+ const screen = pane.vt.screens.active;
+ if (pane.cur_pinned and pane.vt.cursorIsAtPrompt()) handoff: {
+ const goff: i32 = @intCast(screen.pages.scrollbar().offset);
+ const vp_row = pane.gridRow(pane.cur_row) - goff;
+ if (vp_row < 0) break :handoff;
+
+ var grid_col: i32 = @max(0, pane.cur_col);
+ if (screen.pages.pin(.{ .viewport = .{ .x = 0, .y = @intCast(vp_row) } })) |row_pin| {
+ if (row_pin.rowAndCell().row.semantic_prompt != .none) switch (promptCut(row_pin)) {
+ .cut => |cols| grid_col += @intCast(cols),
+ .keep, .blank => {},
+ };
+ }
+ const click_pin = screen.pages.pin(.{
+ .viewport = .{ .x = @intCast(grid_col), .y = @intCast(vp_row) },
+ }) orelse break :handoff;
+ const cursor_pin = screen.cursor.page_pin.*;
+ var prompts = cursor_pin.promptIterator(.left_up, null);
+ const prompt_pin = prompts.next() orelse break :handoff;
+ if (click_pin.before(prompt_pin)) break :handoff;
+ const moves = screen.promptClickMove(click_pin);
+ for (0..moves.left) |_| p.emitWrite(id, "\x1b[D");
+ for (0..moves.right) |_| p.emitWrite(id, "\x1b[C");
+ }
+
+ pane.mode = .tty;
+ pane.msel.active = false;
+ pane.vsel.active = false;
+ pane.nsel = 0;
+ // A pinned row scrolls away. Raw mode must follow the program's live
+ // cursor, and Last must not restore a stale modal spot on the way back.
+ pane.cur_pinned = false;
+ pane.select = false;
+ pane.append_at = null;
+ pane.sticky_col = -1;
+ pane.pending = 0;
+}
+
+test "raw terminal keys encode text controls and special sequences" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+
+ const Case = struct { key: Key, expected: ?[]const u8 };
+ const cases = [_]Case{
+ .{ .key = .{ .cp = 'é', .text = "é" }, .expected = "é" },
+ .{ .key = .{ .cp = 'c', .text = "c", .ctrl = true }, .expected = "\x03" },
+ .{ .key = .{ .cp = 'C', .text = "C", .ctrl = true }, .expected = "\x03" },
+ .{ .key = .{ .cp = '@', .text = "@", .ctrl = true }, .expected = "\x00" },
+ .{ .key = .{ .cp = '_', .text = "_", .ctrl = true }, .expected = "\x1f" },
+ .{ .key = .{ .cp = '1', .text = "1", .ctrl = true }, .expected = "1" },
+ .{ .key = .{ .cp = Key.up, .alt = true, .shift = true }, .expected = "\x1b[A" },
+ .{ .key = .{ .cp = Key.delete }, .expected = "\x1b[3~" },
+ .{ .key = .{ .cp = Key.home }, .expected = null },
+ };
+ for (cases) |case| {
+ forwardKey(p, 0, case.key);
+ const expected = case.expected orelse {
+ try std.testing.expect(p.nextEffect() == null);
+ continue;
+ };
+ const effect = p.nextEffect() orelse return error.MissingWriteEffect;
+ switch (effect) {
+ .write => |write| {
+ try std.testing.expectEqual(@as(u8, 0), write.pane);
+ try std.testing.expectEqualStrings(expected, write.bytes.slice());
+ },
+ else => return error.UnexpectedEffect,
+ }
+ try std.testing.expect(p.nextEffect() == null);
+ }
+}
/// The memo behind `shellRows`. ONE entry for the editor, because the motion
/// surface is built for the pane the cursor is in and a second pane asking
@@ -81,7 +497,7 @@ const Rows = struct {
/// What LEAVING raw tty mode does to one prompt row, decided from its cells
/// alone. See config.tty_blank for why any of this happens.
-pub const PromptCut = union(enum) {
+const PromptCut = union(enum) {
/// show the row exactly as ghostty dumped it
keep,
/// show nothing at all
@@ -96,7 +512,7 @@ pub const PromptCut = union(enum) {
/// prompt). The row flag every caller tests first is only ghostty's "some cell
/// in here is a prompt cell" index; taking the row on that flag alone is what
/// used to throw the command away with the prompt.
-pub fn promptCut(pin: ghostty_vt.Pin) PromptCut {
+fn promptCut(pin: ghostty_vt.Pin) PromptCut {
if (config.tty_blank == .prompt_and_input) return .blank;
const cells = pin.cells(.all);
var cols: usize = 0;
@@ -118,7 +534,7 @@ pub fn promptCut(pin: ghostty_vt.Pin) PromptCut {
/// the single authority on how a cell spells itself — wide glyphs, combining
/// marks and all. One non-spacer cell is one dumped grapheme, and that is what
/// makes the cell walk and the byte walk stay in step.
-pub fn promptRow(pin: ghostty_vt.Pin, raw: []const u8) []const u8 {
+fn promptRow(pin: ghostty_vt.Pin, raw: []const u8) []const u8 {
const cols = switch (promptCut(pin)) {
.keep => return raw,
.blank => return "",
@@ -174,6 +590,188 @@ pub fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 {
return c.rows;
}
+/// The full modal motion surface: shell history with the live edit overlay
+/// spliced into the rows it covers.
+pub fn cursorLines(p: *Pardes, pane: *Pane) ![]const []const u8 {
+ const rows = try shellRows(p, pane);
+ if (pane.ovl == null) return rows;
+ var last = rows.len;
+ if (pane.ovl) |overlay|
+ last = @max(last, @as(usize, @intCast(@max(0, overlay.row + overlay.rows))));
+ var count = last;
+ if (pane.ovl) |overlay| {
+ if (overlay.row >= 0 and @as(usize, @intCast(overlay.row)) < last)
+ count = count - @min(
+ @as(usize, @intCast(overlay.rows)),
+ last - @as(usize, @intCast(overlay.row)),
+ ) + @max(1, modal.lineCount(overlay.text));
+ }
+ const lines = try p.scratch.allocator().alloc([]const u8, count);
+ var n: usize = 0;
+ var grid_row: usize = 0;
+ while (grid_row < last) : (grid_row += 1) {
+ if (pane.ovl) |overlay| if (overlay.row >= 0 and grid_row == @as(usize, @intCast(overlay.row))) {
+ var overlay_lines = std.mem.splitScalar(u8, overlay.text, '\n');
+ while (overlay_lines.next()) |line| : (n += 1) lines[n] = line;
+ grid_row += @intCast(overlay.rows - 1);
+ continue;
+ };
+ lines[n] = if (grid_row < rows.len) rows[grid_row] else "";
+ n += 1;
+ }
+ return lines[0..n];
+}
+
+/// Flatten `cursorLines` without rebuilding the common cached/no-overlay
+/// case. Scratch-owned when a join is required.
+pub fn flatSurface(p: *Pardes, pane: *Pane, lines: []const []const u8) ![]const u8 {
+ const cache = &p.shell_rows;
+ if (!cache.stale and cache.pane == pane and
+ lines.ptr == cache.rows.ptr and lines.len == cache.rows.len) return cache.text;
+ var total: usize = if (lines.len > 0) lines.len - 1 else 0;
+ for (lines) |line| total += line.len;
+ const text = try p.scratch.allocator().alloc(u8, total);
+ var at: usize = 0;
+ for (lines, 0..) |line, i| {
+ if (i > 0) {
+ text[at] = '\n';
+ at += 1;
+ }
+ @memcpy(text[at..][0..line.len], line);
+ at += line.len;
+ }
+ return text;
+}
+
+/// Materialize or extend the terminal edit overlay with one exact allocation.
+/// Row slices are scratch-owned/borrowed; only the joined text is installed.
+pub fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText {
+ const want_lo = @max(0, @min(lo, hi));
+ const want_hi = @max(want_lo, @max(lo, hi));
+ const fresh = pane.ovl == null;
+ const old: EditBuffer = pane.ovl orelse .{ .row = want_lo, .rows = 1, .text = &.{} };
+ const lines: i32 = if (fresh) 1 else @intCast(modal.lineCount(old.text));
+ const up = old.row - want_lo;
+ const down = want_hi - (old.row + lines - 1);
+ const extending = fresh or up > 0 or down > 0;
+ var row0 = old.row;
+ var covered = old.rows;
+ var text = old.text;
+ var owned = false;
+ if (extending) {
+ const rows = shellRows(p, pane) catch return null;
+ row0 = old.row - @max(0, up);
+ covered = old.rows + @max(0, up) + @max(0, down);
+ const up_len: usize = @intCast(@max(0, up));
+ const down_len: usize = @intCast(@max(0, down));
+ const parts = p.scratch.allocator().alloc([]const u8, up_len + 1 + down_len) catch return null;
+ for (parts[0..up_len], 0..) |*part, i| {
+ const src = @as(usize, @intCast(row0)) + i;
+ part.* = if (src < rows.len) rows[src] else "";
+ }
+ const middle: usize = @intCast(old.row);
+ parts[up_len] = if (fresh)
+ (if (middle < rows.len) rows[middle] else "")
+ else
+ old.text;
+ for (parts[up_len + 1 ..], 0..) |*part, i| {
+ const src = @as(usize, @intCast(old.row + old.rows)) + i;
+ part.* = if (src < rows.len) rows[src] else "";
+ }
+ text = std.mem.join(p.gpa, "\n", parts) catch return null;
+ owned = true;
+ }
+
+ const row: usize = @intCast(@max(0, want_lo - row0));
+ const line_len: i32 = @intCast(modal.lineSlice(text, row).len);
+ if (col > line_len) {
+ const spaces = p.scratch.allocator().alloc(u8, @intCast(col - line_len)) catch {
+ if (owned) p.gpa.free(text);
+ return null;
+ };
+ @memset(spaces, ' ');
+ const padded = modal.insertAt(p.gpa, text, .{ .row = row, .col = @intCast(line_len) }, spaces) catch {
+ if (owned) p.gpa.free(text);
+ return null;
+ };
+ if (owned) p.gpa.free(text);
+ text = padded;
+ owned = true;
+ }
+ if (owned) {
+ if (pane.ovl) |overlay| p.gpa.free(overlay.text);
+ pane.ovl = .{ .row = row0, .rows = covered, .text = text };
+ }
+ return .{ .text = pane.ovl.?.text, .row0 = pane.ovl.?.row };
+}
+
+/// Consume a rewritten overlay, freeing the terminal edit text it replaces.
+pub fn setEditText(p: *Pardes, pane: *Pane, new: []u8) void {
+ const overlay = if (pane.ovl) |*value| value else return p.gpa.free(new);
+ p.gpa.free(overlay.text);
+ overlay.text = new;
+}
+
+/// Serialize terminal-only state; the core supplies shared pane metadata.
+pub fn dumpPane(
+ pane: *Pane,
+ arena: std.mem.Allocator,
+ tag: []const u8,
+ body: []const u8,
+ scroll: usize,
+) !dump.Pane {
+ const full = try pane.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} });
+ const extra = if (pane.ovl) |overlay| overlay.text.len else 0;
+ const stream = try arena.alloc(u8, try std.math.add(usize, full.len, extra));
+ var len: usize = 0;
+ var lines = std.mem.splitAny(u8, full, "\n");
+ var prompts = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null);
+ var row: i32 = 0;
+ var skip: i32 = 0;
+ while (lines.next()) |raw| : (row += 1) {
+ // Hidden overlay rows still consume prompt pins to keep them aligned.
+ const prompt = if (pane.mode != .tty) prompts.next() else null;
+ if (skip > 0) {
+ skip -= 1;
+ continue;
+ }
+ if (row > 0) {
+ stream[len] = '\n';
+ len += 1;
+ }
+ if (pane.mode != .tty) if (pane.ovl) |overlay| if (row == overlay.row) {
+ @memcpy(stream[len..][0..overlay.text.len], overlay.text);
+ len += overlay.text.len;
+ skip = overlay.rows - 1;
+ continue;
+ };
+ const shown = if (prompt) |pin|
+ if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, raw) else raw
+ else
+ raw;
+ @memcpy(stream[len..][0..shown.len], shown);
+ len += shown.len;
+ }
+ return .{
+ .kind = .terminal,
+ .tag = tag,
+ .body = body,
+ .scroll = scroll,
+ .cols = pane.cols,
+ .rows = pane.rows,
+ .vweight = pane.vweight,
+ .terminal = .{
+ .cwd = try arena.dupe(u8, pane.cwdSlice()),
+ .stream = stream[0..len],
+ .stream_b64 = try dump.encodeBytes(arena, try replayBytes(pane, arena)),
+ .cursor = .{
+ .col = pane.vt.screens.active.cursor.x,
+ .row = pane.vt.screens.active.cursor.y,
+ },
+ },
+ };
+}
+
fn buildRows(alloc: std.mem.Allocator, p: *Pardes, pane: *Pane) !Rows {
const full = try pane.vt.screens.active.dumpStringAlloc(p.scratch.allocator(), .{ .screen = .{} });
var pit = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null);
@@ -292,7 +890,7 @@ fn fillBody(dst: ?[]u8, pane: *Pane, viewport: []const []const u8) usize {
pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, off: i32) void {
const s = &p.surface;
const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset);
- const body_y = if (p.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
var vr: u16 = 0;
while (vr < body_h and vr < pane.rows) : (vr += 1) {
const grid = pane.gridRow(off + @as(i32, vr)) - goff;
@@ -377,15 +975,15 @@ pub fn padOutputBelowEdits(p: *Pardes, id: usize) void {
}
/// the snapshot takes ownership of a COPY of the edit buffer's text
-pub fn snap(p: *Pardes, pane: *Pane) ?EditSnap {
- var ovl: ?Ovl = null;
+pub fn snap(p: *Pardes, pane: *Pane) ?Snapshot {
+ var ovl: ?EditBuffer = null;
if (pane.ovl) |o| ovl = .{ .row = o.row, .rows = o.rows, .text = p.gpa.dupe(u8, o.text) catch return null };
return .{ .ovl = ovl, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .vsel = pane.vsel };
}
/// undo/redo restores the selection recorded with the snapshot (helix
/// keeps selections in its history transactions)
-pub fn restoreSnap(p: *Pardes, pane: *Pane, s: EditSnap) void {
+pub fn restoreSnap(p: *Pardes, pane: *Pane, s: Snapshot) void {
if (pane.ovl) |o| p.gpa.free(o.text);
pane.ovl = s.ovl;
pane.cur_row = s.cur_row;
@@ -396,12 +994,52 @@ pub fn restoreSnap(p: *Pardes, pane: *Pane, s: EditSnap) void {
pane.ensureCursorVisible();
}
+fn pushHistory(gpa: std.mem.Allocator, slots: []Snapshot, len: *usize, value: Snapshot) void {
+ if (len.* == slots.len) {
+ if (slots[0].ovl) |overlay| gpa.free(overlay.text);
+ std.mem.copyForwards(Snapshot, slots[0 .. slots.len - 1], slots[1..]);
+ len.* -= 1;
+ }
+ slots[len.*] = value;
+ len.* += 1;
+}
+
+pub fn pushUndo(p: *Pardes, pane: *Pane) void {
+ const current = pane.ovl orelse EditBuffer{ .rows = 0 };
+ if (pane.ed_undo_len > 0) {
+ const top = pane.ed_undo[pane.ed_undo_len - 1];
+ const same = if (top.ovl) |overlay| pane.ovl != null and overlay.row == current.row and
+ overlay.rows == current.rows and std.mem.eql(u8, overlay.text, current.text) else pane.ovl == null;
+ if (same) return;
+ }
+ const value = snap(p, pane) orelse return;
+ pushHistory(p.gpa, &pane.ed_undo, &pane.ed_undo_len, value);
+ for (pane.ed_redo[0..pane.ed_redo_len]) |item| if (item.ovl) |overlay| p.gpa.free(overlay.text);
+ pane.ed_redo_len = 0;
+}
+
+pub fn undo(p: *Pardes, pane: *Pane) void {
+ if (pane.ed_undo_len == 0) return;
+ const current = snap(p, pane) orelse return;
+ pushHistory(p.gpa, &pane.ed_redo, &pane.ed_redo_len, current);
+ pane.ed_undo_len -= 1;
+ restoreSnap(p, pane, pane.ed_undo[pane.ed_undo_len]);
+}
+
+pub fn redo(p: *Pardes, pane: *Pane) void {
+ if (pane.ed_redo_len == 0) return;
+ const current = snap(p, pane) orelse return;
+ pushHistory(p.gpa, &pane.ed_undo, &pane.ed_undo_len, current);
+ pane.ed_redo_len -= 1;
+ restoreSnap(p, pane, pane.ed_redo[pane.ed_redo_len]);
+}
+
// ghostty calls this with a reply (cursor-position report, DA, ...) to send
// back to the child as if it typed it. The handler's `terminal` is our Pane.vt
// field; recover the Pane and stash the bytes — sync() drains them into write
// effects (the callback has no path to the effect queue).
pub fn ptyReport(handler: *ghostty_vt.TerminalStream.Handler, data: [:0]const u8) void {
- const pane: *Pane = @fieldParentPtr("vt", handler.terminal);
+ const pane: *Pane = @alignCast(@fieldParentPtr("vt", handler.terminal));
const room = pane.reply.len - pane.reply_len;
const n = @min(room, data.len);
@memcpy(pane.reply[pane.reply_len..][0..n], data[0..n]);
diff --git a/src/tty/panel_compositor.zig b/src/tty/panel_compositor.zig
new file mode 100644
index 00000000..647c36ff
--- /dev/null
+++ b/src/tty/panel_compositor.zig
@@ -0,0 +1,730 @@
+//! TTY implementation of panel transitions.
+//!
+//! The core publishes final cells plus POD tracks. This backend copies that
+//! grid and performs the transition in cell space; no terminal escape trick
+//! or pixel assumption leaks into the core.
+
+const std = @import("std");
+const pardes = @import("../pardes.zig");
+const panel_animation = @import("../panel_animation.zig");
+
+const Box = panel_animation.Box;
+const Track = panel_animation.Track;
+
+/// Kitty placements cannot be resampled through the character-grid transform.
+/// Keep their transmitted pixels cached, but omit the placement while its pane
+/// geometry moves. Data effects animate changed grid cells in place and have
+/// no honest old Kitty bitmap, so leaving an unchanged attachment canonical is
+/// better than making it disappear for the whole reveal and pop back.
+pub fn hidesAttachment(tracks: []const Track, serial: u32) bool {
+ for (tracks) |track| {
+ if (track.serial != serial or !track.active()) continue;
+ if (track.effect != .slide and track.effect != .zoom and track.effect != .vertical) continue;
+ // The last active sample is already the exact canonical frame. Place
+ // the image there too, rather than making it pop in one tick later.
+ if (track.frame + 1 < track.effect.frames()) return true;
+ }
+ return false;
+}
+
+/// Return `source` unchanged when idle, otherwise an arena-owned Surface whose
+/// cells contain this frame's TTY presentation. Geometry exposed by a moving
+/// panel uses the theme's page background when it has one; null deliberately
+/// leaves the host terminal's default background showing.
+pub fn compose(
+ arena: std.mem.Allocator,
+ source: *pardes.Surface,
+ tracks: []const Track,
+ theme_bg: ?[3]u8,
+) !*pardes.Surface {
+ var active = false;
+ var noncanonical = false;
+ for (tracks) |track| {
+ if (!drawable(source, track)) continue;
+ active = true;
+ noncanonical = noncanonical or track.amount() < 1.0;
+ }
+ if (!active) return source;
+ // Every effect's last active sample is its exact canonical endpoint. Keep
+ // the real cursor and attachments in that sample instead of suppressing
+ // them for one extra frame that is otherwise pixel-identical.
+ if (!noncanonical) return source;
+
+ const out = try arena.create(pardes.Surface);
+ out.* = source.*;
+ out.cells = try arena.dupe(pardes.Cell, source.cells);
+ // A cursor has no pane/phase record of its own. All native backends hide
+ // it for the short transition rather than disagreeing about whether it
+ // should move, stay static, or cover an overlapping opening pane.
+ out.cursor = null;
+
+ // Clear every final geometry before drawing any presented geometry. If
+ // clear and draw are interleaved, a new panel can erase the old full-width
+ // panel that was deliberately drawn underneath it at frame zero.
+ for (tracks) |track|
+ if (drawable(source, track) and (track.effect == .slide or track.effect == .zoom))
+ clearBox(out, track.to, theme_bg);
+
+ // A vertical opener starts over the exact old pixels in its fixed target
+ // clip. Its new content then rises into that clip from below. Restore all
+ // bases before painting anything so an opener cannot erase an earlier
+ // moving panel merely because its pane slot sorts later.
+ for (tracks) |track|
+ if (drawable(source, track) and track.effect == .vertical and track.phase == .opening)
+ restorePrevious(out, source, track.contentBox());
+
+ // Stable layout motion first, newly opening panels above it, and closing
+ // tombstones last. A closing pane no longer owns input or canonical cells,
+ // but its frozen old content remains the top visual until it slides out.
+ for ([_]panel_animation.Phase{ .moving, .opening, .closing }) |phase| {
+ for (tracks) |track| {
+ if (!drawable(source, track) or track.phase != phase) continue;
+ switch (track.effect) {
+ .off => {},
+ .slide, .zoom => transform(out, source, track),
+ .dissolve => dissolve(out, source, track),
+ .ascii => ascii(out, source, track),
+ .vertical => vertical(out, source, track),
+ }
+ }
+ }
+ return out;
+}
+
+/// Content effects have no honest frame without the frozen old grid. In
+/// production the core drops such tracks before publishing Surface; retain
+/// this guard at the backend boundary so a partial/custom caller also snaps
+/// to canonical content instead of manufacturing blanks.
+fn drawable(source: *const pardes.Surface, track: Track) bool {
+ return track.active() and
+ (!track.effect.needsPreviousGrid() or source.hasPanelDiff());
+}
+
+fn transform(out: *pardes.Surface, source: *const pardes.Surface, track: Track) void {
+ const shown = track.presented();
+ if (shown.w <= 0 or shown.h <= 0) return;
+ const dst = clipped(shown, out.cols, out.rows) orelse return;
+ var y = dst.y0;
+ while (y < dst.y1) : (y += 1) {
+ var x = dst.x0;
+ while (x < dst.x1) : (x += 1) {
+ // The core hit-tests cell CENTRES against this same half-open box.
+ // floor/ceil clipping includes boundary cells for iteration, but
+ // they are not visible unless their centre is actually inside.
+ // Test that containment before division: at large u16 grids f32
+ // can round an inside right-edge ratio to exactly 1.0.
+ const cx = @as(f32, @floatFromInt(x)) + 0.5;
+ const cy = @as(f32, @floatFromInt(y)) + 0.5;
+ if (cx < shown.x or cx >= shown.x + shown.w or
+ cy < shown.y or cy >= shown.y + shown.h) continue;
+ const u = std.math.clamp((cx - shown.x) / shown.w, 0, 0.999_999);
+ const v = std.math.clamp((cy - shown.y) / shown.h, 0, 0.999_999);
+ const sx: i32 = @intFromFloat(@floor(track.to.x + u * track.to.w));
+ const sy: i32 = @intFromFloat(@floor(track.to.y + v * track.to.h));
+ if (sx < 0 or sy < 0 or sx >= source.cols or sy >= source.rows) continue;
+ out.at(x, y).* = source.cells[@as(usize, @intCast(sy)) * source.cols + @as(usize, @intCast(sx))];
+ }
+ }
+}
+
+fn dissolve(out: *pardes.Surface, source: *const pardes.Surface, track: Track) void {
+ const area = clipped(track.to, out.cols, out.rows) orelse return;
+ const amount = track.amount();
+ var y = area.y0;
+ while (y < area.y1) : (y += 1) {
+ var x = area.x0;
+ while (x < area.x1) : (x += 1) {
+ if (!source.panelCellChanged(x, y)) continue;
+ if (panel_animation.dissolveRevealed(
+ track.serial,
+ x - area.x0,
+ y - area.y0,
+ amount,
+ )) continue;
+ out.at(x, y).* = previousCell(source, x, y).*;
+ }
+ }
+}
+
+fn ascii(out: *pardes.Surface, source: *const pardes.Surface, track: Track) void {
+ const area = clipped(track.to, out.cols, out.rows) orelse return;
+ const amount = track.amount();
+ var y = area.y0;
+ while (y < area.y1) : (y += 1) {
+ var x = area.x0;
+ while (x < area.x1) : (x += 1) {
+ if (!source.panelCellChanged(x, y)) continue;
+ const material = panel_animation.asciiCell(track.serial, x - area.x0, y - area.y0, amount);
+ if (material.reveal) continue;
+ const cell = out.at(x, y);
+ if (material.glyph == ' ') {
+ cell.* = previousCell(source, x, y).*;
+ continue;
+ }
+ // The wave itself wears the new cell's colors, then yields to its
+ // real glyph. Before the wave arrives the complete old cell—not a
+ // synthetic blank—remains visible above.
+ cell.* = source.cells[@as(usize, y) * source.cols + x];
+ cell.text[0] = material.glyph;
+ cell.len = 1;
+ cell.default = false;
+ cell.style.dim = true;
+ }
+ }
+}
+
+fn previousCell(source: *const pardes.Surface, x: u16, y: u16) *const pardes.Cell {
+ std.debug.assert(source.hasPanelDiff());
+ return &source.previous_cells[@as(usize, y) * source.cols + x];
+}
+
+fn restorePrevious(out: *pardes.Surface, source: *const pardes.Surface, box: Box) void {
+ const area = clipped(box, out.cols, out.rows) orelse return;
+ var y = area.y0;
+ while (y < area.y1) : (y += 1) {
+ var x = area.x0;
+ while (x < area.x1) : (x += 1)
+ out.at(x, y).* = previousCell(source, x, y).*;
+ }
+}
+
+fn vertical(out: *pardes.Surface, source: *const pardes.Surface, track: Track) void {
+ const cells = if (track.phase == .closing) source.previous_cells else source.cells;
+ transformClipped(out, source.cols, source.rows, cells, track, track.contentBox());
+}
+
+/// Translate one old/new canonical panel through its fixed lifecycle clip.
+/// Sampling is always relative to contentBox: an opener reads its new target,
+/// while a closer reads the old box retained after that pane was destroyed.
+fn transformClipped(
+ out: *pardes.Surface,
+ source_cols: u16,
+ source_rows: u16,
+ cells: []const pardes.Cell,
+ track: Track,
+ clip: Box,
+) void {
+ const shown = track.presented();
+ if (shown.w <= 0 or shown.h <= 0) return;
+ const dst = clipped(shown, out.cols, out.rows) orelse return;
+ const content = track.contentBox();
+ var y = dst.y0;
+ while (y < dst.y1) : (y += 1) {
+ var x = dst.x0;
+ while (x < dst.x1) : (x += 1) {
+ const cx = @as(f32, @floatFromInt(x)) + 0.5;
+ const cy = @as(f32, @floatFromInt(y)) + 0.5;
+ if (cx < shown.x or cx >= shown.x + shown.w or
+ cy < shown.y or cy >= shown.y + shown.h or
+ cx < clip.x or cx >= clip.x + clip.w or
+ cy < clip.y or cy >= clip.y + clip.h) continue;
+ const u = std.math.clamp((cx - shown.x) / shown.w, 0, 0.999_999);
+ const v = std.math.clamp((cy - shown.y) / shown.h, 0, 0.999_999);
+ const sx: i32 = @intFromFloat(@floor(content.x + u * content.w));
+ const sy: i32 = @intFromFloat(@floor(content.y + v * content.h));
+ if (sx < 0 or sy < 0 or sx >= source_cols or sy >= source_rows) continue;
+ out.at(x, y).* = cells[@as(usize, @intCast(sy)) * source_cols + @as(usize, @intCast(sx))];
+ }
+ }
+}
+
+fn clearBox(surface: *pardes.Surface, box: Box, theme_bg: ?[3]u8) void {
+ const area = clipped(box, surface.cols, surface.rows) orelse return;
+ var blank: pardes.Cell = .{};
+ if (theme_bg) |rgb| {
+ blank.default = false;
+ blank.style.bg = .{ .rgb = rgb };
+ }
+ var y = area.y0;
+ while (y < area.y1) : (y += 1) {
+ var x = area.x0;
+ while (x < area.x1) : (x += 1) surface.at(x, y).* = blank;
+ }
+}
+
+const GridBox = struct { x0: u16, y0: u16, x1: u16, y1: u16 };
+
+fn clipped(box: Box, cols: u16, rows: u16) ?GridBox {
+ const x0: i32 = @intFromFloat(@floor(box.x));
+ const y0: i32 = @intFromFloat(@floor(box.y));
+ const x1: i32 = @intFromFloat(@ceil(box.x + box.w));
+ const y1: i32 = @intFromFloat(@ceil(box.y + box.h));
+ const area: GridBox = .{
+ .x0 = @intCast(std.math.clamp(x0, 0, @as(i32, cols))),
+ .y0 = @intCast(std.math.clamp(y0, 0, @as(i32, rows))),
+ .x1 = @intCast(std.math.clamp(x1, 0, @as(i32, cols))),
+ .y1 = @intCast(std.math.clamp(y1, 0, @as(i32, rows))),
+ };
+ return if (area.x0 < area.x1 and area.y0 < area.y1) area else null;
+}
+
+test "ASCII materialization happens in copied TTY cells" {
+ var cells: [8]pardes.Cell = @splat(.{});
+ var previous: [8]pardes.Cell = @splat(.{});
+ const changed: [8]u8 = @splat(1);
+ for (&cells) |*cell| {
+ cell.text[0] = 'X';
+ cell.len = 1;
+ cell.default = false;
+ }
+ for (&previous) |*cell| {
+ cell.text[0] = 'O';
+ cell.len = 1;
+ cell.default = false;
+ }
+ var surface: pardes.Surface = .{
+ .cols = 4,
+ .rows = 2,
+ .cells = &cells,
+ .previous_cells = &previous,
+ .changed_cells = &changed,
+ };
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+ const track: Track = .{
+ .serial = 9,
+ .effect = .ascii,
+ .frame = 0,
+ .from = .{ .w = 4, .h = 2 },
+ .to = .{ .w = 4, .h = 2 },
+ };
+ const animated = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expect(animated != &surface);
+ try std.testing.expectEqualStrings("X", surface.at(0, 0).grapheme());
+ var differs = false;
+ for (animated.cells) |cell| differs = differs or !std.mem.eql(u8, cell.grapheme(), "X");
+ try std.testing.expect(differs);
+}
+
+test "dissolve changes only diff cells from old to new" {
+ var cells: [2]pardes.Cell = @splat(.{});
+ var previous: [2]pardes.Cell = @splat(.{});
+ for (&cells, "NU") |*cell, glyph| {
+ cell.text[0] = glyph;
+ cell.len = 1;
+ cell.default = false;
+ }
+ for (&previous, "OU") |*cell, glyph| {
+ cell.text[0] = glyph;
+ cell.len = 1;
+ cell.default = false;
+ }
+ const changed = [_]u8{ 1, 0 };
+ var surface: pardes.Surface = .{
+ .cols = 2,
+ .rows = 1,
+ .cells = &cells,
+ .previous_cells = &previous,
+ .changed_cells = &changed,
+ };
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+ var track: Track = .{
+ .serial = 1,
+ .effect = .dissolve,
+ .from = .{ .w = 2, .h = 1 },
+ .to = .{ .w = 2, .h = 1 },
+ };
+
+ const first = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expectEqualStrings("O", first.at(0, 0).grapheme());
+ try std.testing.expectEqual(cells[1], first.at(1, 0).*);
+
+ _ = arena.reset(.retain_capacity);
+ track.frame = track.effect.frames() - 1;
+ const final = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expectEqual(cells[0], final.at(0, 0).*);
+ try std.testing.expectEqual(cells[1], final.at(1, 0).*);
+}
+
+test "ASCII diff travels old through punctuation to new" {
+ var cells = [_]pardes.Cell{.{}};
+ var previous = [_]pardes.Cell{.{}};
+ cells[0].text[0] = 'N';
+ cells[0].default = false;
+ previous[0].text[0] = 'O';
+ previous[0].default = false;
+ const changed = [_]u8{1};
+ var surface: pardes.Surface = .{
+ .cols = 1,
+ .rows = 1,
+ .cells = &cells,
+ .previous_cells = &previous,
+ .changed_cells = &changed,
+ };
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+ var track: Track = .{
+ .serial = 9,
+ .effect = .ascii,
+ .from = .{ .w = 1, .h = 1 },
+ .to = .{ .w = 1, .h = 1 },
+ };
+
+ const old_frame = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expectEqualStrings("O", old_frame.at(0, 0).grapheme());
+
+ _ = arena.reset(.retain_capacity);
+ track.frame = 7;
+ const wave = panel_animation.asciiCell(track.serial, 0, 0, track.amount());
+ try std.testing.expect(!wave.reveal and wave.glyph != ' ');
+ const punctuation = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expectEqualSlices(u8, &.{wave.glyph}, punctuation.at(0, 0).grapheme());
+ try std.testing.expect(punctuation.at(0, 0).style.dim);
+
+ _ = arena.reset(.retain_capacity);
+ track.frame = track.effect.frames() - 1;
+ const new_frame = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expectEqual(cells[0], new_frame.at(0, 0).*);
+}
+
+test "content transition without a diff snaps to canonical surface" {
+ var cells = [_]pardes.Cell{.{}};
+ cells[0].text[0] = 'N';
+ cells[0].default = false;
+ var surface: pardes.Surface = .{
+ .cols = 1,
+ .rows = 1,
+ .cells = &cells,
+ .cursor = .{ .x = 0, .y = 0 },
+ };
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+
+ for ([_]panel_animation.Transition{ .dissolve, .ascii, .vertical }) |effect| {
+ const track: Track = .{
+ .effect = effect,
+ .phase = .opening,
+ .from = .{ .y = 1, .w = 1, .h = 1 },
+ .to = .{ .w = 1, .h = 1 },
+ };
+ try std.testing.expectEqual(&surface, try compose(arena.allocator(), &surface, &.{track}, null));
+ }
+}
+
+test "exact transition endpoint preserves the canonical cursor" {
+ var cells = [_]pardes.Cell{.{}};
+ var previous = cells;
+ const changed = [_]u8{1};
+ var surface: pardes.Surface = .{
+ .cols = 1,
+ .rows = 1,
+ .cells = &cells,
+ .cursor = .{ .x = 0, .y = 0 },
+ .previous_cells = &previous,
+ .changed_cells = &changed,
+ };
+ const track: Track = .{
+ .effect = .ascii,
+ .frame = panel_animation.Transition.ascii.frames() - 1,
+ .from = .{ .w = 1, .h = 1 },
+ .to = .{ .w = 1, .h = 1 },
+ };
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+
+ const result = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expectEqual(&surface, result);
+ try std.testing.expect(result.cursor != null);
+}
+
+test "vertical opening rises through a fixed old-grid clip" {
+ var cells: [12]pardes.Cell = @splat(.{});
+ var previous: [12]pardes.Cell = @splat(.{});
+ for (&cells, "aaabbbcccddd") |*cell, glyph| {
+ cell.text[0] = glyph;
+ cell.len = 1;
+ cell.default = false;
+ }
+ for (&previous, "000111222333") |*cell, glyph| {
+ cell.text[0] = glyph;
+ cell.len = 1;
+ cell.default = false;
+ }
+ const changed: [12]u8 = @splat(1);
+ var surface: pardes.Surface = .{
+ .cols = 3,
+ .rows = 4,
+ .cells = &cells,
+ .previous_cells = &previous,
+ .changed_cells = &changed,
+ };
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+ const target: Box = .{ .y = 1, .w = 3, .h = 2 };
+ var track: Track = .{
+ .serial = 5,
+ .phase = .opening,
+ .effect = .vertical,
+ .from = panel_animation.openingBox(.vertical, target, 3),
+ .to = target,
+ };
+
+ const first = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expectEqualStrings("1", first.at(0, 1).grapheme());
+ try std.testing.expectEqualStrings("2", first.at(0, 2).grapheme());
+ try std.testing.expectEqualStrings("a", first.at(0, 0).grapheme());
+ try std.testing.expectEqualStrings("d", first.at(0, 3).grapheme());
+
+ _ = arena.reset(.retain_capacity);
+ track.frame = 2;
+ const entering = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expectEqualStrings("1", entering.at(0, 1).grapheme());
+ try std.testing.expectEqualStrings("b", entering.at(0, 2).grapheme());
+ try std.testing.expectEqualStrings("a", entering.at(0, 0).grapheme());
+ try std.testing.expectEqualStrings("d", entering.at(0, 3).grapheme());
+
+ _ = arena.reset(.retain_capacity);
+ track.frame = track.effect.frames() - 1;
+ const final = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expectEqualStrings("b", final.at(0, 1).grapheme());
+ try std.testing.expectEqualStrings("c", final.at(0, 2).grapheme());
+}
+
+test "vertical closing slides frozen content upward over canonical cells" {
+ var cells: [12]pardes.Cell = @splat(.{});
+ var previous: [12]pardes.Cell = @splat(.{});
+ for (&cells, "aaabbbcccddd") |*cell, glyph| {
+ cell.text[0] = glyph;
+ cell.len = 1;
+ cell.default = false;
+ }
+ for (&previous, "000111222333") |*cell, glyph| {
+ cell.text[0] = glyph;
+ cell.len = 1;
+ cell.default = false;
+ }
+ const changed: [12]u8 = @splat(1);
+ var surface: pardes.Surface = .{
+ .cols = 3,
+ .rows = 4,
+ .cells = &cells,
+ .previous_cells = &previous,
+ .changed_cells = &changed,
+ };
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+ const old_box: Box = .{ .y = 1, .w = 3, .h = 2 };
+ var track: Track = .{
+ .serial = 5,
+ .phase = .closing,
+ .effect = .vertical,
+ .from = old_box,
+ .to = panel_animation.closingBox(.vertical, old_box),
+ };
+
+ const first = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expectEqualStrings("1", first.at(0, 1).grapheme());
+ try std.testing.expectEqualStrings("2", first.at(0, 2).grapheme());
+
+ _ = arena.reset(.retain_capacity);
+ track.frame = 2;
+ const leaving = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expectEqualStrings("2", leaving.at(0, 1).grapheme());
+ try std.testing.expectEqualStrings("c", leaving.at(0, 2).grapheme());
+ try std.testing.expectEqualStrings("a", leaving.at(0, 0).grapheme());
+ try std.testing.expectEqualStrings("d", leaving.at(0, 3).grapheme());
+
+ _ = arena.reset(.retain_capacity);
+ track.frame = track.effect.frames() - 1;
+ const final = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expectEqual(cells[3], final.at(0, 1).*);
+ try std.testing.expectEqual(cells[6], final.at(0, 2).*);
+}
+
+test "closing content paints after opening content regardless of track order" {
+ var cells = [_]pardes.Cell{.{}};
+ var previous = [_]pardes.Cell{.{}};
+ cells[0].text[0] = 'N';
+ cells[0].default = false;
+ previous[0].text[0] = 'O';
+ previous[0].default = false;
+ const changed = [_]u8{1};
+ var surface: pardes.Surface = .{
+ .cols = 1,
+ .rows = 1,
+ .cells = &cells,
+ .previous_cells = &previous,
+ .changed_cells = &changed,
+ };
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+ const box: Box = .{ .w = 1, .h = 1 };
+ const closing: Track = .{
+ .serial = 1,
+ .phase = .closing,
+ .effect = .vertical,
+ .from = box,
+ .to = panel_animation.closingBox(.vertical, box),
+ };
+ const opening: Track = .{
+ .serial = 2,
+ .phase = .opening,
+ .effect = .ascii,
+ .frame = panel_animation.Transition.ascii.frames() - 1,
+ .from = box,
+ .to = box,
+ };
+
+ const animated = try compose(arena.allocator(), &surface, &.{ closing, opening }, null);
+ try std.testing.expectEqualStrings("O", animated.at(0, 0).grapheme());
+}
+
+test "opening panel cannot erase an existing panel drawn underneath" {
+ var cells: [8]pardes.Cell = @splat(.{});
+ for (&cells, 0..) |*cell, x| {
+ cell.text[0] = if (x < 4) 'A' else 'B';
+ cell.len = 1;
+ cell.default = false;
+ }
+ var surface: pardes.Surface = .{ .cols = 8, .rows = 1, .cells = &cells };
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+ const tracks = [_]Track{
+ .{
+ .serial = 1,
+ .phase = .moving,
+ .effect = .slide,
+ .from = .{ .w = 8, .h = 1 },
+ .to = .{ .w = 4, .h = 1 },
+ },
+ .{
+ .serial = 2,
+ .phase = .opening,
+ .effect = .slide,
+ .from = .{ .x = 8, .w = 4, .h = 1 },
+ .to = .{ .x = 4, .w = 4, .h = 1 },
+ },
+ };
+ const animated = try compose(arena.allocator(), &surface, &tracks, null);
+ try std.testing.expectEqualStrings("A", animated.at(7, 0).grapheme());
+}
+
+test "geometry gap is a blank painted in an explicit theme background" {
+ var cells: [4]pardes.Cell = @splat(.{});
+ for (&cells, "ABCD") |*cell, glyph| {
+ cell.text[0] = glyph;
+ cell.len = 1;
+ cell.default = false;
+ cell.style = .{
+ .fg = .{ .rgb = .{ 220, 210, 200 } },
+ .bg = .{ .rgb = .{ 30, 20, 10 } },
+ .bold = true,
+ .font_role = .tagline,
+ };
+ }
+ var surface: pardes.Surface = .{ .cols = 4, .rows = 1, .cells = &cells };
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+ const track: Track = .{
+ .serial = 1,
+ .effect = .slide,
+ .from = .{ .x = 2, .w = 2, .h = 1 },
+ .to = .{ .w = 4, .h = 1 },
+ };
+ const page_bg = [3]u8{ 248, 244, 232 };
+ const animated = try compose(arena.allocator(), &surface, &.{track}, page_bg);
+
+ const gap = animated.at(0, 0);
+ try std.testing.expect(!gap.default);
+ try std.testing.expectEqualStrings(" ", gap.grapheme());
+ try std.testing.expectEqual(pardes.Color{ .rgb = page_bg }, gap.style.bg);
+ try std.testing.expectEqual(pardes.Color.default, gap.style.fg);
+ try std.testing.expect(!gap.style.bold);
+ try std.testing.expectEqual(pardes.FontRole.body, gap.style.font_role);
+ // The clear supplies only exposed geometry. Cells sampled from the moving
+ // panel retain all canonical content and style fields byte-for-byte.
+ try std.testing.expectEqual(cells[1], animated.at(2, 0).*);
+}
+
+test "geometry gap stays terminal-default for a transparent theme" {
+ var cells: [2]pardes.Cell = @splat(.{});
+ for (&cells, "AB") |*cell, glyph| {
+ cell.text[0] = glyph;
+ cell.len = 1;
+ cell.default = false;
+ }
+ var surface: pardes.Surface = .{ .cols = 2, .rows = 1, .cells = &cells };
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+ const track: Track = .{
+ .serial = 1,
+ .effect = .slide,
+ .from = .{ .x = 1, .w = 1, .h = 1 },
+ .to = .{ .w = 2, .h = 1 },
+ };
+ const animated = try compose(arena.allocator(), &surface, &.{track}, null);
+
+ const gap = animated.at(0, 0);
+ try std.testing.expect(gap.default);
+ try std.testing.expectEqualStrings(" ", gap.grapheme());
+ try std.testing.expectEqual(pardes.Color.default, gap.style.bg);
+}
+
+test "fractional geometry draws exactly the cell centres core can hit" {
+ var cells: [3]pardes.Cell = @splat(.{});
+ for (&cells, "ABC") |*cell, glyph| {
+ cell.text[0] = glyph;
+ cell.len = 1;
+ cell.default = false;
+ }
+ var surface: pardes.Surface = .{ .cols = 3, .rows = 1, .cells = &cells };
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+ const track: Track = .{
+ .serial = 1,
+ .effect = .slide,
+ .from = .{ .x = 0.9, .w = 1, .h = 1 },
+ .to = .{ .x = 0, .w = 1, .h = 1 },
+ };
+ const animated = try compose(arena.allocator(), &surface, &.{track}, null);
+ // Cell 0 lies in floor/ceil iteration bounds, but its centre (0.5) is
+ // outside [0.9, 1.9); cell 1 is the only presented/hit-testable cell.
+ try std.testing.expectEqualStrings(" ", animated.at(0, 0).grapheme());
+ try std.testing.expectEqualStrings("A", animated.at(1, 0).grapheme());
+}
+
+test "large-grid right edge uses containment before rounded ratio" {
+ const cols = std.math.maxInt(u16);
+ const source_cells = try std.testing.allocator.alloc(pardes.Cell, cols);
+ defer std.testing.allocator.free(source_cells);
+ const output_cells = try std.testing.allocator.alloc(pardes.Cell, cols);
+ defer std.testing.allocator.free(output_cells);
+ @memset(source_cells, .{});
+ @memset(output_cells, .{});
+ for (source_cells) |*cell| {
+ cell.text[0] = 'X';
+ cell.len = 1;
+ cell.default = false;
+ }
+ var source: pardes.Surface = .{ .cols = cols, .rows = 1, .cells = source_cells };
+ var output: pardes.Surface = .{ .cols = cols, .rows = 1, .cells = output_cells };
+ const track: Track = .{
+ .serial = 1,
+ .effect = .zoom,
+ .frame = 8,
+ .from = .{ .x = 56_796, .w = 8_299, .h = 1 },
+ .to = .{ .x = 556, .w = 63_483, .h = 1 },
+ };
+ const shown = track.presented();
+ try std.testing.expect(@as(f32, 63_935.5) < shown.x + shown.w);
+ transform(&output, &source, track);
+ try std.testing.expectEqualStrings("X", output.at(63_935, 0).grapheme());
+}
+
+test "active panel transition hides only its own native attachment" {
+ const tracks = [_]Track{
+ .{ .serial = 41, .effect = .slide, .frame = 0 },
+ .{ .serial = 42, .effect = .ascii, .frame = 0 },
+ .{ .serial = 43, .effect = .zoom, .frame = panel_animation.Transition.zoom.frames() - 1 },
+ .{ .serial = 44, .effect = .zoom, .frame = panel_animation.Transition.zoom.frames() },
+ .{ .serial = 45, .phase = .opening, .effect = .vertical, .frame = 0 },
+ };
+
+ try std.testing.expect(hidesAttachment(&tracks, 41));
+ try std.testing.expect(!hidesAttachment(&tracks, 42));
+ try std.testing.expect(!hidesAttachment(&tracks, 43));
+ try std.testing.expect(!hidesAttachment(&tracks, 44));
+ try std.testing.expect(hidesAttachment(&tracks, 45));
+ try std.testing.expect(!hidesAttachment(&tracks, 46));
+}
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index f6d095e5..05585f46 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -14,8 +14,10 @@ const look = @import("../look.zig");
const temp_file = @import("../temp_file.zig");
const shell_bin = @import("../shell_bin.zig");
const message = @import("../message.zig");
+const file_watch = @import("../file_watch.zig");
const selection_pipe = @import("../selection_pipe.zig");
const nested = @import("../nested.zig");
+const panel_compositor = @import("panel_compositor.zig");
extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int;
extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
@@ -36,6 +38,11 @@ pub const Command = struct {
pty_eof: struct { id: usize, gen: u32 },
winsize: vaxis.Winsize,
mouse: vaxis.Mouse,
+ /// Focus reporting is part of vaxis's mouse mode (DEC 1004). A TTY
+ /// cannot report a literal pointer crossing its character grid, so
+ /// losing terminal focus is its only reliable pointer-leave signal.
+ focus_in,
+ focus_out,
paste: []const u8,
/// The bracketed-paste brackets. vaxis posts them ONLY because this
/// union declares fields with these exact names — its Loop gates every
@@ -57,26 +64,6 @@ pub const Command = struct {
};
const Loop = vaxis.Loop(@TypeOf(Command.value));
-/// The files on open panes, watched through ONE inotify instance.
-///
-/// `wd` is the descriptor for the file's CONTAINING DIRECTORY, not for the
-/// file: almost nothing rewrites a file in place. An editor, `git checkout`, a
-/// formatter — they write a temp file and rename it over the target, which
-/// swaps the inode, and a watch on the file itself keeps reporting on the old
-/// unlinked one forever. The directory sees the new inode arrive (MOVED_TO /
-/// CREATE). std.Build.Watch marks directories for exactly this reason; it uses
-/// fanotify because it watches thousands of them across mount points and needs
-/// FID-keyed dedup, whereas sixteen panes' worth of inotify is a third of the
-/// code, hands us no privilege question, and has no kernel version floor.
-///
-/// `hash` is what we last saw ON DISK. A reload only fires when it moves, so
-/// pardes's own Save — which restamps the hash from the bytes it just wrote —
-/// never looks like an external change and never touches the undo stack.
-/// A hash rather than mtime+size because we have to read the file to hand the
-/// core its bytes anyway, so the exact answer costs nothing extra and there is
-/// no clock granularity to reason about.
-const Watch = struct { wd: c_int, hash: u64 };
-
/// One language query, owned by the worker that runs it. Everything the
/// backend may read is copied in here before the worker starts: the core goes
/// on editing the moment the effect is drained, so a borrowed slice would be a
@@ -333,6 +320,81 @@ test "Kitty PDF fragments never declare pixels beyond their clipped bottom" {
try std.testing.expect(kittyPlacement(height, 80, 16, 640, 256) == null);
}
+test "Linux host watch closes initial race and reloads rename-over PDF while idle" {
+ if (comptime builtin.os.tag != .linux or !pardes.pdf_enabled) return;
+ const io = std.testing.io;
+ const gpa = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const original = try pardes.pdf.makeOutlineTestPdf(gpa);
+ defer gpa.free(original);
+ const replacement = try pardes.pdf.makeNoOutlineTestPdf(gpa);
+ defer gpa.free(replacement);
+ try tmp.dir.writeFile(io, .{ .sub_path = "live.pdf", .data = original });
+ // Prepare both editor-style temporary inodes before marking the directory
+ // so the only post-arm wake below is the second rename.
+ try tmp.dir.writeFile(io, .{ .sub_path = "initial.pdf", .data = replacement });
+ try tmp.dir.writeFile(io, .{ .sub_path = "live-replacement.pdf", .data = original });
+ var path_buf: [256]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/live.pdf", .{tmp.sub_path});
+
+ const fd = libc.inotify_init1(linux.IN.CLOEXEC | linux.IN.NONBLOCK);
+ if (fd < 0) return error.InotifyInitFailed;
+ defer _ = libc.close(fd);
+ var watches: file_watch.Table = @splat(null);
+ defer for (0..watches.len) |id| file_watch.watchPane(
+ fd,
+ &watches,
+ @intCast(id),
+ null,
+ 0,
+ .{ .text = 0 },
+ );
+
+ const core = try pardes.Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 });
+ defer core.deinit();
+ try std.testing.expectEqual(@as(usize, 3), core.panes[0].?.pdf.?.page_count);
+
+ // The core opened the three-page inode, but the host has not drained its
+ // watch effect yet. Replace it now: install-then-reconcile must discover
+ // the one-page document even though no source existed for this first edge.
+ try tmp.dir.rename("initial.pdf", tmp.dir, "live.pdf", io);
+ var armed = false;
+ while (core.nextEffect()) |effect| switch (effect) {
+ .watch => |watch| if (watch.pane == 0 and watch.on) {
+ armed = true;
+ try std.testing.expect(!file_watch.applyEffect(core, io, gpa, fd, &watches, 0, true));
+ },
+ else => {},
+ };
+ try std.testing.expect(armed and watches[0] != null);
+ try std.testing.expectEqual(@as(usize, 1), core.panes[0].?.pdf.?.page_count);
+
+ try tmp.dir.rename("live-replacement.pdf", tmp.dir, "live.pdf", io);
+ var events: [4096]u8 = undefined;
+ const event_bytes = libc.read(fd, &events, events.len);
+ try std.testing.expect(event_bytes > 0);
+
+ // This is the same pass the watcher thread schedules; no key, mouse, or
+ // synthetic core file_changed event participates in the transaction.
+ try std.testing.expect(!file_watch.reloadChanged(core, io, gpa, &watches));
+ const pane = core.panes[0].?;
+ try std.testing.expectEqual(@as(usize, 3), pane.pdf.?.page_count);
+ const disk_identity = try file_watch.identify(io, path);
+ switch (watches[0].?.generation) {
+ .pdf => |accepted| try std.testing.expect(accepted != null and accepted.?.eql(disk_identity)),
+ .text => return error.PdfWatchStoredTextGeneration,
+ }
+ try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "reloaded") != null);
+
+ core.native_images = true;
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ const surface = try core.render(frame.allocator());
+ try std.testing.expect(surface.nimages > 0);
+ try std.testing.expectEqual(@as(u32, 0), surface.images[0].?.native.page);
+}
+
fn kittyImageRepresentable(place: pardes.ImagePlace) bool {
return place.iw > 0 and place.ih > 0 and
place.iw <= std.math.maxInt(u16) and place.ih <= std.math.maxInt(u16);
@@ -439,9 +501,10 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
var frame_arena: std.heap.ArenaAllocator = .init(allocs.frame);
defer frame_arena.deinit();
- // shells emit OSC 133 prompt marks via this rc (prompt hiding, click-move)
- writeFile(shell_bin.bash_rc_path, shell_bin.bash_rc);
- writeFile(shell_bin.fish_rc_path, shell_bin.fish_rc);
+ // Private, complete before any fork and retained until the last possible
+ // spawn; children borrow only these stable in-struct path buffers.
+ var prompt_rcs = shell_bin.PromptRcs.init();
+ defer prompt_rcs.deinit();
// macos: apple's bash 3.2 prints the zsh-deprecation banner into every
// pane unless this is in the env BEFORE bash starts (the rc is too late)
if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1);
@@ -471,7 +534,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// file a positional path argument opened. -1 off linux: watchPane goes
// quiet and the core simply never gets a file_changed event.
var inotify_fd: c_int = if (builtin.os.tag == .linux) libc.inotify_init1(linux.IN.CLOEXEC) else -1;
- var watches: [pardes.MAX_PANES]?Watch = @splat(null);
+ var watches: file_watch.Table = @splat(null);
var watch_task: ?std.Io.Future(anyerror!void) = null;
var loop: Loop = .init(io, &tty, &vx);
defer {
@@ -525,7 +588,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// Perform the initial spawns BEFORE any worker thread exists: forkpty from
// a multithreaded process can wedge the child before exec.
- drainEffects(core, &ptys, &gens, io, gpa, allocs.lsp, &loop, &vx, &tty, &lsp_task, &pipe_tasks, inotify_fd, &watches, false);
+ drainEffects(core, &prompt_rcs, &ptys, &gens, io, gpa, allocs.lsp, &loop, &vx, &tty, &lsp_task, &pipe_tasks, inotify_fd, &watches, false);
try loop.start();
defer loop.stop();
@@ -621,6 +684,8 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
.nop => {},
.tick => core.update(.tick),
.quit => stop = true,
+ .focus_in => {},
+ .focus_out => core.update(.pointer_leave),
.winsize => |ws| {
try vx.resize(gpa, tty.writer(), ws);
if (comptime pardes.pdf_enabled)
@@ -761,35 +826,13 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
tz_event.end();
if (stop) break;
- // A watched directory moved. The wake says only THAT something
- // happened, so re-read every watched pane and hand the core the ones
- // whose bytes actually changed — that comparison is what keeps our own
- // Save, and any write that lands on identical content, out of the undo
- // stack. Reading here rather than on the watcher thread because the
- // core is the only thing that knows which pane a path belongs to, and
- // opening a file pane already reads synchronously on this thread.
if (check_files) {
check_files = false;
- for (&watches, 0..) |*slot, id| {
- if (slot.* == null) continue;
- const pane = core.panes[id] orelse continue;
- const f = pane.file orelse continue;
- const bytes = look.readFile(gpa, f.path) catch continue;
- defer gpa.free(bytes);
- const h = std.hash.Wyhash.hash(0, bytes);
- if (h == slot.*.?.hash) continue;
- slot.*.?.hash = h;
- core.update(.{ .file_changed = .{ .pane = @intCast(id), .bytes = bytes } });
- // the hash moving IS "someone else wrote this file" (our own
- // Save restamped it above), so the pane says who did what to it
- // — and it says it after the update, which cannot clear a
- // message because only a key or a mouse event does
- var mbuf: [256]u8 = undefined;
- core.setMessage(id, message.stamp(&mbuf, "reloaded", f.path));
- }
+ if (file_watch.reloadChanged(core, io, gpa, &watches))
+ loop.postEvent(.files_changed) catch {};
}
- drainEffects(core, &ptys, &gens, io, gpa, allocs.lsp, &loop, &vx, &tty, &lsp_task, &pipe_tasks, inotify_fd, &watches, true);
+ drainEffects(core, &prompt_rcs, &ptys, &gens, io, gpa, allocs.lsp, &loop, &vx, &tty, &lsp_task, &pipe_tasks, inotify_fd, &watches, true);
if (core.quit) break;
// Restore builtin: swap in a core rebuilt from the dump; the live
@@ -811,7 +854,14 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// the replay core's pane ids mean new things, and the dying core's
// `watch off` effects go into a queue nobody drains — drop the lot
// here. The new core emits its own `on`s as it builds its panes.
- for (0..watches.len) |wid| watchPane(inotify_fd, &watches, @intCast(wid), null, 0);
+ for (0..watches.len) |wid| file_watch.watchPane(
+ inotify_fd,
+ &watches,
+ @intCast(wid),
+ null,
+ 0,
+ .{ .text = 0 },
+ );
var image_iterator = kitty_handles.valueIterator();
while (image_iterator.next()) |handle| vx.freeImage(tty.writer(), handle.id);
kitty_handles.clearRetainingCapacity();
@@ -858,8 +908,14 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// ---- render: surface -> vaxis, cell for cell ----
_ = frame_arena.reset(.retain_capacity);
const tz_core = tracy.zone(@src(), "core.render");
- const surface = try core.render(frame_arena.allocator());
+ const canonical = try core.render(frame_arena.allocator());
tz_core.end();
+ const surface = try panel_compositor.compose(
+ frame_arena.allocator(),
+ canonical,
+ canonical.panelTracks(),
+ core.theme().bg,
+ );
const tz_cells = tracy.zone(@src(), "surface->vaxis");
const win = vx.window();
win.clear();
@@ -883,6 +939,10 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// aliasing a fixed terminal cache slot.
for (surface.images[0..surface.nimages]) |maybe| {
const place = maybe orelse continue;
+ // Keep the placement in Surface so the terminal-side cache stays
+ // live, but do not pin native pixels over a panel whose cells are
+ // currently moving through the TTY grid.
+ if (panel_compositor.hidesAttachment(surface.panelTracks(), place.serial)) continue;
if (comptime pardes.pdf_enabled) {
if (!kittyImageRepresentable(place)) continue;
}
@@ -947,14 +1007,15 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
const tz_render = tracy.zone(@src(), "vx.render");
try vx.render(tty.writer());
tz_render.end();
+ core.acknowledgePanelPresentation(canonical.panelTracks());
tracy.frameMark();
- // A theme transition is the only reason this otherwise event-driven
+ // Active core animation is the only reason this otherwise event-driven
// frontend redraws on a clock. Sleep on the loop thread, then make the
// next pass a tick; input and worker events can queue during the short
// sleep and are drained in that pass. No timer worker can outlive the
// loop, and at the exact endpoint we return to an indefinite wait.
- if (core.themeAnimationActive()) {
+ if (core.animationActive()) {
std.Io.sleep(io, .fromMilliseconds(pardes.animation.frame_ms), .awake) catch {};
pending = .tick;
}
@@ -963,6 +1024,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
fn drainEffects(
core: *pardes.Pardes,
+ prompt_rcs: *const shell_bin.PromptRcs,
ptys: *[pardes.MAX_PANES]?Pty,
gens: *[pardes.MAX_PANES]u32,
io: std.Io,
@@ -974,7 +1036,7 @@ fn drainEffects(
lsp_task: *?std.Io.Future(anyerror!void),
pipe_tasks: *PipeTasks,
inotify_fd: c_int,
- watches: *[pardes.MAX_PANES]?Watch,
+ watches: *file_watch.Table,
threads_ok: bool,
) void {
while (core.nextEffect()) |effect| switch (effect) {
@@ -996,7 +1058,7 @@ fn drainEffects(
cwd_buf[cwd.len] = 0;
cwd_z = @ptrCast(&cwd_buf);
}
- const child = forkShell(core.shellBin(), cwd_z, core.screen_h, core.screen_w);
+ const child = forkShell(core, sp.pane, prompt_rcs, core.shellBin(), cwd_z, core.screen_h, core.screen_w);
ptys[sp.pane] = .{ .file = child.file, .pid = child.pid, .reader = .{ .any_future = null, .result = {} } };
// report the pane's starting directory back to the core (tags). The
// slot needs no occupancy reset: nothing is remembered, and the next
@@ -1032,7 +1094,10 @@ fn drainEffects(
_ = libc.close(fd);
// our own write is about to come back as a watch event: restamp
// from the bytes we just put there so it reads as "no change"
- if (watches[sf.pane]) |*w| w.hash = std.hash.Wyhash.hash(0, f.content);
+ if (watches[sf.pane]) |*w| if (w.serial == pane.serial) switch (w.generation) {
+ .text => w.generation = .{ .text = std.hash.Wyhash.hash(0, f.content) },
+ .pdf => {},
+ };
// ...and say so on the pane's message row. AFTER the write, not
// beside it: every `continue` above is a save that did not happen
// and must not be reported as one.
@@ -1138,15 +1203,8 @@ fn drainEffects(
pipe_tasks.add(.{ .id = request.id, .future = future });
},
.watch => |w| {
- // starting, the path and the on-disk bytes are read off the core
- // (same split as save_file); stopping, the pane is already gone
- var path: ?[]const u8 = null;
- var hash: u64 = 0;
- if (w.on) if (core.panes[w.pane]) |pane| if (pane.file) |f| {
- path = f.path;
- hash = std.hash.Wyhash.hash(0, f.content);
- };
- watchPane(inotify_fd, watches, w.pane, path, hash);
+ if (file_watch.applyEffect(core, io, gpa, inotify_fd, watches, w.pane, w.on))
+ loop.postEvent(.files_changed) catch {};
},
.quit => {},
};
@@ -1187,42 +1245,6 @@ fn pipeWorker(
loop.postEvent(.{ .pipe_done = response }) catch response.deinit(gpa);
}
-/// Mark or unmark one pane's file (`path` null = unmark). Linux only: on any
-/// other OS this returns silently, the core never receives a file_changed
-/// event, and the feature is simply off — a state the core has to tolerate
-/// regardless, since the browser shell has no filesystem at all.
-/// ponytail: darwin wants the FSEvents half of std.Build.Watch here.
-fn watchPane(fd: c_int, watches: *[pardes.MAX_PANES]?Watch, id: u8, path: ?[]const u8, hash: u64) void {
- if (comptime builtin.os.tag != .linux) return;
- if (fd < 0) return;
- if (watches[id]) |old| {
- // inotify hands out ONE descriptor per directory, so two panes on
- // files in the same directory share it: drop the mark only when the
- // last of them lets go, or closing one blinds the other.
- var shared = false;
- for (watches, 0..) |other, i| {
- const o = other orelse continue;
- if (i != id and o.wd == old.wd) shared = true;
- }
- if (!shared) _ = libc.inotify_rm_watch(fd, old.wd);
- watches[id] = null;
- }
- const p = path orelse return;
- const dir = std.fs.path.dirname(p) orelse ".";
- var dbuf: [4096:0]u8 = undefined;
- if (dir.len >= dbuf.len) return;
- @memcpy(dbuf[0..dir.len], dir);
- dbuf[dir.len] = 0;
- // CLOSE_WRITE, not MODIFY: one event when a writer is DONE rather than one
- // per write(2), which is most of the debounce for free. MOVED_TO and
- // CREATE catch the rename-over and the delete-then-recreate that are how
- // files are actually replaced (see Watch).
- const mask = linux.IN.CLOSE_WRITE | linux.IN.MOVED_TO | linux.IN.CREATE | linux.IN.ONLYDIR;
- const wd = libc.inotify_add_watch(fd, dbuf[0..dir.len :0], mask);
- if (wd < 0) return;
- watches[id] = .{ .wd = wd, .hash = hash };
-}
-
/// Block on the inotify fd and wake the loop. Deliberately does NOT parse the
/// events: the loop re-reads every watched pane anyway, so the only thing an
/// event carries that we need is THAT something happened, and parsing would
@@ -1297,12 +1319,12 @@ fn ttyTakenAt(ctx: ?*anyopaque, pane: usize) bool {
return look.ttyTaken(pt.pid, pt.file.handle);
}
-fn forkShell(bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) struct { file: std.Io.File, pid: posix.pid_t } {
+fn forkShell(core: *pardes.Pardes, pane: usize, prompt_rcs: *const shell_bin.PromptRcs, bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) struct { file: std.Io.File, pid: posix.pid_t } {
var master: c_int = undefined;
// resolved BEFORE the fork, into this frame, which the child inherits:
// nothing between fork and exec may allocate, and a PATH search would
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const spawn = shell_bin.resolve(bin, &path_buf);
+ const spawn = shell_bin.resolve(bin, &path_buf, prompt_rcs);
const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
const pid = forkpty(&master, null, null, &ws);
if (pid == 0) {
@@ -1316,6 +1338,7 @@ fn forkShell(bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) struct
_ = execv(spawn.path, &spawn.argv);
_exit(127);
}
+ if (pid > 0) core.acknowledgeShell(pane, std.mem.span(spawn.path), spawn.argv[1] != null);
return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid };
}
@@ -1398,13 +1421,6 @@ fn vaxisColor(c: pardes.Color) vaxis.Color {
};
}
-fn writeFile(path: [*:0]const u8, data: []const u8) void {
- const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
- if (fd < 0) return;
- defer _ = libc.close(fd);
- writeFd(fd, data);
-}
-
fn writeFd(fd: c_int, data: []const u8) void {
var off: usize = 0;
while (off < data.len) {
diff --git a/src/tutor.txt b/src/tutor.txt
index c2db239d..7c4c2733 100644
--- a/src/tutor.txt
+++ b/src/tutor.txt
@@ -89,10 +89,10 @@
Once a gesture becomes a scroll it cannot also LOOK on
release. The browser has no host filesystem or ptys:
LOOK opens URLs or read-only Pardes .zig sources that
- were Git-tracked and embedded when the web build was made.
+ were tracked or new/nonignored when the web build was made.
The published launcher is a real Pardes terminal dump of
- `git ls-files '*.zig'`: every listed path opens, and the
- opened source uses tree-sitter syntax colors.
+ Git's tracked-plus-new/nonignored source list: every listed
+ path opens, and the opened source uses tree-sitter colors.
A touch beginning on a tagline or pane separator instead
latches a left-mouse gesture, so layout drags never scroll.
Touch circles/trails and the click flash are the SDL
@@ -186,8 +186,8 @@
wear the one you stopped on. The bar holds only what you reach for
often — the rest of the builtins are words you execute and keys you
press. Nearly every one has a KEY PATH under SPC (part 3.13): "Colors"
- (the syntax/ansi recolor) is SPC t c, "Crt" is SPC t r, and SPC ? lists
- the lot.
+ (the syntax/ansi recolor) is SPC t c, the native GUI scene effects are
+ SPC t r/R/g, panel transitions are SPC a s/z/d/a/v, and SPC ? lists the lot.
Right-click a directory in any body to open a terminal there.
SEARCH: `/` in normal mode types a pattern into that pane's own tag (it
@@ -213,7 +213,10 @@
recent first, then the output buffers you have not, newest first, and
only when there is neither does it step the pane in front of you — so
n/N work on a shell that never had a search armed, and off the end
- they come round to the start. After the FIRST press `N` is `n`
+ they come round to the start. A Look from a result row may move focus
+ into the source file; the result pane remains the walk's owner, so the
+ next n/N returns there instead of starting from the file that opened.
+ After the FIRST press `N` is `n`
backwards exactly; the first one is not a step but an arrival, since
from a cursor the walk has never stood on there is nothing yet to step
back from.
@@ -256,8 +259,9 @@
- the scrollbar is the gutter below the box: left-click scrolls UP
to that point, right-click scrolls DOWN
- the FIRST document you open — a file, an image, a PDF, this tutor —
- takes a new leftmost column of its own (nothing is displaced, the
- other columns just narrow); every later one splits below a doc
+ takes a new leftmost column of its own, paid for only by the column
+ you opened it from; every unrelated column keeps its exact rectangle.
+ Every later document splits below a doc
already open, so docs share that column
- closing a window (Del, or its shell exiting) hands focus back to the
window you were in before it, not to whichever one comes first
@@ -846,13 +850,13 @@ abc
Nearly every builtin has a NAME you can execute anywhere text lives,
and a KEY PATH you can press. SPC in body normal mode starts the
- path; the keys you have typed so far show at the right edge of the
- pane's tag until the sequence fires. Esc abandons it — so does any
+ path; the keys you have typed so far show on the active pane's
+ transient body/message row until it fires. Esc abandons it — so does any
key that leads nowhere, rather than leaving the next keystroke armed.
The groups are shared first letters and nothing cleverer: f files,
- h docs, c columns, t toggles, s session, j jumps, l language,
- w windows.
+ h docs, c columns, t toggles, a animations, s session, j jumps,
+ l language, w windows.
SPC ? Help: every builtin and the keys that run it
SPC k Kill (quit) SPC d Del (close this pane)
@@ -866,7 +870,11 @@ abc
SPC t d Debug SPC t c Colors (syntax/ansi recolor)
SPC t n NextColor SPC t t ThemeSel (the list of 228)
- SPC t r Crt SPC t f FontSel (gui and macOS only)
+ SPC t r/R/g Crt / Ripple / Glitch (gui and macOS only)
+ SPC t f FontSel (gui and macOS only)
+ SPC a s/z/d/a/v PanelSlide / PanelZoom / PanelDissolve / PanelAscii /
+ PanelVertical
+ (native tty, SDL GUI, and macOS; press again to turn off)
SPC t w Wrap: soft line breaks in a file pane, ON to start
with — so this is the switch that lets a long line
run off the edge again, hscroll and all. A wrapped
@@ -972,7 +980,9 @@ abc
render at all rather than as a stack of blank pages.
An IMAGE — .png .jpg .jpeg .gif .bmp .ppm .pgm .tga — opens an image
- pane, tagged `img <path>`. Where the shell can place real pixels it
+ pane. Its tag reports `petscii:on/off`, `palette:commodore/terminal`,
+ and `ascii:on/off` before the path, so every pane-local choice is
+ visible even though only the builtins mutate it. Where the shell can place real pixels it
does; where it cannot, the petscii matcher draws the picture out of
block glyphs. The three toggles all act on THAT matcher, so with real
pixels on screen only the first of them changes anything:
@@ -1061,24 +1071,46 @@ abc
WHOLE, since a command line has no place inside it to pick out; Tab (or
a middle click) runs the row you stopped on and puts that face on. So
walking with n and pressing Tab is trying faces on, and stopping is
- choosing. `Font <name>` on its own takes the font file's
- stem.
+ choosing. `Font <name>` on its own takes the font file's stem.
+ `TaglineSize 70` changes the tagline face and visible band to 70 percent
+ of the body face, live; values 1 through 100 are accepted, `Config`
+ reports the active value, and both native GUI renderers retain the same
+ body cell grid while drawing that separate role.
+
+ Native panel transitions are the five SPC a choices listed in 3.13. They
+ start off. Only one is selected; running it again turns it off. Slide and
+ zoom animate geometry. Dissolve and ASCII animate only the old/new cell
+ diff, leaving unchanged content alone. Vertical raises only the pane being
+ added or deleted inside its own box, leaving every survivor still. The
+ native GUI scene switches Crt, Ripple
+ and Glitch may be combined. `EffectCode PanelAscii` (or any other
+ effect builtin as its argument) opens the exact backend sources that
+ were embedded when this binary was built.
Before its first frame a native pardes reads ONE file: a file called
`pardes` in your config directory — $XDG_CONFIG_HOME/pardes when that
is set to an absolute path, else ~/.config/pardes, and on macOS
- ~/Library/Application Support/pardes. `SPC f c` prints the resolved
- path into a "+Config" buffer whether or not anything is there, which
- is the case you ask in. (A right click on that row opens the file when
- there IS one. When there is not, the click has nothing to open and
- falls back to searching for the text — pardes will not create it for
- you.)
+ ~/Library/Application Support/pardes. `SPC f c` opens a refreshable
+ "+Config" buffer containing the resolved path and every live setting:
+ theme, shell, requested/effective font, panel/scene effects, hover
+ delay, and the ordinary display toggles. The path is printed whether
+ or not anything is there, which is the case you ask in. (A right click
+ on that row opens the file when there IS one. When there is not, the
+ click has nothing to open and falls back to searching for the text —
+ pardes will not create it for you.)
+
+ Resting the pointer over look-able text for that reported delay paints
+ a quiet preview of the exact operand a right click would expand. It is
+ only a preview: it does not focus, select, move a cursor, or run Look.
+ Set `look_preview_delay_frames` to null in src/config.zig to compile it
+ out.
The format is one BUILTIN COMMAND per line, spelled exactly the way
you would execute it anywhere else:
Theme gruvbox_dark_hard
Shell zsh
+ TaglineSize 70
Tagbottom
Blank, unknown or malformed lines are ignored in silence, and a bad
@@ -1096,7 +1128,7 @@ abc
The browser build reads no such file, has no ptys, and has no language
backend compiled in. It is not without a filesystem, though: LOOK
- there resolves against a read-only archive of the tracked .zig sources
+ there resolves against a read-only archive of working-tree .zig sources
that was generated when the build was made. What you get is a dump,
replayed, with those sources openable. (The core still ASKS for the
rest. A host that answers gets terminals; that is the seam, not a
@@ -1115,7 +1147,7 @@ abc
top bar = New Newcol Find Grep Help Tutor Dump ... Debug Kill
(keyboard: k off the topmost tagline)
WEB TOUCH: one-finger tap = LOOK; drag = natural scroll
- LOOK opens URLs or embedded tracked .zig source (read-only)
+ LOOK opens URLs or embedded working-tree .zig source (read-only)
published TTY dump lists every source; tree-sitter colors it
tag/separator start = left drag; touch HUD requires Debug
TTY: a terminal IS a pane; Ctrl-b (or Shift-Esc) toggles the shell,
diff --git a/src/user_config.zig b/src/user_config.zig
index 389188c1..aca8cbf3 100644
--- a/src/user_config.zig
+++ b/src/user_config.zig
@@ -1,7 +1,7 @@
//! Native startup configuration discovery and loading.
//!
//! The file is named `pardes` inside the platform's per-user configuration
-//! directory. Discovery is deliberately outside the sans-IO core: native
+//! directory. Discovery is deliberately launcher-owned: native
//! launchers opt in by putting the bytes in `Options.startup_config`, while
//! tests and the browser keep the default `null`.
diff --git a/src/web.zig b/src/web.zig
index 637fa56f..ebcc9546 100644
--- a/src/web.zig
+++ b/src/web.zig
@@ -12,6 +12,7 @@ const gpa = std.heap.wasm_allocator;
const embedded_dump = @embedFile("embedded.dump.zon");
const input_capacity = 64 * 1024;
const c_alloc_header_size = 16;
+const attr_tagline: u16 = 1 << 7;
comptime {
if (c_alloc_header_size < @sizeOf(usize)) @compileError("C allocation header is too small");
@@ -176,6 +177,10 @@ export fn pardes_input_capacity() u32 {
return input_capacity;
}
+export fn pardes_gui_tagline_font_percent() u32 {
+ return pardes.config.gui_tagline_font_percent;
+}
+
export fn pardes_resize(cols_arg: u32, rows_arg: u32) void {
const s = &(state orelse return);
const cols: u16 = @intCast(std.math.clamp(cols_arg, 1, std.math.maxInt(u16)));
@@ -304,6 +309,11 @@ export fn pardes_touch_scroll(delta: f32) void {
s.core.update(.{ .touch_scroll = delta });
}
+export fn pardes_pointer_leave() void {
+ const s = &(state orelse return);
+ s.core.update(.pointer_leave);
+}
+
export fn pardes_tick() void {
const s = &(state orelse return);
s.core.update(.tick);
@@ -506,6 +516,7 @@ fn encodeAttrs(style: pardes.CellStyle) u16 {
attrs |= @as(u16, @intFromBool(style.reverse)) << 4;
attrs |= @as(u16, @intFromBool(style.invisible)) << 5;
attrs |= @as(u16, @intFromBool(style.strikethrough)) << 6;
+ attrs |= @as(u16, @intFromBool(style.font_role == .tagline)) * attr_tagline;
attrs |= @as(u16, @intFromEnum(style.ul)) << 8;
return attrs;
}
diff --git a/src/web/app.mjs b/src/web/app.mjs
index d7a4b6f6..631c6067 100644
--- a/src/web/app.mjs
+++ b/src/web/app.mjs
@@ -1,4 +1,7 @@
const CELL_SIZE = 20;
+const ATTR_TAGLINE = 1 << 7;
+const ANIMATION_FRAME_MS = 1000 / 60;
+const MAX_ANIMATION_TICKS = 4;
const DEFAULT_FG = "#cccccc";
const DEFAULT_BG = "#121212";
const decoder = new TextDecoder();
@@ -31,6 +34,9 @@ export class DomRenderer {
this.cols = 0;
this.rowCount = 0;
this.snapshot = null;
+ const taglinePercent = this.wasm.pardes_gui_tagline_font_percent();
+ if (taglinePercent < 1 || taglinePercent > 100) throw new Error(`bad tagline font percentage ${taglinePercent}`);
+ this.root.style.setProperty("--pardes-tagline-font-size", `${taglinePercent}%`);
}
render() {
@@ -117,6 +123,7 @@ export class PardesRuntime {
this.running = false;
this.pointer = null;
this.frameHandle = 0;
+ this.animationClock = { last: null, lag: 0 };
this.bindEvents();
}
@@ -130,7 +137,8 @@ export class PardesRuntime {
this.renderer.render();
this.drainEffects();
this.running = true;
- this.frameHandle = requestAnimationFrame(() => this.frame());
+ this.animationClock = { last: null, lag: 0 };
+ this.frameHandle = requestAnimationFrame((timestamp) => this.frame(timestamp));
this.root.focus({ preventScroll: true });
}
@@ -140,12 +148,14 @@ export class PardesRuntime {
this.wasm.pardes_deinit();
}
- frame() {
+ frame(timestamp) {
if (!this.running) return;
- this.wasm.pardes_tick();
+ for (let ticks = animationTicks(this.animationClock, timestamp); ticks > 0; ticks -= 1) {
+ this.wasm.pardes_tick();
+ }
this.renderer.render();
this.drainEffects();
- this.frameHandle = requestAnimationFrame(() => this.frame());
+ this.frameHandle = requestAnimationFrame((nextTimestamp) => this.frame(nextTimestamp));
}
measure() {
@@ -299,6 +309,7 @@ export class PardesRuntime {
this.root.addEventListener("pointermove", (event) => this.pointerMove(event));
this.root.addEventListener("pointerup", (event) => this.pointerUp(event));
this.root.addEventListener("pointercancel", (event) => this.pointerUp(event));
+ this.root.addEventListener("pointerleave", () => this.wasm.pardes_pointer_leave());
this.root.addEventListener("wheel", (event) => this.wheel(event), { passive: false });
globalThis.addEventListener?.("resize", () => this.resize());
}
@@ -342,6 +353,25 @@ export class PardesRuntime {
}
}
+/// Spend monotonic requestAnimationFrame time as fixed 60 Hz core ticks. A
+/// high-refresh display therefore renders repeated states rather than making
+/// frame-count transitions run twice as fast. Long suspension is capped so a
+/// returning tab cannot execute an unbounded loop before drawing.
+export function animationTicks(clock, timestamp) {
+ if (!Number.isFinite(timestamp)) return 0;
+ if (clock.last === null || timestamp < clock.last) {
+ clock.last = timestamp;
+ clock.lag = 0;
+ return 0;
+ }
+ const elapsed = Math.min(timestamp - clock.last, ANIMATION_FRAME_MS * MAX_ANIMATION_TICKS);
+ clock.last = timestamp;
+ clock.lag += elapsed;
+ const ticks = Math.min(MAX_ANIMATION_TICKS, Math.floor((clock.lag + 1e-7) / ANIMATION_FRAME_MS));
+ clock.lag -= ticks * ANIMATION_FRAME_MS;
+ return ticks;
+}
+
export async function instantiate(url = "./pardes.wasm") {
const response = await fetch(url);
if (!response.ok) throw new Error(`fetch ${url}: HTTP ${response.status}`);
@@ -386,6 +416,7 @@ function applyStyle(cell, fgCode, bgCode, attrs, cursor, cursorBar) {
cell.classList.toggle("is-cursor", cursor);
cell.classList.toggle("is-bar", cursor && cursorBar);
cell.classList.toggle("is-blinking", Boolean(attrs & (1 << 3)));
+ cell.classList.toggle("is-tagline", Boolean(attrs & ATTR_TAGLINE));
}
function color(encoded, fallback) {
diff --git a/src/web/pardes.css b/src/web/pardes.css
index a959e6c3..d523ec5c 100644
--- a/src/web/pardes.css
+++ b/src/web/pardes.css
@@ -58,6 +58,14 @@ body {
white-space: pre;
}
+/* The cell stays body-sized; only the glyph changes. The inherited fixed
+ line-height centers the smaller face vertically, and one glyph remains
+ centered in its original column just like the native atlas backends. */
+.pardes-cell.is-tagline {
+ font-size: var(--pardes-tagline-font-size, 100%);
+ text-align: center;
+}
+
.pardes-cell.is-cursor {
color: var(--cell-bg) !important;
background: var(--cell-fg) !important;
diff --git a/test/hxdiff.zig b/test/hxdiff.zig
index 599390d6..94ee226e 100644
--- a/test/hxdiff.zig
+++ b/test/hxdiff.zig
@@ -1,5 +1,5 @@
// pardes-hxdiff: the pardes half of the helix differential harness. Drives
-// the sans-IO core (src/pardes.zig) headlessly over JSON-Lines cases and
+// the core (src/pardes.zig) headlessly over JSON-Lines cases and
// prints one contract result line per case on stdout, input order — the same
// case/result contract helix's hx-harness speaks (pardes-harness branch in
// the genizah checkout), so the two editors diff key-for-key.
diff --git a/test/image_harness.zig b/test/image_harness.zig
index 601bce49..21ec706e 100644
--- a/test/image_harness.zig
+++ b/test/image_harness.zig
@@ -27,6 +27,9 @@ const fixture_rgb = [3]u8{ 13, 77, 231 };
// filtered-tint results for the fixture's saturated red and green pages.
const pdf_filtered_red = [3]u8{ 255, 77, 77 };
const pdf_filtered_green = [3]u8{ 0, 162, 0 };
+const pdf_filtered_blue = [3]u8{ 147, 147, 255 };
+
+const PdfFirstPage = enum { red, blue };
pub fn main(init: std.process.Init) !void {
var arena_state: std.heap.ArenaAllocator = .init(gpa);
@@ -101,9 +104,10 @@ fn appendFmt(out: *BoundedPdf, comptime fmt: []const u8, args: anytype) !void {
/// trailing pages make skipped native renders visible in Kitty's transmit
/// headers. Building it here keeps the backend test independent of mutool,
/// fonts, and checked-in binary fixtures while the xref offsets remain exact.
-fn writePdf(arena: std.mem.Allocator, path: [:0]const u8) !void {
+fn writePdf(arena: std.mem.Allocator, path: [:0]const u8, first_page: PdfFirstPage) !void {
_ = arena;
- const first_stream =
+ const first_stream = switch (first_page) {
+ .red =>
\\1 0 0 rg
\\0 0 72 48 re
\\f
@@ -113,7 +117,19 @@ fn writePdf(arena: std.mem.Allocator, path: [:0]const u8) !void {
\\4 20 Td
\\(FIRST NEEDLE) Tj
\\ET
- ;
+ ,
+ .blue =>
+ \\0 0 1 rg
+ \\0 0 72 48 re
+ \\f
+ \\0 0 0 rg
+ \\BT
+ \\/F1 10 Tf
+ \\4 20 Td
+ \\(FIRST NEEDLE) Tj
+ \\ET
+ ,
+ };
const second_stream =
\\0 1 0 rg
\\0 0 72 48 re
@@ -257,10 +273,10 @@ fn runKittyPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8)
// Keep the generated location token short enough that the 38-column PDF
// pane can visibly include each outline title after `path:page:ordinal`.
const pdf_path = try std.fmt.allocPrintSentinel(arena, "/tmp/p{d}.pdf", .{libc.getpid()}, 0);
- try writePdf(arena, pdf_path);
+ try writePdf(arena, pdf_path, .red);
defer _ = libc.unlink(pdf_path);
- var h = try eh.Harness.initArgs(gpa, exe, 16, 80, pdf_path);
+ var h = try eh.Harness.initArgs(gpa, exe, 16, 40, pdf_path);
defer h.deinit();
try h.pump(150);
try h.expectRawContains("\x1b_Gi=1,a=q\x1b\\", "app did not issue the Kitty graphics capability query");
@@ -334,9 +350,13 @@ fn runKittyPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8)
// A viewport step crosses the boundary continuously: page one leaves and
// is deleted, page two remains cached, and newly intersecting page three
- // is transmitted. Distinct fixture heights identify the latter on wire.
+ // is transmitted as the 64-row grain covering its visible head. The new
+ // post-step transmit identifies that page without requiring an offscreen
+ // whole-page raster during a viewport-sized fling.
const page_step_start = h.raw.items.len;
- const page_three_transmit = "\x1b_Gf=32,s=96,v=67,i=";
+ const all_transmits = "\x1b_Gf=32,s=";
+ const transmits_before_step = std.mem.count(u8, h.raw.items, all_transmits);
+ const page_three_transmit = "\x1b_Gf=32,s=96,v=64,i=";
try h.send("\x06");
try h.expectWaitFor("pdf 2/4 width PdfFit", 5000, "continuous page-down did not enter PDF page two");
const page_step_deadline = eh.nowMs() + 5000;
@@ -347,29 +367,31 @@ fn runKittyPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8)
return error.KittyPdfOffscreenPageNotReleased;
if (std.mem.indexOf(u8, h.raw.items[page_step_start..], page_three_transmit) == null)
return error.KittyPdfNewVisiblePageMissing;
- if (std.mem.count(u8, h.raw.items, transmit) != initial_transmits)
+ if (std.mem.count(u8, h.raw.items, all_transmits) != transmits_before_step + 1)
return error.KittyPdfRetainedNeighborRetransmitted;
// Search still addresses semantic pages inside the strip. Returning one
- // viewport, then revealing page two's hit, creates a decorated revision.
+ // viewport, then submitting the bare search selects and Looks its first
+ // hit through the shared walk, creating a decorated page-two revision.
try h.send("\x02");
try h.expectWaitFor("pdf 1/4 width PdfFit", 5000, "continuous page-up did not return to PDF page one");
+ const transmits_before_search = std.mem.count(u8, h.raw.items, all_transmits);
try h.send("/SECOND\r");
const search_row = try std.fmt.allocPrint(arena, "{s}:2:1 SECOND", .{std.fs.path.basename(pdf_path)});
try h.expectWaitFor(search_row, 5000, "PDF search did not preserve the exact page-two query occurrence");
- const before_look = std.mem.count(u8, h.raw.items, transmit);
- try h.send("n");
const look_deadline = eh.nowMs() + 5000;
while (eh.nowMs() < look_deadline) {
_ = try h.pumpOnce(50);
- if (std.mem.count(u8, h.raw.items, transmit) > before_look and h.contains("pdf 2/4 width PdfFit")) break;
+ if (std.mem.count(u8, h.raw.items, all_transmits) > transmits_before_search and
+ h.contains("pdf 2/4 width PdfFit")) break;
}
- try h.expectContains("pdf 2/4 width PdfFit", "n did not reveal the PDF search result at page two");
- if (std.mem.count(u8, h.raw.items, transmit) <= before_look) return error.KittyPdfLookRetransmitMissing;
+ try h.expectContains("pdf 2/4 width PdfFit", "bare search did not reveal its first PDF hit at page two");
+ if (std.mem.count(u8, h.raw.items, all_transmits) <= transmits_before_search)
+ return error.KittyPdfLookRetransmitMissing;
// PdfFit changes only placement. The active decorated raster remains the
// same terminal image while height fit supplies an explicit row extent.
- const before_fit = std.mem.count(u8, h.raw.items, transmit);
+ const before_fit = std.mem.count(u8, h.raw.items, all_transmits);
const fit_raw_start = h.raw.items.len;
try h.send(" tz");
try h.expectWaitFor("pdf 2/4 height PdfFit", 5000, "PdfFit did not expose height fit");
@@ -382,12 +404,12 @@ fn runKittyPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8)
const height_crop_w = kittyParam(height_apc, 'w') orelse return error.KittyPdfHeightPlacementWrong;
if (height_crop_w == 0 or height_crop_w > 96 or kittyParam(height_apc, 'h') != 64)
return error.KittyPdfHeightPlacementWrong;
- if (std.mem.count(u8, h.raw.items, transmit) != before_fit)
+ if (std.mem.count(u8, h.raw.items, all_transmits) != before_fit)
return error.KittyPdfFitRetransmitted;
// The generated PDF carries a real nested outline. PdfSections must show
- // all three DFS rows in its output pane while leaving n/N ownership on
- // the PDF. The first two destinations share page one but have distinct
+ // all three DFS rows in its output pane. n/N only select rows; Enter Looks
+ // the selected destination. The first two share page one but have distinct
// page-space y coordinates; their placement APCs must differ while the
// second reveal reuses exactly the rasters made visible by the first.
try h.send(" ts");
@@ -396,8 +418,8 @@ fn runKittyPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8)
try h.expectContains("Page two", "PdfSections did not expose the other-page outline row");
const upper_start = h.raw.items.len;
- try h.send("n");
- try h.expectWaitFor("pdf 1/4 height PdfFit", 5000, "first PdfSections n did not reveal page one");
+ try h.send("n\r");
+ try h.expectWaitFor("pdf 1/4 height PdfFit", 5000, "first PdfSections n+Look did not reveal page one");
const upper_deadline = eh.nowMs() + 3000;
while (eh.nowMs() < upper_deadline and kittyPlacementApc(h.raw.items, upper_start) == null)
_ = try h.pumpOnce(50);
@@ -408,8 +430,8 @@ fn runKittyPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8)
const same_page_transmits = std.mem.count(u8, h.raw.items, "\x1b_Gf=32,");
const lower_start = h.raw.items.len;
- try h.send("n");
- try h.expectWaitFor("pdf 1/4 height PdfFit", 5000, "nested PdfSections n left page one");
+ try h.send("n\r");
+ try h.expectWaitFor("pdf 1/4 height PdfFit", 5000, "nested PdfSections n+Look left page one");
const lower_deadline = eh.nowMs() + 3000;
while (eh.nowMs() < lower_deadline and kittyPlacementApc(h.raw.items, lower_start) == null)
_ = try h.pumpOnce(50);
@@ -420,12 +442,32 @@ fn runKittyPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8)
if (std.mem.count(u8, h.raw.items, "\x1b_Gf=32,") != same_page_transmits)
return error.KittyPdfSectionsSamePageRetransmitted;
- try h.send("n");
- try h.expectWaitFor("pdf 2/4 height PdfFit", 5000, "third PdfSections n did not reveal page two");
- try h.send("N");
- try h.expectWaitFor("pdf 1/4 height PdfFit", 5000, "PdfSections N did not return to the nested page-one row");
+ try h.send("n\r");
+ try h.expectWaitFor("pdf 2/4 height PdfFit", 5000, "third PdfSections n+Look did not reveal page two");
+ try h.send("N\r");
+ try h.expectWaitFor("pdf 1/4 height PdfFit", 5000, "PdfSections N+Look did not return to the nested page-one row");
- std.debug.print("PDF harness ok: Kitty continuous cache + search/fit + nested PdfSections n/N\n", .{});
+ // Editor-style save: build a same-sized blue first page on another inode,
+ // atomically rename it over the live pathname, and send NO input. The
+ // inotify wake alone must reopen MuPDF and transmit a fresh raster.
+ const replacement_path = try std.fmt.allocPrintSentinel(arena, "{s}.replacement", .{pdf_path}, 0);
+ defer _ = libc.unlink(replacement_path);
+ try writePdf(arena, replacement_path, .blue);
+ try h.pump(250);
+ const before_reload = std.mem.count(u8, h.raw.items, all_transmits);
+ try h.pump(250);
+ if (std.mem.count(u8, h.raw.items, all_transmits) != before_reload)
+ return error.KittyPdfNavigationDidNotSettle;
+ if (libc.rename(replacement_path, pdf_path) != 0) return error.PdfLiveRenameFailed;
+ const reload_deadline = eh.nowMs() + 5000;
+ while (eh.nowMs() < reload_deadline and
+ std.mem.count(u8, h.raw.items, all_transmits) <= before_reload)
+ _ = try h.pumpOnce(50);
+ if (std.mem.count(u8, h.raw.items, all_transmits) <= before_reload)
+ return error.KittyPdfLiveReloadMissing;
+ try h.expectWaitFor("reloaded", 5000, "PDF inotify reload did not report a committed transaction");
+
+ std.debug.print("PDF harness ok: Kitty continuous cache + search/fit + sections + idle live reload\n", .{});
}
fn kittyPlacementApc(raw: []const u8, start: usize) ?[]const u8 {
@@ -498,7 +540,7 @@ fn runGui(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !void
fn runGuiPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !void {
const pdf_path = try std.fmt.allocPrintSentinel(arena, "{s}/pages.pdf", .{base}, 0);
const capture_path = try std.fmt.allocPrintSentinel(arena, "{s}/latest.ppm", .{base}, 0);
- try writePdf(arena, pdf_path);
+ try writePdf(arena, pdf_path, .red);
_ = libc.unlink(capture_path);
_ = setenv("PARDES_TEST", "1", 1);
_ = setenv("PARDES_TEST_CAPTURE_DIR", base, 1);
@@ -532,7 +574,10 @@ fn runGuiPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !v
fit_width.width() - fit_height.width()
else
fit_height.width() - fit_width.width();
- if (fit_height.height() < fit_width.height() * 2 or width_delta > 8) {
+ // A positional PDF now owns the full single column, so width fit already
+ // occupies much of this short test window. Height fit must still extend
+ // the colored page by at least a third while preserving its clipped width.
+ if (fit_height.height() * 3 < fit_width.height() * 4 or width_delta > 8) {
std.debug.print("SDL PDF fit extents did not change as expected: width={any}, height={any}\n", .{ fit_width, fit_height });
return error.SdlPdfFitGeometryWrong;
}
@@ -554,7 +599,8 @@ fn runGuiPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !v
const final_pixels = ppmPixels(final_capture) orelse return error.BadCapture;
if (countColor(final_pixels, pdf_filtered_red) != 0) return error.SdlPdfOffscreenPageStillDrawn;
- // Exercise the actual PdfSections output + Look stepping path in SDL too.
+ // Exercise the actual PdfSections output + n/N selection + Look path in
+ // SDL too.
// The first two nested outline rows stay on red page one at different y
// positions, the third moves to green page two, and N returns exactly to
// the nested page-one destination.
@@ -565,19 +611,37 @@ fn runGuiPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !v
try h.send(" ts");
_ = try waitForCaptureBounds(&h, capture_path, pdf_filtered_red, 500, 5000);
_ = libc.unlink(capture_path);
- try h.send("n");
+ try h.send("n\r");
_ = try waitForCaptureBounds(&h, capture_path, pdf_filtered_red, 200, 5000);
_ = libc.unlink(capture_path);
- try h.send("n");
+ try h.send("n\r");
_ = try waitForCaptureBounds(&h, capture_path, pdf_filtered_red, 100, 5000);
_ = libc.unlink(capture_path);
- try h.send("n");
+ try h.send("n\r");
_ = try waitForCaptureBounds(&h, capture_path, pdf_filtered_green, 200, 5000);
_ = libc.unlink(capture_path);
- try h.send("N");
+ try h.send("N\r");
_ = try waitForCaptureBounds(&h, capture_path, pdf_filtered_red, 100, 5000);
- std.debug.print("PDF harness ok: SDL continuous strip + nested PdfSections red->green->red ({d}->{d} pixels)\n", .{ fit_width.count, green.count });
+ // The GUI host receives the same parent-directory rename while idle. A
+ // newly captured blue first page proves the wake reached MuPDF, replaced
+ // the pane raster, and reached the actual GPU output without a key event.
+ const replacement_path = try std.fmt.allocPrintSentinel(arena, "{s}/pages.replacement.pdf", .{base}, 0);
+ defer _ = libc.unlink(replacement_path);
+ try writePdf(arena, replacement_path, .blue);
+ _ = libc.unlink(capture_path);
+ if (libc.rename(replacement_path, pdf_path) != 0) return error.PdfLiveRenameFailed;
+ const blue = try waitForCaptureBounds(&h, capture_path, pdf_filtered_blue, 100, 5000);
+ const reloaded_capture = try readFile(gpa, capture_path);
+ defer gpa.free(reloaded_capture);
+ const reloaded_pixels = ppmPixels(reloaded_capture) orelse return error.BadCapture;
+ if (countColor(reloaded_pixels, pdf_filtered_red) != 0)
+ return error.SdlPdfLiveReloadRetainedOldPixels;
+
+ std.debug.print(
+ "PDF harness ok: SDL continuous strip + sections + idle red->blue reload ({d}->{d}->{d} pixels)\n",
+ .{ fit_width.count, green.count, blue.count },
+ );
}
const ColorBounds = struct {
diff --git a/test/macos-snapshots/boot.golden b/test/macos-snapshots/boot.golden
index 1c971b3f..fa94142f 100644
--- a/test/macos-snapshots/boot.golden
+++ b/test/macos-snapshots/boot.golden
@@ -56,3 +56,4 @@
|
|
== draw resized 850x495 nonblank
+== draw panels 850x495 nonblank
diff --git a/test/macos-snapshots/boot.snap b/test/macos-snapshots/boot.snap
index e3d4ba14..9adcf95c 100644
--- a/test/macos-snapshots/boot.snap
+++ b/test/macos-snapshots/boot.snap
@@ -21,3 +21,11 @@ resize 30 100
stable 700 20000
snap resized
draw resized
+
+# The panel ABI and the runtime Metal path in one small assertion. Newcol
+# changes the existing pane and opens a second one; paint order must be moving
+# then opening, and draw compiles/runs the shader against the whole frame.
+command PanelSlide
+command Newcol
+tracks moving:slide opening:slide
+draw-effect panels
diff --git a/test/macos-snapshots/font.snap b/test/macos-snapshots/font.snap
index 71049b55..7fa04ebe 100644
--- a/test/macos-snapshots/font.snap
+++ b/test/macos-snapshots/font.snap
@@ -9,6 +9,7 @@
start 24 100
wait 8000 $
stable 700 20000
+font-size 14
snap boot
# Menlo ships with every macOS and ships inside a .ttc, so this is also the
@@ -18,6 +19,7 @@ snap boot
command Font Menlo
stable 700 20000
font Menlo-Regular
+font-size 14
snap menlo
# A name that resolves to nothing must leave the screen alone rather than
@@ -34,16 +36,19 @@ snap unchanged
zoom 4
stable 700 20000
font Menlo-Regular
+font-size 18
snap bigger
# ...and back, which must land on exactly the grid it started from — the whole
# point of "actual size" being a fixed number rather than an undo stack.
zoom reset
stable 700 20000
+font-size 14
snap reset
# Small enough to be worth checking the clamp does not run away: the range
# floor is 6pt, so this asks for far past it and must simply stop.
zoom -40
stable 700 20000
+font-size 6
snap floor
diff --git a/test/macos_e2e.swift b/test/macos_e2e.swift
index 16992271..93019403 100644
--- a/test/macos_e2e.swift
+++ b/test/macos_e2e.swift
@@ -91,6 +91,7 @@ private final class Driver: PardesViewDelegate {
private var booted = false
private var observer: NSObjectProtocol?
private var output = ""
+ private var nextAnimationFrame: Date?
/// The script's own file name, for error messages.
private let scriptName: String
@@ -201,6 +202,9 @@ private final class Driver: PardesViewDelegate {
// seeded below are the core's only notion of cell size.
view.delegate = self
pardesViewDidResize(view)
+ // The resize pump adopts a startup-config Font before this report.
+ // Match the app's ordering so Config sees the actual initial face.
+ view.reportEffectiveFont()
}
func shutdown() {
@@ -266,6 +270,19 @@ private final class Driver: PardesViewDelegate {
/// without which run(until:) returns instantly and this becomes a spin.
private func idle() {
RunLoop.current.run(until: Date().addingTimeInterval(pollInterval))
+ let now = Date()
+ guard pardes_animating() else {
+ nextAnimationFrame = nil
+ return
+ }
+ guard let due = nextAnimationFrame else {
+ nextAnimationFrame = now.addingTimeInterval(1.0 / 60.0)
+ return
+ }
+ guard now >= due else { return }
+ _ = pardes_animation_tick()
+ nextAnimationFrame = now.addingTimeInterval(1.0 / 60.0)
+ pump()
}
// MARK: - waiting
@@ -496,6 +513,16 @@ private final class Driver: PardesViewDelegate {
throw ScriptError(message: "wearing \(view.faceName.debugDescription), wanted \(rest.debugDescription)")
}
+ // The host-owned half of Config's font tuple. Unlike a grid snapshot,
+ // this observes the point size CoreText is actually wearing and catches
+ // a zoom which resized cells but forgot to acknowledge runtime state.
+ case "font-size":
+ let view = try live()
+ let want = try double(args, 0, "font-size points")
+ guard abs(Double(view.effectivePointSize) - want) < 0.005 else {
+ throw ScriptError(message: "wearing \(view.effectivePointSize)pt, wanted \(want)pt")
+ }
+
// Cmd+ / Cmd- / Cmd+0, minus the menu. What the grid does afterwards
// is the assertion: a bigger cell fits fewer columns in the same
// window, so a zoom that changed nothing shows up as a golden that
@@ -641,7 +668,8 @@ private final class Driver: PardesViewDelegate {
// by the RELEASE SPEED, and a script that could only turn the dial
// could never throw it — NSEvent phases have no public constructor, so
// this entry point is the only way the momentum path is reachable at
- // all. The coast is then spent by the pump, at one fixed step per tick.
+ // all. The coast is then spent by idle's display clock, at one fixed
+ // step per animation frame; ordinary pumps do not move it.
case "rotate_end":
let view = try live()
view.rotateEnd()
@@ -723,6 +751,45 @@ private final class Driver: PardesViewDelegate {
guard !rest.isEmpty else { throw ScriptError(message: "draw needs a label") }
try draw(rest)
+ case "draw-effect":
+ let view = try live()
+ guard !rest.isEmpty else { throw ScriptError(message: "draw-effect needs a label") }
+ try draw(rest)
+ guard view.lastFrameUsedPostprocessor else {
+ throw ScriptError(message: "draw-effect \(rest): runtime Metal postprocess was bypassed or failed")
+ }
+
+ // Snapshot and validate the plain panel-track ABI. A subsequent draw
+ // drives those exact records through the runtime-compiled Metal kernel.
+ case "tracks":
+ _ = try live()
+ _ = pardes_frame()
+ let count = Int(pardes_frame_panel_tracks())
+ guard count == args.count else {
+ throw ScriptError(message: "got \(count) panel tracks, wanted \(args.count): \(rest)")
+ }
+ guard count == 0 || pardes_frame_panel_track_list() != nil else {
+ throw ScriptError(message: "panel track count is \(count), but its list is nil")
+ }
+ if let list = pardes_frame_panel_track_list() {
+ for i in 0..<count {
+ let track = list[i]
+ let phase = track.phase == UInt8(PARDES_PANEL_MOVING) ? "moving" :
+ track.phase == UInt8(PARDES_PANEL_OPENING) ? "opening" : "unknown"
+ let effect: String = switch track.effect {
+ case UInt8(PARDES_PANEL_SLIDE): "slide"
+ case UInt8(PARDES_PANEL_ZOOM): "zoom"
+ case UInt8(PARDES_PANEL_DISSOLVE): "dissolve"
+ case UInt8(PARDES_PANEL_ASCII): "ascii"
+ default: "off"
+ }
+ let got = "\(phase):\(effect)"
+ guard got == args[i] else {
+ throw ScriptError(message: "track \(i) is \(got), wanted \(args[i])")
+ }
+ }
+ }
+
default:
throw ScriptError(message: "unknown command \(cmd)")
}
diff --git a/test/pdf_sections_bench.zig b/test/pdf_sections_bench.zig
index 9d44a3dd..baf89443 100644
--- a/test/pdf_sections_bench.zig
+++ b/test/pdf_sections_bench.zig
@@ -173,7 +173,7 @@ fn measureCachedReopen(path: []const u8, config: Config) !Result {
// memory clobber makes each production call's reads and writes
// independently observable to the optimizer.
compilerBarrier();
- core.openPdfSections(0);
+ pardes.pdf_pane.openSections(core, 0);
compilerBarrier();
observed +%= observeReopen(prepared);
}
@@ -207,7 +207,7 @@ fn measureCachedControl(path: []const u8, config: Config) !Result {
observed +%= observeReopen(prepared);
}
const elapsed = nowNs() -| started;
- core.openPdfSections(0);
+ pardes.pdf_pane.openSections(core, 0);
var identity = sectionsOutputIdentity(core);
identity.checksum = mix(identity.checksum, observed);
try verifyIdentity("sections-perturb-observe-control", &expected, identity, round);
@@ -269,7 +269,7 @@ fn preparedCore(path: []const u8) !*pardes.Pardes {
const core = try pardes.Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 });
errdefer core.deinit();
drainEffects(core);
- core.openPdfSections(0);
+ pardes.pdf_pane.openSections(core, 0);
drainEffects(core);
_ = sectionsOutputIdentity(core);
return core;
diff --git a/test/perf.zig b/test/perf.zig
index f440227d..1387a1ac 100644
--- a/test/perf.zig
+++ b/test/perf.zig
@@ -5,7 +5,7 @@
//! zig build perf -- --base old.json -- table + a before/after delta column
//! zig build perf -- --reps 60 -- more samples (default 25)
//!
-//! It drives the sans-IO core directly — feed an Event, drain the effects,
+//! It drives the core directly — feed an Event, drain the effects,
//! call render — exactly like test/hxdiff.zig, so there is no pty, no shell
//! and no scheduler between the clock and the code under test. A run is
//! self-contained: the fixtures are generated into /tmp/pardes-perf here and
diff --git a/test/snapshot.zig b/test/snapshot.zig
index c3611291..18fc2093 100644
--- a/test/snapshot.zig
+++ b/test/snapshot.zig
@@ -4,7 +4,7 @@
// the old-vs-new parity oracle for the rewrite.
//
// zig build snap run all snapshots/*.snap, diff goldens
-// zig build snap -- --update regenerate goldens from this binary
+// zig build snap -- --update regenerate goldens serially
// zig build snap -- snapshots/boot.snap one script
// zig build snap -- --jobs=1 serial (default: 1.25 per cpu)
// zig build snap -- --trace-stable show what every `stable` waited on
@@ -105,6 +105,13 @@ pub fn main(init: std.process.Init) !void {
script_count += 1;
}
}
+ // Updating is the one run whose output becomes evidence instead of being
+ // checked against it. The ordinary parallel pass can reject a transient
+ // capture and retry it alone; update mode has no expected bytes to compare,
+ // so it cannot know which capture needs that retry. Run it serially even if
+ // --jobs was supplied: a slower intentional baseline refresh is cheaper
+ // than blessing scheduler or shared-process startup noise as behavior.
+ if (update) jobs = 1;
var scripts = try arena.alloc([]const u8, script_count);
var script_i: usize = 0;
var saw_exe = false;
@@ -262,6 +269,13 @@ fn runOne(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const u8
}
const base_path = script_path[0 .. script_path.len - ".snap".len];
const golden_path = std.fmt.allocPrint(arena, "{s}.golden", .{base_path}) catch return 1;
+ // `.actual` means THIS invocation failed. Passing runs used to leave an
+ // ignored file from an older failure behind, making a clean suite look
+ // dirty to every manual `cmp` audit. Unlink only this script's exact path
+ // before it starts; a new mismatch below writes it back.
+ const actual_path = std.fmt.allocPrint(arena, "{s}.actual", .{base_path}) catch return 1;
+ const actual_path_z = arena.dupeZ(u8, actual_path) catch return 1;
+ _ = libc.unlink(actual_path_z);
const golden: ?[]u8 = readFileAlloc(arena, golden_path) catch null;
const started = eh.nowMs();
const out = runScript(arena, exe_z, script_path, stem) catch |e| {
@@ -282,7 +296,6 @@ fn runOne(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const u8
std.debug.print("PASS {s} ({d}ms)\n", .{ stem, took });
return 0;
}
- const actual_path = std.fmt.allocPrint(arena, "{s}.actual", .{base_path}) catch return 1;
eh.writeFile(arena.dupeZ(u8, actual_path) catch return 1, out) catch {};
std.debug.print("FAIL {s}: differs from golden (actual written to {s})\n", .{ stem, actual_path });
printFirstDiff(golden.?, out);
diff --git a/test/snapshots/builtins.golden b/test/snapshots/builtins.golden
index 113141d8..10fa04db 100644
--- a/test/snapshots/builtins.golden
+++ b/test/snapshots/builtins.golden
@@ -100,26 +100,26 @@
| 6 SPC P ClipPasteBefore
| 7 SPC R ClipReplace
| 8 SPC Y ClipYankMain
-| 9 SPC c d Delcol
-| 10 SPC c n Newcol topbar
-| 11 SPC d Del
-| 12 SPC f c Config
-| 13 SPC f f Find topbar
-| 14 SPC f g Grep topbar
-| 15 SPC f n New topbar
-| 16 SPC f s Save
-| 17 SPC h t Tutor topbar
-| 18 SPC j i Forward C-i
-| 19 SPC j j Last
-| 20 SPC j l Jumplist
-| 21 SPC j o Back C-o
-| 22 SPC k Kill topbar
-| 23 SPC l D WsDiagnostics
-| 24 SPC l S WsSymbols
-| 25 SPC l a CodeAction
-| 26 SPC l d Diagnostics
-| 27 SPC l h SelectRefs
-| 28 SPC l i Lspinfo
+| 9 SPC a a PanelAscii
+| 10 SPC a d PanelDissolve
+| 11 SPC a s PanelSlide
+| 12 SPC a v PanelVertical
+| 13 SPC a z PanelZoom
+| 14 SPC c d Delcol
+| 15 SPC c n Newcol topbar
+| 16 SPC d Del
+| 17 SPC f c Config
+| 18 SPC f f Find topbar
+| 19 SPC f g Grep topbar
+| 20 SPC f n New topbar
+| 21 SPC f s Save
+| 22 SPC h t Tutor topbar
+| 23 SPC j i Forward C-i
+| 24 SPC j j Last
+| 25 SPC j l Jumplist
+| 26 SPC j o Back C-o
+| 27 SPC k Kill topbar
+| 28 SPC l D WsDiagnostics
== snap index-tail grid=120x60 cursor=7,58
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
| /tmp/pardes-snap/builtins/cwd/notes.txt Save New Del
@@ -153,34 +153,34 @@
|
|
| /tmp/pardes-snap/builtins/cwd/+Help New Del
-| 32 SPC l w Lspwhy
-| 33 SPC p ClipPaste
-| 34 SPC s d Dump topbar
-| 35 SPC s r Restore
-| 36 SPC t a Ascii
-| 37 SPC t b Tagbottom
-| 38 SPC t c Colors
-| 39 SPC t d Debug topbar
-| 40 SPC t i PdfTint
-| 41 SPC t l Palette
-| 42 SPC t n NextColor topbar
-| 43 SPC t p Petscii
-| 44 SPC t r Crt
-| 45 SPC t s PdfSections
-| 46 SPC t t ThemeSel
-| 47 SPC t w Wrap
-| 48 SPC t z PdfFit
-| 49 SPC w h Left C-w h, C-w left
-| 50 SPC w j Down C-w j, C-w down
-| 51 SPC w k Up C-w k, C-w up
-| 52 SPC w l Right C-w l, C-w right
-| 53 SPC y ClipYank
-| 54 Look enter, right-click
-| 55 Exec tab, middle-click
-| 56 Msg
-| 57 Theme
-| 58 Shell
-| 59
+| 37 SPC l w Lspwhy
+| 38 SPC p ClipPaste
+| 39 SPC s d Dump topbar
+| 40 SPC s r Restore
+| 41 SPC t a Ascii
+| 42 SPC t b Tagbottom
+| 43 SPC t c Colors
+| 44 SPC t d Debug topbar
+| 45 SPC t i PdfTint
+| 46 SPC t l Palette
+| 47 SPC t n NextColor topbar
+| 48 SPC t p Petscii
+| 49 SPC t s PdfSections
+| 50 SPC t t ThemeSel
+| 51 SPC t w Wrap
+| 52 SPC t z PdfFit
+| 53 SPC w h Left C-w h, C-w left
+| 54 SPC w j Down C-w j, C-w down
+| 55 SPC w k Up C-w k, C-w up
+| 56 SPC w l Right C-w l, C-w right
+| 57 SPC y ClipYank
+| 58 Look enter, right-click
+| 59 Exec tab, middle-click
+| 60 Msg
+| 61 EffectCode
+| 62 Theme
+| 63 Shell
+| 64
== snap not-a-picker grid=120x60 cursor=7,58
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
| /tmp/pardes-snap/builtins/cwd/notes.txt Save New Del
@@ -214,34 +214,34 @@
|
|
| /tmp/pardes-snap/builtins/cwd/+Help New Del
-| 32 SPC l w Lspwhy
-| 33 SPC p ClipPaste
-| 34 SPC s d Dump topbar
-| 35 SPC s r Restore
-| 36 SPC t a Ascii
-| 37 SPC t b Tagbottom
-| 38 SPC t c Colors
-| 39 SPC t d Debug topbar
-| 40 SPC t i PdfTint
-| 41 SPC t l Palette
-| 42 SPC t n NextColor topbar
-| 43 SPC t p Petscii
-| 44 SPC t r Crt
-| 45 SPC t s PdfSections
-| 46 SPC t t ThemeSel
-| 47 SPC t w Wrap
-| 48 SPC t z PdfFit
-| 49 SPC w h Left C-w h, C-w left
-| 50 SPC w j Down C-w j, C-w down
-| 51 SPC w k Up C-w k, C-w up
-| 52 SPC w l Right C-w l, C-w right
-| 53 SPC y ClipYank
-| 54 Look enter, right-click
-| 55 Exec tab, middle-click
-| 56 Msg
-| 57 Theme
-| 58 Shell
-| 59
+| 37 SPC l w Lspwhy
+| 38 SPC p ClipPaste
+| 39 SPC s d Dump topbar
+| 40 SPC s r Restore
+| 41 SPC t a Ascii
+| 42 SPC t b Tagbottom
+| 43 SPC t c Colors
+| 44 SPC t d Debug topbar
+| 45 SPC t i PdfTint
+| 46 SPC t l Palette
+| 47 SPC t n NextColor topbar
+| 48 SPC t p Petscii
+| 49 SPC t s PdfSections
+| 50 SPC t t ThemeSel
+| 51 SPC t w Wrap
+| 52 SPC t z PdfFit
+| 53 SPC w h Left C-w h, C-w left
+| 54 SPC w j Down C-w j, C-w down
+| 55 SPC w k Up C-w k, C-w up
+| 56 SPC w l Right C-w l, C-w right
+| 57 SPC y ClipYank
+| 58 Look enter, right-click
+| 59 Exec tab, middle-click
+| 60 Msg
+| 61 EffectCode
+| 62 Theme
+| 63 Shell
+| 64
== snap window-group grid=120x60 cursor=7,32
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
| /tmp/pardes-snap/builtins/cwd/notes.txt Save New Del
@@ -344,23 +344,23 @@
| 6 SPC P ClipPasteBefore
| 7 SPC R ClipReplace
| 8 SPC Y ClipYankMain
-| 9 SPC c d Delcol
-| 10 SPC c n Newcol topbar
-| 11 SPC d Del
-| 12 SPC f c Config
-| 13 SPC f f Find topbar
-| 14 SPC f g Grep topbar
-| 15 SPC f n New topbar
-| 16 SPC f s Save
-| 17 SPC h t Tutor topbar
-| 18 SPC j i Forward C-i
-| 19 SPC j j Last
-| 20 SPC j l Jumplist
-| 21 SPC j o Back C-o
-| 22 SPC k Kill topbar
-| 23 SPC l D WsDiagnostics
-| 24 SPC l S WsSymbols
-| 25 SPC l a CodeAction
-| 26 SPC l d Diagnostics
-| 27 SPC l h SelectRefs
-| 28 SPC l i Lspinfo
+| 9 SPC a a PanelAscii
+| 10 SPC a d PanelDissolve
+| 11 SPC a s PanelSlide
+| 12 SPC a v PanelVertical
+| 13 SPC a z PanelZoom
+| 14 SPC c d Delcol
+| 15 SPC c n Newcol topbar
+| 16 SPC d Del
+| 17 SPC f c Config
+| 18 SPC f f Find topbar
+| 19 SPC f g Grep topbar
+| 20 SPC f n New topbar
+| 21 SPC f s Save
+| 22 SPC h t Tutor topbar
+| 23 SPC j i Forward C-i
+| 24 SPC j j Last
+| 25 SPC j l Jumplist
+| 26 SPC j o Back C-o
+| 27 SPC k Kill topbar
+| 28 SPC l D WsDiagnostics
diff --git a/test/snapshots/builtins.snap b/test/snapshots/builtins.snap
index 3b8fb684..cf11e5a1 100644
--- a/test/snapshots/builtins.snap
+++ b/test/snapshots/builtins.snap
@@ -58,12 +58,12 @@ snap window-group
# a SCREEN coordinate, so every row inserted above `SPC h t` in the listing
# moves Tutor down one and this number with it. The five clipboard builtins
# put three there — SPC P, SPC R and SPC Y, uppercase sorting ahead of the
-# lowercase `SPC h` — which is why this reads 49 and not 46.
+# lowercase `SPC h`; the five panel effects now follow them as well.
key space ?
wait 10000 SPC c n
stable 700 15000
-press middle 20 49
-release middle 20 49
+press middle 20 54
+release middle 20 54
wait 10000 PARDES TUTOR
stable 700 15000
snap tutor-from-index
diff --git a/test/snapshots/chordcut.golden b/test/snapshots/chordcut.golden
index 16d007e2..13bc9cd7 100644
--- a/test/snapshots/chordcut.golden
+++ b/test/snapshots/chordcut.golden
@@ -1,6 +1,6 @@
-== snap opened grid=90x30 cursor=7,2
+== snap opened grid=120x30 cursor=7,2
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cw
+| /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cwd New Del
| 1 line 1 ls ls
| 2 line 2 f.txt g.txt f.txt g.txt
| 3 line 3
@@ -29,9 +29,9 @@
|
|
|
-== snap cut grid=90x30 cursor=7,3
+== snap cut grid=120x30 cursor=7,3
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cw
+| /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cwd New Del
| 1 line 1 ls ls
| 2 2 f.txt g.txt f.txt g.txt
| 3 line 3
@@ -60,9 +60,9 @@
|
|
|
-== snap copied grid=90x30 cursor=10,4
+== snap copied grid=120x30 cursor=10,4
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cw
+| /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cwd New Del
| 1 line 1 ls ls
| 2 2 f.txt g.txt f.txt g.txt
| 3 line 3
@@ -91,9 +91,9 @@
|
|
|
-== snap pointpaste grid=90x30 cursor=16,5
+== snap pointpaste grid=120x30 cursor=16,5
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cw
+| /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cwd New Del
| 1 line 1 ls ls
| 2 2 f.txt g.txt f.txt g.txt
| 3 line 3
@@ -122,9 +122,9 @@
|
|
|
-== snap replaced grid=90x30 cursor=11,6
+== snap replaced grid=120x30 cursor=11,6
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cw
+| /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cwd New Del
| 1 line 1 ls ls
| 2 2 f.txt g.txt f.txt g.txt
| 3 line 3
@@ -153,9 +153,9 @@
|
|
|
-== snap cancelexec grid=90x30 cursor=25,3
+== snap cancelexec grid=120x30 cursor=25,3
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cw
+| /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cwd New Del
| 1 line 1 ls ls
| 2 2echo B''OO g.txt f.txt g.txt f.txt g.txt
| 3 line 3
@@ -184,9 +184,9 @@
|
|
|
-== snap execafter grid=90x30 cursor=25,3
+== snap execafter grid=120x30 cursor=25,3
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cw
+| /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cwd New Del
| 1 line 1 ls ls
| 2 2echo B''OO g.txt f.txt g.txt f.txt g.txt
| 3 line 3
@@ -215,9 +215,9 @@
|
|
|
-== snap cancellook grid=90x30 cursor=25,3
+== snap cancellook grid=120x30 cursor=25,3
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cw
+| /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cwd New Del
| 1 line 1 ls ls
| 2 2echo B''OO g.txt f.txt g.txt f.txt g.txt
| 3 line 3
@@ -246,9 +246,9 @@
|
|
|
-== snap lookafter grid=90x30 cursor=7,17
+== snap lookafter grid=120x30 cursor=7,17
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cw
+| /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cwd New Del
| 1 line 1 ls ls
| 2 2echo B''OO g.txt f.txt g.txt f.txt g.txt
| 3 line 3
@@ -278,70 +278,70 @@
|
|
== style chordsel
-|0: 0-56 #949494,#121212, 57-89 d,#121212,
-|1: 0-1 #ffffff,#626262, 2-29 #949494,#121212, 30-31 #ffffff,#262626, 32-59 #949494,#121212, 60-61 #ffffff,#262626, 62-89 #949494,#121212,
-|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-24 #bdbdbd,#080808, 25-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-10 #080808,#80a0ff, 11-16 #bdbdbd,#080808, 17-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|7: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-38 #bdbdbd,#080808, 39-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|8: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|9: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|10: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|11: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|12: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|16: 0-1 #ffffff,#262626, 2-29 #949494,#121212, 30-31 #ffffff,#262626, 32-59 #949494,#121212, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|19: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|20: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|21: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|22: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|23: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|24: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|25: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|26: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|27: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|28: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|29: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
+|0: 0-56 #949494,#121212, 57-119 d,#121212,
+|1: 0-1 #ffffff,#626262, 2-29 #949494,#121212, 30-31 #ffffff,#262626, 32-59 #949494,#121212, 60-61 #ffffff,#262626, 62-99 #949494,#121212, 100-119 d,#121212,
+|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-24 #bdbdbd,#080808, 25-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-10 #080808,#80a0ff, 11-16 #bdbdbd,#080808, 17-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|7: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-38 #bdbdbd,#080808, 39-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|8: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|9: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|10: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|11: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|12: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|16: 0-1 #ffffff,#262626, 2-29 #949494,#121212, 30-31 #ffffff,#262626, 32-59 #949494,#121212, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|19: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|20: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|21: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|22: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|23: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|24: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|25: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|26: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|27: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|28: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|29: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
== style clickdismiss
-|0: 0-56 #949494,#121212, 57-89 d,#121212,
-|1: 0-1 #ffffff,#626262, 2-29 #949494,#121212, 30-31 #ffffff,#262626, 32-59 #949494,#121212, 60-61 #ffffff,#262626, 62-89 #949494,#121212,
-|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-24 #bdbdbd,#080808, 25-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-16 #bdbdbd,#080808, 17-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-13 #bdbdbd,#080808, 14-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|7: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-38 #bdbdbd,#080808, 39-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|8: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|9: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|10: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|11: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|12: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|16: 0-1 #ffffff,#262626, 2-29 #949494,#121212, 30-31 #ffffff,#262626, 32-59 #949494,#121212, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|19: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|20: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|21: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|22: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|23: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|24: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|25: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|26: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|27: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|28: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-|29: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-89 #bdbdbd,#080808,
-== snap ttyprobe grid=90x30 cursor=44,13
+|0: 0-56 #949494,#121212, 57-119 d,#121212,
+|1: 0-1 #ffffff,#626262, 2-29 #949494,#121212, 30-31 #ffffff,#262626, 32-59 #949494,#121212, 60-61 #ffffff,#262626, 62-99 #949494,#121212, 100-119 d,#121212,
+|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-24 #bdbdbd,#080808, 25-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-16 #bdbdbd,#080808, 17-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-13 #bdbdbd,#080808, 14-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|7: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-38 #bdbdbd,#080808, 39-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|8: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|9: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|10: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|11: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|12: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|16: 0-1 #ffffff,#262626, 2-29 #949494,#121212, 30-31 #ffffff,#262626, 32-59 #949494,#121212, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|19: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|20: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|21: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|22: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|23: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|24: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|25: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|26: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|27: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|28: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|29: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-29 d,#080808, 30-30 d,#444444, 31-31 d,#080808, 32-59 #bdbdbd,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+== snap ttyprobe grid=120x30 cursor=44,13
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/chordcut/cw$ /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cw
+| /tmp/pardes-snap/chordcut/cw$ /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cwd New Del
| 1 line 1 $ ls ls
| 2 2echo B''OO g.txt f.txt g.txt f.txt g.txt
| 3 line 3 $
@@ -370,9 +370,9 @@
|
|
|
-== snap ttyprompt grid=90x30 cursor=38,15
+== snap ttyprompt grid=120x30 cursor=38,15
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/chordcut/cw$ /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cw
+| /tmp/pardes-snap/chordcut/cw$ /tmp/pardes-snap/chordcut/cw /tmp/pardes-snap/chordcut/cwd New Del
| 1 line 1 $ ls ls
| 2 2echo B''OO g.txt f.txt g.txt f.txt g.txt
| 3 line 3 $
diff --git a/test/snapshots/chordcut.snap b/test/snapshots/chordcut.snap
index a1b65a4d..fe191f64 100644
--- a/test/snapshots/chordcut.snap
+++ b/test/snapshots/chordcut.snap
@@ -7,7 +7,11 @@
# left during a right (look) drag cancels the gesture entirely.
lines f.txt 5
lines g.txt 2
-start 30 90 -n 3
+# Source-local column splitting deliberately leaves the unrelated right
+# column wide. Give the caller half enough cells that the selected
+# `echo B''OO g.txt` remains one displayed row; this test is about chords, not
+# soft-wrap traversal.
+start 30 120 -n 3
wait 8000 f.txt
stable 700 20000
# open f.txt: click a body spot, type the name, look it
diff --git a/test/snapshots/cmdword.golden b/test/snapshots/cmdword.golden
index 940ce6fb..b657dced 100644
--- a/test/snapshots/cmdword.golden
+++ b/test/snapshots/cmdword.golden
@@ -48,7 +48,6 @@
|
| /tmp/pardes-snap/cmdword/cwd New Del
| echo ZZ''E
-| echo ZZ''E
| ZZE
|
|
@@ -60,7 +59,8 @@
|
|
|
-== snap atlook grid=120x30 cursor=4,22
+|
+== snap atlook grid=120x30 cursor=4,21
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
| /tmp/pardes-snap/cmdword/cwd/cmds.txt Save New Del
| 1 @`echo ZZ''E` @`echo ZZ''L` Look p.txt @`Look .`
@@ -79,7 +79,6 @@
|
| /tmp/pardes-snap/cmdword/cwd New Del
| echo ZZ''E
-| echo ZZ''E
| ZZE
| echo ZZ''L
| ZZL
@@ -91,6 +90,7 @@
|
|
|
+|
== snap lookword grid=120x30 cursor=7,10
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
| /tmp/pardes-snap/cmdword/cwd/cmds.txt Save New Del
@@ -110,7 +110,6 @@
|
| /tmp/pardes-snap/cmdword/cwd New Del
| echo ZZ''E
-| echo ZZ''E
| ZZE
| echo ZZ''L
| ZZL
@@ -122,7 +121,8 @@
|
|
|
-== snap nested grid=120x30 cursor=4,24
+|
+== snap nested grid=120x30 cursor=4,23
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
| /tmp/pardes-snap/cmdword/cwd/cmds.txt Save New Del
| 1 @`echo ZZ''E` @`echo ZZ''L` Look p.txt @`Look .`
@@ -141,7 +141,6 @@
|
| /tmp/pardes-snap/cmdword/cwd New Del
| echo ZZ''E
-| echo ZZ''E
| ZZE
| echo ZZ''L
| ZZL
@@ -153,6 +152,7 @@
|
|
|
+|
== snap execword grid=120x30 cursor=104,16
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
| /tmp/pardes-snap/cmdword/cwd/cmds.txt Save New Del
@@ -172,7 +172,6 @@
|
| /tmp/pardes-snap/cmdword/cwd New Del Exec
| echo ZZ''E
-| echo ZZ''E
| ZZE
| echo ZZ''L
| ZZL
@@ -184,6 +183,7 @@
|
|
|
+|
== snap tagcmd grid=120x30 cursor=118,16
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
| /tmp/pardes-snap/cmdword/cwd/cmds.txt Save New Del
@@ -203,7 +203,6 @@
|
| /tmp/pardes-snap/cmdword/cwd New Del Exec @`echo ZZ''T`
| echo ZZ''E
-| echo ZZ''E
| ZZE
| echo ZZ''L
| ZZL
@@ -215,3 +214,4 @@
|
|
|
+|
diff --git a/test/snapshots/cursorline.golden b/test/snapshots/cursorline.golden
index ce416b91..9203cb3c 100644
--- a/test/snapshots/cursorline.golden
+++ b/test/snapshots/cursorline.golden
@@ -1,12 +1,12 @@
== snap top grid=150x30 cursor=7,2
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/cursorline/cwd/cur.txt Save New /tmp/pardes-snap/cursorline/cwd New Del /tmp/pardes-snap/cursorline/cwd New Del
-| 1 line 1 ls ls
-| 2 line 2 cur.txt cur.txt
+| /tmp/pardes-snap/cursorline/cwd/cur. /tmp/pardes-snap/cursorline/cwd New /tmp/pardes-snap/cursorline/cwd New Del
+| 1 line 1 ls ls
+| 2 line 2 cur.txt cur.txt
| 3 line 3
| 4 line 4
| 5 line 5
-| 6 line 6 cur.txt
+| 6 line 6 cur.txt
| 7 line 7
| 8 line 8
| 9 line 9
@@ -15,9 +15,9 @@
| 12 line 12
| 13 line 13
| 14 line 14
-| 15 line 15 /tmp/pardes-snap/cursorline/cwd New Del
-| 16 line 16 ls
-| 17 line 17 cur.txt
+| 15 line 15 /tmp/pardes-snap/cursorline/cwd New
+| 16 line 16 ls
+| 17 line 17 cur.txt
| 18 line 18
| 19 line 19
| 20 line 20
@@ -31,44 +31,44 @@
|
== style top
|0: 0-56 #949494,#121212, 57-149 d,#121212,
-|1: 0-1 #ffffff,#626262, 2-49 #949494,#121212, 50-51 #ffffff,#262626, 52-90 #949494,#121212, 91-99 d,#121212, 100-101 #ffffff,#262626, 102-140 #949494,#121212, 141-149 d,#121212,
-|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|7: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-60 #bdbdbd,#080808, 61-99 d,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|8: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|9: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|10: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|11: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|12: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|16: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-51 #ffffff,#262626, 52-90 #949494,#121212, 91-99 d,#121212, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|19: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|20: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|21: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|22: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|23: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|24: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|25: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|26: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|27: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|28: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|29: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
+|1: 0-1 #ffffff,#626262, 2-37 #949494,#121212, 38-39 #ffffff,#262626, 40-74 #949494,#121212, 75-76 #ffffff,#262626, 77-129 #949494,#121212, 130-149 d,#121212,
+|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|7: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-48 #bdbdbd,#080808, 49-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|8: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|9: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|10: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|11: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|12: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|16: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-39 #ffffff,#262626, 40-74 #949494,#121212, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|19: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|20: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|21: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|22: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|23: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|24: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|25: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|26: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|27: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|28: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|29: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
== snap down3 grid=150x30 cursor=7,5
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/cursorline/cwd/cur.txt Save New /tmp/pardes-snap/cursorline/cwd New Del /tmp/pardes-snap/cursorline/cwd New Del
-| 1 line 1 ls ls
-| 2 line 2 cur.txt cur.txt
+| /tmp/pardes-snap/cursorline/cwd/cur. /tmp/pardes-snap/cursorline/cwd New /tmp/pardes-snap/cursorline/cwd New Del
+| 1 line 1 ls ls
+| 2 line 2 cur.txt cur.txt
| 3 line 3
| 4 line 4
| 5 line 5
-| 6 line 6 cur.txt
+| 6 line 6 cur.txt
| 7 line 7
| 8 line 8
| 9 line 9
@@ -77,9 +77,9 @@
| 12 line 12
| 13 line 13
| 14 line 14
-| 15 line 15 /tmp/pardes-snap/cursorline/cwd New Del
-| 16 line 16 ls
-| 17 line 17 cur.txt
+| 15 line 15 /tmp/pardes-snap/cursorline/cwd New
+| 16 line 16 ls
+| 17 line 17 cur.txt
| 18 line 18
| 19 line 19
| 20 line 20
@@ -93,32 +93,32 @@
|
== style down3
|0: 0-56 #949494,#121212, 57-149 d,#121212,
-|1: 0-1 #ffffff,#626262, 2-49 #949494,#121212, 50-51 #ffffff,#262626, 52-90 #949494,#121212, 91-99 d,#121212, 100-101 #ffffff,#262626, 102-140 #949494,#121212, 141-149 d,#121212,
-|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|7: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-60 #bdbdbd,#080808, 61-99 d,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|8: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|9: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|10: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|11: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|12: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|16: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-51 #ffffff,#262626, 52-90 #949494,#121212, 91-99 d,#121212, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|19: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|20: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|21: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|22: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|23: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|24: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|25: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|26: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|27: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|28: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|29: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
+|1: 0-1 #ffffff,#626262, 2-37 #949494,#121212, 38-39 #ffffff,#262626, 40-74 #949494,#121212, 75-76 #ffffff,#262626, 77-129 #949494,#121212, 130-149 d,#121212,
+|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|7: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-48 #bdbdbd,#080808, 49-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|8: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|9: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|10: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|11: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|12: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|16: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-39 #ffffff,#262626, 40-74 #949494,#121212, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|19: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|20: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|21: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|22: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|23: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|24: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|25: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|26: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|27: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|28: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|29: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
diff --git a/test/snapshots/dump.golden b/test/snapshots/dump.golden
index 1cda1a95..d45c3eba 100644
--- a/test/snapshots/dump.golden
+++ b/test/snapshots/dump.golden
@@ -16,6 +16,7 @@
.cols = 48,
.rows = 14,
.vweight = 1,
+ .tag_tail = null,
.terminal = .{
.cwd = "/tmp/pardes-snap/dump/cwd",
.stream = "ls\nd.txt\n",
@@ -33,6 +34,7 @@
.cols = 48,
.rows = 13,
.vweight = 1,
+ .tag_tail = null,
.terminal = .{
.cwd = "/tmp/pardes-snap/dump/cwd",
.stream = "ls\nd.txt\n",
@@ -50,6 +52,7 @@
.cols = 48,
.rows = 28,
.vweight = 1,
+ .tag_tail = null,
.terminal = .{
.cwd = "/tmp/pardes-snap/dump/cwd",
.stream = "ls\nd.txt\n",
diff --git a/test/snapshots/fileedit.golden b/test/snapshots/fileedit.golden
index 72e6b713..03b8735e 100644
--- a/test/snapshots/fileedit.golden
+++ b/test/snapshots/fileedit.golden
@@ -1,12 +1,12 @@
== snap edited grid=150x30 cursor=17,2
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/fileedit/cwd/f.txt Save New Del /tmp/pardes-snap/fileedit/cwd New Del /tmp/pardes-snap/fileedit/cwd New Del
-| 1 liEDIT''MEne 1 ls ls
-| 2 line 2 f.txt f.txt
+| /tmp/pardes-snap/fileedit/cwd/f.txt /tmp/pardes-snap/fileedit/cwd New D /tmp/pardes-snap/fileedit/cwd New Del
+| 1 liEDIT''MEne 1 ls ls
+| 2 line 2 f.txt f.txt
| 3 line 3
| 4 line 4
| 5 line 5
-| 6 line 6 f.txt
+| 6 line 6 f.txt
| 7 line 7
| 8 line 8
| 9 line 9
@@ -15,9 +15,9 @@
| 12 line 12
| 13 line 13
| 14 line 14
-| 15 line 15 /tmp/pardes-snap/fileedit/cwd New Del
-| 16 line 16 ls
-| 17 line 17 f.txt
+| 15 line 15 /tmp/pardes-snap/fileedit/cwd New D
+| 16 line 16 ls
+| 17 line 17 f.txt
| 18 line 18
| 19 line 19
| 20 line 20
@@ -31,13 +31,13 @@
|
== snap deleted grid=150x30 cursor=7,3
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/fileedit/cwd/f.txt Save New Del /tmp/pardes-snap/fileedit/cwd New Del /tmp/pardes-snap/fileedit/cwd New Del
-| 1 liEDIT''MEne 1 ls ls
-| 2 line 3 f.txt f.txt
+| /tmp/pardes-snap/fileedit/cwd/f.txt /tmp/pardes-snap/fileedit/cwd New D /tmp/pardes-snap/fileedit/cwd New Del
+| 1 liEDIT''MEne 1 ls ls
+| 2 line 3 f.txt f.txt
| 3 line 4
| 4 line 5
| 5 line 6
-| 6 line 7 f.txt
+| 6 line 7 f.txt
| 7 line 8
| 8 line 9
| 9 line 10
@@ -46,9 +46,9 @@
| 12 line 13
| 13 line 14
| 14 line 15
-| 15 line 16 /tmp/pardes-snap/fileedit/cwd New Del
-| 16 line 17 ls
-| 17 line 18 f.txt
+| 15 line 16 /tmp/pardes-snap/fileedit/cwd New D
+| 16 line 17 ls
+| 17 line 18 f.txt
| 18 line 19
| 19 line 20
| 20
@@ -62,13 +62,13 @@
|
== snap undone grid=150x30 cursor=13,3
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/fileedit/cwd/f.txt Save New Del /tmp/pardes-snap/fileedit/cwd New Del /tmp/pardes-snap/fileedit/cwd New Del
-| 1 liEDIT''MEne 1 ls ls
-| 2 line 2 f.txt f.txt
+| /tmp/pardes-snap/fileedit/cwd/f.txt /tmp/pardes-snap/fileedit/cwd New D /tmp/pardes-snap/fileedit/cwd New Del
+| 1 liEDIT''MEne 1 ls ls
+| 2 line 2 f.txt f.txt
| 3 line 3
| 4 line 4
| 5 line 5
-| 6 line 6 f.txt
+| 6 line 6 f.txt
| 7 line 7
| 8 line 8
| 9 line 9
@@ -77,9 +77,9 @@
| 12 line 12
| 13 line 13
| 14 line 14
-| 15 line 15 /tmp/pardes-snap/fileedit/cwd New Del
-| 16 line 16 ls
-| 17 line 17 f.txt
+| 15 line 15 /tmp/pardes-snap/fileedit/cwd New D
+| 16 line 16 ls
+| 17 line 17 f.txt
| 18 line 18
| 19 line 19
| 20 line 20
diff --git a/test/snapshots/fileedit.snap b/test/snapshots/fileedit.snap
index 00887072..46e6c787 100644
--- a/test/snapshots/fileedit.snap
+++ b/test/snapshots/fileedit.snap
@@ -33,10 +33,11 @@ snap deleted
key u
stable 400 5000
snap undone
-# Save via the tag: middle-click the Save word (right after the path — the
-# mode word that used to lead the prefix is a character in the layout box now,
-# so every column of every tagline moved four left)
-press middle 39 2
-release middle 39 2
+# Save through the file pane's own tag. The local-column split contract keeps
+# this source column narrow, so its tail is initially clipped; `:` focuses that
+# same tag and `w` reveals/selects Save before the ordinary execute key runs it.
+key :
+stable 400 5000
+key w tab
stable 600 8000
snapfile saved /tmp/pardes-snap/fileedit/cwd/f.txt
diff --git a/test/snapshots/filewatch.golden b/test/snapshots/filewatch.golden
index f81d5709..af5ee8e2 100644
--- a/test/snapshots/filewatch.golden
+++ b/test/snapshots/filewatch.golden
@@ -1,12 +1,13 @@
== snap edited grid=150x30 cursor=15,2
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/filewatch/cwd/w.txt Save New De /tmp/pardes-snap/filewatch/cwd New Del /tmp/pardes-snap/filewatch/cwd New Del
-| 1 liMYEDITne 1 ls ls
-| 2 line 2 sub w.txt sub w.txt
-| 3 /tmp/pardes-snap/filewatch/cwd/sub/s.txt
+| /tmp/pardes-snap/filewatch/cwd/w.txt /tmp/pardes-snap/filewatch/cwd New /tmp/pardes-snap/filewatch/cwd New Del
+| 1 liMYEDITne 1 ls ls
+| 2 line 2 sub w.txt sub w.txt
+| 3 /tmp/pardes-snap/filewatch/cwd↩
+| /sub/s.txt
| 4 line 4
-| 5 line 5
-| 6 line 6 w.txt
+| 5 line 5 w.txt
+| 6 line 6
| 7 line 7
| 8 line 8
| 9 line 9
@@ -14,10 +15,9 @@
| 11 line 11
| 12 line 12
| 13
-|
-| /tmp/pardes-snap/filewatch/cwd/sub/s.txt Save Ne /tmp/pardes-snap/filewatch/cwd New Del
-| 1 line 1 ls
-| 2 line 2 sub w.txt
+| /tmp/pardes-snap/filewatch/cwd/sub/s /tmp/pardes-snap/filewatch/cwd New
+| 1 line 1 ls
+| 2 line 2 sub w.txt
| 3 line 3
| 4 line 4
| 5 line 5
@@ -31,13 +31,13 @@
|
== snap reloaded grid=150x30 cursor=15,2
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/filewatch/cwd/w.txt Save New De /tmp/pardes-snap/filewatch/cwd New Del /tmp/pardes-snap/filewatch/cwd New Del
-| 1 EXTERNAL ls ls
-| 2 sub w.txt sub w.txt
+| /tmp/pardes-snap/filewatch/cwd/w.txt /tmp/pardes-snap/filewatch/cwd New /tmp/pardes-snap/filewatch/cwd New Del
+| 1 EXTERNAL ls ls
+| 2 sub w.txt sub w.txt
|
|
|
-| w.txt
+| w.txt
|
|
|
@@ -45,10 +45,10 @@
|
|
|
-| --:--:-- reloaded /tmp/pardes-snap/filewatch/cw
-| /tmp/pardes-snap/filewatch/cwd/sub/s.txt Save Ne /tmp/pardes-snap/filewatch/cwd New Del
-| 1 line 1 ls
-| 2 line 2 sub w.txt
+| --:--:-- reloaded /tmp/pardes-snap/
+| /tmp/pardes-snap/filewatch/cwd/sub/s /tmp/pardes-snap/filewatch/cwd New
+| 1 line 1 ls
+| 2 line 2 sub w.txt
| 3 line 3
| 4 line 4
| 5 line 5
@@ -62,13 +62,13 @@
|
== snap reloaded_sub grid=150x30 cursor=15,2
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/filewatch/cwd/w.txt Save New De /tmp/pardes-snap/filewatch/cwd New Del /tmp/pardes-snap/filewatch/cwd New Del
-| 1 EXTERNAL ls ls
-| 2 sub w.txt sub w.txt
+| /tmp/pardes-snap/filewatch/cwd/w.txt /tmp/pardes-snap/filewatch/cwd New /tmp/pardes-snap/filewatch/cwd New Del
+| 1 EXTERNAL ls ls
+| 2 sub w.txt sub w.txt
|
|
|
-| w.txt
+| w.txt
|
|
|
@@ -76,10 +76,10 @@
|
|
|
-| --:--:-- reloaded /tmp/pardes-snap/filewatch/cw
-| /tmp/pardes-snap/filewatch/cwd/sub/s.txt Save Ne /tmp/pardes-snap/filewatch/cwd New Del
-| 1 RENAMED ls
-| 2 sub w.txt
+| --:--:-- reloaded /tmp/pardes-snap/
+| /tmp/pardes-snap/filewatch/cwd/sub/s /tmp/pardes-snap/filewatch/cwd New
+| 1 RENAMED ls
+| 2 sub w.txt
|
|
|
@@ -90,16 +90,17 @@
|
|
|
-| --:--:-- reloaded /tmp/pardes-snap/filewatch/cw
+| --:--:-- reloaded /tmp/pardes-snap/
== snap undone grid=150x30 cursor=15,2
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/filewatch/cwd/w.txt Save New De /tmp/pardes-snap/filewatch/cwd New Del /tmp/pardes-snap/filewatch/cwd New Del
-| 1 liMYEDITne 1 ls ls
-| 2 line 2 sub w.txt sub w.txt
-| 3 /tmp/pardes-snap/filewatch/cwd/sub/s.txt
+| /tmp/pardes-snap/filewatch/cwd/w.txt /tmp/pardes-snap/filewatch/cwd New /tmp/pardes-snap/filewatch/cwd New Del
+| 1 liMYEDITne 1 ls ls
+| 2 line 2 sub w.txt sub w.txt
+| 3 /tmp/pardes-snap/filewatch/cwd↩
+| /sub/s.txt
| 4 line 4
-| 5 line 5
-| 6 line 6 w.txt
+| 5 line 5 w.txt
+| 6 line 6
| 7 line 7
| 8 line 8
| 9 line 9
@@ -107,10 +108,9 @@
| 11 line 11
| 12 line 12
| 13
-|
-| /tmp/pardes-snap/filewatch/cwd/sub/s.txt Save Ne /tmp/pardes-snap/filewatch/cwd New Del
-| 1 RENAMED ls
-| 2 sub w.txt
+| /tmp/pardes-snap/filewatch/cwd/sub/s /tmp/pardes-snap/filewatch/cwd New
+| 1 RENAMED ls
+| 2 sub w.txt
|
|
|
diff --git a/test/snapshots/fsearch.golden b/test/snapshots/fsearch.golden
index 317e7b5e..e9a4bcbc 100644
--- a/test/snapshots/fsearch.golden
+++ b/test/snapshots/fsearch.golden
@@ -1,12 +1,12 @@
== snap opened grid=180x30 cursor=7,2
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/fsearch/cwd/g.txt Save New Del /tmp/pardes-snap/fsearch/cwd New Del /tmp/pardes-snap/fsearch/cwd New Del
-| 1 l1 ls ls
-| 2 MARK a g.txt g.txt
+| /tmp/pardes-snap/fsearch/cwd/g.txt Save New /tmp/pardes-snap/fsearch/cwd New Del /tmp/pardes-snap/fsearch/cwd New Del
+| 1 l1 ls ls
+| 2 MARK a g.txt g.txt
| 3 l3
| 4 l4
| 5 l5
-| 6 MARK b g.txt
+| 6 MARK b g.txt
| 7 l7
| 8 l8
| 9 l9
@@ -15,9 +15,9 @@
| 12 l12
|
|
-| /tmp/pardes-snap/fsearch/cwd New Del
-| ls
-| g.txt
+| /tmp/pardes-snap/fsearch/cwd New Del
+| ls
+| g.txt
|
|
|
@@ -31,13 +31,13 @@
|
== snap typing grid=180x30 cursor=8,29
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/fsearch/cwd/g.txt Save New Del /tmp/pardes-snap/fsearch/cwd New Del /tmp/pardes-snap/fsearch/cwd New Del
-| 1 l1 ls ls
-| 2 MARK a g.txt g.txt
+| /tmp/pardes-snap/fsearch/cwd/g.txt Save New /tmp/pardes-snap/fsearch/cwd New Del /tmp/pardes-snap/fsearch/cwd New Del
+| 1 l1 ls ls
+| 2 MARK a g.txt g.txt
| 3 l3
| 4 l4
| 5 l5
-| 6 MARK b g.txt
+| 6 MARK b g.txt
| 7 l7
| 8 l8
| 9 l9
@@ -46,9 +46,9 @@
| 12 l12
|
|
-| /tmp/pardes-snap/fsearch/cwd New Del
-| ls
-| g.txt
+| /tmp/pardes-snap/fsearch/cwd New Del
+| ls
+| g.txt
|
|
|
@@ -62,13 +62,13 @@
| /MARK
== snap results grid=180x30 cursor=10,3
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/fsearch/cwd/g.txt Save New Del /tmp/pardes-snap/fsearch/cwd New Del /tmp/pardes-snap/fsearch/cwd New Del
-| 1 l1 ls ls
-| 2 MARK a g.txt g.txt
+| /tmp/pardes-snap/fsearch/cwd/g.txt Save New /tmp/pardes-snap/fsearch/cwd New Del /tmp/pardes-snap/fsearch/cwd New Del
+| 1 l1 ls ls
+| 2 MARK a g.txt g.txt
| 3 l3
| 4 l4
| 5 l5
-| 6 MARK b g.txt
+| 6 MARK b g.txt
| 7 l7
| 8 l8
| 9 l9
@@ -77,9 +77,9 @@
| 12 l12
|
|
-| /tmp/pardes-snap/fsearch/cwd New Del
-| ls
-| g.txt
+| /tmp/pardes-snap/fsearch/cwd New Del
+| ls
+| g.txt
|
|
|
@@ -87,50 +87,50 @@
|
|
|
-| /tmp/pardes-snap/fsearch/cwd/+Search New Del
+| /tmp/pardes-snap/fsearch/cwd/+Search New De
| 1 g.txt:2:1-4 MARK a
| 2 g.txt:6:1-4 MARK b
| 3 g.txt:10:1-4 MARK c
== style results
|0: 0-56 #949494,#121212, 57-179 d,#121212,
-|1: 0-1 #ffffff,#626262, 2-48 #949494,#121212, 49-59 d,#121212, 60-61 #ffffff,#262626, 62-99 #949494,#121212, 100-119 d,#121212, 120-121 #ffffff,#262626, 122-159 #949494,#121212, 160-179 d,#121212,
-|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-8 #bdbdbd,#080808, 9-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808, 120-120 d,#444444, 121-121 d,#080808, 122-179 #bdbdbd,#080808,
-|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-10 #080808,#80a0ff, 11-12 #bdbdbd,#080808, 13-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808, 120-120 d,#444444, 121-121 d,#080808, 122-179 #bdbdbd,#080808,
-|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-8 #bdbdbd,#080808, 9-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808, 120-120 d,#444444, 121-121 d,#080808, 122-179 #bdbdbd,#080808,
-|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-8 #bdbdbd,#080808, 9-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808, 120-120 d,#444444, 121-121 d,#080808, 122-179 #bdbdbd,#080808,
-|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-8 #bdbdbd,#080808, 9-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808, 120-120 d,#444444, 121-121 d,#080808, 122-179 #bdbdbd,#080808,
-|7: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-68 #bdbdbd,#080808, 69-119 d,#080808, 120-120 d,#444444, 121-121 d,#080808, 122-179 #bdbdbd,#080808,
-|8: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-8 #bdbdbd,#080808, 9-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808, 120-120 d,#444444, 121-121 d,#080808, 122-179 #bdbdbd,#080808,
-|9: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-8 #bdbdbd,#080808, 9-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808, 120-120 d,#444444, 121-121 d,#080808, 122-179 #bdbdbd,#080808,
-|10: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-8 #bdbdbd,#080808, 9-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808, 120-120 d,#444444, 121-121 d,#080808, 122-179 #bdbdbd,#080808,
-|11: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808, 120-120 d,#444444, 121-121 d,#080808, 122-179 #bdbdbd,#080808,
-|12: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-9 #bdbdbd,#080808, 10-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808, 120-120 d,#444444, 121-121 d,#080808, 122-179 #bdbdbd,#080808,
-|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-9 #bdbdbd,#080808, 10-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808, 120-120 d,#444444, 121-121 d,#080808, 122-179 #bdbdbd,#080808,
-|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808, 120-120 d,#444444, 121-121 d,#080808, 122-179 #bdbdbd,#080808,
-|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808, 120-120 d,#444444, 121-121 d,#080808, 122-179 #bdbdbd,#080808,
-|16: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-59 d,#080808, 60-61 #ffffff,#262626, 62-99 #949494,#121212, 100-119 d,#121212, 120-120 d,#444444, 121-121 d,#080808, 122-179 #bdbdbd,#080808,
-|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808, 120-120 d,#444444, 121-121 d,#080808, 122-179 #bdbdbd,#080808,
-|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808, 120-120 d,#444444, 121-121 d,#080808, 122-179 #bdbdbd,#080808,
-|19: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808, 120-120 d,#444444, 121-121 d,#080808, 122-179 #bdbdbd,#080808,
-|20: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808, 120-120 d,#444444, 121-121 d,#080808, 122-179 #bdbdbd,#080808,
-|21: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808, 120-120 d,#444444, 121-121 d,#080808, 122-179 #bdbdbd,#080808,
-|22: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808, 120-120 d,#444444, 121-121 d,#080808, 122-179 #bdbdbd,#080808,
-|23: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808, 120-120 d,#444444, 121-121 d,#080808, 122-179 #bdbdbd,#080808,
-|24: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808, 120-120 d,#444444, 121-121 d,#080808, 122-179 #bdbdbd,#080808,
-|25: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808, 120-120 d,#444444, 121-121 d,#080808, 122-179 #bdbdbd,#080808,
-|26: 0-1 #ffffff,#262626, 2-48 #949494,#121212, 49-59 d,#121212, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808, 120-120 d,#444444, 121-121 d,#080808, 122-179 #bdbdbd,#080808,
-|27: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-24 #bdbdbd,#080808, 25-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808, 120-120 d,#444444, 121-121 d,#080808, 122-179 #bdbdbd,#080808,
-|28: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-24 #bdbdbd,#080808, 25-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808, 120-120 d,#444444, 121-121 d,#080808, 122-179 #bdbdbd,#080808,
-|29: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-25 #bdbdbd,#080808, 26-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808, 120-120 d,#444444, 121-121 d,#080808, 122-179 #bdbdbd,#080808,
+|1: 0-1 #ffffff,#626262, 2-44 #949494,#121212, 45-46 #ffffff,#262626, 47-82 #949494,#121212, 83-89 d,#121212, 90-91 #ffffff,#262626, 92-159 #949494,#121212, 160-179 d,#121212,
+|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-8 #bdbdbd,#080808, 9-44 d,#080808, 45-45 d,#444444, 46-46 d,#080808, 47-89 #bdbdbd,#080808, 90-90 d,#444444, 91-91 d,#080808, 92-179 #bdbdbd,#080808,
+|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-10 #080808,#80a0ff, 11-12 #bdbdbd,#080808, 13-44 d,#080808, 45-45 d,#444444, 46-46 d,#080808, 47-89 #bdbdbd,#080808, 90-90 d,#444444, 91-91 d,#080808, 92-179 #bdbdbd,#080808,
+|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-8 #bdbdbd,#080808, 9-44 d,#080808, 45-45 d,#444444, 46-46 d,#080808, 47-89 #bdbdbd,#080808, 90-90 d,#444444, 91-91 d,#080808, 92-179 #bdbdbd,#080808,
+|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-8 #bdbdbd,#080808, 9-44 d,#080808, 45-45 d,#444444, 46-46 d,#080808, 47-89 #bdbdbd,#080808, 90-90 d,#444444, 91-91 d,#080808, 92-179 #bdbdbd,#080808,
+|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-8 #bdbdbd,#080808, 9-44 d,#080808, 45-45 d,#444444, 46-46 d,#080808, 47-89 #bdbdbd,#080808, 90-90 d,#444444, 91-91 d,#080808, 92-179 #bdbdbd,#080808,
+|7: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-44 d,#080808, 45-45 d,#444444, 46-46 d,#080808, 47-53 #bdbdbd,#080808, 54-89 d,#080808, 90-90 d,#444444, 91-91 d,#080808, 92-179 #bdbdbd,#080808,
+|8: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-8 #bdbdbd,#080808, 9-44 d,#080808, 45-45 d,#444444, 46-46 d,#080808, 47-89 #bdbdbd,#080808, 90-90 d,#444444, 91-91 d,#080808, 92-179 #bdbdbd,#080808,
+|9: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-8 #bdbdbd,#080808, 9-44 d,#080808, 45-45 d,#444444, 46-46 d,#080808, 47-89 #bdbdbd,#080808, 90-90 d,#444444, 91-91 d,#080808, 92-179 #bdbdbd,#080808,
+|10: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-8 #bdbdbd,#080808, 9-44 d,#080808, 45-45 d,#444444, 46-46 d,#080808, 47-89 #bdbdbd,#080808, 90-90 d,#444444, 91-91 d,#080808, 92-179 #bdbdbd,#080808,
+|11: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-44 d,#080808, 45-45 d,#444444, 46-46 d,#080808, 47-89 #bdbdbd,#080808, 90-90 d,#444444, 91-91 d,#080808, 92-179 #bdbdbd,#080808,
+|12: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-9 #bdbdbd,#080808, 10-44 d,#080808, 45-45 d,#444444, 46-46 d,#080808, 47-89 #bdbdbd,#080808, 90-90 d,#444444, 91-91 d,#080808, 92-179 #bdbdbd,#080808,
+|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-9 #bdbdbd,#080808, 10-44 d,#080808, 45-45 d,#444444, 46-46 d,#080808, 47-89 #bdbdbd,#080808, 90-90 d,#444444, 91-91 d,#080808, 92-179 #bdbdbd,#080808,
+|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-44 d,#080808, 45-45 d,#444444, 46-46 d,#080808, 47-89 #bdbdbd,#080808, 90-90 d,#444444, 91-91 d,#080808, 92-179 #bdbdbd,#080808,
+|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-44 d,#080808, 45-45 d,#444444, 46-46 d,#080808, 47-89 #bdbdbd,#080808, 90-90 d,#444444, 91-91 d,#080808, 92-179 #bdbdbd,#080808,
+|16: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-44 d,#080808, 45-46 #ffffff,#262626, 47-82 #949494,#121212, 83-89 d,#121212, 90-90 d,#444444, 91-91 d,#080808, 92-179 #bdbdbd,#080808,
+|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-44 d,#080808, 45-45 d,#444444, 46-46 d,#080808, 47-89 #bdbdbd,#080808, 90-90 d,#444444, 91-91 d,#080808, 92-179 #bdbdbd,#080808,
+|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-44 d,#080808, 45-45 d,#444444, 46-46 d,#080808, 47-89 #bdbdbd,#080808, 90-90 d,#444444, 91-91 d,#080808, 92-179 #bdbdbd,#080808,
+|19: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-44 d,#080808, 45-45 d,#444444, 46-46 d,#080808, 47-89 #bdbdbd,#080808, 90-90 d,#444444, 91-91 d,#080808, 92-179 #bdbdbd,#080808,
+|20: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-44 d,#080808, 45-45 d,#444444, 46-46 d,#080808, 47-89 #bdbdbd,#080808, 90-90 d,#444444, 91-91 d,#080808, 92-179 #bdbdbd,#080808,
+|21: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-44 d,#080808, 45-45 d,#444444, 46-46 d,#080808, 47-89 #bdbdbd,#080808, 90-90 d,#444444, 91-91 d,#080808, 92-179 #bdbdbd,#080808,
+|22: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-44 d,#080808, 45-45 d,#444444, 46-46 d,#080808, 47-89 #bdbdbd,#080808, 90-90 d,#444444, 91-91 d,#080808, 92-179 #bdbdbd,#080808,
+|23: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-44 d,#080808, 45-45 d,#444444, 46-46 d,#080808, 47-89 #bdbdbd,#080808, 90-90 d,#444444, 91-91 d,#080808, 92-179 #bdbdbd,#080808,
+|24: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-44 d,#080808, 45-45 d,#444444, 46-46 d,#080808, 47-89 #bdbdbd,#080808, 90-90 d,#444444, 91-91 d,#080808, 92-179 #bdbdbd,#080808,
+|25: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-44 d,#080808, 45-45 d,#444444, 46-46 d,#080808, 47-89 #bdbdbd,#080808, 90-90 d,#444444, 91-91 d,#080808, 92-179 #bdbdbd,#080808,
+|26: 0-1 #ffffff,#262626, 2-44 #949494,#121212, 45-45 d,#444444, 46-46 d,#080808, 47-89 #bdbdbd,#080808, 90-90 d,#444444, 91-91 d,#080808, 92-179 #bdbdbd,#080808,
+|27: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-24 #bdbdbd,#080808, 25-44 d,#080808, 45-45 d,#444444, 46-46 d,#080808, 47-89 #bdbdbd,#080808, 90-90 d,#444444, 91-91 d,#080808, 92-179 #bdbdbd,#080808,
+|28: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-24 #bdbdbd,#080808, 25-44 d,#080808, 45-45 d,#444444, 46-46 d,#080808, 47-89 #bdbdbd,#080808, 90-90 d,#444444, 91-91 d,#080808, 92-179 #bdbdbd,#080808,
+|29: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-25 #bdbdbd,#080808, 26-44 d,#080808, 45-45 d,#444444, 46-46 d,#080808, 47-89 #bdbdbd,#080808, 90-90 d,#444444, 91-91 d,#080808, 92-179 #bdbdbd,#080808,
== snap second grid=180x30 cursor=10,7
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/fsearch/cwd/g.txt Save New Del /tmp/pardes-snap/fsearch/cwd New Del /tmp/pardes-snap/fsearch/cwd New Del
-| 1 l1 ls ls
-| 2 MARK a g.txt g.txt
+| /tmp/pardes-snap/fsearch/cwd/g.txt Save New /tmp/pardes-snap/fsearch/cwd New Del /tmp/pardes-snap/fsearch/cwd New Del
+| 1 l1 ls ls
+| 2 MARK a g.txt g.txt
| 3 l3
| 4 l4
| 5 l5
-| 6 MARK b g.txt
+| 6 MARK b g.txt
| 7 l7
| 8 l8
| 9 l9
@@ -139,9 +139,9 @@
| 12 l12
|
|
-| /tmp/pardes-snap/fsearch/cwd New Del
-| ls
-| g.txt
+| /tmp/pardes-snap/fsearch/cwd New Del
+| ls
+| g.txt
|
|
|
@@ -149,19 +149,19 @@
|
|
|
-| /tmp/pardes-snap/fsearch/cwd/+Search New Del
+| /tmp/pardes-snap/fsearch/cwd/+Search New De
| 1 g.txt:2:1-4 MARK a
| 2 g.txt:6:1-4 MARK b
| 3 g.txt:10:1-4 MARK c
== snap third grid=180x30 cursor=10,11
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/fsearch/cwd/g.txt Save New Del /tmp/pardes-snap/fsearch/cwd New Del /tmp/pardes-snap/fsearch/cwd New Del
-| 1 l1 ls ls
-| 2 MARK a g.txt g.txt
+| /tmp/pardes-snap/fsearch/cwd/g.txt Save New /tmp/pardes-snap/fsearch/cwd New Del /tmp/pardes-snap/fsearch/cwd New Del
+| 1 l1 ls ls
+| 2 MARK a g.txt g.txt
| 3 l3
| 4 l4
| 5 l5
-| 6 MARK b g.txt
+| 6 MARK b g.txt
| 7 l7
| 8 l8
| 9 l9
@@ -170,9 +170,9 @@
| 12 l12
|
|
-| /tmp/pardes-snap/fsearch/cwd New Del
-| ls
-| g.txt
+| /tmp/pardes-snap/fsearch/cwd New Del
+| ls
+| g.txt
|
|
|
@@ -180,19 +180,19 @@
|
|
|
-| /tmp/pardes-snap/fsearch/cwd/+Search New Del
+| /tmp/pardes-snap/fsearch/cwd/+Search New De
| 2 g.txt:6:1-4 MARK b
| 3 g.txt:10:1-4 MARK c
| 4
== snap back grid=180x30 cursor=10,7
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/fsearch/cwd/g.txt Save New Del /tmp/pardes-snap/fsearch/cwd New Del /tmp/pardes-snap/fsearch/cwd New Del
-| 1 l1 ls ls
-| 2 MARK a g.txt g.txt
+| /tmp/pardes-snap/fsearch/cwd/g.txt Save New /tmp/pardes-snap/fsearch/cwd New Del /tmp/pardes-snap/fsearch/cwd New Del
+| 1 l1 ls ls
+| 2 MARK a g.txt g.txt
| 3 l3
| 4 l4
| 5 l5
-| 6 MARK b g.txt
+| 6 MARK b g.txt
| 7 l7
| 8 l8
| 9 l9
@@ -201,9 +201,9 @@
| 12 l12
|
|
-| /tmp/pardes-snap/fsearch/cwd New Del
-| ls
-| g.txt
+| /tmp/pardes-snap/fsearch/cwd New Del
+| ls
+| g.txt
|
|
|
@@ -211,19 +211,19 @@
|
|
|
-| /tmp/pardes-snap/fsearch/cwd/+Search New Del
+| /tmp/pardes-snap/fsearch/cwd/+Search New De
| 1 g.txt:2:1-4 MARK a
| 2 g.txt:6:1-4 MARK b
| 3 g.txt:10:1-4 MARK c
== snap cancel grid=180x30 cursor=10,7
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/fsearch/cwd/g.txt Save New Del /tmp/pardes-snap/fsearch/cwd New Del /tmp/pardes-snap/fsearch/cwd New Del
-| 1 l1 ls ls
-| 2 MARK a g.txt g.txt
+| /tmp/pardes-snap/fsearch/cwd/g.txt Save New /tmp/pardes-snap/fsearch/cwd New Del /tmp/pardes-snap/fsearch/cwd New Del
+| 1 l1 ls ls
+| 2 MARK a g.txt g.txt
| 3 l3
| 4 l4
| 5 l5
-| 6 MARK b g.txt
+| 6 MARK b g.txt
| 7 l7
| 8 l8
| 9 l9
@@ -232,9 +232,9 @@
| 12 l12
|
|
-| /tmp/pardes-snap/fsearch/cwd New Del
-| ls
-| g.txt
+| /tmp/pardes-snap/fsearch/cwd New Del
+| ls
+| g.txt
|
|
|
@@ -242,19 +242,19 @@
|
|
|
-| /tmp/pardes-snap/fsearch/cwd/+Search New Del
+| /tmp/pardes-snap/fsearch/cwd/+Search New De
| 1 g.txt:2:1-4 MARK a
| 2 g.txt:6:1-4 MARK b
| 3 g.txt:10:1-4 MARK c
== snap nomatch grid=180x30 cursor=7,28
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/fsearch/cwd/g.txt Save New Del /tmp/pardes-snap/fsearch/cwd New Del /tmp/pardes-snap/fsearch/cwd New Del
-| 1 l1 ls ls
-| 2 MARK a g.txt g.txt
+| /tmp/pardes-snap/fsearch/cwd/g.txt Save New /tmp/pardes-snap/fsearch/cwd New Del /tmp/pardes-snap/fsearch/cwd New Del
+| 1 l1 ls ls
+| 2 MARK a g.txt g.txt
| 3 l3
| 4 l4
| 5 l5
-| 6 MARK b g.txt
+| 6 MARK b g.txt
| 7 l7
| 8 l8
| 9 l9
@@ -263,17 +263,17 @@
| 12 l12
|
|
-| /tmp/pardes-snap/fsearch/cwd New Del
-| ls
-| g.txt
+| /tmp/pardes-snap/fsearch/cwd New Del
+| ls
+| g.txt
|
|
|
|
|
-| /tmp/pardes-snap/fsearch/cwd/+Search New Del
+| /tmp/pardes-snap/fsearch/cwd/+Search New De
| 1
-| /tmp/pardes-snap/fsearch/cwd/+Search New Del
+| /tmp/pardes-snap/fsearch/cwd/+Search New De
| 2 g.txt:6:1-4 MARK b
| 3 g.txt:10:1-4 MARK c
| 4
diff --git a/test/snapshots/grep.golden b/test/snapshots/grep.golden
index af4c4708..0dd40aab 100644
--- a/test/snapshots/grep.golden
+++ b/test/snapshots/grep.golden
@@ -1,12 +1,12 @@
== snap opened grid=180x30 cursor=7,2
|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
-| 2 nothing here a.txt noise.txt sub a.txt noise.txt sub
+| /tmp/pardes-snap/grep/cwd/a.txt Save New De /tmp/pardes-snap/grep/cwd New Del /tmp/pardes-snap/grep/cwd New Del
+| 1 hello TARGET one ls ls
+| 2 nothing here a.txt noise.txt sub a.txt noise.txt sub
| 3 last TARGET line
|
|
-| a.txt
+| a.txt
|
|
|
@@ -15,9 +15,9 @@
|
|
|
-| /tmp/pardes-snap/grep/cwd New Del
-| ls
-| a.txt noise.txt sub
+| /tmp/pardes-snap/grep/cwd New Del
+| ls
+| a.txt noise.txt sub
|
|
|
@@ -31,13 +31,13 @@
|
== snap typing grid=180x30 cursor=15,29
|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
-| 2 nothing here a.txt noise.txt sub a.txt noise.txt sub
+| /tmp/pardes-snap/grep/cwd/a.txt Save New De /tmp/pardes-snap/grep/cwd New Del /tmp/pardes-snap/grep/cwd New Del
+| 1 hello TARGET one ls ls
+| 2 nothing here a.txt noise.txt sub a.txt noise.txt sub
| 3 last TARGET line
|
|
-| a.txt
+| a.txt
|
|
|
@@ -46,9 +46,9 @@
|
|
|
-| /tmp/pardes-snap/grep/cwd New Del
-| ls
-| a.txt noise.txt sub
+| /tmp/pardes-snap/grep/cwd New Del
+| ls
+| a.txt noise.txt sub
|
|
|
@@ -62,13 +62,13 @@
| Grep /TARGET
== snap results grid=180x30 cursor=7,2
|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
-| 2 nothing here a.txt noise.txt sub a.txt noise.txt sub
+| /tmp/pardes-snap/grep/cwd/a.txt Save New De /tmp/pardes-snap/grep/cwd New Del /tmp/pardes-snap/grep/cwd New Del
+| 1 hello TARGET one ls ls
+| 2 nothing here a.txt noise.txt sub a.txt noise.txt sub
| 3 last TARGET line
|
|
-| a.txt
+| a.txt
|
|
|
@@ -77,9 +77,9 @@
|
|
|
-| /tmp/pardes-snap/grep/cwd New Del
-| ls
-| a.txt noise.txt sub
+| /tmp/pardes-snap/grep/cwd New Del
+| ls
+| a.txt noise.txt sub
|
|
|
@@ -87,19 +87,19 @@
|
|
|
-| /tmp/pardes-snap/grep/cwd/+Search New Del
+| /tmp/pardes-snap/grep/cwd/+Search New Del
| 1 a.txt:1:7-12 hello TARGET one
| 2 a.txt:3:6-11 last TARGET line
| 3 sub/b.txt:2:1-6 TARGET inside sub
== snap first grid=180x30 cursor=18,2
|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
-| 2 nothing here a.txt noise.txt sub a.txt noise.txt sub
+| /tmp/pardes-snap/grep/cwd/a.txt Save New De /tmp/pardes-snap/grep/cwd New Del /tmp/pardes-snap/grep/cwd New Del
+| 1 hello TARGET one ls ls
+| 2 nothing here a.txt noise.txt sub a.txt noise.txt sub
| 3 last TARGET line
|
|
-| a.txt
+| a.txt
|
|
|
@@ -108,9 +108,9 @@
|
|
|
-| /tmp/pardes-snap/grep/cwd New Del
-| ls
-| a.txt noise.txt sub
+| /tmp/pardes-snap/grep/cwd New Del
+| ls
+| a.txt noise.txt sub
|
|
|
@@ -118,30 +118,30 @@
|
|
|
-| /tmp/pardes-snap/grep/cwd/+Search New Del
+| /tmp/pardes-snap/grep/cwd/+Search New Del
| 1 a.txt:1:7-12 hello TARGET one
| 2 a.txt:3:6-11 last TARGET line
| 3 sub/b.txt:2:1-6 TARGET inside sub
== snap opened-hit grid=180x30 cursor=12,16
|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
-| 2 nothing here a.txt noise.txt sub a.txt noise.txt sub
+| /tmp/pardes-snap/grep/cwd/a.txt Save New De /tmp/pardes-snap/grep/cwd New Del /tmp/pardes-snap/grep/cwd New Del
+| 1 hello TARGET one ls ls
+| 2 nothing here a.txt noise.txt sub a.txt noise.txt sub
| 3 last TARGET line
|
|
-| a.txt
+| a.txt
|
|
|
|
|
|
-| /tmp/pardes-snap/grep/cwd/sub/b.txt Save New Del
+| /tmp/pardes-snap/grep/cwd/sub/b.txt Save Ne
| 1 deep
-| 2 TARGET inside sub /tmp/pardes-snap/grep/cwd New Del
-| ls
-| a.txt noise.txt sub
+| 2 TARGET inside sub /tmp/pardes-snap/grep/cwd New Del
+| ls
+| a.txt noise.txt sub
|
|
|
@@ -149,100 +149,100 @@
|
|
|
-| /tmp/pardes-snap/grep/cwd/+Search New Del
+| /tmp/pardes-snap/grep/cwd/+Search New Del
| 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=62,3
+== snap nomatch grid=180x30 cursor=47,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
-| 2 nothing here a.txt noise.txt sub a.txt noise.txt sub
+| /tmp/pardes-snap/grep/cwd/a.txt Save New De /tmp/pardes-snap/grep/cwd New Del /tmp/pardes-snap/grep/cwd New Del
+| 1 hello TARGET one ls ls
+| 2 nothing here a.txt noise.txt sub a.txt noise.txt sub
| 3 last TARGET line
|
|
-| a.txt
+| a.txt
|
|
|
|
|
|
-| /tmp/pardes-snap/grep/cwd/sub/b.txt Save New Del
+| /tmp/pardes-snap/grep/cwd/sub/b.txt Save Ne
| 1 deep
-| 2 TARGET inside sub /tmp/pardes-snap/grep/cwd New Del
-| ls
-| a.txt noise.txt sub
+| 2 TARGET inside sub /tmp/pardes-snap/grep/cwd New Del
+| ls
+| a.txt noise.txt sub
|
|
|
|
|
-| /tmp/pardes-snap/grep/cwd/sub/+Search New Del
+| /tmp/pardes-snap/grep/cwd/sub/+Search New D
| 1
-| /tmp/pardes-snap/grep/cwd/+Search New Del
+| /tmp/pardes-snap/grep/cwd/+Search New Del
| 2 a.txt:3:6-11 last TARGET line
| 3 sub/b.txt:2:1-6 TARGET inside sub
| 4
== snap chordsel grid=180x30 cursor=19,2
|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
-| 2 nothing here a.txt noise.txt sub a.txt noise.txt sub
+| /tmp/pardes-snap/grep/cwd/a.txt Save New De /tmp/pardes-snap/grep/cwd New Del /tmp/pardes-snap/grep/cwd New Del
+| 1 hello TARGET one ls ls
+| 2 nothing here a.txt noise.txt sub a.txt noise.txt sub
| 3 last TARGET line
|
|
-| a.txt
+| a.txt
|
|
|
|
|
|
-| /tmp/pardes-snap/grep/cwd/sub/b.txt Save New Del
+| /tmp/pardes-snap/grep/cwd/sub/b.txt Save Ne
| 1 deep
-| 2 TARGET inside sub /tmp/pardes-snap/grep/cwd New Del
-| ls
-| a.txt noise.txt sub
+| 2 TARGET inside sub /tmp/pardes-snap/grep/cwd New Del
+| ls
+| a.txt noise.txt sub
|
|
|
|
|
-| /tmp/pardes-snap/grep/cwd/sub/+Search New Del
+| /tmp/pardes-snap/grep/cwd/sub/+Search New D
| 1
-| /tmp/pardes-snap/grep/cwd/+Search New Del
+| /tmp/pardes-snap/grep/cwd/+Search New Del
| 2 a.txt:3:6-11 last TARGET line
| 3 sub/b.txt:2:1-6 TARGET inside sub
| 4
== snap chord grid=180x30 cursor=19,2
|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
-| 2 nothing here a.txt noise.txt sub a.txt noise.txt sub
+| /tmp/pardes-snap/grep/cwd/a.txt Save New De /tmp/pardes-snap/grep/cwd New Del /tmp/pardes-snap/grep/cwd New Del
+| 1 hello TARGET one ls ls
+| 2 nothing here a.txt noise.txt sub a.txt noise.txt sub
| 3 last TARGET line
|
|
-| a.txt
+| a.txt
|
|
|
|
|
|
-| /tmp/pardes-snap/grep/cwd/sub/b.txt Save New Del
+| /tmp/pardes-snap/grep/cwd/sub/b.txt Save Ne
| 1 deep
-| 2 TARGET inside sub /tmp/pardes-snap/grep/cwd New Del
-| ls
-| a.txt noise.txt sub
+| 2 TARGET inside sub /tmp/pardes-snap/grep/cwd New Del
+| ls
+| a.txt noise.txt sub
|
|
|
|
|
-| /tmp/pardes-snap/grep/cwd/sub/+Search New Del
+| /tmp/pardes-snap/grep/cwd/sub/+Search New D
| 1
-| /tmp/pardes-snap/grep/cwd/+Search New Del
+| /tmp/pardes-snap/grep/cwd/+Search New Del
| 2 a.txt:3:6-11 last TARGET line
| 3 sub/b.txt:2:1-6 TARGET inside sub
| 4
diff --git a/test/snapshots/hscroll.golden b/test/snapshots/hscroll.golden
index 3aad04d8..b55a00d8 100644
--- a/test/snapshots/hscroll.golden
+++ b/test/snapshots/hscroll.golden
@@ -1,12 +1,12 @@
== snap opened grid=90x30 cursor=7,2
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/hscroll/cwd /tmp/pardes-snap/hscroll/cwd /tmp/pardes-snap/hscroll/cwd
-| 1 short1 END_A_MARKER_WAY ls ls
-| wide.txt wide.txt
+| /tmp/pardes-snap/hscr /tmp/pardes-snap/hsc /tmp/pardes-snap/hscroll/cwd New Del
+| 1 short1 END_A_MAR ls ls
+| wide.txt wide.txt
|
|
|
-| wide.txt
+| wide.txt
|
|
|
@@ -15,9 +15,9 @@
|
|
|
-| /tmp/pardes-snap/hscroll/cwd
-| ls
-| wide.txt
+| /tmp/pardes-snap/hsc
+| ls
+| wide.txt
|
|
|
@@ -31,13 +31,13 @@
|
== snap wheeled grid=90x30 cursor=7,2
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/hscroll/cwd /tmp/pardes-snap/hscroll/cwd /tmp/pardes-snap/hscroll/cwd
-| 1 ND_A_MARKER_WAY_OUT_RIG ls ls
-| wide.txt wide.txt
+| /tmp/pardes-snap/hscr /tmp/pardes-snap/hsc /tmp/pardes-snap/hscroll/cwd New Del
+| 1 ND_A_MARKER_WAY_ ls ls
+| wide.txt wide.txt
|
|
|
-| wide.txt
+| wide.txt
|
|
|
@@ -46,9 +46,9 @@
|
|
|
-| /tmp/pardes-snap/hscroll/cwd
-| ls
-| wide.txt
+| /tmp/pardes-snap/hsc
+| ls
+| wide.txt
|
|
|
@@ -62,13 +62,13 @@
|
== snap clamped grid=90x30 cursor=7,2
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/hscroll/cwd /tmp/pardes-snap/hscroll/cwd /tmp/pardes-snap/hscroll/cwd
-| 1 short1 END_A_MARKER_WAY ls ls
-| wide.txt wide.txt
+| /tmp/pardes-snap/hscr /tmp/pardes-snap/hsc /tmp/pardes-snap/hscroll/cwd New Del
+| 1 short1 END_A_MAR ls ls
+| wide.txt wide.txt
|
|
|
-| wide.txt
+| wide.txt
|
|
|
@@ -77,9 +77,9 @@
|
|
|
-| /tmp/pardes-snap/hscroll/cwd
-| ls
-| wide.txt
+| /tmp/pardes-snap/hsc
+| ls
+| wide.txt
|
|
|
@@ -91,15 +91,15 @@
|
|
|
-== snap dollar grid=90x30 cursor=26,2
+== snap dollar grid=90x30 cursor=19,2
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/hscroll/cwd /tmp/pardes-snap/hscroll/cwd /tmp/pardes-snap/hscroll/cwd
-| 1 padpadpadpadpad_TAIL ls ls
-| wide.txt wide.txt
+| /tmp/pardes-snap/hscr /tmp/pardes-snap/hsc /tmp/pardes-snap/hscroll/cwd New Del
+| 1 adpadpad_TAIL ls ls
+| wide.txt wide.txt
|
|
|
-| wide.txt
+| wide.txt
|
|
|
@@ -108,9 +108,9 @@
|
|
|
-| /tmp/pardes-snap/hscroll/cwd
-| ls
-| wide.txt
+| /tmp/pardes-snap/hsc
+| ls
+| wide.txt
|
|
|
@@ -124,13 +124,13 @@
|
== snap zero grid=90x30 cursor=7,2
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/hscroll/cwd /tmp/pardes-snap/hscroll/cwd /tmp/pardes-snap/hscroll/cwd
-| 1 short1 END_A_MARKER_WAY ls ls
-| wide.txt wide.txt
+| /tmp/pardes-snap/hscr /tmp/pardes-snap/hsc /tmp/pardes-snap/hscroll/cwd New Del
+| 1 short1 END_A_MAR ls ls
+| wide.txt wide.txt
|
|
|
-| wide.txt
+| wide.txt
|
|
|
@@ -139,9 +139,9 @@
|
|
|
-| /tmp/pardes-snap/hscroll/cwd
-| ls
-| wide.txt
+| /tmp/pardes-snap/hsc
+| ls
+| wide.txt
|
|
|
diff --git a/test/snapshots/images.golden b/test/snapshots/images.golden
index e02ec8d6..def1234b 100644
--- a/test/snapshots/images.golden
+++ b/test/snapshots/images.golden
@@ -1,155 +1,155 @@
== snap img-open grid=150x30 cursor=11,7
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| img /tmp/pardes-snap/images/cwd/pic.ppm New Del /tmp/pardes-snap/images/cwd New Del /tmp/pardes-snap/images/cwd New Del
-| ls ls
-| pic.ppm pic.ppm
-| 4*🬹~F~N🬰|G🬏▚Y~KF🬴s<fcSf🬰b^🬘J%🬝ZH|S_"Z}╲%'"qA🬗a🬝🬻
-| 8jDStG▄40e~.>>T&zZ/🬻iTY=`S~╳bkZR37v🬦🬵d4QHZ8X▛▟
-| ^🬪🬛$}DrydNU&wsz=`*r🬒🬓🬳🬟*Y🬴G6🬧f🬮/>t🬧u🬲1Rg1t🬝🬻█
-| ▖#_{🬗X@🬢&$s'C%*=w@🬆▀wq%🬡d┼a┼m─>🬭🬜A▀A🬲H]p🬝🬹 pic.ppm
-| %?WX🬕f🬩*TA8n╱┼E▘🬭Y#qo~XVE▐(╳▀H4^[mj🬗=🬒🬕▟█
-| ]Z🬷eb~Oyl3=SL▙a🬱🬉~MPMz}FMu<🬝▚>3&▖🬷WS🬝~█
-| EF-🬑y🬔!r🬙🬐╳O🬩▐%Qj─4za/?/\a🬜"🬖🬈🬛🬟A🬙🬝▄
-| 🬃N@xzOnczIK=t0@🬖j5🬮🬧65▟{🬮🬉🬄🬩zQ9/🬝🬻
-| ╲${&%@"#ctt▀Cr5F;🬐ARM╱uej╳75🬹9▛🬻
-| h^🬜"🬈🬨▜╲|W🬊🬦k6^🬰🬶rZ─▄\z9🬎🬉4^🬎🬻
-| 🬆f│Q_V🬗wGx🬱=5▛5*&V🬟🬄ZrXGz2🬝🬻█
-| !🬷W🬐(🬭]7x_>▗er🬯~▚di🬬4jY:🬝▟
-| gU~🬧3▙bjr\C#kKMpffAjps🬝🬻 /tmp/pardes-snap/images/cwd New Del
-| *🬯G"🬣▟d🬮▀zJ🬪EHV▟╳9}y▛▄ ls
-| ``))~RyJ🬴jA~w=hLpX🬝🬻█ pic.ppm
-| 4x▀J"TG7=y-QZ🬙╲~▛🬻
-| 🬯/+╳f9ZG🬕+{QN_🬝🬹
-| E<'─🬫pS!"n4&🬝~
-| m$dx"!2🬳&{🬝🬻
-| *8f🬩▙Jb🬒🬝🬻█
-| m🬩7CiN🬎🬹
-| pG╲L🬝🬻
-| 🬡L🬝▄
-| 🬝~
+| img petscii:off palette:commodore as /tmp/pardes-snap/images/cwd New Del /tmp/pardes-snap/images/cwd New Del
+| ls ls
+| pic.ppm pic.ppm
+|
+|
+|
+| 🬥~CKA$4k\RQ/>g▙))▐p_'YztV1🬏%🬮E🬝CQX🬆▄ pic.ppm
+| 2Ss)L▖~ey🬵🬨╳/\,t🬊🬢~🬦/🬧G]}&▚r4n4w🬎🬹
+| 🬪f]V🬱Fk▚KhO~GP🬎/Y🬶pzXJ🬎╱@^c*🬚r🬝▟
+| mQ▌5Ee"^S(╱U^5🬥(`e🬫🬋Fb🬢$╱▄🬢#🬝🬻█
+| 83KHF▜╳A$DqA`🬬"`▚N$j🬥🬫q$W🬺🬝▄
+| 7Z🬏xq🬱E🬊H^🬅🬬}Z_╲S@aQSVJH🬝~█
+| d\>y🬪O🬢j5D07E┼🬍=Eh▄jHC🬆▄
+| 8[🬓Ke_#r"🬳q4FaM▚\$r'🬝🬹█
+| X];🬝{>▄kU┼🬳)DUbYhb🬎~
+| rHo0i5🬱msyc[W_dB▛🬻 /tmp/pardes-snap/images/cwd New Del
+| 7?🬊k#ZF*v🬥"#^h🬝🬻█ ls
+| ╲uc-q4q?*2f.🬝🬻█ pic.ppm
+| eY─a?🬫^🬆I🬁🬝🬹
+| 2🬷r🬲.x49🬝🬻
+| 2╱Y─Tc🬝🬹█
+| 26🬭g▛🬻
+| 🬩🬬🬝🬻█
+| 🬆🬹█
+|
+|
+|
|
|
== style img-open
|0: 0-56 #949494,#121212, 57-149 d,#121212,
-|1: 0-1 #ffffff,#626262, 2-48 #949494,#121212, 49-49 d,#121212, 50-51 #ffffff,#262626, 52-86 #949494,#121212, 87-99 d,#121212, 100-101 #ffffff,#262626, 102-136 #949494,#121212, 137-149 d,#121212,
-|2: 0-0 d,#121212, 1-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|3: 0-0 d,#121212, 1-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|4: 0-0 d,#121212, 1-1 d,#080808, 2-9 #959595,#6c6c6c, 10-10 #6c6c6c,#959595, 11-15 #959595,#6c6c6c, 16-16 #6c6c6c,#959595, 17-20 #959595,#6c6c6c, 21-23 #6c6c6c,#959595, 24-25 #959595,#6c6c6c, 26-26 #6c6c6c,#959595, 27-34 #959595,#6c6c6c, 35-35 #6c6c6c,#959595, 36-40 #959595,#6c6c6c, 41-41 #6c6c6c,#959595, 42-45 #959595,#6c6c6c, 46-47 #6c6c6c,#959595, 48-48 #959595,#352879, 49-49 #352879,#959595, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|5: 0-0 d,#121212, 1-1 d,#080808, 2-2 #6c6c6c,#959595, 3-4 #959595,#6c6c6c, 5-5 #6c6c6c,#959595, 6-6 #959595,#6c6c6c, 7-7 #6c6c6c,#959595, 8-8 #959595,#6c6c6c, 9-9 #6c6c6c,#959595, 10-10 #959595,#6c6c6c, 11-11 #6c6c6c,#959595, 12-16 #959595,#6c6c6c, 17-17 #6c6c6c,#959595, 18-20 #959595,#6c6c6c, 21-22 #6c6c6c,#959595, 23-23 #959595,#6c6c6c, 24-24 #6c6c6c,#959595, 25-25 #959595,#6c6c6c, 26-26 #444444,#959595, 27-27 #6c6c6c,#959595, 28-31 #959595,#6c6c6c, 32-34 #6c6c6c,#959595, 35-35 #959595,#6c6c6c, 36-36 #6c6c6c,#959595, 37-39 #959595,#6c6c6c, 40-40 #6c6c6c,#959595, 41-42 #959595,#6c6c6c, 43-44 #6c6c6c,#959595, 45-45 #959595,#6c6c6c, 46-46 #959595,#352879, 47-47 #352879,#959595, 48-49 #352879,#352879, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|6: 0-0 d,#121212, 1-1 d,#080808, 2-5 #959595,#6c6c6c, 6-6 #6c6c6c,#959595, 7-7 #959595,#6c6c6c, 8-8 #6c6c6c,#959595, 9-9 #959595,#6c6c6c, 10-10 #6c6c6c,#959595, 11-11 #959595,#6c6c6c, 12-12 #6c6c6c,#959595, 13-14 #959595,#6c6c6c, 15-15 #6c6c6c,#959595, 16-16 #959595,#6c6c6c, 17-17 #6c6c6c,#959595, 18-18 #959595,#6c6c6c, 19-19 #6c6c6c,#959595, 20-20 #959595,#6c6c6c, 21-21 #6c6c6c,#959595, 22-25 #959595,#6c6c6c, 26-28 #6c6c6c,#959595, 29-30 #959595,#6c6c6c, 31-31 #6c6c6c,#959595, 32-33 #959595,#6c6c6c, 34-36 #6c6c6c,#959595, 37-38 #959595,#6c6c6c, 39-41 #6c6c6c,#959595, 42-42 #959595,#6c6c6c, 43-43 #6c6c6c,#959595, 44-44 #6c6c6c,#352879, 45-45 #352879,#959595, 46-46 #352879,#6c5eb5, 47-49 #352879,#352879, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|7: 0-0 d,#121212, 1-1 d,#080808, 2-6 #959595,#6c6c6c, 7-7 #6c6c6c,#959595, 8-10 #959595,#6c6c6c, 11-11 #6c6c6c,#959595, 12-16 #959595,#6c6c6c, 17-17 #444444,#6c6c6c, 18-18 #959595,#6c6c6c, 19-20 #6c6c6c,#959595, 21-21 #959595,#6c6c6c, 22-22 #6c6c6c,#959595, 23-23 #959595,#6c6c6c, 24-24 #6c6c6c,#959595, 25-25 #959595,#6c6c6c, 26-29 #6c6c6c,#959595, 30-32 #959595,#6c6c6c, 33-33 #6c6c6c,#959595, 34-34 #959595,#6c6c6c, 35-35 #6c6c6c,#959595, 36-36 #959595,#6c6c6c, 37-37 #6c6c6c,#959595, 38-38 #959595,#6c6c6c, 39-39 #6c6c6c,#959595, 40-41 #959595,#6c6c6c, 42-42 #6c6c6c,#352879, 43-43 #352879,#6c5eb5, 44-49 #352879,#352879, 50-50 d,#444444, 51-51 d,#080808, 52-60 #bdbdbd,#080808, 61-99 d,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|8: 0-0 d,#121212, 1-1 d,#080808, 2-5 #6c6c6c,#959595, 6-7 #959595,#6c6c6c, 8-9 #6c6c6c,#959595, 10-12 #959595,#6c6c6c, 13-13 #6c6c6c,#959595, 14-14 #959595,#6c6c6c, 15-16 #6c6c6c,#959595, 17-18 #959595,#6c6c6c, 19-22 #6c6c6c,#959595, 23-25 #959595,#6c6c6c, 26-26 #6c6c6c,#959595, 27-27 #959595,#6c6c6c, 28-30 #6c6c6c,#959595, 31-39 #959595,#6c6c6c, 40-40 #959595,#352879, 41-41 #352879,#6c6c6c, 42-42 #352879,#6c5eb5, 43-49 #352879,#352879, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|9: 0-0 d,#121212, 1-1 d,#080808, 2-5 #959595,#6c6c6c, 6-6 #6c6c6c,#959595, 7-7 #959595,#6c6c6c, 8-9 #6c6c6c,#959595, 10-10 #959595,#6c6c6c, 11-11 #6c6c6c,#959595, 12-12 #959595,#6c6c6c, 13-14 #6c6c6c,#959595, 15-16 #959595,#6c6c6c, 17-17 #6c6c6c,#959595, 18-20 #959595,#6c6c6c, 21-22 #6c6c6c,#959595, 23-28 #959595,#6c6c6c, 29-30 #6c6c6c,#959595, 31-35 #959595,#6c6c6c, 36-36 #6c6c6c,#959595, 37-37 #959595,#6c6c6c, 38-39 #959595,#352879, 40-40 #352879,#6c5eb5, 41-49 #352879,#352879, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|10: 0-0 d,#121212, 1-1 d,#080808, 2-2 #959595,#6c6c6c, 3-3 #6c6c6c,#959595, 4-6 #959595,#6c6c6c, 7-7 #6c6c6c,#959595, 8-8 #959595,#6c6c6c, 9-9 #6c6c6c,#959595, 10-16 #959595,#6c6c6c, 17-17 #6c6c6c,#959595, 18-21 #959595,#6c6c6c, 22-22 #6c6c6c,#959595, 23-23 #959595,#6c6c6c, 24-24 #6c6c6c,#959595, 25-26 #959595,#6c6c6c, 27-28 #6c6c6c,#959595, 29-31 #959595,#6c6c6c, 32-32 #6c6c6c,#959595, 33-33 #959595,#6c6c6c, 34-34 #6c6c6c,#959595, 35-35 #959595,#6c6c6c, 36-36 #959595,#352879, 37-37 #352879,#6c5eb5, 38-49 #352879,#352879, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|11: 0-0 d,#121212, 1-1 d,#080808, 2-2 #959595,#6c6c6c, 3-4 #6c6c6c,#959595, 5-5 #959595,#6c6c6c, 6-8 #6c6c6c,#959595, 9-11 #959595,#6c6c6c, 12-12 #6c6c6c,#959595, 13-15 #959595,#6c6c6c, 16-16 #6c6c6c,#959595, 17-17 #959595,#6c6c6c, 18-18 #6c6c6c,#959595, 19-22 #959595,#6c6c6c, 23-24 #6c6c6c,#959595, 25-25 #959595,#6c6c6c, 26-26 #6c6c6c,#959595, 27-27 #959595,#6c6c6c, 28-28 #6c6c6c,#959595, 29-29 #959595,#6c6c6c, 30-30 #6c6c6c,#959595, 31-31 #959595,#6c6c6c, 32-32 #6c6c6c,#959595, 33-33 #959595,#6c6c6c, 34-34 #6c6c6c,#352879, 35-35 #352879,#6c6c6c, 36-49 #352879,#352879, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|12: 0-0 d,#121212, 1-1 d,#080808, 2-2 #444444,#959595, 3-5 #959595,#6c6c6c, 6-6 #6c6c6c,#959595, 7-7 #959595,#6c6c6c, 8-8 #444444,#959595, 9-11 #959595,#6c6c6c, 12-13 #6c6c6c,#959595, 14-14 #959595,#6c6c6c, 15-16 #6c6c6c,#959595, 17-17 #959595,#6c6c6c, 18-18 #6c6c6c,#959595, 19-20 #959595,#6c6c6c, 21-21 #6c6c6c,#959595, 22-23 #959595,#6c6c6c, 24-24 #6c6c6c,#959595, 25-25 #959595,#6c6c6c, 26-26 #6c6c6c,#959595, 27-28 #959595,#6c6c6c, 29-30 #6c6c6c,#959595, 31-31 #959595,#6c6c6c, 32-32 #959595,#352879, 33-33 #352879,#6c6c6c, 34-49 #352879,#352879, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|13: 0-0 d,#121212, 1-1 d,#080808, 2-6 #959595,#6c6c6c, 7-7 #6c6c6c,#959595, 8-9 #959595,#6c6c6c, 10-11 #6c6c6c,#959595, 12-13 #959595,#6c6c6c, 14-14 #6c6c6c,#959595, 15-15 #959595,#6c6c6c, 16-16 #6c6c6c,#959595, 17-18 #959595,#6c6c6c, 19-19 #6c6c6c,#959595, 20-24 #959595,#6c6c6c, 25-26 #6c6c6c,#959595, 27-28 #959595,#6c6c6c, 29-29 #6c6c6c,#959595, 30-30 #959595,#352879, 31-31 #352879,#959595, 32-49 #352879,#352879, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|14: 0-0 d,#121212, 1-1 d,#080808, 2-2 #6c6c6c,#959595, 3-3 #959595,#6c6c6c, 4-4 #6c6c6c,#959595, 5-9 #959595,#6c6c6c, 10-11 #6c6c6c,#959595, 12-14 #959595,#6c6c6c, 15-15 #6c6c6c,#959595, 16-17 #959595,#6c6c6c, 18-19 #6c6c6c,#959595, 20-21 #959595,#6c6c6c, 22-25 #6c6c6c,#959595, 26-27 #959595,#6c6c6c, 28-28 #6c6c6c,#352879, 29-29 #352879,#959595, 30-30 #352879,#6c5eb5, 31-49 #352879,#352879, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|15: 0-0 d,#121212, 1-1 d,#080808, 2-2 #959595,#6c6c6c, 3-3 #6c6c6c,#959595, 4-13 #959595,#6c6c6c, 14-15 #6c6c6c,#959595, 16-18 #959595,#6c6c6c, 19-21 #6c6c6c,#959595, 22-23 #959595,#6c6c6c, 24-25 #6c6c6c,#959595, 26-26 #6c6c6c,#352879, 27-27 #352879,#6c6c6c, 28-49 #352879,#352879, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|16: 0-0 d,#121212, 1-1 d,#080808, 2-2 #959595,#6c6c6c, 3-5 #6c6c6c,#959595, 6-10 #959595,#6c6c6c, 11-11 #444444,#959595, 12-12 #959595,#6c6c6c, 13-13 #6c6c6c,#959595, 14-14 #959595,#6c6c6c, 15-16 #6c6c6c,#959595, 17-19 #959595,#6c6c6c, 20-21 #6c6c6c,#959595, 22-22 #959595,#6c6c6c, 23-23 #6c6c6c,#959595, 24-24 #6c6c6c,#352879, 25-25 #352879,#6c6c6c, 26-49 #352879,#352879, 50-51 #ffffff,#262626, 52-86 #949494,#121212, 87-99 d,#121212, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|17: 0-0 d,#121212, 1-1 d,#080808, 2-2 #959595,#6c6c6c, 3-5 #6c6c6c,#959595, 6-13 #959595,#6c6c6c, 14-14 #6c6c6c,#959595, 15-16 #959595,#6c6c6c, 17-17 #6c6c6c,#959595, 18-19 #959595,#6c6c6c, 20-20 #6c6c6c,#959595, 21-21 #959595,#6c6c6c, 22-22 #959595,#352879, 23-23 #352879,#6c5eb5, 24-49 #352879,#352879, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|18: 0-0 d,#121212, 1-1 d,#080808, 2-6 #959595,#6c6c6c, 7-9 #6c6c6c,#959595, 10-12 #959595,#6c6c6c, 13-13 #6c6c6c,#959595, 14-16 #959595,#6c6c6c, 17-19 #6c6c6c,#959595, 20-20 #6c6c6c,#352879, 21-21 #352879,#6c6c6c, 22-22 #352879,#6c5eb5, 23-49 #352879,#352879, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|19: 0-0 d,#121212, 1-1 d,#080808, 2-2 #6c6c6c,#959595, 3-6 #959595,#6c6c6c, 7-7 #6c6c6c,#959595, 8-17 #959595,#6c6c6c, 18-18 #6c6c6c,#352879, 19-19 #352879,#6c6c6c, 20-49 #352879,#352879, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|20: 0-0 d,#121212, 1-1 d,#080808, 2-2 #959595,#6c6c6c, 3-3 #6c6c6c,#959595, 4-4 #959595,#6c6c6c, 5-5 #6c6c6c,#959595, 6-8 #959595,#6c6c6c, 9-12 #6c6c6c,#959595, 13-15 #959595,#6c6c6c, 16-16 #6c6c6c,#352879, 17-17 #352879,#6c5eb5, 18-49 #352879,#352879, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|21: 0-0 d,#121212, 1-1 d,#080808, 2-2 #959595,#6c6c6c, 3-3 #6c6c6c,#959595, 4-5 #959595,#6c6c6c, 6-6 #6c6c6c,#959595, 7-10 #959595,#6c6c6c, 11-12 #6c6c6c,#959595, 13-13 #959595,#6c6c6c, 14-14 #6c6c6c,#352879, 15-15 #959595,#352879, 16-49 #352879,#352879, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|22: 0-0 d,#121212, 1-1 d,#080808, 2-3 #959595,#6c6c6c, 4-5 #6c6c6c,#959595, 6-6 #959595,#6c6c6c, 7-7 #6c6c6c,#959595, 8-8 #959595,#6c6c6c, 9-11 #6c6c6c,#959595, 12-12 #6c6c6c,#352879, 13-13 #352879,#959595, 14-49 #352879,#352879, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|23: 0-0 d,#121212, 1-1 d,#080808, 2-2 #959595,#6c6c6c, 3-3 #6c6c6c,#959595, 4-5 #959595,#6c6c6c, 6-6 #6c6c6c,#959595, 7-7 #959595,#6c6c6c, 8-8 #6c6c6c,#959595, 9-9 #959595,#6c6c6c, 10-10 #6c6c6c,#352879, 11-11 #352879,#959595, 12-12 #352879,#6c5eb5, 13-49 #352879,#352879, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|24: 0-0 d,#121212, 1-1 d,#080808, 2-3 #959595,#6c6c6c, 4-7 #6c6c6c,#959595, 8-8 #959595,#352879, 9-9 #352879,#6c6c6c, 10-49 #352879,#352879, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|25: 0-0 d,#121212, 1-1 d,#080808, 2-2 #959595,#6c6c6c, 3-3 #6c6c6c,#959595, 4-5 #959595,#6c6c6c, 6-6 #6c6c6c,#352879, 7-7 #352879,#959595, 8-49 #352879,#352879, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|26: 0-0 d,#121212, 1-1 d,#080808, 2-3 #959595,#6c6c6c, 4-4 #6c6c6c,#352879, 5-5 #352879,#6c5eb5, 6-49 #352879,#352879, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|27: 0-0 d,#121212, 1-1 d,#080808, 2-2 #6c6c6c,#352879, 3-3 #959595,#352879, 4-49 #352879,#352879, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|28: 0-0 d,#121212, 1-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|29: 0-0 d,#121212, 1-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
+|1: 0-1 #ffffff,#626262, 2-37 #949494,#121212, 38-39 #ffffff,#262626, 40-74 #949494,#121212, 75-76 #ffffff,#262626, 77-129 #949494,#121212, 130-149 d,#121212,
+|2: 0-0 d,#121212, 1-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|3: 0-0 d,#121212, 1-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|4: 0-0 d,#121212, 1-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|5: 0-0 d,#121212, 1-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|6: 0-0 d,#121212, 1-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|7: 0-0 d,#121212, 1-1 d,#080808, 2-2 #959595,#6c6c6c, 3-3 #6c6c6c,#959595, 4-7 #959595,#6c6c6c, 8-8 #6c6c6c,#959595, 9-11 #959595,#6c6c6c, 12-12 #6c6c6c,#959595, 13-13 #959595,#6c6c6c, 14-15 #6c6c6c,#959595, 16-16 #959595,#6c6c6c, 17-18 #6c6c6c,#959595, 19-19 #959595,#6c6c6c, 20-20 #6c6c6c,#959595, 21-21 #959595,#6c6c6c, 22-24 #6c6c6c,#959595, 25-25 #959595,#6c6c6c, 26-26 #6c6c6c,#959595, 27-28 #959595,#6c6c6c, 29-29 #6c6c6c,#959595, 30-30 #959595,#6c6c6c, 31-32 #6c6c6c,#959595, 33-33 #959595,#6c6c6c, 34-35 #6c6c6c,#959595, 36-36 #959595,#6c5eb5, 37-37 #352879,#6c5eb5, 38-38 d,#444444, 39-39 d,#080808, 40-48 #bdbdbd,#080808, 49-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|8: 0-0 d,#121212, 1-1 d,#080808, 2-3 #959595,#6c6c6c, 4-6 #6c6c6c,#959595, 7-8 #959595,#6c6c6c, 9-9 #6c6c6c,#959595, 10-11 #959595,#6c6c6c, 12-12 #6c6c6c,#959595, 13-19 #959595,#6c6c6c, 20-20 #6c6c6c,#959595, 21-22 #959595,#6c6c6c, 23-23 #6c6c6c,#959595, 24-26 #959595,#6c6c6c, 27-28 #6c6c6c,#959595, 29-31 #959595,#6c6c6c, 32-33 #6c6c6c,#959595, 34-34 #959595,#352879, 35-35 #352879,#6c5eb5, 36-37 #352879,#352879, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|9: 0-0 d,#121212, 1-1 d,#080808, 2-2 #6c6c6c,#959595, 3-5 #959595,#6c6c6c, 6-6 #6c6c6c,#959595, 7-7 #959595,#6c6c6c, 8-8 #6c6c6c,#959595, 9-16 #959595,#6c6c6c, 17-18 #6c6c6c,#959595, 19-19 #959595,#6c6c6c, 20-20 #6c6c6c,#959595, 21-21 #959595,#6c6c6c, 22-22 #6c6c6c,#959595, 23-24 #959595,#6c6c6c, 25-25 #6c6c6c,#959595, 26-28 #959595,#6c6c6c, 29-31 #6c6c6c,#959595, 32-32 #6c6c6c,#352879, 33-33 #352879,#959595, 34-37 #352879,#352879, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|10: 0-0 d,#121212, 1-1 d,#080808, 2-2 #959595,#6c6c6c, 3-4 #6c6c6c,#959595, 5-6 #959595,#6c6c6c, 7-7 #6c6c6c,#959595, 8-12 #959595,#6c6c6c, 13-14 #6c6c6c,#959595, 15-19 #959595,#6c6c6c, 20-20 #6c6c6c,#959595, 21-29 #959595,#6c6c6c, 30-30 #6c6c6c,#352879, 31-31 #352879,#6c6c6c, 32-32 #352879,#6c5eb5, 33-37 #352879,#352879, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|11: 0-0 d,#121212, 1-1 d,#080808, 2-2 #6c6c6c,#959595, 3-5 #959595,#6c6c6c, 6-6 #6c6c6c,#959595, 7-9 #959595,#6c6c6c, 10-11 #6c6c6c,#959595, 12-12 #959595,#6c6c6c, 13-13 #6c6c6c,#959595, 14-14 #959595,#6c6c6c, 15-15 #6c6c6c,#959595, 16-16 #959595,#6c6c6c, 17-17 #6c6c6c,#959595, 18-19 #959595,#6c6c6c, 20-20 #6c6c6c,#959595, 21-21 #959595,#6c6c6c, 22-24 #6c6c6c,#959595, 25-25 #959595,#6c6c6c, 26-26 #6c6c6c,#959595, 27-27 #959595,#6c6c6c, 28-28 #6c6c6c,#352879, 29-29 #352879,#6c5eb5, 30-37 #352879,#352879, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|12: 0-0 d,#121212, 1-1 d,#080808, 2-8 #959595,#6c6c6c, 9-9 #6c6c6c,#959595, 10-12 #959595,#6c6c6c, 13-15 #6c6c6c,#959595, 16-18 #959595,#6c6c6c, 19-19 #6c6c6c,#959595, 20-20 #959595,#6c6c6c, 21-23 #6c6c6c,#959595, 24-25 #959595,#6c6c6c, 26-27 #6c6c6c,#352879, 28-28 #352879,#6c5eb5, 29-37 #352879,#352879, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|13: 0-0 d,#121212, 1-1 d,#080808, 2-8 #959595,#6c6c6c, 9-9 #6c6c6c,#959595, 10-10 #959595,#6c6c6c, 11-12 #6c6c6c,#959595, 13-14 #959595,#6c6c6c, 15-15 #6c6c6c,#959595, 16-17 #959595,#6c6c6c, 18-18 #6c6c6c,#959595, 19-19 #959595,#6c6c6c, 20-20 #6c6c6c,#959595, 21-23 #959595,#6c6c6c, 24-24 #959595,#6c5eb5, 25-25 #352879,#6c5eb5, 26-37 #352879,#352879, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|14: 0-0 d,#121212, 1-1 d,#080808, 2-3 #6c6c6c,#959595, 4-5 #959595,#6c6c6c, 6-6 #6c6c6c,#959595, 7-11 #959595,#6c6c6c, 12-13 #6c6c6c,#959595, 14-15 #959595,#6c6c6c, 16-16 #6c6c6c,#959595, 17-21 #959595,#6c6c6c, 22-22 #6c6c6c,#352879, 23-24 #352879,#6c5eb5, 25-37 #352879,#352879, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|15: 0-0 d,#121212, 1-1 d,#080808, 2-2 #6c6c6c,#959595, 3-4 #959595,#6c6c6c, 5-6 #6c6c6c,#959595, 7-8 #959595,#6c6c6c, 9-9 #6c6c6c,#959595, 10-13 #959595,#6c6c6c, 14-16 #6c6c6c,#959595, 17-17 #959595,#6c6c6c, 18-19 #6c6c6c,#959595, 20-20 #6c6c6c,#352879, 21-21 #959595,#352879, 22-37 #352879,#352879, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|16: 0-0 d,#121212, 1-1 d,#080808, 2-3 #959595,#6c6c6c, 4-4 #6c6c6c,#959595, 5-5 #959595,#6c6c6c, 6-6 #6c6c6c,#959595, 7-13 #959595,#6c6c6c, 14-14 #6c6c6c,#959595, 15-16 #959595,#6c6c6c, 17-17 #6c6c6c,#959595, 18-18 #959595,#352879, 19-19 #352879,#6c6c6c, 20-37 #352879,#352879, 38-39 #ffffff,#262626, 40-74 #949494,#121212, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|17: 0-0 d,#121212, 1-1 d,#080808, 2-6 #959595,#6c6c6c, 7-7 #6c6c6c,#959595, 8-8 #959595,#6c6c6c, 9-11 #6c6c6c,#959595, 12-12 #959595,#6c6c6c, 13-13 #6c6c6c,#959595, 14-15 #959595,#6c6c6c, 16-16 #6c6c6c,#352879, 17-17 #352879,#959595, 18-18 #352879,#6c5eb5, 19-37 #352879,#352879, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|18: 0-0 d,#121212, 1-1 d,#080808, 2-6 #959595,#6c6c6c, 7-8 #6c6c6c,#959595, 9-9 #959595,#6c6c6c, 10-10 #6c6c6c,#959595, 11-13 #959595,#6c6c6c, 14-14 #6c6c6c,#352879, 15-15 #352879,#6c6c6c, 16-16 #352879,#6c5eb5, 17-37 #352879,#352879, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|19: 0-0 d,#121212, 1-1 d,#080808, 2-3 #6c6c6c,#959595, 4-4 #959595,#6c6c6c, 5-6 #6c6c6c,#959595, 7-7 #959595,#6c6c6c, 8-8 #6c6c6c,#959595, 9-9 #959595,#6c6c6c, 10-10 #6c6c6c,#959595, 11-11 #959595,#6c6c6c, 12-12 #6c6c6c,#352879, 13-13 #352879,#6c5eb5, 14-37 #352879,#352879, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|20: 0-0 d,#121212, 1-1 d,#080808, 2-5 #6c6c6c,#959595, 6-7 #959595,#6c6c6c, 8-8 #6c6c6c,#959595, 9-9 #959595,#6c6c6c, 10-10 #6c6c6c,#352879, 11-11 #352879,#6c6c6c, 12-37 #352879,#352879, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|21: 0-0 d,#121212, 1-1 d,#080808, 2-2 #959595,#6c6c6c, 3-4 #6c6c6c,#959595, 5-7 #959595,#6c6c6c, 8-8 #6c6c6c,#352879, 9-10 #352879,#6c5eb5, 11-37 #352879,#352879, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|22: 0-0 d,#121212, 1-1 d,#080808, 2-5 #6c6c6c,#959595, 6-6 #6c6c6c,#352879, 7-7 #352879,#6c6c6c, 8-37 #352879,#352879, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|23: 0-0 d,#121212, 1-1 d,#080808, 2-2 #959595,#6c6c6c, 3-3 #6c6c6c,#959595, 4-4 #6c6c6c,#352879, 5-5 #352879,#6c6c6c, 6-6 #352879,#6c5eb5, 7-37 #352879,#352879, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|24: 0-0 d,#121212, 1-1 d,#080808, 2-2 #959595,#6c5eb5, 3-4 #352879,#6c5eb5, 5-37 #352879,#352879, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|25: 0-0 d,#121212, 1-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|26: 0-0 d,#121212, 1-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|27: 0-0 d,#121212, 1-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|28: 0-0 d,#121212, 1-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|29: 0-0 d,#121212, 1-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
== style term-palette
|0: 0-56 #949494,#121212, 57-149 d,#121212,
-|1: 0-1 #ffffff,#626262, 2-48 #949494,#121212, 49-49 d,#121212, 50-51 #ffffff,#262626, 52-86 #949494,#121212, 87-99 d,#121212, 100-101 #ffffff,#262626, 102-136 #949494,#121212, 137-149 d,#121212,
-|2: 0-0 d,#121212, 1-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|3: 0-0 d,#121212, 1-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|4: 0-0 d,#121212, 1-1 d,#080808, 2-2 p5,p8, 3-3 p4,p8, 4-9 p5,p8, 10-10 p8,p4, 11-11 p5,p8, 12-12 p8,p4, 13-13 p8,p5, 14-16 p5,p8, 17-17 p8,p5, 18-18 p8,p4, 19-21 p5,p8, 22-22 p8,p4, 23-23 p5,p8, 24-26 p8,p4, 27-27 p4,p8, 28-28 p8,p5, 29-29 p5,p8, 30-30 p4,p8, 31-31 p8,p5, 32-32 p4,p8, 33-34 p5,p8, 35-35 p4,p8, 36-36 p5,p8, 37-37 p4,p8, 38-40 p5,p8, 41-41 p8,p5, 42-45 p4,p8, 46-47 p8,p4, 48-48 p5,p8, 49-49 p8,p4, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|5: 0-0 d,#121212, 1-1 d,#080808, 2-2 p5,p8, 3-4 p4,p8, 5-6 p5,p8, 7-7 p8,p4, 8-8 p8,p5, 9-9 p5,p8, 10-10 p4,p8, 11-11 p8,p4, 12-13 p5,p8, 14-16 p4,p8, 17-18 p8,p5, 19-20 p4,p8, 21-21 p8,p4, 22-22 p5,p8, 23-23 p4,p8, 24-24 p8,p4, 25-25 p5,p8, 26-26 p4,p8, 27-27 p8,p4, 28-31 p4,p8, 32-32 p5,p8, 33-33 p8,p5, 34-34 p4,p8, 35-36 p5,p8, 37-37 p8,p5, 38-38 p5,p8, 39-39 p4,p8, 40-40 p5,p8, 41-41 p8,p0, 42-43 p4,p8, 44-44 p8,p4, 45-45 p4,p8, 46-47 p5,p8, 48-48 p8,p12, 49-49 p12,p12, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|6: 0-0 d,#121212, 1-1 d,#080808, 2-2 p5,p8, 3-3 p4,p8, 4-4 p8,p5, 5-6 p5,p8, 7-7 p4,p8, 8-8 p8,p5, 9-9 p8,p4, 10-10 p8,p5, 11-14 p5,p8, 15-16 p8,p5, 17-17 p5,p8, 18-19 p4,p8, 20-20 p5,p8, 21-21 p8,p4, 22-22 p5,p8, 23-23 p8,p4, 24-24 p8,p5, 25-25 p5,p8, 26-26 p8,p5, 27-28 p8,p4, 29-30 p4,p8, 31-32 p8,p4, 33-34 p4,p8, 35-35 p8,p4, 36-36 p5,p8, 37-38 p4,p8, 39-40 p5,p8, 41-41 p4,p8, 42-42 p5,p8, 43-43 p4,p8, 44-44 p5,p8, 45-46 p8,p12, 47-49 p12,p12, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|7: 0-0 d,#121212, 1-1 d,#080808, 2-2 p8,p4, 3-5 p4,p8, 6-6 p8,p4, 7-8 p5,p8, 9-10 p4,p8, 11-11 p8,p5, 12-12 p4,p8, 13-13 p5,p8, 14-14 p4,p8, 15-16 p5,p8, 17-17 p8,p0, 18-18 p4,p8, 19-19 p8,p4, 20-20 p5,p8, 21-21 p8,p5, 22-22 p5,p8, 23-23 p4,p8, 24-24 p5,p8, 25-27 p8,p4, 28-29 p5,p8, 30-31 p4,p8, 32-32 p5,p8, 33-34 p8,p4, 35-37 p5,p8, 38-38 p8,p4, 39-39 p5,p8, 40-41 p4,p8, 42-42 p8,p12, 43-43 p12,p8, 44-49 p12,p12, 50-50 d,#444444, 51-51 d,#080808, 52-60 #bdbdbd,#080808, 61-99 d,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|8: 0-0 d,#121212, 1-1 d,#080808, 2-3 p4,p8, 4-4 p8,p5, 5-5 p8,p4, 6-6 p5,p8, 7-7 p4,p8, 8-8 p8,p4, 9-9 p8,p5, 10-12 p5,p8, 13-13 p8,p4, 14-14 p5,p8, 15-15 p8,p4, 16-16 p4,p8, 17-19 p8,p5, 20-22 p5,p8, 23-25 p4,p8, 26-26 p7,p8, 27-27 p8,p4, 28-28 p5,p8, 29-31 p4,p8, 32-32 p8,p5, 33-33 p5,p8, 34-34 p4,p8, 35-35 p8,p5, 36-36 p8,p4, 37-38 p5,p8, 39-39 p8,p4, 40-40 p7,p8, 41-41 p12,p8, 42-49 p12,p12, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|9: 0-0 d,#121212, 1-1 d,#080808, 2-2 p4,p8, 3-3 p5,p8, 4-4 p8,p4, 5-5 p5,p8, 6-6 p8,p4, 7-7 p8,p5, 8-9 p5,p8, 10-10 p4,p8, 11-11 p8,p4, 12-12 p4,p8, 13-13 p8,p4, 14-14 p4,p8, 15-15 p5,p8, 16-16 p4,p8, 17-18 p8,p4, 19-20 p5,p8, 21-21 p8,p4, 22-22 p5,p8, 23-23 p4,p8, 24-24 p5,p8, 25-25 p4,p8, 26-26 p8,p4, 27-27 p8,p5, 28-28 p5,p8, 29-30 p8,p5, 31-32 p5,p8, 33-33 p4,p8, 34-34 p8,p4, 35-36 p8,p5, 37-38 p4,p8, 39-39 p12,p8, 40-49 p12,p12, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|10: 0-0 d,#121212, 1-1 d,#080808, 2-2 p4,p8, 3-4 p5,p8, 5-5 p8,p4, 6-6 p4,p8, 7-7 p8,p4, 8-10 p5,p8, 11-11 p8,p4, 12-12 p5,p8, 13-16 p4,p8, 17-17 p8,p4, 18-18 p5,p8, 19-23 p4,p8, 24-26 p5,p8, 27-28 p8,p5, 29-29 p5,p8, 30-30 p8,p5, 31-31 p8,p4, 32-32 p5,p8, 33-33 p8,p5, 34-34 p8,p4, 35-35 p5,p8, 36-36 p8,p4, 37-37 p12,p8, 38-49 p12,p12, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|11: 0-0 d,#121212, 1-1 d,#080808, 2-2 p8,p5, 3-3 p4,p8, 4-4 p8,p4, 5-5 p5,p8, 6-6 p4,p8, 7-8 p8,p4, 9-9 p4,p8, 10-11 p5,p8, 12-12 p8,p4, 13-14 p5,p8, 15-15 p4,p8, 16-16 p8,p4, 17-17 p8,p5, 18-18 p7,p8, 19-19 p4,p8, 20-20 p8,p5, 21-21 p8,p4, 22-22 p4,p8, 23-23 p5,p8, 24-24 p4,p8, 25-26 p5,p8, 27-29 p8,p4, 30-34 p5,p8, 35-35 p12,p8, 36-36 p12,p0, 37-49 p12,p12, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|12: 0-0 d,#121212, 1-1 d,#080808, 2-4 p5,p8, 5-5 p4,p8, 6-6 p8,p4, 7-7 p4,p8, 8-8 p5,p8, 9-9 p8,p4, 10-10 p5,p8, 11-11 p4,p8, 12-12 p8,p5, 13-13 p8,p4, 14-15 p5,p8, 16-16 p7,p8, 17-17 p4,p8, 18-18 p5,p8, 19-19 p8,p4, 20-20 p4,p8, 21-22 p5,p8, 23-23 p4,p8, 24-24 p8,p4, 25-25 p8,p5, 26-26 p5,p8, 27-27 p8,p5, 28-28 p5,p8, 29-29 p4,p8, 30-30 p5,p8, 31-31 p4,p8, 32-32 p8,p5, 33-33 p12,p8, 34-34 p12,p0, 35-49 p12,p12, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|13: 0-0 d,#121212, 1-1 d,#080808, 2-3 p4,p8, 4-4 p5,p8, 5-5 p4,p8, 6-7 p8,p4, 8-8 p5,p8, 9-9 p4,p8, 10-11 p5,p8, 12-13 p8,p4, 14-14 p5,p8, 15-15 p4,p8, 16-16 p5,p8, 17-17 p8,p4, 18-18 p8,p5, 19-21 p5,p8, 22-22 p8,p4, 23-23 p8,p5, 24-24 p4,p8, 25-26 p8,p4, 27-27 p8,p5, 28-28 p8,p4, 29-29 p4,p8, 30-30 p8,p4, 31-31 p8,p12, 32-49 p12,p12, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|14: 0-0 d,#121212, 1-1 d,#080808, 2-3 p5,p8, 4-4 p4,p8, 5-5 p5,p8, 6-7 p4,p8, 8-8 p8,p5, 9-9 p5,p8, 10-10 p4,p8, 11-11 p5,p8, 12-13 p4,p8, 14-14 p5,p8, 15-15 p8,p5, 16-16 p5,p8, 17-17 p4,p8, 18-18 p5,p8, 19-20 p8,p5, 21-21 p5,p8, 22-22 p4,p8, 23-23 p8,p5, 24-24 p5,p8, 25-25 p8,p4, 26-26 p5,p8, 27-27 p4,p8, 28-28 p5,p8, 29-29 p12,p8, 30-49 p12,p12, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|15: 0-0 d,#121212, 1-1 d,#080808, 2-2 p4,p8, 3-3 p8,p5, 4-4 p4,p8, 5-5 p8,p5, 6-6 p8,p0, 7-7 p8,p5, 8-10 p5,p8, 11-11 p4,p8, 12-15 p8,p4, 16-17 p4,p8, 18-18 p8,p4, 19-20 p5,p8, 21-21 p8,p4, 22-22 p5,p8, 23-23 p4,p8, 24-24 p8,p4, 25-26 p5,p8, 27-27 p8,p12, 28-28 p12,p0, 29-49 p12,p12, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|16: 0-0 d,#121212, 1-1 d,#080808, 2-3 p4,p8, 4-4 p5,p8, 5-5 p8,p4, 6-6 p4,p8, 7-7 p8,p4, 8-8 p5,p8, 9-10 p4,p8, 11-11 p5,p8, 12-12 p8,p4, 13-13 p5,p8, 14-14 p4,p8, 15-15 p8,p4, 16-16 p4,p8, 17-18 p8,p4, 19-19 p4,p8, 20-22 p5,p8, 23-23 p8,p4, 24-24 p4,p8, 25-25 p12,p8, 26-49 p12,p12, 50-51 #ffffff,#262626, 52-86 #949494,#121212, 87-99 d,#121212, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|17: 0-0 d,#121212, 1-1 d,#080808, 2-2 p4,p8, 3-3 p8,p4, 4-4 p5,p8, 5-5 p8,p5, 6-8 p5,p8, 9-10 p8,p4, 11-12 p4,p8, 13-13 p8,p4, 14-16 p4,p8, 17-17 p8,p5, 18-20 p5,p8, 21-21 p4,p8, 22-22 p8,p4, 23-23 p12,p8, 24-24 p12,p0, 25-49 p12,p12, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|18: 0-0 d,#121212, 1-1 d,#080808, 2-2 p5,p8, 3-4 p4,p8, 5-5 p5,p8, 6-6 p4,p8, 7-7 p5,p8, 8-8 p8,p5, 9-9 p4,p8, 10-10 p8,p4, 11-13 p4,p8, 14-15 p5,p8, 16-17 p8,p4, 18-18 p8,p5, 19-20 p5,p8, 21-21 p12,p8, 22-49 p12,p12, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|19: 0-0 d,#121212, 1-1 d,#080808, 2-3 p4,p8, 4-4 p8,p4, 5-5 p8,p5, 6-6 p4,p8, 7-7 p8,p5, 8-8 p4,p8, 9-9 p8,p5, 10-10 p5,p8, 11-11 p4,p8, 12-12 p5,p8, 13-14 p4,p8, 15-15 p5,p8, 16-17 p4,p8, 18-18 p8,p12, 19-19 p12,p8, 20-49 p12,p12, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|20: 0-0 d,#121212, 1-1 d,#080808, 2-2 p8,p5, 3-3 p5,p8, 4-4 p4,p8, 5-5 p8,p4, 6-7 p4,p8, 8-9 p8,p4, 10-10 p8,p5, 11-11 p8,p4, 12-13 p4,p8, 14-14 p5,p8, 15-15 p4,p8, 16-16 p8,p4, 17-17 p12,p8, 18-18 p12,p0, 19-49 p12,p12, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|21: 0-0 d,#121212, 1-1 d,#080808, 2-2 p5,p8, 3-3 p8,p4, 4-5 p4,p8, 6-6 p8,p4, 7-8 p4,p8, 9-10 p5,p8, 11-12 p8,p4, 13-13 p4,p8, 14-14 p12,p8, 15-15 p5,p8, 16-16 p8,p12, 17-49 p12,p12, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|22: 0-0 d,#121212, 1-1 d,#080808, 2-2 p4,p8, 3-3 p5,p8, 4-5 p8,p5, 6-6 p8,p0, 7-8 p8,p4, 9-9 p5,p8, 10-10 p8,p5, 11-11 p8,p4, 12-12 p4,p8, 13-13 p8,p12, 14-49 p12,p12, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|23: 0-0 d,#121212, 1-1 d,#080808, 2-2 p4,p8, 3-3 p8,p5, 4-4 p4,p8, 5-6 p8,p4, 7-8 p4,p8, 9-9 p8,p4, 10-10 p5,p8, 11-11 p12,p8, 12-49 p12,p12, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|24: 0-0 d,#121212, 1-1 d,#080808, 2-2 p5,p8, 3-3 p8,p5, 4-6 p8,p4, 7-8 p5,p8, 9-9 p12,p8, 10-10 p12,p0, 11-49 p12,p12, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|25: 0-0 d,#121212, 1-1 d,#080808, 2-3 p5,p8, 4-4 p4,p8, 5-5 p8,p4, 6-6 p4,p8, 7-7 p12,p8, 8-49 p12,p12, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|26: 0-0 d,#121212, 1-1 d,#080808, 2-2 p8,p5, 3-3 p4,p8, 4-5 p8,p12, 6-6 p12,p0, 7-49 p12,p12, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|27: 0-0 d,#121212, 1-1 d,#080808, 2-2 p4,p8, 3-3 p5,p8, 4-4 p8,p12, 5-49 p12,p12, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|28: 0-0 d,#121212, 1-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|29: 0-0 d,#121212, 1-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
+|1: 0-1 #ffffff,#626262, 2-37 #949494,#121212, 38-39 #ffffff,#262626, 40-74 #949494,#121212, 75-76 #ffffff,#262626, 77-129 #949494,#121212, 130-149 d,#121212,
+|2: 0-0 d,#121212, 1-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|3: 0-0 d,#121212, 1-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|4: 0-0 d,#121212, 1-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|5: 0-0 d,#121212, 1-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|6: 0-0 d,#121212, 1-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|7: 0-0 d,#121212, 1-1 d,#080808, 2-2 p8,p4, 3-3 p5,p8, 4-4 p4,p8, 5-6 p8,p4, 7-17 p4,p8, 18-18 p8,p4, 19-19 p4,p8, 20-20 p8,p4, 21-25 p4,p8, 26-26 p5,p8, 27-27 p4,p8, 28-28 p8,p4, 29-29 p5,p8, 30-30 p8,p4, 31-31 p4,p8, 32-32 p8,p4, 33-33 p8,p5, 34-34 p4,p8, 35-36 p8,p4, 37-37 p12,p8, 38-38 d,#444444, 39-39 d,#080808, 40-48 #bdbdbd,#080808, 49-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|8: 0-0 d,#121212, 1-1 d,#080808, 2-3 p4,p8, 4-4 p5,p8, 5-5 p4,p8, 6-7 p8,p4, 8-9 p5,p8, 10-10 p4,p8, 11-11 p8,p4, 12-12 p8,p5, 13-13 p5,p8, 14-15 p4,p8, 16-16 p5,p8, 17-18 p4,p8, 19-19 p5,p8, 20-20 p4,p8, 21-21 p8,p4, 22-22 p4,p8, 23-23 p8,p4, 24-24 p4,p8, 25-25 p5,p8, 26-26 p4,p8, 27-28 p8,p4, 29-30 p4,p8, 31-31 p8,p5, 32-32 p8,p4, 33-34 p4,p8, 35-35 p12,p8, 36-36 p12,p0, 37-37 p12,p12, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|9: 0-0 d,#121212, 1-1 d,#080808, 2-3 p4,p8, 4-5 p5,p8, 6-6 p4,p8, 7-8 p8,p4, 9-10 p4,p8, 11-11 p5,p8, 12-13 p4,p8, 14-14 p8,p5, 15-15 p4,p8, 16-16 p5,p8, 17-17 p4,p8, 18-18 p5,p8, 19-19 p8,p4, 20-23 p4,p8, 24-24 p8,p4, 25-25 p5,p8, 26-28 p4,p8, 29-29 p8,p5, 30-30 p4,p8, 31-31 p8,p4, 32-33 p8,p12, 34-34 p12,p0, 35-37 p12,p12, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|10: 0-0 d,#121212, 1-1 d,#080808, 2-2 p4,p8, 3-3 p8,p4, 4-4 p5,p8, 5-5 p8,p4, 6-9 p4,p8, 10-10 p5,p8, 11-12 p4,p8, 13-13 p5,p8, 14-14 p4,p8, 15-16 p8,p4, 17-17 p4,p8, 18-18 p8,p4, 19-19 p4,p8, 20-20 p8,p4, 21-23 p4,p8, 24-24 p8,p5, 25-26 p4,p8, 27-28 p8,p4, 29-30 p5,p8, 31-31 p12,p8, 32-37 p12,p12, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|11: 0-0 d,#121212, 1-1 d,#080808, 2-4 p4,p8, 5-5 p8,p4, 6-8 p4,p8, 9-9 p8,p5, 10-10 p8,p4, 11-11 p8,p5, 12-14 p4,p8, 15-15 p8,p4, 16-17 p4,p8, 18-19 p8,p4, 20-22 p4,p8, 23-23 p8,p4, 24-24 p8,p5, 25-25 p5,p8, 26-26 p4,p8, 27-27 p5,p8, 28-28 p4,p8, 29-29 p12,p8, 30-30 p12,p0, 31-37 p12,p12, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|12: 0-0 d,#121212, 1-1 d,#080808, 2-2 p4,p8, 3-4 p8,p4, 5-6 p4,p8, 7-7 p8,p4, 8-8 p4,p8, 9-9 p8,p4, 10-10 p5,p8, 11-11 p4,p8, 12-12 p8,p4, 13-13 p8,p5, 14-14 p4,p8, 15-15 p8,p4, 16-18 p4,p8, 19-19 p8,p4, 20-20 p4,p8, 21-22 p8,p4, 23-23 p4,p8, 24-24 p8,p4, 25-26 p4,p8, 27-27 p12,p8, 28-37 p12,p12, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|13: 0-0 d,#121212, 1-1 d,#080808, 2-2 p5,p8, 3-5 p4,p8, 6-6 p5,p8, 7-7 p8,p4, 8-8 p8,p5, 9-9 p8,p4, 10-15 p4,p8, 16-16 p5,p8, 17-19 p4,p8, 20-20 p8,p4, 21-23 p4,p8, 24-24 p8,p12, 25-25 p12,p8, 26-26 p8,p12, 27-37 p12,p12, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|14: 0-0 d,#121212, 1-1 d,#080808, 2-3 p5,p8, 4-4 p8,p4, 5-5 p5,p8, 6-6 p4,p8, 7-7 p5,p8, 8-9 p4,p8, 10-10 p8,p4, 11-12 p4,p8, 13-13 p8,p4, 14-15 p4,p8, 16-16 p5,p8, 17-17 p8,p4, 18-18 p8,p5, 19-20 p4,p8, 21-21 p5,p8, 22-22 p4,p8, 23-23 p12,p8, 24-37 p12,p12, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|15: 0-0 d,#121212, 1-1 d,#080808, 2-2 p8,p4, 3-4 p4,p8, 5-6 p8,p4, 7-8 p4,p8, 9-9 p8,p4, 10-11 p4,p8, 12-12 p8,p4, 13-13 p4,p8, 14-14 p8,p4, 15-15 p5,p8, 16-16 p8,p4, 17-17 p5,p8, 18-18 p4,p8, 19-19 p8,p4, 20-20 p8,p12, 21-21 p12,p8, 22-22 p8,p12, 23-37 p12,p12, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|16: 0-0 d,#121212, 1-1 d,#080808, 2-5 p4,p8, 6-6 p8,p4, 7-7 p5,p8, 8-8 p8,p5, 9-9 p4,p8, 10-10 p8,p4, 11-11 p4,p8, 12-12 p8,p4, 13-13 p5,p8, 14-15 p8,p5, 16-17 p4,p8, 18-18 p8,p4, 19-19 p12,p8, 20-37 p12,p12, 38-39 #ffffff,#262626, 40-74 #949494,#121212, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|17: 0-0 d,#121212, 1-1 d,#080808, 2-2 p4,p8, 3-3 p5,p8, 4-4 p8,p4, 5-5 p4,p8, 6-6 p8,p4, 7-8 p4,p8, 9-9 p8,p4, 10-10 p4,p8, 11-11 p8,p4, 12-13 p4,p8, 14-14 p8,p5, 15-17 p4,p8, 18-37 p12,p12, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|18: 0-0 d,#121212, 1-1 d,#080808, 2-8 p4,p8, 9-10 p8,p4, 11-12 p4,p8, 13-13 p5,p8, 14-14 p8,p4, 15-15 p12,p8, 16-37 p12,p12, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|19: 0-0 d,#121212, 1-1 d,#080808, 2-5 p4,p8, 6-6 p5,p8, 7-7 p8,p4, 8-8 p8,p5, 9-10 p4,p8, 11-11 p8,p4, 12-12 p8,p12, 13-13 p4,p8, 14-14 p12,p0, 15-37 p12,p12, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|20: 0-0 d,#121212, 1-1 d,#080808, 2-5 p8,p4, 6-7 p4,p8, 8-8 p5,p8, 9-9 p4,p8, 10-10 p8,p4, 11-11 p12,p8, 12-12 p12,p0, 13-37 p12,p12, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|21: 0-0 d,#121212, 1-1 d,#080808, 2-3 p4,p8, 4-4 p8,p4, 5-5 p4,p8, 6-6 p5,p8, 7-8 p4,p8, 9-9 p12,p8, 10-37 p12,p12, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|22: 0-0 d,#121212, 1-1 d,#080808, 2-2 p4,p8, 3-3 p8,p4, 4-4 p4,p8, 5-5 p5,p8, 6-6 p4,p8, 7-7 p12,p8, 8-8 p12,p0, 9-37 p12,p12, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|23: 0-0 d,#121212, 1-1 d,#080808, 2-4 p8,p4, 5-5 p12,p8, 6-37 p12,p12, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|24: 0-0 d,#121212, 1-1 d,#080808, 2-2 p5,p8, 3-4 p12,p8, 5-37 p12,p12, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|25: 0-0 d,#121212, 1-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|26: 0-0 d,#121212, 1-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|27: 0-0 d,#121212, 1-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|28: 0-0 d,#121212, 1-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|29: 0-0 d,#121212, 1-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
== snap no-ascii grid=150x30 cursor=11,7
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| img /tmp/pardes-snap/images/cwd/pic.ppm New Del /tmp/pardes-snap/images/cwd New Del /tmp/pardes-snap/images/cwd New Del
-| ls ls
-| pic.ppm pic.ppm
-| ╳╱╱🬻🬫🬴🬸│🬜🬴🬬█▗╲│█🬷█🬴🬙🬋─🬬🬋╳🬺🬲│█🬫▜▞│🬪🬝🬴🬝🬸╲🬣██🬛🬸🬛🬸│🬻
-| 🬝🬺┼─▜🬱🬬🬻🬸🬔╲█🬻╱██🬝╲▙🬻🬹▙▀🬺╲┼🬺╳🬷▜─🬙█││🬴🬎🬹╲█🬺▜🬴─▙🬻╲
-| 🬬🬙▟🬳🬺╳🬴🬸🬝▜│╲🬸🬻█─█🬬🬌🬒╱🬧🬝─╳🬴🬶█╲🬕🬍🬸│─🬶╲▐▙🬌▙▟🬴🬝┼╲
-| 🬬🬙█│🬬🬺─🬝╲🬬─█🬺╲─█─🬺🬥🬺🬢╲╲🬚🬚┼─╱🬸─█🬹🬲🬝🬹🬺🬎│█╱█🬷 pic.ppm
-| 🬨▟🬎🬥╱╱🬫▜│╲🬻─╱🬜▛▟🬎🬻🬝▙🬝██🬰🬹▛🬗╳▜🬫█🬻🬬🬙▛🬤▛🬫🬸╲
-| 🬴🬛🬌─🬵█╳│🬸─█╲🬥▐█🬳🬺█╲▌▛╲│🬸▟█╲🬝█▜🬻╲▜🬣🬬╲▀🬵
-| 🬪│█🬪🬴🬳╲🬲🬣🬫╳🬳─╱█╳▛─🬴🬸🬺─▄🬝╲▙🬝▟🬴🬴🬛🬜█🬢🬡╲
-| 🬸🬝╲🬬┼│🬫🬫╳█🬪🬻🬻╱▛🬬🬷🬚🬍🬻█🬆╲🬬─🬲🬄🬙╲🬙█🬺█▄█
-| 🬸██─🬨🬲🬸▛█╱🬛▀🬝▛│╱─🬫▞🬸╲╱▄🬸▟█▟🬝🬹▛▙🬵█
-| █▟🬟█🬳🬸▄╲┼🬎🬱▛🬺🬻│🬜🬍╲╳─▜█╱╲🬎🬴🬬🬛🬳╲
-| 🬎╱│─██🬳▀🬪🬺🬎│▜█🬴🬸🬻🬆🬜▟█🬻🬬▀🬺╱▜🬵
-| │▟▐🬻█🬬🬥🬬🬕█🬻🬴┼🬑🬝🬬▞╱▟🬬╱🬝▜🬣█╳█
-| 🬰█│🬧🬰▛╱🬬╲─🬬█┼🬩█🬬▜─🬴│🬝🬲─🬵 /tmp/pardes-snap/images/cwd New Del
-| 🬴🬷🬰🬐🬸▘╱🬍▄│╲▞█╳╱▟╱🬝🬛🬬🬶🬻█ ls
-| ███╱█🬺🬺█🬇▙🬸🬫🬙█🬴▙🬝╱🬺╱ pic.ppm
-| █╱▙🬺█▜╱█🬬▜█🬕╲🬩╲█🬝│
-| 🬌🬸🬴🬴█▄🬬▙▛🬫🬔┼╳█🬺╳█
-| 🬫🬯█─🬫🬬🬻│█┼┼╳█🬵╲
-| ▛🬚🬬🬢█▛▜🬴▙▜─🬵
-| ─🬮🬙🬒🬺▙╱🬩🬸╳
-| 🬸🬝🬅🬔🬬│╲╱█
-| ╱╱╲🬻🬫🬻
-| 🬜▟🬎┼█
-| 🬺╲╲
+| img petscii:off palette:terminal asc /tmp/pardes-snap/images/cwd New Del /tmp/pardes-snap/images/cwd New Del
+| ls ls
+| pic.ppm pic.ppm
|
|
-== snap tag-edit grid=150x30 cursor=45,1
-|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| img /tmp/pardes-snap/images/cwd/pic.ppmZork New /tmp/pardes-snap/images/cwd New Del /tmp/pardes-snap/images/cwd New Del
-| ls ls
-| pic.ppm pic.ppm
-| ╳╱╱🬻🬫🬴🬸│🬜🬴🬬█▗╲│█🬷█🬴🬙🬋─🬬🬋╳🬺🬲│█🬫▜▞│🬪🬝🬴🬝🬸╲🬣██🬛🬸🬛🬸│🬻
-| 🬝🬺┼─▜🬱🬬🬻🬸🬔╲█🬻╱██🬝╲▙🬻🬹▙▀🬺╲┼🬺╳🬷▜─🬙█││🬴🬎🬹╲█🬺▜🬴─▙🬻╲
-| 🬬🬙▟🬳🬺╳🬴🬸🬝▜│╲🬸🬻█─█🬬🬌🬒╱🬧🬝─╳🬴🬶█╲🬕🬍🬸│─🬶╲▐▙🬌▙▟🬴🬝┼╲
-| 🬬🬙█│🬬🬺─🬝╲🬬─█🬺╲─█─🬺🬥🬺🬢╲╲🬚🬚┼─╱🬸─█🬹🬲🬝🬹🬺🬎│█╱█🬷 pic.ppm
-| 🬨▟🬎🬥╱╱🬫▜│╲🬻─╱🬜▛▟🬎🬻🬝▙🬝██🬰🬹▛🬗╳▜🬫█🬻🬬🬙▛🬤▛🬫🬸╲
-| 🬴🬛🬌─🬵█╳│🬸─█╲🬥▐█🬳🬺█╲▌▛╲│🬸▟█╲🬝█▜🬻╲▜🬣🬬╲▀🬵
-| 🬪│█🬪🬴🬳╲🬲🬣🬫╳🬳─╱█╳▛─🬴🬸🬺─▄🬝╲▙🬝▟🬴🬴🬛🬜█🬢🬡╲
-| 🬸🬝╲🬬┼│🬫🬫╳█🬪🬻🬻╱▛🬬🬷🬚🬍🬻█🬆╲🬬─🬲🬄🬙╲🬙█🬺█▄█
-| 🬸██─🬨🬲🬸▛█╱🬛▀🬝▛│╱─🬫▞🬸╲╱▄🬸▟█▟🬝🬹▛▙🬵█
-| █▟🬟█🬳🬸▄╲┼🬎🬱▛🬺🬻│🬜🬍╲╳─▜█╱╲🬎🬴🬬🬛🬳╲
-| 🬎╱│─██🬳▀🬪🬺🬎│▜█🬴🬸🬻🬆🬜▟█🬻🬬▀🬺╱▜🬵
-| │▟▐🬻█🬬🬥🬬🬕█🬻🬴┼🬑🬝🬬▞╱▟🬬╱🬝▜🬣█╳█
-| 🬰█│🬧🬰▛╱🬬╲─🬬█┼🬩█🬬▜─🬴│🬝🬲─🬵 /tmp/pardes-snap/images/cwd New Del
-| 🬴🬷🬰🬐🬸▘╱🬍▄│╲▞█╳╱▟╱🬝🬛🬬🬶🬻█ ls
-| ███╱█🬺🬺█🬇▙🬸🬫🬙█🬴▙🬝╱🬺╱ pic.ppm
-| █╱▙🬺█▜╱█🬬▜█🬕╲🬩╲█🬝│
-| 🬌🬸🬴🬴█▄🬬▙▛🬫🬔┼╳█🬺╳█
-| 🬫🬯█─🬫🬬🬻│█┼┼╳█🬵╲
-| ▛🬚🬬🬢█▛▜🬴▙▜─🬵
-| ─🬮🬙🬒🬺▙╱🬩🬸╳
-| 🬸🬝🬅🬔🬬│╲╱█
-| ╱╱╲🬻🬫🬻
-| 🬜▟🬎┼█
-| 🬺╲╲
+|
+| 🬻┼█🬬🬬🬺╲🬻█▟╱█╲█▛─▟▛╲█🬨─🬬█─🬬🬬🬴🬎▙🬝██🬛▟🬵 pic.ppm
+| 🬸▛█🬝╱▜🬝🬻▛🬥█╳🬻███╳╲🬍🬕█🬺███🬜🬬█│█▜🬝╱🬵█
+| 🬬▜╱█🬴█🬺🬴█▟███▟🬱▟🬺🬴🬜🬴🬻█🬰│🬹██🬜█─█▟█
+| ▙🬬│█▙╱╱█╲🬝╱🬫╲🬎🬶██🬴🬫─🬹███╱▜🬙╱🬝▄
+| ╲╱╲▟╲▌╳█🬬█🬸▛🬝🬬🬺🬕▞🬻╲█╲🬬█🬴🬬🬘╱▟█
+| █🬬🬬█🬝🬸🬻🬸█🬬🬸🬬─╱█╲│🬬╳🬝▜🬲█▄█🬵
+| █╲█🬨🬝🬴🬝▞▟█🬸🬬│█🬮██▟🬻███🬝🬵╲
+| █╱▜🬺█🬎─██🬝╲🬬│🬸🬬▞██🬴█╳▄
+| │╱█🬝🬬█▀🬺🬝┼🬧█🬺╱█╱▌🬴▛▄╲
+| 🬝╲🬝█🬥█🬝▟█▛🬻🬬██🬺╱🬫🬵 /tmp/pardes-snap/images/cwd New Del
+| 🬺╱🬲╲▟──▛▙🬥███──▄ ls
+| ╲█─██╱╲█🬝🬸─█🬺🬵 pic.ppm
+| ▜█─🬬🬻🬛▛🬶┼🬺█▄█
+| ▟🬸▛🬺███╲🬸▄█
+| 🬸🬗🬸╲│╱🬝🬷
+| █🬶╲🬺🬝╱█
+| 🬰█╱🬵
+| 🬺╱█
+|
|
|
+|
+|
+== snap tag-edit grid=400x30 cursor=85,1
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| img petscii:off palette:terminal ascii:off /tmp/pardes-snap/images/cwd/pic.ppm ZorkNew Del /tmp/pardes-snap/images/cwd New Del /tmp/pardes-snap/images/cwd New Del
+| 🬙│🬎╱🬻🬸█🬸🬺╳🬴🬍🬺╱│🬺🬬█🬴█🬸🬝🬙🬣🬳🬸█🬛🬶██▝▟🬝▄🬪╲█▀🬺██╱│▙█🬲▛▙╱🬙╳🬺🬜🬡🬵 ls ls
+| ││🬜🬲🬊╳╲🬝▟🬀🬬█│█▟█▙🬷🬲🬶█🬬🬩▌▟┼🬘█🬝─🬬🬱╱🬝🬥🬸▜🬴🬺🬻▚🬊█╳│🬬█🬸🬬▛🬕╳🬯🬹 pic.ppm pic.ppm
+| 🬣╱▛🬬┼╲🬰🬷╱╱│🬹🬧▙▜─█🬎█🬜─🬰🬎█🬫╳│🬝╲🬬╲│🬴🬬▛🬸🬲╳🬻╳🬤🬺🬦█🬬│▞▜🬜🬝🬸▟╲
+| ─🬺██╳▟🬴🬩🬺╱🬐🬝▟╱🬹▀🬜🬬🬝🬙▟🬜┼╲🬰██🬝🬖🬺▜│🬺🬛─🬌│🬤▀🬶▀─▙🬴─▟🬬🬹🬎│╲
+| ─╳█🬸🬜🬫█🬍🬻╱▌│🬷🬝╲▀🬸╳🬝🬻╳┼▜🬥╳╱▛╳🬊🬙🬶│🬛╲🬺🬝🬷╲█🬸█🬫┼┼🬎╱🬻🬵█
+| 🬜🬜🬜╲▙█🬻┼╳🬪█🬻🬸🬸▛🬨🬶🬳╱🬤🬝🬬█🬝│██▙🬺█🬰🬄█🬨╲│🬍▛╱🬙🬆🬸─🬸🬲▄ pic.ppm
+| 🬳🬶🬆🬤╲🬸█🬗┼🬳╳🬎╳🬗🬥█🬬🬺╳│██▛🬷│🬴█🬸🬹╲🬛█🬛╱🬬█🬝🬳█▖█╲▜▄╲
+| 🬧▛🬴🬬▄🬴▛│🬲╱▌╲🬔▌▛🬺🬷╲█🬴🬰🬕🬴╱▟│🬹█╳█▟🬥🬻🬰╱╳╲🬬│🬳╱┼
+| ▜█╱🬬🬬🬰╱🬨┼█🬝🬝█🬥┼🬲🬺🬬█🬶🬺│█🬥🬝🬤🬤█▀🬻🬨🬙🬝│🬫█🬣█▛▄╲
+| 🬬🬳╱╱┼█🬙🬻🬮╱🬸▜╲─│🬸─🬶╱🬥🬴🬱▛🬬🬪█╱🬫▜▜╳🬫╲🬰╲│█▄█
+| 🬻▜██╲▗█🬫▙🬹🬫🬎╳🬶🬻🬡🬤╱▛🬯🬸╱🬷╱██▙▜🬸█│╱🬙█🬺▄█
+| █🬗🬛🬍█🬱███🬲🬷▜██╲▙🬴█─┼▜▙🬬▟🬝🬸🬬🬷🬰╱🬫🬜█┼╲
+| ▛🬫╲🬔█╱🬩╱🬤╳🬺╲█╳🬸🬬╲🬹╲╱▙█🬱🬺🬌🬬🬸🬒███🬵█
+| 🬜🬤│▛█🬴╲🬪█▛🬨🬬🬻🬙▟🬻🬷█🬜🬘╱🬚█🬝─🬬╲🬤🬝🬻
+| ▀🬸🬒🬺││🬸🬥███🬮█🬺🬙🬛╲🬻🬻🬬🬻🬜🬪╲🬝🬶─🬵 /tmp/pardes-snap/images/cwd New Del
+| ╲🬇🬪🬒🬡🬙🬌╱🬩🬸🬻🬸🬩▀╱🬳▜🬲╳▟🬰██─🬴│╲ ls
+| 🬴█🬬─🬳🬺█🬎╳🬗▟│🬬🬸╳╲╲╱🬶██🬩🬜╲█ pic.ppm
+| ██🬙▀🬲▛█🬬🬙▟🬹╱🬺🬙🬩🬂🬬█╳▙🬸🬵█
+| 🬛🬝🬬█🬻🬬│╲🬝🬥🬜🬻🬎🬮🬻🬴█╳█🬹
+| ▀🬫🬝─│█╱▜🬎🬍🬚╲🬛╳▙🬜╳🬵
+| ┼🬬🬷█▙🬴▟🬬🬝🬬🬝🬄▌╲🬴🬻█
+| 🬝🬺🬝╳🬯🬺🬝▜╳🬗╲─🬪╳
+| ▀▘🬚🬵🬥╳🬶╱┼▌🬺│
+| ╱│▛╳▙🬕▟▛🬺🬹
+| ▟█│▙┼█🬨🬵╲
+| ▜│╱▌🬝╲╲
+| ▛││🬷╲
+| 🬬🬵╲
diff --git a/test/snapshots/images.snap b/test/snapshots/images.snap
index 0eccda49..6b9a53d0 100644
--- a/test/snapshots/images.snap
+++ b/test/snapshots/images.snap
@@ -1,7 +1,8 @@
# image panes: right-click a .ppm opens petscii glyph art (no kitty in the
-# harness). The tag is an ordinary `img <path> New Del` — its renderer toggles are
-# builtins now, pressed under SPC t (the old tag words sat past the pane's
-# right edge, so clicking them only ever hit the neighbouring shell's tag).
+# harness). The tag starts with the renderer's plain state fields followed by
+# its path and `New Del`; the fields are display-only, while their toggles are
+# builtins under SPC t (the old clickable tag words sat past the pane's right
+# edge, so clicking them only ever hit the neighbouring shell's tag).
ppmdiag pic.ppm
start 30 150 -n 3
wait 8000 pic.ppm
@@ -36,12 +37,16 @@ key a
stable 700 15000
snap no-ascii
# the tag takes an edit like any other pane's now — nothing live left in it to
-# protect, so the image early-return in enterTagEdit is gone. Col 42 is the
-# FIRST EDITABLE column (right after "img <path>"): a tag column is a column of
-# the whole rendered tag, and the live prefix is read-only, so a click at col 20
-# would land in the path and the typing below would be inert.
-press left 42 2
-release left 42 2
+# protect, so the image early-return in enterTagEdit is gone. The image-state
+# prefix is wider than this source-local column, so widen the grid for this
+# final assertion. (A column-border drag intentionally takes width from only
+# one neighbour, which cannot expose this 82-column prefix.) Then click the
+# visible tail itself, at its `New`. This proves the image tag is an ordinary
+# editor rather than accidentally typing at a clipped read-only path.
+resize 30 400
+stable 400 5000
+press left 82 2
+release left 82 2
stable 400 5000
text Zork
stable 400 5000
diff --git a/test/snapshots/layout-close.golden b/test/snapshots/layout-close.golden
index 5e4faba0..16c33523 100644
--- a/test/snapshots/layout-close.golden
+++ b/test/snapshots/layout-close.golden
@@ -39,17 +39,17 @@
|
|
|
-== snap altc grid=140x40 cursor=98,4
+== snap altc grid=140x40 cursor=39,4
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/layout-close/cwd New Del /tmp/pardes-snap/layout-close/cwd New Del /tmp/pardes-snap/layout-close/cwd New Del
-| ls ls ls
-| marker.txt marker.txt marker.txt
+| /tmp/pardes-snap/layout-close/cwd /tmp/pardes-snap/layout-close/cwd /tmp/pardes-snap/layout-close/cwd New Del
+| ls ls ls
+| marker.txt marker.txt marker.txt
|
-| /tmp/pardes-snap/layout-close/cwd New Del
+| /tmp/pardes-snap/layout-close/cwd
| ls
| marker.txt
|
-| /tmp/pardes-snap/layout-close/cwd New Del
+| /tmp/pardes-snap/layout-close/cwd
| ls
| marker.txt
|
@@ -61,7 +61,7 @@
|
|
|
-| /tmp/pardes-snap/layout-close/cwd New Del
+| /tmp/pardes-snap/layout-close/cwd
| ls
| marker.txt
|
@@ -80,17 +80,17 @@
|
|
|
-== snap del grid=140x40 cursor=98,4
+== snap del grid=140x40 cursor=39,4
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/layout-close/cwd New Del /tmp/pardes-snap/layout-close/cwd New Del /tmp/pardes-snap/layout-close/cwd New Del
-| ls ls ls
-| marker.txt marker.txt marker.txt
+| /tmp/pardes-snap/layout-close/cwd /tmp/pardes-snap/layout-close/cwd /tmp/pardes-snap/layout-close/cwd New Del
+| ls ls ls
+| marker.txt marker.txt marker.txt
|
|
|
|
|
-| /tmp/pardes-snap/layout-close/cwd New Del
+| /tmp/pardes-snap/layout-close/cwd
| ls
| marker.txt
|
@@ -102,7 +102,7 @@
|
|
|
-| /tmp/pardes-snap/layout-close/cwd New Del
+| /tmp/pardes-snap/layout-close/cwd
| ls
| marker.txt
|
@@ -121,11 +121,11 @@
|
|
|
-== snap exit grid=140x40 cursor=98,4
+== snap exit grid=140x40 cursor=39,4
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/layout-close/cwd New Del /tmp/pardes-snap/layout-close/cwd New Del /tmp/pardes-snap/layout-close/cwd New Del
-| ls ls ls
-| marker.txt marker.txt marker.txt
+| /tmp/pardes-snap/layout-close/cwd /tmp/pardes-snap/layout-close/cwd /tmp/pardes-snap/layout-close/cwd New Del
+| ls ls ls
+| marker.txt marker.txt marker.txt
|
|
|
@@ -143,7 +143,7 @@
|
|
|
-| /tmp/pardes-snap/layout-close/cwd New Del
+| /tmp/pardes-snap/layout-close/cwd
| ls
| marker.txt
|
@@ -164,9 +164,9 @@
|
== snap colclose grid=140x40 cursor=4,7
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/layout-close/cwd New Del /tmp/pardes-snap/layout-close/cwd New Del
-| ls ls
-| marker.txt marker.txt
+| /tmp/pardes-snap/layout-close/cwd New Del /tmp/pardes-snap/layout-close/cwd New Del
+| ls ls
+| marker.txt marker.txt
|
|
|
@@ -184,7 +184,7 @@
|
|
|
-| /tmp/pardes-snap/layout-close/cwd New Del
+| /tmp/pardes-snap/layout-close/cwd New Del
| ls
| marker.txt
|
diff --git a/test/snapshots/layout-close.snap b/test/snapshots/layout-close.snap
index 0b481323..d539d22b 100644
--- a/test/snapshots/layout-close.snap
+++ b/test/snapshots/layout-close.snap
@@ -22,34 +22,35 @@ snap built
key a-c
stable 700 15000
snap altc
-# middle-click Del in the 2nd pane's tag: the pane above absorbs. New now
-# occupies the old Del position, so Del sits four columns farther right.
-press middle 41 6
-release middle 41 6
+# Del the 2nd pane: source-local column insertion makes this column too narrow
+# to expose its tag words, so focus its body and invoke the same pane-scoped
+# builtin by chord. The pane above absorbs; the other panes keep their rows.
+press left 5 8
+release left 5 8
+stable 400 5000
+key space d
stable 700 10000
snap del
-# exit the (now) 2nd shell (type the command, esc, Tab executes the word):
-# pty EOF, again only the pane above absorbs — and focus falls back to the
+# exit the (now) 2nd shell through its real tty: pty EOF, again only the pane
+# above absorbs — and focus falls back to the
# pane that had it before this one (the moved-out column), not to slot 0
press left 5 14
release left 5 14
stable 400 5000
-key i
+key c-b
+stable 400 5000
text exit
-key esc
-settle 100
-key tab
+key enter
stable 700 10000
snap exit
-# exit the moved-out pane's shell: its column empties and hands its width to
-# the middle column only; the left column's width must not change
-press left 100 8
-release left 100 8
+# exit the moved-out pane's shell: its column empties and returns its width to
+# the source column it was carved from; the untouched right column stays put
+press left 40 8
+release left 40 8
+stable 400 5000
+key c-b
stable 400 5000
-key i
text exit
-key esc
-settle 100
-key tab
+key enter
stable 700 10000
snap colclose
diff --git a/test/snapshots/layout-open.golden b/test/snapshots/layout-open.golden
index cb6c5f6a..0936abea 100644
--- a/test/snapshots/layout-open.golden
+++ b/test/snapshots/layout-open.golden
@@ -93,13 +93,13 @@
|
== snap file-open grid=100x30 cursor=7,2
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/layout-open/cw /tmp/pardes-snap/layout-open/cw /tmp/pardes-snap/layout-open/cwd
-| 1 echo 'TTY'OK ls ls
-| sub sub
+| /tmp/pardes-snap/layout /tmp/pardes-snap/layout /tmp/pardes-snap/layout-open/cwd New Del
+| 1 echo 'TTY'OK ls ls
+| sub sub
|
-| /tmp/pardes-snap/layout-open/cw /tmp/pardes-snap/layout-open/cwd
-| ls ls
-| sub sub
+| /tmp/pardes-snap/layout /tmp/pardes-snap/layout-open/cwd New Del
+| ls ls
+| sub sub
|
|
|
@@ -108,9 +108,9 @@
|
|
|
-| /tmp/pardes-snap/layout-open/cw
-| ls
-| sub
+| /tmp/pardes-snap/layout
+| ls
+| sub
|
|
|
@@ -118,38 +118,38 @@
|
|
|
-| sub/f.txt
+| sub/f.txt
|
|
|
== snap ttydir grid=100x30 cursor=7,2
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/layout-open/cw /tmp/pardes-snap/layout-open/cw /tmp/pardes-snap/layout-open/cwd
-| 1 echo 'TTY'OK ls ls
-| sub sub
+| /tmp/pardes-snap/layout /tmp/pardes-snap/layout /tmp/pardes-snap/layout-open/cwd New Del
+| 1 echo 'TTY'OK ls ls
+| sub sub
|
-| /tmp/pardes-snap/layout-open/cw /tmp/pardes-snap/layout-open/cwd
-| ls ls
-| sub sub
+| /tmp/pardes-snap/layout /tmp/pardes-snap/layout-open/cwd New Del
+| ls ls
+| sub sub
|
-| /tmp/pardes-snap/layout-open/cwd
-| echo 'TTY'OK
-| echo 'TTY'OK
-| TTYOK
+| /tmp/pardes-snap/layout-open/cwd/sub New Del
+| echo 'TTY'OK
+| TTYOK
|
|
|
-| /tmp/pardes-snap/layout-open/cw
-| ls
-| sub
|
+| /tmp/pardes-snap/layout
+| ls
+| sub
|
|
|
|
|
|
-| sub/f.txt
+|
+| sub/f.txt
|
|
|
diff --git a/test/snapshots/layout-open.snap b/test/snapshots/layout-open.snap
index 36d8bb1b..1198b685 100644
--- a/test/snapshots/layout-open.snap
+++ b/test/snapshots/layout-open.snap
@@ -2,7 +2,8 @@
# sizes: Alt-n splits the active pane, an execute-spawned shell for a file's
# dir splits only the bottom pane of the rightmost column. The first looked-up
# file is the exception: it takes a NEW leftmost column, splitting nothing —
-# the other columns only narrow
+# and takes width only from the column that opened it; unrelated columns stay
+# at the exact same x/width
dirmk sub
file sub/f.txt echo 'TTY'OK
start 30 100 -n 3
@@ -31,7 +32,8 @@ key a-n
stable 700 15000
snap altn-right
# look sub/f.txt from the bottom-left shell: the first doc takes a new leftmost
-# column of its own — every existing pane keeps its rows, only the widths change
+# column of its own — every pane keeps its rows, the source column alone pays
+# half its width, and the right column keeps its exact rectangle
press left 5 27
release left 5 27
stable 400 5000
diff --git a/test/snapshots/leader.golden b/test/snapshots/leader.golden
index 10907b99..bb6ee8f1 100644
--- a/test/snapshots/leader.golden
+++ b/test/snapshots/leader.golden
@@ -193,16 +193,16 @@
| 6 SPC P ClipPasteBefore
| 7 SPC R ClipReplace
| 8 SPC Y ClipYankMain
-| 9 SPC c d Delcol
-| 10 SPC c n Newcol topbar
-| 11 SPC d Del
-| 12 SPC f c Config
-| 13 SPC f f Find topbar
-| 14 SPC f g Grep topbar
-| 15 SPC f n New topbar
-| 16 SPC f s Save
-| 17 SPC h t Tutor topbar
-| 18 SPC j i Forward C-i
+| 9 SPC a a PanelAscii
+| 10 SPC a d PanelDissolve
+| 11 SPC a s PanelSlide
+| 12 SPC a v PanelVertical
+| 13 SPC a z PanelZoom
+| 14 SPC c d Delcol
+| 15 SPC c n Newcol topbar
+| 16 SPC d Del
+| 17 SPC f c Config
+| 18 SPC f f Find topbar
== snap help-group grid=100x40 cursor=7,22
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
| /tmp/pardes-snap/leader/cwd/cmds.txt Save New Del
@@ -357,16 +357,16 @@
| 6 SPC P ClipPasteBefore
| 7 SPC R ClipReplace
| 8 SPC Y ClipYankMain
-| 9 SPC c d Delcol
-| 10 SPC c n Newcol topbar
-| 11 SPC d Del
-| 12 SPC f c Config
-| 13 SPC f f Find topbar
-| 14 SPC f g Grep topbar
-| 15 SPC f n New topbar
-| 16 SPC f s Save
-| 17 SPC h t Tutor topbar
-| 18 SPC j i Forward C-i
+| 9 SPC a a PanelAscii
+| 10 SPC a d PanelDissolve
+| 11 SPC a s PanelSlide
+| 12 SPC a v PanelVertical
+| 13 SPC a z PanelZoom
+| 14 SPC c d Delcol
+| 15 SPC c n Newcol topbar
+| 16 SPC d Del
+| 17 SPC f c Config
+| 18 SPC f f Find topbar
== snap help-toggles grid=100x40 cursor=7,22
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
| /tmp/pardes-snap/leader/cwd/cmds.txt Save New Del
@@ -400,12 +400,12 @@
| 8 SPC t l Palette
| 9 SPC t n NextColor topbar
| 10 SPC t p Petscii
-| 11 SPC t r Crt
-| 12 SPC t s PdfSections
-| 13 SPC t t ThemeSel
-| 14 SPC t w Wrap
-| 15 SPC t z PdfFit
-| 16
+| 11 SPC t s PdfSections
+| 12 SPC t t ThemeSel
+| 13 SPC t w Wrap
+| 14 SPC t z PdfFit
+| 15
+|
|
|
== snap help-window grid=100x40 cursor=7,22
diff --git a/test/snapshots/leader.snap b/test/snapshots/leader.snap
index db212631..8c22717d 100644
--- a/test/snapshots/leader.snap
+++ b/test/snapshots/leader.snap
@@ -34,7 +34,10 @@ snap unmapped
# lookup, so a name works anywhere text lives. Every builtin, with its keys.
press middle 9 3
release middle 9 3
-wait 10000 SPC h t
+# New builtins can push the h-group below this short pane; Config is an early
+# full-index row and proves the same Help buffer opened without tying the test
+# to the registry's current length.
+wait 10000 SPC f c
stable 700 15000
snap help-word
# `SPC h ?`: only the `h` group, rewritten into the SAME +Help buffer
@@ -66,10 +69,9 @@ key ?
wait 10000 SPC c n
stable 700 15000
snap help-all
-# `SPC t ?`: the toggle group, twelve of them now that the image renderer
-# switches and the PDF ones are builtins instead of words in the image tag —
-# Crt sits past this pane's last row in the full listing above, and only a
-# group filter shows it
+# `SPC t ?`: the TTY toggle group, including the image-renderer and PDF
+# switches that became builtins instead of tag words. Native scene shaders are
+# capability-gated out of this build, so Crt/Ripple/Glitch are absent here.
key space
key t
key ?
diff --git a/test/snapshots/look-center.golden b/test/snapshots/look-center.golden
index da1fbfed..ff43e5bf 100644
--- a/test/snapshots/look-center.golden
+++ b/test/snapshots/look-center.golden
@@ -1,12 +1,12 @@
== snap center-open grid=150x30 cursor=7,16
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/look-center/cwd/big.txt Save Ne /tmp/pardes-snap/look-center/cwd New Del /tmp/pardes-snap/look-center/cwd New Del
-| 86 line 86 ls ls
-| 87 line 87 big.txt big.txt
+| /tmp/pardes-snap/look-center/cwd/big /tmp/pardes-snap/look-center/cwd Ne /tmp/pardes-snap/look-center/cwd New Del
+| 86 line 86 ls ls
+| 87 line 87 big.txt big.txt
| 88 line 88
| 89 line 89
| 90 line 90
-| 91 line 91 big.txt:100 big.txt:150 big.txt:2
+| 91 line 91 big.txt:100 big.txt:150 big.txt:2
| 92 line 92
| 93 line 93
| 94 line 94
@@ -15,9 +15,9 @@
| 97 line 97
| 98 line 98
| 99 line 99
-| 100 line 100 /tmp/pardes-snap/look-center/cwd New Del
-| 101 line 101 ls
-| 102 line 102 big.txt
+| 100 line 100 /tmp/pardes-snap/look-center/cwd Ne
+| 101 line 101 ls
+| 102 line 102 big.txt
| 103 line 103
| 104 line 104
| 105 line 105
@@ -31,13 +31,13 @@
| 113 line 113
== snap center-dedup grid=150x30 cursor=17,14
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/look-center/cwd/big.txt Save Ne /tmp/pardes-snap/look-center/cwd New Del /tmp/pardes-snap/look-center/cwd New Del
-| 86 line 86 ls ls
-| 87 line 87 big.txt big.txt
+| /tmp/pardes-snap/look-center/cwd/big /tmp/pardes-snap/look-center/cwd Ne /tmp/pardes-snap/look-center/cwd New Del
+| 86 line 86 ls ls
+| 87 line 87 big.txt big.txt
| 88 line 88
| 89 line 89
| 90 line 90
-| 91 line 91 big.txt:100 big.txt:150 big.txt:2
+| 91 line 91 big.txt:100 big.txt:150 big.txt:2
| 92 line 92
| 93 line 93
| 94 line 94
@@ -46,9 +46,9 @@
| 97 line 97
| 98 line 98
| 99 line 99
-| 100 line 100 /tmp/pardes-snap/look-center/cwd New Del
-| 101 line 101 ls
-| 102 line 102 big.txt
+| 100 line 100 /tmp/pardes-snap/look-center/cwd Ne
+| 101 line 101 ls
+| 102 line 102 big.txt
| 103 line 103
| 104 line 104
| 105 line 105
@@ -62,13 +62,13 @@
| 113 line 113
== snap center-clamp grid=150x30 cursor=29,14
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/look-center/cwd/big.txt Save Ne /tmp/pardes-snap/look-center/cwd New Del /tmp/pardes-snap/look-center/cwd New Del
-| 86 line 86 ls ls
-| 87 line 87 big.txt big.txt
+| /tmp/pardes-snap/look-center/cwd/big /tmp/pardes-snap/look-center/cwd Ne /tmp/pardes-snap/look-center/cwd New Del
+| 86 line 86 ls ls
+| 87 line 87 big.txt big.txt
| 88 line 88
| 89 line 89
| 90 line 90
-| 91 line 91 big.txt:100 big.txt:150 big.txt:2
+| 91 line 91 big.txt:100 big.txt:150 big.txt:2
| 92 line 92
| 93 line 93
| 94 line 94
@@ -77,9 +77,9 @@
| 97 line 97
| 98 line 98
| 99 line 99
-| 100 line 100 /tmp/pardes-snap/look-center/cwd New Del
-| 101 line 101 ls
-| 102 line 102 big.txt
+| 100 line 100 /tmp/pardes-snap/look-center/cwd Ne
+| 101 line 101 ls
+| 102 line 102 big.txt
| 103 line 103
| 104 line 104
| 105 line 105
diff --git a/test/snapshots/look-file.golden b/test/snapshots/look-file.golden
index fa674003..a100849c 100644
--- a/test/snapshots/look-file.golden
+++ b/test/snapshots/look-file.golden
@@ -1,12 +1,12 @@
== snap file-open grid=150x30 cursor=7,16
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/look-file/cwd/big.txt Save New /tmp/pardes-snap/look-file/cwd New Del /tmp/pardes-snap/look-file/cwd New Del
-| 26 line 26 ls ls
-| 27 line 27 big.txt big.txt
+| /tmp/pardes-snap/look-file/cwd/big.t /tmp/pardes-snap/look-file/cwd New /tmp/pardes-snap/look-file/cwd New Del
+| 26 line 26 ls ls
+| 27 line 27 big.txt big.txt
| 28 line 28
| 29 line 29
| 30 line 30
-| 31 line 31 big.txt:40
+| 31 line 31 big.txt:40
| 32 line 32
| 33 line 33
| 34 line 34
@@ -15,9 +15,9 @@
| 37 line 37
| 38 line 38
| 39 line 39
-| 40 line 40 /tmp/pardes-snap/look-file/cwd New Del
-| 41 line 41 ls
-| 42 line 42 big.txt
+| 40 line 40 /tmp/pardes-snap/look-file/cwd New
+| 41 line 41 ls
+| 42 line 42 big.txt
| 43 line 43
| 44 line 44
| 45 line 45
@@ -31,32 +31,32 @@
| 53 line 53
== style file-open
|0: 0-56 #949494,#121212, 57-149 d,#121212,
-|1: 0-1 #ffffff,#626262, 2-49 #949494,#121212, 50-51 #ffffff,#262626, 52-89 #949494,#121212, 90-99 d,#121212, 100-101 #ffffff,#262626, 102-139 #949494,#121212, 140-149 d,#121212,
-|2: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|3: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|4: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|5: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|6: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|7: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-63 #bdbdbd,#080808, 64-99 d,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|8: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|9: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|10: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|11: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|12: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|16: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-51 #ffffff,#262626, 52-89 #949494,#121212, 90-99 d,#121212, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|19: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|20: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|21: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|22: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|23: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|24: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|25: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|26: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|27: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|28: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|29: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
+|1: 0-1 #ffffff,#626262, 2-37 #949494,#121212, 38-39 #ffffff,#262626, 40-74 #949494,#121212, 75-76 #ffffff,#262626, 77-129 #949494,#121212, 130-149 d,#121212,
+|2: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|3: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|4: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|5: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|6: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|7: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-51 #bdbdbd,#080808, 52-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|8: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|9: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|10: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|11: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|12: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|16: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-39 #ffffff,#262626, 40-74 #949494,#121212, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|19: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|20: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|21: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|22: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|23: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|24: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|25: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|26: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|27: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|28: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|29: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
diff --git a/test/snapshots/lookloaded.golden b/test/snapshots/lookloaded.golden
index 1d09b582..c5467394 100644
--- a/test/snapshots/lookloaded.golden
+++ b/test/snapshots/lookloaded.golden
@@ -1,12 +1,12 @@
== snap file-open grid=100x30 cursor=7,16
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/lookloaded/cwd /tmp/pardes-snap/lookloaded/cwd /tmp/pardes-snap/lookloaded/cwd
-| 26 line 26 ls ls
-| 27 line 27 big.txt big.txt
+| /tmp/pardes-snap/looklo /tmp/pardes-snap/looklo /tmp/pardes-snap/lookloaded/cwd New Del
+| 26 line 26 ls ls
+| 27 line 27 big.txt big.txt
| 28 line 28
| 29 line 29
| 30 line 30
-| 31 line 31 big.txt:40 big.txt:10
+| 31 line 31 big.txt:40 big.txt:10
| 32 line 32
| 33 line 33
| 34 line 34
@@ -15,9 +15,9 @@
| 37 line 37
| 38 line 38
| 39 line 39
-| 40 line 40 /tmp/pardes-snap/lookloaded/cwd
-| 41 line 41 ls
-| 42 line 42 big.txt
+| 40 line 40 /tmp/pardes-snap/looklo
+| 41 line 41 ls
+| 42 line 42 big.txt
| 43 line 43
| 44 line 44
| 45 line 45
@@ -29,15 +29,15 @@
| 51 line 51
| 52 line 52
| 53 line 53
-== snap removed grid=100x30 cursor=37,6
+== snap removed grid=100x30 cursor=29,7
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/lookloaded/cwd /tmp/pardes-snap/lookloaded/cwd /tmp/pardes-snap/lookloaded/cwd
-| 26 line 26 ls ls
-| 27 line 27 big.txt big.txt
-| 28 line 28 rm big.txt; echo GO''NE
-| 29 line 29 GONE
-| 30 line 30
-| 31 line 31 big.txt:40 big.txt:10
+| /tmp/pardes-snap/looklo /tmp/pardes-snap/looklo /tmp/pardes-snap/lookloaded/cwd New Del
+| 26 line 26 ls ls
+| 27 line 27 big.txt big.txt
+| 28 line 28 rm big.txt; echo GO''
+| 29 line 29 NE
+| 30 line 30 GONE
+| 31 line 31 big.txt:40 big.txt:10
| 32 line 32
| 33 line 33
| 34 line 34
@@ -46,9 +46,9 @@
| 37 line 37
| 38 line 38
| 39 line 39
-| 40 line 40 /tmp/pardes-snap/lookloaded/cwd
-| 41 line 41 ls
-| 42 line 42 big.txt
+| 40 line 40 /tmp/pardes-snap/looklo
+| 41 line 41 ls
+| 42 line 42 big.txt
| 43 line 43
| 44 line 44
| 45 line 45
@@ -62,13 +62,13 @@
| 53 line 53
== snap look-noFS grid=100x30 cursor=7,11
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/lookloaded/cwd /tmp/pardes-snap/lookloaded/cwd /tmp/pardes-snap/lookloaded/cwd
-| 1 line 1 ls ls
-| 2 line 2 big.txt big.txt
-| 3 line 3 rm big.txt; echo GO''NE
-| 4 line 4 GONE
-| 5 line 5
-| 6 line 6 big.txt:40 big.txt:10
+| /tmp/pardes-snap/looklo /tmp/pardes-snap/looklo /tmp/pardes-snap/lookloaded/cwd New Del
+| 1 line 1 ls ls
+| 2 line 2 big.txt big.txt
+| 3 line 3 rm big.txt; echo GO''
+| 4 line 4 NE
+| 5 line 5 GONE
+| 6 line 6 big.txt:40 big.txt:10
| 7 line 7
| 8 line 8
| 9 line 9
@@ -77,9 +77,9 @@
| 12 line 12
| 13 line 13
| 14 line 14
-| 15 line 15 /tmp/pardes-snap/lookloaded/cwd
-| 16 line 16 ls
-| 17 line 17 big.txt
+| 15 line 15 /tmp/pardes-snap/looklo
+| 16 line 16 ls
+| 17 line 17 big.txt
| 18 line 18
| 19 line 19
| 20 line 20
diff --git a/test/snapshots/lookloaded.snap b/test/snapshots/lookloaded.snap
index 68674ba2..5341a063 100644
--- a/test/snapshots/lookloaded.snap
+++ b/test/snapshots/lookloaded.snap
@@ -33,7 +33,9 @@ stable 600 8000
snap removed
# look big.txt:10: nothing on disk, but the pane is loaded — it focuses and
# recenters on line 10 (before this change: realpath fails, look no-ops)
-press right 51 8
-release right 51 8
+# The source-local split leaves this terminal narrower than before: :10 now
+# starts at column 41 (the old column 51 was just past the wrapped command).
+press right 45 8
+release right 45 8
stable 700 15000
snap look-noFS
diff --git a/test/snapshots/multicursor.golden b/test/snapshots/multicursor.golden
index 764ca170..863c4161 100644
--- a/test/snapshots/multicursor.golden
+++ b/test/snapshots/multicursor.golden
@@ -1,12 +1,12 @@
== snap three-cursors grid=150x30 cursor=7,4
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/multicursor/cwd/f.txt Save New /tmp/pardes-snap/multicursor/cwd New Del /tmp/pardes-snap/multicursor/cwd New Del
-| 1 line 1 ls ls
-| 2 line 2 f.txt f.txt
+| /tmp/pardes-snap/multicursor/cwd/f.t /tmp/pardes-snap/multicursor/cwd Ne /tmp/pardes-snap/multicursor/cwd New Del
+| 1 line 1 ls ls
+| 2 line 2 f.txt f.txt
| 3 line 3
| 4 line 4
| 5 line 5
-| 6 line 6 f.txt
+| 6 line 6 f.txt
| 7 line 7
| 8 line 8
| 9 line 9
@@ -15,9 +15,9 @@
| 12 line 12
| 13
|
-| /tmp/pardes-snap/multicursor/cwd New Del
-| ls
-| f.txt
+| /tmp/pardes-snap/multicursor/cwd Ne
+| ls
+| f.txt
|
|
|
@@ -31,44 +31,44 @@
|
== style three-cursors
|0: 0-56 #949494,#121212, 57-149 d,#121212,
-|1: 0-1 #ffffff,#626262, 2-49 #949494,#121212, 50-51 #ffffff,#262626, 52-91 #949494,#121212, 92-99 d,#121212, 100-101 #ffffff,#262626, 102-141 #949494,#121212, 142-149 d,#121212,
-|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-7 #80a0ff,#080808, 8-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-7 #80a0ff,#080808, 8-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|7: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-58 #bdbdbd,#080808, 59-99 d,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|8: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|9: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|10: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|11: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|12: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|16: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 #ffffff,#262626, 52-91 #949494,#121212, 92-99 d,#121212, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|19: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|20: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|21: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|22: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|23: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|24: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|25: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|26: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|27: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|28: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|29: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
+|1: 0-1 #ffffff,#626262, 2-37 #949494,#121212, 38-39 #ffffff,#262626, 40-74 #949494,#121212, 75-76 #ffffff,#262626, 77-129 #949494,#121212, 130-149 d,#121212,
+|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-7 #80a0ff,#080808, 8-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-7 #80a0ff,#080808, 8-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|7: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-46 #bdbdbd,#080808, 47-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|8: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|9: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|10: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|11: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|12: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|16: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-39 #ffffff,#262626, 40-74 #949494,#121212, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|19: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|20: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|21: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|22: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|23: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|24: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|25: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|26: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|27: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|28: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|29: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
== snap edited-at-three grid=150x30 cursor=9,4
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/multicursor/cwd/f.txt Save New /tmp/pardes-snap/multicursor/cwd New Del /tmp/pardes-snap/multicursor/cwd New Del
-| 1 >>line 1 ls ls
-| 2 >>line 2 f.txt f.txt
+| /tmp/pardes-snap/multicursor/cwd/f.t /tmp/pardes-snap/multicursor/cwd Ne /tmp/pardes-snap/multicursor/cwd New Del
+| 1 >>line 1 ls ls
+| 2 >>line 2 f.txt f.txt
| 3 >>line 3
| 4 line 4
| 5 line 5
-| 6 line 6 f.txt
+| 6 line 6 f.txt
| 7 line 7
| 8 line 8
| 9 line 9
@@ -77,9 +77,9 @@
| 12 line 12
| 13
|
-| /tmp/pardes-snap/multicursor/cwd New Del
-| ls
-| f.txt
+| /tmp/pardes-snap/multicursor/cwd Ne
+| ls
+| f.txt
|
|
|
@@ -93,13 +93,13 @@
|
== snap three-selections grid=150x30 cursor=8,4
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/multicursor/cwd/f.txt Save New /tmp/pardes-snap/multicursor/cwd New Del /tmp/pardes-snap/multicursor/cwd New Del
-| 1 >>line 1 ls ls
-| 2 >>line 2 f.txt f.txt
+| /tmp/pardes-snap/multicursor/cwd/f.t /tmp/pardes-snap/multicursor/cwd Ne /tmp/pardes-snap/multicursor/cwd New Del
+| 1 >>line 1 ls ls
+| 2 >>line 2 f.txt f.txt
| 3 >>line 3
| 4 line 4
| 5 line 5
-| 6 line 6 f.txt
+| 6 line 6 f.txt
| 7 line 7
| 8 line 8
| 9 line 9
@@ -108,9 +108,9 @@
| 12 line 12
| 13
|
-| /tmp/pardes-snap/multicursor/cwd New Del
-| ls
-| f.txt
+| /tmp/pardes-snap/multicursor/cwd Ne
+| ls
+| f.txt
|
|
|
@@ -124,44 +124,44 @@
|
== style three-selections
|0: 0-56 #949494,#121212, 57-149 d,#121212,
-|1: 0-1 #ffffff,#626262, 2-49 #949494,#121212, 50-51 #ffffff,#262626, 52-91 #949494,#121212, 92-99 d,#121212, 100-101 #ffffff,#262626, 102-141 #949494,#121212, 142-149 d,#121212,
-|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-7 #080808,#445483, 8-8 #80a0ff,#080808, 9-14 #bdbdbd,#080808, 15-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-7 #080808,#445483, 8-8 #80a0ff,#080808, 9-14 #bdbdbd,#080808, 15-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-8 #080808,#80a0ff, 9-14 #bdbdbd,#080808, 15-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|7: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-58 #bdbdbd,#080808, 59-99 d,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|8: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|9: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|10: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|11: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|12: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|16: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 #ffffff,#262626, 52-91 #949494,#121212, 92-99 d,#121212, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|19: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|20: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|21: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|22: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|23: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|24: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|25: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|26: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|27: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|28: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|29: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
+|1: 0-1 #ffffff,#626262, 2-37 #949494,#121212, 38-39 #ffffff,#262626, 40-74 #949494,#121212, 75-76 #ffffff,#262626, 77-129 #949494,#121212, 130-149 d,#121212,
+|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-7 #080808,#445483, 8-8 #80a0ff,#080808, 9-14 #bdbdbd,#080808, 15-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-7 #080808,#445483, 8-8 #80a0ff,#080808, 9-14 #bdbdbd,#080808, 15-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-8 #080808,#80a0ff, 9-14 #bdbdbd,#080808, 15-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|7: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-46 #bdbdbd,#080808, 47-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|8: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|9: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|10: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|11: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|12: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|16: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-39 #ffffff,#262626, 40-74 #949494,#121212, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|19: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|20: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|21: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|22: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|23: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|24: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|25: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|26: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|27: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|28: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|29: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
== snap keep-primary grid=150x30 cursor=8,4
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/multicursor/cwd/f.txt Save New /tmp/pardes-snap/multicursor/cwd New Del /tmp/pardes-snap/multicursor/cwd New Del
-| 1 >>line 1 ls ls
-| 2 >>line 2 f.txt f.txt
+| /tmp/pardes-snap/multicursor/cwd/f.t /tmp/pardes-snap/multicursor/cwd Ne /tmp/pardes-snap/multicursor/cwd New Del
+| 1 >>line 1 ls ls
+| 2 >>line 2 f.txt f.txt
| 3 >>line 3
| 4 line 4
| 5 line 5
-| 6 line 6 f.txt
+| 6 line 6 f.txt
| 7 line 7
| 8 line 8
| 9 line 9
@@ -170,9 +170,9 @@
| 12 line 12
| 13
|
-| /tmp/pardes-snap/multicursor/cwd New Del
-| ls
-| f.txt
+| /tmp/pardes-snap/multicursor/cwd Ne
+| ls
+| f.txt
|
|
|
@@ -186,32 +186,32 @@
|
== style keep-primary
|0: 0-56 #949494,#121212, 57-149 d,#121212,
-|1: 0-1 #ffffff,#626262, 2-49 #949494,#121212, 50-51 #ffffff,#262626, 52-91 #949494,#121212, 92-99 d,#121212, 100-101 #ffffff,#262626, 102-141 #949494,#121212, 142-149 d,#121212,
-|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-14 #bdbdbd,#080808, 15-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-14 #bdbdbd,#080808, 15-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-8 #080808,#80a0ff, 9-14 #bdbdbd,#080808, 15-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|7: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-58 #bdbdbd,#080808, 59-99 d,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|8: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|9: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|10: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|11: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|12: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|16: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 #ffffff,#262626, 52-91 #949494,#121212, 92-99 d,#121212, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|19: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|20: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|21: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|22: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|23: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|24: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|25: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|26: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|27: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|28: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|29: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
+|1: 0-1 #ffffff,#626262, 2-37 #949494,#121212, 38-39 #ffffff,#262626, 40-74 #949494,#121212, 75-76 #ffffff,#262626, 77-129 #949494,#121212, 130-149 d,#121212,
+|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-14 #bdbdbd,#080808, 15-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-14 #bdbdbd,#080808, 15-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-8 #080808,#80a0ff, 9-14 #bdbdbd,#080808, 15-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|7: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-46 #bdbdbd,#080808, 47-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|8: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|9: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|10: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|11: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|12: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|16: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-39 #ffffff,#262626, 40-74 #949494,#121212, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|19: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|20: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|21: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|22: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|23: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|24: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|25: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|26: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|27: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|28: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|29: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
diff --git a/test/snapshots/rsearch.golden b/test/snapshots/rsearch.golden
index 99820250..9966e223 100644
--- a/test/snapshots/rsearch.golden
+++ b/test/snapshots/rsearch.golden
@@ -1,12 +1,12 @@
== snap opened grid=180x30 cursor=7,2
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/rsearch/cwd/code.txt Save New Del /tmp/pardes-snap/rsearch/cwd New Del /tmp/pardes-snap/rsearch/cwd New Del
-| 1 run count_x now ls ls
-| 2 open sub/dep.txt:3 here code.txt sub code.txt sub
+| /tmp/pardes-snap/rsearch/cwd/code.txt Save /tmp/pardes-snap/rsearch/cwd New Del /tmp/pardes-snap/rsearch/cwd New Del
+| 1 run count_x now ls ls
+| 2 open sub/dep.txt:3 here code.txt sub code.txt sub
| 3 count_x again
| 4 pick two words
|
-| code.txt
+| code.txt
|
|
|
@@ -15,9 +15,9 @@
|
|
|
-| /tmp/pardes-snap/rsearch/cwd New Del
-| ls
-| code.txt sub
+| /tmp/pardes-snap/rsearch/cwd New Del
+| ls
+| code.txt sub
|
|
|
@@ -31,13 +31,13 @@
|
== snap search grid=180x30 cursor=13,4
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/rsearch/cwd/code.txt Save New Del /tmp/pardes-snap/rsearch/cwd New Del /tmp/pardes-snap/rsearch/cwd New Del
-| 1 run count_x now ls ls
-| 2 open sub/dep.txt:3 here code.txt sub code.txt sub
+| /tmp/pardes-snap/rsearch/cwd/code.txt Save /tmp/pardes-snap/rsearch/cwd New Del /tmp/pardes-snap/rsearch/cwd New Del
+| 1 run count_x now ls ls
+| 2 open sub/dep.txt:3 here code.txt sub code.txt sub
| 3 count_x again
| 4 pick two words
|
-| code.txt
+| code.txt
|
|
|
@@ -46,9 +46,9 @@
|
|
|
-| /tmp/pardes-snap/rsearch/cwd New Del
-| ls
-| code.txt sub
+| /tmp/pardes-snap/rsearch/cwd New Del
+| ls
+| code.txt sub
|
|
|
@@ -57,18 +57,18 @@
|
|
|
-| /tmp/pardes-snap/rsearch/cwd/+Search New Del
+| /tmp/pardes-snap/rsearch/cwd/+Search New De
| 1 code.txt:1:5-11 run count_x now
| 2 code.txt:3:1-7 count_x again
== snap select grid=180x30 cursor=14,5
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/rsearch/cwd/code.txt Save New Del /tmp/pardes-snap/rsearch/cwd New Del /tmp/pardes-snap/rsearch/cwd New Del
-| 1 run count_x now ls ls
-| 2 open sub/dep.txt:3 here code.txt sub code.txt sub
+| /tmp/pardes-snap/rsearch/cwd/code.txt Save /tmp/pardes-snap/rsearch/cwd New Del /tmp/pardes-snap/rsearch/cwd New Del
+| 1 run count_x now ls ls
+| 2 open sub/dep.txt:3 here code.txt sub code.txt sub
| 3 count_x again
| 4 pick two words
|
-| code.txt
+| code.txt
|
|
|
@@ -77,203 +77,203 @@
|
|
|
-| /tmp/pardes-snap/rsearch/cwd New Del
-| ls
-| code.txt sub
+| /tmp/pardes-snap/rsearch/cwd New Del
+| ls
+| code.txt sub
|
|
|
|
|
|
-| /tmp/pardes-snap/rsearch/cwd/+Search New Del
+| /tmp/pardes-snap/rsearch/cwd/+Search New De
| 1 code.txt:4:1-8 pick two words
-| /tmp/pardes-snap/rsearch/cwd/+Search New Del
+| /tmp/pardes-snap/rsearch/cwd/+Search New De
| 1 code.txt:1:5-11 run count_x now
| 2 code.txt:3:1-7 count_x again
== snap path-open grid=180x30 cursor=7,16
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/rsearch/cwd/code.txt Save New Del /tmp/pardes-snap/rsearch/cwd New Del /tmp/pardes-snap/rsearch/cwd New Del
-| 1 run count_x now ls ls
-| 2 open sub/dep.txt:3 here code.txt sub code.txt sub
+| /tmp/pardes-snap/rsearch/cwd/code.txt Save /tmp/pardes-snap/rsearch/cwd New Del /tmp/pardes-snap/rsearch/cwd New Del
+| 1 run count_x now ls ls
+| 2 open sub/dep.txt:3 here code.txt sub code.txt sub
| 3 count_x again
| 4 pick two words
|
-| code.txt
+| code.txt
|
|
|
|
|
-| /tmp/pardes-snap/rsearch/cwd/sub/dep.txt Save New Del
+| /tmp/pardes-snap/rsearch/cwd/sub/dep.txt Sa
| 1 d1
| 2 d2
-| 3 DEPTARGET /tmp/pardes-snap/rsearch/cwd New Del
-| 4 d4 ls
-| 5 d5 code.txt sub
+| 3 DEPTARGET /tmp/pardes-snap/rsearch/cwd New Del
+| 4 d4 ls
+| 5 d5 code.txt sub
| 6 d6
|
|
|
|
|
-| /tmp/pardes-snap/rsearch/cwd/+Search New Del
+| /tmp/pardes-snap/rsearch/cwd/+Search New De
| 1 code.txt:4:1-8 pick two words
-| /tmp/pardes-snap/rsearch/cwd/+Search New Del
+| /tmp/pardes-snap/rsearch/cwd/+Search New De
| 1 code.txt:1:5-11 run count_x now
| 2 code.txt:3:1-7 count_x again
== snap step1 grid=180x30 cursor=13,4
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/rsearch/cwd/code.txt Save New Del /tmp/pardes-snap/rsearch/cwd New Del /tmp/pardes-snap/rsearch/cwd New Del
-| 1 run count_x now ls ls
-| 2 open sub/dep.txt:3 here code.txt sub code.txt sub
+| /tmp/pardes-snap/rsearch/cwd/code.txt Save /tmp/pardes-snap/rsearch/cwd New Del /tmp/pardes-snap/rsearch/cwd New Del
+| 1 run count_x now ls ls
+| 2 open sub/dep.txt:3 here code.txt sub code.txt sub
| 3 count_x again
| 4 pick two words
|
-| code.txt
+| code.txt
|
|
|
|
|
-| /tmp/pardes-snap/rsearch/cwd/sub/dep.txt Save New Del
+| /tmp/pardes-snap/rsearch/cwd/sub/dep.txt Sa
| 1 d1
| 2 d2
-| 3 DEPTARGET /tmp/pardes-snap/rsearch/cwd New Del
-| 4 d4 ls
-| 5 d5 code.txt sub
+| 3 DEPTARGET /tmp/pardes-snap/rsearch/cwd New Del
+| 4 d4 ls
+| 5 d5 code.txt sub
| 6 d6
|
|
|
|
|
-| /tmp/pardes-snap/rsearch/cwd/+Search New Del
+| /tmp/pardes-snap/rsearch/cwd/+Search New De
| 1 code.txt:4:1-8 pick two words
-| /tmp/pardes-snap/rsearch/cwd/+Search New Del
+| /tmp/pardes-snap/rsearch/cwd/+Search New De
| 1 code.txt:1:5-11 run count_x now
| 2 code.txt:3:1-7 count_x again
== snap step2 grid=180x30 cursor=17,2
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/rsearch/cwd/code.txt Save New Del /tmp/pardes-snap/rsearch/cwd New Del /tmp/pardes-snap/rsearch/cwd New Del
-| 1 run count_x now ls ls
-| 2 open sub/dep.txt:3 here code.txt sub code.txt sub
+| /tmp/pardes-snap/rsearch/cwd/code.txt Save /tmp/pardes-snap/rsearch/cwd New Del /tmp/pardes-snap/rsearch/cwd New Del
+| 1 run count_x now ls ls
+| 2 open sub/dep.txt:3 here code.txt sub code.txt sub
| 3 count_x again
| 4 pick two words
|
-| code.txt
+| code.txt
|
|
|
|
|
-| /tmp/pardes-snap/rsearch/cwd/sub/dep.txt Save New Del
+| /tmp/pardes-snap/rsearch/cwd/sub/dep.txt Sa
| 1 d1
| 2 d2
-| 3 DEPTARGET /tmp/pardes-snap/rsearch/cwd New Del
-| 4 d4 ls
-| 5 d5 code.txt sub
+| 3 DEPTARGET /tmp/pardes-snap/rsearch/cwd New Del
+| 4 d4 ls
+| 5 d5 code.txt sub
| 6 d6
|
|
|
|
|
-| /tmp/pardes-snap/rsearch/cwd/+Search New Del
+| /tmp/pardes-snap/rsearch/cwd/+Search New De
| 1 code.txt:4:1-8 pick two words
-| /tmp/pardes-snap/rsearch/cwd/+Search New Del
+| /tmp/pardes-snap/rsearch/cwd/+Search New De
| 1 code.txt:1:5-11 run count_x now
| 2 code.txt:3:1-7 count_x again
== snap step3 grid=180x30 cursor=17,2
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/rsearch/cwd/code.txt Save New Del /tmp/pardes-snap/rsearch/cwd New Del /tmp/pardes-snap/rsearch/cwd New Del
-| 1 run count_x now ls ls
-| 2 open sub/dep.txt:3 here code.txt sub code.txt sub
+| /tmp/pardes-snap/rsearch/cwd/code.txt Save /tmp/pardes-snap/rsearch/cwd New Del /tmp/pardes-snap/rsearch/cwd New Del
+| 1 run count_x now ls ls
+| 2 open sub/dep.txt:3 here code.txt sub code.txt sub
| 3 count_x again
| 4 pick two words
|
-| code.txt
+| code.txt
|
|
|
|
|
-| /tmp/pardes-snap/rsearch/cwd/sub/dep.txt Save New Del
+| /tmp/pardes-snap/rsearch/cwd/sub/dep.txt Sa
| 1 d1
| 2 d2
-| 3 DEPTARGET /tmp/pardes-snap/rsearch/cwd New Del
-| 4 d4 ls
-| 5 d5 code.txt sub
+| 3 DEPTARGET /tmp/pardes-snap/rsearch/cwd New Del
+| 4 d4 ls
+| 5 d5 code.txt sub
| 6 d6
|
|
|
|
|
-| /tmp/pardes-snap/rsearch/cwd/+Search New Del
+| /tmp/pardes-snap/rsearch/cwd/+Search New De
| 1 code.txt:4:1-8 pick two words
-| /tmp/pardes-snap/rsearch/cwd/+Search New Del
+| /tmp/pardes-snap/rsearch/cwd/+Search New De
| 1 code.txt:1:5-11 run count_x now
| 2 code.txt:3:1-7 count_x again
== snap selclick grid=180x30 cursor=14,5
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/rsearch/cwd/code.txt Save New Del /tmp/pardes-snap/rsearch/cwd New Del /tmp/pardes-snap/rsearch/cwd New Del
-| 1 run count_x now ls ls
-| 2 open sub/dep.txt:3 here code.txt sub code.txt sub
+| /tmp/pardes-snap/rsearch/cwd/code.txt Save /tmp/pardes-snap/rsearch/cwd New Del /tmp/pardes-snap/rsearch/cwd New Del
+| 1 run count_x now ls ls
+| 2 open sub/dep.txt:3 here code.txt sub code.txt sub
| 3 count_x again
| 4 pick two words
|
-| code.txt
+| code.txt
|
|
|
|
|
-| /tmp/pardes-snap/rsearch/cwd/sub/dep.txt Save New Del
+| /tmp/pardes-snap/rsearch/cwd/sub/dep.txt Sa
| 1 d1
| 2 d2
-| 3 DEPTARGET /tmp/pardes-snap/rsearch/cwd New Del
-| 4 d4 ls
-| 5 d5 code.txt sub
+| 3 DEPTARGET /tmp/pardes-snap/rsearch/cwd New Del
+| 4 d4 ls
+| 5 d5 code.txt sub
| 6 d6
|
|
|
|
|
-| /tmp/pardes-snap/rsearch/cwd/+Search New Del
+| /tmp/pardes-snap/rsearch/cwd/+Search New De
| 1 code.txt:4:1-8 pick two words
-| /tmp/pardes-snap/rsearch/cwd/+Search New Del
+| /tmp/pardes-snap/rsearch/cwd/+Search New De
| 1 code.txt:1:5-11 run count_x now
| 2 code.txt:3:1-7 count_x again
== snap outside grid=180x30 cursor=20,5
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/rsearch/cwd/code.txt Save New Del /tmp/pardes-snap/rsearch/cwd New Del /tmp/pardes-snap/rsearch/cwd New Del
-| 1 run count_x now ls ls
-| 2 open sub/dep.txt:3 here code.txt sub code.txt sub
+| /tmp/pardes-snap/rsearch/cwd/code.txt Save /tmp/pardes-snap/rsearch/cwd New Del /tmp/pardes-snap/rsearch/cwd New Del
+| 1 run count_x now ls ls
+| 2 open sub/dep.txt:3 here code.txt sub code.txt sub
| 3 count_x again
| 4 pick two words
|
-| code.txt
+| code.txt
|
|
|
-| /tmp/pardes-snap/rsearch/cwd/+Search New Del
+| /tmp/pardes-snap/rsearch/cwd/+Search New De
| 1 code.txt:4:10-14 pick two words
-| /tmp/pardes-snap/rsearch/cwd/sub/dep.txt Save New Del
+| /tmp/pardes-snap/rsearch/cwd/sub/dep.txt Sa
| 1 d1
| 2 d2
-| 3 DEPTARGET /tmp/pardes-snap/rsearch/cwd New Del
-| 4 d4 ls
-| 5 d5 code.txt sub
+| 3 DEPTARGET /tmp/pardes-snap/rsearch/cwd New Del
+| 4 d4 ls
+| 5 d5 code.txt sub
| 6 d6
|
|
|
|
|
-| /tmp/pardes-snap/rsearch/cwd/+Search New Del
+| /tmp/pardes-snap/rsearch/cwd/+Search New De
| 1 code.txt:4:1-8 pick two words
-| /tmp/pardes-snap/rsearch/cwd/+Search New Del
+| /tmp/pardes-snap/rsearch/cwd/+Search New De
| 1 code.txt:1:5-11 run count_x now
| 2 code.txt:3:1-7 count_x again
diff --git a/test/snapshots/scrolloff.golden b/test/snapshots/scrolloff.golden
index 265d427d..5e86f802 100644
--- a/test/snapshots/scrolloff.golden
+++ b/test/snapshots/scrolloff.golden
@@ -1,84 +1,84 @@
== snap top grid=150x11 cursor=7,2
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/scrolloff/cwd/g.txt Save New De /tmp/pardes-snap/scrolloff/cwd New Del /tmp/pardes-snap/scrolloff/cwd New Del
-| 1 line 1 ls ls
-| 2 line 2 g.txt g.txt
+| /tmp/pardes-snap/scrolloff/cwd/g.txt /tmp/pardes-snap/scrolloff/cwd New /tmp/pardes-snap/scrolloff/cwd New Del
+| 1 line 1 ls ls
+| 2 line 2 g.txt g.txt
| 3 line 3
| 4 line 4
-| 5 line 5 /tmp/pardes-snap/scrolloff/cwd New Del
-| 6 line 6 ls
-| 7 line 7 g.txt
+| 5 line 5 /tmp/pardes-snap/scrolloff/cwd New
+| 6 line 6 ls
+| 7 line 7 g.txt
| 8 line 8
| 9 line 9
== snap edge grid=150x11 cursor=7,7
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/scrolloff/cwd/g.txt Save New De /tmp/pardes-snap/scrolloff/cwd New Del /tmp/pardes-snap/scrolloff/cwd New Del
-| 1 line 1 ls ls
-| 2 line 2 g.txt g.txt
+| /tmp/pardes-snap/scrolloff/cwd/g.txt /tmp/pardes-snap/scrolloff/cwd New /tmp/pardes-snap/scrolloff/cwd New Del
+| 1 line 1 ls ls
+| 2 line 2 g.txt g.txt
| 3 line 3
| 4 line 4
-| 5 line 5 /tmp/pardes-snap/scrolloff/cwd New Del
-| 6 line 6 ls
-| 7 line 7 g.txt
+| 5 line 5 /tmp/pardes-snap/scrolloff/cwd New
+| 6 line 6 ls
+| 7 line 7 g.txt
| 8 line 8
| 9 line 9
== snap scrolled1 grid=150x11 cursor=7,7
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/scrolloff/cwd/g.txt Save New De /tmp/pardes-snap/scrolloff/cwd New Del /tmp/pardes-snap/scrolloff/cwd New Del
-| 2 line 2 ls ls
-| 3 line 3 g.txt g.txt
+| /tmp/pardes-snap/scrolloff/cwd/g.txt /tmp/pardes-snap/scrolloff/cwd New /tmp/pardes-snap/scrolloff/cwd New Del
+| 2 line 2 ls ls
+| 3 line 3 g.txt g.txt
| 4 line 4
| 5 line 5
-| 6 line 6 /tmp/pardes-snap/scrolloff/cwd New Del
-| 7 line 7 ls
-| 8 line 8 g.txt
+| 6 line 6 /tmp/pardes-snap/scrolloff/cwd New
+| 7 line 7 ls
+| 8 line 8 g.txt
| 9 line 9
| 10 line 10
== snap jdown grid=150x11 cursor=7,7
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/scrolloff/cwd/g.txt Save New De /tmp/pardes-snap/scrolloff/cwd New Del /tmp/pardes-snap/scrolloff/cwd New Del
-| 6 line 6 ls ls
-| 7 line 7 g.txt g.txt
+| /tmp/pardes-snap/scrolloff/cwd/g.txt /tmp/pardes-snap/scrolloff/cwd New /tmp/pardes-snap/scrolloff/cwd New Del
+| 6 line 6 ls ls
+| 7 line 7 g.txt g.txt
| 8 line 8
| 9 line 9
-| 10 line 10 /tmp/pardes-snap/scrolloff/cwd New Del
-| 11 line 11 ls
-| 12 line 12 g.txt
+| 10 line 10 /tmp/pardes-snap/scrolloff/cwd New
+| 11 line 11 ls
+| 12 line 12 g.txt
| 13 line 13
| 14 line 14
== snap end grid=150x11 cursor=7,9
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/scrolloff/cwd/g.txt Save New De /tmp/pardes-snap/scrolloff/cwd New Del /tmp/pardes-snap/scrolloff/cwd New Del
-| 33 line 33 ls ls
-| 34 line 34 g.txt g.txt
+| /tmp/pardes-snap/scrolloff/cwd/g.txt /tmp/pardes-snap/scrolloff/cwd New /tmp/pardes-snap/scrolloff/cwd New Del
+| 33 line 33 ls ls
+| 34 line 34 g.txt g.txt
| 35 line 35
| 36 line 36
-| 37 line 37 /tmp/pardes-snap/scrolloff/cwd New Del
-| 38 line 38 ls
-| 39 line 39 g.txt
+| 37 line 37 /tmp/pardes-snap/scrolloff/cwd New
+| 38 line 38 ls
+| 39 line 39 g.txt
| 40 line 40
| 41
== snap kup grid=150x11 cursor=7,5
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/scrolloff/cwd/g.txt Save New De /tmp/pardes-snap/scrolloff/cwd New Del /tmp/pardes-snap/scrolloff/cwd New Del
-| 29 line 29 ls ls
-| 30 line 30 g.txt g.txt
+| /tmp/pardes-snap/scrolloff/cwd/g.txt /tmp/pardes-snap/scrolloff/cwd New /tmp/pardes-snap/scrolloff/cwd New Del
+| 29 line 29 ls ls
+| 30 line 30 g.txt g.txt
| 31 line 31
| 32 line 32
-| 33 line 33 /tmp/pardes-snap/scrolloff/cwd New Del
-| 34 line 34 ls
-| 35 line 35 g.txt
+| 33 line 33 /tmp/pardes-snap/scrolloff/cwd New
+| 34 line 34 ls
+| 35 line 35 g.txt
| 36 line 36
| 37 line 37
== snap top2 grid=150x11 cursor=7,2
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/scrolloff/cwd/g.txt Save New De /tmp/pardes-snap/scrolloff/cwd New Del /tmp/pardes-snap/scrolloff/cwd New Del
-| 1 line 1 ls ls
-| 2 line 2 g.txt g.txt
+| /tmp/pardes-snap/scrolloff/cwd/g.txt /tmp/pardes-snap/scrolloff/cwd New /tmp/pardes-snap/scrolloff/cwd New Del
+| 1 line 1 ls ls
+| 2 line 2 g.txt g.txt
| 3 line 3
| 4 line 4
-| 5 line 5 /tmp/pardes-snap/scrolloff/cwd New Del
-| 6 line 6 ls
-| 7 line 7 g.txt
+| 5 line 5 /tmp/pardes-snap/scrolloff/cwd New
+| 6 line 6 ls
+| 7 line 7 g.txt
| 8 line 8
| 9 line 9
diff --git a/test/snapshots/syntax.golden b/test/snapshots/syntax.golden
index 5d6b85c7..a77948ef 100644
--- a/test/snapshots/syntax.golden
+++ b/test/snapshots/syntax.golden
@@ -1,12 +1,12 @@
== snap zig-open grid=150x30 cursor=7,2
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/syntax/cwd/demo.zig Save New De /tmp/pardes-snap/syntax/cwd New Del /tmp/pardes-snap/syntax/cwd New Del
-| 1 const std = @import("std"); // greet ls ls
-| 2 pub fn main() void { demo.zig demo.zig
+| /tmp/pardes-snap/syntax/cwd/demo.zig /tmp/pardes-snap/syntax/cwd New Del /tmp/pardes-snap/syntax/cwd New Del
+| 1 const std = @import("std"); //↩ ls ls
+| greet demo.zig demo.zig
+| 2 pub fn main() void {
| 3 var x: u32 = 42;
| 4 _ = x;
-| 5 }
-| demo.zig
+| 5 } demo.zig
|
|
|
@@ -15,9 +15,9 @@
|
|
|
-| /tmp/pardes-snap/syntax/cwd New Del
-| ls
-| demo.zig
+| /tmp/pardes-snap/syntax/cwd New Del
+| ls
+| demo.zig
|
|
|
@@ -31,94 +31,94 @@
|
== style zig-open
|0: 0-56 #949494,#121212, 57-149 d,#121212,
-|1: 0-1 #ffffff,#626262, 2-49 #949494,#121212, 50-51 #ffffff,#262626, 52-86 #949494,#121212, 87-99 d,#121212, 100-101 #ffffff,#262626, 102-136 #949494,#121212, 137-149 d,#121212,
-|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-18 #bdbdbd,#080808, 19-25 #cf87e8,#080808,b 26-26 #bdbdbd,#080808, 27-31 #e3c78a,#080808, 32-34 #bdbdbd,#080808, 35-42 #ffffff,#080808,b 43-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-10 #bdbdbd,#080808, 11-12 #cf87e8,#080808,b 13-26 #bdbdbd,#080808, 27-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-23 #bdbdbd,#080808, 24-25 #e3c78a,#080808, 26-26 #bdbdbd,#080808, 27-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-16 #bdbdbd,#080808, 17-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-7 #bdbdbd,#080808, 8-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|7: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-61 #bdbdbd,#080808, 62-99 d,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|8: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|9: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|10: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|11: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|12: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|16: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 #ffffff,#262626, 52-86 #949494,#121212, 87-99 d,#121212, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|19: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|20: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|21: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|22: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|23: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|24: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|25: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|26: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|27: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|28: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|29: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
+|1: 0-1 #ffffff,#626262, 2-37 #949494,#121212, 38-39 #ffffff,#262626, 40-74 #949494,#121212, 75-76 #ffffff,#262626, 77-129 #949494,#121212, 130-149 d,#121212,
+|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-18 #bdbdbd,#080808, 19-25 #cf87e8,#080808,b 26-26 #bdbdbd,#080808, 27-31 #e3c78a,#080808, 32-34 #bdbdbd,#080808, 35-36 #ffffff,#080808,b 37-37 #626262,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-12 #ffffff,#080808,b 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-10 #bdbdbd,#080808, 11-12 #cf87e8,#080808,b 13-26 #bdbdbd,#080808, 27-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-23 #bdbdbd,#080808, 24-25 #e3c78a,#080808, 26-26 #bdbdbd,#080808, 27-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-16 #bdbdbd,#080808, 17-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|7: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-7 #bdbdbd,#080808, 8-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-49 #bdbdbd,#080808, 50-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|8: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|9: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|10: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|11: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|12: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|16: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-39 #ffffff,#262626, 40-74 #949494,#121212, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|19: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|20: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|21: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|22: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|23: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|24: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|25: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|26: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|27: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|28: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|29: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
== style colors-off
|0: 0-56 #949494,#121212, 57-149 d,#121212,
-|1: 0-1 #ffffff,#626262, 2-49 #949494,#121212, 50-51 #ffffff,#262626, 52-86 #949494,#121212, 87-99 d,#121212, 100-101 #ffffff,#262626, 102-136 #949494,#121212, 137-149 d,#121212,
-|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-42 #bdbdbd,#080808, 43-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-53 #bdbdbd,#080808, 54-99 d,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-103 #bdbdbd,#080808, 104-149 d,#080808,
-|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-26 #bdbdbd,#080808, 27-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-59 #bdbdbd,#080808, 60-99 d,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-109 #bdbdbd,#080808, 110-149 d,#080808,
-|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-26 #bdbdbd,#080808, 27-49 d,#080808, 50-50 d,#444444, 51-99 d,#080808, 100-100 d,#444444, 101-149 d,#080808,
-|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-16 #bdbdbd,#080808, 17-49 d,#080808, 50-50 d,#444444, 51-99 d,#080808, 100-100 d,#444444, 101-149 d,#080808,
-|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-7 #bdbdbd,#080808, 8-49 d,#080808, 50-50 d,#444444, 51-99 d,#080808, 100-100 d,#444444, 101-149 d,#080808,
-|7: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-61 #bdbdbd,#080808, 62-99 d,#080808, 100-100 d,#444444, 101-149 d,#080808,
-|8: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-99 d,#080808, 100-100 d,#444444, 101-149 d,#080808,
-|9: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-99 d,#080808, 100-100 d,#444444, 101-149 d,#080808,
-|10: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-99 d,#080808, 100-100 d,#444444, 101-149 d,#080808,
-|11: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-99 d,#080808, 100-100 d,#444444, 101-149 d,#080808,
-|12: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-99 d,#080808, 100-100 d,#444444, 101-149 d,#080808,
-|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-99 d,#080808, 100-100 d,#444444, 101-149 d,#080808,
-|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-99 d,#080808, 100-100 d,#444444, 101-149 d,#080808,
-|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-99 d,#080808, 100-100 d,#444444, 101-149 d,#080808,
-|16: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 #ffffff,#262626, 52-86 #949494,#121212, 87-99 d,#121212, 100-100 d,#444444, 101-149 d,#080808,
-|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-53 #bdbdbd,#080808, 54-99 d,#080808, 100-100 d,#444444, 101-149 d,#080808,
-|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-59 #bdbdbd,#080808, 60-99 d,#080808, 100-100 d,#444444, 101-149 d,#080808,
-|19: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-99 d,#080808, 100-100 d,#444444, 101-149 d,#080808,
-|20: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-99 d,#080808, 100-100 d,#444444, 101-149 d,#080808,
-|21: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-99 d,#080808, 100-100 d,#444444, 101-149 d,#080808,
-|22: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-99 d,#080808, 100-100 d,#444444, 101-149 d,#080808,
-|23: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-99 d,#080808, 100-100 d,#444444, 101-149 d,#080808,
-|24: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-99 d,#080808, 100-100 d,#444444, 101-149 d,#080808,
-|25: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-99 d,#080808, 100-100 d,#444444, 101-149 d,#080808,
-|26: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-99 d,#080808, 100-100 d,#444444, 101-149 d,#080808,
-|27: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-99 d,#080808, 100-100 d,#444444, 101-149 d,#080808,
-|28: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-99 d,#080808, 100-100 d,#444444, 101-149 d,#080808,
-|29: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-99 d,#080808, 100-100 d,#444444, 101-149 d,#080808,
+|1: 0-1 #ffffff,#626262, 2-37 #949494,#121212, 38-39 #ffffff,#262626, 40-74 #949494,#121212, 75-76 #ffffff,#262626, 77-129 #949494,#121212, 130-149 d,#121212,
+|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-36 #bdbdbd,#080808, 37-37 #626262,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-41 #bdbdbd,#080808, 42-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-78 #bdbdbd,#080808, 79-149 d,#080808,
+|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-12 #bdbdbd,#080808, 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-47 #bdbdbd,#080808, 48-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-84 #bdbdbd,#080808, 85-149 d,#080808,
+|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-26 #bdbdbd,#080808, 27-37 d,#080808, 38-38 d,#444444, 39-74 d,#080808, 75-75 d,#444444, 76-149 d,#080808,
+|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-26 #bdbdbd,#080808, 27-37 d,#080808, 38-38 d,#444444, 39-74 d,#080808, 75-75 d,#444444, 76-149 d,#080808,
+|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-16 #bdbdbd,#080808, 17-37 d,#080808, 38-38 d,#444444, 39-74 d,#080808, 75-75 d,#444444, 76-149 d,#080808,
+|7: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-7 #bdbdbd,#080808, 8-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-49 #bdbdbd,#080808, 50-74 d,#080808, 75-75 d,#444444, 76-149 d,#080808,
+|8: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-74 d,#080808, 75-75 d,#444444, 76-149 d,#080808,
+|9: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-74 d,#080808, 75-75 d,#444444, 76-149 d,#080808,
+|10: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-74 d,#080808, 75-75 d,#444444, 76-149 d,#080808,
+|11: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-74 d,#080808, 75-75 d,#444444, 76-149 d,#080808,
+|12: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-74 d,#080808, 75-75 d,#444444, 76-149 d,#080808,
+|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-74 d,#080808, 75-75 d,#444444, 76-149 d,#080808,
+|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-74 d,#080808, 75-75 d,#444444, 76-149 d,#080808,
+|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-74 d,#080808, 75-75 d,#444444, 76-149 d,#080808,
+|16: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-39 #ffffff,#262626, 40-74 #949494,#121212, 75-75 d,#444444, 76-149 d,#080808,
+|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-41 #bdbdbd,#080808, 42-74 d,#080808, 75-75 d,#444444, 76-149 d,#080808,
+|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-47 #bdbdbd,#080808, 48-74 d,#080808, 75-75 d,#444444, 76-149 d,#080808,
+|19: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-74 d,#080808, 75-75 d,#444444, 76-149 d,#080808,
+|20: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-74 d,#080808, 75-75 d,#444444, 76-149 d,#080808,
+|21: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-74 d,#080808, 75-75 d,#444444, 76-149 d,#080808,
+|22: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-74 d,#080808, 75-75 d,#444444, 76-149 d,#080808,
+|23: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-74 d,#080808, 75-75 d,#444444, 76-149 d,#080808,
+|24: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-74 d,#080808, 75-75 d,#444444, 76-149 d,#080808,
+|25: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-74 d,#080808, 75-75 d,#444444, 76-149 d,#080808,
+|26: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-74 d,#080808, 75-75 d,#444444, 76-149 d,#080808,
+|27: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-74 d,#080808, 75-75 d,#444444, 76-149 d,#080808,
+|28: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-74 d,#080808, 75-75 d,#444444, 76-149 d,#080808,
+|29: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-74 d,#080808, 75-75 d,#444444, 76-149 d,#080808,
== style colors-on
|0: 0-56 #949494,#121212, 57-149 d,#121212,
-|1: 0-1 #ffffff,#626262, 2-49 #949494,#121212, 50-51 #ffffff,#262626, 52-86 #949494,#121212, 87-99 d,#121212, 100-101 #ffffff,#262626, 102-136 #949494,#121212, 137-149 d,#121212,
-|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-18 #bdbdbd,#080808, 19-25 #cf87e8,#080808,b 26-26 #bdbdbd,#080808, 27-31 #e3c78a,#080808, 32-34 #bdbdbd,#080808, 35-42 #ffffff,#080808,b 43-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-10 #bdbdbd,#080808, 11-12 #cf87e8,#080808,b 13-26 #bdbdbd,#080808, 27-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-23 #bdbdbd,#080808, 24-25 #e3c78a,#080808, 26-26 #bdbdbd,#080808, 27-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-16 #bdbdbd,#080808, 17-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-7 #bdbdbd,#080808, 8-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|7: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-61 #bdbdbd,#080808, 62-99 d,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|8: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|9: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|10: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|11: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|12: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|16: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 #ffffff,#262626, 52-86 #949494,#121212, 87-99 d,#121212, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|19: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|20: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|21: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|22: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|23: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|24: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|25: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|26: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|27: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|28: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
-|29: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, 100-100 d,#444444, 101-101 d,#080808, 102-149 #bdbdbd,#080808,
+|1: 0-1 #ffffff,#626262, 2-37 #949494,#121212, 38-39 #ffffff,#262626, 40-74 #949494,#121212, 75-76 #ffffff,#262626, 77-129 #949494,#121212, 130-149 d,#121212,
+|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-18 #bdbdbd,#080808, 19-25 #cf87e8,#080808,b 26-26 #bdbdbd,#080808, 27-31 #e3c78a,#080808, 32-34 #bdbdbd,#080808, 35-36 #ffffff,#080808,b 37-37 #626262,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-12 #ffffff,#080808,b 13-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-10 #bdbdbd,#080808, 11-12 #cf87e8,#080808,b 13-26 #bdbdbd,#080808, 27-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-23 #bdbdbd,#080808, 24-25 #e3c78a,#080808, 26-26 #bdbdbd,#080808, 27-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-16 #bdbdbd,#080808, 17-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|7: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-7 #bdbdbd,#080808, 8-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-49 #bdbdbd,#080808, 50-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|8: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|9: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|10: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|11: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|12: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|16: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-39 #ffffff,#262626, 40-74 #949494,#121212, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|19: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|20: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|21: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|22: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|23: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|24: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|25: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|26: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|27: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|28: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|29: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 d,#080808, 38-38 d,#444444, 39-39 d,#080808, 40-74 #bdbdbd,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
diff --git a/test/snapshots/tag.golden b/test/snapshots/tag.golden
index 13cb2e72..0716cd58 100644
--- a/test/snapshots/tag.golden
+++ b/test/snapshots/tag.golden
@@ -124,9 +124,9 @@
|
== snap tag-look grid=150x30 cursor=7,2
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/tag/cwd/t.txt Save New Del /tmp/pardes-snap/tag/cwd t.txt /tmp/pardes-snap/tag/cwd New Del
-| 1 ZZLOOKED ls ls
-| t.txt t.txt
+| /tmp/pardes-snap/tag/cwd/t.txt Save /tmp/pardes-snap/tag/cwd /tmp/pardes-snap/tag/cwd New Del
+| 1 ZZLOOKED ls ls
+| t.txt t.txt
|
|
|
@@ -139,9 +139,9 @@
|
|
|
-| /tmp/pardes-snap/tag/cwd New Del
-| ls
-| t.txt
+| /tmp/pardes-snap/tag/cwd New Del
+| ls
+| t.txt
|
|
|
diff --git a/test/snapshots/tagbottomimage.golden b/test/snapshots/tagbottomimage.golden
index 9ba62909..fe5b3421 100644
--- a/test/snapshots/tagbottomimage.golden
+++ b/test/snapshots/tagbottomimage.golden
@@ -1,6 +1,6 @@
== snap img-top grid=150x30 cursor=11,7
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| img /tmp/pardes-snap/tagbottomimage/cwd/pic.ppm New Del /tmp/pardes-snap/tagbottomimage/cwd New Del
+| img petscii:off palette:commodore ascii:on /tmp/pardes-snap/tagbottomimag /tmp/pardes-snap/tagbottomimage/cwd New Del
| (f🬱]0V7R*%m🬶🬁G🬬tN#XR9q🬢tC#f/🬜_GR(🬞🬙tCRp$o*7^+[#H7s🬢YDZ🬝▟ ls
| iU)dSotT[r27T"\*Fh▐🬅g,🬰p🬛▙🬲&*3/9xy🬛▀0kCg▛🬱C,>C}PC06m🬝▟█ pic.ppm
| "S▄;$gs,Xa▟T🬔s▖┼W▘?by🬋🬭uCR3/A3🬶lO▖▞🬃🬘╳🬟#=🬺🬕yoaJ@>4🬝🬵
@@ -59,7 +59,7 @@
| ▀(u🬬🬝▟
| fc🬎🬹█
| 🬝🬹
-| img /tmp/pardes-snap/tagbottomimage/cwd/pic.ppm New Del /tmp/pardes-snap/tagbottomimage/cwd New Del
+| img petscii:off palette:commodore ascii:on /tmp/pardes-snap/tagbottomimag /tmp/pardes-snap/tagbottomimage/cwd New Del
== style img-bottom
|0: 0-56 #949494,#121212, 57-149 d,#121212,
|1: 0-0 d,#121212, 1-9 d,#080808, 10-10 #959595,#6c6c6c, 11-11 #6c6c6c,#959595, 12-12 #959595,#6c6c6c, 13-13 #6c6c6c,#959595, 14-24 #959595,#6c6c6c, 25-27 #6c6c6c,#959595, 28-28 #959595,#6c6c6c, 29-30 #6c6c6c,#959595, 31-32 #959595,#6c6c6c, 33-34 #6c6c6c,#959595, 35-35 #959595,#6c6c6c, 36-37 #6c6c6c,#959595, 38-39 #959595,#6c6c6c, 40-40 #6c6c6c,#959595, 41-41 #959595,#6c6c6c, 42-42 #6c6c6c,#959595, 43-44 #959595,#6c6c6c, 45-47 #6c6c6c,#959595, 48-50 #959595,#6c6c6c, 51-51 #6c6c6c,#959595, 52-52 #959595,#6c6c6c, 53-53 #6c6c6c,#959595, 54-63 #959595,#6c6c6c, 64-64 #959595,#352879, 65-65 #352879,#6c6c6c, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
@@ -90,7 +90,7 @@
|26: 0-0 d,#121212, 1-9 d,#080808, 10-10 #6c6c6c,#959595, 11-12 #959595,#6c6c6c, 13-13 #959595,#444444, 14-14 #6c6c6c,#352879, 15-15 #352879,#959595, 16-65 #352879,#352879, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
|27: 0-0 d,#121212, 1-9 d,#080808, 10-11 #6c6c6c,#959595, 12-12 #6c6c6c,#352879, 13-13 #352879,#6c6c6c, 14-14 #352879,#6c5eb5, 15-65 #352879,#352879, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
|28: 0-0 d,#121212, 1-9 d,#080808, 10-10 #6c6c6c,#352879, 11-11 #352879,#6c6c6c, 12-65 #352879,#352879, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
-|29: 0-1 #ffffff,#626262, 2-56 #949494,#121212, 57-74 d,#121212, 75-76 #ffffff,#262626, 77-129 #949494,#121212, 130-149 d,#121212,
+|29: 0-1 #ffffff,#626262, 2-74 #949494,#121212, 75-76 #ffffff,#262626, 77-129 #949494,#121212, 130-149 d,#121212,
== snap search-prompt grid=150x30 cursor=6,28
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
| (f🬱]0V7R*%m🬶🬁G🬬tN#XR9q🬢tC#f/🬜_GR(🬞🬙tCRp$o*7^+[#H7s🬢YDZ🬝▟ ls
@@ -121,7 +121,7 @@
| ▀(u🬬🬝▟
| fc🬎🬹█
| /ZZ
-| img /tmp/pardes-snap/tagbottomimage/cwd/pic.ppm New Del /tmp/pardes-snap/tagbottomimage/cwd New Del
+| img petscii:off palette:commodore ascii:on /tmp/pardes-snap/tagbottomimag /tmp/pardes-snap/tagbottomimage/cwd New Del
== snap del-image grid=150x30 cursor=5,6
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
| ls
diff --git a/test/snapshots/tagbottomimage.snap b/test/snapshots/tagbottomimage.snap
index 8ac06d55..f361186d 100644
--- a/test/snapshots/tagbottomimage.snap
+++ b/test/snapshots/tagbottomimage.snap
@@ -45,8 +45,14 @@ stable 400 5000
# ...and EXECUTE `Del` on that bottom tagline, which is the assertion the
# pixels cannot make: the middle click has to map row 29 to the image pane's
# TAG row rather than to a body row it has no body for. The image pane goes
-# away entirely and the shell takes the whole window back.
-press middle 56 30
-release middle 56 30
+# away entirely and the shell takes the whole window back. Source-local
+# columns plus the image-state prefix clip Del at 150 columns, so briefly widen
+# the grid: the test still clicks the visible bottom-tag word, never the path.
+resize 30 300
+stable 400 5000
+press middle 95 30
+release middle 95 30
+stable 400 5000
+resize 30 150
stable 700 15000
snap del-image
diff --git a/test/snapshots/tinywin.golden b/test/snapshots/tinywin.golden
index 36d1af12..c06c77d3 100644
--- a/test/snapshots/tinywin.golden
+++ b/test/snapshots/tinywin.golden
@@ -33,20 +33,20 @@
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
|$ /tmp/pardes-snap/tinywin/cwd New Del
| /tmp/pardes-snap/tinywin/cwd New Del
-== snap columns grid=100x30 cursor=79,3
+== snap columns grid=100x30 cursor=92,3
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-|$ /tmp/pardes-snap/tinywi /tmp/pardes-snap/tinywi /tmp/pardes-snap/tinywi /tmp/pardes-snap/tinywi
-| $ ls ls ls
-| /tmp/pardes-snap/tinywi
+|$ /tmp/pardes-snap/tinywin/cwd New Del /tmp/pardes-snap/tinywi /tmp/pardes /tmp/parde
+| $ ls ls ls
+| /tmp/pardes-snap/tinywin/cwd New Del
| ls
|
-| /tmp/pardes-snap/tinywi
+| /tmp/pardes-snap/tinywin/cwd New Del
| ls
|
-| /tmp/pardes-snap/tinywi
+| /tmp/pardes-snap/tinywin/cwd New Del
| ls
|
-| /tmp/pardes-snap/tinywi
+| /tmp/pardes-snap/tinywin/cwd New Del
| ls
|
|
@@ -66,32 +66,32 @@
|
== snap narrow grid=4x30 cursor=3,29
|New
-|╎
-|╎
-|╎
-|╎
-|╎
-|╎
-|╎
-|╎
-|╎
-|╎
-|╎
-|╎
-|╎
-|╎
-|╎
-|╎
-|╎
-|╎
-|╎
-|╎
-|╎
-|╎
-|╎
-|╎
-|╎
-|╎
-|╎
-|╎
-|╎
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
diff --git a/test/snapshots/ttyhelp.golden b/test/snapshots/ttyhelp.golden
index 7895d83e..dd85d65e 100644
--- a/test/snapshots/ttyhelp.golden
+++ b/test/snapshots/ttyhelp.golden
@@ -42,24 +42,24 @@
| 6 SPC P ClipPasteBefore
| 7 SPC R ClipReplace
| 8 SPC Y ClipYankMain
-| 9 SPC c d Delcol
-| 10 SPC c n Newcol topbar
-| 11 SPC d Del
-| 12 SPC f c Config
-| 13 SPC f f Find topbar
-| 14 SPC f g Grep topbar
-| 15 SPC f n New topbar
-| 16 SPC f s Save
-| 17 SPC h t Tutor topbar
-| 18 SPC j i Forward C-i
-| 19 SPC j j Last
-| 20 SPC j l Jumplist
-| 21 SPC j o Back C-o
-| 22 SPC k Kill topbar
-| 23 SPC l D WsDiagnostics
-| 24 SPC l S WsSymbols
-| 25 SPC l a CodeAction
-| 26 SPC l d Diagnostics
+| 9 SPC a a PanelAscii
+| 10 SPC a d PanelDissolve
+| 11 SPC a s PanelSlide
+| 12 SPC a v PanelVertical
+| 13 SPC a z PanelZoom
+| 14 SPC c d Delcol
+| 15 SPC c n Newcol topbar
+| 16 SPC d Del
+| 17 SPC f c Config
+| 18 SPC f f Find topbar
+| 19 SPC f g Grep topbar
+| 20 SPC f n New topbar
+| 21 SPC f s Save
+| 22 SPC h t Tutor topbar
+| 23 SPC j i Forward C-i
+| 24 SPC j j Last
+| 25 SPC j l Jumplist
+| 26 SPC j o Back C-o
== snap leaderworks grid=100x30 cursor=7,4
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
|$ /tmp/pardes-snap/ttyhelp/cwd New Del
@@ -73,21 +73,21 @@
| 6 SPC P ClipPasteBefore
| 7 SPC R ClipReplace
| 8 SPC Y ClipYankMain
-| 9 SPC c d Delcol
-| 10 SPC c n Newcol topbar
-| 11 SPC d Del
-| 12 SPC f c Config
-| 13 SPC f f Find topbar
-| 14 SPC f g Grep topbar
-| 15 SPC f n New topbar
-| 16 SPC f s Save
-| 17 SPC h t Tutor topbar
-| 18 SPC j i Forward C-i
-| 19 SPC j j Last
-| 20 SPC j l Jumplist
-| 21 SPC j o Back C-o
-| 22 SPC k Kill topbar
-| 23 SPC l D WsDiagnostics
-| 24 SPC l S WsSymbols
-| 25 SPC l a CodeAction
-| 26 SPC l d Diagnostics
+| 9 SPC a a PanelAscii
+| 10 SPC a d PanelDissolve
+| 11 SPC a s PanelSlide
+| 12 SPC a v PanelVertical
+| 13 SPC a z PanelZoom
+| 14 SPC c d Delcol
+| 15 SPC c n Newcol topbar
+| 16 SPC d Del
+| 17 SPC f c Config
+| 18 SPC f f Find topbar
+| 19 SPC f g Grep topbar
+| 20 SPC f n New topbar
+| 21 SPC f s Save
+| 22 SPC h t Tutor topbar
+| 23 SPC j i Forward C-i
+| 24 SPC j j Last
+| 25 SPC j l Jumplist
+| 26 SPC j o Back C-o
diff --git a/test/snapshots/ttylook.golden b/test/snapshots/ttylook.golden
index bf3785cc..b9c272b3 100644
--- a/test/snapshots/ttylook.golden
+++ b/test/snapshots/ttylook.golden
@@ -248,12 +248,12 @@
|
== snap fileopen grid=120x30 cursor=7,2
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /tmp/pardes-snap/ttylook/cwd/big.txt S /tmp/pardes-snap/ttylook/cwd New Del /tmp/pardes-snap/ttylook/cwd New Del
-| 1 line 1 ls ls
-| 2 line 2 big.txt plain.txt sub big.txt plain.txt sub
+| /tmp/pardes-snap/ttylook/cwd /tmp/pardes-snap/ttylook/cwd /tmp/pardes-snap/ttylook/cwd New Del
+| 1 line 1 ls ls
+| 2 line 2 big.txt plain.txt sub big.txt plain.txt sub
| 3 line 3
-| 4 line 4 /tmp/pardes-snap/ttylook/cwd/sub New D
-| 5 line 5 ls
+| 4 line 4 /tmp/pardes-snap/ttylook/cwd
+| 5 line 5 ls
| 6 line 6
| 7 line 7
| 8 line 8
@@ -263,9 +263,9 @@
| 12 line 12
| 13 line 13
| 14 line 14
-| 15 line 15 /tmp/pardes-snap/ttylook/cwd New Del
-| 16 line 16 ls
-| 17 line 17 big.txt plain.txt sub
+| 15 line 15 /tmp/pardes-snap/ttylook/cwd
+| 16 line 16 ls
+| 17 line 17 big.txt plain.txt sub
| 18 line 18
| 19 line 19
| 20 line 20
diff --git a/test/snapshots/tutor.golden b/test/snapshots/tutor.golden
index 9973f04b..878496a5 100644
--- a/test/snapshots/tutor.golden
+++ b/test/snapshots/tutor.golden
@@ -1,31 +1,31 @@
== snap tutor grid=150x30 cursor=7,2
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| /Tutor Save New Del /tmp/pardes-snap/tutor/cwd New Del /tmp/pardes-snap/tutor/cwd New Del
-| 1 ls ls
-| 2 PARDES TUTO↩
-| R
-| 3 tmux + vi + acme, fro↩
-| m scratch.
+| /Tutor Save New Del /tmp/pardes-snap/tutor/cwd New Del /tmp/pardes-snap/tutor/cwd New Del
+| 1 ls ls
+| 2 ↩
+| PARDES TUTOR
+| 3 tmux + vi↩
+| + acme, from scratch.
| 4
-| 5 The model is acme's: EVERYTHING is↩
-| text, editable and executable
-| 6 the same way. Modal editing (helix↩
-| -style) lives on top of that.
-| 7 Press j until you reach the introd↩
-| uction.
-| 8
-| 9
-| 10 ==========================================↩ /tmp/pardes-snap/tutor/cwd New Del
-| ======================= ls
-| 11 = INTRODUCTION ↩
-| =
-| 12 ==========================================↩
-| =======================
+| 5 The model is acme's: E↩
+| VERYTHING is text, editable an↩
+| d executable
+| 6 the same way. Modal ed↩
+| iting (helix-style) lives on t↩
+| op of that.
+| 7 Press j until you reac↩
+| h the introduction.
+| 8 /tmp/pardes-snap/tutor/cwd New Del
+| 9 ls
+| 10 ==============================↩
+| ==============================↩
+| =====
+| 11 = INTRODU↩
+| CTION ↩
+| =
+| 12 ==============================↩
+| ==============================↩
+| =====
| 13
-| 14 Pardes is tmux + vi + acme, made from scr↩
-| atch. The foundation is
-| 15 the acme model: a pane is just text on sc↩
-| reen. There is no separate
-| 16 "buffer" type — what you see is what yo ↩
-| u edit. Some panes happen to
-| 17 be live terminals (tty mode); most are te
+| 14 Pardes is tmux + vi + acme, m↩
+| ade from scratch. The foundati
diff --git a/test/web-snapshots/README.md b/test/web-snapshots/README.md
index c6564ba0..3b121949 100644
--- a/test/web-snapshots/README.md
+++ b/test/web-snapshots/README.md
@@ -7,13 +7,19 @@ To recapture it:
1. Build and launch the native TTY in this checkout with `PARDES_DUMP` pointing
at a temporary file.
-2. Enter TTY mode, run `clear`, then run `git ls-files '*.zig'`.
-3. Leave the pane in TTY mode and middle-click Pardes's `Dump` builtin.
+2. Enter raw TTY mode, run `clear`, then run
+ `git ls-files --cached --others --exclude-standard -- '*.zig' | sort`.
+ This is the archive generator's tracked-plus-new/nonignored selection in its
+ deterministic display order.
+3. Leave raw TTY mode, press `gg` so the listing starts at scrollback row zero,
+ and middle-click Pardes's `Dump` builtin.
4. Copy the resulting dump into `source-list.dump.zon` without editing it.
-The visible `.body` must exactly match the command plus every path reported by
-Git (and the final prompt). The base64 PTY stream must contain the command
-itself. `source-list.snap` then drives the freestanding WASM + DOM build through
-Chromium: it checks the full listing, taps `build.zig`, snapshots packed cell
-styles (including Tree-sitter's Zig highlighting), resizes the pane separator,
-and finally verifies body scrolling.
+The visible `.body` is the first terminal viewport; `.terminal.stream` must
+contain the command plus every path reported by Git, and every listed path must
+exist. The base64 PTY stream is the real interaction and must contain the
+command itself. `source-list.snap` then drives the freestanding WASM + DOM build
+through Chromium: top and bottom launcher snapshots jointly cover the complete
+listing, then it taps `build.zig`, snapshots packed cell styles (including
+Tree-sitter's Zig highlighting), resizes the pane separator, and finally
+verifies body scrolling.
diff --git a/test/web-snapshots/source-list.dump.zon b/test/web-snapshots/source-list.dump.zon
index 02e1eb43..63d7d67d 100644
--- a/test/web-snapshots/source-list.dump.zon
+++ b/test/web-snapshots/source-list.dump.zon
@@ -3,24 +3,25 @@
.version = 1,
.screen = .{ .cols = 100, .rows = 30 },
.active = 0,
- .topbar = "Kill Newcol Tutor Debug Colors NextColor Crt Dump",
- .theme = "dark",
+ .topbar = "New Newcol Find Grep Help Tutor Dump NextColor Debug Kill",
+ .theme = "helix",
.columns = .{.{ .weight = 1, .panes = .{0} }},
.panes = .{.{
.kind = .terminal,
- .tag = "TTY /home/goblin/00-projects/0x4200.cafe/02-pardes-code Del",
- .body = "$ git ls-files '*.zig'\nbuild.zig\nsrc/crt.zig\nsrc/deck.zig\nsrc/dump.zig\nsrc/gui.zig\nsrc/image.zig\nsrc/look.zig\nsrc/main.zig\nsrc/modal.zig\nsrc/pardes.zig\nsrc/petscii.zig\nsrc/syntax.zig\nsrc/tracy.zig\nsrc/tty.zig\ntest/deck/replay.zig\ntest/deck/schema.zig\ntest/e2e_harness.zig\ntest/hxdiff.zig\ntest/snapshot.zig\nuucode_config.zig\n$ \n\n\n\n\n\n",
+ .tag = "/home/goblin/00-projects/0x4200.cafe/02-pardes-code New Del",
+ .body = "git ls-files --cached --others --exclude-standard -- '*.zig' | sort\nbuild.zig\nbuild/snap.zig\nmupdf.zig\nsrc/allocators.zig\nsrc/animation.zig\nsrc/builtins.zig\nsrc/config.zig\nsrc/dump.zig\nsrc/effect_sources.zig\nsrc/file_pane.zig\nsrc/fonts.zig\nsrc/grammar_manifest.zig\nsrc/gui/crt.zig\nsrc/gui/deck.zig\nsrc/gui/gui.zig\nsrc/image.zig\nsrc/image_pane.zig\nsrc/look.zig\nsrc/lsp/lsp.zig\nsrc/lsp/lsp_zls.zig\nsrc/macos.zig\nsrc/main.zig\nsrc/message.zig\nsrc/modal.zig\nsrc/nested.zig\nsrc/normal_input.zig\nsrc/output_pane.zig",
.scroll = 0,
.cols = 98,
.rows = 28,
.vweight = 1,
+ .tag_tail = null,
.terminal = .{
.cwd = "/home/goblin/00-projects/0x4200.cafe/02-pardes-code",
- .stream = "$ git ls-files '*.zig'\nbuild.zig\nsrc/crt.zig\nsrc/deck.zig\nsrc/dump.zig\nsrc/gui.zig\nsrc/image.zig\nsrc/look.zig\nsrc/main.zig\nsrc/modal.zig\nsrc/pardes.zig\nsrc/petscii.zig\nsrc/syntax.zig\nsrc/tracy.zig\nsrc/tty.zig\ntest/deck/replay.zig\ntest/deck/schema.zig\ntest/e2e_harness.zig\ntest/hxdiff.zig\ntest/snapshot.zig\nuucode_config.zig\n$ ",
- .stream_b64 = "G10xMzM7QwcbXTEzMztEBxtdMTMzO0MHG10wO2dvYmxpbkBwYXJkZXM6L2hvbWUvZ29ibGluLzAwLXByb2plY3RzLzB4NDIwMC5jYWZlLzAyLXBhcmRlcy1jb2RlBxtbPzIwMDRoG10xMzM7QTtjbD1saW5lByQgG10xMzM7QgcNG1tLDRtdMTMzO0E7Y2w9bGluZQckIBtdMTMzO0IHbHMNChtbPzIwMDRsDRtdMTMzO0MHYXNzZXRzCSAgICAgICBkb2NzCQkgICAgbmV4dC1zdGVwcy50eHQgIHRlc3QJICAgICAgdXVjb2RlX2NvbmZpZy56aWcgIHppZy1wa2cNCmJ1aWxkLnppZyAgICAgIG1pc2UudG9tbAkgICAgc2hhZGVycwkgICAgdG9vbHMJICAgICAgdmVuZG9yDQpidWlsZC56aWcuem9uICBuZXh0LW5leHQtc3RlcHMudHh0ICBzcmMJCSAgICB0cmFuc2FjdGlvbnMudHh0ICB6aWctb3V0DQobXTEzMztDBxtdMTMzO0QHG10xMzM7QwcbXTA7Z29ibGluQHBhcmRlczovaG9tZS9nb2JsaW4vMDAtcHJvamVjdHMvMHg0MjAwLmNhZmUvMDItcGFyZGVzLWNvZGUHG1s/MjAwNGgbXTEzMztBO2NsPWxpbmUHJCAbXTEzMztCB2NsZWFyDQobWz8yMDA0bA0bXTEzMztDBxtbSBtbMkobWzNKG10xMzM7QwcbXTEzMztEBxtdMTMzO0MHG10wO2dvYmxpbkBwYXJkZXM6L2hvbWUvZ29ibGluLzAwLXByb2plY3RzLzB4NDIwMC5jYWZlLzAyLXBhcmRlcy1jb2RlBxtbPzIwMDRoG10xMzM7QTtjbD1saW5lByQgG10xMzM7QgdnaXQgbHMtZmlsZXMgJyouemlnJw0KG1s/MjAwNGwNG10xMzM7QwdidWlsZC56aWcNCnNyYy9jcnQuemlnDQpzcmMvZGVjay56aWcNCnNyYy9kdW1wLnppZw0Kc3JjL2d1aS56aWcNCnNyYy9pbWFnZS56aWcNCnNyYy9sb29rLnppZw0Kc3JjL21haW4uemlnDQpzcmMvbW9kYWwuemlnDQpzcmMvcGFyZGVzLnppZw0Kc3JjL3BldHNjaWkuemlnDQpzcmMvc3ludGF4LnppZw0Kc3JjL3RyYWN5LnppZw0Kc3JjL3R0eS56aWcNCnRlc3QvZGVjay9yZXBsYXkuemlnDQp0ZXN0L2RlY2svc2NoZW1hLnppZw0KdGVzdC9lMmVfaGFybmVzcy56aWcNCnRlc3QvaHhkaWZmLnppZw0KdGVzdC9zbmFwc2hvdC56aWcNCnV1Y29kZV9jb25maWcuemlnDQobXTEzMztDBxtdMTMzO0QHG10xMzM7QwcbXTA7Z29ibGluQHBhcmRlczovaG9tZS9nb2JsaW4vMDAtcHJvamVjdHMvMHg0MjAwLmNhZmUvMDItcGFyZGVzLWNvZGUHG1s/MjAwNGgbXTEzMztBO2NsPWxpbmUHJCAbXTEzMztCBw==",
- .cursor = .{ .col = 2, .row = 21 },
+ .stream = "git ls-files --cached --others --exclude-standard -- '*.zig' | sort\nbuild.zig\nbuild/snap.zig\nmupdf.zig\nsrc/allocators.zig\nsrc/animation.zig\nsrc/builtins.zig\nsrc/config.zig\nsrc/dump.zig\nsrc/effect_sources.zig\nsrc/file_pane.zig\nsrc/fonts.zig\nsrc/grammar_manifest.zig\nsrc/gui/crt.zig\nsrc/gui/deck.zig\nsrc/gui/gui.zig\nsrc/image.zig\nsrc/image_pane.zig\nsrc/look.zig\nsrc/lsp/lsp.zig\nsrc/lsp/lsp_zls.zig\nsrc/macos.zig\nsrc/main.zig\nsrc/message.zig\nsrc/modal.zig\nsrc/nested.zig\nsrc/normal_input.zig\nsrc/output_pane.zig\nsrc/panel_animation.zig\nsrc/pardes.zig\nsrc/pdf.zig\nsrc/pdf_pane.zig\nsrc/pdf_pane_integration_test.zig\nsrc/petscii.zig\nsrc/runtime_config.zig\nsrc/selection_pipe.zig\nsrc/shell_bin.zig\nsrc/syntax.zig\nsrc/temp_file.zig\nsrc/term_pane.zig\nsrc/themes/acme.zig\nsrc/themes/dark.zig\nsrc/themes/helix.zig\nsrc/tracy.zig\nsrc/tty/panel_compositor.zig\nsrc/tty/tty.zig\nsrc/user_config.zig\nsrc/web.zig\nsrc/web/zstbi.zig\ntest/deck/replay.zig\ntest/deck/schema.zig\ntest/e2e_harness.zig\ntest/hxdiff.zig\ntest/image_harness.zig\ntest/lspbench.zig\ntest/lspfixture/broken.zig\ntest/lspfixture/dotcomplete.zig\ntest/lspfixture/dothalf.zig\ntest/pdf_bench.zig\ntest/pdf_scroll_bench.zig\ntest/pdf_sections_bench.zig\ntest/perf.zig\ntest/snapshot.zig\ntools/embed_zig_sources.zig\ntools/gen_themes.zig\nuucode_config.zig\n",
+ .stream_b64 = "G10xMzM7QwcbXTEzMztEBxtdMTMzO0MHG10wO2dvYmxpbkBwYXJkZXM6L3RtcC9wYXJkZXMtc25hcC9zb3VyY2UtbGlzdC1jYXB0dXJlL2N3ZAcbWz8yMDA0aBtdMTMzO0E7Y2w9bGluZQckIBtdMTMzO0IHDRtbSw0bXTEzMztBO2NsPWxpbmUHJCAbXTEzMztCB2NkIC9ob21lL2dvYmxpbi8wMC1wcm9qZWN0cy8weDQyMDAuY2FmZS8wMi1wYXJkZXMtY29kZQ0KG1s/MjAwNGwNG10xMzM7QwcbXTEzMztDBxtdMTMzO0QHG10xMzM7QwcbXTA7Z29ibGluQHBhcmRlczovaG9tZS9nb2JsaW4vMDAtcHJvamVjdHMvMHg0MjAwLmNhZmUvMDItcGFyZGVzLWNvZGUHG1s/MjAwNGgbXTEzMztBO2NsPWxpbmUHJCAbXTEzMztCBxtbS2NsZWFyDQobWz8yMDA0bA0bXTEzMztDBxtbSBtbMkobWzNKG10xMzM7QwcbXTEzMztEBxtdMTMzO0MHG10wO2dvYmxpbkBwYXJkZXM6L2hvbWUvZ29ibGluLzAwLXByb2plY3RzLzB4NDIwMC5jYWZlLzAyLXBhcmRlcy1jb2RlBxtbPzIwMDRoG10xMzM7QTtjbD1saW5lByQgG10xMzM7QgdnaXQgbHMtZmlsZXMgLS1jYWNoZWQgLS1vdGhlcnMgLS1leGNsdWRlLXN0YW5kYXJkIC0tICcqLnppZycgfCBzb3J0DQobWz8yMDA0bA0bXTEzMztDBxtdMTMzO0MHYnVpbGQuemlnDQpidWlsZC9zbmFwLnppZw0KbXVwZGYuemlnDQpzcmMvYWxsb2NhdG9ycy56aWcNCnNyYy9hbmltYXRpb24uemlnDQpzcmMvYnVpbHRpbnMuemlnDQpzcmMvY29uZmlnLnppZw0Kc3JjL2R1bXAuemlnDQpzcmMvZWZmZWN0X3NvdXJjZXMuemlnDQpzcmMvZmlsZV9wYW5lLnppZw0Kc3JjL2ZvbnRzLnppZw0Kc3JjL2dyYW1tYXJfbWFuaWZlc3QuemlnDQpzcmMvZ3VpL2NydC56aWcNCnNyYy9ndWkvZGVjay56aWcNCnNyYy9ndWkvZ3VpLnppZw0Kc3JjL2ltYWdlLnppZw0Kc3JjL2ltYWdlX3BhbmUuemlnDQpzcmMvbG9vay56aWcNCnNyYy9sc3AvbHNwLnppZw0Kc3JjL2xzcC9sc3BfemxzLnppZw0Kc3JjL21hY29zLnppZw0Kc3JjL21haW4uemlnDQpzcmMvbWVzc2FnZS56aWcNCnNyYy9tb2RhbC56aWcNCnNyYy9uZXN0ZWQuemlnDQpzcmMvbm9ybWFsX2lucHV0LnppZw0Kc3JjL291dHB1dF9wYW5lLnppZw0Kc3JjL3BhbmVsX2FuaW1hdGlvbi56aWcNCnNyYy9wYXJkZXMuemlnDQpzcmMvcGRmLnppZw0Kc3JjL3BkZl9wYW5lLnppZw0Kc3JjL3BkZl9wYW5lX2ludGVncmF0aW9uX3Rlc3QuemlnDQpzcmMvcGV0c2NpaS56aWcNCnNyYy9ydW50aW1lX2NvbmZpZy56aWcNCnNyYy9zZWxlY3Rpb25fcGlwZS56aWcNCnNyYy9zaGVsbF9iaW4uemlnDQpzcmMvc3ludGF4LnppZw0Kc3JjL3RlbXBfZmlsZS56aWcNCnNyYy90ZXJtX3BhbmUuemlnDQpzcmMvdGhlbWVzL2FjbWUuemlnDQpzcmMvdGhlbWVzL2RhcmsuemlnDQpzcmMvdGhlbWVzL2hlbGl4LnppZw0Kc3JjL3RyYWN5LnppZw0Kc3JjL3R0eS9wYW5lbF9jb21wb3NpdG9yLnppZw0Kc3JjL3R0eS90dHkuemlnDQpzcmMvdXNlcl9jb25maWcuemlnDQpzcmMvd2ViLnppZw0Kc3JjL3dlYi96c3RiaS56aWcNCnRlc3QvZGVjay9yZXBsYXkuemlnDQp0ZXN0L2RlY2svc2NoZW1hLnppZw0KdGVzdC9lMmVfaGFybmVzcy56aWcNCnRlc3QvaHhkaWZmLnppZw0KdGVzdC9pbWFnZV9oYXJuZXNzLnppZw0KdGVzdC9sc3BiZW5jaC56aWcNCnRlc3QvbHNwZml4dHVyZS9icm9rZW4uemlnDQp0ZXN0L2xzcGZpeHR1cmUvZG90Y29tcGxldGUuemlnDQp0ZXN0L2xzcGZpeHR1cmUvZG90aGFsZi56aWcNCnRlc3QvcGRmX2JlbmNoLnppZw0KdGVzdC9wZGZfc2Nyb2xsX2JlbmNoLnppZw0KdGVzdC9wZGZfc2VjdGlvbnNfYmVuY2guemlnDQp0ZXN0L3BlcmYuemlnDQp0ZXN0L3NuYXBzaG90LnppZw0KdG9vbHMvZW1iZWRfemlnX3NvdXJjZXMuemlnDQp0b29scy9nZW5fdGhlbWVzLnppZw0KdXVjb2RlX2NvbmZpZy56aWcNChtdMTMzO0MHG10xMzM7RAcbXTEzMztDBxtdMDtnb2JsaW5AcGFyZGVzOi9ob21lL2dvYmxpbi8wMC1wcm9qZWN0cy8weDQyMDAuY2FmZS8wMi1wYXJkZXMtY29kZQcbWz8yMDA0aBtdMTMzO0E7Y2w9bGluZQckIBtdMTMzO0IHG1tL",
+ .cursor = .{ .col = 2, .row = 27 },
},
.file = null,
.image = null,
}},
-} \ No newline at end of file
+}
diff --git a/test/web-snapshots/source-list.golden b/test/web-snapshots/source-list.golden
index 907b1bd5..afe1414b 100644
--- a/test/web-snapshots/source-list.golden
+++ b/test/web-snapshots/source-list.golden
@@ -1,190 +1,228 @@
-== snap launcher grid=120x37 cursor=4,23
+== snap launcher-bottom grid=120x37 cursor=4,36
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-|$ /home/goblin/00-projects/0x4200.cafe/02-pardes-code New Del
-| $ git ls-files '*.zig'
-| build.zig
-| src/crt.zig
-| src/deck.zig
-| src/dump.zig
-| src/gui.zig
-| src/image.zig
-| src/look.zig
-| src/main.zig
-| src/modal.zig
-| src/pardes.zig
+| /home/goblin/00-projects/0x4200.cafe/02-pardes-code New Del
+| src/pdf_pane_integration_test.zig
| src/petscii.zig
+| src/runtime_config.zig
+| src/selection_pipe.zig
+| src/shell_bin.zig
| src/syntax.zig
+| src/temp_file.zig
+| src/term_pane.zig
+| src/themes/acme.zig
+| src/themes/dark.zig
+| src/themes/helix.zig
| src/tracy.zig
-| src/tty.zig
+| src/tty/panel_compositor.zig
+| src/tty/tty.zig
+| src/user_config.zig
+| src/web.zig
+| src/web/zstbi.zig
| test/deck/replay.zig
| test/deck/schema.zig
| test/e2e_harness.zig
| test/hxdiff.zig
+| test/image_harness.zig
+| test/lspbench.zig
+| test/lspfixture/broken.zig
+| test/lspfixture/dotcomplete.zig
+| test/lspfixture/dothalf.zig
+| test/pdf_bench.zig
+| test/pdf_scroll_bench.zig
+| test/pdf_sections_bench.zig
+| test/perf.zig
| test/snapshot.zig
+| tools/embed_zig_sources.zig
+| tools/gen_themes.zig
| uucode_config.zig
|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
+== snap launcher-top grid=120x37 cursor=4,36
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /home/goblin/00-projects/0x4200.cafe/02-pardes-code New Del
+| git ls-files --cached --others --exclude-standard -- '*.zig' | sort
+| build.zig
+| build/snap.zig
+| mupdf.zig
+| src/allocators.zig
+| src/animation.zig
+| src/builtins.zig
+| src/config.zig
+| src/dump.zig
+| src/effect_sources.zig
+| src/file_pane.zig
+| src/fonts.zig
+| src/grammar_manifest.zig
+| src/gui/crt.zig
+| src/gui/deck.zig
+| src/gui/gui.zig
+| src/image.zig
+| src/image_pane.zig
+| src/look.zig
+| src/lsp/lsp.zig
+| src/lsp/lsp_zls.zig
+| src/macos.zig
+| src/main.zig
+| src/message.zig
+| src/modal.zig
+| src/nested.zig
+| src/normal_input.zig
+| src/output_pane.zig
+| src/panel_animation.zig
+| src/pardes.zig
+| src/pdf.zig
+| src/pdf_pane.zig
+| src/pdf_pane_integration_test.zig
+| src/petscii.zig
+| src/runtime_config.zig
== snap source-open grid=120x37 cursor=7,2
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| build.zig Save New Del $ /home/goblin/00-projects/0x4200.cafe/02-pardes-code New De
-| 1 const std = @import("std"); $ git ls-files '*.zig'
-| 2 const snap_build = @import("build/snap.zig"); build.zig
-| 3 src/crt.zig
-| 4 pub const Platform = enum { tty, gui, web }; src/deck.zig
-| 5 src/dump.zig
-| 6 /// The ZLS the language backend links, spelled once. src/gui.zig
-| 7 /// build.zig.zon pins (0.16.x branch) and is passed src/image.zig
-| 8 /// `-Dversion-string` (its build.zig otherwise shell src/look.zig
-| 9 /// which fails on a fetched package that has no .git src/main.zig
-| 10 const zls_version = "0.16.1-dev+3e0d0820"; src/modal.zig
-| 11 pub const TreeSitterGrammars = enum { disabled, zig, src/pardes.zig
-| 12 src/petscii.zig
-| 13 pub fn build(b: *std.Build) void { src/syntax.zig
-| 14 // Default target is the Steam Deck (deckcap's tr src/tracy.zig
-| 15 // with the glibc version pinned low, so a binary src/tty.zig
-| 16 // release host runs on SteamOS — a native build test/deck/replay.zig
-| 17 // newer versioned libm/libc symbols and dies wit test/deck/schema.zig
-| 18 // on the deck. Override with -Dtarget= as usual. test/e2e_harness.zig
-| 19 const target = b.standardTargetOptions(.{ .defaul test/hxdiff.zig
-| 20 .cpu_arch = .x86_64, test/snapshot.zig
-| 21 .os_tag = .linux, uucode_config.zig
-| 22 .abi = .gnu,
-| 23 .glibc_version = .{ .major = 2, .minor = 38,
-| 24 } });
-| 25 const requested_optimize = b.standardOptimizeOpti
-| 26 const platform = b.option(Platform, "platform", "
-| 27 const static = b.option(bool, "static", "statical
-| 28 const dump_path = b.option([]const u8, "dump", "d
-| 29 const is_web = platform == .web;
-| 30 const is_web_target = target.result.cpu.arch == .
-| 31 // wasm: size is the budget
-| 32 const optimize = if (is_web) .ReleaseSmall else r
-| 33 // The vendored C is never what we are debugging,
-| 34 // the app: 90% of a Debug startup is tree-sitter
-| 35 // (perf: ts_query__perform_analysis + ts_lookahe
+| build.zig Save New Del /home/goblin/00-projects/0x4200.cafe/02-pardes-code New De
+| 1 const std = @import("std"); git ls-files --cached --others --exclude-standard -- '*.
+| 2 const builtin = @import("builtin"); zig' | sort
+| 3 const mupdf_build = @import("mupdf.zig"); build.zig
+| 4 const snap_build = @import("build/snap.zig"); build/snap.zig
+| 5 const grammar_manifest = @import("src/grammar_manife↩ mupdf.zig
+| st.zig"); src/allocators.zig
+| 6 src/animation.zig
+| 7 pub const Platform = enum { tty, gui, web, macos }; src/builtins.zig
+| 8 src/config.zig
+| 9 /// The oldest macOS pardes.app claims to run on, sp↩ src/dump.zig
+| elled ONCE. Three things src/effect_sources.zig
+| 10 /// have to agree about it or the bundle is a lie: t↩ src/file_pane.zig
+| he target this build gives src/fonts.zig
+| 11 /// the static library, the `-target` the app's swif↩ src/grammar_manifest.zig
+| tc link is given (which is src/gui/crt.zig
+| 12 /// what writes LC_BUILD_VERSION, the thing dyld act↩ src/gui/deck.zig
+| ually enforces), and the src/gui/gui.zig
+| 13 /// plist's LSMinimumSystemVersion. The macos branch↩ src/image.zig
+| below derives the last two src/image_pane.zig
+| 14 /// from this, so there is one string and no drift. src/look.zig
+| 15 pub const macos_min_version: std.SemanticVersion = .↩ src/lsp/lsp.zig
+| { .major = 13, .minor = 0, .patch = 0 }; src/lsp/lsp_zls.zig
+| 16 src/macos.zig
+| 17 /// The ZLS the language backend links, spelled once↩ src/main.zig
+| . It names the commit src/message.zig
+| 18 /// build.zig.zon pins (0.16.x branch) and is passed↩ src/modal.zig
+| BOTH to ZLS's own src/nested.zig
+| 19 /// `-Dversion-string` (its build.zig otherwise shel↩ src/normal_input.zig
+| ls out to `git describe`, src/output_pane.zig
+| 20 /// which fails on a fetched package that has no .gi↩ src/panel_animation.zig
+| t) and to `SPC l i`. src/pardes.zig
+| 21 const zls_version = "0.16.1-dev+3e0d0820"; src/pdf.zig
+| 22 pub const TreeSitterGrammars = enum { disabled, zig,↩ src/pdf_pane.zig
+| minimal, full }; src/pdf_pane_integration_test.zig
+| 23 src/petscii.zig
== style source-open
-|0: 0-56 #969696,#2c2a3e, 57-119 d,#2c2a3e,
-|1: 0-1 #ffffff,#6e4fd0, 2-23 #969696,#2c2a3e, 24-59 d,#2c2a3e, 60-61 #ffffff,#383c54, 62-119 #969696,#2c2a3e,
-|2: 0-0 d,#525252, 1-1 d,d, 2-6 #969696,#2c2a3e, 7-18 d,d, 19-25 #cc70d8,d,b 26-26 d,d, 27-31 #8ab87a,d, 32-59 d,d, 60-60 d,#525252, 61-119 d,d,
-|3: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-25 d,d, 26-32 #cc70d8,d,b 33-33 d,d, 34-49 #8ab87a,d, 50-59 d,d, 60-60 d,#525252, 61-119 d,d,
-|4: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-59 d,d, 60-60 d,#525252, 61-119 d,d,
-|5: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-27 d,d, 28-31 #cc70d8,d,b 32-59 d,d, 60-60 d,#525252, 61-119 d,d,
-|6: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-59 d,d, 60-60 d,#525252, 61-119 d,d,
-|7: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-59 #f0f0f0,d,b 60-60 d,#525252, 61-119 d,d,
-|8: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-59 #f0f0f0,d,b 60-60 d,#525252, 61-119 d,d,
-|9: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-59 #f0f0f0,d,b 60-60 d,#525252, 61-119 d,d,
-|10: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-59 #f0f0f0,d,b 60-60 d,#525252, 61-119 d,d,
-|11: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-26 d,d, 27-47 #8ab87a,d, 48-59 d,d, 60-60 d,#525252, 61-119 d,d,
-|12: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-37 d,d, 38-41 #cc70d8,d,b 42-59 d,d, 60-60 d,#525252, 61-119 d,d,
-|13: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-59 d,d, 60-60 d,#525252, 61-119 d,d,
-|14: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-10 d,d, 11-12 #cc70d8,d,b 13-59 d,d, 60-60 d,#525252, 61-119 d,d,
-|15: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-10 d,d, 11-59 #f0f0f0,d,b 60-60 d,#525252, 61-119 d,d,
-|16: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-10 d,d, 11-59 #f0f0f0,d,b 60-60 d,#525252, 61-119 d,d,
-|17: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-10 d,d, 11-59 #f0f0f0,d,b 60-60 d,#525252, 61-119 d,d,
-|18: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-10 d,d, 11-59 #f0f0f0,d,b 60-60 d,#525252, 61-119 d,d,
-|19: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-10 d,d, 11-59 #f0f0f0,d,b 60-60 d,#525252, 61-119 d,d,
-|20: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-59 d,d, 60-60 d,#525252, 61-119 d,d,
-|21: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-59 d,d, 60-60 d,#525252, 61-119 d,d,
-|22: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-59 d,d, 60-60 d,#525252, 61-119 d,d,
-|23: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-59 d,d, 60-60 d,#525252, 61-119 d,d,
-|24: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-43 d,d, 44-44 #d0c070,d, 45-55 d,d, 56-57 #d0c070,d, 58-59 d,d, 60-60 d,#525252, 61-119 d,d,
-|25: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-59 d,d, 60-60 d,#525252, 61-119 d,d,
-|26: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-59 d,d, 60-60 d,#525252, 61-119 d,d,
-|27: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-46 d,d, 47-56 #8ab87a,d, 57-58 d,d, 59-59 #8ab87a,d, 60-60 d,#525252, 61-119 d,d,
-|28: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-40 d,d, 41-48 #8ab87a,d, 49-50 d,d, 51-59 #8ab87a,d, 60-60 d,#525252, 61-119 d,d,
-|29: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-49 d,d, 50-55 #8ab87a,d, 56-57 d,d, 58-59 #8ab87a,d, 60-60 d,#525252, 61-119 d,d,
-|30: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-59 d,d, 60-60 d,#525252, 61-119 d,d,
-|31: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-59 d,d, 60-60 d,#525252, 61-119 d,d,
-|32: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-10 d,d, 11-37 #f0f0f0,d,b 38-59 d,d, 60-60 d,#525252, 61-119 d,d,
-|33: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-27 d,d, 28-29 #cc70d8,d,b 30-53 d,d, 54-57 #cc70d8,d,b 58-59 d,d, 60-60 d,#525252, 61-119 d,d,
-|34: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-10 d,d, 11-59 #f0f0f0,d,b 60-60 d,#525252, 61-119 d,d,
-|35: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-10 d,d, 11-59 #f0f0f0,d,b 60-60 d,#525252, 61-119 d,d,
-|36: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-10 d,d, 11-59 #f0f0f0,d,b 60-60 d,#525252, 61-119 d,d,
+|0: 0-56 #949494,#121212, 57-119 d,#121212,
+|1: 0-1 #ffffff,#626262, 2-39 #949494,#121212, 40-59 d,#121212, 60-61 #ffffff,#262626, 62-119 #949494,#121212,
+|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-18 #bdbdbd,#080808, 19-25 #cf87e8,#080808,b 26-26 #bdbdbd,#080808, 27-31 #e3c78a,#080808, 32-33 #bdbdbd,#080808, 34-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|3: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-22 #bdbdbd,#080808, 23-29 #cf87e8,#080808,b 30-30 #bdbdbd,#080808, 31-39 #e3c78a,#080808, 40-41 #bdbdbd,#080808, 42-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|4: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-26 #bdbdbd,#080808, 27-33 #cf87e8,#080808,b 34-34 #bdbdbd,#080808, 35-45 #e3c78a,#080808, 46-47 #bdbdbd,#080808, 48-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|5: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-25 #bdbdbd,#080808, 26-32 #cf87e8,#080808,b 33-33 #bdbdbd,#080808, 34-49 #e3c78a,#080808, 50-51 #bdbdbd,#080808, 52-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|6: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-31 #bdbdbd,#080808, 32-38 #cf87e8,#080808,b 39-39 #bdbdbd,#080808, 40-58 #e3c78a,#080808, 59-59 #626262,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|7: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #e3c78a,#080808, 14-15 #bdbdbd,#080808, 16-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|8: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|9: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-27 #bdbdbd,#080808, 28-31 #cf87e8,#080808,b 32-57 #bdbdbd,#080808, 58-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|10: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|11: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-58 #ffffff,#080808,b 59-59 #626262,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|12: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-30 #ffffff,#080808,b 31-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|13: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-58 #ffffff,#080808,b 59-59 #626262,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|14: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-32 #ffffff,#080808,b 33-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|15: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-58 #ffffff,#080808,b 59-59 #626262,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|16: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-32 #ffffff,#080808,b 33-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|17: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-58 #ffffff,#080808,b 59-59 #626262,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|18: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-30 #ffffff,#080808,b 31-59 d,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|19: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-58 #ffffff,#080808,b 59-59 #626262,#080808, 60-60 d,#444444, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|20: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-33 #ffffff,#080808,b 34-59 d,#080808, 60-60 d,#121212, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|21: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-57 #ffffff,#080808,b 58-59 d,#080808, 60-60 d,#121212, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|22: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-58 #bdbdbd,#080808, 59-59 #626262,#080808, 60-60 d,#121212, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|23: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-17 #bdbdbd,#080808, 18-19 #e3c78a,#080808, 20-30 #bdbdbd,#080808, 31-31 #e3c78a,#080808, 32-42 #bdbdbd,#080808, 43-43 #e3c78a,#080808, 44-46 #bdbdbd,#080808, 47-59 d,#080808, 60-60 d,#121212, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|24: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-59 d,#080808, 60-60 d,#121212, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|25: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-58 #ffffff,#080808,b 59-59 #626262,#080808, 60-60 d,#121212, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|26: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-27 #ffffff,#080808,b 28-59 d,#080808, 60-60 d,#121212, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|27: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-58 #ffffff,#080808,b 59-59 #626262,#080808, 60-60 d,#121212, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|28: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-24 #ffffff,#080808,b 25-59 d,#080808, 60-60 d,#121212, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|29: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-58 #ffffff,#080808,b 59-59 #626262,#080808, 60-60 d,#121212, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|30: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-31 #ffffff,#080808,b 32-59 d,#080808, 60-60 d,#121212, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|31: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-58 #ffffff,#080808,b 59-59 #626262,#080808, 60-60 d,#121212, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|32: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-26 #ffffff,#080808,b 27-59 d,#080808, 60-60 d,#121212, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|33: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-26 #bdbdbd,#080808, 27-47 #e3c78a,#080808, 48-48 #bdbdbd,#080808, 49-59 d,#080808, 60-60 d,#121212, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|34: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-37 #bdbdbd,#080808, 38-41 #cf87e8,#080808,b 42-58 #bdbdbd,#080808, 59-59 #626262,#080808, 60-60 d,#121212, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|35: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-23 #bdbdbd,#080808, 24-59 d,#080808, 60-60 d,#121212, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
+|36: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-59 d,#080808, 60-60 d,#121212, 61-61 d,#080808, 62-119 #bdbdbd,#080808,
== snap separator-resized grid=120x37 cursor=7,2
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| build.zig Save New Del ╎$ /home/goblin/00-projects/0x4200.cafe/02-pardes-code New D
-| 1 const std = @import("std"); ╎ $ git ls-files '*.zig'
-| 2 const snap_build = @import("build/snap.zig"); ╎ build.zig
-| 3 ╎ src/crt.zig
-| 4 pub const Platform = enum { tty, gui, web }; ╎ src/deck.zig
-| 5 ╎ src/dump.zig
-| 6 /// The ZLS the language backend links, spelled once.╎ src/gui.zig
-| 7 /// build.zig.zon pins (0.16.x branch) and is passed ╎ src/image.zig
-| 8 /// `-Dversion-string` (its build.zig otherwise shell╎ src/look.zig
-| 9 /// which fails on a fetched package that has no .git╎ src/main.zig
-| 10 const zls_version = "0.16.1-dev+3e0d0820"; ╎ src/modal.zig
-| 11 pub const TreeSitterGrammars = enum { disabled, zig, ╎ src/pardes.zig
-| 12 ╎ src/petscii.zig
-| 13 pub fn build(b: *std.Build) void { ╎ src/syntax.zig
-| 14 // Default target is the Steam Deck (deckcap's tr╎ src/tracy.zig
-| 15 // with the glibc version pinned low, so a binary╎ src/tty.zig
-| 16 // release host runs on SteamOS — a native build ╎ test/deck/replay.zig
-| 17 // newer versioned libm/libc symbols and dies wit╎ test/deck/schema.zig
-| 18 // on the deck. Override with -Dtarget= as usual.╎ test/e2e_harness.zig
-| 19 const target = b.standardTargetOptions(.{ .defaul╎ test/hxdiff.zig
-| 20 .cpu_arch = .x86_64, ╎ test/snapshot.zig
-| 21 .os_tag = .linux, ╎ uucode_config.zig
-| 22 .abi = .gnu, ╎
-| 23 .glibc_version = .{ .major = 2, .minor = 38, ╎
-| 24 } }); ╎
-| 25 const requested_optimize = b.standardOptimizeOpti╎
-| 26 const platform = b.option(Platform, "platform", "╎
-| 27 const static = b.option(bool, "static", "statical╎
-| 28 const dump_path = b.option([]const u8, "dump", "d╎
-| 29 const is_web = platform == .web; ╎
-| 30 const is_web_target = target.result.cpu.arch == .╎
-| 31 // wasm: size is the budget ╎
-| 32 const optimize = if (is_web) .ReleaseSmall else r╎
-| 33 // The vendored C is never what we are debugging,╎
-| 34 // the app: 90% of a Debug startup is tree-sitter╎
-| 35 // (perf: ts_query__perform_analysis + ts_lookahe╎
+| build.zig Save New Del /home/goblin/00-projects/0x4200.cafe/02-pardes-code New D
+| 1 const std = @import("std"); git ls-files --cached --others --exclude-standard -- '*
+| 2 const builtin = @import("builtin"); .zig' | sort
+| 3 const mupdf_build = @import("mupdf.zig"); build.zig
+| 4 const snap_build = @import("build/snap.zig"); build/snap.zig
+| 5 const grammar_manifest = @import("src/grammar_manifes↩ mupdf.zig
+| t.zig"); src/allocators.zig
+| 6 src/animation.zig
+| 7 pub const Platform = enum { tty, gui, web, macos }; src/builtins.zig
+| 8 src/config.zig
+| 9 /// The oldest macOS pardes.app claims to run on, spe↩ src/dump.zig
+| lled ONCE. Three things src/effect_sources.zig
+| 10 /// have to agree about it or the bundle is a lie: th↩ src/file_pane.zig
+| e target this build gives src/fonts.zig
+| 11 /// the static library, the `-target` the app's swift↩ src/grammar_manifest.zig
+| c link is given (which is src/gui/crt.zig
+| 12 /// what writes LC_BUILD_VERSION, the thing dyld actu↩ src/gui/deck.zig
+| ally enforces), and the src/gui/gui.zig
+| 13 /// plist's LSMinimumSystemVersion. The macos branch ↩ src/image.zig
+| below derives the last two src/image_pane.zig
+| 14 /// from this, so there is one string and no drift. src/look.zig
+| 15 pub const macos_min_version: std.SemanticVersion = .{↩ src/lsp/lsp.zig
+| .major = 13, .minor = 0, .patch = 0 }; src/lsp/lsp_zls.zig
+| 16 src/macos.zig
+| 17 /// The ZLS the language backend links, spelled once.↩ src/main.zig
+| It names the commit src/message.zig
+| 18 /// build.zig.zon pins (0.16.x branch) and is passed ↩ src/modal.zig
+| BOTH to ZLS's own src/nested.zig
+| 19 /// `-Dversion-string` (its build.zig otherwise shell↩ src/normal_input.zig
+| s out to `git describe`, src/output_pane.zig
+| 20 /// which fails on a fetched package that has no .git↩ src/panel_animation.zig
+| ) and to `SPC l i`. src/pardes.zig
+| 21 const zls_version = "0.16.1-dev+3e0d0820"; src/pdf.zig
+| 22 pub const TreeSitterGrammars = enum { disabled, zig, ↩ src/pdf_pane.zig
+| minimal, full }; src/pdf_pane_integration_test.zig
+| 23 src/petscii.zig
== snap source-scrolled grid=120x37 cursor=7,2
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| build.zig Save New Del ╎$ /home/goblin/00-projects/0x4200.cafe/02-pardes-code New D
-| 1 const std = @import("std"); ╎ $ git ls-files '*.zig'
-| 2 const snap_build = @import("build/snap.zig"); ╎ build.zig
-| 3 ╎ src/crt.zig
-| 4 pub const Platform = enum { tty, gui, web }; ╎ src/deck.zig
-| 5 ╎ src/dump.zig
-| 6 /// The ZLS the language backend links, spelled once.╎ src/gui.zig
-| 7 /// build.zig.zon pins (0.16.x branch) and is passed ╎ src/image.zig
-| 8 /// `-Dversion-string` (its build.zig otherwise shell╎ src/look.zig
-| 9 /// which fails on a fetched package that has no .git╎ src/main.zig
-| 10 const zls_version = "0.16.1-dev+3e0d0820"; ╎ src/modal.zig
-| 11 pub const TreeSitterGrammars = enum { disabled, zig, ╎ src/pardes.zig
-| 12 ╎ src/petscii.zig
-| 13 pub fn build(b: *std.Build) void { ╎ src/syntax.zig
-| 14 // Default target is the Steam Deck (deckcap's tr╎ src/tracy.zig
-| 15 // with the glibc version pinned low, so a binary╎ src/tty.zig
-| 16 // release host runs on SteamOS — a native build ╎ test/deck/replay.zig
-| 17 // newer versioned libm/libc symbols and dies wit╎ test/deck/schema.zig
-| 18 // on the deck. Override with -Dtarget= as usual.╎ test/e2e_harness.zig
-| 19 const target = b.standardTargetOptions(.{ .defaul╎ test/hxdiff.zig
-| 20 .cpu_arch = .x86_64, ╎ test/snapshot.zig
-| 21 .os_tag = .linux, ╎ uucode_config.zig
-| 22 .abi = .gnu, ╎
-| 23 .glibc_version = .{ .major = 2, .minor = 38, ╎
-| 24 } }); ╎
-| 25 const requested_optimize = b.standardOptimizeOpti╎
-| 26 const platform = b.option(Platform, "platform", "╎
-| 27 const static = b.option(bool, "static", "statical╎
-| 28 const dump_path = b.option([]const u8, "dump", "d╎
-| 29 const is_web = platform == .web; ╎
-| 30 const is_web_target = target.result.cpu.arch == .╎
-| 31 // wasm: size is the budget ╎
-| 32 const optimize = if (is_web) .ReleaseSmall else r╎
-| 33 // The vendored C is never what we are debugging,╎
-| 34 // the app: 90% of a Debug startup is tree-sitter╎
-| 35 // (perf: ts_query__perform_analysis + ts_lookahe╎
+| build.zig Save New Del /home/goblin/00-projects/0x4200.cafe/02-pardes-code New D
+| 1 const std = @import("std"); git ls-files --cached --others --exclude-standard -- '*
+| 2 const builtin = @import("builtin"); .zig' | sort
+| 3 const mupdf_build = @import("mupdf.zig"); build.zig
+| 4 const snap_build = @import("build/snap.zig"); build/snap.zig
+| 5 const grammar_manifest = @import("src/grammar_manifes↩ mupdf.zig
+| t.zig"); src/allocators.zig
+| 6 src/animation.zig
+| 7 pub const Platform = enum { tty, gui, web, macos }; src/builtins.zig
+| 8 src/config.zig
+| 9 /// The oldest macOS pardes.app claims to run on, spe↩ src/dump.zig
+| lled ONCE. Three things src/effect_sources.zig
+| 10 /// have to agree about it or the bundle is a lie: th↩ src/file_pane.zig
+| e target this build gives src/fonts.zig
+| 11 /// the static library, the `-target` the app's swift↩ src/grammar_manifest.zig
+| c link is given (which is src/gui/crt.zig
+| 12 /// what writes LC_BUILD_VERSION, the thing dyld actu↩ src/gui/deck.zig
+| ally enforces), and the src/gui/gui.zig
+| 13 /// plist's LSMinimumSystemVersion. The macos branch ↩ src/image.zig
+| below derives the last two src/image_pane.zig
+| 14 /// from this, so there is one string and no drift. src/look.zig
+| 15 pub const macos_min_version: std.SemanticVersion = .{↩ src/lsp/lsp.zig
+| .major = 13, .minor = 0, .patch = 0 }; src/lsp/lsp_zls.zig
+| 16 src/macos.zig
+| 17 /// The ZLS the language backend links, spelled once.↩ src/main.zig
+| It names the commit src/message.zig
+| 18 /// build.zig.zon pins (0.16.x branch) and is passed ↩ src/modal.zig
+| BOTH to ZLS's own src/nested.zig
+| 19 /// `-Dversion-string` (its build.zig otherwise shell↩ src/normal_input.zig
+| s out to `git describe`, src/output_pane.zig
+| 20 /// which fails on a fetched package that has no .git↩ src/panel_animation.zig
+| ) and to `SPC l i`. src/pardes.zig
+| 21 const zls_version = "0.16.1-dev+3e0d0820"; src/pdf.zig
+| 22 pub const TreeSitterGrammars = enum { disabled, zig, ↩ src/pdf_pane.zig
+| minimal, full }; src/pdf_pane_integration_test.zig
+| 23 src/petscii.zig
diff --git a/test/web-snapshots/source-list.snap b/test/web-snapshots/source-list.snap
index 61cb94d3..3f1a2a20 100644
--- a/test/web-snapshots/source-list.snap
+++ b/test/web-snapshots/source-list.snap
@@ -1,16 +1,29 @@
# Browser-only .snap commands. The page is built from a dump made by a real
-# Pardes TTY after running `git ls-files '*.zig'`; touch events below are
-# injected through Chrome's real browser input path.
+# Pardes TTY after running the same tracked-plus-new/nonignored source selection
+# as the web archive generator; touch events below use Chrome's browser path.
wait 10000 uucode_config.zig
stable 100 5000
-snap launcher
+snap launcher-bottom
+
+# Replayed terminal state follows its live cursor at the bottom. Scroll to the
+# first half as an actual browser gesture: together these two snapshots cover
+# every path instead of silently testing only a stale suffix. The dump command
+# is sorted like the archive, leaving build.zig at row 3 once we reach the top.
+swipe 4 60 2 60 34 40
+wait 10000 build.zig
+stable 100 5000
+snap launcher-top
# A little sub-slop movement is still a tap. On web one-finger taps are LOOK,
-# so this opens build.zig from the archive generated from Git-tracked sources.
+# so this opens build.zig from the working-tree archive generated from Git's
+# tracked-plus-new/nonignored source view.
touch down 1 5 3
touch move 1 5.2 3.1
touch up 1 5.2 3.1
-wait 10000 build.zig Save New Del
+# The editable path and the right-aligned command tail deliberately have
+# layout-owned padding between them. Check the two semantic facts separately.
+wait 10000 Save New Del
+wait 10000 build.zig
stable 100 5000
snap source-open
snapstyle source-open
diff --git a/test/web_harness.mjs b/test/web_harness.mjs
index b9243435..087dd91b 100644
--- a/test/web_harness.mjs
+++ b/test/web_harness.mjs
@@ -22,7 +22,8 @@ async function main() {
for (const name of [
"memory", "pardes_init", "pardes_frame", "pardes_frame_ptr", "pardes_key",
- "pardes_mouse", "pardes_output", "pardes_lsp_response", "pardes_pipe_response", "pardes_file_changed",
+ "pardes_mouse", "pardes_pointer_leave", "pardes_gui_tagline_font_percent", "pardes_output",
+ "pardes_lsp_response", "pardes_pipe_response", "pardes_file_changed",
"pardes_effect_next", "pardes_deinit",
]) assert.ok(wasm[name], `missing wasm export ${name}`);
@@ -30,7 +31,7 @@ async function main() {
assert.equal(wasm.pardes_frame(), 80 * 24);
assert.ok(wasm.pardes_frame_ptr() > 0);
- const { DomRenderer } = await import(pathToFileURL(path.join(webDir, "app.mjs")));
+ const { DomRenderer, animationTicks } = await import(pathToFileURL(path.join(webDir, "app.mjs")));
const document = new FakeDocument();
const root = document.createElement("main");
root.ownerDocument = document;
@@ -43,12 +44,45 @@ async function main() {
assert.equal(root.children[0].children.length, 80);
assert.ok(first.cells.some((cell) => cell.text !== " "), "dump rendered an empty frame");
assert.ok(root.walk().every((node) => node.tagName !== "CANVAS"), "renderer created a canvas");
+ const taglineIndex = first.cells.findIndex((cell) => cell.attrs & (1 << 7));
+ const bodyIndex = first.cells.findIndex((cell) => !(cell.attrs & (1 << 7)) && cell.text !== " ");
+ assert.ok(taglineIndex >= 0, "frame ABI did not mark a tagline font role");
+ assert.ok(bodyIndex >= 0, "fixture has no body glyph to compare with tagline");
+ assert.ok(renderer.cells[taglineIndex].classList.names.has("is-tagline"), "DOM tagline class was not applied");
+ assert.ok(!renderer.cells[bodyIndex].classList.names.has("is-tagline"), "DOM inferred tagline role for body text");
+ assert.equal(
+ root.style["--pardes-tagline-font-size"],
+ `${wasm.pardes_gui_tagline_font_percent()}%`,
+ "DOM did not use the build-time tagline percentage",
+ );
+
+ for (const hz of [60, 120, 144]) {
+ const clock = { last: null, lag: 0 };
+ let ticks = 0;
+ for (let frame = 0; frame <= hz; frame += 1) {
+ ticks += animationTicks(clock, frame * 1000 / hz);
+ }
+ assert.equal(ticks, 60, `${hz} Hz rAF did not produce 60 core ticks per second`);
+ }
+ const stalled = { last: null, lag: 0 };
+ assert.equal(animationTicks(stalled, 0), 0);
+ assert.equal(animationTicks(stalled, 60_000), 4, "suspended tab catch-up was not bounded");
const firstCell = renderer.cells[0];
const second = renderer.render();
assert.equal(renderer.cells[0], firstCell, "stable frames should reuse DOM nodes");
assert.equal(second.cells.length, 80 * 24);
+ // A replay follows the terminal cursor at the bottom. Deliberately
+ // over-scroll (the core clamps at row zero) so adding more source files does
+ // not silently move the hard-coded build.zig click onto some later path.
+ for (let tick = 0; tick < 128; tick += 1) wasm.pardes_mouse(3, 0, 5, 3, 0);
+ const listingTop = renderer.render();
+ assert.match(
+ listingTop.cells.map((cell) => cell.text).join(""),
+ /git ls-files --cached --others --exclude-standard.*build\.zig/,
+ "working-tree source listing did not scroll to its first row",
+ );
wasm.pardes_mouse(2, 0, 5, 3, 0);
wasm.pardes_mouse(2, 1, 5, 3, 0);
const looked = renderer.render();
diff --git a/test/web_snapshot.mjs b/test/web_snapshot.mjs
index 4c50956e..df42b1c5 100644
--- a/test/web_snapshot.mjs
+++ b/test/web_snapshot.mjs
@@ -300,8 +300,15 @@ async function waitForRuntime(timeout) {
if (!app?.test?.grid()?.cols || document.querySelector("canvas")) return false;
const want = app.runtime.gridSize();
const frame = app.test.grid();
+ const tagline = document.querySelector(".pardes-cell.is-tagline");
+ const body = document.querySelector(".pardes-cell:not(.is-tagline)");
+ if (!tagline || !body) return false;
+ const tagRect = tagline.getBoundingClientRect();
+ const bodyRect = body.getBoundingClientRect();
return frame.cols === want.cols && frame.rows === want.rows &&
- document.querySelectorAll(".pardes-row").length === want.rows;
+ document.querySelectorAll(".pardes-row").length === want.rows &&
+ tagRect.width === bodyRect.width && tagRect.height === bodyRect.height &&
+ parseFloat(getComputedStyle(tagline).fontSize) < parseFloat(getComputedStyle(body).fontSize);
})()`);
} catch {
return false;
diff --git a/tools/embed_zig_sources.zig b/tools/embed_zig_sources.zig
index 9934d09a..91fd128f 100644
--- a/tools/embed_zig_sources.zig
+++ b/tools/embed_zig_sources.zig
@@ -1,8 +1,9 @@
//! Build-time generator for the web LOOK filesystem.
//!
-//! Prefer `git ls-files` so ignored build/cache/vendor files never leak into
-//! the browser bundle. A source archive should still be buildable without Git,
-//! so a conservative directory walk is the fallback.
+//! Prefer Git's tracked-plus-untracked/nonignored view so a new source works in
+//! the browser before it is staged, while ignored build/cache/vendor files can
+//! never leak into the bundle. A source archive should still be buildable
+//! without Git, so a conservative directory walk is the fallback.
const std = @import("std");
pub fn main(init: std.process.Init) !void {
@@ -13,7 +14,7 @@ pub fn main(init: std.process.Init) !void {
const root = args[1];
const output_path = args[2];
- const paths = (try gitTrackedZigFiles(gpa, io, root)) orelse try walkedZigFiles(gpa, io, root);
+ const paths = (try gitWorkingTreeZigFiles(gpa, io, root)) orelse try walkedZigFiles(gpa, io, root);
defer {
for (paths) |path| gpa.free(path);
gpa.free(paths);
@@ -32,10 +33,9 @@ pub fn main(init: std.process.Init) !void {
for (paths) |path| {
const full_path = try std.Io.Dir.path.join(gpa, &.{ root, path });
defer gpa.free(full_path);
- // `git ls-files` reports the INDEX, not the working tree, and the two
- // disagree the moment a file is moved or deleted without staging it —
- // an ordinary mid-edit state, and in a jj-colocated repo the normal one
- // until the change is exported. A path that is no longer on disk is
+ // Cached entries can still name a file moved or deleted without
+ // staging it—an ordinary mid-edit state, and in a jj-colocated repo the
+ // normal one until the change is exported. A path no longer on disk is
// simply not a source to embed; failing the whole web build over it
// means an unrelated refactor breaks a shell it never touched.
const contents = std.Io.Dir.cwd().readFileAlloc(io, full_path, gpa, .limited(64 * 1024 * 1024)) catch |err| switch (err) {
@@ -58,15 +58,20 @@ pub fn main(init: std.process.Init) !void {
try output.writeStreamingAll(io, generated.written());
}
-/// Returns null only when Git cannot supply the tracked-file list. Working-tree
-/// contents are read later; Git supplies the path filter, not historical blobs.
-fn gitTrackedZigFiles(
+/// Returns null only when Git cannot supply the working-tree source list.
+/// `--cached --others --exclude-standard` includes a newly-created module
+/// before it is staged but retains Git's ignore rules. Contents are read later;
+/// Git supplies only the path filter, never historical blobs.
+fn gitWorkingTreeZigFiles(
gpa: std.mem.Allocator,
io: std.Io,
root: []const u8,
) !?[][]const u8 {
const result = std.process.run(gpa, io, .{
- .argv = &.{ "git", "-C", root, "ls-files", "-z", "--", "*.zig" },
+ .argv = &.{
+ "git", "-C", root, "ls-files", "-z",
+ "--cached", "--others", "--exclude-standard", "--", "*.zig",
+ },
.stdout_limit = .limited(16 * 1024 * 1024),
.stderr_limit = .limited(1024 * 1024),
}) catch return null;