summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-05 20:21:50 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commitde4def3548a6729b0dfd2120495a61beef8c8c2c (patch)
tree12144f0f64dc96bb4741459c1f9db57f44349330
parent7988d9bc6e31210ff13994f18d5622dd57ba9341 (diff)
downloadpardes-de4def3548a6729b0dfd2120495a61beef8c8c2c.tar.gz
pardes-de4def3548a6729b0dfd2120495a61beef8c8c2c.zip
pardes v2: the rewrite, complete and organized. src/ (core + three shells), test/ (snapshot parity harness + 18 frozen goldens). One sans-IO core, vaxis tty + SDL3 GPU native + wasm web shells, 18/18 parity with the purged prototype, 7.6k lines vs 12.1k. Fix: gui shell pre-sized the core at init so the greet-releasing resize never fired (blank panes until first interaction); live sessions now init at defaults and get the real grid as a resize event (the shell contract, documented on Options).
-rw-r--r--.gitignore1
-rw-r--r--assets/AdwaitaMono-Regular.ttfbin0 -> 1435180 bytes
-rw-r--r--assets/DejaVuSansMono.ttfbin343140 -> 0 bytes
-rw-r--r--build.zig832
-rw-r--r--build.zig.zon4
-rw-r--r--dbg.txt25
-rw-r--r--docs/design.pdf1380
-rw-r--r--docs/design.typ246
-rw-r--r--docs/ideas.typ (renamed from ideas.typ)0
-rw-r--r--e2e_harness_sdl.zig332
-rw-r--r--image.zig199
-rw-r--r--main.zig3940
-rw-r--r--paint.zig340
-rw-r--r--pane_ui.zig496
-rw-r--r--replay.zig786
-rw-r--r--src/dump.zig (renamed from dump.zig)0
-rw-r--r--src/gui.zig2399
-rw-r--r--src/image.zig53
-rw-r--r--src/look.zig123
-rw-r--r--src/main.zig102
-rw-r--r--src/modal.zig (renamed from modal.zig)0
-rw-r--r--src/pardes.zig3728
-rw-r--r--src/petscii.zig (renamed from petscii.zig)0
-rw-r--r--src/syntax.zig173
-rw-r--r--src/tty.zig386
-rw-r--r--src/tutor.txt (renamed from tutor.txt)0
-rw-r--r--term.zig213
-rw-r--r--test/e2e_harness.zig (renamed from e2e_harness.zig)11
-rw-r--r--test/snapshot.zig553
-rw-r--r--test/snapshots/boot.golden62
-rw-r--r--test/snapshots/boot.snap8
-rw-r--r--test/snapshots/dump.actual63
-rw-r--r--test/snapshots/dump.golden64
-rw-r--r--test/snapshots/dump.snap18
-rw-r--r--test/snapshots/edit.actual62
-rw-r--r--test/snapshots/edit.golden62
-rw-r--r--test/snapshots/edit.snap15
-rw-r--r--test/snapshots/exec.golden124
-rw-r--r--test/snapshots/exec.snap46
-rw-r--r--test/snapshots/fileedit.golden116
-rw-r--r--test/snapshots/fileedit.snap40
-rw-r--r--test/snapshots/images.golden124
-rw-r--r--test/snapshots/images.snap29
-rw-r--r--test/snapshots/load.golden62
-rw-r--r--test/snapshots/load.snap27
-rw-r--r--test/snapshots/look-dir.actual31
-rw-r--r--test/snapshots/look-dir.golden31
-rw-r--r--test/snapshots/look-dir.snap20
-rw-r--r--test/snapshots/look-file.golden62
-rw-r--r--test/snapshots/look-file.snap19
-rw-r--r--test/snapshots/modal.golden372
-rw-r--r--test/snapshots/modal.snap53
-rw-r--r--test/snapshots/scroll.golden186
-rw-r--r--test/snapshots/scroll.snap31
-rw-r--r--test/snapshots/syntax.actual124
-rw-r--r--test/snapshots/syntax.golden124
-rw-r--r--test/snapshots/syntax.snap27
-rw-r--r--test/snapshots/tag.golden217
-rw-r--r--test/snapshots/tag.snap41
-rw-r--r--test/snapshots/theme.golden186
-rw-r--r--test/snapshots/theme.snap24
-rw-r--r--test/snapshots/tty.golden93
-rw-r--r--test/snapshots/tty.snap16
-rw-r--r--test/snapshots/ttyonly.golden62
-rw-r--r--test/snapshots/ttyonly.snap11
-rw-r--r--test/snapshots/tutor.golden31
-rw-r--r--test/snapshots/tutor.snap8
-rw-r--r--test/snapshots/windowops.golden372
-rw-r--r--test/snapshots/windowops.snap53
-rw-r--r--tests.zig1061
-rw-r--r--tests/browser_replay_dump.zon54
-rw-r--r--tests/browser_replay_e2e.mjs356
-rw-r--r--tests_sdl.zig185
-rw-r--r--tests_syntax_perf.zig795
-rw-r--r--tests_tty_perf.zig88
-rw-r--r--tutor_gen.zig386
-rw-r--r--ui_sdl.zig2916
-rw-r--r--ui_vaxis.zig38
78 files changed, 12529 insertions, 12788 deletions
diff --git a/.gitignore b/.gitignore
index 4a6692ad..c006b9dd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@ zig-pkg
zig-out
.zig-cache/
.zig-cache/*
+assets/MapleMono-NF-Regular.ttf
diff --git a/assets/AdwaitaMono-Regular.ttf b/assets/AdwaitaMono-Regular.ttf
new file mode 100644
index 00000000..7f95a677
--- /dev/null
+++ b/assets/AdwaitaMono-Regular.ttf
Binary files differ
diff --git a/assets/DejaVuSansMono.ttf b/assets/DejaVuSansMono.ttf
deleted file mode 100644
index bcf640e2..00000000
--- a/assets/DejaVuSansMono.ttf
+++ /dev/null
Binary files differ
diff --git a/build.zig b/build.zig
index 8b1b6a3b..22dd7158 100644
--- a/build.zig
+++ b/build.zig
@@ -1,50 +1,34 @@
const std = @import("std");
-const AppKind = enum { main, replay };
+pub const Platform = enum { tty, gui, web };
+pub const TreeSitterGrammars = enum { disabled, minimal, full };
const GhosttyArtifact = enum { ghostty, lib };
-const TreeSitterGrammars = enum { disabled, minimal, full };
-const WebFilter = enum { linear, nearest };
pub fn build(b: *std.Build) void {
- const requested_optimize = b.standardOptimizeOption(.{});
const target = b.standardTargetOptions(.{});
+ const requested_optimize = b.standardOptimizeOption(.{});
+ const platform = b.option(Platform, "platform", "which shell to build (tty, gui, web)") orelse .tty;
+ const static = b.option(bool, "static", "statically link") orelse false;
+ const dump_path = b.option([]const u8, "dump", "dump .zon embedded into the web shell (-Dplatform=web)");
+ const emcc_path = b.option([]const u8, "emcc", "emcc for the web shell's final link") orelse "/usr/lib/emscripten/emcc";
const is_emscripten = target.result.os.tag == .emscripten;
+ // wasm: size is the budget
const optimize = if (is_emscripten) .ReleaseSmall else requested_optimize;
- const emscripten_sysroot = if (is_emscripten) detectEmscriptenSysroot(b) else null;
+ const default_grammars: TreeSitterGrammars = if (is_emscripten) .minimal else .full;
+ const tree_sitter_grammars = b.option(TreeSitterGrammars, "tree-sitter", "tree-sitter grammar set: disabled, minimal, full") orelse default_grammars;
- // build-time IO, only used to slurp the grammars' highlights.scm queries.
- var threaded: std.Io.Threaded = .init(b.allocator, .{});
- defer threaded.deinit();
- const io = threaded.io();
+ // the browser shell: zig object + emcc link + html, installed to zig-out/web
+ const web_step = b.step("web", "build the browser shell into zig-out/web (-Dplatform=web -Dtarget=wasm32-emscripten -Ddump=<dump.zon>)");
- // -Dtrace: make the e2e runner print the captured screen state (after each
- // pump/waitFor and on every assertion) so failures can be inspected live.
- // Usage: `zig build test -Dtrace`.
- const trace = b.option(bool, "trace", "print captured tty/screen state during e2e tests") orelse false;
- const static_link = b.option(bool, "static", "link all build executables and test binaries statically") orelse false;
- const exe_linkage: ?std.builtin.LinkMode = if (static_link) .static else null;
- const strip = b.option(bool, "strip", "strip debug info from the compiled pardes binary") orelse false;
- // -Dsdl: build the SDL3 GPU UI frontend (a windowed cell-grid renderer)
- // instead of the default libvaxis terminal backend. The sdl/stb deps are
- // lazy, so a plain `zig build` never fetches or compiles them; they're only
- // pulled in when this flag is set. Use `zig build test-sdl -Dsdl` for the
- // rendered-image SDL e2e suite.
- const sdl = b.option(bool, "sdl", "build the SDL3 GPU UI frontend instead of the terminal backend") orelse false;
- const sdl_backend = sdl or is_emscripten;
- const replay_dump = b.option([]const u8, "replay-dump", "embed this replay dump .zon into pardes-replay");
- const web_render_scale = b.option(f32, "web-render-scale", "browser replay backing/glyph scale") orelse 1.5;
- if (web_render_scale <= 0) @panic("web-render-scale must be greater than zero");
- const web_filter = b.option(WebFilter, "web-filter", "browser replay filtering: linear, nearest") orelse .linear;
- const default_tree_sitter_grammars: TreeSitterGrammars = if (is_emscripten) .minimal else .full;
- const tree_sitter_grammars = b.option(TreeSitterGrammars, "tree-sitter", "tree-sitter grammar set: disabled, minimal, full") orelse default_tree_sitter_grammars;
- const syntax_highlighting = tree_sitter_grammars != .disabled;
- const syntax_minimal_grammars = tree_sitter_grammars == .minimal or tree_sitter_grammars == .full;
- const syntax_full_grammars = tree_sitter_grammars == .full;
- const emscripten_include: ?std.Build.LazyPath = if (emscripten_sysroot) |sysroot|
- .{ .cwd_relative = b.pathJoin(&.{ sysroot, "include" }) }
- else
- null;
- const emscripten_libc_file: ?std.Build.LazyPath = if (emscripten_sysroot) |sysroot| blk: {
+ const emscripten_sysroot = if (is_emscripten) detectEmscriptenSysroot(b) else null;
+ if (platform == .web and !is_emscripten) return failBuild(b, web_step, "-Dplatform=web requires -Dtarget=wasm32-emscripten");
+ if (platform != .web and is_emscripten) return failBuild(b, web_step, "-Dtarget=wasm32-emscripten requires -Dplatform=web");
+ if (platform == .web and dump_path == null) return failBuild(b, web_step, "-Dplatform=web requires -Ddump=<dump.zon> (the browser has no ptys; state replays from an embedded dump)");
+ if (platform == .web and emscripten_sysroot == null) return failBuild(b, web_step, "no emscripten sysroot found; pass --sysroot <emscripten-cache>/sysroot or set EM_CACHE/EMSDK");
+
+ // emscripten "libc": point zig at the sysroot headers/crt via a libc.txt
+ // (the prototype's WriteFiles trick) — applied to EVERY compile step.
+ const libc_file: ?std.Build.LazyPath = if (emscripten_sysroot) |sysroot| blk: {
const files = b.addWriteFiles();
break :blk files.add("emscripten-libc.txt", b.fmt(
\\include_dir={s}/include
@@ -56,265 +40,49 @@ pub fn build(b: *std.Build) void {
\\
, .{ sysroot, sysroot, sysroot }));
} else null;
+ const emscripten_include: ?std.Build.LazyPath = if (emscripten_sysroot) |sysroot|
+ .{ .cwd_relative = b.pathJoin(&.{ sysroot, "include" }) }
+ else
+ null;
- const exe_name = targetBinaryName(b, "pardes", target.result);
- const replay_name = targetBinaryName(b, "pardes-replay", target.result);
- const exe = b.addExecutable(.{
- .name = exe_name,
- .linkage = exe_linkage,
- .root_module = b.createModule(.{
- .target = target,
- .optimize = optimize,
- .root_source_file = b.path("main.zig"),
- .strip = strip,
- .link_libc = true,
- }),
- });
- const replay = b.addExecutable(.{
- .name = replay_name,
- .linkage = exe_linkage,
- .root_module = b.createModule(.{
- .target = target,
- .optimize = optimize,
- .root_source_file = b.path("replay.zig"),
- .strip = strip,
- }),
- });
- if (emscripten_libc_file) |libc_file| {
- exe.setLibCFile(libc_file);
- replay.setLibCFile(libc_file);
- }
- const tests = b.addExecutable(.{
- .name = "pardes-e2e",
- .linkage = exe_linkage,
- .root_module = b.createModule(.{
- .target = target,
- .optimize = optimize,
- .root_source_file = b.path("tests.zig"),
- .link_libc = true,
- }),
- });
-
- const main_app_config = b.addOptions();
- main_app_config.addOption(AppKind, "app_kind", .main);
- main_app_config.addOption(bool, "syntax_highlighting", syntax_highlighting);
- main_app_config.addOption(bool, "syntax_minimal_grammars", syntax_minimal_grammars);
- main_app_config.addOption(bool, "syntax_full_grammars", syntax_full_grammars);
- exe.root_module.addOptions("app_config", main_app_config);
-
- const replay_app_config = b.addOptions();
- replay_app_config.addOption(AppKind, "app_kind", .replay);
- replay_app_config.addOption(bool, "syntax_highlighting", syntax_highlighting);
- replay_app_config.addOption(bool, "syntax_minimal_grammars", syntax_minimal_grammars);
- replay_app_config.addOption(bool, "syntax_full_grammars", syntax_full_grammars);
- replay.root_module.addOptions("app_config", replay_app_config);
- const replay_config = b.addOptions();
- replay_config.addOption(bool, "embedded_dump", replay_dump != null);
- replay.root_module.addOptions("replay_config", replay_config);
- if (replay_dump) |path| {
- replay.root_module.addAnonymousImport("replay.dump.zon", .{
- .root_source_file = if (std.fs.path.isAbsolute(path)) .{ .cwd_relative = path } else b.path(path),
- });
- }
-
- // ghostty-vt and vaxis both depend on uucode, but Zig 0.16 forbids the
- // same source file from belonging to two modules, so they can't each have
- // their own uucode instance. We build a single uucode module here (with our
- // own uucode_config.zig) and hand it to both.
- //
- // Our config matches ghostty's exactly except the tables are `.unpacked`;
- // see uucode_config.zig for why that's required on Zig 0.16.
- const uucode_config = b.path("uucode_config.zig");
- const uucode_tables = b.dependency("uucode", .{
- .target = target,
- .optimize = optimize,
- .build_config_path = uucode_config,
- }).namedLazyPath("tables.zig");
- const uucode_mod = b.dependency("uucode", .{
- .target = target,
- .optimize = optimize,
- .build_config_path = uucode_config,
- .tables_path = uucode_tables,
- }).module("uucode");
-
- // vaxis is built with `external_uucode = true` so it skips its built-in
- // uucode dependency and lets us wire one in.
- const vaxis_dep = b.dependency("vaxis", .{
- .target = target,
- .optimize = optimize,
- .external_uucode = true,
- });
- const vaxis_mod = vaxis_dep.module("vaxis");
-
- // You'll want to use a lazy dependency here so that ghostty is only
- // downloaded if you actually need it.
- var ghostty_vt_for_tests: ?*std.Build.Module = null;
- const ghostty_simd = switch (target.result.os.tag) {
- .emscripten, .openbsd => false,
- else => true,
- };
- if (b.lazyDependency("ghostty", .{
- // Setting simd to false will force a pure static build that
- // doesn't even require libc, but it has a significant performance
- // penalty. If your embedding app requires libc anyway, you should
- // always keep simd enabled.
- .simd = ghostty_simd,
- })) |dep| {
- // Accessing ghostty's modules runs its build(), which (because vaxis is
- // a shared dependency, deduplicated to the same module) wires ghostty's
- // own uucode into vaxis. We undo that below by overriding both imports
- // with ours — order matters, so this must happen after this point.
- const ghostty_vt = dep.module("ghostty-vt");
- ghostty_vt_for_tests = ghostty_vt;
- ghostty_vt.addImport("uucode", uucode_mod);
- if (is_emscripten) {
- const term_opts = b.addOptions();
- term_opts.addOption(GhosttyArtifact, "artifact", .lib);
- term_opts.addOption(bool, "c_abi", false);
- term_opts.addOption(bool, "oniguruma", false);
- term_opts.addOption(bool, "simd", false);
- term_opts.addOption(bool, "slow_runtime_safety", false);
- term_opts.addOption(bool, "kitty_graphics", false);
- term_opts.addOption(bool, "tmux_control_mode", false);
- term_opts.addOption([]const u8, "version_string", "0.0.0");
- term_opts.addOption(usize, "version_major", 0);
- term_opts.addOption(usize, "version_minor", 0);
- term_opts.addOption(usize, "version_patch", 0);
- term_opts.addOption(?[]const u8, "version_pre", null);
- term_opts.addOption(?[]const u8, "version_build", null);
- ghostty_vt.addOptions("terminal_options", term_opts);
- }
- exe.root_module.addImport("ghostty-vt", ghostty_vt);
- replay.root_module.addImport("ghostty-vt", ghostty_vt);
- // The e2e test harness parses the app's pty output with its own ghostty
- // terminal (reads the 2D screen grid, not raw bytes), so it needs the
- // same module.
- tests.root_module.addImport("ghostty-vt", ghostty_vt);
- }
-
- // Override uucode on vaxis last, so it wins over ghostty's build() above.
- vaxis_mod.addImport("uucode", uucode_mod);
- exe.root_module.addImport("vaxis", vaxis_mod);
+ // build-time IO, only used to slurp the grammars' highlights.scm queries
+ var threaded: std.Io.Threaded = .init(b.allocator, .{});
+ defer threaded.deinit();
+ const io = threaded.io();
- // The UI backend module (`-Dui`). main.zig + image.zig import `"ui"` instead of
- // `"vaxis"` directly; the chosen backend file re-exports the shared UI types
- // (Key/Mouse/Window/Cell/…) and provides the renderer/loop (`Vaxis`/`Tty`/
- // `Loop`/`init`). vaxis = transparent passthrough; sdl = SDL3 GPU frontend.
- const ui_root = if (sdl_backend) b.path("ui_sdl.zig") else b.path("ui_vaxis.zig");
- const ui_mod = b.createModule(.{
+ const root_mod = b.createModule(.{
.target = target,
.optimize = optimize,
- .root_source_file = ui_root,
+ .root_source_file = b.path("src/main.zig"),
+ .link_libc = true,
});
- const ui_sdl_config = b.addOptions();
- ui_sdl_config.addOption(f32, "web_render_scale", web_render_scale);
- ui_sdl_config.addOption(WebFilter, "web_filter", web_filter);
- ui_mod.addOptions("ui_sdl_config", ui_sdl_config);
- ui_mod.addImport("vaxis", vaxis_mod);
- exe.root_module.addImport("ui", ui_mod);
- replay.root_module.addImport("ui", ui_mod);
-
- // SDL3 backend wiring (`-Dsdl`, or Emscripten replay builds). The sdl package builds SDL3 from source
- // as a static lib + emits the SDL3/ headers; we @cImport those headers in
- // ui_sdl.zig, link the lib, and pull in stb_truetype (for the glyph atlas) and
- // the embedded Adwaita Mono font. All gated on the flag so a plain `zig build`
- // (vaxis) never fetches or compiles SDL.
- var sdl_lib_for_wasm: ?*std.Build.Step.Compile = null;
- if (sdl_backend) {
- const sdl_dep = if (is_emscripten and emscripten_include != null)
- b.lazyDependency("sdl", .{
- .target = target,
- .optimize = optimize,
- .system_include_path = emscripten_include.?,
- }) orelse return
- else
- b.lazyDependency("sdl", .{
- .target = target,
- .optimize = optimize,
- }) orelse return; // not fetched (offline?) — skip SDL wiring
- const sdl_lib = sdl_dep.artifact("SDL3");
- if (emscripten_libc_file) |libc_file| sdl_lib.setLibCFile(libc_file);
- if (is_emscripten) sdl_lib_for_wasm = sdl_lib;
- ui_mod.linkLibrary(sdl_lib);
- ui_mod.addIncludePath(sdl_dep.path("include"));
- if (emscripten_include) |include| ui_mod.addSystemIncludePath(include);
- ui_mod.link_libc = true;
+ // static archives the emcc link needs beyond the zig object (grammars, SDL)
+ var wasm_libs: std.ArrayList(std.Build.LazyPath) = .empty;
- // stb_truetype: the header (declarations, via include path) + the impl TU
- // + a small C shim (font.c) so ui_sdl.zig doesn't feed the whole
- // single-header lib to translate-c.
- ui_mod.addIncludePath(b.path("vendor/stb"));
- ui_mod.addCSourceFile(.{ .file = b.path("vendor/stb/stb_truetype_impl.c") });
- ui_mod.addCSourceFile(.{ .file = b.path("vendor/stb/font.c") });
+ const opts = b.addOptions();
+ opts.addOption(Platform, "platform", platform);
+ opts.addOption(bool, "syntax_highlighting", tree_sitter_grammars != .disabled);
+ opts.addOption(bool, "syntax_minimal_grammars", tree_sitter_grammars != .disabled);
+ opts.addOption(bool, "syntax_full_grammars", tree_sitter_grammars == .full);
+ root_mod.addOptions("pardes_config", opts);
- if (is_emscripten) {
- ui_mod.addAnonymousImport("ui.vert.es.glsl", .{ .root_source_file = b.path("shaders/ui.vert.es.glsl") });
- ui_mod.addAnonymousImport("ui.frag.es.glsl", .{ .root_source_file = b.path("shaders/ui.frag.es.glsl") });
- ui_mod.addAnonymousImport("overlay.vert.es.glsl", .{ .root_source_file = b.path("shaders/overlay.vert.es.glsl") });
- ui_mod.addAnonymousImport("overlay.frag.es.glsl", .{ .root_source_file = b.path("shaders/overlay.frag.es.glsl") });
- } else {
- // GLSL -> SPIR-V at build time, embedded into ui_sdl.zig via @embedFile.
- ui_mod.addAnonymousImport("ui.vert.spv", .{
- .root_source_file = compileGlsl(b, "shaders/ui.vert.glsl", "vertex", "ui.vert.spv"),
- });
- ui_mod.addAnonymousImport("ui.frag.spv", .{
- .root_source_file = compileGlsl(b, "shaders/ui.frag.glsl", "fragment", "ui.frag.spv"),
- });
- ui_mod.addAnonymousImport("overlay.vert.spv", .{
- .root_source_file = compileGlsl(b, "shaders/overlay.vert.glsl", "vertex", "overlay.vert.spv"),
- });
- ui_mod.addAnonymousImport("overlay.frag.spv", .{
- .root_source_file = compileGlsl(b, "shaders/overlay.frag.glsl", "fragment", "overlay.frag.spv"),
- });
- }
- }
-
- // zstbi: C (stb_image)-backed image decoder for the image-viewer panes. We use
- // it instead of zigimg because zigimg's pure-Zig decoder crashes the Zig 0.16
- // compiler here; stb_image is plain C (extern fns), so it compiles fine, decodes
- // every common format, and offers downscaling. Its module ("root") carries the
- // C source + include path + libc, so importing it is all that's needed.
- const zstbi_dep = b.dependency("zstbi", .{ .target = target, .optimize = optimize });
- const zstbi_mod = zstbi_dep.module("root");
- exe.root_module.addImport("zstbi", zstbi_mod);
- replay.root_module.addImport("zstbi", zstbi_mod);
-
- const replay_wasm_ready = is_emscripten and replay_dump != null and emscripten_sysroot != null;
- const emcc_for_wasm = if (replay_wasm_ready) blk: {
- const replay_obj = b.addObject(.{
- .name = "pardes-replay",
- .root_module = replay.root_module,
- });
- if (emscripten_libc_file) |libc_file| replay_obj.setLibCFile(libc_file);
-
- const emcc_path = b.option([]const u8, "emcc", "path to emcc for browser replay final link") orelse "/usr/lib/emscripten/emcc";
- const emcc = b.addSystemCommand(&.{emcc_path});
- emcc.addFileArg(replay_obj.getEmittedBinDirectory().path(b, "pardes-replay_zcu.o"));
- emcc.addFileArg(replay_obj.getEmittedBin());
- emcc.addFileArg(b.path("vendor/stb/stb_truetype_impl.c"));
- emcc.addFileArg(b.path("vendor/stb/font.c"));
- emcc.addFileArg(zstbi_dep.path("src/zstbi.c"));
- if (sdl_lib_for_wasm) |sdl_lib| emcc.addFileArg(sdl_lib.getEmittedBin());
- break :blk emcc;
- } else null;
-
- if (syntax_highlighting) {
- // tree-sitter syntax highlighting for file panes: the Zig bindings + C runtime
- // (the `tree-sitter` module), each grammar's parser.c linked as a static lib,
- // and its highlights.scm read at build time into the `ts_queries` options module.
- const tree_sitter = b.dependency("tree_sitter", .{ .target = target, .optimize = optimize });
- const tree_sitter_mod = tree_sitter.module("tree_sitter");
+ // tree-sitter: the zig bindings + C runtime, one static lib per grammar,
+ // and each grammar's highlights.scm slurped at build time into the
+ // ts_queries options module (codegen consumed by comptime in syntax.zig).
+ if (tree_sitter_grammars != .disabled) {
+ const tree_sitter_mod = b.dependency("tree_sitter", .{ .target = target, .optimize = optimize }).module("tree_sitter");
+ // the C runtime rides the module as a static lib; give it the wasm
+ // libc and hand its archive to the emcc link
if (is_emscripten) {
for (tree_sitter_mod.link_objects.items) |link_object| switch (link_object) {
.other_step => |lib| {
- if (emscripten_libc_file) |libc_file| lib.setLibCFile(libc_file);
- if (emcc_for_wasm) |emcc| emcc.addFileArg(lib.getEmittedBin());
+ lib.setLibCFile(libc_file.?);
+ wasm_libs.append(b.allocator, lib.getEmittedBin()) catch @panic("OOM");
},
else => {},
};
}
- exe.root_module.addImport("tree-sitter", tree_sitter_mod);
- replay.root_module.addImport("tree-sitter", tree_sitter_mod);
+ root_mod.addImport("tree-sitter", tree_sitter_mod);
const Grammar = struct {
name: []const u8,
@@ -322,7 +90,6 @@ pub fn build(b: *std.Build) void {
src: []const u8 = "src",
scanner: bool = false,
query: []const u8 = "queries/highlights.scm",
- local_query: ?[]const u8 = null,
minimal: bool = false,
};
const grammars = [_]Grammar{
@@ -357,7 +124,7 @@ pub fn build(b: *std.Build) void {
inline for (grammars) |g| {
if (tree_sitter_grammars == .full or g.minimal) {
const dep = b.dependency(g.dep, .{});
- const query_path = if (g.local_query) |p| b.path(p) else dep.path(g.query);
+ const query_path = dep.path(g.query);
const query = std.Io.Dir.cwd().readFileAlloc(io, query_path.getPath(b), b.allocator, .limited(0x100000)) catch @panic("read " ++ g.name ++ " highlights");
ts_queries.addOption([]const u8, g.name ++ "_highlights", query);
@@ -366,297 +133,238 @@ pub fn build(b: *std.Build) void {
.root_module = b.createModule(.{ .target = target, .optimize = optimize, .link_libc = true }),
.linkage = .static,
});
- if (emscripten_libc_file) |libc_file| lib.setLibCFile(libc_file);
+ if (libc_file) |f| lib.setLibCFile(f);
lib.root_module.addCSourceFile(.{ .file = dep.path(g.src ++ "/parser.c") });
if (g.scanner) lib.root_module.addCSourceFile(.{ .file = dep.path(g.src ++ "/scanner.c") });
lib.root_module.addIncludePath(dep.path(g.src));
- exe.root_module.linkLibrary(lib);
- replay.root_module.linkLibrary(lib);
- if (emcc_for_wasm) |emcc| emcc.addFileArg(lib.getEmittedBin());
+ root_mod.linkLibrary(lib);
+ if (is_emscripten) wasm_libs.append(b.allocator, lib.getEmittedBin()) catch @panic("OOM");
}
}
- exe.root_module.addOptions("ts_queries", ts_queries);
- replay.root_module.addOptions("ts_queries", ts_queries);
+ root_mod.addOptions("ts_queries", ts_queries);
}
- // Install the exe so `zig build` (and `zig build install`) produces it in
- // zig-out/bin for global install, not just as a hidden dep of `run`/`test`.
- b.installArtifact(exe);
- b.installArtifact(replay);
+ // zstbi (C stb_image): image pane decode. zigimg crashes the 0.16 compiler.
+ const zstbi_dep = b.dependency("zstbi", .{ .target = target, .optimize = optimize });
+ root_mod.addImport("zstbi", zstbi_dep.module("root"));
+
+ // ghostty-vt and vaxis both depend on uucode, but Zig forbids one source
+ // file in two modules — build a single uucode (our config, unpacked tables)
+ // and hand it to both. Same trick as the prototype; see uucode_config.zig.
+ const uucode_config = b.path("uucode_config.zig");
+ const uucode_tables = b.dependency("uucode", .{
+ .target = target,
+ .optimize = optimize,
+ .build_config_path = uucode_config,
+ }).namedLazyPath("tables.zig");
+ const uucode_mod = b.dependency("uucode", .{
+ .target = target,
+ .optimize = optimize,
+ .build_config_path = uucode_config,
+ .tables_path = uucode_tables,
+ }).module("uucode");
- const run_exe = b.addRunArtifact(exe);
+ const vaxis_mod = b.dependency("vaxis", .{
+ .target = target,
+ .optimize = optimize,
+ .external_uucode = true,
+ }).module("vaxis");
- const run = b.step("run", "descend to the pardes");
- run.dependOn(&run_exe.step);
+ var ghostty_vt_for_snap: ?*std.Build.Module = null;
+ if (b.lazyDependency("ghostty", .{ .simd = target.result.os.tag != .emscripten })) |dep| {
+ const ghostty_vt = dep.module("ghostty-vt");
+ ghostty_vt_for_snap = ghostty_vt;
+ ghostty_vt.addImport("uucode", uucode_mod);
+ // emscripten: hand ghostty-vt the terminal_options module its build()
+ // would otherwise provide (copied verbatim from the prototype build)
+ if (is_emscripten) {
+ const term_opts = b.addOptions();
+ term_opts.addOption(GhosttyArtifact, "artifact", .lib);
+ term_opts.addOption(bool, "c_abi", false);
+ term_opts.addOption(bool, "oniguruma", false);
+ term_opts.addOption(bool, "simd", false);
+ term_opts.addOption(bool, "slow_runtime_safety", false);
+ term_opts.addOption(bool, "kitty_graphics", false);
+ term_opts.addOption(bool, "tmux_control_mode", false);
+ term_opts.addOption([]const u8, "version_string", "0.0.0");
+ term_opts.addOption(usize, "version_major", 0);
+ term_opts.addOption(usize, "version_minor", 0);
+ term_opts.addOption(usize, "version_patch", 0);
+ term_opts.addOption(?[]const u8, "version_pre", null);
+ term_opts.addOption(?[]const u8, "version_build", null);
+ ghostty_vt.addOptions("terminal_options", term_opts);
+ }
+ root_mod.addImport("ghostty-vt", ghostty_vt);
+ }
+ // override uucode on vaxis LAST so it wins over ghostty's build() wiring.
+ vaxis_mod.addImport("uucode", uucode_mod);
+ root_mod.addImport("vaxis", vaxis_mod);
+ root_mod.addImport("uucode", uucode_mod);
- const replay_step = b.step("replay", if (is_emscripten)
- "build browser SDL replay (requires -Dreplay-dump=<dump.zon>)"
- else
- "replay a pardes dump: zig build replay -- <dump.zon>");
- const replay_wasm_step = b.step("replay-wasm", "compatibility alias for browser replay; use `zig build replay -Dtarget=wasm32-emscripten -Dreplay-dump=<dump.zon>`");
- if (!is_emscripten) {
- const run_replay = b.addRunArtifact(replay);
- if (b.args) |args| run_replay.addArgs(args);
- replay_step.dependOn(&run_replay.step);
- replay_wasm_step.dependOn(&b.addFail("replay-wasm requires -Dtarget=wasm32-emscripten; use `zig build replay` for native replay").step);
- } else if (replay_dump == null) {
- replay_step.dependOn(&b.addFail("replay requires -Dreplay-dump=<dump.zon> when targeting wasm32-emscripten").step);
- replay_wasm_step.dependOn(replay_step);
- } else if (emscripten_sysroot == null) {
- replay_step.dependOn(&b.addFail("replay requires an Emscripten sysroot; pass --sysroot <emscripten>/cache/sysroot or set EM_CACHE/EMSDK").step);
- replay_wasm_step.dependOn(replay_step);
- } else {
- const emcc = emcc_for_wasm.?;
+ // SDL3 shell wiring (-Dplatform=gui and web). The sdl package builds SDL3
+ // from source as a static lib + emits the SDL3/ headers; gui.zig @cImports
+ // those, plus a small stb_truetype C shim for the glyph atlas, and embeds
+ // the SPIR-V compiled at build time from shaders/*.glsl — or, on web, the
+ // GL ES shader sources compiled at runtime by WebGL.
+ if (platform == .gui or platform == .web) {
+ const sdl_dep = if (emscripten_include) |include|
+ b.lazyDependency("sdl", .{ .target = target, .optimize = optimize, .system_include_path = include })
+ else
+ b.lazyDependency("sdl", .{ .target = target, .optimize = optimize });
+ if (sdl_dep) |dep| {
+ const sdl_lib = dep.artifact("SDL3");
+ if (libc_file) |f| sdl_lib.setLibCFile(f);
+ root_mod.linkLibrary(sdl_lib);
+ root_mod.addIncludePath(dep.path("include"));
+ if (emscripten_include) |include| root_mod.addSystemIncludePath(include);
+ if (is_emscripten) wasm_libs.append(b.allocator, sdl_lib.getEmittedBin()) catch @panic("OOM");
+ }
+ root_mod.addIncludePath(b.path("vendor/stb"));
+ root_mod.addCSourceFile(.{ .file = b.path("vendor/stb/stb_truetype_impl.c") });
+ root_mod.addCSourceFile(.{ .file = b.path("vendor/stb/font.c") });
+ // the embedded UI font: provided as a build import since assets/ lives
+ // outside the src/ module root (@embedFile can't escape it)
+ root_mod.addAnonymousImport("AdwaitaMono-Regular.ttf", .{
+ .root_source_file = b.path("assets/AdwaitaMono-Regular.ttf"),
+ });
+ if (platform == .web) {
+ root_mod.addAnonymousImport("ui.vert.es.glsl", .{ .root_source_file = b.path("shaders/ui.vert.es.glsl") });
+ root_mod.addAnonymousImport("ui.frag.es.glsl", .{ .root_source_file = b.path("shaders/ui.frag.es.glsl") });
+ root_mod.addAnonymousImport("overlay.vert.es.glsl", .{ .root_source_file = b.path("shaders/overlay.vert.es.glsl") });
+ root_mod.addAnonymousImport("overlay.frag.es.glsl", .{ .root_source_file = b.path("shaders/overlay.frag.es.glsl") });
+ const dp = dump_path.?;
+ root_mod.addAnonymousImport("embedded.dump.zon", .{
+ .root_source_file = if (std.fs.path.isAbsolute(dp)) .{ .cwd_relative = dp } else b.path(dp),
+ });
+ } else {
+ root_mod.addAnonymousImport("ui.vert.spv", .{
+ .root_source_file = compileGlsl(b, "shaders/ui.vert.glsl", "vertex", "ui.vert.spv"),
+ });
+ root_mod.addAnonymousImport("ui.frag.spv", .{
+ .root_source_file = compileGlsl(b, "shaders/ui.frag.glsl", "fragment", "ui.frag.spv"),
+ });
+ root_mod.addAnonymousImport("overlay.vert.spv", .{
+ .root_source_file = compileGlsl(b, "shaders/overlay.vert.glsl", "vertex", "overlay.vert.spv"),
+ });
+ root_mod.addAnonymousImport("overlay.frag.spv", .{
+ .root_source_file = compileGlsl(b, "shaders/overlay.frag.glsl", "fragment", "overlay.frag.spv"),
+ });
+ }
+ }
+
+ if (platform == .web) {
+ // zig emits an object; emcc does the final link (wasm + js glue),
+ // compiling the C shims itself and pulling in every static archive.
+ const obj = b.addObject(.{ .name = "pardes", .root_module = root_mod });
+ obj.setLibCFile(libc_file.?);
+
+ const emcc = b.addSystemCommand(&.{emcc_path});
+ emcc.addFileArg(obj.getEmittedBinDirectory().path(b, "pardes_zcu.o"));
+ emcc.addFileArg(obj.getEmittedBin());
+ emcc.addFileArg(b.path("vendor/stb/stb_truetype_impl.c"));
+ emcc.addFileArg(b.path("vendor/stb/font.c"));
+ emcc.addFileArg(zstbi_dep.path("src/zstbi.c"));
+ for (wasm_libs.items) |lib| emcc.addFileArg(lib);
emcc.addPrefixedDirectoryArg("-I", b.path("vendor/stb"));
emcc.addPrefixedDirectoryArg("-I", zstbi_dep.path("libs/stbi"));
emcc.addArgs(&.{
"-std=c99",
"-fno-sanitize=undefined",
"-Oz",
- "-sINITIAL_MEMORY=2147483648",
- "-sEXIT_RUNTIME=0",
+ "-sINITIAL_MEMORY=1073741824", // the 512 MiB web heap + code/stack
+ "-sEXIT_RUNTIME=0", // run() returns; the rAF loop keeps living
"-sFULL_ES3=1",
"-sMIN_WEBGL_VERSION=2",
"-sMAX_WEBGL_VERSION=2",
"-sINCOMING_MODULE_JS_API=canvas",
"-o",
});
- const replay_js = emcc.addOutputFileArg("pardes-replay.js");
- replay_step.dependOn(&b.addInstallFileWithDir(replay_js, .{ .custom = "web" }, "pardes-replay.js").step);
- replay_step.dependOn(&b.addInstallFileWithDir(replay_js.dirname().path(b, "pardes-replay.wasm"), .{ .custom = "web" }, "pardes-replay.wasm").step);
-
- const replay_html = std.mem.concat(b.allocator, u8, &.{
- \\<!doctype html>
- \\<html>
- \\<head>
- \\ <meta charset="utf-8">
- \\ <meta name="viewport" content="width=device-width, initial-scale=1">
- \\ <title>pardes replay</title>
- \\ <style>
- \\ html, body { margin: 0; width: 100%; height: 100%; background: #121212; }
- \\ body { overflow: hidden; }
- \\ canvas { display: block; width: 100vw; height: 100vh; outline: none;
- \\ image-rendering: crisp-edges; image-rendering: pixelated;
- \\ -ms-interpolation-mode: nearest-neighbor;
- \\ transform: translateZ(0); backface-visibility: hidden; }
- \\ touch-action: none; }
- \\ </style>
- \\</head>
- \\<body>
- \\ <canvas id="canvas" tabindex="0"></canvas>
- \\ <script>
- \\ const canvas = document.getElementById('canvas');
- \\ canvas.addEventListener('contextmenu', (event) => event.preventDefault());
- \\ canvas.addEventListener('pointerdown', (event) => {
- \\ canvas.focus({ preventScroll: true });
- \\ canvas.setPointerCapture?.(event.pointerId);
- \\ });
- \\ const releasePointer = (event) => {
- \\ if (canvas.hasPointerCapture?.(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
- \\ };
- \\ canvas.addEventListener('pointerup', releasePointer);
- \\ canvas.addEventListener('pointercancel', releasePointer);
- \\ window.Module = { canvas };
- \\ </script>
- \\ <script src="pardes-replay.js"></script>
- \\</body>
- \\</html>
- }) catch @panic("failed to build replay.html");
- const web = b.addWriteFiles();
- const html = web.add("replay.html", replay_html);
- replay_step.dependOn(&b.addInstallFileWithDir(html, .{ .custom = "web" }, "replay.html").step);
- replay_wasm_step.dependOn(replay_step);
- }
-
- const test_browser_step = b.step("test-browser", "run browser SDL replay screenshot e2e (requires -Dtarget=wasm32-emscripten -Dreplay-dump=<dump.zon>)");
- if (!is_emscripten) {
- test_browser_step.dependOn(&b.addFail("test-browser requires -Dtarget=wasm32-emscripten").step);
- } else if (replay_dump == null) {
- test_browser_step.dependOn(&b.addFail("test-browser requires -Dreplay-dump=<dump.zon>").step);
+ const js = emcc.addOutputFileArg("pardes.js");
+ web_step.dependOn(&b.addInstallFileWithDir(js, .{ .custom = "web" }, "pardes.js").step);
+ web_step.dependOn(&b.addInstallFileWithDir(js.dirname().path(b, "pardes.wasm"), .{ .custom = "web" }, "pardes.wasm").step);
+ const html = b.addWriteFiles().add("index.html", html_shell);
+ web_step.dependOn(&b.addInstallFileWithDir(html, .{ .custom = "web" }, "index.html").step);
+ b.getInstallStep().dependOn(web_step);
} else {
- const run_browser_e2e = b.addSystemCommand(&.{ "node", "tests/browser_replay_e2e.mjs" });
- run_browser_e2e.addArg("zig-out/web");
- run_browser_e2e.addArg("zig-out/browser-replay.png");
- run_browser_e2e.addArg(b.fmt("{d}", .{web_render_scale}));
- run_browser_e2e.step.dependOn(replay_step);
- test_browser_step.dependOn(&run_browser_e2e.step);
- }
-
- // tutor_gen: a separate binary that parses tutor.txt and writes
- // tutor_cases.zig (a file of `test` blocks, one per practice block).
- // `zig build tutor-gen` runs it; the unit-test step depends on the output.
- const tutor_gen = b.addExecutable(.{
- .name = "tutor-gen",
- .linkage = exe_linkage,
- .root_module = b.createModule(.{
- .target = target,
- .optimize = optimize,
- .root_source_file = b.path("tutor_gen.zig"),
- }),
- });
- const run_tutor_gen = b.addRunArtifact(tutor_gen);
- run_tutor_gen.addArg("tutor.txt");
- const tutor_cases_lazy = run_tutor_gen.addOutputFileArg("tutor_cases.zig");
- const tutor_gen_step = b.step("tutor-gen", "regenerate tutor_cases.zig from tutor.txt");
- tutor_gen_step.dependOn(&run_tutor_gen.step);
-
- const run_tests = b.addRunArtifact(tests);
- run_tests.addArtifactArg(exe);
- run_tests.addArtifactArg(replay);
-
- // Expose build-time flags (e.g. -Dtrace) to the test runner as an importable
- // `config` module. tests.zig reads `config.trace` and hands it to the harness.
- const test_opts = b.addOptions();
- test_opts.addOption(bool, "trace", trace);
- tests.root_module.addOptions("config", test_opts);
-
- const test_step = b.step("test", "run deterministic tests");
- test_step.dependOn(&run_tests.step);
-
- // `test-sdl`: e2e tests for the SDL3 GPU backend. The SDL pardes exe runs in
- // test mode (PARDES_TEST=1): stdin drives app events through a pty, while the
- // real GPU renderer writes the latest framebuffer capture to a temp PPM file.
- // Requires `-Dsdl` so the SDL exe is the one under test.
- const sdl_tests = b.addExecutable(.{
- .name = "pardes-e2e-sdl",
- .linkage = exe_linkage,
- .root_module = b.createModule(.{
- .target = target,
- .optimize = optimize,
- .root_source_file = b.path("tests_sdl.zig"),
- .link_libc = true,
- }),
- });
- const run_sdl_tests = b.addRunArtifact(sdl_tests);
- run_sdl_tests.addArtifactArg(exe);
- const sdl_test_opts = b.addOptions();
- sdl_test_opts.addOption(bool, "trace", trace);
- sdl_tests.root_module.addOptions("config", sdl_test_opts);
- const test_sdl_step = b.step("test-sdl", "run SDL3 GPU e2e tests (requires -Dsdl)");
- test_sdl_step.dependOn(&run_sdl_tests.step);
-
- const tty_perf = b.addExecutable(.{
- .name = "pardes-tty-edit-perf",
- .linkage = exe_linkage,
- .root_module = b.createModule(.{
- .target = target,
- .optimize = optimize,
- .root_source_file = b.path("tests_tty_perf.zig"),
- .link_libc = true,
- }),
- });
- if (ghostty_vt_for_tests) |ghostty_vt| tty_perf.root_module.addImport("ghostty-vt", ghostty_vt);
- const run_tty_perf = b.addRunArtifact(tty_perf);
- run_tty_perf.addArtifactArg(exe);
- const tty_perf_step = b.step("perf-tty-edit", "measure text editing latency through the vaxis tty backend");
- if (sdl_backend) {
- tty_perf_step.dependOn(&b.addFail("perf-tty-edit measures the terminal backend; run without -Dsdl and without an Emscripten target").step);
- } else {
- tty_perf_step.dependOn(&run_tty_perf.step);
- }
+ const exe = b.addExecutable(.{
+ .name = "pardes",
+ .linkage = if (static) .static else null,
+ .root_module = root_mod,
+ });
+ b.installArtifact(exe);
+ const run = b.addRunArtifact(exe);
+ b.step("run", "descend to the pardes").dependOn(&run.step);
+ web_step.dependOn(&b.addFail("web needs -Dplatform=web -Dtarget=wasm32-emscripten -Ddump=<dump.zon>").step);
- const syntax_perf = b.addExecutable(.{
- .name = "pardes-syntax-perf",
- .linkage = exe_linkage,
- .root_module = b.createModule(.{
- .target = target,
- .optimize = optimize,
- .root_source_file = b.path("tests_syntax_perf.zig"),
- .link_libc = true,
- }),
- });
- if (ghostty_vt_for_tests) |ghostty_vt| syntax_perf.root_module.addImport("ghostty-vt", ghostty_vt);
- const run_syntax_perf = b.addRunArtifact(syntax_perf);
- run_syntax_perf.addArtifactArg(exe);
- const syntax_perf_step = b.step("perf-syntax", "measure highlighted file open/edit latency through the vaxis tty backend");
- if (sdl_backend) {
- syntax_perf_step.dependOn(&b.addFail("perf-syntax measures the terminal backend; run without -Dsdl and without an Emscripten target").step);
- } else if (!syntax_highlighting) {
- syntax_perf_step.dependOn(&b.addFail("perf-syntax requires tree-sitter; run with -Dtree-sitter=minimal or -Dtree-sitter=full").step);
- } else {
- syntax_perf_step.dependOn(&run_syntax_perf.step);
+ // snapshot parity suite: scripted input traces in a pty, captured grids
+ // diffed against the goldens in snapshots/ — the frozen record of parity
+ // with the prototype this program replaced. `-- --update` regenerates.
+ const snap = b.addExecutable(.{
+ .name = "pardes-snap",
+ .root_module = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("test/snapshot.zig"),
+ .link_libc = true,
+ }),
+ });
+ if (ghostty_vt_for_snap) |vt| snap.root_module.addImport("ghostty-vt", vt);
+ b.installArtifact(snap);
+ const run_snap = b.addRunArtifact(snap);
+ run_snap.addArtifactArg(exe);
+ if (b.args) |args| run_snap.addArgs(args);
+ b.step("snap", "run the snapshot parity suite (-- --update regenerates goldens)").dependOn(&run_snap.step);
}
+}
- // Unit tests for the pure modal-editing math (modal.zig has no vaxis/ghostty
- // deps, so `zig test` on it is fast and needs no wired-in modules).
- const unit_tests = b.addTest(.{
- .root_module = b.createModule(.{
- .target = target,
- .optimize = optimize,
- .root_source_file = b.path("modal.zig"),
- }),
- });
- unit_tests.linkage = exe_linkage;
- const run_unit = b.addRunArtifact(unit_tests);
- const unit_step = b.step("unit-test", "run modal.zig unit tests");
- unit_step.dependOn(&run_unit.step);
- test_step.dependOn(&run_unit.step);
-
- // petscii.zig is likewise pure (std only) — fold its matcher tests into both
- // the unit-test and the umbrella test step.
- const petscii_tests = b.addTest(.{
- .root_module = b.createModule(.{
- .target = target,
- .optimize = optimize,
- .root_source_file = b.path("petscii.zig"),
- }),
- });
- petscii_tests.linkage = exe_linkage;
- const run_petscii = b.addRunArtifact(petscii_tests);
- unit_step.dependOn(&run_petscii.step);
- test_step.dependOn(&run_petscii.step);
-
- const pane_ui_tests = b.addTest(.{
- .root_module = b.createModule(.{
- .target = target,
- .optimize = optimize,
- .root_source_file = b.path("pane_ui.zig"),
- }),
- });
- pane_ui_tests.linkage = exe_linkage;
- const run_pane_ui = b.addRunArtifact(pane_ui_tests);
- unit_step.dependOn(&run_pane_ui.step);
- test_step.dependOn(&run_pane_ui.step);
-
- const dump_tests = b.addTest(.{
- .root_module = b.createModule(.{
- .target = target,
- .optimize = optimize,
- .root_source_file = b.path("dump.zig"),
- }),
- });
- dump_tests.linkage = exe_linkage;
- const run_dump = b.addRunArtifact(dump_tests);
- unit_step.dependOn(&run_dump.step);
- test_step.dependOn(&run_dump.step);
+// the html shell: a full-viewport canvas wired into Module (from the prototype)
+const html_shell =
+ \\<!doctype html>
+ \\<html>
+ \\<head>
+ \\ <meta charset="utf-8">
+ \\ <meta name="viewport" content="width=device-width, initial-scale=1">
+ \\ <title>pardes</title>
+ \\ <style>
+ \\ html, body { margin: 0; width: 100%; height: 100%; background: #121212; }
+ \\ body { overflow: hidden; }
+ \\ canvas { display: block; width: 100vw; height: 100vh; outline: none;
+ \\ image-rendering: crisp-edges; image-rendering: pixelated;
+ \\ -ms-interpolation-mode: nearest-neighbor;
+ \\ transform: translateZ(0); backface-visibility: hidden;
+ \\ touch-action: none; }
+ \\ </style>
+ \\</head>
+ \\<body>
+ \\ <canvas id="canvas" tabindex="0"></canvas>
+ \\ <script>
+ \\ const canvas = document.getElementById('canvas');
+ \\ canvas.addEventListener('contextmenu', (event) => event.preventDefault());
+ \\ canvas.addEventListener('pointerdown', (event) => {
+ \\ canvas.focus({ preventScroll: true });
+ \\ canvas.setPointerCapture?.(event.pointerId);
+ \\ });
+ \\ const releasePointer = (event) => {
+ \\ if (canvas.hasPointerCapture?.(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
+ \\ };
+ \\ canvas.addEventListener('pointerup', releasePointer);
+ \\ canvas.addEventListener('pointercancel', releasePointer);
+ \\ window.Module = { canvas };
+ \\ </script>
+ \\ <script src="pardes.js"></script>
+ \\</body>
+ \\</html>
+;
- // Tutor practice blocks as unit tests: a second `zig test` target over the
- // generated tutor_cases.zig (which imports modal.zig). Depends on tutor-gen
- // so the file is (re)generated first.
- const tutor_tests = b.addTest(.{
- .root_module = b.createModule(.{
- .target = target,
- .optimize = optimize,
- .root_source_file = tutor_cases_lazy,
- }),
- });
- tutor_tests.linkage = exe_linkage;
- // The generated cases `@import("modal")` (a module name, not a relative
- // path, since the generated file lives in the cache dir, not next to modal.zig).
- const modal_for_tutor = b.createModule(.{
- .target = target,
- .optimize = optimize,
- .root_source_file = b.path("modal.zig"),
- });
- tutor_tests.root_module.addImport("modal", modal_for_tutor);
- const run_tutor = b.addRunArtifact(tutor_tests);
- const tutor_step = b.step("tutor-test", "run the tutor practice-block tests");
- tutor_step.dependOn(&run_tutor.step);
- test_step.dependOn(&run_tutor.step);
+// a bad -D combination: fail both `zig build` and `zig build web` with the why
+fn failBuild(b: *std.Build, web_step: *std.Build.Step, msg: []const u8) void {
+ const fail = &b.addFail(msg).step;
+ web_step.dependOn(fail);
+ b.getInstallStep().dependOn(fail);
}
-// glslc <src> -fshader-stage=<stage> -o <out> -> .spv, returned as a LazyPath to
-// @embedFile. Only used by the SDL3 GPU UI backend (`-Dui=sdl`).
+// glslc <src> -fshader-stage=<stage> -o <out> -> .spv, returned as a LazyPath
+// for @embedFile. Only used by the SDL3 GPU shell (-Dplatform=gui).
fn compileGlsl(b: *std.Build, src: []const u8, stage: []const u8, out: []const u8) std.Build.LazyPath {
const cmd = b.addSystemCommand(&.{ "glslc", b.fmt("-fshader-stage={s}", .{stage}) });
cmd.addFileArg(b.path(src));
@@ -668,38 +376,12 @@ fn detectEmscriptenSysroot(b: *std.Build) ?[]const u8 {
if (b.option([]const u8, "emscripten-sysroot", "path to the Emscripten sysroot")) |path| return path;
if (b.sysroot) |path| return path;
if (envPath(b, "EMSCRIPTEN_SYSROOT")) |path| return path;
- if (envPath(b, "EM_CACHE")) |cache| {
- return b.pathJoin(&.{ cache, "sysroot" });
- }
- if (envPath(b, "EMSDK")) |emsdk| {
- return b.pathJoin(&.{ emsdk, "upstream", "emscripten", "cache", "sysroot" });
- }
- if (envPath(b, "HOME")) |home| {
- return b.pathJoin(&.{ home, ".cache", "emscripten", "sysroot" });
- }
+ if (envPath(b, "EM_CACHE")) |cache| return b.pathJoin(&.{ cache, "sysroot" });
+ if (envPath(b, "EMSDK")) |emsdk| return b.pathJoin(&.{ emsdk, "upstream", "emscripten", "cache", "sysroot" });
+ if (envPath(b, "HOME")) |home| return b.pathJoin(&.{ home, ".cache", "emscripten", "sysroot" });
return null;
}
-fn targetBinaryName(b: *std.Build, base: []const u8, target: std.Target) []const u8 {
- return switch (target.os.tag) {
- .linux, .emscripten => base,
- .macos, .freebsd, .openbsd, .netbsd, .dragonfly => b.fmt("{s}-{s}-{s}", .{
- base,
- @tagName(target.os.tag),
- archName(target.cpu.arch),
- }),
- else => base,
- };
-}
-
-fn archName(arch: std.Target.Cpu.Arch) []const u8 {
- return switch (arch) {
- .x86_64 => "x64",
- .aarch64 => "arm64",
- else => @tagName(arch),
- };
-}
-
fn envPath(b: *std.Build, name: []const u8) ?[]const u8 {
return b.graph.environ_map.get(name);
}
diff --git a/build.zig.zon b/build.zig.zon
index 3a29c77d..121542c3 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -1,5 +1,5 @@
.{
- .name = ._02_pardes_code,
+ .name = .pardes,
.version = "0.0.1",
.dependencies = .{
// Pinned to the exact libvaxis commit ghostty is built against, so it
@@ -145,5 +145,5 @@
},
.minimum_zig_version = "0.16.0",
.paths = .{""},
- .fingerprint = 0x7d072ef9f9841c0d,
+ .fingerprint = 0x41dc46101fc764d8,
}
diff --git a/dbg.txt b/dbg.txt
deleted file mode 100644
index 076a07cc..00000000
--- a/dbg.txt
+++ /dev/null
@@ -1,25 +0,0 @@
-hello!
-error: FileNotFound
-/usr/lib/zig/std/Io/Threaded.zig:1434:9: 0x11809a3 in fail__anon_24261 (std.zig)
- return err;
- ^
-/usr/lib/zig/std/Io/Threaded.zig:5292:23: 0x11a5a7a in dirOpenDirPosix (std.zig)
- .NOENT => return syscall.fail(error.FileNotFound),
- ^
-/usr/lib/zig/std/Io/Dir.zig:482:5: 0x11ac29a in openDir (std.zig)
- return io.vtable.dirOpenDir(io.userdata, dir, sub_path, options);
- ^
-/home/goblin/00-projects/02-pardes-code/main.zig:47:17: 0x1208335 in main (main.zig)
- const cwd = try std.Io.Dir.openDir(dir, io, "", .{ .iterate = true });
- ^
-run
-+- run exe pardes failure
-error: process exited with error code 1
-failed command: ./.zig-cache/o/605d89fea3f4d2723007022f57b9e214/pardes
-
-Build Summary: 4/6 steps succeeded (1 failed)
-run transitive failure
-+- run exe pardes failure
-
-error: the following build command failed with exit code 1:
-.zig-cache/o/bc886f30ac2e1c5536b489917da9cbab/build /usr/bin/zig /usr/lib/zig /home/goblin/00-projects/02-pardes-code .zig-cache /home/goblin/.cache/zig --seed 0x488ca716 -Z27dadd76006b2149 run
diff --git a/docs/design.pdf b/docs/design.pdf
new file mode 100644
index 00000000..0022972b
--- /dev/null
+++ b/docs/design.pdf
@@ -0,0 +1,1380 @@
+%PDF-1.7
+%����
+
+1 0 obj
+<</Type/Pages/Count 4/Kids[245 0 R 246 0 R 247 0 R 248 0 R]>>
+endobj
+2 0 obj
+<</Parent 11 0 R/Next 3 0 R/Title(What it is)/Dest 217 0 R>>
+endobj
+3 0 obj
+<</Parent 11 0 R/Next 4 0 R/Prev 2 0 R/Title(The shape)/Dest 218 0 R>>
+endobj
+4 0 obj
+<</Parent 11 0 R/Next 5 0 R/Prev 3 0 R/Title(Data structures)/Dest 219 0 R>>
+endobj
+5 0 obj
+<</Parent 11 0 R/Next 6 0 R/Prev 4 0 R/Title(Style)/Dest 220 0 R>>
+endobj
+6 0 obj
+<</Parent 11 0 R/Next 7 0 R/Prev 5 0 R/Title(Testing: the old program is the oracle)/Dest 221 0 R>>
+endobj
+7 0 obj
+<</Parent 11 0 R/Next 8 0 R/Prev 6 0 R/Title(Build)/Dest 222 0 R>>
+endobj
+8 0 obj
+<</Parent 11 0 R/Next 9 0 R/Prev 7 0 R/Title(What is deliberately absent)/Dest 223 0 R>>
+endobj
+9 0 obj
+<</Parent 11 0 R/Next 10 0 R/Prev 8 0 R/Title(Appendix A: feature parity checklist)/Dest 224 0 R>>
+endobj
+10 0 obj
+<</Parent 11 0 R/Prev 9 0 R/Title(middle click, pinch, touch debug overlay, PARDES_TEST PPM capture and)/Dest 225 0 R>>
+endobj
+11 0 obj
+<</Type/Outlines/First 2 0 R/Last 10 0 R/Count 9>>
+endobj
+12 0 obj
+<</Type/StructTreeRoot/RoleMap<</Datetime/Span/Terms/Part/Title/P/Strong/Span/Em/Span>>/K[214 0 R]/ParentTree<</Nums[0 13 0 R 1 14 0 R 2 15 0 R 3 16 0 R]>>/ParentTreeNextKey 4>>
+endobj
+13 0 obj
+[17 0 R 19 0 R 20 0 R 21 0 R 21 0 R 21 0 R 21 0 R 22 0 R 22 0 R 22 0 R 23 0 R 25 0 R 24 0 R 25 0 R 25 0 R 25 0 R 26 0 R 30 0 R 28 0 R 30 0 R 29 0 R 30 0 R 32 0 R 34 0 R 34 0 R 34 0 R 40 0 R 37 0 R 40 0 R 40 0 R 40 0 R 38 0 R 40 0 R 40 0 R 40 0 R 39 0 R 40 0 R 41 0 R 42 0 R 50 0 R 43 0 R 50 0 R 44 0 R 50 0 R 45 0 R 50 0 R 46 0 R 50 0 R 47 0 R 50 0 R 48 0 R 50 0 R 49 0 R 50 0 R 50 0 R 50 0 R 51 0 R 53 0 R 53 0 R 52 0 R 53 0 R 53 0 R 53 0 R 53 0 R 54 0 R 64 0 R 55 0 R 64 0 R 56 0 R 64 0 R 57 0 R 64 0 R 58 0 R 64 0 R 59 0 R 64 0 R 60 0 R 64 0 R 61 0 R 64 0 R 62 0 R 64 0 R 64 0 R 63 0 R 64 0 R 64 0 R 66 0 R 65 0 R 66 0 R 67 0 R 67 0 R 67 0 R 67 0 R 67 0 R 67 0 R 67 0 R 67 0 R 67 0 R 67 0 R 67 0 R 67 0 R 67 0 R 67 0 R 67 0 R 67 0 R 67 0 R 67 0 R 67 0 R 67 0 R 67 0 R 67 0 R 68 0 R 69 0 R 69 0 R 69 0 R 69 0 R 69 0 R 69 0 R 69 0 R 69 0 R 69 0 R 69 0 R 69 0 R 69 0 R 69 0 R 69 0 R 69 0 R 69 0 R 69 0 R 70 0 R 70 0 R 70 0 R 70 0 R 70 0 R 70 0 R 70 0 R 70 0 R 70 0 R 70 0 R 70 0 R 70 0 R 70 0 R 70 0 R 70 0 R]
+endobj
+14 0 obj
+[76 0 R 72 0 R 76 0 R 73 0 R 76 0 R 76 0 R 74 0 R 76 0 R 75 0 R 76 0 R 76 0 R 77 0 R 78 0 R 79 0 R 80 0 R 80 0 R 80 0 R 80 0 R 80 0 R 80 0 R 80 0 R 81 0 R 81 0 R 81 0 R 81 0 R 81 0 R 81 0 R 81 0 R 82 0 R 82 0 R 82 0 R 82 0 R 82 0 R 83 0 R 83 0 R 83 0 R 83 0 R 83 0 R 83 0 R 83 0 R 84 0 R 84 0 R 84 0 R 84 0 R 84 0 R 84 0 R 84 0 R 85 0 R 85 0 R 85 0 R 85 0 R 85 0 R 86 0 R 86 0 R 86 0 R 86 0 R 86 0 R 86 0 R 86 0 R 87 0 R 87 0 R 87 0 R 87 0 R 87 0 R 87 0 R 87 0 R 87 0 R 89 0 R 90 0 R 90 0 R 90 0 R 90 0 R 90 0 R 90 0 R 90 0 R 91 0 R 91 0 R 91 0 R 91 0 R 91 0 R 91 0 R 91 0 R 91 0 R 91 0 R 91 0 R 91 0 R 91 0 R 91 0 R 91 0 R 91 0 R 91 0 R 91 0 R 91 0 R 91 0 R 91 0 R 91 0 R 91 0 R 91 0 R 91 0 R 91 0 R 92 0 R 92 0 R 92 0 R 92 0 R 92 0 R 93 0 R 93 0 R 93 0 R 93 0 R 93 0 R 93 0 R 93 0 R 93 0 R 93 0 R 93 0 R 93 0 R 93 0 R 93 0 R 93 0 R 93 0 R 93 0 R 93 0 R 93 0 R 93 0 R 94 0 R 94 0 R 94 0 R 94 0 R 94 0 R 94 0 R 94 0 R 94 0 R 94 0 R 94 0 R 94 0 R 95 0 R 95 0 R 95 0 R 95 0 R 95 0 R 95 0 R 95 0 R 95 0 R 95 0 R 95 0 R 95 0 R 95 0 R 95 0 R 95 0 R 95 0 R 95 0 R 95 0 R 95 0 R 95 0 R 96 0 R 96 0 R 96 0 R 96 0 R 96 0 R 96 0 R 96 0 R 97 0 R 97 0 R 97 0 R 97 0 R 97 0 R 97 0 R 97 0 R 97 0 R 97 0 R 99 0 R 99 0 R 99 0 R 99 0 R 99 0 R 99 0 R 99 0 R 99 0 R 99 0 R 99 0 R 99 0 R 99 0 R 99 0 R 99 0 R 99 0 R 99 0 R 99 0 R 99 0 R 99 0 R 99 0 R 99 0 R 99 0 R 99 0 R 99 0 R 99 0 R 99 0 R 99 0 R 100 0 R 100 0 R 100 0 R 100 0 R 100 0 R 100 0 R 100 0 R 100 0 R 100 0 R 100 0 R 100 0 R 100 0 R 100 0 R 100 0 R 100 0 R 100 0 R 100 0 R 100 0 R 100 0 R 100 0 R 100 0 R 100 0 R 100 0 R 100 0 R 100 0 R 100 0 R 100 0 R 100 0 R 100 0 R 101 0 R 101 0 R 101 0 R 101 0 R 101 0 R 101 0 R 101 0 R 101 0 R 101 0 R 101 0 R 101 0 R 101 0 R 101 0 R 101 0 R 101 0 R 105 0 R 103 0 R 105 0 R 104 0 R 105 0 R 105 0 R 105 0 R 106 0 R 109 0 R 107 0 R 109 0 R 109 0 R 108 0 R 108 0 R 109 0 R 109 0 R 110 0 R 112 0 R 116 0 R 117 0 R 118 0 R 120 0 R 121 0 R 122 0 R 124 0 R 125 0 R 126 0 R 128 0 R 129 0 R 130 0 R 132 0 R 134 0 R 136 0 R 136 0 R 138 0 R 139 0 R 140 0 R]
+endobj
+15 0 obj
+[143 0 R 152 0 R 144 0 R 152 0 R 145 0 R 152 0 R 152 0 R 146 0 R 152 0 R 152 0 R 152 0 R 147 0 R 152 0 R 152 0 R 148 0 R 152 0 R 152 0 R 152 0 R 149 0 R 152 0 R 150 0 R 152 0 R 151 0 R 152 0 R 154 0 R 153 0 R 154 0 R 154 0 R 154 0 R 154 0 R 154 0 R 155 0 R 155 0 R 156 0 R 157 0 R 164 0 R 158 0 R 164 0 R 159 0 R 164 0 R 160 0 R 164 0 R 164 0 R 164 0 R 161 0 R 164 0 R 162 0 R 164 0 R 164 0 R 163 0 R 164 0 R 164 0 R 164 0 R 165 0 R 169 0 R 166 0 R 169 0 R 169 0 R 169 0 R 169 0 R 167 0 R 169 0 R 168 0 R 169 0 R 170 0 R 172 0 R 171 0 R 171 0 R 172 0 R 172 0 R 173 0 R 178 0 R 178 0 R 178 0 R 178 0 R 178 0 R 178 0 R 174 0 R 178 0 R 178 0 R 178 0 R 175 0 R 178 0 R 176 0 R 178 0 R 178 0 R 177 0 R 178 0 R 179 0 R 184 0 R 184 0 R 184 0 R 180 0 R 184 0 R 184 0 R 181 0 R 184 0 R 182 0 R 184 0 R 184 0 R 183 0 R 184 0 R 184 0 R]
+endobj
+16 0 obj
+[184 0 R 184 0 R 185 0 R 195 0 R 186 0 R 195 0 R 187 0 R 195 0 R 188 0 R 195 0 R 195 0 R 189 0 R 195 0 R 190 0 R 195 0 R 191 0 R 195 0 R 192 0 R 195 0 R 195 0 R 195 0 R 193 0 R 195 0 R 195 0 R 194 0 R 195 0 R 195 0 R 196 0 R 201 0 R 197 0 R 201 0 R 198 0 R 201 0 R 199 0 R 201 0 R 200 0 R 200 0 R 200 0 R 201 0 R 202 0 R 204 0 R 204 0 R 203 0 R 204 0 R 204 0 R 204 0 R 204 0 R 204 0 R 205 0 R 206 0 R 206 0 R 207 0 R 208 0 R 208 0 R 208 0 R 208 0 R 210 0 R 209 0 R 210 0 R 210 0 R 210 0 R 211 0 R 213 0 R 212 0 R 213 0 R 213 0 R]
+endobj
+17 0 obj
+<</Type/StructElem/S/Strong/P 18 0 R/K[0]/Pg 245 0 R>>
+endobj
+18 0 obj
+<</Type/StructElem/S/P/P 214 0 R/K[17 0 R]>>
+endobj
+19 0 obj
+<</Type/StructElem/S/P/P 214 0 R/K[1]/Pg 245 0 R>>
+endobj
+20 0 obj
+<</Type/StructElem/S/H1/P 214 0 R/T(What it is)/K[2]/Pg 245 0 R>>
+endobj
+21 0 obj
+<</Type/StructElem/S/P/P 214 0 R/K[3 4 5 6]/Pg 245 0 R>>
+endobj
+22 0 obj
+<</Type/StructElem/S/P/P 214 0 R/K[7 8 9]/Pg 245 0 R>>
+endobj
+23 0 obj
+<</Type/StructElem/S/H1/P 214 0 R/T(The shape)/K[10]/Pg 245 0 R>>
+endobj
+24 0 obj
+<</Type/StructElem/S/Strong/P 25 0 R/K[12]/Pg 245 0 R>>
+endobj
+25 0 obj
+<</Type/StructElem/S/P/P 214 0 R/K[11 24 0 R 13 14 15]/Pg 245 0 R>>
+endobj
+26 0 obj
+<</Type/StructElem/S/Strong/P 27 0 R/K[16]/Pg 245 0 R>>
+endobj
+27 0 obj
+<</Type/StructElem/S/TD/P 31 0 R/A[<</O/Table/Headers[]>><</O/Layout/BorderStyle/Solid>>]/K[26 0 R]>>
+endobj
+28 0 obj
+<</Type/StructElem/S/Code/P 30 0 R/K[18]/Pg 245 0 R>>
+endobj
+29 0 obj
+<</Type/StructElem/S/Code/P 30 0 R/K[20]/Pg 245 0 R>>
+endobj
+30 0 obj
+<</Type/StructElem/S/TD/P 31 0 R/A[<</O/Table/Headers[]>><</O/Layout/BorderStyle/Solid>>]/K[17 28 0 R 19 29 0 R 21]/Pg 245 0 R>>
+endobj
+31 0 obj
+<</Type/StructElem/S/TR/P 36 0 R/K[27 0 R 30 0 R]>>
+endobj
+32 0 obj
+<</Type/StructElem/S/Strong/P 33 0 R/K[22]/Pg 245 0 R>>
+endobj
+33 0 obj
+<</Type/StructElem/S/TD/P 35 0 R/A[<</O/Table/Headers[]>><</O/Layout/BorderStyle/Solid>>]/K[32 0 R]>>
+endobj
+34 0 obj
+<</Type/StructElem/S/TD/P 35 0 R/A[<</O/Table/Headers[]>><</O/Layout/BorderStyle/Solid>>]/K[23 24 25]/Pg 245 0 R>>
+endobj
+35 0 obj
+<</Type/StructElem/S/TR/P 36 0 R/K[33 0 R 34 0 R]>>
+endobj
+36 0 obj
+<</Type/StructElem/S/Table/P 214 0 R/A[<</O/Layout/BorderColor[0 0 0]/BorderThickness 0.4>>]/K[31 0 R 35 0 R]>>
+endobj
+37 0 obj
+<</Type/StructElem/S/Strong/P 40 0 R/K[27]/Pg 245 0 R>>
+endobj
+38 0 obj
+<</Type/StructElem/S/Code/P 40 0 R/K[31]/Pg 245 0 R>>
+endobj
+39 0 obj
+<</Type/StructElem/S/Code/P 40 0 R/K[35]/Pg 245 0 R>>
+endobj
+40 0 obj
+<</Type/StructElem/S/P/P 214 0 R/K[26 37 0 R 28 29 30 38 0 R 32 33 34 39 0 R 36]/Pg 245 0 R>>
+endobj
+41 0 obj
+<</Type/StructElem/S/P/P 214 0 R/K[37]/Pg 245 0 R>>
+endobj
+42 0 obj
+<</Type/StructElem/S/Strong/P 50 0 R/K[38]/Pg 245 0 R>>
+endobj
+43 0 obj
+<</Type/StructElem/S/Code/P 50 0 R/K[40]/Pg 245 0 R>>
+endobj
+44 0 obj
+<</Type/StructElem/S/Code/P 50 0 R/K[42]/Pg 245 0 R>>
+endobj
+45 0 obj
+<</Type/StructElem/S/Code/P 50 0 R/K[44]/Pg 245 0 R>>
+endobj
+46 0 obj
+<</Type/StructElem/S/Code/P 50 0 R/K[46]/Pg 245 0 R>>
+endobj
+47 0 obj
+<</Type/StructElem/S/Code/P 50 0 R/K[48]/Pg 245 0 R>>
+endobj
+48 0 obj
+<</Type/StructElem/S/Code/P 50 0 R/K[50]/Pg 245 0 R>>
+endobj
+49 0 obj
+<</Type/StructElem/S/Code/P 50 0 R/K[52]/Pg 245 0 R>>
+endobj
+50 0 obj
+<</Type/StructElem/S/P/P 214 0 R/K[42 0 R 39 43 0 R 41 44 0 R 43 45 0 R 45 46 0 R 47 47 0 R 49 48 0 R 51 49 0 R 53 54 55]/Pg 245 0 R>>
+endobj
+51 0 obj
+<</Type/StructElem/S/Strong/P 53 0 R/K[56]/Pg 245 0 R>>
+endobj
+52 0 obj
+<</Type/StructElem/S/Strong/P 53 0 R/K[59]/Pg 245 0 R>>
+endobj
+53 0 obj
+<</Type/StructElem/S/P/P 214 0 R/K[51 0 R 57 58 52 0 R 60 61 62 63]/Pg 245 0 R>>
+endobj
+54 0 obj
+<</Type/StructElem/S/Strong/P 64 0 R/K[64]/Pg 245 0 R>>
+endobj
+55 0 obj
+<</Type/StructElem/S/Code/P 64 0 R/K[66]/Pg 245 0 R>>
+endobj
+56 0 obj
+<</Type/StructElem/S/Code/P 64 0 R/K[68]/Pg 245 0 R>>
+endobj
+57 0 obj
+<</Type/StructElem/S/Code/P 64 0 R/K[70]/Pg 245 0 R>>
+endobj
+58 0 obj
+<</Type/StructElem/S/Code/P 64 0 R/K[72]/Pg 245 0 R>>
+endobj
+59 0 obj
+<</Type/StructElem/S/Code/P 64 0 R/K[74]/Pg 245 0 R>>
+endobj
+60 0 obj
+<</Type/StructElem/S/Code/P 64 0 R/K[76]/Pg 245 0 R>>
+endobj
+61 0 obj
+<</Type/StructElem/S/Code/P 64 0 R/K[78]/Pg 245 0 R>>
+endobj
+62 0 obj
+<</Type/StructElem/S/Code/P 64 0 R/K[80]/Pg 245 0 R>>
+endobj
+63 0 obj
+<</Type/StructElem/S/Code/P 64 0 R/K[83]/Pg 245 0 R>>
+endobj
+64 0 obj
+<</Type/StructElem/S/P/P 214 0 R/K[54 0 R 65 55 0 R 67 56 0 R 69 57 0 R 71 58 0 R 73 59 0 R 75 60 0 R 77 61 0 R 79 62 0 R 81 82 63 0 R 84 85]/Pg 245 0 R>>
+endobj
+65 0 obj
+<</Type/StructElem/S/Code/P 66 0 R/K[87]/Pg 245 0 R>>
+endobj
+66 0 obj
+<</Type/StructElem/S/P/P 214 0 R/K[86 65 0 R 88]/Pg 245 0 R>>
+endobj
+67 0 obj
+<</Type/StructElem/S/P/P 71 0 R/K[89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110]/Pg 245 0 R>>
+endobj
+68 0 obj
+<</Type/StructElem/S/P/P 71 0 R/K[111]/Pg 245 0 R>>
+endobj
+69 0 obj
+<</Type/StructElem/S/P/P 71 0 R/K[112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128]/Pg 245 0 R>>
+endobj
+70 0 obj
+<</Type/StructElem/S/P/P 71 0 R/K[129 130 131 132 133 134 135 136 137 138 139 140 141 142 143]/Pg 245 0 R>>
+endobj
+71 0 obj
+<</Type/StructElem/S/Code/P 214 0 R/A[<</O/Layout/Placement/Block>>]/K[67 0 R 68 0 R 69 0 R 70 0 R]>>
+endobj
+72 0 obj
+<</Type/StructElem/S/Code/P 76 0 R/K[1]/Pg 246 0 R>>
+endobj
+73 0 obj
+<</Type/StructElem/S/Strong/P 76 0 R/K[3]/Pg 246 0 R>>
+endobj
+74 0 obj
+<</Type/StructElem/S/Code/P 76 0 R/K[6]/Pg 246 0 R>>
+endobj
+75 0 obj
+<</Type/StructElem/S/Code/P 76 0 R/K[8]/Pg 246 0 R>>
+endobj
+76 0 obj
+<</Type/StructElem/S/P/P 214 0 R/K[0 72 0 R 2 73 0 R 4 5 74 0 R 7 75 0 R 9 10]/Pg 246 0 R>>
+endobj
+77 0 obj
+<</Type/StructElem/S/H1/P 214 0 R/T(Data structures)/K[11]/Pg 246 0 R>>
+endobj
+78 0 obj
+<</Type/StructElem/S/P/P 214 0 R/K[12]/Pg 246 0 R>>
+endobj
+79 0 obj
+<</Type/StructElem/S/P/P 102 0 R/K[13]/Pg 246 0 R>>
+endobj
+80 0 obj
+<</Type/StructElem/S/P/P 102 0 R/K[14 15 16 17 18 19 20]/Pg 246 0 R>>
+endobj
+81 0 obj
+<</Type/StructElem/S/P/P 102 0 R/K[21 22 23 24 25 26 27]/Pg 246 0 R>>
+endobj
+82 0 obj
+<</Type/StructElem/S/P/P 102 0 R/K[28 29 30 31 32]/Pg 246 0 R>>
+endobj
+83 0 obj
+<</Type/StructElem/S/P/P 102 0 R/K[33 34 35 36 37 38 39]/Pg 246 0 R>>
+endobj
+84 0 obj
+<</Type/StructElem/S/P/P 102 0 R/K[40 41 42 43 44 45 46]/Pg 246 0 R>>
+endobj
+85 0 obj
+<</Type/StructElem/S/P/P 102 0 R/K[47 48 49 50 51]/Pg 246 0 R>>
+endobj
+86 0 obj
+<</Type/StructElem/S/P/P 102 0 R/K[52 53 54 55 56 57 58]/Pg 246 0 R>>
+endobj
+87 0 obj
+<</Type/StructElem/S/P/P 102 0 R/K[59 60 61 62 63 64 65 66]/Pg 246 0 R>>
+endobj
+88 0 obj
+<</Type/StructElem/S/P/P 102 0 R/K[]>>
+endobj
+89 0 obj
+<</Type/StructElem/S/P/P 102 0 R/K[67]/Pg 246 0 R>>
+endobj
+90 0 obj
+<</Type/StructElem/S/P/P 102 0 R/K[68 69 70 71 72 73 74]/Pg 246 0 R>>
+endobj
+91 0 obj
+<</Type/StructElem/S/P/P 102 0 R/K[75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99]/Pg 246 0 R>>
+endobj
+92 0 obj
+<</Type/StructElem/S/P/P 102 0 R/K[100 101 102 103 104]/Pg 246 0 R>>
+endobj
+93 0 obj
+<</Type/StructElem/S/P/P 102 0 R/K[105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123]/Pg 246 0 R>>
+endobj
+94 0 obj
+<</Type/StructElem/S/P/P 102 0 R/K[124 125 126 127 128 129 130 131 132 133 134]/Pg 246 0 R>>
+endobj
+95 0 obj
+<</Type/StructElem/S/P/P 102 0 R/K[135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153]/Pg 246 0 R>>
+endobj
+96 0 obj
+<</Type/StructElem/S/P/P 102 0 R/K[154 155 156 157 158 159 160]/Pg 246 0 R>>
+endobj
+97 0 obj
+<</Type/StructElem/S/P/P 102 0 R/K[161 162 163 164 165 166 167 168 169]/Pg 246 0 R>>
+endobj
+98 0 obj
+<</Type/StructElem/S/P/P 102 0 R/K[]>>
+endobj
+99 0 obj
+<</Type/StructElem/S/P/P 102 0 R/K[170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196]/Pg 246 0 R>>
+endobj
+100 0 obj
+<</Type/StructElem/S/P/P 102 0 R/K[197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225]/Pg 246 0 R>>
+endobj
+101 0 obj
+<</Type/StructElem/S/P/P 102 0 R/K[226 227 228 229 230 231 232 233 234 235 236 237 238 239 240]/Pg 246 0 R>>
+endobj
+102 0 obj
+<</Type/StructElem/S/Code/P 214 0 R/A[<</O/Layout/Placement/Block>>]/K[79 0 R 80 0 R 81 0 R 82 0 R 83 0 R 84 0 R 85 0 R 86 0 R 87 0 R 88 0 R 89 0 R 90 0 R 91 0 R 92 0 R 93 0 R 94 0 R 95 0 R 96 0 R 97 0 R 98 0 R 99 0 R 100 0 R 101 0 R]>>
+endobj
+103 0 obj
+<</Type/StructElem/S/Code/P 105 0 R/K[242]/Pg 246 0 R>>
+endobj
+104 0 obj
+<</Type/StructElem/S/Code/P 105 0 R/K[244]/Pg 246 0 R>>
+endobj
+105 0 obj
+<</Type/StructElem/S/P/P 214 0 R/K[241 103 0 R 243 104 0 R 245 246 247]/Pg 246 0 R>>
+endobj
+106 0 obj
+<</Type/StructElem/S/H1/P 214 0 R/T(Style)/K[248]/Pg 246 0 R>>
+endobj
+107 0 obj
+<</Type/StructElem/S/Code/P 109 0 R/K[250]/Pg 246 0 R>>
+endobj
+108 0 obj
+<</Type/StructElem/S/Code/P 109 0 R/K[253 254]/Pg 246 0 R>>
+endobj
+109 0 obj
+<</Type/StructElem/S/P/P 214 0 R/K[249 107 0 R 251 252 108 0 R 255 256]/Pg 246 0 R>>
+endobj
+110 0 obj
+<</Type/StructElem/S/Strong/P 111 0 R/K[257]/Pg 246 0 R>>
+endobj
+111 0 obj
+<</Type/StructElem/S/TD/P 115 0 R/A[<</O/Table/Headers[]>><</O/Layout/BorderStyle/Solid>>]/K[110 0 R]>>
+endobj
+112 0 obj
+<</Type/StructElem/S/Strong/P 113 0 R/K[258]/Pg 246 0 R>>
+endobj
+113 0 obj
+<</Type/StructElem/S/TD/P 115 0 R/A[<</O/Table/Headers[]>><</O/Layout/BorderStyle/Solid>>]/K[112 0 R]>>
+endobj
+114 0 obj
+<</Type/StructElem/S/TD/P 115 0 R/A[<</O/Table/Headers[]>><</O/Layout/BorderStyle/Solid>>]/K[]>>
+endobj
+115 0 obj
+<</Type/StructElem/S/TR/P 142 0 R/K[111 0 R 113 0 R 114 0 R]>>
+endobj
+116 0 obj
+<</Type/StructElem/S/TD/P 119 0 R/A[<</O/Table/Headers[]>><</O/Layout/BorderStyle/Solid>>]/K[259]/Pg 246 0 R>>
+endobj
+117 0 obj
+<</Type/StructElem/S/TD/P 119 0 R/A[<</O/Table/Headers[]>><</O/Layout/BorderStyle/Solid>>]/K[260]/Pg 246 0 R>>
+endobj
+118 0 obj
+<</Type/StructElem/S/TD/P 119 0 R/A[<</O/Table/Headers[]>><</O/Layout/BorderStyle/Solid>>]/K[261]/Pg 246 0 R>>
+endobj
+119 0 obj
+<</Type/StructElem/S/TR/P 142 0 R/K[116 0 R 117 0 R 118 0 R]>>
+endobj
+120 0 obj
+<</Type/StructElem/S/TD/P 123 0 R/A[<</O/Table/Headers[]>><</O/Layout/BorderStyle/Solid>>]/K[262]/Pg 246 0 R>>
+endobj
+121 0 obj
+<</Type/StructElem/S/TD/P 123 0 R/A[<</O/Table/Headers[]>><</O/Layout/BorderStyle/Solid>>]/K[263]/Pg 246 0 R>>
+endobj
+122 0 obj
+<</Type/StructElem/S/TD/P 123 0 R/A[<</O/Table/Headers[]>><</O/Layout/BorderStyle/Solid>>]/K[264]/Pg 246 0 R>>
+endobj
+123 0 obj
+<</Type/StructElem/S/TR/P 142 0 R/K[120 0 R 121 0 R 122 0 R]>>
+endobj
+124 0 obj
+<</Type/StructElem/S/TD/P 127 0 R/A[<</O/Table/Headers[]>><</O/Layout/BorderStyle/Solid>>]/K[265]/Pg 246 0 R>>
+endobj
+125 0 obj
+<</Type/StructElem/S/TD/P 127 0 R/A[<</O/Table/Headers[]>><</O/Layout/BorderStyle/Solid>>]/K[266]/Pg 246 0 R>>
+endobj
+126 0 obj
+<</Type/StructElem/S/TD/P 127 0 R/A[<</O/Table/Headers[]>><</O/Layout/BorderStyle/Solid>>]/K[267]/Pg 246 0 R>>
+endobj
+127 0 obj
+<</Type/StructElem/S/TR/P 142 0 R/K[124 0 R 125 0 R 126 0 R]>>
+endobj
+128 0 obj
+<</Type/StructElem/S/TD/P 131 0 R/A[<</O/Table/Headers[]>><</O/Layout/BorderStyle/Solid>>]/K[268]/Pg 246 0 R>>
+endobj
+129 0 obj
+<</Type/StructElem/S/TD/P 131 0 R/A[<</O/Table/Headers[]>><</O/Layout/BorderStyle/Solid>>]/K[269]/Pg 246 0 R>>
+endobj
+130 0 obj
+<</Type/StructElem/S/TD/P 131 0 R/A[<</O/Table/Headers[]>><</O/Layout/BorderStyle/Solid>>]/K[270]/Pg 246 0 R>>
+endobj
+131 0 obj
+<</Type/StructElem/S/TR/P 142 0 R/K[128 0 R 129 0 R 130 0 R]>>
+endobj
+132 0 obj
+<</Type/StructElem/S/Strong/P 133 0 R/K[271]/Pg 246 0 R>>
+endobj
+133 0 obj
+<</Type/StructElem/S/TD/P 137 0 R/A[<</O/Table/Headers[]>><</O/Layout/BorderStyle/Solid>>]/K[132 0 R]>>
+endobj
+134 0 obj
+<</Type/StructElem/S/Strong/P 135 0 R/K[272]/Pg 246 0 R>>
+endobj
+135 0 obj
+<</Type/StructElem/S/TD/P 137 0 R/A[<</O/Table/Headers[]>><</O/Layout/BorderStyle/Solid>>]/K[134 0 R]>>
+endobj
+136 0 obj
+<</Type/StructElem/S/TD/P 137 0 R/A[<</O/Table/Headers[]>><</O/Layout/BorderStyle/Solid>>]/K[273 274]/Pg 246 0 R>>
+endobj
+137 0 obj
+<</Type/StructElem/S/TR/P 142 0 R/K[133 0 R 135 0 R 136 0 R]>>
+endobj
+138 0 obj
+<</Type/StructElem/S/TD/P 141 0 R/A[<</O/Table/Headers[]>><</O/Layout/BorderStyle/Solid>>]/K[275]/Pg 246 0 R>>
+endobj
+139 0 obj
+<</Type/StructElem/S/TD/P 141 0 R/A[<</O/Table/Headers[]>><</O/Layout/BorderStyle/Solid>>]/K[276]/Pg 246 0 R>>
+endobj
+140 0 obj
+<</Type/StructElem/S/TD/P 141 0 R/A[<</O/Table/Headers[]>><</O/Layout/BorderStyle/Solid>>]/K[277]/Pg 246 0 R>>
+endobj
+141 0 obj
+<</Type/StructElem/S/TR/P 142 0 R/K[138 0 R 139 0 R 140 0 R]>>
+endobj
+142 0 obj
+<</Type/StructElem/S/Table/P 214 0 R/A[<</O/Layout/BorderColor[0 0 0]/BorderThickness 0.4>>]/K[115 0 R 119 0 R 123 0 R 127 0 R 131 0 R 137 0 R 141 0 R]>>
+endobj
+143 0 obj
+<</Type/StructElem/S/H1/P 214 0 R/T(Testing: the old program is the oracle)/K[0]/Pg 247 0 R>>
+endobj
+144 0 obj
+<</Type/StructElem/S/Code/P 152 0 R/K[2]/Pg 247 0 R>>
+endobj
+145 0 obj
+<</Type/StructElem/S/Code/P 152 0 R/K[4]/Pg 247 0 R>>
+endobj
+146 0 obj
+<</Type/StructElem/S/Strong/P 152 0 R/K[7]/Pg 247 0 R>>
+endobj
+147 0 obj
+<</Type/StructElem/S/Code/P 152 0 R/K[11]/Pg 247 0 R>>
+endobj
+148 0 obj
+<</Type/StructElem/S/Code/P 152 0 R/K[14]/Pg 247 0 R>>
+endobj
+149 0 obj
+<</Type/StructElem/S/Code/P 152 0 R/K[18]/Pg 247 0 R>>
+endobj
+150 0 obj
+<</Type/StructElem/S/Code/P 152 0 R/K[20]/Pg 247 0 R>>
+endobj
+151 0 obj
+<</Type/StructElem/S/Code/P 152 0 R/K[22]/Pg 247 0 R>>
+endobj
+152 0 obj
+<</Type/StructElem/S/P/P 214 0 R/K[1 144 0 R 3 145 0 R 5 6 146 0 R 8 9 10 147 0 R 12 13 148 0 R 15 16 17 149 0 R 19 150 0 R 21 151 0 R 23]/Pg 247 0 R>>
+endobj
+153 0 obj
+<</Type/StructElem/S/Code/P 154 0 R/K[25]/Pg 247 0 R>>
+endobj
+154 0 obj
+<</Type/StructElem/S/P/P 214 0 R/K[24 153 0 R 26 27 28 29 30]/Pg 247 0 R>>
+endobj
+155 0 obj
+<</Type/StructElem/S/P/P 214 0 R/K[31 32]/Pg 247 0 R>>
+endobj
+156 0 obj
+<</Type/StructElem/S/H1/P 214 0 R/T(Build)/K[33]/Pg 247 0 R>>
+endobj
+157 0 obj
+<</Type/StructElem/S/Code/P 164 0 R/K[34]/Pg 247 0 R>>
+endobj
+158 0 obj
+<</Type/StructElem/S/Code/P 164 0 R/K[36]/Pg 247 0 R>>
+endobj
+159 0 obj
+<</Type/StructElem/S/Code/P 164 0 R/K[38]/Pg 247 0 R>>
+endobj
+160 0 obj
+<</Type/StructElem/S/Code/P 164 0 R/K[40]/Pg 247 0 R>>
+endobj
+161 0 obj
+<</Type/StructElem/S/Code/P 164 0 R/K[44]/Pg 247 0 R>>
+endobj
+162 0 obj
+<</Type/StructElem/S/Code/P 164 0 R/K[46]/Pg 247 0 R>>
+endobj
+163 0 obj
+<</Type/StructElem/S/Code/P 164 0 R/K[49]/Pg 247 0 R>>
+endobj
+164 0 obj
+<</Type/StructElem/S/P/P 214 0 R/K[157 0 R 35 158 0 R 37 159 0 R 39 160 0 R 41 42 43 161 0 R 45 162 0 R 47 48 163 0 R 50 51 52]/Pg 247 0 R>>
+endobj
+165 0 obj
+<</Type/StructElem/S/H1/P 214 0 R/T(What is deliberately absent)/K[53]/Pg 247 0 R>>
+endobj
+166 0 obj
+<</Type/StructElem/S/Strong/P 169 0 R/K[55]/Pg 247 0 R>>
+endobj
+167 0 obj
+<</Type/StructElem/S/Code/P 169 0 R/K[60]/Pg 247 0 R>>
+endobj
+168 0 obj
+<</Type/StructElem/S/Code/P 169 0 R/K[62]/Pg 247 0 R>>
+endobj
+169 0 obj
+<</Type/StructElem/S/P/P 214 0 R/K[54 166 0 R 56 57 58 59 167 0 R 61 168 0 R 63]/Pg 247 0 R>>
+endobj
+170 0 obj
+<</Type/StructElem/S/H1/P 214 0 R/T(Appendix A: feature parity checklist)/K[64]/Pg 247 0 R>>
+endobj
+171 0 obj
+<</Type/StructElem/S/Code/P 172 0 R/K[66 67]/Pg 247 0 R>>
+endobj
+172 0 obj
+<</Type/StructElem/S/P/P 214 0 R/K[65 171 0 R 68 69]/Pg 247 0 R>>
+endobj
+173 0 obj
+<</Type/StructElem/S/Strong/P 178 0 R/K[70]/Pg 247 0 R>>
+endobj
+174 0 obj
+<</Type/StructElem/S/Code/P 178 0 R/K[77]/Pg 247 0 R>>
+endobj
+175 0 obj
+<</Type/StructElem/S/Code/P 178 0 R/K[81]/Pg 247 0 R>>
+endobj
+176 0 obj
+<</Type/StructElem/S/Code/P 178 0 R/K[83]/Pg 247 0 R>>
+endobj
+177 0 obj
+<</Type/StructElem/S/Code/P 178 0 R/K[86]/Pg 247 0 R>>
+endobj
+178 0 obj
+<</Type/StructElem/S/P/P 214 0 R/K[173 0 R 71 72 73 74 75 76 174 0 R 78 79 80 175 0 R 82 176 0 R 84 85 177 0 R 87]/Pg 247 0 R>>
+endobj
+179 0 obj
+<</Type/StructElem/S/Strong/P 184 0 R/K[88]/Pg 247 0 R>>
+endobj
+180 0 obj
+<</Type/StructElem/S/Code/P 184 0 R/K[92]/Pg 247 0 R>>
+endobj
+181 0 obj
+<</Type/StructElem/S/Code/P 184 0 R/K[95]/Pg 247 0 R>>
+endobj
+182 0 obj
+<</Type/StructElem/S/Code/P 184 0 R/K[97]/Pg 247 0 R>>
+endobj
+183 0 obj
+<</Type/StructElem/S/Code/P 184 0 R/K[100]/Pg 247 0 R>>
+endobj
+184 0 obj
+<</Type/StructElem/S/P/P 214 0 R/K[179 0 R 89 90 91 180 0 R 93 94 181 0 R 96 182 0 R 98 99 183 0 R 101 102<</Type/MCR/MCID 0/Pg 248 0 R>><</Type/MCR/MCID 1/Pg 248 0 R>>]/Pg 247 0 R>>
+endobj
+185 0 obj
+<</Type/StructElem/S/Strong/P 195 0 R/K[2]/Pg 248 0 R>>
+endobj
+186 0 obj
+<</Type/StructElem/S/Code/P 195 0 R/K[4]/Pg 248 0 R>>
+endobj
+187 0 obj
+<</Type/StructElem/S/Code/P 195 0 R/K[6]/Pg 248 0 R>>
+endobj
+188 0 obj
+<</Type/StructElem/S/Code/P 195 0 R/K[8]/Pg 248 0 R>>
+endobj
+189 0 obj
+<</Type/StructElem/S/Code/P 195 0 R/K[11]/Pg 248 0 R>>
+endobj
+190 0 obj
+<</Type/StructElem/S/Code/P 195 0 R/K[13]/Pg 248 0 R>>
+endobj
+191 0 obj
+<</Type/StructElem/S/Code/P 195 0 R/K[15]/Pg 248 0 R>>
+endobj
+192 0 obj
+<</Type/StructElem/S/Code/P 195 0 R/K[17]/Pg 248 0 R>>
+endobj
+193 0 obj
+<</Type/StructElem/S/Code/P 195 0 R/K[21]/Pg 248 0 R>>
+endobj
+194 0 obj
+<</Type/StructElem/S/Code/P 195 0 R/K[24]/Pg 248 0 R>>
+endobj
+195 0 obj
+<</Type/StructElem/S/P/P 214 0 R/K[185 0 R 3 186 0 R 5 187 0 R 7 188 0 R 9 10 189 0 R 12 190 0 R 14 191 0 R 16 192 0 R 18 19 20 193 0 R 22 23 194 0 R 25 26]/Pg 248 0 R>>
+endobj
+196 0 obj
+<</Type/StructElem/S/Strong/P 201 0 R/K[27]/Pg 248 0 R>>
+endobj
+197 0 obj
+<</Type/StructElem/S/Code/P 201 0 R/K[29]/Pg 248 0 R>>
+endobj
+198 0 obj
+<</Type/StructElem/S/Code/P 201 0 R/K[31]/Pg 248 0 R>>
+endobj
+199 0 obj
+<</Type/StructElem/S/Code/P 201 0 R/K[33]/Pg 248 0 R>>
+endobj
+200 0 obj
+<</Type/StructElem/S/Code/P 201 0 R/K[35 36 37]/Pg 248 0 R>>
+endobj
+201 0 obj
+<</Type/StructElem/S/P/P 214 0 R/K[196 0 R 28 197 0 R 30 198 0 R 32 199 0 R 34 200 0 R 38]/Pg 248 0 R>>
+endobj
+202 0 obj
+<</Type/StructElem/S/Strong/P 204 0 R/K[39]/Pg 248 0 R>>
+endobj
+203 0 obj
+<</Type/StructElem/S/Code/P 204 0 R/K[42]/Pg 248 0 R>>
+endobj
+204 0 obj
+<</Type/StructElem/S/P/P 214 0 R/K[202 0 R 40 41 203 0 R 43 44 45 46 47]/Pg 248 0 R>>
+endobj
+205 0 obj
+<</Type/StructElem/S/Strong/P 206 0 R/K[48]/Pg 248 0 R>>
+endobj
+206 0 obj
+<</Type/StructElem/S/P/P 214 0 R/K[205 0 R 49 50]/Pg 248 0 R>>
+endobj
+207 0 obj
+<</Type/StructElem/S/Strong/P 208 0 R/K[51]/Pg 248 0 R>>
+endobj
+208 0 obj
+<</Type/StructElem/S/P/P 214 0 R/K[207 0 R 52 53 54 55]/Pg 248 0 R>>
+endobj
+209 0 obj
+<</Type/StructElem/S/Code/P 210 0 R/K[57]/Pg 248 0 R>>
+endobj
+210 0 obj
+<</Type/StructElem/S/H1/P 214 0 R/T(middle click, pinch, touch debug overlay, PARDES_TEST PPM capture and)/K[56 209 0 R 58 59 60]/Pg 248 0 R>>
+endobj
+211 0 obj
+<</Type/StructElem/S/Code/P 213 0 R/K[61]/Pg 248 0 R>>
+endobj
+212 0 obj
+<</Type/StructElem/S/Strong/P 213 0 R/K[63]/Pg 248 0 R>>
+endobj
+213 0 obj
+<</Type/StructElem/S/P/P 214 0 R/K[211 0 R 62 212 0 R 64 65]/Pg 248 0 R>>
+endobj
+214 0 obj
+<</Type/StructElem/S/Document/P 12 0 R/K[18 0 R 19 0 R 20 0 R 21 0 R 22 0 R 23 0 R 25 0 R 36 0 R 40 0 R 41 0 R 50 0 R 53 0 R 64 0 R 66 0 R 71 0 R 76 0 R 77 0 R 78 0 R 102 0 R 105 0 R 106 0 R 109 0 R 142 0 R 143 0 R 152 0 R 154 0 R 155 0 R 156 0 R 164 0 R 165 0 R 169 0 R 170 0 R 172 0 R 178 0 R 184 0 R 195 0 R 201 0 R 204 0 R 206 0 R 208 0 R 210 0 R 213 0 R]>>
+endobj
+215 0 obj
+[/ICCBased 268 0 R]
+endobj
+216 0 obj
+[/ICCBased 269 0 R]
+endobj
+217 0 obj
+[245 0 R/XYZ 62.362206 729.37256 0]
+endobj
+218 0 obj
+[245 0 R/XYZ 62.362206 590.0229 0]
+endobj
+219 0 obj
+[246 0 R/XYZ 62.362206 733.7516 0]
+endobj
+220 0 obj
+[246 0 R/XYZ 62.362206 342.88815 0]
+endobj
+221 0 obj
+[247 0 R/XYZ 62.362206 789.5276 0]
+endobj
+222 0 obj
+[247 0 R/XYZ 62.362206 546.3959 0]
+endobj
+223 0 obj
+[247 0 R/XYZ 62.362206 426.81766 0]
+endobj
+224 0 obj
+[247 0 R/XYZ 62.362206 335.20096 0]
+endobj
+225 0 obj
+[248 0 R/XYZ 62.362206 411.61353 0]
+endobj
+226 0 obj
+<</ProcSet[/PDF/Text/ImageC/ImageB]/ColorSpace<</c0 215 0 R/c1 216 0 R>>/Font<</f0 230 0 R/f1 233 0 R/f2 236 0 R>>>>
+endobj
+227 0 obj
+<</ProcSet[/PDF/Text/ImageC/ImageB]/ColorSpace<</c0 215 0 R/c1 216 0 R>>/Font<</f0 233 0 R/f1 236 0 R/f2 230 0 R>>>>
+endobj
+228 0 obj
+<</ProcSet[/PDF/Text/ImageC/ImageB]/ColorSpace<</c0 215 0 R>>/Font<</f0 230 0 R/f1 233 0 R/f2 236 0 R/f3 239 0 R>>>>
+endobj
+229 0 obj
+<</ProcSet[/PDF/Text/ImageC/ImageB]/ColorSpace<</c0 215 0 R>>/Font<</f0 233 0 R/f1 230 0 R/f2 236 0 R/f3 242 0 R>>>>
+endobj
+230 0 obj
+<</Type/Font/Subtype/Type0/BaseFont/PNVZNM+NewCM10-Bold-Identity-H/Encoding/Identity-H/DescendantFonts[231 0 R]/ToUnicode 250 0 R>>
+endobj
+231 0 obj
+<</Type/Font/Subtype/CIDFontType0/BaseFont/PNVZNM+NewCM10-Bold/CIDSystemInfo<</Registry(Adobe)/Ordering(Identity)/Supplement 0>>/FontDescriptor 232 0 R/DW 0/W[0 0 280 1 1 786 2 2 559 3 3 474 4 4 639 5 5 527 6 6 454 7 7 319 8 8 383 9 9 869 10 10 800 11 11 607 12 12 447 13 13 756 14 14 639 15 15 607 16 16 319 17 17 575 18 18 958 19 19 1189 20 21 639 22 22 319 23 23 639 24 24 607 25 25 510.99997 26 26 831 27 28 639 29 29 351 30 30 671 31 31 607 32 32 882 33 33 575 34 34 319 35 37 575 38 38 818 39 39 692 40 40 319 41 41 1092 42 42 831 43 44 447 45 45 383]>>
+endobj
+232 0 obj
+<</Type/FontDescriptor/FontName/PNVZNM+NewCM10-Bold/Flags 131076/FontBBox[13 -249 1164 750]/ItalicAngle 0/Ascent 806/Descent -194/CapHeight 686/StemV 168.6/CIDSet 249 0 R/FontFile3 251 0 R>>
+endobj
+233 0 obj
+<</Type/Font/Subtype/Type0/BaseFont/IZZBXO+NewCM10-Regular-Identity-H/Encoding/Identity-H/DescendantFonts[234 0 R]/ToUnicode 253 0 R>>
+endobj
+234 0 obj
+<</Type/Font/Subtype/CIDFontType0/BaseFont/IZZBXO+NewCM10-Regular/CIDSystemInfo<</Registry(Adobe)/Ordering(Identity)/Supplement 0>>/FontDescriptor 235 0 R/DW 0/W[0 0 500 1 1 556 2 2 444 3 3 394 4 4 278 5 5 500 6 6 556 7 7 333 8 8 306 9 9 500 10 10 392 11 11 389 12 12 556 13 13 722 14 14 1000 15 17 500 18 18 333 19 20 500 21 21 681 22 22 500 23 24 528 25 25 833 26 26 444 27 28 278 29 30 556 31 31 278 32 32 556 33 33 528 34 34 278 35 35 556 36 36 278 37 37 722 38 38 686 39 39 528 40 40 681 41 41 528 42 42 361 43 44 389 45 45 556 46 46 764 47 47 625 48 48 500 49 49 778 50 50 444 51 52 750 53 53 778 54 54 278 55 55 583 56 56 500 57 57 750 58 58 500 59 59 750 60 60 306 61 61 736 62 62 785 63 63 708 64 64 917 65 65 556 66 66 500 67 67 611 68 69 500 70 70 778 71 71 750 72 72 722 73 73 1000 74 74 653 75 75 1028 76 76 514]>>
+endobj
+235 0 obj
+<</Type/FontDescriptor/FontName/IZZBXO+NewCM10-Regular/Flags 131076/FontBBox[-41 -260 1009 760]/ItalicAngle 0/Ascent 806/Descent -194/CapHeight 683/StemV 95.4/CIDSet 252 0 R/FontFile3 254 0 R>>
+endobj
+236 0 obj
+<</Type/Font/Subtype/Type0/BaseFont/EFHJNG+DejaVuSansMono/Encoding/Identity-H/DescendantFonts[237 0 R]/ToUnicode 256 0 R>>
+endobj
+237 0 obj
+<</Type/Font/Subtype/CIDFontType2/BaseFont/EFHJNG+DejaVuSansMono/CIDSystemInfo<</Registry(Adobe)/Ordering(Identity)/Supplement 0>>/FontDescriptor 238 0 R/DW 0/CIDToGIDMap/Identity/W[0 78 602.0508]>>
+endobj
+238 0 obj
+<</Type/FontDescriptor/FontName/EFHJNG+DejaVuSansMono/Flags 131077/FontBBox[0 -235.83984 602.0508 844.2383]/ItalicAngle 0/Ascent 759.7656/Descent -240.23438/CapHeight 759.7656/StemV 95.4/CIDSet 255 0 R/FontFile2 257 0 R>>
+endobj
+239 0 obj
+<</Type/Font/Subtype/Type0/BaseFont/HCGNIB+LibertinusSerif-Regular-Identity-H/Encoding/Identity-H/DescendantFonts[240 0 R]/ToUnicode 259 0 R>>
+endobj
+240 0 obj
+<</Type/Font/Subtype/CIDFontType0/BaseFont/HCGNIB+LibertinusSerif-Regular/CIDSystemInfo<</Registry(Adobe)/Ordering(Identity)/Supplement 0>>/FontDescriptor 241 0 R/DW 0/W[0 0 500 1 1 527]>>
+endobj
+241 0 obj
+<</Type/FontDescriptor/FontName/HCGNIB+LibertinusSerif-Regular/Flags 131078/FontBBox[0 0 500 698]/ItalicAngle 0/Ascent 894/Descent -246/CapHeight 658/StemV 95.4/CIDSet 258 0 R/FontFile3 260 0 R>>
+endobj
+242 0 obj
+<</Type/Font/Subtype/Type0/BaseFont/EFDVLV+DejaVuSansMono-Bold/Encoding/Identity-H/DescendantFonts[243 0 R]/ToUnicode 262 0 R>>
+endobj
+243 0 obj
+<</Type/Font/Subtype/CIDFontType2/BaseFont/EFDVLV+DejaVuSansMono-Bold/CIDSystemInfo<</Registry(Adobe)/Ordering(Identity)/Supplement 0>>/FontDescriptor 244 0 R/DW 0/CIDToGIDMap/Identity/W[0 8 602.0508]>>
+endobj
+244 0 obj
+<</Type/FontDescriptor/FontName/EFDVLV+DejaVuSansMono-Bold/Flags 131077/FontBBox[0 -235.83984 602.0508 742.1875]/ItalicAngle 0/Ascent 759.7656/Descent -240.23438/CapHeight 759.7656/StemV 168.6/CIDSet 261 0 R/FontFile2 263 0 R>>
+endobj
+245 0 obj
+<</Type/Page/Resources 226 0 R/MediaBox[0 0 595.2756 841.8898]/StructParents 0/Parent 1 0 R/Contents 264 0 R>>
+endobj
+246 0 obj
+<</Type/Page/Resources 227 0 R/MediaBox[0 0 595.2756 841.8898]/StructParents 1/Parent 1 0 R/Contents 265 0 R>>
+endobj
+247 0 obj
+<</Type/Page/Resources 228 0 R/MediaBox[0 0 595.2756 841.8898]/StructParents 2/Parent 1 0 R/Contents 266 0 R>>
+endobj
+248 0 obj
+<</Type/Page/Resources 229 0 R/MediaBox[0 0 595.2756 841.8898]/StructParents 3/Parent 1 0 R/Contents 267 0 R>>
+endobj
+249 0 obj
+<</Length 14/Filter/FlateDecode>>
+stream
+x�������
+endstream
+endobj
+250 0 obj
+<</Length 554/Type/CMap/Filter/FlateDecode/WMode 0>>
+stream
+x�m�ˎ�0E�|E�)��
+�!*W��2�����*��跒=D#P)0*tQ�Q�7
+��=�r�C:����(Ǯc3ثh��t�+��;�6�4�>�gp��L�=� ��B�
+endstream
+endobj
+251 0 obj
+<</Length 4378/Filter/FlateDecode/Subtype/CIDFontType0C>>
+stream
+x��XiX׶������F�lL�T�ApBp�I�FE�!jb4N4
+
+
+AA�A�Vh�f7͌�L��eTJ�c4�!��M��$�!�LO�]�{��8�\s��ޟ��9}�޻�Zg�V0}�0
+��q~h��yc|�|�օ���A�+8��
+�/�x�t�];�sȕ�v���}�a�N�g�� �9 �} pp�},t�(
+NU3-$jU������ؤaӇ�����X��q��(&j]������n���c���#��cܢCׅDŽ���E��F�ņ����`�"7���X���C#cBݼ���bBC��bc7L����[;:*z�������uO�x�~�����E^sgO����otlb�ۚ�h������u1�ƎQ0����O��H~�
+����P U#��3.���S��q+����V��p �Ԙ�Qn�y�/냧h�yA�a�yۀ���/� ����-D�_��%�*v�- D^#H#�S����?,28�䘅����0vJQV�cd(Ӂ�����r͉�/�`��ع�e��tb���y���y�j �
+ 58�C���}�e�Q�D%n��4�a�AAaaAA�a�����"m��1MUa6H�!)�?w�F𴠛e�թ� Q�=-=�Ps�Ϭa�1m
+�x���N�!#�x �����+���c,���� �����a ��9��� I�"a:�4I�m��5�h�}�S�l��7�����Kǝ����Y@%����K����A�[��#*=H�%)�o\�<��h�.lqIn޷@@�6�[@ǖ�ӎ��0m�N^��|�D������>t.@=��ĵ
+[� @Ϸ��u��UU��D$�C�r]��P'S�w��>h�G���7.%��M(���@�$
+X}��
+�)g���=��b(�R(�V��2�[��w� ���~F�7�CU����"͓=y����{�Wzz�UYX�� ��ߘ �`�U�d����J�Wʍ���ޝ�u���P�
+�w���I�;�I���4�2T� s[�#�q���C-���-�v�U��" *��b��!��� ������]K��EX�lc`�=oK(���WD�ԙa��,z�,����w'������$��?/��$)\�����o� 3��P�k-D��_O�Ԟ��O^��Ć� B`�Y���<���G��4���`s7eo� {!�0wA_y1��,~at�|��-5g�X*�*a$��)�!�k�n�
+���&hg����������ְ�[����_�G���w�������V���� ��Pn6���fS����@.��Oս-�w{3%����E��� _�)+'[����ii#�E���>*�²Ș����C�=����lWy~Z�w����m �\��
+u���1� �7�.�}۸��(��az �v3�g��
+m7�d^E+,��K� V�#s���l�[Ӳ���� ���1U�%�����B^���<�}U�Ẃ�3m֧%B" �O�>P[�,�N���6~�A�tRv��ݚ�5�'�a� C�>�~!��*�Z(���|~����*C��]��;��0�%���#�]v��ٙ��/�A�4k�� �a $�ŗ���t�C��K ?�5��P�����%O�@�.�"%�A`�N�E�d\�.�lIQA�e �� �P��*���]@���
+��$6t|�����ϭ(!�T���J
+3��
+K{Ŭ��������^�����b��K���2�N���z�5@��#V��i��3��w �Ml��_�?FUPa���k��TIBg�C>����q�@=s�d�gg�
+�X�Q͓�-���8;�(�&���\R\b|R�H�g:t�; ����KbG-G�˕g� z�,������a���J��h>?� p]X��}���PQj��QŜ��I� � dk�����n�VT�[�ˊ��GQ���솮�#�
+�5��q�:�q����r��l�۹G:i��࠱*��ڌ� ���WE56�n8����}s�aR�[��Ҕ�b�
+��m&L�Й~3 ��lJ*� �$��xU �⨎+V|�s�U]{k*G��Tf��%tUʧ������@q3�+È�@��#�rPwX4�`UŪ�X"���
+3|�Dxt6g/o�H1���\���Tԡߍ�WD8��٬� ?zF�M�2�D��re��ʠ0{gn~[�Ǧ���p�\)�n��*���Ĕa�]�T-V����҈Q�0k�(<Џ��G���;w`�
+�p��=���!P갼���gP"�=W�,�ӪP�b��FO�'q��Sx�m���i�e�Rdd�/������_� b��s��p8�;��oj��7B�>X�s�βI�Z��>��&h�.ҹxs �� R AOT�1���ᜤ���;��'ӱt&w�.���F���< �B��~��~[�x��ݣJ�|J�a'���Y���E��|�n��@�hB�{�i�\����{u3�v�9�23��1FD����r��C��H�|$���"-�f�Md���ޞ�lAŽ�"�����kej��kk2��{� �pߦ�* �K�o��z�i'mX���ME�9YL�m���NR��0^߳�Sݙ�/�{8mA�K/�.:6�3�`�n*Xͦ* ��j�p#�ܮ��dJK'��c��U6͢^t8 ���E�c��E��ת�d:����jr�9 F��i0������ಏq ڡr�N�r�h�% �!xMR2�>�;�΅�@�먝��%��0�H~���wcC�@���C��};y�ˉ�[��� 0�nB5���t�R:��Q%ټ932@_ḣRh9TV�>�]�s�1Q�T��P�'J������^��9�g�
+endstream
+endobj
+252 0 obj
+<</Length 13/Filter/FlateDecode>>
+stream
+x���
+~
+endstream
+endobj
+253 0 obj
+<</Length 692/Type/CMap/Filter/FlateDecode/WMode 0>>
+stream
+x�m��n�@E������d��_��!�%y(D3kb��ж�X����[�h4 �nW��*SI�no�u��7���7ک��f���4]��td?>3�\���=u}[ <�j���fL�t�ϩ�s��R�����P�V�0��$Mߛ���
+=Ѧf?6��u������ߓN���׫�� �Lj��o�-��o|�K%�um�n�QT�[w]|x{F>n��%��z�$��h�Ƈf�]Ec�T�������:���N]���$�xʾ�߳
+Lŀ�>@�/�/S3`4��Ft�c~��g.�����g��p:�N��p��3CG 8�����9"ӂ���g�NZpfpf���h�����h�*����Y0Z8�`�$F#7�с��,ŏ0bBV�YaD́��mq�[ ~�'�D'����}E�8-�fi�^pfp�d�� �F߉�#�38w`t˸Xd��6�e�US߳�"� ,l���e#wm��������z)��~��
+endstream
+endobj
+254 0 obj
+<</Length 10538/Filter/FlateDecode/Subtype/CIDFontType0C>>
+stream
+x�}zwt���Ȳ��,D �� �� @0�w�c�� ���c�]ҕ�^q�E�0�
+�ߍ�:H�� uRnD�ж�:��w�;��]�T�QXZ��֝Al��p�EW���-�� Q5�(�ѫW�LZ���sL`P���7��������<p����C=���������p�������?�#�G����#<`�O�G���DŽ�S?�3~�G�~�>>~aaA�_ �u@x�������>�>�V���z�� [��2>0 l\��OP�`���X�а���MO��}ϱ�z�3{�Z���?�c��w�C<}=&L��O���l��������x��y�yx�� �����p ��X�?��m��70 ,�?!���H�6 �t�ҘB��^��\��h��� Z4w��Z��Ւa��hq�yE�-ݚn���Z�T!�L�M��`�MՉ�l�tw2M��SrH�������]_��ΨǪwr��uh֧s����ۓ��Zx���2�U�G#Z����e��/�*���|�f����g��mg�}���v�۝j����'_v����q~�N��;]� W���Z�):���Ӭ._u���^7}�f�O���㗞#{�սח�N����d��}��4���a�<�<�V�*�4vUnܨ�&Qg�$N��PjpH�KiZ�{�-����5ta�Y�����i���9��0�����#J��Kd �s�#q�" ���Y�e1�M_F������}���$!W��PF��� B��쎮��`�߲�����p��U,�Y"��|�(�5�+�N�[�g���6�?���QN�v���P��м�"���&_@ws���B�M����� ֒ o�S��}O[ҍ��b"������YW���z�F��e�v���gˌ+�>+q�~hq��Ci�Icu��6�R�bM�9�O�b��իXYY�������x$�85��1��y�=$����g�H�� �����0���,��A��w�NA��k.� J7�#��G|�jb�eH�~�HZ+R�C�Oې(ɐlA{1��dvD|�X��E��]�j$��D�3x!���/�ۊ�cY��\�$$c��y8��Sp`MŊ|�{����
+�z�D=p�W9�}��x�v8
+����H��s���C�7�ӀD�*�C���9��B�Ѩ.@D��c�� ��U���Bc(�O�S:m�cK��&�(88x�_HA`��a+���S��(�Jݷ��mf]�ێ��Oh�q�B�jX�k_�K�7/�^0� +��-�6������gy�?N��G���A|>:�ʏ/�O��O�;�v@�4���=N$Ë����+��M�����YF?C��{�>رK�Œ����m����p�܀�vّ�n���B�!���nH���� � ���a#�1?6�$;������/b�!F�E���.���`,H�X�!6�E"@|:ɔ�H�/�H��dK~~q�6@2ye$ q+2"ֶ�/�����ԥ�M�x��4b�)�ڛ�S^�����EMپ��rw�r��\��c��%��a���9<�Ri������g ⳝ�YDd%���<Q�sep�VE��1x7)��%I�� "��i��_<y��V�?����)X
+�xm�3��y�I��c�}PWLyf���MBҼU$��5좆=iڋ4�LA]�?�SI�_��?�����f������%��b�=��f���E�=�U G�mw��#��� nU�q��W����}����L�c7Ɛ�]��٥���[�v[Im����.����~��3�V� � ����w �v�#���� {�@XQ@��L�aJ��A�h����`�DG��x�Z�ֽ���f��}h3ڲ�)�
+��dC���s������ne�i���찿I���Y��!�x��ېm�,�ݯ�A� ͼ)!>"��-ѥ�+�kj�T������Y<P�~��2�S�X�a1t�5,/�.�) �vq)��~R��NOQ��d�Ln4�#Ι�;�A��}��Y�Vpڳ_�����yhy�W�����e-��c]䇝��"�]9 7����x��>kWF}��#ܩ��������Q�k/�ŏ��G��� ^y���N�,�C������ ��� �om+
+�cd� qqmH4O��<��� $$��F$�����H����"�<� ��H�0��&q��(*3߽_tY�s10��a�1L������D���oȘ���nҮ9��-�������k/$��C���"u�VH��J�ר�L���P!�;�Dl8D�z"�K��]@|�6�"p)bc�I��r���x⛂KH>ڀ��g
+Q
+[�Hss�"6���p^^��q�b��vGI5���~a��Qw�na��/�f�%sg�&�%�Bږ�r��t"�� i/�KO粉l���e�M�V����k��u��������DC��
+J�R,)
+[B��>��HZ�1!�'w�4;����O{7�TH\n}�(����s=bC˳��H��#q9�@��1�����K��پ�H�u�D7q�nwo����4��A4L���!}�l�� �qi��J���w/�.,O��S�v��ԝ�\�ޗ��Km:joJ��)��!^w�o@|˪��<�f+�mp�X�c�А�K�3�Ν�xоf���9oC��^��>��X& � "M�^��1LKV̊;������^�xxٔY����/Y�ѸT�Nڊ�;8�4%7)�ˉ��g. ���~%~��촻o�M�?�T��h�AG�wQuaz�-^L
+�/�,:�����,����M���*SB D�A�U�⢪�#v�f��L������hGA΂_��׊?_+�I��ZP�m[y�6������kʝM�WyN{����[�a��e,�y�7,3�ȫ;�c�%J۸�í����.�5G��W�\���u��K�����Tݖu5�}����^�g�#�L8�d��S�K��I�3��Z[H{�
+:�LI]�\]�u$ӿ;�$x҄�qg��n  ���� ?#+�O�`D���K�77 �{�bcsO0�)��LA�y�ziH)��ʍpu���v��RK�d��� ��������PK
+l���x3;c���U�� �,��#�jUOՔ<�mi۞O���x�OF?�z�/��ӏ]t�/��_��� ��D� N$n�=�~O$ng�P��0�%�?�d��`$�7M@2�T*�O_#���ܛ `��s�Ұ�a�eޫ%v[
+w(Jn*���+Ke�V��f���y9� ��cL
+SiɞU�:^jZ"6n�| F�٪���=��+���&Z�����cϘ�B���+�˼�H������:0���K��Z� NS���>�5U
+��>]d���V�RN�uvZ�f�:3k!,�)Ꝺ��m���a���)_�4���^uw�y��X���� z�7n/� U��t �<Ӫ�Ӷ��� G�;L�e?0�A��J=���i�%�4���I'�9
+�
+yJI7|X��?�ܺ�"�,&/��,����e��zV�W���Ռ��~�ϲ��â����˶To��|�F�^�0����=�,=v^���q�Jי�
+)�5�jJ�Th*�Y��s��-5��V�
+� H�}�
+C� i&y 2�i$.ܝ� ��XQC? @����^�����%5GG�v� :�Ne�l��F� �Vu���/6�b� ���_�����D� g�Is����s�=��c�bQ���;ܧw�J�:�х�7���f���W�����
+nwf�a�C��4�< f�T�����p��6�k9���B�^*Rk·^�Jڭ����+��rh=���E�hUHWuՇ/��8�>�1AcD"�h�D|{����'"4�1">���݅��o�z#q� ѐbII5�V�n(����]c��.#����a����R�6��(��V�����㩒�Wר�Wg�`���.� ��ώ��<v�ص%�j�ʶ ��  �э{�j�RRэ:V˅g'ggg��
+���r�i������"����Ҹ�����p���=������J��R�2E����Pn��A:W�$�c�H\�mH�#��Zjɚ��5��9IC�ȡ_Л��lMN!�l2����S]_ֆ��#K���z�6���bRR�DM}�����|[��h��>��f�|,8� 0�k`�-TU�����3�u�6j��{�XA���a��S(p��M3�sa.�l2t>��-�_ll�1-ua�hg����!h�������۔R����)%%^��i�s����񗯐�\ʊ�l�\j
+__�S0 ��]�YKX]���-}�M�dR��{]���:�k�$�.e�+���ցSB�L.ݴ>9zCXAl^�)����'omhU�^w8|?-�~D{����u?T�?7���m�7�x�w{8�eSB�����4��/�N�<��˴�
+������� 켾Z}2�?tv�qt�m��"��{ͻM2�=Ř�M4Ӭ��R �t n�o�2[,X,�a
+Xm�x푒δUJ1���#֓|�)-�`L�]��G���2�������[����v�/�/ѡ:��. ��WF%���� �3i��Rf�8 �y5�(ݕY˼�r����'��v w䮧I�����K��k���>m���!T��>��߯G|[8 Qg��R���M�|�g6��F�o�B:� ���?�ڶ�ymtV�ޥy���Y�E:��f-�%TU��ƨ�mo�����K��!�*C�)��~��pꊡ������yꤋv��~g�r��5���6�Zy�!k*�m��V\��'hNB5=[JwW�S�6���z�$-� �'%�N�5�a���S��֫/��Z��4��1>��Mz�RM#`ۨ���m����#n1u�bi�./'�T�=��~�:r�؉�E�O�~r���ܝF9}t?��SoL-5t��XEMj��l���E�x�[�`!��� gS4��J��|noZ�-���ƫ��hL�ȳ�Ɠl�tXg7YC�g_���׳�|��Ln�؅�g��4F���;��s����>���&�����%d��C:���2�Cz8��,���`�9�uI+g�k�R����s��qT��x^{3>ec����]�ij����;���d��� �[�,�aC�1��wVoD�?0���$�>e�B�C�����Z,`."S���:��� ��I��7y�~'�k��.��
+��R�V�7���E�'��D���W������K�\c�A�W� Z�������B��^�ĭ��H��|����Y��r�'�� ��I124����\)<���4\7�^�5��j�E�4�B)U�]��l�Ȳ�ȧ4���'A�yHRW���+$*�&����lz�.�g�g�b}���Dֲ �KKd�� B"���ȾIFd������O��ᠢ#a���m_-��PУW��)��56cFN������]��=�U\^J�A����5���$⛴و�K��ىF_��b��T�����z��N�>xq�׃ۥRW�{���r�W�BRH�t]=���v8{�B�����_���]�:��LCM:��.��1�iou��xv,9�o�XVg��*X�a�3�U�eeK��";���|��FG�m��l����'{h�=�c�^qə�<�����-�Tkz�5���RғSm���jCJR��l�+�e$f���� [Zjj:�ϙҒS�m&����eu|Jb2xM=슆x&����9�/�� }�c/��
+��y��v�K9�Ȅ�\H��Y����ϖ�^ԓΡ�i/:��•�K��mu����b
+vî&��E�6-����'��Xcr,l��]�b$-zNEd v$-���l�ldc�ʜ���
+endstream
+endobj
+255 0 obj
+<</Length 13/Filter/FlateDecode>>
+stream
+x���
+�
+endstream
+endobj
+256 0 obj
+<</Length 681/Type/CMap/Filter/FlateDecode/WMode 0>>
+stream
+x�m�MO�0����C$8�Ml'�*іH=�!�v�%q��h������)��
+�9]��m*|R�\b��rN�$'|Z��o.Y�˙��/�O-�ǭh�r���e�E�gȩ�3T��i� �a.�L��e�>�=h��8��j�,�E �>�>r�,�n೼3#|�4�Yr�,�eR>˔ |Vb�Y�i�ˤ:|����� |�Y��t��X�,����<-|��>M=��tf�g=�΄��g�n�}�S�D���n��IL���>ح�Ad�˰dd��u���~+��w��jXf~sՍ���]��S�'�������xB�x
+endstream
+endobj
+257 0 obj
+<</Length 12338/Filter/FlateDecode>>
+stream
+x��| xT���گs�<2���d�~2 1�AB��@�@���;��A ��?&-Hx�FjS*1�!�������ۢ�6 �ds����>�������w&��9����o��^�(
+h4��*�-��"��2r .����?P&y ΢ �>�z��1 i��p�4@9i (����%
+�p9��1P���
+)�����b����
+@7O��B� �ҶÈN�� �i(V`�����mN�)W�Z�m����
+Į@��b�؜�,�l�6�";vWl��a�E�֭]���z��j�~�����<���ЩO?A�̀����i��,OqQF� a&+� ��� �d
+#1�y����%𿒖�:ll�je_�V��a_M�9�L�XTP�F4Jvc<MH7�t[x�A�����4�8��N��?
+LUd� ��M�E�4,��v���=�=0zD�Y�n�Z��̳?��g7��q�
+zC�q���T��e��[L��̀U�+"2"7<"�h���N�@'���y"��F+� �]Í��PF�*�|Uq��u��46{��ck37+�q�z��%�����͕�ʿc�S���"�ӡ����gD �Rp2���3=-��;�f���G^�qS�c���>6n�7��g��ۓ�������64��{�ޣ�� 3'��-�[V=w Zb8����`�Q�
+���[��y@P@�J$�A�'�
+�l3r�9��0ш'M��d4hz�jR!�隳[�m.�=��U+;�Z5߇��s��(�u��@M���q ��,�j 1E�� %�8��$��Z7s&��2�L5]�6�i��A����Z�a�i���!Et�k�eZ�W�Ǵ������8�D�`i�4&�D��c��!Ɖƹx+6��Ud9]EW��W�&�ɶ�P$��X��;U��=����/:H�y������N .jݩ��f
+S]�� .�5���\
+�S6�0!'a�Q�%�)�@�3[[���l:��W\�F8�+��PԄ�i���1
+ִ@Ă� ܃���"\��2��hZ0q� ���AZWϺ�}Hڋe����$_��LT�9t�2G]Nc˕�j�d��nC��T[ls��)��g|����-G����A8w ��|<]C��j<y~.��0%6���6��V����f�����,�\k�X ��)��d����gTA�k�|K^�H %
+V��gf�Ś�Y��=:���j]jŚM�����,q��m��|�D?�d�����2����IɃg�$?��֒�G�B���S���8��5r��)�t�w+���y8
+6�ڽ94x�/om*��r���EUK����U�%�.jK�d/!�l ���J����]�`�2+JH��F�^B�^F�G�3֖N�m��Z/w�U��?��-ܑ\_�S|� �ؘ~�Ϳ�'��$Z\��������NyǗ:�3��;)�d�h���| ���$�鞰v��w��
+>.9 v{��NH��L����qe� p��u�6n\�n=I�1����]�#���(�o����'�ND�� �� c�����f�j��B�x����b�4TFV���. ����| >�`�n��^@��kԛ ֦k�܋s�w�J}�l�I�׬�\�a���k��w쌚�l趽��/�oK�Ǚ�>��ܹ?�/y�7,�.�۞���)��,�" e������i
+9_v�TmC/� �ڽͶ�k�޸� ʹtw��1�]�^р��q��n[U�kZ�QJaQ;�
+����Z�V��ʊKA���E��Z92�jo�3s?8�oy�\������eO����d|��n��u��,� ���+M�&%}�?pc�¥Q�) 5o��x@p��&0@O�RM����k uWI�0J�ܨۑ�Z���b����G��]gYS-R��K�^�����<�>zZ5��1�$�n�IJ���vF�&K[��ͭ��q�2AyPm�e�A
+
+]̓��D�T{��%�4>�b�K�jf�D�=��Y�Ҝ�.��,>�-dM��j�s�Wҋ]��0ؓ
+��d”��AU�'�F� �H�eBMbqq�忿�Μ_�zc�y�J�K��:^.�i&
+�2�2M��h*�j�jz�E��gɳ�Y��������Y㳦p
+m�>��_��(�)P��Ժv5!��)VW
+��酆o� ��Wɽ��t�k��4�����K 2ġ�%z�L�S�m����������h�E���Ȃ0���(����x�5�j���D�����3 � @�&0
+��s� *T�X%�@,2Y/���WV\lNuG�X�[�yȂ��Kh�ۨ���ZZ6�~���D�c%��A����Щ���{^bM���2�G�O�z�uY��x/���m8�����'y{g�ݯ�<ϥϐ@}l@F�3=#ڙ��/G������� ��u��-􀅞�]�����l�A� ̶�.]�'f��)]͠$: �/��N4'%gX���m���F�\T���6^��E���c�|��Վ�NE��N=��ad_H�����\Y��I-���[�f޼ՕO�׎y�…�F?����7�%4�{��L�)���d��>G�ٵk��v��� ��رo�������#�U���\�>@�� �����)��.'f�8���?XhM�jLa`���D�7�bs�.o��������N�K�E��5��@ӈ��6���،¡f�Ƀ6�K}z�itP����{F2c���V�ٚJ<a*�+��+��a�)H�6�]�A�����F�oe�bb�!�:΅����V'�B<W-�+Tc���A(_��f�bE�@� ��-��u�.p�k��N߽�H߽�^��Uq�ؠ�'�2Vj��X-��I�ݤa����g���{�Gv��&&�� �
+� E�A%��֋����NZf���E�u��!�4��!�kj��*xN4�ܽL��&��˟؁"Cu�+��a�# 5�� �o��^�s��I4�W�5U2�r���|��Ǣ��ogʭ�;�v@^�K�{�%���dg� ~d>��-����>YY}����f[�]D�FW#����RT�iu�6�wL��ŨJ
+�t���{��Y���f�^�c��"^����z��y+�|��yĉ7Rk�髖K�� �E��[��5[��yb�v�5w<����p��_�� =�V�M���_{���~���J��ľ���� ����� ĤV3D6��U��],V�W؀,ᮈ��t��kP4I �]���>����7ϴ�g��/ɻ����nTx�-6��������}h��� g8��oF��z�-pܓ⊦kc���K�cqZO<?�'h�B��-�� LU(X��Y7%Q�ӲH>� V&X�R�N���1�u���Ƴx�W�G���SD�b���W�M����zi�
+��ՠ������C6�F�Q�FСt}�~ʞP��B��.U_R�� ъ�� ��n�����cO�4�
+�nc��SS�i���z�������^��,������������z̖m+���k�����%v�/�2ୀ/��J���4��(�a(��
+;D�M@6`@�[�ODE�&�_��DA������N�i�sA�w߹a�}�Ġ߹ �P���/ -��[�ر@),����l(�%]a&$@�A*�B:D� XQ� s` ,�%�f�t(�n��
+ƴ�Z,�͂�0 �R��� F�`.L��PQ0�a:,��rdL���`,�(Xe0��� QP�P���錕T�P
+ �r�Ja���/�9�,ҡ�=Ϸ=��콴��U]�%>酄K`!dA��5|�B$�b(�2X3a�|v��.�,X�:Qk�� ��,� Ũ0 �C),����$�N�?8���t�����[����b�� K��72��(t.� ��<��R(����FKz%�Z� 괋�Y>�f�Yʂ?A�Hޝ�>��aݚ��%P*9\ �_�s}�R�K|��>Yf��n��Drq��O��r\ ,�Qo� F��4K�݂c:YI�Ԝx�P��0��t�|� ΄2(�T�K��)�90�g�]�y�A�w��X�s1c&��BX�Pe��n
+�Bcs`�I~:p��u��L�e����2i�r�/�!#�}�Dm�;,|q;�e�n��#�.��X���X^�!9��˙"�N��������j���nCN�V�3]�{��C�e���h���P$}������Ԅ�IJ%7�%sa̔��1��x��K�iHH.$�Ӯ�Ő%W�8�S�a�J�С�ξ���{/]ae��۶V������J�m]S���kk:�'����u!t_"���?��X.�-�^@�N���zV`���1��\��6�&xv����Ω��MJ���V���,:^e0_~�H<+����B]��0_JS컶����,n��~|�[�:���{,LH���4'��w?.?ģ��9���ǽ��%�W�Wrݶ+��-�m��Ef����c�eR�B�|��Řv��B�o��1��M_;��3=6��*�a~'M,�R���͂�$κ�N�������Ѧ����y��#�(��b�b���]����n�u/�1?���ܣ���5+|����ݱ��V�� ��'�_�(�� �`�#(�qQX�����ǥ�3-E�ڑyr�Q0��yF� �?����a,D�h�`<��@ȃ�R/��q?F�� �/)���$-���/iO�(I[d�#�a�#a�|6
+�y0VRc$�0�C��S�1G�
+<N:��'���}W<&��B_\[����9{��J;��:��Ι��˱%��븪�g=fu�<�s��\mUr���߶�C��zm�9�-�y�� .n�J��Qڞ���_�ڪQ ��ro��XΫK���}Zz~)��-�4*B�_!
+^F��2y.zIz�0]�c���Uź ���d�w����e�����'�}t��g���V�}Z�>A-�{y��`v'�E}��"�s�����<�)�G��c�,����r_F)�%���?�$_g�ޟQ�3=�@�L03��� ���F� &��#�xF�0S��A��8*;X`�p�� t�x�
+N� �zA
+B<
+7;��ۜ�YI���MN����N?���)NNr�:''6�� 9n& 8�8�ñ��G�V�cG���dr�C���k��F���W9�����n�Wv��Ü�\H��[�A;9��K��J~��~N~k'������^L#�,䅽6�BW��F~�|��J�|�5'{8y���g�YM!y�WV�l0�����Hvs�k����N?�c{2����dR]՟Uo#۞9ʶq����왣��t�Sn�u2��[8�'O?�fO%O�IU��U�'�7��f�d"�nVYH*���
+7y�F6r�����<��ƞ�d���'k9yܖ�K�p��1���+�*N~���� ?��BVp������-1�2RV���]b&KN��v��Cq�(' 9)]0��n# J��cIIW2��yid.'s�H�m2�()�d'��̜�fr2�lF���4N�r2e��M��Ʌ䑷�ÓL�a�d"9)p� �����`�P��XN�p��J2��Q2��(���d�Q2�+�Ć�"��,?� � bC8<���AyAl�Q�D����Ȁ\`'��c����Y����c�h����'��� ��zr��c!�z��c�9f�1��z���~��EI��m��I��$��L[.�,$������H/Nz&9XON2���!��0����9I��4N�'9X��BR�,%�$Y�Q��-�%9HR=�v��X�
+endstream
+endobj
+258 0 obj
+<</Length 9/Filter/FlateDecode>>
+stream
+x�;
+endstream
+endobj
+259 0 obj
+<</Length 343/Type/CMap/Filter/FlateDecode/WMode 0>>
+stream
+x�mQ=k�0��+�� R�I�B�uj���Ύtv�$$y�/�夔��w����ѧS�x�ꊋ�s
+g��7 ��քҝb}��9�)kנ�b�宔�JK�n=lj��[!�yo����n���@�%G� �J?�X��2B���9K����(V��FHn�\�.ɖ�s1
+7�j���:�J�(X�,���
+��
+endstream
+endobj
+260 0 obj
+<</Length 380/Filter/FlateDecode/Subtype/CIDFontType0C>>
+stream
+x�cd`aa`dd���LJ-*��+-N-�L� JM/�I,ɹ���!��#�C��A��A��B��<��=EFB��OVY���� �\�� ���(��>񨀨W<� ̌�l|ގ)�I��)�y%�%����E��%
+����������BHF��Y
+E�Y��%
+��%�E�z L � ��8Θ;����u3VNe��4�]��y��n��\��oM����O�����Q�������4@�!!&FF����?^�c���Ce/s7ˏ��~:�n���n�}�w�ߓX����+
+b��W}��c�3��w�������Od�����`�.��?L��}� ���w�3{V�N��#�[����zJ����J3'v���)�G�����]���
+endstream
+endobj
+261 0 obj
+<</Length 10/Filter/FlateDecode>>
+stream
+x���
+endstream
+endobj
+262 0 obj
+<</Length 380/Type/CMap/Filter/FlateDecode/WMode 0>>
+stream
+x�m��n�0��y���h~Z�R ��-��={���m�΁���MhU��D��83k����'i�8��sأ7�8Zok�1�1�mP�WD����Xg��
+endstream
+endobj
+263 0 obj
+<</Length 4371/Filter/FlateDecode>>
+stream
+x��Y p\�y�����]�,iw����]=,?�������-�ɒ,�%bKڕ%���Zɶ0;���%IJ��1���!J�x���2��P���R�dF���iE��s�>$ �$����{�=�|������ @2�Pֶ��s���
+X�8��$z��.�K��Z|� ��p�����
+� ����,�g8F����`=nIe����e\@p���j�:i EǐVP�����foqѤ[���B�Pʀg�ڵ�-�,�<�gQ�mH/����?,.�i��z���D��QY\TӴeH+w��^oqQU�F�x�/��1�i��<�x$o�#���b@C�1�w� X1�?M?�9f㭚����.�����G�ҝ^g�����1�Y������k� �}?� �(�g��ᤀ˦9��ޔ9N�%�v;�G}������E(�"P�s�o��%s�
+�}�[��9������7v�?gKY���;��E��������w���U* ^@�#�`�+����s]㺕��uf';4ٓ�n���
+m�u�-����c�vVK݆m���˼�%���(�4�`����ŭF���E�o�>E�����1��a@��G�F6����I6��l!���S�EG�a��pj`�N��δ[�����L�k��䴔���NWyf�S�'��*�����3��9��L楹�s2����dLw[b!�¢�R�j�~ƖR�k�q��b��^��сΎ{�7�*�_�w6������}��k���+�.cw��e�81�?]$ ;�)����\�c|4�0�W�|q:��1�N�36�p�;�9ma+Y7[e�3�e������2�sf7�o��9��>�d,�g��=��a�AX�s�x�=MJ��E(�h�ʨ�r�|���w�o��Wk�]}S��G�q�1n ��е_��g(D�k�t-�]���4���Sҁ‚tw!t{Ρ�ox��PROJ��=�w^a����y��sٔ;�1~~t|�#�Ky���d�ߗ��f�RE�̯�\V–.�_���t���\�tw����~ʝ[��a*b����i��j���:�cWoӃ��9e���M�{~���P��x��w>--1�u��M ��_T���'�탥Kآ �mͫ��BG���i���L�V�3Y(��t�R���K�K�Ή=�p'���#��X��
+dU/D�C�;^?�J,�0 ���* �K&(u��B��a]h.�r�� �"O{cOS�i�K�Ĭ��_Šҫ]�.�X+��if��w'��7�ބ*�SL"1���|�O�5.0!Ä�S3�1���&�2G�b��$<�⪮�$��Bp�O"�̌�ɧHЬw�q��^�U@�ϝ�\̍�=y��=us�M��I��E�MQ�"�"�=�B� �I�U�� ��y���/N����+�7�1"�bL|GL��΍�Ey7U ��r�D�soد�N���g#f��������(�A��'�%u�LFkڰ -r�@V���'��닽R��RQ���1�nG��S�:l�v��h�c�h� hD=6���B��K��r7nF�D��F��0Q!���آS��w5�F*��*l�6��$Q��(�ס���6U�Y�ը�FT�뵲U��P/���u��b�� V'��M1f�P�F����hV�Z� ���y]��dZ!5�s56�Vމ�шԣI�)�+M�u҇5h4}�� T$�ըG�匵�$ ai�9S�o���Ȭ�Vk�SŬތ������
+ 1&Tz��f6&DEĴB�M0�'B��)=��M��T�� �D܄R�rvP��I=Q�X-}]mj�0Uޫ�Ps'�:���h�Px��P;D��{�"Pa��:A�x����#�Yd�����'��Y" �]T�c"J���/�F4����I�F�Qt�W�
++j{b��B'�PU�����jW�ɺ6ϵ�u'wb��J�Ϣ�Z�� �*�V� M���:���<�=�T'W�-�hR��>T�V�F��o@���ĺu~��:����!��"���2�}/"�*ϔ��T)�)k�)Լ� 5� Qp ��Z���:�9G����V�|��1���e���y��^�S*,���7�~�D(�~�
+M�z<�ڊ��P������N���6���k�R�]�D��(��C����(��Q��!����� x�oŀ�ew1����eX����A�9�Ξ������я��W����ҫk錏~4H?��+��O��w��t��t��A�^J���z����������f��c��A���s��f:�ן�K' ����O��]˟3��k���g���Ϯ�g*��|��]����g�_�ѓc�=���`}۠o��Afб��;@�<���̠��9�Q7=�G�s��>zdy�,?b��m�����}��?�����(�����3��4z`��nС���������
+��=�whg{�������P�A�� j5��
+�xA ����(�M���<�G^�<pq��3���̤�Yn�cP�l7���l7͚��g�i��I��N>3�f*N3li|��fN��,7e�P\<c��/'�rJg9<}�\>r:|�9F���Gi�[x�J���n�[Oɡi�i|Z%��.n�N�4�)�;�N#��0I��$n��e����<��0���i)�rHK!g9�a8|�-���?�������
+endstream
+endobj
+264 0 obj
+<</Length 5745/Filter/FlateDecode>>
+stream
+x��]ݓ�m�_Ѻ[[��[���R�쇤|�r���<�Tޔ]����T��}�G��&�9�=뗝�� �� ��ǿ�tz�����׃~�۟N���O����_x�?����3����1D���n����z���A�|z����>>�{ԃ���ǹ����������6���p���Ow����p����O�ϋ�����/m=.J=]~ s��K�tM����߀Z��x�������7_���o~��n�H���Q���*{,��`�Ⱦݬ;U�� �ۍUYX0�T�x��k�#�������`��w<�a|
+�`��V��"���,2�Z�hOw$!û�v_� M���aH;/�O�R%7���Z���*�1 /:��f�Y�$�㒲!����q㡙�š6&n\dƆ�������gV��I��7+�o�U�`rR����-���u ����T|�TQ[��|��#��!�.��Hl[|K)�ay8��X۔��Rs�5j�!�:�B$�S)gD�v�?j��n������؍�������$�~�!d:�G�7ײ���%�UIR�)������ �n'��}�A+k�qi15O >5/E�Ue����ńъ����ʪ���Q_�) �� ��F��XѶ�8�7�yY�!���_��deU<W����S)�x���T ;3ò&.>
+k=Kk]k4�̣iE�-�Y1����0Cby$k`C��z�k�^��0�/�e{X��-Ol�L����-�1����k����廙ǚ l��&|�*��f��ĶS�$tZ�h}���=�^ �H�{�r��}B�ULZ .!T��6 8�������ꈧ�h ��$-��:��H`H��%�ǰ<p%x��*W�\h�$���e��@&C��N���ig�$� :
+m �-NvU;� ;0�;��(8�68n��Wc��O8<`@8�Q� �*o뻟�h��A����d$���A��慌��L,���w�_����`�L���$����� ��9�<F�,�>���Oq�v�l�*I��<��0%�[�T�sR�#[õe�&�(��c],��嵊�&�
+�j�n�Jƛ�1�u�{�Y����;�]|�z�Q��
+���)���8!�j�Z�'H`>W|R��x$�Y]Mx�(�
+�?k�\!���Nx�L3ɤ � �0�l��ӆ�n�W���̞�6f���`�( �`E-8������?8���������'��6
+j�{2 �y��찂����[�]سi q �D?�%��\x��L?�amK>��$��!��G8, �exv��x�JS۹8�ab��H����hF�]L�w���eeō.HG��T��R3N��Q�'�(w����3�5�� �ť�8�հ92P2��Xgg��feU����W��؋�,�g�<s�����n��]V[���k�
+?[�7��Jٛ�Y[�� ���`�ڳ?8�B���(+��������� ����*��I�M�[>q�
+)��
+��x����{����j/]�k� ��Kq�Nk^�r����u��j��1\�b08�o�m�'�v�Q ��| ^���wL;߶��R��"iH]���Z]�7���ԅ:��B��O�$�5o�*�f;�����\�,<8�.�ֆ)�M����5u2+� #�C�c~�5 د� ��C�!�)(�ΤG�d�6���~P�m����;��J�!;���T���H 4��e�^]ꓸuX��
+\�z��f˶R*
+k�r)�͎way/���6�d�p�K��Qx��y?�a�x �^�זɀ�v�H�������\���I4 �0eV��$���`�
+)��O�^�"��U�[m (fo�Bu='�c��.�����)sD�.v-@a
+sU��P�ؾ��En���m�U��=Xe����(�ՙx�<�Ҙ�D����+���m����Z�^�� ]8B�"0Gcu,8]$�Yȇ,�.�H��v*d ����69��� t��|�*�4�kd��n�٪��5�3�~�72��J�[98e!�I�eQT.&ߓ������QZ�`��������@!a$����`'f��+ ps��Bo�=�� ����e����� n߀�@"�_ [3������R�Ue�q�8|ɗʚ��ƅ#�#s��P�NJ�0��l%� M!G�*d�ǃQE6�S2_bW�_�- V*]��k�ט4N�+S�5�"�P��Z��`�s���-FԻU�x����RP������k �$F}�1J�)B� ><QQ�U�5�J<�{���C�H� r4�S�a��5�_�u�ZfH�Nl,��y�p�U�a]��?d=4v
+��I3���\q% v}q=���~b����Y���*D��um�1��G��R(�����вA�� �T
+y�s�ؖ�E�q���1`@�hc\�:� N���-m�UN"��~���G���}A�S�d�� ���4�1���6)��U���G����P6����؏�x-7���[7ޡ�����H��M�����UI�H��qc���{��Ѻ� z.���y[{M ����Ѱ!�n��R���,�gQc걔�+�-�sB"���u�����b ]�E5y�,Gm�X
+��˫�Êa��������?����,uΖ����1KD�<Z��`���bZA.A����dtf���.�ϒW�^��߮��R��oh#��
+� �D�,�!�EA�͙_m���
+�8k���s=���l��
+7�;K����J�gJש�
+o���q�k8*J�TU��4����
+
+L����}|L��G �x �M&��!Qq�*�ow�|����<wD]��j"�q4E�v�1���j�t���(!��2e^�}��N@��X��� z�>���k���R���9�Y��M�|R�gӱHo�� �v�KVY�G��
+a�I�!lLV�@ؑ\�\Mܐ�W�0� $�m�6`'�;lҙ|Mr�e[�m����6nJ��<(?�{����0��n�}bG$�ai���ӞsY�c �18�e�'�+���$8>�S�i���A{���L�R�\���V{�ٜ{V�Yc#�A�{�����5����I �M?�G�=ƨ��)!B���i�,�r{�KcZ c����S�/��L\��Q��ݖ_4hF?�s�f��!3��Z���8h�O�a����񶧊�%�A� �/z�lȷ� ��'�ܘ>�f"����"�wgV�&��q%$��/��*���)Il�g�RQ���vKF/�e���f�ֱ����-:�@� Gl4�RW[���~G�M����!ԭ����X � \h5.�S&���)�[ 0B��v/���P��|�O����K�1� ����<�n7e>���Nyƈ���_�#�(��+#d�t��eB&�b�l���gC<��J
+��b��$?�8��)�� �f� ��O�a=C��3�L�i�
+tm��
+2z[v$�/={ "�� ��f�d
+endstream
+endobj
+265 0 obj
+<</Length 6017/Filter/FlateDecode>>
+stream
+x��]ݏ7r�_1�eK[\��}Qw�� � �ޢ<h �!A����!��>��nv�X��i6��E���&���Ǫb���?~zx����w���$�۷��=��I��I�^����t'�Ah0���/�?��O>�ӟzx���I�>|���'%�=}���>>���>J)���x�WJ8>d����8)@{�η|�Ҍ��r����[iA��n ��hB�|9^h��O�8_T�Vh��a��m�z~����/Ͼ���ߞ}�ûۥ�T!)�Ahp�%�NA���>J �>Ga|�xz�|^�JjM>������R5��&)lா����^��pS�0Ѩu�a�n=�1װ'��n\����_��B‘��7Z�ct��K��!&y+G�~������n$��ll)]����Q^���P����d'�<qLꉹ�)HS�hD��]��%g�X�9!�c��j]�-�v
+��6�R^�p���M��@,FX�M3s_�<y�,�NS,irQ��>�vJ�a�(�t2����D���r�dW�؍I3N����� w�zBKC�J5����HU�J�M�Q��H ed��k�\�����.�1Bke�^-|�V�Y�97��HX�yC�kzB7���z��&`��Ȃ�� �5
+}-��f��~�� Om��P�Qܕ+F����Pts���G��#��s^�����G'¡MlF�=z�I�j���LOn��<��Fxm�92�����A*"�lA�V�ܨH�8FX�P��t�} +P�
+ou�g�Tg��R޹��?�?I�O�.q}�̞
+�^r&9� G��"*v;�j�~n�+#�x�9���[5(0�xR���������Yz�� ���#Ű��|� ÞҕW:�Rc���ÒdH,={e������>��Z���":c-�ecM�*:�gp������^@`������E@&=��x�RN~�O#$�'Az�*%S�R�\�i4K&㪿�����\t�f8g�Y�E�?��I����fVd��+h�vӵ��P�sJ� ���J�Z�2К��Տؐ:"Yf�0&Zu5v�V�A+��؊R�v\-}<�8?%��Z����/ȋi�QK����8#��}.r�JA
+h�a���I$�F�d1g/�+H�˥GI/��p
+���-!�&fM��7(�E���g$�~��L|Dg���J{UV���-��{.��N�/�^�&��7�I���VnɃ:st��p�]��,iO91L�
+{u%ųn��1\�PI#�T�o����I����5�i!8�+C3��U^Ĩ|�+ f�H ��&Dh��,��Ċ��l��c�2��%un�]VA�j�ՌV�S0�r?�s%�g�bk�0{7�kzL,9
+D���#��AH�Q쌒�#y���P�ia��'�We�U��x�U��R����� �e&`���q����� ���a�CI)��W�3R�|%����$���<��I��2|r\�r7�+�逃�ㄳAH���%%�gn��
+ ��fè63��D�8DV���0"qn�6B˖�k���8�BG��U��]��)!l6�#���eD¥wF8���
+-�Y�e8`�Hn}�2�|��}�hD���~�Q���R ��]�)����JF������۽�)�Tk)t�p����RɕYP"Hot�f8�5�pwQ�G&���M�Z���\�mZ֚�4���|����+`W���d#yET���|+ �t G��hUM�p�:�`��% =��|�u�~M���~X��x�&NMX�'f�yE�f0'z>0��nTμ�9��yZ�m�%��qv�<Ʝ"���vO���͠��=Q�y:��-��8�w=�A��*
+��� �z0BEml�8�^bĩ�9����{y8��
+� WZ9� Չ��o�3*PF�bikk>"��#e%��Q�T�z�(n<]�_+�.:�7�wf��A(���� wj�4��e������縶�����Ds��x6N�Z�� �%�7a�,�4���D+�ܢz�S�~;��34&nq6Eu.'�/7����t,��l�o��TDK����[?9Z ���^�t��0��\�r5�A%ƨ��q�H�]��r�1L�$��Lp�9Ѻ�CF�}/V�v�\�_���O�ښJ�k�֑B�>_ql��R�Q4��K֞��RGP�œ�i�!\�&&d$:�S��Q8o�i�[F2��V�-�k�tS+sإ�p�&�=G�"τ}�P�U���W��n͗���v�9��WB_3|�3؎z������趝�[�� w�E;/֓��\�IW�X��_0}�1��_Y#�v���M� �2�y+tr�}����y���4����kG��΋�̠v��n��H4 /�io�ѱs��B�p�[IX+e筇9�i7ek�5��H�5M�b��K�-'��7�S��>��>�ٟ<N�{}r�?s'�Rb� �('_���+�"���+�ЉP�<�D��4)��-�A�/�Ձ�q�Y��+�a�@�fr���*Ӿ��N�9y���5T%��g(��(,�����tMܩ����Ax�s9@"y��O���{R�3Y�\ x�.�Y+��,��*��H� ��^���lwZh*�n%AW����2��g��j��3+�7}�h�˽X|tQi��˼�KG�a�T�TNi7h&:��5���a����7�S�wse0]����M����E�HD�����ڐ7`���r�:�m�|�%t{jN��9��#�X@�\b�#�����S��� ��x�*�� �Vz6/ 偈��EJ�' �Q�hP"��{�gѡ<����oX߉�e�Q�h�Ȝ��dF� ���i����&��ʹ�G��){��Y��r�U\%�j�BkC�'':�SC�A�A�5.C�*kc�u�ьh:�V
+��{@�tKE4+koET�Ʌ�Q%r� Et~(gi5����O��M�7%���VCGu#o��)�yyՂ�+���S���^-�s`��-�%���Ȗ��B[ ����v7_��f�el�,���[܋�������Ͼ�Q�7��l�3�]��e����]��y>�/S<s�ѯ�$���ʏȥ�����z9�z��tEO^�ʋ|E��� �ͼٻ�D31��e.D�ܹٗ�i���M��c��,��U����HZgf2@�3O'"��i#ň��E��t�6�T�s:��
+�r�Z;���)G��0h�S��F�"w�&o���T)|�Ҥ
+
+�6��]�q��|�F�����&5�,i-%?�-�;r]���Tu�s�!�:6X�u����o^�\��&�v���Ӗ�8��u�_[��+�V ��n�����1����jɺ��՛��
+p���R��,�9j$4��g[x�ɑ{'�Aٻ�ᤌ���7:!�DLd]��Zj<��j�]C�4����z%�+SC.3zi�B��W���{�)���zz:���q��a�A��8�jA\SE��(�/EC�����~ʥ(\A�r���Q\�{j��Ց���<Ut�{�'��
+����U-����6˔ʄ�9ְ�z�c��G�<��<������ ~�%j���Z��.��n�9���������]P���L�-��|��\ƹ��5"#��
+AV\+D')
+�O�yD�W�����r��?�B�R�Lf�|�H�xȕ�������O�o��~������_��/�� /D
+���w?����w�:h���dN?����$�7/����ُ�M�� e-\i�s ��י�j�e�ufs � 8{��DZu:㥈v���M�iJ��7�2"�`��SC�v���� RXg��e���S�t�kͧA
+��{��z! �s�T�W��#țщ��������Ïg]l���{r�΅W�q$�V.��B�V�c$β��M�
+a�n�G�e, �����
+�e��/9�4c
+V
+>� ���j+k�A1>��>K��q��6Ypm�{�\�G�|�5�{���<^��8��� ���~��� ��.������V��(z?�g���(��?�}����3L��?�g�Lώ�x�r$pn/4�զm�j�1]����3F!P#�~��5������
+endstream
+endobj
+266 0 obj
+<</Length 5845/Filter/FlateDecode>>
+stream
+x��]Y�9~�����=�G�},���.�<���o0D��� ��K-�U���y��[G*�Je~��^���{���|��w��ͷ߽}���9�><�C��Eku<�X�����O�z��>��߃>����g߾;�û���x��/է_�����?j�|�+������;?����'�Gm��~y.����w}���;m����Q�}��ܥkK����K�U��+H��:Hb����?��ݳ?=���o_��a��Zc�g'��V���_vt���x���b��\V����3��P�o�C�����s������Ӭ�-���F2���Rr�]����X�f�r�p�Dn�,;}�d�*7��Y[:g���>���q� �E�W�F����r
+g��>��W����Jf4J��E�+����� ��SV��z�>1�`Z#VE7|ϠQ��_͛@�|�#a_�=5�2$F�Ifɍ�r�^��oO�x���;$6�K��ђ@�Ey?�I�����i���=�J�1��G2o�Նҝm�ȏ3cuk�4����;���N��M�#��P��ݹ����͓��`��w��yE�Y�~��~���� ,`)WE�.'h3�v�p�a�Y�)���V��\�!6�e�<#�)��ol�Ĕí,^槪�n�m� �S0�9@���q>p��-
+�!���Cu��=+� N>�4���
+N���� FE����=�^��l��ڪ �<�P�H=\ mgq����i�g���f�&x�pw�8��a@�9A���/~��VI��ʚ�L����H�Ff�O�j@��@\����e�����葾�(n�=P���V%큑"��!X�<�K��3�fz���yI8��$�z�J�jUg@U����s �_��C� ��E���������A�svб�WIdWK��zc�O�>�� �Y+_R�;P�s�Q;��ͺ�Z����KZt��
+uI���4`�['��������9�D7z�՛��{����>񜩧��XeC�6*��5�������f]}�BB@��@��� d�#�����9� �ķ�2jXTFPx��$��:��]�4Ht�*�h|"����* AKq�L5&��]0��w�75:���ǯ�B��`��x��-�]����@��6�}��r.��mGep48��J�m����)AY�i7}+Љ=@�����t ���#�}#k�ʡ���WǙ�Y�&J��^�L �76D�̵��
+�T��!�Md��BV�K�:e�����?���Zg����lf8(H+R:����$t���� ��1c�V^��lQH cQO $Z8�2d ����F�VIk����,�2Dц~�� ��^� 28�8 �wR�� ,'0��d�S�A�Zd����e0K�� ����,��vô0�,�D64(2�#�C&z#~r�(a��O�H�d�� ��l},&��������5� ��6�� 5�1�v�&�C�e�9�L9�(��[ m<K
+�{δ�o��30��l�4���7�@���t�ͳ��V#�N:���.$F�魅�+p>n�n�n!g�L8"!��5��/" "b�r%mG�6�3#^A��u<�~�u5C�-��I���� �. ��ؗ�Sn$����#�����\V�$�@
+��"t.'������~;=�=���p���O��LIv�G}�^2I�o�A[�4p���u��$���N�Q���i���j����-Ey��~Y�7�WU|ML��ª[� �Z♐̈́@� ZJǠ�ܚ
+���=3���$�KN���f��ér1���nLpQ�z(WZ�X��7$��9,�
+=,H�qI���\�N�Y�h�HL[m ���O"ۍa�pf���FNxo=�#���Y
+���
+�{� {m��L��E5�ڥswMz�˙�)���+>
+�7�ȼ�@Ln~�N�� L�-Q���;J~_S�؛ϭQƚ�asX�Yb�m��,��ɭЎ�u0<2y|�l� D��:�ۣ��=R'���ʩTT]�����k�d����5�B���e�J-�ȶ���r��MvD�,fW����[�K�l��䜶g�{���D=uu�t���i��!�ת��=��y�S��1%���TYXi�/T�����VhF�{@ɄB+tH�E�41^��V����n��<����*� �}J %|u`dצy�^�N�� ]�c�J��e�2��=�w0 ���뙹��Q˝ ���<� p�Ͱ�������.@H�6��r��͡���u����k�[{
+��6��^�<38�Ɏ+ &.C����0�bޘї Q��$�3c�W�X5?h9=[V
+�m}Ċ&��۬�
+bn?l�2w��{�d ��o̎�'Z!�/���x%��+B�ސ
+ �;Nh��1 CY�bB�撆�2-ە���n� �) ��6Vd�J��r��A5�*��u�lJ�~��fX�Iu�޼�T-l-7�Ί�N���oT��@�G��� ���Qvn�rC^�����U9٘Ĉ��t|Ǻm���KRŖ�Gv��~����~7� i�Wq������ʺd�5�))�/Zö�&A�L�˸(qm"a8��qc��
+� ���]�;��̲t� �[���f)����v;`����W d�@j�a)w�kj~���u�#G�86,7���գ��4����k����C掂$��H���,��L^c�2��;2K{^�r����z��M��E���e��U��<�����g_y�_B@\��f����1mO ��� ���D�rR�g�(N��zayq���ݪ!�r ^��U�NN��qGF� +�xU&�n�ZY�x�2�c�*��ѡ����[η�:�4q�F��� }�� ���5��N��\0UD�C��R{1vj�s� O����hUI�Xz9��3� �3!�p�#����y������qs�$l�r���"����V���H����s��s�[��
+����[��%�����Oe ��=*����}�E/��6�u E�XJ~�6\���9�n�s��
+��b�-��xU��E��uS����
+��c�hg�JB�aB�
+�&7 ��N�m���jA5�o���@ �(椴��j�^%0 ٱz�� ~b�g��•C�߫�s ��ިc"~8
+�2>@���g�������{�.� ��>��DR�iB*/��%ܮѕ�ag�1��PQa��n?�<����΅�
+endstream
+endobj
+267 0 obj
+<</Length 3869/Filter/FlateDecode>>
+stream
+x��\Y��~ׯ�${ǫ��fS�C�!_��-ʃW��A���G�3M6Y�9�����f���"��*����m��Ww����ۍ|��ͷo��w�6r#7�j�0^k�7!ha�W�����(7����wO޼����ǻ�QR������������ �� �?|��[�o=?_� �[��1�QӾ�7��|���ؒ
+��g���^�~�~��@���yn������FG�' ^5����B�u� �i��oo�,����>��:������@����;����n����خ&f�7ѯS{`��П �Yۃ�uh�e6w����m�h�ʯ�H-w����OO�{��/O����]���j�&a\t��Z>H����~9�;�_ �s^<}��o�^�c�m�i&���T1� �j{kt� �� �5ϫA,*)��J�9�N�^�����+���0YH^9(ͮ�9������ۧ������S��T+-�t>�Uo&a[ʝ�尽�AHg�h��~��M=S�g����z��?��{0qXy������=���ӖPW���� uc�tH��2�I9]�D׵���c�2k��紩�Ҩ�0iYa�e���c�i�
+�R�H����nS�ˤD��{0D��K��Ȓ��o�k,���^��5�U�.��R��9cCs�}�/�[�ːN��Y���}�w��#���z�۶�sx�v�w!����E,�������e�[��QLZy}sN�6�<o��_4�H1�~n���I�'1E.1�� ���fW�j���R�O+�����9�/���\�MA,�a�Y�v%�Cj,�b�w
+�z
+�F��Z�
+��S�� �Ѵ��I ��'u�(�++��ݐO���>�9g�S!���Dc�Bay�:ݥZ
+7����-�9ƾ���I�`�(
+9���-��gZ��5��j�x���B6fBC!�Y�^��[��hJ�WDC�=ͺ���
+ ��e��@%[tU�w�!�v`�]}���1p�4|��{;��<=���{�K�k sMNH%�� �X��B;eԡ2�h3��m�ָ�� ���V7��?�m[ �H� P�����,7O���^�u���CᇢVlg���0��vD�j�Hv������{:v�i� �9�Ŵ^�4N G� "u*�pZO#�P;����jT�`�i+|��Y{^��ƴ
+¼�"��CS�2���C����J")��Llw��� ����`��uSQ�ڢ���D�����1�o"�ey.9rCdhm�{�v=�ѯ�ꥅ�B�ۗ��2�:��#I��7�:A#�zo �x������� �.0wkz,���Wk�H2��5S���� �s�F�U�'�XP����m��FP�pI-�KYv�T]�Əc3B�jF��y�_Pz S6b s���5� -!e.(y���H�R�.���.*��؟k�K����^�P
+�: � o��ւ��t��r���J�
+�H`�y�ByH
+Ւ5{d�C�B����L(��^���_��d�E�׶t"N�݄xU�V�
+ &����&p;��H2��(�C5�`���ݐw�,�
+�֑^,�Z�c�u. @�d�R_�:Z.<_>
+J��gF��;w�#�\��
+<"-1i~��v�"�z�S��}����mp�ZF�\ q`ȇ68)���j�Hq}?^Ӆ��_�-h��JP�O@�'��n�1����T��g� aV�A�����K{p3l���u��:)����[�my�]�cדJ[ �ѡ�CĬ�����B6���S[S<��s�Ø�I�L~E���X�c�t�δ�!�&t8ű(�6'�����+I
+�� c
+�$������
+endstream
+endobj
+268 0 obj
+<</Length 258/N 1/Range[0 1]/Filter/FlateDecode>>
+stream
+x�u��J�PFO��U�v�D�D@�`]\�
+F��Mk�I���R�B|��&إ�n�ऋ��(�H�r�Tų��9�`�� ըa�&�ʺ���:�l
+�3Ŭ�*ުn�
+̵�8+�/<����a�Rkp��ش�A�mW�Q�Ih�� �%��l��E5VW~��d���X��)�ϴ�:׺��b���k#�&�_¤ 3�0�� �I�
+endstream
+endobj
+269 0 obj
+<</Length 314/N 3/Range[0 1 0 1 0 1]/Filter/FlateDecode>>
+stream
+x�}��Kq�?�U�X 94%MQKS��N�#Ԧ��Q�v��B�ˡ�h�Fk ������!
+��ՠ����ZP��~xx^ޗ��Q�����x"��^P��� c�!��H0
+ ��0l+��ߣ�y7���;�׫���;Q�?V.�_�tF��3L�E�K�)y ��z�80e�IP���k��T�/%[�p
+endstream
+endobj
+270 0 obj
+<</Creator(Typst 0.15.0)/ModDate(D:20260706083532-03'00)/CreationDate(D:20260706083532-03'00)>>
+endobj
+271 0 obj
+<</Length 996/Type/Metadata/Subtype/XML>>
+stream
+<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?><x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="xmp-writer"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/" xmlns:xmpTPg="http://ns.adobe.com/xap/1.0/t/pg/" xmlns:pdf="http://ns.adobe.com/pdf/1.3/" ><xmp:CreatorTool>Typst 0.15.0</xmp:CreatorTool><dc:language><rdf:Bag><rdf:li>en</rdf:li></rdf:Bag></dc:language><xmp:ModifyDate>2026-07-06T08:35:32-03:00</xmp:ModifyDate><xmp:CreateDate>2026-07-06T08:35:32-03:00</xmp:CreateDate><xmpTPg:NPages>4</xmpTPg:NPages><dc:format>application/pdf</dc:format><xmpMM:InstanceID>ryCwVs21NRYeVZORaVGmEw==</xmpMM:InstanceID><xmpMM:DocumentID>ryCwVs21NRYeVZORaVGmEw==</xmpMM:DocumentID><xmpMM:RenditionClass>proof</xmpMM:RenditionClass><pdf:PDFVersion>1.7</pdf:PDFVersion></rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end="r"?>
+endstream
+endobj
+272 0 obj
+<</Type/Catalog/Pages 1 0 R/Metadata 271 0 R/Lang(en)/StructTreeRoot 12 0 R/MarkInfo<</Marked true/Suspects false>>/ViewerPreferences<</Direction/L2R>>/Outlines 11 0 R>>
+endobj
+xref
+0 273
+0000000000 65535 f
+0000000016 00000 n
+0000000093 00000 n
+0000000169 00000 n
+0000000255 00000 n
+0000000347 00000 n
+0000000429 00000 n
+0000000544 00000 n
+0000000626 00000 n
+0000000730 00000 n
+0000000844 00000 n
+0000000980 00000 n
+0000001047 00000 n
+0000001241 00000 n
+0000002267 00000 n
+0000004312 00000 n
+0000005154 00000 n
+0000005700 00000 n
+0000005771 00000 n
+0000005832 00000 n
+0000005899 00000 n
+0000005981 00000 n
+0000006054 00000 n
+0000006125 00000 n
+0000006207 00000 n
+0000006279 00000 n
+0000006363 00000 n
+0000006435 00000 n
+0000006553 00000 n
+0000006623 00000 n
+0000006693 00000 n
+0000006838 00000 n
+0000006906 00000 n
+0000006978 00000 n
+0000007096 00000 n
+0000007227 00000 n
+0000007295 00000 n
+0000007423 00000 n
+0000007495 00000 n
+0000007565 00000 n
+0000007635 00000 n
+0000007745 00000 n
+0000007813 00000 n
+0000007885 00000 n
+0000007955 00000 n
+0000008025 00000 n
+0000008095 00000 n
+0000008165 00000 n
+0000008235 00000 n
+0000008305 00000 n
+0000008375 00000 n
+0000008526 00000 n
+0000008598 00000 n
+0000008670 00000 n
+0000008767 00000 n
+0000008839 00000 n
+0000008909 00000 n
+0000008979 00000 n
+0000009049 00000 n
+0000009119 00000 n
+0000009189 00000 n
+0000009259 00000 n
+0000009329 00000 n
+0000009399 00000 n
+0000009469 00000 n
+0000009640 00000 n
+0000009710 00000 n
+0000009788 00000 n
+0000009929 00000 n
+0000009997 00000 n
+0000010129 00000 n
+0000010253 00000 n
+0000010371 00000 n
+0000010440 00000 n
+0000010511 00000 n
+0000010580 00000 n
+0000010649 00000 n
+0000010757 00000 n
+0000010845 00000 n
+0000010913 00000 n
+0000010981 00000 n
+0000011067 00000 n
+0000011153 00000 n
+0000011233 00000 n
+0000011319 00000 n
+0000011405 00000 n
+0000011485 00000 n
+0000011571 00000 n
+0000011660 00000 n
+0000011715 00000 n
+0000011783 00000 n
+0000011869 00000 n
+0000012009 00000 n
+0000012094 00000 n
+0000012235 00000 n
+0000012344 00000 n
+0000012485 00000 n
+0000012578 00000 n
+0000012679 00000 n
+0000012734 00000 n
+0000012907 00000 n
+0000013089 00000 n
+0000013215 00000 n
+0000013469 00000 n
+0000013542 00000 n
+0000013615 00000 n
+0000013717 00000 n
+0000013797 00000 n
+0000013870 00000 n
+0000013947 00000 n
+0000014049 00000 n
+0000014124 00000 n
+0000014245 00000 n
+0000014320 00000 n
+0000014441 00000 n
+0000014555 00000 n
+0000014635 00000 n
+0000014763 00000 n
+0000014891 00000 n
+0000015019 00000 n
+0000015099 00000 n
+0000015227 00000 n
+0000015355 00000 n
+0000015483 00000 n
+0000015563 00000 n
+0000015691 00000 n
+0000015819 00000 n
+0000015947 00000 n
+0000016027 00000 n
+0000016155 00000 n
+0000016283 00000 n
+0000016411 00000 n
+0000016491 00000 n
+0000016566 00000 n
+0000016687 00000 n
+0000016762 00000 n
+0000016883 00000 n
+0000017015 00000 n
+0000017095 00000 n
+0000017223 00000 n
+0000017351 00000 n
+0000017479 00000 n
+0000017559 00000 n
+0000017730 00000 n
+0000017841 00000 n
+0000017912 00000 n
+0000017983 00000 n
+0000018056 00000 n
+0000018128 00000 n
+0000018200 00000 n
+0000018272 00000 n
+0000018344 00000 n
+0000018416 00000 n
+0000018585 00000 n
+0000018657 00000 n
+0000018749 00000 n
+0000018821 00000 n
+0000018900 00000 n
+0000018972 00000 n
+0000019044 00000 n
+0000019116 00000 n
+0000019188 00000 n
+0000019260 00000 n
+0000019332 00000 n
+0000019404 00000 n
+0000019562 00000 n
+0000019663 00000 n
+0000019737 00000 n
+0000019809 00000 n
+0000019881 00000 n
+0000019992 00000 n
+0000020102 00000 n
+0000020177 00000 n
+0000020260 00000 n
+0000020334 00000 n
+0000020406 00000 n
+0000020478 00000 n
+0000020550 00000 n
+0000020622 00000 n
+0000020767 00000 n
+0000020841 00000 n
+0000020913 00000 n
+0000020985 00000 n
+0000021057 00000 n
+0000021130 00000 n
+0000021330 00000 n
+0000021403 00000 n
+0000021474 00000 n
+0000021545 00000 n
+0000021616 00000 n
+0000021688 00000 n
+0000021760 00000 n
+0000021832 00000 n
+0000021904 00000 n
+0000021976 00000 n
+0000022048 00000 n
+0000022235 00000 n
+0000022309 00000 n
+0000022381 00000 n
+0000022453 00000 n
+0000022525 00000 n
+0000022603 00000 n
+0000022724 00000 n
+0000022798 00000 n
+0000022870 00000 n
+0000022973 00000 n
+0000023047 00000 n
+0000023127 00000 n
+0000023201 00000 n
+0000023287 00000 n
+0000023359 00000 n
+0000023519 00000 n
+0000023591 00000 n
+0000023665 00000 n
+0000023756 00000 n
+0000024135 00000 n
+0000024172 00000 n
+0000024209 00000 n
+0000024262 00000 n
+0000024314 00000 n
+0000024366 00000 n
+0000024419 00000 n
+0000024471 00000 n
+0000024523 00000 n
+0000024576 00000 n
+0000024629 00000 n
+0000024682 00000 n
+0000024816 00000 n
+0000024950 00000 n
+0000025084 00000 n
+0000025218 00000 n
+0000025367 00000 n
+0000025944 00000 n
+0000026152 00000 n
+0000026304 00000 n
+0000027149 00000 n
+0000027360 00000 n
+0000027500 00000 n
+0000027716 00000 n
+0000027955 00000 n
+0000028115 00000 n
+0000028321 00000 n
+0000028534 00000 n
+0000028679 00000 n
+0000028899 00000 n
+0000029144 00000 n
+0000029272 00000 n
+0000029400 00000 n
+0000029528 00000 n
+0000029656 00000 n
+0000029739 00000 n
+0000030381 00000 n
+0000034852 00000 n
+0000034934 00000 n
+0000035714 00000 n
+0000046346 00000 n
+0000046428 00000 n
+0000047197 00000 n
+0000059607 00000 n
+0000059684 00000 n
+0000060115 00000 n
+0000060587 00000 n
+0000060666 00000 n
+0000061134 00000 n
+0000065576 00000 n
+0000071392 00000 n
+0000077480 00000 n
+0000083396 00000 n
+0000087336 00000 n
+0000087679 00000 n
+0000088086 00000 n
+0000088199 00000 n
+0000089272 00000 n
+trailer
+<</Size 273/Root 272 0 R/Info 270 0 R/ID[(ryCwVs21NRYeVZORaVGmEw==)(ryCwVs21NRYeVZORaVGmEw==)]>>
+startxref
+89459
+%%EOF \ No newline at end of file
diff --git a/docs/design.typ b/docs/design.typ
new file mode 100644
index 00000000..382063cd
--- /dev/null
+++ b/docs/design.typ
@@ -0,0 +1,246 @@
+#set page(margin: 2.2cm)
+#set text(font: "New Computer Modern", size: 10.5pt)
+#set par(justify: true)
+#show heading: set block(above: 1.4em, below: 0.8em)
+
+#align(center)[
+ #text(17pt)[*Pardes: A Text Environment*]
+
+ #text(11pt)[design for the rewrite — 2026-07-05]
+]
+
+= What it is
+
+Pardes is a text environment in the acme tradition: columns of panes, each pane a
+tag line plus a body; the body is a live terminal, a file, or an image. The mouse
+carries meaning — left selects, middle executes, right looks. Everything on screen
+is text and all text is equally alive, whether the shell printed it or the user
+typed it.
+
+It runs in three places: the terminal (libvaxis), a native SDL3 window (the
+steamdeck), and the browser (wasm + SDL3 + emscripten). The rewrite exists because
+the prototype grew three parallel implementations of one program. The rewrite has
+exactly one program and three thin shells.
+
+= The shape
+
+Pardes is a *library*, in the way ghostty-vt is a library: you feed it bytes and
+events, and you read state out of it. Every platform owns its own event loop and
+its own renderer; the core owns everything the user would recognize as pardes.
+
+#table(
+ columns: (auto, 1fr),
+ stroke: 0.4pt,
+ [*core*], [layout, panes, modes, selection, click semantics, themes, text of the
+ UI. Pure state machine: `update(event)` mutates, `surface()` describes. No
+ syscalls, no rendering, no event loop.],
+ [*shell*], [one file per platform. 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).],
+)
+
+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 and images from their bytes). The web shell embeds
+a dump and leaves `spawn` unanswered. Same core, no viewer fork.
+
+The boundary is two data types, both plain values:
+
+*Event* (in): `key`, `text`, `mouse` (press/release/motion; button left, middle,
+right; cell position), `wheel`, `pinch`, `resize`, and `output` (bytes a pty
+produced, tagged with the pane id). A touch tap is a left press; a joystick
+cursor is a motion plus buttons; the shells do this translation and nothing else
+with input. One event vocabulary, three dialects translated at the door.
+
+*Surface* (out): a grid of cells — grapheme, fg, bg, attrs — plus a cursor and
+per-pane rectangles. This is the *canonical interface*: it is literally what the
+tty shell hands to vaxis, cell by cell. The SDL shells rasterize the same grid
+through a glyph atlas. If it cannot be expressed in the surface, it does not
+exist in pardes. (Pixel images ride along as an attachment per pane: the SDL
+shells blit RGBA, the tty shell falls back to the petscii matcher, kitty
+graphics when available.)
+
+*Effect* (out, queued): `spawn{pane, cwd}`, `write{pane, bytes}`,
+`resize_pty{pane, cols, rows}`, `open_link{url}`, `read_file{path}`,
+`set_clipboard`, `bell`, `quit`. The core never performs IO; it asks. 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.
+
+Platform divergence inside the core is a comptime tag, used the way the stdlib
+uses `os.tag`:
+
+```zig
+pub const Platform = enum { tty, gui, web };
+// in look.zig:
+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.
+
+= Data structures
+
+The whole state is one struct, sized at init, no hidden allocation after:
+
+```zig
+Pardes
+ cols: []Col // col: weight + pane ids, top to bottom
+ panes: []Pane // slot array; id = index
+ active: Pane.Id
+ drag: Drag // none | select | move | border | scrollbar
+ theme: u2 // index into themes
+ colors_on: bool
+ surface: Surface // rebuilt by surface(), arena-backed
+ effects: Fifo(Effect)
+
+Pane
+ tag: TagLine // live prefix (mode, cwd/path) + editable tail
+ kind: union { term: Term, file: File, image: Image }
+ vweight: f32
+ mode: enum { normal, insert, tty } // helix-modal; tty = raw to the pty
+ cursor: absolute body position // rides the scrollback, not the screen
+ sel: [3]Sel + line/char modal sels // per-button block sels, msel, vsel
+ edits: Splice // typed runs anchored to absolute rows
+ undo: edit snapshots // unified undo across term edits and file content
+
+Term = ghostty-vt Terminal + its stream (bytes in via Event.output)
+File = path + bytes + line index + Syn (tree-sitter highlight bytes)
+Image = decoded RGBA + petscii grid cache
+```
+
+Layout is arithmetic, not objects: columns are weights over the width, panes are
+weights over the column. `splitBelow` shrinks only the source pane;
+`absorbVWeight` gives a dying pane's weight to one sibling; an emptied column
+hands its width to a neighbor. Minimal motion is the invariant: an operation on
+one pane may not move panes it does not touch.
+
+= Style
+
+TigerStyle, plus house rules proven in the prototype: imperative and flat; one
+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 is lines of code; the number
+only goes down. As built, against the prototype's 12,072 lines of Zig:
+
+#table(
+ columns: (auto, auto, 1fr),
+ stroke: 0.4pt,
+ [*module*], [*lines*], [],
+ [core (pardes.zig + look.zig + syntax + image + dump)], [4,289], [all semantics, all platforms],
+ [modal.zig + petscii.zig (pure, unit-testable)], [1,410], [carried over, unchanged],
+ [shells (tty.zig; gui.zig = sdl native + web)], [2,768], [translate + rasterize only],
+ [main.zig + build.zig], [484], [three targets, codegen for queries],
+ [*application total*], [*7,626*], [vs 12,072 — three full backends instead of one and a half],
+ [snapshot harness (snapshot.zig + e2e_harness.zig)], [831], [the parity oracle, kept],
+)
+
+= Testing: the old program is the oracle
+
+Before the rewrite compiled, the prototype got a harness (`snapshot.zig`, the
+`zig build snap` step): it forks either binary in a pty, feeds it an *event
+script* (one line per input: keys, SGR mouse, resizes, sync points), and
+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 cover the
+checklist in Appendix A; 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
+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
+the same semantics).
+
+Shell correctness (that SDL draws the grid it was given, that vaxis diffs
+correctly) is out of snapshot scope and covered by each shell's single smoke
+test.
+
+= Build
+
+`zig build` produces three statically linked artifacts: `pardes` (vaxis),
+`pardes-gui` (SDL3), `pardes.wasm` + shell page (emscripten). Same dependency
+set as the prototype — ghostty, vaxis, uucode (shared config), zstbi, SDL
+(castholm, lazy), zig-tree-sitter + grammars — all via `zig fetch`, wired in
+`build.zig`. Codegen during build, consumed by comptime: tree-sitter
+`highlights.scm` → options module; tutor text → embedded pane content. Debug
+builds are incremental for the seconds-loop; release builds are the product.
+
+= What is deliberately absent
+
+No render abstraction over the shells (the surface *is* the abstraction). No
+config files. No plugin system. No async runtime in the core — the shells may
+thread, the core is single-threaded by construction. No 9P yet — but the
+library boundary is exactly where acme put the file server, and a fourth shell
+could serve `Surface` and `Event` over 9P without touching the core.
+
+= Appendix A: feature parity checklist
+
+From the prototype survey; every line is covered by at least one of the
+eighteen event scripts in `test/snapshots/` (boot, tty, edit, scroll, modal,
+look-file, look-dir, exec, tag, theme, tutor, windowops, dump, load, ttyonly,
+syntax, fileedit, images).
+
+*Layout.* Columns by weight (≤6), panes by vweight (≤16); 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
+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
+pane to new column; Ctrl-w h/j/k/l directional focus; `--tty` single-pane mode.
+
+*Mouse.* Left: select (block, stays highlighted after drag, pins cursor,
+enters normal), click clears, tag-row click enters tag edit, scrollbar
+click scrolls up-to-row (right button: down), border/move drags. Middle:
+execute — no-drag expands to file-ish word (alnum `.-+/:@_~`); builtin or send
+to shell; does not focus. Right: look — peel `:NNN`, resolve against pane cwd
+(`/proc/pid/cwd`) or file's dir; dir → shell + `ls` in source column (dedup by
+cwd), file → file pane at line (dedup by path, first doc opens left column and
+may evict a lone pristine shell; later docs split below the existing doc),
+image ext → image pane. Middle+left chord: kept left selection appended as
+trailing CLI argument. Wheel: scroll hovered pane, batched.
+
+*Modes.* normal: helix motions (`h j k l w b e W B E 0 $ ^ gg ge gh gl G`,
+`Ctrl-d/u/f/b`, `zt zz zb`), insert entries (`i a I A o O`), `v`/`x`
+selections, `d c y p u U`, Enter=look Tab=execute at cursor. insert:
+click-and-type; terminals get splice runs (shift right, never overwrite;
+absolute-row anchored), files get real edits. tty: raw pty forwarding
+(Ctrl-key toggle, default Ctrl-b, `--tty-toggle`), mouse still usable,
+promptClickMove on entry, prompts visible (hidden in the other modes via
+OSC 133). `p` pastes from system clipboard (OSC 52 request/reply); yank
+mirrors out via OSC 52.
+
+*Tag.* Live prefix (mode indicator, cwd or path) + editable tail with the
+full modal editor; defaults `Del` / `Save Del`; image tags are toggle words
+(`Petscii C64|Term Ascii`). Topbar: `Kill Newcol Tutor Debug Colors NextColor
+Dump` — execute-only (left click inert).
+
+*Panes.* Terminal: ghostty-vt, 16 MiB scrollback, OSC 133 prompt semantics,
+OSC 7 cwd, DSR/DA/kitty-query replies (write_pty + device_attributes — the
+nushell/helix regression), greeting `ls`, auto-follow output unless
+navigating. File: line-number gutter (fixed width), tree-sitter highlights
+(c/cpp/zig minimal tier; 25 grammars full tier; re-highlight on edit,
+visible-range first), Save, open-at-line, undo/redo. Image: zstbi decode,
+kitty graphics when available, petscii matcher fallback (C64/terminal
+palettes, ascii glyph set toggle). Tutor: embedded text as file pane.
+
+*Chrome.* Themes dark + acme-light (terminal-native vs paletted); Colors
+toggles all recolor passes; Debug stats overlay; Dump writes state ZON.
+
+*Web (replay viewer parity).* Embedded dump; focus, border/move drags, wheel,
+scrollbar, Colors/NextColor, pinch + touch (two-finger = middle), render
+scale, link-look opens a new tab (new — the prototype has no web link
+handling). SDL shells: stb_truetype atlas over the SDL GPU API (SPIR-V on
+native, GLES3 on web), letterboxed web scaling, two-finger drag = wheel, tap
+= middle click, pinch, touch debug overlay, `PARDES_TEST` PPM capture and
+`PARDES_TEST_GRID` text-grid capture. Joystick cursor for the steamdeck is
+*new* scope — the prototype ships no gamepad input.
diff --git a/ideas.typ b/docs/ideas.typ
index f4faf810..f4faf810 100644
--- a/ideas.typ
+++ b/docs/ideas.typ
diff --git a/e2e_harness_sdl.zig b/e2e_harness_sdl.zig
deleted file mode 100644
index cac25473..00000000
--- a/e2e_harness_sdl.zig
+++ /dev/null
@@ -1,332 +0,0 @@
-// E2E harness for the SDL3 GPU backend's rendered-image test mode.
-//
-// The child app runs the real SDL/GPU renderer. Input is still delivered through
-// a pty so the same terminal escape sequences exercise the same app input path,
-// but rendered frames are captured by the app as PPM image files in a temp
-// directory. Assertions inspect pixels, not the retained cell grid.
-const std = @import("std");
-const posix = std.posix;
-const linux = std.os.linux;
-
-extern "c" fn forkpty(
- amaster: *c_int,
- name: ?[*:0]u8,
- termp: ?*const anyopaque,
- winp: ?*const posix.winsize,
-) c_int;
-extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*: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;
-
-fn nowMs() i64 {
- var ts: linux.timespec = undefined;
- _ = linux.clock_gettime(linux.CLOCK.MONOTONIC, &ts);
- return ts.sec * 1000 + @divFloor(@as(i64, ts.nsec), 1_000_000);
-}
-
-pub const Image = struct {
- width: u32,
- height: u32,
- pixels: []u8, // RGB, row-major
-
- pub fn deinit(self: *Image, gpa: std.mem.Allocator) void {
- gpa.free(self.pixels);
- self.* = .{ .width = 0, .height = 0, .pixels = &.{} };
- }
-
- pub fn hash(self: Image) u64 {
- return std.hash.Wyhash.hash(0, self.pixels);
- }
-
- pub fn brightPixels(self: Image, y0_in: u32, y1_in: u32, threshold: u8) usize {
- const y0 = @min(y0_in, self.height);
- const y1 = @min(y1_in, self.height);
- var n: usize = 0;
- var y = y0;
- while (y < y1) : (y += 1) {
- var x: u32 = 0;
- while (x < self.width) : (x += 1) {
- const i = (@as(usize, y) * @as(usize, self.width) + @as(usize, x)) * 3;
- const lum = (@as(u16, self.pixels[i]) + @as(u16, self.pixels[i + 1]) + @as(u16, self.pixels[i + 2])) / 3;
- if (lum >= threshold) n += 1;
- }
- }
- return n;
- }
-
- pub fn changedPixels(self: Image, other: Image, threshold: u8) usize {
- if (self.width != other.width or self.height != other.height) return std.math.maxInt(usize);
- var n: usize = 0;
- var i: usize = 0;
- while (i < self.pixels.len) : (i += 3) {
- const dr = absDiff(self.pixels[i], other.pixels[i]);
- const dg = absDiff(self.pixels[i + 1], other.pixels[i + 1]);
- const db = absDiff(self.pixels[i + 2], other.pixels[i + 2]);
- if (dr + dg + db >= threshold) n += 1;
- }
- return n;
- }
-};
-
-pub const SdlHarness = struct {
- gpa: std.mem.Allocator,
- io: std.Io,
- master: c_int,
- pid: posix.pid_t,
- rows: u16,
- cols: u16,
- trace: bool = false,
- capture_dir: [:0]u8,
- latest_path: []u8,
- image: ?Image = null,
- rbuf: std.ArrayList(u8) = .empty,
-
- pub fn init(gpa: std.mem.Allocator, io: std.Io, exe: [*:0]const u8, rows: u16, cols: u16) !SdlHarness {
- return initArgs(gpa, io, exe, rows, cols, null);
- }
-
- pub fn initArgs(gpa: std.mem.Allocator, io: std.Io, exe: [*:0]const u8, rows: u16, cols: u16, arg: ?[*:0]const u8) !SdlHarness {
- const base = ".zig-cache/pardes-sdl-e2e";
- try std.Io.Dir.cwd().createDirPath(io, base);
- const capture_dir_plain = try std.fmt.allocPrint(gpa, "{s}/{d}-{d}", .{ base, linux.getpid(), nowMs() });
- defer gpa.free(capture_dir_plain);
- const capture_dir = try gpa.dupeZ(u8, capture_dir_plain);
- errdefer gpa.free(capture_dir);
- try std.Io.Dir.cwd().createDirPath(io, capture_dir);
- const latest_path = try std.fmt.allocPrint(gpa, "{s}/latest.ppm", .{capture_dir});
- errdefer gpa.free(latest_path);
-
- var master: c_int = undefined;
- const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
- const pid = forkpty(&master, null, null, &ws);
- if (pid == 0) {
- _ = setenv("PARDES_TEST", "1", 1);
- _ = setenv("PARDES_TEST_CAPTURE_DIR", capture_dir.ptr, 1);
- var rows_buf: [16]u8 = undefined;
- var cols_buf: [16]u8 = undefined;
- const rows_z = std.fmt.bufPrintZ(&rows_buf, "{d}", .{rows}) catch unreachable;
- const cols_z = std.fmt.bufPrintZ(&cols_buf, "{d}", .{cols}) catch unreachable;
- _ = setenv("PARDES_TEST_ROWS", rows_z.ptr, 1);
- _ = setenv("PARDES_TEST_COLS", cols_z.ptr, 1);
- const argv: [3:null]?[*:0]const u8 = .{ exe, arg, null };
- _ = execvp(exe, &argv);
- _ = _exit(127);
- }
- return .{
- .gpa = gpa,
- .io = io,
- .master = master,
- .pid = pid,
- .rows = rows,
- .cols = cols,
- .capture_dir = capture_dir,
- .latest_path = latest_path,
- };
- }
-
- pub fn deinit(self: *SdlHarness) void {
- posix.kill(self.pid, posix.SIG.KILL) catch {};
- _ = linux.close(self.master);
- if (self.image) |*img| img.deinit(self.gpa);
- self.rbuf.deinit(self.gpa);
- self.gpa.free(self.latest_path);
- self.gpa.free(self.capture_dir);
- }
-
- pub fn send(self: *SdlHarness, bytes: []const u8) !void {
- var off: usize = 0;
- while (off < bytes.len) {
- const rc = linux.write(self.master, bytes[off..].ptr, bytes.len - off);
- const n: isize = @bitCast(rc);
- if (n < 0) {
- if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue;
- return error.WriteFailed;
- }
- off += @intCast(n);
- }
- }
-
- pub fn pump(self: *SdlHarness, ms: i64) !void {
- const deadline = nowMs() + ms;
- var buf: [4096]u8 = undefined;
- while (nowMs() < deadline) {
- var fds = [_]posix.pollfd{.{ .fd = self.master, .events = posix.POLL.IN, .revents = 0 }};
- _ = posix.poll(&fds, 50) catch {};
- if ((fds[0].revents & posix.POLL.IN) != 0) {
- const n = posix.read(self.master, &buf) catch break;
- if (n == 0) break;
- self.rbuf.appendSlice(self.gpa, buf[0..n]) catch {};
- }
- self.loadLatest() catch {};
- }
- self.loadLatest() catch {};
- if (self.trace) self.traceImage("pump");
- }
-
- pub fn resize(self: *SdlHarness, rows: u16, cols: u16) !void {
- self.rows = rows;
- self.cols = cols;
- const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
- _ = posix.system.ioctl(self.master, posix.T.IOCSWINSZ, @intFromPtr(&ws));
- }
-
- pub fn waitForImage(self: *SdlHarness, ms: i64) !bool {
- if (self.image != null) return true;
- const deadline = nowMs() + ms;
- while (nowMs() < deadline) {
- try self.pump(100);
- if (self.image != null) return true;
- }
- return false;
- }
-
- pub fn waitForChange(self: *SdlHarness, old_hash: u64, ms: i64) !bool {
- const deadline = nowMs() + ms;
- while (nowMs() < deadline) {
- try self.pump(100);
- if (self.image) |img| if (img.hash() != old_hash) return true;
- }
- return false;
- }
-
- pub fn imageHash(self: *SdlHarness) u64 {
- return if (self.image) |img| img.hash() else 0;
- }
-
- pub fn imageCopy(self: *SdlHarness) !Image {
- const img = self.image orelse return error.NoImage;
- return .{
- .width = img.width,
- .height = img.height,
- .pixels = try self.gpa.dupe(u8, img.pixels),
- };
- }
-
- pub fn cellWidth(self: *SdlHarness) u32 {
- const img = self.image orelse return 0;
- return img.width / @as(u32, self.cols);
- }
-
- pub fn cellHeight(self: *SdlHarness) u32 {
- const img = self.image orelse return 0;
- return img.height / @as(u32, self.rows);
- }
-
- pub fn expectImage(self: *SdlHarness, msg: []const u8) !void {
- if (self.image == null) {
- self.dump(msg);
- return error.ExpectFailed;
- }
- }
-
- pub fn expectWaitForImage(self: *SdlHarness, ms: i64, msg: []const u8) !void {
- if (!try self.waitForImage(ms)) {
- self.dump(msg);
- return error.ExpectFailed;
- }
- }
-
- pub fn expectWaitForChange(self: *SdlHarness, old_hash: u64, ms: i64, msg: []const u8) !void {
- if (!try self.waitForChange(old_hash, ms)) {
- self.dump(msg);
- return error.ExpectFailed;
- }
- }
-
- pub fn expectGridPixels(self: *SdlHarness, msg: []const u8) !void {
- const img = self.image orelse {
- self.dump(msg);
- return error.ExpectFailed;
- };
- if (self.cols == 0 or self.rows == 0 or img.width % self.cols != 0 or img.height % self.rows != 0) {
- self.dump(msg);
- return error.ExpectFailed;
- }
- if (img.width / self.cols < 6 or img.height / self.rows < 10) {
- self.dump(msg);
- return error.ExpectFailed;
- }
- }
-
- pub fn expectBrightRows(self: *SdlHarness, row0: u16, row1: u16, min_bright: usize, msg: []const u8) !void {
- const img = self.image orelse {
- self.dump(msg);
- return error.ExpectFailed;
- };
- const ch = self.cellHeight();
- const y0 = @as(u32, row0) * ch;
- const y1 = @as(u32, row1) * ch;
- if (img.brightPixels(y0, y1, 60) < min_bright) {
- self.dump(msg);
- return error.ExpectFailed;
- }
- }
-
- fn loadLatest(self: *SdlHarness) !void {
- const data = std.Io.Dir.cwd().readFileAlloc(self.io, self.latest_path, self.gpa, .limited(128 * 1024 * 1024)) catch |err| switch (err) {
- error.FileNotFound => return,
- else => return err,
- };
- defer self.gpa.free(data);
- const img = try parsePpm(self.gpa, data);
- if (self.image) |*old| old.deinit(self.gpa);
- self.image = img;
- }
-
- pub fn dump(self: *SdlHarness, msg: []const u8) void {
- if (self.image) |img| {
- std.debug.print("E2E FAIL: {s}\nimage {d}x{d} hash={x} path={s}\nraw tail:\n{s}\n", .{
- msg, img.width, img.height, img.hash(), self.latest_path, rawTail(self.rbuf.items),
- });
- } else {
- std.debug.print("E2E FAIL: {s}\n(no captured image yet) path={s}\nraw tail:\n{s}\n", .{
- msg, self.latest_path, rawTail(self.rbuf.items),
- });
- }
- }
-
- pub fn traceImage(self: *SdlHarness, tag: []const u8) void {
- if (self.image) |img| {
- std.debug.print("[trace {s}] image {d}x{d} hash={x} path={s}\n", .{
- tag, img.width, img.height, img.hash(), self.latest_path,
- });
- } else {
- std.debug.print("[trace {s}] no image yet path={s}\n", .{ tag, self.latest_path });
- }
- }
-};
-
-fn parsePpm(gpa: std.mem.Allocator, data: []const u8) !Image {
- if (!std.mem.startsWith(u8, data, "P6")) return error.BadPpm;
- var idx: usize = 2;
- const w = try parsePpmInt(data, &idx);
- const h = try parsePpmInt(data, &idx);
- const max = try parsePpmInt(data, &idx);
- if (max != 255) return error.BadPpm;
- if (idx >= data.len or !isWs(data[idx])) return error.BadPpm;
- idx += 1;
- const len = @as(usize, w) * @as(usize, h) * 3;
- if (data.len < idx + len) return error.BadPpm;
- return .{ .width = w, .height = h, .pixels = try gpa.dupe(u8, data[idx .. idx + len]) };
-}
-
-fn parsePpmInt(data: []const u8, idx: *usize) !u32 {
- while (idx.* < data.len and isWs(data[idx.*])) idx.* += 1;
- const start = idx.*;
- while (idx.* < data.len and data[idx.*] >= '0' and data[idx.*] <= '9') idx.* += 1;
- if (idx.* == start) return error.BadPpm;
- return std.fmt.parseInt(u32, data[start..idx.*], 10);
-}
-
-fn isWs(b: u8) bool {
- return b == ' ' or b == '\n' or b == '\r' or b == '\t';
-}
-
-fn absDiff(a: u8, b: u8) u16 {
- return if (a >= b) @as(u16, a - b) else @as(u16, b - a);
-}
-
-fn rawTail(raw: []const u8) []const u8 {
- const max = 4096;
- return if (raw.len > max) raw[raw.len - max ..] else raw;
-}
diff --git a/image.zig b/image.zig
deleted file mode 100644
index 79e7775f..00000000
--- a/image.zig
+++ /dev/null
@@ -1,199 +0,0 @@
-// Image-viewer panes — the kitty-graphics side quest, kept out of the main editor
-// logic. A pane can carry a `View`; the render loop transmits it once (lazily,
-// because transmitting needs the tty writer) and re-places it every frame.
-//
-// Decoding is done by zstbi (C stb_image) — NOT zigimg, whose pure-Zig decoder
-// crashes the Zig 0.16 compiler when analyzed here. We decode to RGBA, downscale
-// large images so the transmit stays cheap, then hand the raw pixels to the
-// terminal via vaxis (kitty graphics). When the terminal has no kitty graphics —
-// or the user toggles "Petscii" on the pane — we instead render the kept RGBA as
-// PETSCII art (see petscii.zig). Call `start` once before any pane loads.
-const std = @import("std");
-const vaxis = @import("ui");
-const zstbi = @import("zstbi");
-const ghostty_vt = @import("ghostty-vt");
-const petscii = @import("petscii.zig");
-
-// The terminal's own 16 ANSI colors (ghostty's default palette) — what the PETSCII
-// matcher scores against; cells are then painted as indexed colors so the real
-// terminal resolves the displayed RGB (the art recolors with the terminal theme).
-fn ansiPalette() [16][3]u8 {
- var p: [16][3]u8 = undefined;
- for (0..16) |i| {
- const c = ghostty_vt.color.default[i];
- p[i] = .{ c.r, c.g, c.b };
- }
- return p;
-}
-
-// Cap the longest side before transmitting: a terminal pane is at most a screenful
-// of cells, so multi-thousand-pixel photos just waste decode/transmit time.
-const MAX_DIM: u32 = 1280;
-
-// Which 16-color palette the PETSCII art matches + paints with. `commodore` (the
-// default) is the fixed C64 palette drawn as truecolor; `terminal` scores against
-// and paints with the terminal's own ANSI palette (recolors with the terminal theme).
-pub const PaletteMode = enum { commodore, terminal };
-
-pub const View = struct {
- path: []const u8, // gpa-owned absolute path; doubles as the tag label
- tried: bool = false, // decode/transmit attempted — try exactly once
- handle: ?vaxis.Image = null, // kitty image handle once transmitted (null = none)
- // PETSCII fallback/toggle: when set we render the kept pixels as C64 glyph art
- // instead of placing the kitty image. Defaults to true on no-kitty terminals.
- petscii: bool = false,
- pmode: PaletteMode = .commodore, // color palette; terminal colors behind the toggle
- ascii: bool = true, // include ASCII glyphs in the matcher (max detail)
- rgba: []u8 = &.{}, // gpa-owned decoded+downscaled pixels, kept for PETSCII re-render
- iw: usize = 0,
- ih: usize = 0,
- grid: []petscii.Cell = &.{}, // gpa-owned cached PETSCII grid
- gw: usize = 0,
- gh: usize = 0,
- grid_w: u16 = 0, // body cols the grid was built for (recompute on change)
- grid_h: u16 = 0,
- grid_mode: PaletteMode = .commodore, // palette the grid was built for
- grid_ascii: bool = true, // ascii setting the grid was built for
- // ponytail: we never vx.freeImage on close, so the terminal-side pixel data
- // leaks until app exit. Fine for a viewer; add freeImage in the close paths
- // (needs vx + the tty writer there) if you open many large images.
-};
-
-// Initialize / tear down the stb_image allocator. Call once at startup / exit.
-pub fn start(io: std.Io, gpa: std.mem.Allocator) void {
- zstbi.init(io, gpa);
-}
-pub fn stop() void {
- zstbi.deinit();
-}
-
-// free the gpa-owned buffers a View holds (RGBA + PETSCII grid). Call on pane close.
-pub fn deinitView(view: *View, gpa: std.mem.Allocator) void {
- if (view.rgba.len > 0) gpa.free(view.rgba);
- if (view.grid.len > 0) gpa.free(view.grid);
-}
-
-// Extensions stb_image can decode. Right-clicking one of these opens an image pane
-// (kitty if supported, else PETSCII) instead of a text view.
-pub fn isImagePath(path: []const u8) bool {
- const exts = [_][]const u8{
- ".png", ".jpg", ".jpeg", ".jpe", ".bmp", ".tga",
- ".gif", ".psd", ".hdr", ".pic", ".pnm", ".ppm",
- ".pgm",
- };
- for (exts) |ext| {
- if (path.len >= ext.len and std.ascii.eqlIgnoreCase(path[path.len - ext.len ..], ext)) return true;
- }
- return false;
-}
-
-fn keepDecodedImage(view: *View, vx: *vaxis.Vaxis, gpa: std.mem.Allocator, writer: *std.Io.Writer, img: *const zstbi.Image) void {
- // downscale the longest side to MAX_DIM (keeping aspect) when it's bigger.
- var scaled: ?zstbi.Image = null;
- defer if (scaled) |*s| s.deinit();
- const longest = @max(img.width, img.height);
- const src: *const zstbi.Image = if (longest > MAX_DIM) blk: {
- const nw = @max(1, img.width * MAX_DIM / longest);
- const nh = @max(1, img.height * MAX_DIM / longest);
- scaled = img.resize(nw, nh);
- break :blk &scaled.?;
- } else img;
-
- // keep a gpa-owned copy of the pixels for the PETSCII renderer.
- view.rgba = gpa.dupe(u8, src.data) catch &.{};
- view.iw = src.width;
- view.ih = src.height;
-
- if (!vx.caps.kitty_graphics) return; // PETSCII-only terminal: nothing to transmit
- // base64-encode the raw RGBA and transmit it (terminal shows the pixels as-is,
- // no terminal-side decode); vaxis chunks the payload over the kitty protocol.
- const enc = std.base64.standard.Encoder;
- const b64 = gpa.alloc(u8, enc.calcSize(src.data.len)) catch return;
- defer gpa.free(b64);
- _ = enc.encode(b64, src.data);
- view.handle = vx.transmitPreEncodedImage(writer, b64, @intCast(src.width), @intCast(src.height), .rgba) catch null;
-}
-
-// Decode (RGBA), downscale to MAX_DIM, keep the pixels for PETSCII, and — when the
-// terminal supports kitty graphics — transmit them once. Runs from the render loop,
-// which owns the tty writer. On failure `handle` stays null (PETSCII still works).
-pub fn ensureLoaded(view: *View, vx: *vaxis.Vaxis, gpa: std.mem.Allocator, writer: *std.Io.Writer) void {
- if (view.tried) return;
- view.tried = true;
- var pathbuf: [4096]u8 = undefined;
- const pz = std.fmt.bufPrintZ(&pathbuf, "{s}", .{view.path}) catch return;
- var img = zstbi.Image.loadFromFile(pz, 4) catch return; // force 4 components = RGBA
- defer img.deinit();
- keepDecodedImage(view, vx, gpa, writer, &img);
-}
-
-pub fn ensureLoadedFromMemory(view: *View, bytes: []const u8, vx: *vaxis.Vaxis, gpa: std.mem.Allocator, writer: *std.Io.Writer) void {
- if (view.tried) return;
- view.tried = true;
- if (bytes.len == 0) return;
- var img = zstbi.Image.loadFromMemory(bytes, 4) catch return; // force 4 components = RGBA
- defer img.deinit();
- keepDecodedImage(view, vx, gpa, writer, &img);
-}
-
-// (Re)build the cached PETSCII grid for the current body size, if PETSCII drawing
-// is active and the size changed. Runs from the render loop (which has gpa).
-pub fn ensureGrid(view: *View, gpa: std.mem.Allocator, body_cols: u16, body_rows: u16) void {
- if (!petsciiActive(view) or view.rgba.len == 0 or body_cols == 0 or body_rows == 0) return;
- if (view.grid.len != 0 and view.grid_w == body_cols and view.grid_h == body_rows and
- view.grid_mode == view.pmode and view.grid_ascii == view.ascii) return;
- if (view.grid.len > 0) gpa.free(view.grid);
- const pal = switch (view.pmode) {
- .commodore => petscii.commodore,
- .terminal => ansiPalette(),
- };
- const g = petscii.render(gpa, view.rgba, view.iw, view.ih, body_cols, body_rows, pal, view.ascii) catch petscii.Grid{ .cells = &.{}, .gw = 0, .gh = 0 };
- view.grid = g.cells;
- view.gw = g.gw;
- view.gh = g.gh;
- view.grid_w = body_cols;
- view.grid_h = body_rows;
- view.grid_mode = view.pmode;
- view.grid_ascii = view.ascii;
-}
-
-// PETSCII draws when toggled on, or whenever there's no kitty handle to place.
-fn petsciiActive(view: *const View) bool {
- return view.petscii or view.handle == null;
-}
-
-// Draw the image into `win` (the pane body). PETSCII blits the cached glyph grid,
-// centered; otherwise the kitty placement is re-issued (it isn't persistent).
-pub fn draw(view: *const View, win: vaxis.Window, body_cols: u16, body_rows: u16) void {
- if (petsciiActive(view) and view.grid.len != 0) {
- const offx = if (body_cols > view.gw) (@as(usize, body_cols) - view.gw) / 2 else 0;
- const offy = if (body_rows > view.gh) (@as(usize, body_rows) - view.gh) / 2 else 0;
- var cy: usize = 0;
- while (cy < view.gh) : (cy += 1) {
- var cx: usize = 0;
- while (cx < view.gw) : (cx += 1) {
- // pointer into the retained grid — NOT a copy: vaxis keeps the
- // grapheme slice alive until vx.render(), so it must point at the
- // persistent grid, not a stack-local Cell.
- const c = &view.grid[cy * view.gw + cx];
- // commodore: paint the fixed C64 colors as truecolor. terminal:
- // paint indexed colors so the real terminal resolves the RGB.
- const fg: vaxis.Color = switch (view.pmode) {
- .commodore => .{ .rgb = petscii.commodore[c.fg] },
- .terminal => .{ .index = c.fg },
- };
- const bg: vaxis.Color = switch (view.pmode) {
- .commodore => .{ .rgb = petscii.commodore[c.bg] },
- .terminal => .{ .index = c.bg },
- };
- win.writeCell(@intCast(offx + cx), @intCast(offy + cy), .{
- .char = .{ .grapheme = c.glyph[0..c.glen] },
- .style = .{ .fg = fg, .bg = bg },
- });
- }
- }
- return;
- }
- const h = view.handle orelse return;
- h.draw(win, .{ .scale = .contain }) catch {};
-}
diff --git a/main.zig b/main.zig
deleted file mode 100644
index f787f7f5..00000000
--- a/main.zig
+++ /dev/null
@@ -1,3940 +0,0 @@
-const std = @import("std");
-const builtin = @import("builtin");
-const vaxis = @import("ui");
-const ghostty_vt = @import("ghostty-vt");
-const Term = @import("term.zig");
-const modal = @import("modal.zig");
-const image = @import("image.zig");
-const dump = @import("dump.zig");
-const paint = @import("paint.zig");
-const pane_ui = @import("pane_ui.zig");
-
-const os_tag = builtin.os.tag;
-const posix = std.posix;
-const linux = std.os.linux;
-const shell_reports_cwd = os_tag == .macos;
-
-// A TUI can't let std.log hit stderr — it's the same tty as our alt-screen, so the
-// message lands on top of the UI (this is the stray text you saw when opening yazi:
-// ghostty-vt's parser warns via std.log about sequences it doesn't implement, e.g.
-// kitty text-sizing). Route every log line to /tmp/pardes.log instead: the display
-// stays clean and the messages are still there to inspect.
-pub const std_options: std.Options = .{ .logFn = fileLog };
-
-var log_fd: posix.fd_t = -1;
-fn fileLog(
- comptime level: std.log.Level,
- comptime scope: @EnumLiteral(),
- comptime format: []const u8,
- args: anytype,
-) void {
- if (log_fd < 0) {
- log_fd = openCreateTrunc("/tmp/pardes.log", true) orelse return;
- }
- var buf: [4096]u8 = undefined;
- const prefix = "[" ++ @tagName(level) ++ "] " ++ @tagName(scope) ++ ": ";
- const line = std.fmt.bufPrint(&buf, prefix ++ format ++ "\n", args) catch return;
- writeFdBestEffort(log_fd, line);
-}
-
-fn nowNs() u64 {
- switch (os_tag) {
- .linux => {
- var ts: linux.timespec = undefined;
- _ = linux.clock_gettime(linux.CLOCK.MONOTONIC, &ts);
- return @intCast(ts.sec * 1_000_000_000 + ts.nsec);
- },
- else => {
- var ts: posix.timespec = undefined;
- switch (posix.errno(posix.system.clock_gettime(posix.CLOCK.MONOTONIC, &ts))) {
- .SUCCESS => return @intCast(ts.sec * 1_000_000_000 + ts.nsec),
- else => return 0,
- }
- },
- }
-}
-
-fn openCreateTrunc(path: [*:0]const u8, append: bool) ?posix.fd_t {
- switch (os_tag) {
- .linux => {
- const rc = linux.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true, .APPEND = append }, 0o644);
- const sfd: isize = @bitCast(rc);
- return if (sfd < 0) null else @intCast(sfd);
- },
- else => return posix.openatZ(posix.AT.FDCWD, path, .{
- .ACCMODE = .WRONLY,
- .CREAT = true,
- .TRUNC = true,
- .APPEND = append,
- }, 0o644) catch null,
- }
-}
-
-fn closeFdBestEffort(fd: posix.fd_t) void {
- switch (os_tag) {
- .linux => _ = linux.close(fd),
- else => switch (posix.errno(posix.system.close(fd))) {
- .SUCCESS, .INTR => {},
- else => {},
- },
- }
-}
-
-fn writeFdBestEffort(fd: posix.fd_t, bytes: []const u8) void {
- var off: usize = 0;
- while (off < bytes.len) {
- switch (os_tag) {
- .linux => {
- const rc = linux.write(fd, bytes[off..].ptr, bytes.len - off);
- const n: isize = @bitCast(rc);
- if (n < 0) {
- if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue;
- return;
- }
- if (n == 0) return;
- off += @intCast(n);
- },
- else => {
- const rc = posix.system.write(fd, bytes[off..].ptr, bytes.len - off);
- switch (posix.errno(rc)) {
- .SUCCESS => {
- const n: usize = @intCast(rc);
- if (n == 0) return;
- off += n;
- },
- .INTR => continue,
- else => return,
- }
- },
- }
- }
-}
-
-extern "c" fn forkpty(
- amaster: *c_int,
- name: ?[*:0]u8,
- termp: ?*const anyopaque,
- winp: ?*const std.posix.winsize,
-) c_int;
-
-// execv, not execvp: no PATH search means no malloc in the child, so it's
-// async-signal-safe and survives being forked from a multithreaded process.
-extern "c" fn execv(
- path: [*:0]const u8,
- argv: [*:null]const ?[*:0]const u8,
-) c_int;
-
-extern "c" fn _exit(status: c_int) noreturn;
-extern "c" fn chdir(path: [*:0]const u8) c_int;
-extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8;
-
-// basic file navigation
-// basic shell io
-// acme-style layout: columns split horizontally, windows stacked in each column.
-// * see plan9port's acme: src/cmd/acme
-// right click and enter on lines to open files at specified lines
-
-const PtyRead = struct { id: usize, bytes: []u8 };
-
-pub const Command = struct {
- prev: ?*Command = null,
- pub var value: union(enum) {
- close,
- ping,
- nop,
- quit,
- tick,
- key_press: vaxis.Key,
- pty_read: PtyRead,
- pty_eof: usize,
- winsize: vaxis.Winsize,
- mouse: vaxis.Mouse,
- paste: []const u8, // OSC 52 system-clipboard read reply (the loop decodes it)
- pinch: f32,
- touch_scroll_delta: f32,
- } = .nop;
-};
-
-// Modal yank register: gpa-owned, freed on overwrite / app exit. Helix-style
-// `y` yanks the (line) selection into here; `p` pastes the system clipboard.
-// A yank also mirrors out to the system clipboard (OSC 52): setYank raises
-// yank_dirty and the main loop emits the copy where the tty writer is in scope.
-var yank_reg: ?[]u8 = null;
-var yank_dirty: bool = false;
-fn freeYank(gpa: std.mem.Allocator) void {
- if (yank_reg) |y| gpa.free(y);
- yank_reg = null;
-}
-fn setYank(gpa: std.mem.Allocator, text: []const u8) void {
- freeYank(gpa);
- yank_reg = gpa.dupe(u8, text) catch null;
- yank_dirty = true;
-}
-
-const default_tty_toggle: u21 = 'b';
-
-const Cli = struct {
- tty_only: bool = false,
- tty_toggle: u21 = default_tty_toggle,
- help: bool = false,
- perf_startup: bool = false,
- debug_overlay: bool = false,
-};
-
-const help_text =
- \\Usage: pardes [options]
- \\
- \\Options:
- \\ --tty start as one shell pane already in tty mode
- \\ --tty-toggle <key> use Ctrl-<key> to enter/leave tty mode
- \\ --tty-toggle=<key> same as above; accepts b, Ctrl-b, or ^b
- \\ --debug-overlay start with debug overlay visible
- \\ -h, --help show this help and exit
- \\\;
-;
-
-fn printHelp(io: std.Io) !void {
- try std.Io.File.stdout().writeStreamingAll(io, help_text);
-}
-
-fn parseCtrlKey(raw: []const u8) ?u21 {
- var value = std.mem.trim(u8, raw, " \t\r\n");
- if (value.len >= 5 and std.ascii.eqlIgnoreCase(value[0..5], "ctrl-")) {
- value = value[5..];
- } else if (value.len >= 5 and std.ascii.eqlIgnoreCase(value[0..5], "ctrl+")) {
- value = value[5..];
- } else if (value.len == 2 and value[0] == '^') {
- value = value[1..];
- }
- if (value.len != 1) return null;
- const c = std.ascii.toLower(value[0]);
- if (c < 'a' or c > 'z' or c == 'c') return null;
- return c;
-}
-
-fn parseCli(args: []const [:0]const u8) !Cli {
- var cli: Cli = .{};
- var i: usize = 1;
- while (i < args.len) : (i += 1) {
- const a = args[i];
- if (std.mem.eql(u8, a, "--tty")) {
- cli.tty_only = true;
- } else if (std.mem.eql(u8, a, "--perf-startup")) {
- cli.perf_startup = true;
- } else if (std.mem.eql(u8, a, "-h") or std.mem.eql(u8, a, "--help")) {
- cli.help = true;
- } else if (std.mem.startsWith(u8, a, "--tty-toggle=")) {
- cli.tty_toggle = parseCtrlKey(a["--tty-toggle=".len..]) orelse return error.BadArgs;
- } else if (std.mem.eql(u8, a, "--tty-toggle")) {
- i += 1;
- if (i >= args.len) return error.BadArgs;
- cli.tty_toggle = parseCtrlKey(args[i]) orelse return error.BadArgs;
- } else if (std.mem.eql(u8, a, "--debug-overlay")) {
- cli.debug_overlay = true;
- } else {
- return error.BadArgs;
- }
- }
- return cli;
-}
-
-const Loop = vaxis.Loop(@TypeOf(Command.value));
-
-// layout constants
-const TOPBAR_H: u16 = pane_ui.TOPBAR_H; // global tagbar row at the very top (full width)
-const GUTTER: u16 = pane_ui.GUTTER; // left gutter: box (top) + scrollbar (below) per pane
-const BOX_H: u16 = pane_ui.BOX_H; // rows of the drag/rearrange box; scrollbar starts below it
-const MINW: u16 = pane_ui.MINW; // min pane width (cells)
-const MINH: u16 = pane_ui.MINH; // min pane height (cells)
-const MAX_TERMS: usize = 16;
-const MAX_COLS: usize = 6;
-const MAX_PTY_DRAIN_EVENTS: usize = 128;
-const MAX_KEY_DRAIN_EVENTS: usize = 512;
-const MAX_SCROLL_DRAIN_EVENTS: usize = 4096;
-const SYNTAX_CONTEXT_BEFORE_ROWS: usize = 0;
-const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2;
-const PREFIX_W: u16 = pane_ui.PREFIX_W; // file-pane line-number prefix width: "%4d "
-
-const Rect = pane_ui.Rect;
-
-// acme model: columns own X (col_weight), windows own Y (Term.vweight). The
-// per-column ordered list `col_terms[c][0..col_n[c]]` is the source of truth.
-const Layout = pane_ui.Layout(MAX_COLS, MAX_TERMS);
-const layoutFindTerm = pane_ui.layoutFindTerm;
-const layoutRemove = pane_ui.layoutRemove;
-const layoutInsert = pane_ui.layoutInsert;
-const layoutAppendColumn = pane_ui.layoutAppendColumn;
-const moveTerm = pane_ui.moveTerm;
-
-const Drag = union(enum) {
- none,
- border_v: struct { left_col: usize, cur_x: u16 },
- border_h: struct { col: usize, top_idx: usize, cur_y: u16 },
- move: struct { id: usize, cur_x: u16, cur_y: u16 },
- select: struct { id: usize, button: Button },
-};
-
-// mouse button driving a selection; also indexes Term.sel.
-const Button = enum { left, middle, right };
-
-// monochrome, low-contrast palette (rgb grays).
-const gray = struct {
- const bar = [3]u8{ 0x2a, 0x2a, 0x2a }; // dim bars (scrollbar track, box idle)
- const dim = [3]u8{ 0x3c, 0x3c, 0x3c }; // separators
- const mid = [3]u8{ 0x52, 0x52, 0x52 }; // box active, scrollbar thumb, move preview
- const lit = [3]u8{ 0x6e, 0x6e, 0x6e }; // dashed resize preview
- const txt = [3]u8{ 0x96, 0x96, 0x96 }; // muted text
-};
-const accent = paint.accent;
-const accent_dim = paint.accent_dim;
-// per-button selection highlight: distinct (muted) colors so left/middle/right
-// selections are easy to tell apart against the otherwise-monochrome chrome.
-const SEL_BG = [3][3]u8{ .{ 0x2c, 0x46, 0x74 }, .{ 0x2c, 0x60, 0x38 }, .{ 0x64, 0x2c, 0x58 } };
-const sel_fg = [3]u8{ 0xd2, 0xd2, 0xd2 };
-// modal line-selection (helix `x`) highlight: a warm tan so it reads as
-// keyboard-driven, distinct from the mouse selections above.
-const msel_bg = [3]u8{ 0x4a, 0x3a, 0x1e };
-const msel_fg = [3]u8{ 0xd8, 0xc8, 0xa8 };
-
-const T = paint.T;
-const rgbColor = paint.rgbColor;
-const colorOpt = paint.colorOpt;
-const synStyle = paint.synStyle;
-const highlightFileRange = paint.highlightFileRange;
-const cellStyle = paint.cellStyle;
-
-// Master switch for the per-cell recolor passes (tty ansi + file syntax). Off =
-// the plain-text render path, exactly as before colors existed; the recolor loops
-// are skipped so there's no per-frame cost. Toggled by the "Colors" tagbar command.
-var colors_on: bool = true;
-
-// The stats overlay, toggled by the "Debug" builtin. Global (like colors_on) so the
-// central command dispatcher can flip it; the render reads it.
-var show_debug: bool = false;
-var ov_pinch_scale: f32 = 1.0;
-var ov_touch_scroll_delta: f32 = 0.0;
-
-// Builtins. Executing the name (middle-click / Tab) runs the action through the ONE
-// dispatcher (onSelect + actOnSelection), no matter where the name is shown — a pane
-// body, a file, the tagbar. onSelect matches against the whole list, so a name works
-// wherever it appears. Window-specific ones (Save, Del, Delcol) live in each
-// pane's tag or can be typed/executed; window-agnostic ones (Kill, Newcol, Tutor,
-// Debug, Colors, NextColor) in the tagbar.
-const SelectAction = enum { none, send, newcol, delcol, del, save, tutor, kill, debug, colors, nextcolor, dump };
-const Builtin = struct { name: []const u8, action: SelectAction };
-const builtins = [_]Builtin{
- .{ .name = "Kill", .action = .kill },
- .{ .name = "Newcol", .action = .newcol },
- .{ .name = "Tutor", .action = .tutor },
- .{ .name = "Debug", .action = .debug },
- .{ .name = "Colors", .action = .colors },
- .{ .name = "NextColor", .action = .nextcolor },
- .{ .name = "Dump", .action = .dump },
- .{ .name = "Save", .action = .save },
- .{ .name = "Del", .action = .del },
- .{ .name = "Delcol", .action = .delcol },
-};
-// Default per-pane tag commands. Delcol remains a builtin command, but it is no
-// longer advertised in the default tag text.
-const pane_builtins_str = "Del";
-const file_pane_builtins_str = "Save Del";
-// default editable tails (builtins with the leading gap after the cwd/path).
-const pane_tail = " " ++ pane_builtins_str;
-const file_pane_tail = " " ++ file_pane_builtins_str;
-// the global tagbar: window-agnostic commands. Kill closes everything; Debug toggles
-// the stats overlay, Colors the ansi/syntax recolor, NextColor cycles the theme.
-const topbar_str = "Kill Newcol Tutor Debug Colors NextColor Dump";
-
-fn writeStdoutBestEffort(bytes: []const u8) void {
- writeFdBestEffort(1, bytes);
-}
-
-fn paintStartupTopbar() void {
- writeStdoutBestEffort("\x1b[?1049h\x1b[H" ++ topbar_str);
-}
-
-fn writeStartupPerfMarker(start_ns: u64) void {
- const elapsed_us = (nowNs() - start_ns) / 1000;
- var buf: [64]u8 = undefined;
- const marker = std.fmt.bufPrint(&buf, "\x1b]1337;PARDES_STARTUP_US={d}\x07", .{elapsed_us}) catch return;
- writeStdoutBestEffort(marker);
-}
-
-// Path of the generated bash rc that adds OSC 133 semantic prompts (shell
-// integration). bash --rcfile reads it for interactive shells; it sources the
-// user's ~/.bashrc first, then wraps PS1 so each prompt emits 133;A..B. That
-// marks the prompt rows so we can hide them in text mode. The A marker also
-// carries cl=line, opting in to ghostty's promptClickMove (arrow-key
-// repositioning of the shell cursor within the typed input) so enterTty can
-// land the shell cursor where the modal cursor navigated (start vs end).
-const rc_path = "/tmp/pardes-osc133.bash";
-const bash_rc = if (shell_reports_cwd)
- \\[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc"
- \\PS1='\[\e]7;file://localhost$PWD\a\]\[\e]133;A;cl=line\a\]'"$PS1"'\[\e]133;B\a\]'
- \\PROMPT_COMMAND='printf "\e]133;D\a"'"${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
- \\trap 'printf "\e]133;C\a"' DEBUG
- \\
-else
- \\[ -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}"
- \\trap 'printf "\e]133;C\a"' DEBUG
- \\
-;
-
-fn writeFile(path: [*:0]const u8, data: []const u8) void {
- const fd = openCreateTrunc(path, false) orelse return;
- defer closeFdBestEffort(fd);
- writeFdBestEffort(fd, data);
-}
-
-fn saveFile(io: std.Io, f: *Term.File) !void {
- var file = try std.Io.Dir.createFileAbsolute(io, f.path, .{});
- defer file.close(io);
- try file.writeStreamingAll(io, f.content);
-}
-
-fn fork_pty(argv: []const ?[*:0]const u8, cwd: ?[*:0]const u8, ws: std.posix.winsize) struct { file: std.Io.File, pid: std.posix.pid_t } {
- var master: c_int = undefined;
- const pid = forkpty(&master, null, null, &ws);
- if (pid == 0) {
- if (cwd) |c| _ = chdir(c); // start the shell in this directory
- var child_argv: [8]?[*:0]const u8 = @splat(null);
- for (argv, 0..) |a, i| child_argv[i] = a;
- _ = execv(argv[0].?, @ptrCast(&child_argv));
- _exit(127);
- }
- return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid };
-}
-
-fn termWrite(t: *Term, bytes: []const u8) void {
- if (t.pty.handle < 0) return; // file pane: no pty
- writeFdBestEffort(t.pty.handle, bytes);
-}
-
-fn fileLineCount(content: []const u8) usize {
- return std.mem.count(u8, content, "\n") + 1;
-}
-
-fn lineStartByte(content: []const u8, row: usize) usize {
- var off: usize = 0;
- var r: usize = 0;
- while (r < row) : (r += 1) {
- const rel = std.mem.indexOfScalarPos(u8, content, off, '\n') orelse return content.len;
- off = rel + 1;
- }
- return off;
-}
-
-fn visibleByteRange(content: []const u8, row: usize, rows: usize) struct { start: usize, end: usize } {
- const start = lineStartByte(content, row);
- const end = lineStartByte(content, row + rows);
- return .{ .start = start, .end = @max(start, end) };
-}
-
-// the pane's current scroll offset (top visible row, in body coordinates). For a
-// file it's `scroll`; for a terminal it's the emulator's scrollback offset.
-fn paneScroll(t: *Term) i32 {
- if (t.file) |f| return @intCast(f.scroll);
- return @intCast(t.term.screens.active.pages.scrollbar().offset);
-}
-
-fn paneScrollBy(t: *Term, delta: i32) void {
- if (t.file) |*f| {
- const max: i64 = @intCast(fileLineCount(f.content) -| 1);
- const n = std.math.clamp(@as(i64, @intCast(f.scroll)) + delta, 0, max);
- const next: usize = @intCast(n);
- if (next != f.scroll) {
- f.scroll = next;
- f.syntax_dirty = true;
- }
- } else {
- t.term.screens.active.scroll(.{ .delta_row = @intCast(delta) });
- }
-}
-
-fn hoveredPane(terms: *const [MAX_TERMS]?*Term, rects: *const [MAX_TERMS]Rect, mcol: u16, mrow: u16) ?usize {
- return for (terms, 0..) |slot, i| {
- if (slot == null) continue;
- const r = 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;
-}
-
-fn handleWheelMouse(
- terms: *[MAX_TERMS]?*Term,
- rects: *const [MAX_TERMS]Rect,
- screen_w: u16,
- screen_h: u16,
- hover_col: *u16,
- hover_row: *u16,
- mouse: vaxis.Mouse,
-) bool {
- if (mouse.button != .wheel_down and mouse.button != .wheel_up) return false;
- const mpos = pane_ui.clampMousePos(screen_w, screen_h, mouse);
- const mcol = mpos.col;
- const mrow = mpos.row;
- hover_col.* = mcol;
- hover_row.* = mrow;
- if (hoveredPane(terms, rects, mcol, mrow)) |id| {
- if (terms[id]) |t| paneScrollBy(t, if (mouse.button == .wheel_down) 1 else -1);
- }
- return true;
-}
-
-// Before running a composed command in a terminal, push the shell down so its
-// output lands BELOW the last row the user typed overlay text on. The shell owns
-// its cursor, so we move it the only way we can: emit newlines to the pty until
-// the prompt sits at max(last-edit row, the shell's current prompt row). The empty
-// prompts this creates are hidden in text mode (promptFlags), so the gap reads as
-// blank padding and the output flows right under the composed text. No-op unless
-// the shell is at a prompt (don't inject newlines into a running program).
-// ponytail: pad caps at the grid height — you can't click/type past the pane.
-fn padOutputBelowEdits(t: *Term) void {
- if (!t.isTerminal() or t.nedits == 0) return;
- if (!t.term.cursorIsAtPrompt()) return;
- var max_row: i32 = std.math.minInt(i32);
- for (t.edits[0..t.nedits]) |e| {
- if (e.len > 0 and e.row > max_row) max_row = e.row;
- }
- if (max_row == std.math.minInt(i32)) return;
- const cursor_abs: i32 = paneScroll(t) + @as(i32, @intCast(t.term.screens.active.cursor.y));
- const pad = std.math.clamp(max_row - cursor_abs, 0, @as(i32, t.rows));
- var i: i32 = 0;
- while (i < pad) : (i += 1) termWrite(t, "\r");
-}
-
-// The shell's current working directory. Linux keeps the strongest existing
-// behavior by reading /proc/<pid>/cwd, which follows external `cd` commands
-// without shell cooperation. macOS uses OSC 7 emitted by our bash rc. BSDs use
-// the spawn cwd cached on Term until a native live-cwd implementation is added.
-fn termCwd(t: *Term, buf: []u8) []const u8 {
- if (!t.isTerminal()) return "?"; // file/image pane: no shell, no cwd
- switch (os_tag) {
- .linux => {
- var pathbuf: [64]u8 = undefined;
- const path = std.fmt.bufPrintZ(&pathbuf, "/proc/{d}/cwd", .{t.pid}) catch return "?";
- const rc = linux.readlinkat(linux.AT.FDCWD, path, buf.ptr, buf.len);
- const n: isize = @bitCast(rc);
- if (n < 0) return "?";
- return buf[0..@intCast(n)];
- },
- .macos, .freebsd, .openbsd, .netbsd, .dragonfly => return t.cwd orelse "?",
- else => return t.cwd orelse "?",
- }
-}
-
-// True if `path` exists and is a directory. open(O_DIRECTORY) fails with ENOTDIR
-// for anything else, so no stat struct needed.
-fn isDir(path: [*:0]const u8) bool {
- switch (os_tag) {
- .linux => {
- const rc = linux.open(path, .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true }, 0);
- const sfd: isize = @bitCast(rc);
- if (sfd < 0) return false;
- _ = linux.close(@intCast(sfd));
- return true;
- },
- else => {
- const fd = posix.openatZ(posix.AT.FDCWD, path, .{
- .ACCMODE = .RDONLY,
- .DIRECTORY = true,
- .CLOEXEC = true,
- }, 0) catch return false;
- closeFdBestEffort(fd);
- return true;
- },
- }
-}
-
-// The LIVE prefix of a window tag: mode indicator + cwd/path. Regenerated every
-// frame; the editable tail (builtins onward) lives in t.tag_tail. Caller frees.
-// Image panes have no editable tail, so their whole tag (toggles + builtins) is
-// returned here and stays live/non-editable. Caller frees with `alloc`.
-fn tagPrefix(alloc: std.mem.Allocator, t: *Term) ![]u8 {
- if (t.image) |iv| {
- // toggles: Petscii (glyph art on/off), the color mode (C64 = commodore /
- // Term = terminal palette), Ascii (include ascii glyphs in the matcher).
- const mode = if (iv.pmode == .commodore) "C64" else "Term";
- return std.fmt.allocPrint(alloc, "img {s} Petscii {s} Ascii {s}", .{ iv.path, mode, pane_builtins_str });
- }
- // a file pane's tag: mode + path; no cwd. Files never reach tty.
- if (t.file) |f| {
- const fmode = if (t.mode == .insert) "in" else "nm";
- return std.fmt.allocPrint(alloc, "{s} {s}", .{ fmode, f.path });
- }
- var link: [1024]u8 = undefined;
- const cwd = termCwd(t, &link);
- // leading 2-char mode indicator: "nm" (normal), "in" (insert) or "sy" (sym/tty).
- const mode = switch (t.mode) {
- .normal => "nm",
- .insert => "in",
- .tty => "sy",
- };
- return std.fmt.allocPrint(alloc, "{s} {s}", .{ mode, cwd });
-}
-
-// The editable tail: the user's edited buffer once touched, else the default
-// builtins. Empty for image panes (whole tag is live). Borrowed, not owned.
-fn defaultTail(t: *Term) []const u8 {
- return if (t.file != null) file_pane_tail else pane_tail;
-}
-
-fn curTail(t: *Term) []const u8 {
- if (t.image != null) return "";
- return if (t.tag_init) t.tag_tail.items else defaultTail(t);
-}
-
-// prefix + tail: the full rendered/selectable tag line. Caller frees with `alloc`.
-fn fullTag(alloc: std.mem.Allocator, t: *Term) ![]u8 {
- const pfx = try tagPrefix(alloc, t);
- const tail = curTail(t);
- if (tail.len == 0) return pfx;
- defer alloc.free(pfx);
- return std.fmt.allocPrint(alloc, "{s}{s}", .{ pfx, tail });
-}
-
-// Focus the tag for editing, seeding the tail with the default builtins on first
-// touch and placing the cursor under `click_col` (a column within the text area).
-// Enters NORMAL mode (like a body left-click), so the tag then follows the pane's
-// modal keys — i/a to insert, hjkl/w/b/e to move, v to select, Enter/Tab chords.
-fn enterTagEdit(gpa: std.mem.Allocator, alloc: std.mem.Allocator, t: *Term, click_col: i32) void {
- if (t.image != null) return; // images keep a live toggle tag, no editable tail
- const pfx = tagPrefix(alloc, t) catch return;
- if (!t.tag_init) {
- t.tag_tail.appendSlice(gpa, defaultTail(t)) catch return;
- t.tag_init = true;
- }
- t.tag_edit = true;
- t.tag_sel = false;
- t.mode = .normal;
- t.pending = 0;
- const cc = std.math.clamp(click_col - @as(i32, @intCast(pfx.len)), 0, @as(i32, @intCast(t.tag_tail.items.len)));
- t.tag_col = @intCast(cc);
-}
-
-// leave tag editing (used when a body click / edit steals focus).
-fn exitTagEdit(t: *Term) void {
- t.tag_edit = false;
- t.tag_sel = false;
-}
-
-// inclusive char-select bounds within the tail: [lo, hi] (hi < len).
-fn tagSelBounds(t: *Term) struct { lo: usize, hi: usize } {
- const a: usize = t.tag_anchor;
- const c: usize = t.tag_col;
- return .{ .lo = @min(a, c), .hi = @max(a, c) };
-}
-
-// The text a tag Enter/Tab chord acts on: the char-selection if active, else the
-// file-ish word under the cursor (acme isfilec). Borrowed from `alloc`; null if
-// there's nothing under the cursor.
-fn tagChordText(alloc: std.mem.Allocator, t: *Term) ?[]const u8 {
- const tail = t.tag_tail.items;
- if (t.tag_sel) {
- const b = tagSelBounds(t);
- const hi = @min(b.hi + 1, tail.len);
- if (hi > b.lo) return alloc.dupe(u8, tail[b.lo..hi]) catch null;
- return null;
- }
- const col: usize = @min(@as(usize, t.tag_col), tail.len);
- var lo = col;
- while (lo > 0 and isFileChar(tail[lo - 1])) lo -= 1;
- var hi = col;
- while (hi < tail.len and isFileChar(tail[hi])) hi += 1;
- if (hi > lo) return alloc.dupe(u8, tail[lo..hi]) catch null;
- return null;
-}
-
-// A single-line view of the tail for the modal motion helpers (cursor row is
-// always 0). The tail never contains a newline (inserts strip them).
-fn tagLines(t: *Term) [1][]const u8 {
- return .{t.tag_tail.items};
-}
-
-// tag-edit key dispatch. The tag shares the pane's `mode`: normal keys navigate/
-// select/edit; insert keys type. Newlines are always dropped (one-line tag).
-fn handleTagKey(gpa: std.mem.Allocator, t: *Term, key: vaxis.Key) void {
- if (t.mode == .insert) return tagInsertKey(gpa, t, key);
-
- const lines = tagLines(t);
- const cur: modal.Cursor = .{ .row = 0, .col = t.tag_col };
-
- // `g`-prefixed motions (gg/ge/gh/gl), matching the body.
- if (t.pending == 'g') {
- t.pending = 0;
- const m: modal.Cursor = if (key.matches('g', .{})) modal.gotoFirst() else if (key.matches('e', .{})) modal.gotoLast(&lines) else if (key.matches('h', .{})) modal.lineStart(cur) else if (key.matches('l', .{})) modal.lineEnd(&lines, cur) else cur;
- t.tag_col = @intCast(m.col);
- return;
- }
- if (key.matches('g', .{})) {
- t.pending = 'g';
- return;
- }
-
- // motions: move the cursor (head); a live selection keeps its anchor.
- const moved: ?modal.Cursor =
- if (key.matches('h', .{}) or key.matches(vaxis.Key.left, .{})) modal.charLeft(cur) else if (key.matches('l', .{}) or key.matches(vaxis.Key.right, .{})) modal.charRight(&lines, cur) else if (key.matches('w', .{})) modal.nextWordStart(&lines, cur, false) else if (key.matches('W', .{})) modal.nextWordStart(&lines, cur, true) else if (key.matches('b', .{})) modal.prevWordStart(&lines, cur, false) else if (key.matches('B', .{})) modal.prevWordStart(&lines, cur, true) else if (key.matches('e', .{})) modal.nextWordEnd(&lines, cur, false) else if (key.matches('E', .{})) modal.nextWordEnd(&lines, cur, true) else if (key.matches('0', .{}) or key.matches(vaxis.Key.home, .{})) modal.lineStart(cur) else if (key.matches('$', .{}) or key.matches(vaxis.Key.end, .{})) modal.lineEnd(&lines, cur) else if (key.matches('^', .{})) modal.firstNonWsOf(&lines, cur) else null;
- if (moved) |m| {
- t.tag_col = @intCast(m.col);
- return;
- }
-
- // insert entry (no o/O — those open a new line)
- if (key.matches('i', .{})) return tagEnterInsert(t, t.tag_col);
- if (key.matches('a', .{})) return tagEnterInsert(t, @intCast(modal.charRight(&lines, cur).col));
- if (key.matches('I', .{})) return tagEnterInsert(t, @intCast(modal.firstNonWsOf(&lines, cur).col));
- if (key.matches('A', .{})) return tagEnterInsert(t, @intCast(t.tag_tail.items.len));
-
- // select / edit
- if (key.matches('v', .{})) {
- t.tag_sel = !t.tag_sel;
- if (t.tag_sel) t.tag_anchor = t.tag_col;
- return;
- }
- if (key.matches('x', .{})) { // select the whole one-line tail
- const len = t.tag_tail.items.len;
- if (len > 0) {
- t.tag_sel = true;
- t.tag_anchor = 0;
- t.tag_col = @intCast(len - 1);
- }
- return;
- }
- if (key.matches('d', .{})) return tagDelete(gpa, t, false);
- if (key.matches('c', .{})) return tagDelete(gpa, t, true);
- if (key.matches('y', .{})) return tagYank(gpa, t);
- if (key.matches('p', .{})) return tagPaste(gpa, t);
- // Esc escalates: clear the selection, else hand focus back to the body.
- if (key.matches(vaxis.Key.escape, .{})) {
- if (t.tag_sel) t.tag_sel = false else exitTagEdit(t);
- return;
- }
-}
-
-// begin insert mode at `col` within the tail.
-fn tagEnterInsert(t: *Term, col: u16) void {
- t.tag_col = @min(col, @as(u16, @intCast(t.tag_tail.items.len)));
- t.tag_sel = false;
- t.mode = .insert;
- t.pending = 0;
-}
-
-fn tagInsertKey(gpa: std.mem.Allocator, t: *Term, key: vaxis.Key) void {
- if (key.matches(vaxis.Key.escape, .{})) {
- t.mode = .normal;
- return;
- }
- if (key.text) |text| {
- for (text) |ch| {
- if (ch == '\n' or ch == '\r') continue; // one-line tag
- t.tag_tail.insert(gpa, t.tag_col, ch) catch return;
- t.tag_col += 1;
- }
- return;
- }
- switch (key.codepoint) {
- vaxis.Key.enter => {}, // no newline
- vaxis.Key.backspace => if (t.tag_col > 0) {
- _ = t.tag_tail.orderedRemove(t.tag_col - 1);
- t.tag_col -= 1;
- },
- vaxis.Key.left => if (t.tag_col > 0) {
- t.tag_col -= 1;
- },
- vaxis.Key.right => if (t.tag_col < t.tag_tail.items.len) {
- t.tag_col += 1;
- },
- vaxis.Key.home => t.tag_col = 0,
- vaxis.Key.end => t.tag_col = @intCast(t.tag_tail.items.len),
- else => {},
- }
-}
-
-// `d`/`c`: delete the selection (or the char under the cursor), yanking it; `c`
-// then enters insert. Cursor lands at the start of the removed span.
-fn tagDelete(gpa: std.mem.Allocator, t: *Term, change: bool) void {
- const tail = t.tag_tail.items;
- var lo: usize = undefined;
- var count: usize = undefined;
- if (t.tag_sel) {
- const b = tagSelBounds(t);
- lo = b.lo;
- count = @min(b.hi + 1, tail.len) - b.lo;
- } else {
- lo = @min(@as(usize, t.tag_col), tail.len);
- count = if (lo < tail.len) 1 else 0;
- }
- if (count > 0) {
- setYank(gpa, tail[lo .. lo + count]);
- t.tag_tail.replaceRange(gpa, lo, count, "") catch return;
- }
- t.tag_col = @intCast(lo);
- t.tag_sel = false;
- if (change) t.mode = .insert;
-}
-
-fn tagYank(gpa: std.mem.Allocator, t: *Term) void {
- const tail = t.tag_tail.items;
- if (t.tag_sel) {
- const b = tagSelBounds(t);
- const hi = @min(b.hi + 1, tail.len);
- setYank(gpa, tail[b.lo..hi]);
- t.tag_col = @intCast(b.lo);
- t.tag_sel = false;
- } else setYank(gpa, tail); // whole tail, like a line yank
-}
-
-fn tagPaste(gpa: std.mem.Allocator, t: *Term) void {
- const y = yank_reg orelse return;
- const col: usize = @min(@as(usize, t.tag_col), t.tag_tail.items.len);
- var at = col;
- for (y) |ch| {
- if (ch == '\n' or ch == '\r') continue; // one-line tag
- t.tag_tail.insert(gpa, at, ch) catch break;
- at += 1;
- }
- t.tag_col = @intCast(at);
-}
-
-// The whitespace-delimited word covering `col` in `str` (empty if on a gap / past
-// the end). Used to resolve which global-tagbar command was clicked.
-fn wordAtCol(str: []const u8, col: u16) []const u8 {
- if (col >= str.len or str[col] == ' ') return "";
- var lo: usize = col;
- while (lo > 0 and str[lo - 1] != ' ') lo -= 1;
- var hi: usize = col;
- while (hi < str.len and str[hi] != ' ') hi += 1;
- return str[lo..hi];
-}
-
-// TIOCSWINSZ: set the pty size; the kernel raises SIGWINCH in the child so it
-// (and anything running in it) reflows to the new grid.
-fn pty_set_size(pty: std.Io.File, rows: u16, cols: u16) void {
- const ws: posix.winsize = .{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
- _ = posix.system.ioctl(pty.handle, tiocswinszReq(), @intFromPtr(&ws));
-}
-
-fn ioctlReq(bits: u32) c_int {
- return @bitCast(bits);
-}
-
-fn tiocswinszReq() c_int {
- return switch (os_tag) {
- .linux => @intCast(posix.T.IOCSWINSZ),
- .macos, .freebsd, .openbsd, .netbsd, .dragonfly => ioctlReq(0x80087467),
- else => @intCast(posix.T.IOCSWINSZ),
- };
-}
-
-// One reader task per terminal. Tags each chunk with the terminal's slot id so
-// the main loop knows which emulator to feed; posts pty_eof when the shell exits.
-fn readPty(io: std.Io, gpa: std.mem.Allocator, pty: std.Io.File, id: usize, loop: *Loop) anyerror!void {
- var read_buf: [0x10000]u8 = undefined;
- var reader = pty.readerStreaming(io, &read_buf);
- while (true) {
- var buf: [0x10000]u8 = undefined;
- var vec = [_][]u8{&buf};
- const n = reader.interface.readVec(&vec) catch break;
- if (n == 0) break;
- const bytes = try gpa.dupe(u8, buf[0..n]);
- loop.postEvent(.{ .pty_read = .{ .id = id, .bytes = bytes } }) catch {
- gpa.free(bytes);
- break;
- };
- }
- loop.postEvent(.{ .pty_eof = id }) catch {};
-}
-
-fn feedPtyRead(gpa: std.mem.Allocator, terms: *[MAX_TERMS]?*Term, active: usize, pr: PtyRead) void {
- defer gpa.free(pr.bytes);
- if (pr.id >= MAX_TERMS) return;
- if (terms[pr.id]) |t| {
- if (shell_reports_cwd) updateCwdFromOsc7(gpa, t, pr.bytes);
- t.tty_stream.appendSlice(gpa, pr.bytes) catch {};
- t.stream.nextSlice(pr.bytes);
- // follow new output to the bottom, so a command's result shows even in a
- // pane you didn't focus. Exception: the pane you're actively navigating
- // in a text mode — don't yank the cursor out from under you.
- if (!(pr.id == active and t.mode != .tty))
- t.term.screens.active.scroll(.active);
- }
-}
-
-fn updateCwdFromOsc7(gpa: std.mem.Allocator, t: *Term, bytes: []const u8) void {
- if (!t.isTerminal()) return;
- for (bytes) |b| switch (t.cwd_osc_state) {
- .normal => {
- if (b == 0x1b) t.cwd_osc_state = .esc;
- },
- .esc => {
- t.cwd_osc_state = if (b == ']') .osc else if (b == 0x1b) .esc else .normal;
- },
- .osc => {
- t.cwd_osc_state = if (b == '7') .osc7 else if (b == 0x1b) .esc else .normal;
- },
- .osc7 => {
- if (b == ';') {
- t.cwd_osc.clearRetainingCapacity();
- t.cwd_osc_state = .collect;
- } else {
- t.cwd_osc_state = if (b == 0x1b) .esc else .normal;
- }
- },
- .collect => switch (b) {
- 0x07 => {
- finishCwdOsc(gpa, t);
- },
- 0x1b => {
- t.cwd_osc_state = .st_esc;
- },
- else => {
- if (t.cwd_osc.items.len < std.fs.max_path_bytes) {
- t.cwd_osc.append(gpa, b) catch {
- t.cwd_osc_state = .normal;
- };
- } else {
- t.cwd_osc.clearRetainingCapacity();
- t.cwd_osc_state = .normal;
- }
- },
- },
- .st_esc => {
- if (b == '\\') finishCwdOsc(gpa, t) else t.cwd_osc_state = .normal;
- },
- };
-}
-
-fn finishCwdOsc(gpa: std.mem.Allocator, t: *Term) void {
- defer {
- t.cwd_osc.clearRetainingCapacity();
- t.cwd_osc_state = .normal;
- }
- const path = osc7Path(t.cwd_osc.items) orelse return;
- if (path.len == 0 or path[0] != '/') return;
- const next = gpa.dupe(u8, path) catch return;
- if (t.cwd) |old| gpa.free(old);
- t.cwd = next;
-}
-
-fn osc7Path(raw: []const u8) ?[]const u8 {
- if (std.mem.startsWith(u8, raw, "file://")) {
- const rest = raw["file://".len..];
- const slash = std.mem.indexOfScalar(u8, rest, '/') orelse return null;
- return rest[slash..];
- }
- if (std.mem.startsWith(u8, raw, "file:")) return raw["file:".len..];
- return raw;
-}
-
-// fork the pty + boot the emulator. Forking is the dangerous part in a
-// multithreaded process, so this does NOT start the reader task (no new
-// threads) — call startReader once the term is wired in.
-fn forkTerm(io: std.Io, gpa: std.mem.Allocator, init_cols: u16, init_rows: u16, cwd: ?[*:0]const u8) !*Term {
- const argv = [_]?[*:0]const u8{ "/usr/bin/bash", "--rcfile", rc_path };
- const cwd_copy = if (cwd) |c| try gpa.dupe(u8, std.mem.span(c)) else blk: {
- var current: [std.fs.max_path_bytes]u8 = undefined;
- const n = try std.process.currentPath(io, &current);
- break :blk try gpa.dupe(u8, current[0..n]);
- };
- errdefer gpa.free(cwd_copy);
- const child = fork_pty(&argv, cwd, .{ .row = init_rows, .col = init_cols, .xpixel = 0, .ypixel = 0 });
- const t = try gpa.create(Term);
- errdefer gpa.destroy(t);
- t.* = .{
- .pty = child.file,
- .pid = child.pid,
- .term = try ghostty_vt.Terminal.init(gpa, .{ .cols = init_cols, .rows = init_rows, .max_scrollback = 16 * 1024 * 1024 }),
- .stream = undefined,
- .reader = .{ .any_future = null, .result = {} }, // no-op until startReader
- .cwd = cwd_copy,
- .cols = init_cols,
- .rows = init_rows,
- };
- t.stream = t.term.vtStream();
- // Answer terminal QUERIES back into the pty. Apps (nushell/reedline, helix, fzf
- // — anything on crossterm) query the terminal at startup and BLOCK on the reply.
- // ghostty computes the reply but drops it unless these effects are wired, so the
- // app spins forever. write_pty is the sink for DSR cursor-position (ESC[6n) and
- // the kitty-keyboard report (ESC[?u); device_attributes answers primary DA
- // (ESC[c), which crossterm sends as `ESC[?u ESC[c` and waits on the DA as the
- // TERMINATOR — without it helix hangs after entering its alt screen (a blank
- // pane). Only real terminals need this (file/image stubs read no pty).
- t.stream.handler.effects.write_pty = ptyReport;
- t.stream.handler.effects.device_attributes = ptyDeviceAttrs;
- return t;
-}
-
-// ghostty calls this with a reply (cursor-position report, kitty-keyboard flags,
-// operating status, an encoded DA, ...) to send back to the child. The handler's
-// `terminal` is our Term.term field, so recover the owning Term and echo the bytes
-// into its pty as if the child typed them. termWrite no-ops on a closed pty, so a
-// late report after EOF is harmless.
-fn ptyReport(handler: *ghostty_vt.TerminalStream.Handler, data: [:0]const u8) void {
- const t: *Term = @fieldParentPtr("term", handler.terminal);
- termWrite(t, data);
-}
-
-// device_attributes.Attributes isn't re-exported by the ghostty-vt module, so name
-// its type by reflection off the effects field. The default (VT220 + ansi_color) is
-// a fine DA reply — its only job here is to TERMINATE crossterm's capability probe.
-const DeviceAttrs = @typeInfo(@typeInfo(@typeInfo(
- @FieldType(ghostty_vt.TerminalStream.Handler.Effects, "device_attributes"),
-).optional.child).pointer.child).@"fn".return_type.?;
-fn ptyDeviceAttrs(_: *ghostty_vt.TerminalStream.Handler) DeviceAttrs {
- return .{};
-}
-
-// peel a trailing :NNN (or :NNN:) line suffix: main.zig:100 -> {main.zig, 100}.
-fn parsePathLine(tok: []const u8) struct { path: []const u8, line: usize } {
- var sep: usize = 0;
- while (sep < tok.len) : (sep += 1) {
- if (tok[sep] != ':') continue;
- var j = sep + 1;
- var line: usize = 0;
- while (j < tok.len and std.ascii.isDigit(tok[j])) : (j += 1) line = line * 10 + (tok[j] - '0');
- if (j == sep + 1) continue; // no digits after ':'
- if (j < tok.len and tok[j] != ':') continue; // junk after the number
- return .{ .path = tok[0..sep], .line = line };
- }
- return .{ .path = tok, .line = 0 };
-}
-
-fn readFileAlloc(io: std.Io, gpa: std.mem.Allocator, path: [*:0]const u8) ![]u8 {
- return std.Io.Dir.cwd().readFileAlloc(io, std.mem.span(path), gpa, .limited(std.math.maxInt(usize)));
-}
-
-fn refreshFileHighlightsVisible(gpa: std.mem.Allocator, f: *Term.File, rows: usize) void {
- const start_row = f.scroll -| SYNTAX_CONTEXT_BEFORE_ROWS;
- const range = visibleByteRange(f.content, start_row, SYNTAX_CONTEXT_BEFORE_ROWS + rows + SYNTAX_CONTEXT_AFTER_ROWS);
- if (!colors_on) {
- if (f.highlights.len > 0) gpa.free(f.highlights);
- f.highlights = &.{};
- f.highlight_start = 0;
- f.syntax_dirty = false;
- return;
- }
- const new_highlights = highlightFileRange(gpa, f.path, f.content, range.start, range.end) catch {
- f.syntax_dirty = false;
- return;
- };
- if (f.highlights.len > 0) gpa.free(f.highlights);
- f.highlights = new_highlights;
- f.highlight_start = if (f.highlights.len > 0) range.start else 0;
- f.syntax_dirty = false;
-}
-
-fn refreshDirtyFileHighlightsVisible(gpa: std.mem.Allocator, terms: *[MAX_TERMS]?*Term) bool {
- var refreshed = false;
- for (terms) |*slot| {
- if (slot.*) |t| {
- if (t.file) |*f| {
- if (f.syntax_dirty) {
- refreshFileHighlightsVisible(gpa, f, t.rows);
- refreshed = true;
- }
- }
- }
- }
- return refreshed;
-}
-
-// Replace a file pane's content without synchronously reparsing the whole file.
-// The old style map remains in use until a bounded visible-range refresh catches
-// up, so edits do not flash to unhighlighted text.
-fn setFileContent(gpa: std.mem.Allocator, f: *Term.File, new: []u8) void {
- const had_syntax = f.syntax_dirty or f.highlights.len > 0;
- gpa.free(f.content);
- f.content = new;
- if (had_syntax and colors_on) {
- // Keep the old style map while dirty. This preserves most visible color
- // state during input batching; the synchronous visible-range refresh below
- // swaps in a correctly offset map before the next frame is rendered.
- f.syntax_dirty = true;
- } else {
- if (f.highlights.len > 0) gpa.free(f.highlights);
- f.highlights = &.{};
- f.highlight_start = 0;
- f.syntax_dirty = false;
- }
-}
-
-fn setFileContentStaleSyntax(gpa: std.mem.Allocator, f: *Term.File, new: []u8) void {
- setFileContent(gpa, f, new);
-}
-
-// Open a read/editable FILE pane: a Term with `file` set, sharing all the
-// layout/selection/edit machinery. No pty (handle -1), no reader, a stub 1x1
-// emulator (only its allocator-owned bits are touched). `line` (1-based, 0=none)
-// scrolls the target near the top. Caller wires it into the layout + frees later.
-fn openFileView(io: std.Io, gpa: std.mem.Allocator, rp: [*:0]const u8, line: usize, cols: u16, rows: u16) !*Term {
- const content = try readFileAlloc(io, gpa, rp);
- errdefer gpa.free(content);
- const path = try gpa.dupe(u8, std.mem.span(rp));
- errdefer gpa.free(path);
- const t = try gpa.create(Term);
- errdefer gpa.destroy(t);
- const total = fileLineCount(content);
- const scroll: usize = if (line > 0 and line <= total) line - 1 else 0;
- t.* = .{
- .pty = .{ .handle = -1, .flags = .{ .nonblocking = false } },
- .pid = 0,
- .term = try ghostty_vt.Terminal.init(gpa, .{ .cols = 1, .rows = 1 }),
- .stream = undefined,
- .reader = .{ .any_future = null, .result = {} },
- .file = .{ .path = path, .content = content, .scroll = scroll, .highlights = &.{}, .syntax_dirty = true },
- .cols = cols,
- .rows = rows,
- .cur_pinned = true,
- .cur_row = @intCast(scroll),
- };
- t.stream = t.term.vtStream();
- return t;
-}
-
-// Open an IMAGE pane (the kitty-graphics side quest): a Term with `image` set,
-// sharing the layout machinery. No pty (handle -1), no reader, a stub 1x1 emulator
-// (only its allocator-owned bits are touched). The render loop transmits + draws
-// the image (see image.zig). `rp` is the absolute path. Caller wires it in + frees.
-fn openImageView(gpa: std.mem.Allocator, rp: [*:0]const u8, cols: u16, rows: u16, petscii: bool) !*Term {
- const path = try gpa.dupe(u8, std.mem.span(rp));
- errdefer gpa.free(path);
- const t = try gpa.create(Term);
- errdefer gpa.destroy(t);
- t.* = .{
- .pty = .{ .handle = -1, .flags = .{ .nonblocking = false } },
- .pid = 0,
- .term = try ghostty_vt.Terminal.init(gpa, .{ .cols = 1, .rows = 1 }),
- .stream = undefined,
- .reader = .{ .any_future = null, .result = {} },
- .image = .{ .path = path, .petscii = petscii },
- .cols = cols,
- .rows = rows,
- };
- t.stream = t.term.vtStream();
- return t;
-}
-
-// Open the built-in Tutor as a read/editable FILE pane: same machinery as
-// openFileView, but the content is the tutor text embedded at comptime (so the
-// binary is self-contained — no external file needed) and the path is a fixed
-// label. Middle-clicking "Tutor" in any tag spawns this. Caller wires it into
-// the layout + frees later.
-const tutor_text = @embedFile("tutor.txt");
-fn openTutorView(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Term {
- const content = try gpa.dupe(u8, tutor_text);
- errdefer gpa.free(content);
- const path = try gpa.dupe(u8, "/Tutor");
- errdefer gpa.free(path);
- const t = try gpa.create(Term);
- errdefer gpa.destroy(t);
- t.* = .{
- .pty = .{ .handle = -1, .flags = .{ .nonblocking = false } },
- .pid = 0,
- .term = try ghostty_vt.Terminal.init(gpa, .{ .cols = 1, .rows = 1 }),
- .stream = undefined,
- .reader = .{ .any_future = null, .result = {} },
- .file = .{ .path = path, .content = content, .scroll = 0 },
- .cols = cols,
- .rows = rows,
- .cur_pinned = true,
- .cur_row = 0,
- };
- t.stream = t.term.vtStream();
- return t;
-}
-
-fn startReader(io: std.Io, gpa: std.mem.Allocator, loop: *Loop, t: *Term, id: usize) !void {
- t.reader = try io.concurrent(readPty, .{ io, gpa, t.pty, id, loop });
-}
-
-// Before deleting `id`, hand its vertical weight to a sibling (the pane above, or
-// below if it's the first) so the freed space goes to ONE neighbor and the rest of
-// the column keeps its sizes — the deletion mirror of splitBelow. Call BEFORE
-// layoutRemove/deinit, while `id` is still in the layout and not yet freed. No-op
-// when `id` is alone in its column (the column just closes).
-fn absorbVWeight(l: *const Layout, terms: *const [MAX_TERMS]?*Term, id: usize) void {
- const f = layoutFindTerm(l, id) orelse return;
- if (l.col_n[f.col] <= 1) return;
- const gone = terms[id] orelse return;
- const sib = if (f.idx > 0) l.col_terms[f.col][f.idx - 1] else l.col_terms[f.col][f.idx + 1];
- if (terms[sib]) |s| s.vweight += gone.vweight;
-}
-
-// Open a window BELOW `src` (acme-style) without rebalancing the whole column:
-// shrink ONLY `src` to the height of its content so far — and hand the freed
-// rows to the new window `nw`. `src`+`nw` together fill `src`'s old slot, so the
-// other panes in the column keep their proportions. `src_h` is `src`'s height.
-//
-// `keep` INCLUDES the cursor row (`cursor.y + 1`): shrinking to exactly the
-// content height means ghostty only drops blank rows *below* the cursor, so the
-// source's visible text doesn't scroll. Cutting at `cursor.y` would evict the
-// cursor's row and force a 1-row scroll to keep it visible.
-fn splitBelow(src: *Term, nw: *Term, src_h: u16) void {
- const body: u16 = if (src_h > BOX_H) src_h - BOX_H else 1;
- const cur: u16 = if (src.file != null) body / 2 else @as(u16, @intCast(src.term.screens.active.cursor.y)) + 1;
- const keep = std.math.clamp(cur, 1, @max(1, body -| 1)); // both panes get >=1 row
- const w = src.vweight;
- src.vweight = w * @as(f32, @floatFromInt(BOX_H + keep)) / @as(f32, @floatFromInt(src_h));
- nw.vweight = w - src.vweight;
-}
-
-// A pristine terminal: a shell nobody has touched — no char-mode typing, the
-// cursor still on its first prompt line, and no scrollback. Used to recognize a
-// throwaway placeholder shell that a file should replace. (A file pane is never
-// pristine — it isn't a terminal.)
-fn isPristineTty(t: *Term) bool {
- if (!t.isTerminal()) return false;
- if (t.nedits != 0) return false;
- if (t.term.screens.active.cursor.y != 0) return false;
- return t.term.screens.active.pages.scrollbar().total <= t.rows;
-}
-
-// When a file lands in `col` (the files column), a leftover UNUSED shell there is
-// just clutter: if the column holds exactly one terminal and it's pristine, delete
-// it. Its weight flows to a neighbor via absorbVWeight (typically the file), so the
-// file takes its place. `keep_id` is the file, used to refocus if the shell was active.
-fn evictLonePristineTty(l: *Layout, terms: *[MAX_TERMS]?*Term, io: std.Io, gpa: std.mem.Allocator, col: usize, keep_id: usize, active: *usize) void {
- var n_tty: usize = 0;
- var tty_id: usize = 0;
- var k: usize = 0;
- while (k < l.col_n[col]) : (k += 1) {
- const cid = l.col_terms[col][k];
- if (terms[cid]) |ct| if (ct.file == null) {
- n_tty += 1;
- tty_id = cid;
- };
- }
- if (n_tty != 1) return;
- const tt = terms[tty_id] orelse return;
- if (!isPristineTty(tt)) return;
- absorbVWeight(l, terms, tty_id);
- layoutRemove(l, tty_id);
- tt.deinit(io, gpa);
- gpa.destroy(tt);
- terms[tty_id] = null;
- if (active.* == tty_id) active.* = keep_id;
-}
-
-// Find a terminal whose shell cwd is `dir`, else fork a fresh shell there and
-// stack it at the bottom of the rightmost column. Returns the terminal to drive,
-// or null if no slot is free / the fork failed. Backs middle-click "send" from a
-// file pane: the selected line runs in a shell in the file's dir. Does NOT change
-// focus — middle/execute keeps you where you were editing (right/look focuses).
-// ponytail: rightmost column; with a single column the tty stacks under the file.
-fn ttyForDir(
- io: std.Io,
- gpa: std.mem.Allocator,
- loop: *Loop,
- terms: *[MAX_TERMS]?*Term,
- layout: *Layout,
- sw: u16,
- sh: u16,
- dir: []const u8,
-) ?*Term {
- for (terms.*) |slot| if (slot) |tt| {
- if (tt.file != null) continue;
- var cb: [1024]u8 = undefined;
- if (std.mem.eql(u8, termCwd(tt, &cb), dir)) return tt;
- };
- const free: usize = for (terms.*, 0..) |slot, i| {
- if (slot == null) break i;
- } else return null;
- var dirbuf: [1024]u8 = undefined;
- const dirz = std.fmt.bufPrintZ(&dirbuf, "{s}", .{dir}) catch return null;
- const nt = forkTerm(io, gpa, sw, sh, dirz.ptr) catch return null;
- terms[free] = nt;
- startReader(io, gpa, loop, nt, free) catch {};
- const rc = if (layout.ncol > 0) layout.ncol - 1 else 0;
- layoutInsert(layout, rc, layout.col_n[rc], free);
- return nt;
-}
-
-fn termVWeight(terms: *const [MAX_TERMS]?*Term, id: usize) ?f32 {
- return if (terms[id]) |t| t.vweight else null;
-}
-
-fn setTermVWeight(terms: *[MAX_TERMS]?*Term, id: usize, weight: f32) void {
- if (terms[id]) |t| t.vweight = weight;
-}
-
-// `alloc` must be a per-frame arena, NOT freed before vx.render(): vaxis is a
-// retained renderer and stores each cell's grapheme as a *slice* into the text
-// we hand it, so plainString has to outlive the render call.
-fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, active: bool) !void {
- if (r.w <= GUTTER or r.h == 0) return;
-
- // text area: tag line on row 0 (same row as the box), terminal body below.
- const text = root.child(.{ .x_off = r.x + GUTTER, .y_off = r.y, .width = r.w - GUTTER, .height = r.h });
- text.clear();
- // light themes paint the page; dark leaves it on the terminal's own background.
- if (T().bg) |bg| text.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = rgbColor(bg) } });
-
- // tag: live prefix (mode + cwd/path) + editable tail (builtins onward), drawn
- // as a dim bar; plain selectable text, tail editable in place (handleTagKey).
- const tag_pfx = try tagPrefix(alloc, t);
- const tail = curTail(t);
- const tag = if (tail.len == 0) tag_pfx else try std.fmt.allocPrint(alloc, "{s}{s}", .{ tag_pfx, tail });
- const tagw = text.child(.{ .height = BOX_H });
- tagw.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = rgbColor(T().tag_bg) } });
- _ = tagw.printSegment(.{ .text = tag, .style = .{ .fg = rgbColor(T().tag_fg), .bg = rgbColor(T().tag_bg) } }, .{ .wrap = .none });
- // char-selection highlight on the tail (helix `v`/`x`), inclusive [lo, hi].
- if (t.tag_edit and t.tag_sel) {
- const b = tagSelBounds(t);
- var col: usize = tag_pfx.len + b.lo;
- const end: usize = tag_pfx.len + b.hi;
- while (col <= end and col < r.w - GUTTER) : (col += 1) {
- if (text.readCell(@intCast(col), 0)) |cell| {
- var nc = cell;
- nc.default = false;
- nc.style.bg = .{ .rgb = msel_bg };
- nc.style.fg = .{ .rgb = msel_fg };
- text.writeCell(@intCast(col), 0, nc);
- }
- }
- }
- // cursor while editing the tail: prefix length + column within the tail.
- if (active and t.tag_edit) {
- const cx: usize = tag_pfx.len + t.tag_col;
- if (cx < r.w - GUTTER) text.showCursor(@intCast(cx), 0);
- }
-
- // image pane: the picture fills the body; draw the box + a plain (thumbless)
- // gutter so it reads like any other pane. kitty placements are re-issued each
- // frame, so this runs every render. Nothing else (text/cursor/selection) applies.
- if (t.image) |*iv| {
- if (r.h > BOX_H) image.draw(iv, text.child(.{ .y_off = BOX_H, .height = r.h - BOX_H }), r.w - GUTTER, r.h - BOX_H);
- pane_ui.drawGutter(root, r, active, accent, accent_dim, T().scroll_track, T().scroll_thumb, null);
- return;
- }
-
- // body = tty rows with insertion runs spliced in (text shifts, not overdrawn).
- // In text mode bodyText also hides OSC 133 prompt rows (see promptFlags); the
- // shells emit OSC 133 via the rc we drop at startup (writeFile/bash_rc).
- const off: i32 = paneScroll(t);
- const body = try bodyText(alloc, t);
- const body_style: vaxis.Style = .{ .fg = colorOpt(T().fg), .bg = colorOpt(T().bg) };
- var it = std.mem.splitAny(u8, body, "\n");
- var i: u16 = 0;
- while (it.next()) |line| : (i += 1) {
- _ = text.printSegment(.{ .text = line, .style = body_style }, .{ .row_offset = BOX_H + i, .wrap = .none });
- }
-
- const body_w = r.w - GUTTER;
- const body_h = if (r.h > BOX_H) r.h - BOX_H else 0;
- // mute the file-pane line-number gutter (the first PREFIX_W cols). Cheap chrome,
- // so it's not gated on colors_on; the selection/cursor passes below still win.
- if (t.file != null) {
- const ln_fg = rgbColor(T().lineno);
- var vr: u16 = 0;
- while (vr < body_h) : (vr += 1) {
- var c: u16 = 0;
- while (c < PREFIX_W and c < body_w) : (c += 1) {
- if (text.readCell(c, BOX_H + vr)) |cell| {
- var nc = cell;
- nc.default = false;
- nc.style.fg = ln_fg;
- text.writeCell(c, BOX_H + vr, nc);
- }
- }
- }
- }
- // syntax colors (file panes): recolor each content cell from its tree-sitter
- // style. Content starts after the PREFIX_W line-number gutter; highlights are
- // indexed by content byte, and a line slice's byte offset is its ptr - content.
- const pw: usize = PREFIX_W;
- const bw: usize = body_w;
- if (colors_on) {
- if (t.file) |f| {
- if (f.highlights.len > 0) {
- var lines = std.mem.splitScalar(u8, f.content, '\n');
- var skip: usize = 0;
- while (skip < f.scroll) : (skip += 1) if (lines.next() == null) break;
- var vr: u16 = 0;
- while (vr < body_h) : (vr += 1) {
- const line = lines.next() orelse break;
- const base = @intFromPtr(line.ptr) - @intFromPtr(f.content.ptr);
- var c: usize = 0;
- while (c < line.len and pw + c < bw) : (c += 1) {
- const idx = base + c;
- if (idx < f.highlight_start) continue;
- const hidx = idx - f.highlight_start;
- if (hidx >= f.highlights.len) continue;
- const ss = synStyle(@enumFromInt(f.highlights[hidx])) orelse continue;
- const cx: u16 = @intCast(pw + c);
- if (text.readCell(cx, BOX_H + vr)) |cell| {
- var nc = cell;
- nc.default = false;
- nc.style.fg = rgbColor(ss.fg);
- nc.style.bold = ss.bold;
- text.writeCell(cx, BOX_H + vr, nc);
- }
- }
- }
- }
- } else if (t.isTerminal()) {
- // tty colors: recolor each body cell from the emulator's per-cell style, so
- // shell output (ls, prompts, TUIs) keeps its ansi colors. row i <-> viewport
- // row i, col c <-> viewport col c (1:1; edit runs are repainted just below).
- var vr: u16 = 0;
- while (vr < body_h and vr < t.rows) : (vr += 1) {
- var c: u16 = 0;
- while (c < body_w) : (c += 1) {
- const ci = t.term.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(c), .y = @intCast(vr) } }) orelse continue;
- if (ci.cell.wide == .spacer_tail) continue;
- if (text.readCell(c, BOX_H + vr)) |cell| {
- var nc = cell;
- nc.default = false;
- nc.style = cellStyle(ci);
- text.writeCell(c, BOX_H + vr, nc);
- }
- }
- }
- }
- }
-
- // repaint the inserted cells with the plain body style so typed text reads
- // EXACTLY like normal text — not a distinct overlay. (The pass is still needed:
- // the tty recolor above paints cell (c,vr) from the emulator cell at column c,
- // but inserted runs shifted the row right, so those cells would otherwise pick
- // up the wrong emulator colors.) edit.row is absolute -> (row - off) + BOX_H.
- // Skipped in tty mode: the overlay is hidden there (bodyText doesn't splice it).
- if (t.mode != .tty) for (t.edits[0..t.nedits]) |*e| {
- if (e.len == 0) continue;
- const prow = e.row - off + @as(i32, BOX_H);
- if (prow < 0 or prow >= @as(i32, r.h)) continue;
- const start = renderedCol(t, e.row, e.col, 0);
- var k: u16 = 0;
- while (k < e.len) : (k += 1) {
- const cc = start + @as(i32, k);
- if (cc < 0 or cc >= @as(i32, r.w - GUTTER)) continue;
- if (text.readCell(@intCast(cc), @intCast(prow))) |cell| {
- var nc = cell;
- nc.default = false;
- nc.style = body_style;
- text.writeCell(@intCast(cc), @intCast(prow), nc);
- }
- }
- };
-
- // recolor selected cells (pane-local visual coords), one pass per button —
- // later buttons win on overlap. .dragging = a live drag (any button); a left
- // .done is a FINISHED selection that is kept highlighted after release (it
- // clears on the next left click or a new left drag, and feeds the middle+left
- // chord as the argument). middle/right .done are transient (they fire their
- // action on release) so they are not kept visible.
- for (t.sel, 0..) |sl, b| {
- if (sl.state == .none) continue;
- if (sl.state == .done and b != @intFromEnum(Button.left)) 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;
- // file panes: don't paint over the line-number gutter.
- var col: i32 = if (t.file != null) @max(c0, @as(i32, PREFIX_W)) else c0;
- while (col <= c1 and col < @as(i32, r.w - GUTTER)) : (col += 1) {
- const cc: u16 = @intCast(col);
- if (text.readCell(cc, row)) |cell| {
- var nc = cell;
- nc.default = false; // else vaxis ignores the style on cleared cells
- nc.style.bg = .{ .rgb = SEL_BG[b] };
- nc.style.fg = .{ .rgb = sel_fg };
- text.writeCell(cc, row, nc);
- }
- }
- }
- }
- // modal line selection (helix `x`): highlight the selected body rows across
- // the text width. Only in normal mode; r0/r1 are absolute body rows.
- if (t.mode == .normal and t.msel.active) {
- const lo = @min(t.msel.r0, t.msel.r1) - off + @as(i32, BOX_H);
- const hi = @max(t.msel.r0, t.msel.r1) - off + @as(i32, BOX_H);
- var row: u16 = 0;
- while (row < r.h) : (row += 1) {
- if (@as(i32, row) < lo or @as(i32, row) > hi) continue;
- var col: u16 = if (t.file != null) PREFIX_W else 0; // skip the line-number gutter
- while (col < r.w - GUTTER) : (col += 1) {
- if (text.readCell(col, row)) |cell| {
- var nc = cell;
- nc.default = false;
- nc.style.bg = .{ .rgb = msel_bg };
- nc.style.fg = .{ .rgb = msel_fg };
- text.writeCell(col, row, nc);
- }
- }
- }
- }
- // modal char selection (helix `v`): stream-shaped highlight from the anchor to
- // the cursor (head). r0/r1 are absolute body rows; cols are content-relative on
- // file panes (offset by PREFIX_W) and visual on terminals. Normal mode only.
- if (t.mode == .normal and t.vsel.active) {
- const bnd = vselBounds(t);
- const pfx: i32 = if (t.file != null) PREFIX_W else 0;
- var ar: i32 = bnd.lo_row;
- while (ar <= bnd.hi_row) : (ar += 1) {
- const prow = ar - off + @as(i32, BOX_H);
- if (prow < 0 or prow >= @as(i32, r.h)) continue;
- // first row starts at the anchor col, last row ends at the head col
- // (inclusive); interior rows span the whole width.
- const cstart: i32 = if (ar == bnd.lo_row) bnd.lo_col + pfx else pfx;
- const cend: i32 = if (ar == bnd.hi_row) bnd.hi_col + pfx else @as(i32, r.w - GUTTER) - 1;
- var col: i32 = @max(cstart, pfx);
- while (col <= cend and col < @as(i32, r.w - GUTTER)) : (col += 1) {
- if (text.readCell(@intCast(col), @intCast(prow))) |cell| {
- var nc = cell;
- nc.default = false;
- nc.style.bg = .{ .rgb = msel_bg };
- nc.style.fg = .{ .rgb = msel_fg };
- text.writeCell(@intCast(col), @intCast(prow), nc);
- }
- }
- }
- }
- if (active and !t.tag_edit) {
- if (t.mode != .tty) {
- // text mode (normal/insert): cursor at the (scroll-adjusted) edit
- // position. until the user pins it (click/type) it tracks the shell cursor.
- const crow = if (t.cur_pinned) t.cur_row else @as(i32, @intCast(t.term.screens.active.cursor.y)) + off;
- const ccol = if (t.cur_pinned) t.cur_col else @as(i32, @intCast(t.term.screens.active.cursor.x));
- const coff = if (t.cur_pinned) t.cur_off else 0;
- const prow = crow - off + @as(i32, BOX_H);
- // insert mode uses the edit-run coordinate -> visual mapping; normal
- // mode keeps cur_col already-visual; file panes add the line-number prefix.
- const cx = if (t.file != null)
- @as(i32, PREFIX_W) + ccol + @as(i32, coff)
- else if (t.mode == .insert)
- renderedCol(t, crow, ccol, coff)
- else
- ccol;
- if (prow >= 0 and cx >= 0 and prow < @as(i32, r.h) and cx < @as(i32, r.w - GUTTER))
- text.showCursor(@intCast(cx), @intCast(prow));
- } else {
- text.showCursor(t.term.screens.active.cursor.x, t.term.screens.active.cursor.y + BOX_H);
- }
- }
-
- const scrollbar: pane_ui.Scrollbar = if (t.file) |f| .{
- .total = fileLineCount(f.content),
- .offset = f.scroll,
- .len = t.rows,
- } else blk: {
- const sb = t.term.screens.active.pages.scrollbar();
- break :blk .{ .total = sb.total, .offset = sb.offset, .len = sb.len };
- };
- pane_ui.drawGutter(root, r, active, accent, accent_dim, T().scroll_track, T().scroll_thumb, scrollbar);
-}
-
-// ---- modal editing helpers (wired onto Term by the key handler) ----
-
-const PaneLines = struct {
- lines: []const []const u8,
- row0: i32, // absolute row of lines[0]; cursor lines-row = cur_row - row0
- body: ?[]u8, // terminal only: bodyText temp; arena-owned, freed by caller
-};
-
-// The lines the cursor moves over, always with row0 = 0 (absolute rows). File: all
-// content lines. Terminal: the WHOLE history+active grid, not just the viewport, so
-// h/j/k/l/w/b/e/gg/G and the page motions ride the scrollback and ensureCursorVisible
-// scrolls the viewport to follow — exactly like a file pane. `alloc` is per-frame
-// scratch.
-fn paneCursorLines(alloc: std.mem.Allocator, t: *Term) !PaneLines {
- if (t.file) |f| {
- var ls: std.ArrayList([]const u8) = .empty;
- var it = std.mem.splitScalar(u8, f.content, '\n');
- while (it.next()) |ln| try ls.append(alloc, ln);
- return .{ .lines = try ls.toOwnedSlice(alloc), .row0 = 0, .body = null };
- }
- // Same prompt-hide + edit-splice transform as bodyText, but over every screen row
- // (dump .screen, not .viewport) so the visible window still lines up with what's
- // rendered while off-screen scrollback becomes navigable. paneCursorLines only runs
- // in normal/insert (never tty), so prompt rows are always hidden here.
- // ponytail: O(scrollback) dump+scan per keystroke — fine for normal sessions; window
- // it around the viewport if a multi-MB scrollback ever makes navigation lag.
- const full = try t.term.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} });
- defer alloc.free(full);
- var out: std.ArrayList(u8) = .empty;
- errdefer out.deinit(alloc);
- var it = std.mem.splitAny(u8, full, "\n");
- var pit = t.term.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null);
- var i: usize = 0;
- while (it.next()) |raw| : (i += 1) {
- if (i > 0) try out.append(alloc, '\n');
- const is_prompt = if (pit.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false;
- try spliceRow(alloc, &out, t, @intCast(i), if (is_prompt) "" else raw);
- }
- const body = try out.toOwnedSlice(alloc);
- var ls: std.ArrayList([]const u8) = .empty;
- var lit = std.mem.splitScalar(u8, body, '\n');
- while (lit.next()) |ln| try ls.append(alloc, ln);
- return .{ .lines = try ls.toOwnedSlice(alloc), .row0 = 0, .body = body };
-}
-
-fn freePaneLines(alloc: std.mem.Allocator, pl: PaneLines) void {
- alloc.free(pl.lines);
- if (pl.body) |b| alloc.free(b);
-}
-
-fn toModalCursor(t: *Term, pl: PaneLines) modal.Cursor {
- const r: i32 = t.cur_row - pl.row0;
- return .{ .row = @intCast(@max(0, r)), .col = @intCast(@max(0, t.cur_col)) };
-}
-
-// write a modal Cursor back to the Term (absolute row, pin, clear run offset)
-fn fromModalCursor(t: *Term, pl: PaneLines, c: modal.Cursor) void {
- t.cur_row = @as(i32, @intCast(c.row)) + pl.row0;
- t.cur_col = @intCast(c.col);
- t.cur_off = 0;
- t.cur_pinned = true;
-}
-
-// apply a motion result: move the cursor and keep it visible. A line selection
-// (`x`) collapses on motion; a char selection (`v`) is left active so the motion
-// extends it — the head is just the cursor, which fromModalCursor moved.
-fn applyMove(t: *Term, pl: PaneLines, c: modal.Cursor) void {
- fromModalCursor(t, pl, c);
- t.msel.active = false;
- t.pending = 0;
- ensureCursorVisible(t);
-}
-
-fn ensureCursorVisible(t: *Term) void {
- const off = paneScroll(t);
- const last = off + @as(i32, t.rows) - 1;
- if (t.cur_row < off) paneScrollBy(t, t.cur_row - off) else if (t.cur_row > last) paneScrollBy(t, t.cur_row - last);
-}
-
-fn alignViewportTop(t: *Term) void {
- paneScrollBy(t, t.cur_row - paneScroll(t));
-}
-fn alignViewportBottom(t: *Term) void {
- paneScrollBy(t, t.cur_row - (paneScroll(t) + @as(i32, t.rows) - 1));
-}
-fn alignViewportCenter(t: *Term) void {
- paneScrollBy(t, t.cur_row - (paneScroll(t) + @as(i32, t.rows / 2)));
-}
-
-// pin the cursor to the shell's current cursor (terminal) or the scroll top
-// (file), the first time the user interacts in normal/insert mode.
-fn pinCursor(t: *Term) void {
- if (t.cur_pinned) return;
- const off: i32 = paneScroll(t);
- if (t.file != null) {
- t.cur_row = off;
- t.cur_col = 0;
- } else {
- t.cur_row = @as(i32, @intCast(t.term.screens.active.cursor.y)) + off;
- t.cur_col = @intCast(t.term.screens.active.cursor.x);
- }
- t.cur_off = 0;
- t.cur_pinned = true;
-}
-
-// the last navigable row: file -> last content line; terminal -> viewport bottom.
-fn lastNavRow(t: *Term) i32 {
- if (t.file) |f| return @intCast(modal.lineCount(f.content) -| 1);
- return paneScroll(t) + @as(i32, t.rows) - 1;
-}
-
-const InsertAt = enum { at, append, line_start, line_end, open_below, open_above };
-
-// enter insert mode, positioning the cursor per `where`. For file panes,
-// open_below/open_above splice a newline into the content; terminals just move.
-fn enterInsert(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, where: InsertAt) void {
- pinCursor(t);
- // snapshot once per insert session so `u` undoes the whole session, not each key.
- // (file: content; terminal: the char-mode runs — both via the unified pushUndo.)
- pushUndo(gpa, t);
- const pl = paneCursorLines(talloc, t) catch {
- t.mode = .insert;
- t.msel.active = false;
- t.vsel.active = false;
- t.pending = 0;
- return;
- };
- defer freePaneLines(talloc, pl);
- const cur = toModalCursor(t, pl);
- const llen: usize = if (cur.row < pl.lines.len) pl.lines[cur.row].len else 0;
- switch (where) {
- .at => {},
- .append => fromModalCursor(t, pl, modal.charRight(pl.lines, cur)),
- .line_start => fromModalCursor(t, pl, modal.firstNonWsOf(pl.lines, cur)),
- .line_end => {
- t.cur_col = @intCast(llen);
- t.cur_pinned = true;
- },
- .open_below => {
- if (t.file) |*f| {
- const new = modal.insertAt(gpa, f.content, .{ .row = cur.row, .col = llen }, "\n") catch return;
- setFileContentStaleSyntax(gpa, f, new);
- t.cur_row = @as(i32, @intCast(cur.row + 1)) + pl.row0;
- t.cur_col = 0;
- } else {
- const bottom = paneScroll(t) + @as(i32, t.rows) - 1;
- t.cur_row = @min(bottom, t.cur_row + 1);
- t.cur_col = 0;
- }
- t.cur_off = 0;
- t.cur_pinned = true;
- },
- .open_above => {
- if (t.file) |*f| {
- const new = modal.insertAt(gpa, f.content, .{ .row = cur.row, .col = 0 }, "\n") catch return;
- setFileContentStaleSyntax(gpa, f, new);
- // current line shifts down; the new empty line is at cur.row
- t.cur_col = 0;
- } else {
- const top = paneScroll(t);
- t.cur_row = @max(top, t.cur_row - 1);
- t.cur_col = 0;
- }
- t.cur_off = 0;
- t.cur_pinned = true;
- },
- }
- // run-offset for the terminal insert model: `i` inserts before the run at the
- // cursor (off 0); the others append after any run already sitting there.
- t.cur_off = if (where == .at) 0 else if (runAt(t, t.cur_row, t.cur_col)) |i| t.edits[i].len else 0;
- t.mode = .insert;
- t.msel.active = false;
- t.vsel.active = false;
- t.pending = 0;
- ensureCursorVisible(t);
-}
-
-// the char selection [anchor, cursor] normalized to document order. Absolute body
-// rows; cols in the cursor's coords (content for files, visual for terminals).
-const Bounds = struct { lo_row: i32, lo_col: i32, hi_row: i32, hi_col: i32 };
-fn vselBounds(t: *Term) Bounds {
- var ar = t.vsel.row;
- var ac = t.vsel.col;
- var br = t.cur_row;
- var bc = t.cur_col;
- if (br < ar or (br == ar and bc < ac)) {
- const sr = ar;
- const sc = ac;
- ar = br;
- ac = bc;
- br = sr;
- bc = sc;
- }
- return .{ .lo_row = ar, .lo_col = ac, .hi_row = br, .hi_col = bc };
-}
-
-// the selected text of the char selection. File: sliced from content (content
-// cols). Terminal: sliced from the rendered body (visual cols), so it copies tty
-// output and typed runs alike. Caller frees via `alloc`.
-fn vselText(alloc: std.mem.Allocator, t: *Term) []const u8 {
- const b = vselBounds(t);
- if (t.file) |f| {
- return modal.rangeText(alloc, f.content, .{ .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 "";
- }
- const body = bodyText(alloc, t) catch return "";
- defer alloc.free(body);
- const off = paneScroll(t);
- var out: std.ArrayList(u8) = .empty;
- var it = std.mem.splitAny(u8, body, "\n");
- var i: i32 = 0;
- while (it.next()) |ln| : (i += 1) {
- const abs = i + off;
- if (abs < b.lo_row or abs > b.hi_row) continue;
- const lo: usize = if (abs == b.lo_row) @intCast(@max(0, b.lo_col)) else 0;
- const hi_excl: usize = if (abs == b.hi_row) @as(usize, @intCast(@max(0, b.hi_col))) + 1 else ln.len;
- if (abs > b.lo_row) out.append(alloc, '\n') catch return "";
- out.appendSlice(alloc, ln[@min(lo, ln.len)..@min(hi_excl, ln.len)]) catch return "";
- }
- return out.toOwnedSlice(alloc) catch "";
-}
-
-// The active modal selection (`v` char range or `x` line range) as text, or null
-// if nothing is selected. The normal-mode Enter/Tab chords use this to act on the
-// SELECTION instead of the word under the cursor. `alloc` owns the result.
-fn currentSelText(alloc: std.mem.Allocator, t: *Term) ?[]const u8 {
- if (t.vsel.active) return vselText(alloc, t);
- if (!t.msel.active) return null;
- const r0 = @min(t.msel.r0, t.msel.r1);
- const r1 = @max(t.msel.r0, t.msel.r1);
- if (t.file) |f| {
- var buf: std.ArrayList(u8) = .empty;
- const lo: usize = @intCast(@max(0, r0));
- const hi: usize = @intCast(@max(0, r1));
- var r: usize = lo;
- while (r <= hi) : (r += 1) {
- if (r > lo) buf.append(alloc, '\n') catch break;
- buf.appendSlice(alloc, modal.lineSlice(f.content, r)) catch break;
- }
- return buf.toOwnedSlice(alloc) catch null;
- }
- return yankRows(alloc, t, r0, r1);
-}
-
-// ---- dumb undo/redo: whole-state snapshots, one per edit op ----
-// File panes snapshot the content bytes (File.undo/redo); terminal panes snapshot
-// the char-mode insertion runs (Term.ed_undo/redo). One `pushUndo(t)` handles both
-// so every edit site is type-agnostic.
-const UNDO_MAX = 256;
-const EDIT_UNDO_MAX = 64; // terminal snapshots are ~2KB each; cap tighter
-
-fn editSnapEq(a: Term.EditSnap, b: Term.EditSnap) bool {
- if (a.nedits != b.nedits) return false;
- for (a.edits[0..a.nedits], b.edits[0..b.nedits]) |x, y| {
- if (x.row != y.row or x.col != y.col or x.len != y.len) return false;
- if (!std.mem.eql(u8, x.text[0..x.len], y.text[0..y.len])) return false;
- }
- return true;
-}
-
-fn snapTerm(t: *Term) Term.EditSnap {
- return .{ .edits = t.edits, .nedits = t.nedits, .cur_row = t.cur_row, .cur_col = t.cur_col, .cur_off = t.cur_off };
-}
-
-// snapshot the pane's edit state onto its undo stack before an edit, and clear
-// redo. Deduped against the stack top so repeated no-op ops don't pile up.
-fn pushUndo(gpa: std.mem.Allocator, t: *Term) void {
- if (t.file) |*f| {
- if (f.undo.items.len > 0 and std.mem.eql(u8, f.undo.items[f.undo.items.len - 1], f.content)) return;
- const snap = gpa.dupe(u8, f.content) catch return;
- f.undo.append(gpa, snap) catch {
- gpa.free(snap);
- return;
- };
- if (f.undo.items.len > UNDO_MAX) gpa.free(f.undo.orderedRemove(0));
- for (f.redo.items) |s| gpa.free(s);
- f.redo.clearRetainingCapacity();
- } else if (t.isTerminal()) {
- const snap = snapTerm(t);
- if (t.ed_undo.items.len > 0 and editSnapEq(t.ed_undo.items[t.ed_undo.items.len - 1], snap)) return;
- t.ed_undo.append(gpa, snap) catch return;
- if (t.ed_undo.items.len > EDIT_UNDO_MAX) _ = t.ed_undo.orderedRemove(0);
- t.ed_redo.clearRetainingCapacity();
- }
-}
-
-fn doUndo(gpa: std.mem.Allocator, t: *Term) void {
- if (t.file) |*f| {
- if (f.undo.items.len == 0) return;
- const cur = gpa.dupe(u8, f.content) catch return;
- f.redo.append(gpa, cur) catch {
- gpa.free(cur);
- return;
- };
- const prev = f.undo.pop() orelse return;
- setFileContent(gpa, f, prev); // transfers ownership of `prev` to f.content
- clampCursorToFile(t, f);
- return;
- }
- if (!t.isTerminal() or t.ed_undo.items.len == 0) return;
- t.ed_redo.append(gpa, snapTerm(t)) catch return;
- restoreTerm(t, t.ed_undo.pop() orelse return);
-}
-
-fn doRedo(gpa: std.mem.Allocator, t: *Term) void {
- if (t.file) |*f| {
- if (f.redo.items.len == 0) return;
- const cur = gpa.dupe(u8, f.content) catch return;
- f.undo.append(gpa, cur) catch {
- gpa.free(cur);
- return;
- };
- const next = f.redo.pop() orelse return;
- setFileContent(gpa, f, next);
- clampCursorToFile(t, f);
- return;
- }
- if (!t.isTerminal() or t.ed_redo.items.len == 0) return;
- t.ed_undo.append(gpa, snapTerm(t)) catch return;
- restoreTerm(t, t.ed_redo.pop() orelse return);
-}
-
-fn restoreTerm(t: *Term, snap: Term.EditSnap) void {
- t.edits = snap.edits;
- t.nedits = snap.nedits;
- t.cur_row = snap.cur_row;
- t.cur_col = snap.cur_col;
- t.cur_off = snap.cur_off;
- t.cur_pinned = true;
- t.vsel.active = false;
- t.msel.active = false;
- ensureCursorVisible(t);
-}
-
-// clamp the cursor into the (possibly shrunken) content and collapse selections.
-fn clampCursorToFile(t: *Term, f: *Term.File) void {
- const n = modal.lineCount(f.content);
- const row: usize = @min(@as(usize, @intCast(@max(0, t.cur_row))), if (n == 0) 0 else n - 1);
- const llen = modal.lineSlice(f.content, row).len;
- t.cur_row = @intCast(row);
- t.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, t.cur_col))), llen));
- t.cur_off = 0;
- t.cur_pinned = true;
- t.vsel.active = false;
- t.msel.active = false;
- ensureCursorVisible(t);
-}
-
-// helix `v`: toggle char-range select. Starting it anchors at the cursor; while
-// active, motions move the head (the cursor) and the selection spans [anchor,head].
-fn charSelect(t: *Term) void {
- if (t.vsel.active) {
- t.vsel.active = false;
- } else {
- t.vsel = .{ .active = true, .row = t.cur_row, .col = t.cur_col };
- }
- t.msel.active = false;
- t.cur_pinned = true;
- t.pending = 0;
-}
-
-// helix `x`: extend the line selection down by one (or start it at the cursor).
-fn lineSelect(t: *Term) void {
- if (!t.msel.active) {
- t.msel = .{ .active = true, .r0 = t.cur_row, .r1 = t.cur_row };
- } else {
- const last = lastNavRow(t);
- t.msel.r1 = @min(t.msel.r1 + 1, last);
- t.cur_row = t.msel.r1;
- }
- t.vsel.active = false;
- t.cur_col = 0;
- t.cur_off = 0;
- t.cur_pinned = true;
- t.pending = 0;
-}
-
-// drop all insertion runs whose row is in [r0, r1] (terminal delete/change).
-fn clearEditsInRange(t: *Term, r0: i32, r1: i32) void {
- var w: usize = 0;
- for (t.edits[0..t.nedits]) |e| {
- if (e.row >= r0 and e.row <= r1) continue;
- t.edits[w] = e;
- w += 1;
- }
- t.nedits = w;
-}
-
-// join the rendered body rows [r0, r1] (absolute) with '\n'. `alloc` is scratch;
-// caller (setYank) copies it into the gpa-owned register before the frame ends.
-fn yankRows(alloc: std.mem.Allocator, t: *Term, r0: i32, r1: i32) []const u8 {
- const body = bodyText(alloc, t) catch return "";
- defer alloc.free(body);
- const off = paneScroll(t);
- var out: std.ArrayList(u8) = .empty;
- var it = std.mem.splitAny(u8, body, "\n");
- var i: i32 = 0;
- while (it.next()) |ln| : (i += 1) {
- const abs = i + off;
- if (abs < r0 or abs > r1) continue;
- if (abs > r0) out.append(alloc, '\n') catch return "";
- out.appendSlice(alloc, ln) catch return "";
- }
- return out.toOwnedSlice(alloc) catch "";
-}
-
-fn normalDelete(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term) void {
- if (t.vsel.active) {
- const b = vselBounds(t);
- setYank(gpa, vselText(talloc, t)); // read the range before the cursor/content move
- pushUndo(gpa, t);
- if (t.file) |*f| {
- const d = modal.deleteRange(gpa, f.content, .{ .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 {
- t.vsel.active = false;
- return;
- };
- setFileContent(gpa, f, d.content);
- gpa.free(d.deleted);
- t.cur_row = b.lo_row;
- t.cur_col = b.lo_col;
- clampCursorToFile(t, f);
- } else {
- // terminal: can't delete shell output — only drop typed runs in the span.
- clearEditsInRange(t, b.lo_row, b.hi_row);
- t.cur_row = b.lo_row;
- t.cur_col = b.lo_col;
- t.cur_off = 0;
- t.cur_pinned = true;
- t.vsel.active = false;
- ensureCursorVisible(t);
- }
- return;
- }
- if (t.msel.active) {
- const r0 = @min(t.msel.r0, t.msel.r1);
- const r1 = @max(t.msel.r0, t.msel.r1);
- pushUndo(gpa, t);
- if (t.file) |*f| {
- const d = modal.deleteLines(gpa, f.content, @intCast(@max(0, r0)), @intCast(@max(0, r1))) catch {
- t.msel.active = false;
- return;
- };
- setFileContent(gpa, f, d.content);
- setYank(gpa, d.deleted);
- gpa.free(d.deleted);
- const n = modal.lineCount(f.content);
- t.cur_row = @intCast(@min(@as(usize, @intCast(r0)), if (n == 0) 0 else n - 1));
- t.cur_col = 0;
- } else {
- clearEditsInRange(t, r0, r1);
- setYank(gpa, yankRows(talloc, t, r0, r1));
- }
- t.msel.active = false;
- t.cur_off = 0;
- t.cur_pinned = true;
- ensureCursorVisible(t);
- } else if (t.file) |*f| {
- pushUndo(gpa, t);
- const c = modal.Cursor{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) };
- const new = modal.deleteChar(gpa, f.content, c) catch return;
- setFileContent(gpa, f, new);
- } else {
- // terminal, no selection: can't delete shell output, but CAN delete a char
- // of your own inserted run sitting under the (visual) cursor.
- deleteRunCharAtCursor(gpa, t);
- }
-}
-
-// delete the inserted char under the normal-mode cursor (visual col) from its run,
-// if any. tty output is untouched. No-op when the cursor isn't over a typed run.
-fn deleteRunCharAtCursor(gpa: std.mem.Allocator, t: *Term) void {
- for (t.edits[0..t.nedits]) |*e| {
- if (e.row != t.cur_row or e.len == 0) continue;
- const vstart = renderedCol(t, e.row, e.col, 0);
- if (t.cur_col < vstart or t.cur_col >= vstart + @as(i32, e.len)) continue;
- pushUndo(gpa, t);
- var k: usize = @intCast(t.cur_col - vstart);
- while (k + 1 < e.len) : (k += 1) e.text[k] = e.text[k + 1];
- e.len -= 1;
- t.cur_pinned = true;
- return;
- }
-}
-
-fn normalYank(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term) void {
- if (t.vsel.active) {
- const b = vselBounds(t);
- setYank(gpa, vselText(talloc, t));
- t.cur_row = b.lo_row;
- t.cur_col = b.lo_col;
- t.cur_off = 0;
- t.cur_pinned = true;
- t.vsel.active = false;
- ensureCursorVisible(t);
- return;
- }
- if (t.msel.active) {
- const r0 = @min(t.msel.r0, t.msel.r1);
- const r1 = @max(t.msel.r0, t.msel.r1);
- if (t.file) |*f| {
- var buf: std.ArrayList(u8) = .empty;
- var r: usize = @intCast(@max(0, r0));
- const hi: usize = @intCast(@max(0, r1));
- const lo: usize = @intCast(@max(0, r0));
- while (r <= hi) : (r += 1) {
- if (r > lo) buf.append(gpa, '\n') catch break;
- buf.appendSlice(gpa, modal.lineSlice(f.content, r)) catch break;
- }
- setYank(gpa, buf.items);
- buf.deinit(gpa);
- } else {
- setYank(gpa, yankRows(talloc, t, r0, r1));
- }
- t.cur_row = r0;
- t.cur_col = 0;
- t.msel.active = false;
- } else if (t.file) |f| {
- setYank(gpa, modal.lineSlice(f.content, @intCast(@max(0, t.cur_row))));
- } else {
- setYank(gpa, yankRows(talloc, t, t.cur_row, t.cur_row));
- }
-}
-
-fn normalPaste(gpa: std.mem.Allocator, t: *Term) void {
- const y = yank_reg orelse return;
- pushUndo(gpa, t);
- if (t.file) |*f| {
- const new = modal.pasteLineBelow(gpa, f.content, @intCast(@max(0, t.cur_row)), y) catch return;
- setFileContent(gpa, f, new);
- t.cur_row += 1;
- t.cur_col = 0;
- } else {
- // terminal: spill the yanked text into an insertion run at the cursor
- insertRunText(t, y);
- }
- t.cur_off = if (runAt(t, t.cur_row, t.cur_col)) |i| t.edits[i].len else 0;
- t.cur_pinned = true;
- ensureCursorVisible(t);
-}
-
-fn normalChange(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term) void {
- if (t.vsel.active) {
- const b = vselBounds(t);
- setYank(gpa, vselText(talloc, t));
- pushUndo(gpa, t);
- if (t.file) |*f| {
- const d = modal.deleteRange(gpa, f.content, .{ .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 {
- t.vsel.active = false;
- return;
- };
- setFileContent(gpa, f, d.content);
- gpa.free(d.deleted);
- t.cur_row = b.lo_row;
- t.cur_col = b.lo_col;
- clampCursorToFile(t, f);
- } else {
- clearEditsInRange(t, b.lo_row, b.hi_row);
- t.cur_row = b.lo_row;
- t.cur_col = b.lo_col;
- t.cur_off = 0;
- t.cur_pinned = true;
- t.vsel.active = false;
- ensureCursorVisible(t);
- }
- t.mode = .insert;
- t.pending = 0;
- return;
- }
- if (t.msel.active) {
- const r0 = @min(t.msel.r0, t.msel.r1);
- const r1 = @max(t.msel.r0, t.msel.r1);
- pushUndo(gpa, t);
- if (t.file) |*f| {
- // keep one empty line at r0, drop the rest; yank the removed text
- var buf: std.ArrayList(u8) = .empty;
- var r: usize = @intCast(@max(0, r0));
- const hi: usize = @intCast(@max(0, r1));
- const lo: usize = @intCast(@max(0, r0));
- while (r <= hi) : (r += 1) {
- if (r > lo) buf.append(gpa, '\n') catch break;
- buf.appendSlice(gpa, modal.lineSlice(f.content, r)) catch break;
- }
- setYank(gpa, buf.items);
- buf.deinit(gpa);
- if (r1 > r0) {
- const d = modal.deleteLines(gpa, f.content, @intCast(@max(0, r0 + 1)), @intCast(@max(0, r1))) catch {
- t.msel.active = false;
- return;
- };
- setFileContent(gpa, f, d.content);
- gpa.free(d.deleted);
- }
- const cl = modal.clearLine(gpa, f.content, @intCast(@max(0, r0))) catch {
- t.msel.active = false;
- return;
- };
- setFileContent(gpa, f, cl);
- t.cur_row = r0;
- t.cur_col = 0;
- } else {
- clearEditsInRange(t, r0, r1);
- setYank(gpa, yankRows(talloc, t, r0, r1));
- t.cur_row = r0;
- t.cur_col = 0;
- }
- t.msel.active = false;
- t.cur_off = 0;
- t.cur_pinned = true;
- t.mode = .insert;
- t.pending = 0;
- ensureCursorVisible(t);
- } else if (t.file) |*f| {
- pushUndo(gpa, t);
- const c = modal.Cursor{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) };
- const new = modal.deleteChar(gpa, f.content, c) catch return;
- setFileContent(gpa, f, new);
- t.mode = .insert;
- t.cur_off = 0;
- t.pending = 0;
- } else {
- // terminal, no selection: just enter insert at the cursor (snapshot first so
- // the typing that follows is undoable as one group).
- pushUndo(gpa, t);
- t.mode = .insert;
- t.cur_off = 0;
- t.pending = 0;
- }
-}
-
-// insert `text` into the terminal run model at the cursor (the existing char-mode
-// splice). Factored out so `p`aste reuses it.
-fn insertRunText(t: *Term, text: []const u8) void {
- var idx: ?usize = runAt(t, t.cur_row, t.cur_col);
- if (idx == null and t.nedits < Term.MAX_EDITS) {
- t.edits[t.nedits] = .{ .row = t.cur_row, .col = t.cur_col };
- t.nedits += 1;
- idx = t.nedits - 1;
- }
- if (idx) |ei| {
- const e = &t.edits[ei];
- for (text) |ch| {
- if (e.len >= Term.EDIT_CAP) break;
- if (t.cur_off > e.len) t.cur_off = e.len;
- var k: usize = e.len;
- while (k > t.cur_off) : (k -= 1) e.text[k] = e.text[k - 1];
- e.text[t.cur_off] = ch;
- e.len += 1;
- t.cur_off += 1;
- }
- }
-}
-
-// normal-mode (helix-style) key dispatch on a pane. The cursor is a block that
-// moves over the rendered text (file content lines, or the terminal viewport);
-// paneCursorLines builds that view per keystroke. Bindings (helix defaults,
-// adapted to the acme text model):
-// move h j k l (or arrows) word w b e / W B E (long)
-// line 0 $ ^ (or home/end) goto g g (top) G (last) g e g h g l
-// page Ctrl-d/u (half) Ctrl-f/b (full) PgUp/PgDn
-// viewport z t / z z / z b (top/center/bottom)
-// insert i a I A o O (at/append/line-start/line-end/below/above)
-// select v (char range, motions extend) x (whole lines, extend down)
-// edit delete d change c yank y paste p undo u redo U
-// chord Enter -> acme look (open file/dir) Tab -> acme execute (run it)
-// (act on the selection if one is active, else the word under cursor)
-// mode Esc collapses the selection; insert Esc -> normal; configured Ctrl key -> raw tty
-// window Ctrl-w h/j/k/l focus neighbor pane (Alt-n new term, Alt-c new col)
-// File panes mutate their content (real edits) with whole-content undo/redo;
-// terminals yank rendered text and paste it as an insertion run (shell output can't
-// be deleted, so d/c there only clear pasted runs in the selected rows + yank).
-// Enter raw tty mode (the configured Ctrl-key toggle; terminals only). tty-native: if the shell is at a
-// prompt and the modal cursor sits on the current input line, move the shell's
-// REAL cursor there via ghostty's promptClickMove (sends left/right arrows the
-// shell understands) — so dropping into tty lands where you navigated: at the
-// prompt start when the input is empty, or at/after typed text at the end. Then
-// hand the keyboard to the pty. If the shell isn't at a prompt (outputting) or
-// the cursor is above the prompt, just switch — the shell cursor stays put.
-// Requires OSC 133 with cl=line (our bash_rc emits it).
-fn enterTty(t: *Term) void {
- const screen = t.term.screens.active;
- if (t.term.cursorIsAtPrompt()) {
- const off = paneScroll(t);
- const vp_row: i32 = t.cur_row - off;
- if (vp_row >= 0) {
- if (screen.pages.pin(.{ .viewport = .{ .x = @intCast(@max(0, t.cur_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) termWrite(t, "\x1b[D");
- i = 0;
- while (i < m.right) : (i += 1) termWrite(t, "\x1b[C");
- }
- }
- }
- }
- }
- t.mode = .tty;
- t.msel.active = false;
- t.vsel.active = false;
- t.pending = 0;
-}
-
-fn handleNormal(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, key: vaxis.Key) void {
- pinCursor(t);
- const pl = paneCursorLines(talloc, t) catch return;
- defer freePaneLines(talloc, pl);
- const cur = toModalCursor(t, pl);
- const lines = pl.lines;
- const half: usize = @max(1, t.rows / 2);
-
- // Esc collapses any selection / cancels a pending prefix (helix). It does NOT
- // drop terminals to tty — that's the deliberate configured Ctrl-key toggle.
- if (key.matches(vaxis.Key.escape, .{})) {
- t.vsel.active = false;
- t.msel.active = false;
- t.pending = 0;
- return;
- }
-
- // prefix continuations
- if (t.pending == 'g') {
- t.pending = 0;
- if (key.matches('g', .{})) return applyMove(t, pl, modal.gotoFirst());
- if (key.matches('e', .{})) return applyMove(t, pl, modal.gotoLast(lines));
- if (key.matches('h', .{})) return applyMove(t, pl, modal.lineStart(cur));
- if (key.matches('l', .{})) return applyMove(t, pl, modal.lineEnd(lines, cur));
- return;
- }
- if (t.pending == 'z') {
- t.pending = 0;
- if (key.matches('t', .{})) return alignViewportTop(t);
- if (key.matches('z', .{})) return alignViewportCenter(t);
- if (key.matches('b', .{})) return alignViewportBottom(t);
- return;
- }
- if (key.matches('g', .{})) {
- t.pending = 'g';
- return;
- }
- if (key.matches('z', .{})) {
- t.pending = 'z';
- return;
- }
-
- // movement
- if (key.matches('h', .{}) or key.matches(vaxis.Key.left, .{})) return applyMove(t, pl, modal.charLeft(cur));
- if (key.matches('l', .{}) or key.matches(vaxis.Key.right, .{})) return applyMove(t, pl, modal.charRight(lines, cur));
- if (key.matches('j', .{}) or key.matches(vaxis.Key.down, .{})) return applyMove(t, pl, modal.lineDown(lines, cur));
- if (key.matches('k', .{}) or key.matches(vaxis.Key.up, .{})) return applyMove(t, pl, modal.lineUp(lines, cur));
- if (key.matches('w', .{})) return applyMove(t, pl, modal.nextWordStart(lines, cur, false));
- if (key.matches('b', .{})) return applyMove(t, pl, modal.prevWordStart(lines, cur, false));
- if (key.matches('e', .{})) return applyMove(t, pl, modal.nextWordEnd(lines, cur, false));
- if (key.matches('W', .{})) return applyMove(t, pl, modal.nextWordStart(lines, cur, true));
- if (key.matches('B', .{})) return applyMove(t, pl, modal.prevWordStart(lines, cur, true));
- if (key.matches('E', .{})) return applyMove(t, pl, modal.nextWordEnd(lines, cur, true));
- if (key.matches('0', .{}) or key.matches(vaxis.Key.home, .{})) return applyMove(t, pl, modal.lineStart(cur));
- if (key.matches('$', .{}) or key.matches(vaxis.Key.end, .{})) return applyMove(t, pl, modal.lineEnd(lines, cur));
- if (key.matches('^', .{})) return applyMove(t, pl, modal.firstNonWsOf(lines, cur));
- if (key.matches('G', .{})) return applyMove(t, pl, modal.gotoLast(lines));
- if (key.matches('d', .{ .ctrl = true }) or key.matches(vaxis.Key.page_down, .{}))
- return applyMove(t, pl, modal.clampToChar(lines, modal.pageDown(cur, half)));
- if (key.matches('u', .{ .ctrl = true }) or key.matches(vaxis.Key.page_up, .{}))
- return applyMove(t, pl, modal.clampToChar(lines, modal.pageUp(cur, half)));
- if (key.matches('f', .{ .ctrl = true }))
- return applyMove(t, pl, modal.clampToChar(lines, modal.pageDown(cur, @as(usize, t.rows))));
- if (key.matches('b', .{ .ctrl = true }))
- return applyMove(t, pl, modal.clampToChar(lines, modal.pageUp(cur, @as(usize, t.rows))));
-
- // insert entry
- if (key.matches('i', .{})) return enterInsert(gpa, talloc, t, .at);
- if (key.matches('a', .{})) return enterInsert(gpa, talloc, t, .append);
- if (key.matches('I', .{})) return enterInsert(gpa, talloc, t, .line_start);
- if (key.matches('A', .{})) return enterInsert(gpa, talloc, t, .line_end);
- if (key.matches('o', .{})) return enterInsert(gpa, talloc, t, .open_below);
- if (key.matches('O', .{})) return enterInsert(gpa, talloc, t, .open_above);
-
- // selection + edit (helix `v` char range / `x` lines, then d/c/y; p pastes)
- if (key.matches('v', .{})) return charSelect(t);
- if (key.matches('x', .{})) return lineSelect(t);
- if (key.matches('d', .{})) return normalDelete(gpa, talloc, t);
- if (key.matches('c', .{})) return normalChange(gpa, talloc, t);
- if (key.matches('y', .{})) return normalYank(gpa, talloc, t);
- // `p` is intercepted in the main loop: it pastes the SYSTEM clipboard, which
- // requires an OSC 52 round-trip (async .paste event), so it can't be done here.
- if (key.matches('u', .{})) return doUndo(gpa, t);
- if (key.matches('U', .{})) return doRedo(gpa, t);
-}
-
-// insert-mode key dispatch on a pane. File: real content editing; terminal: the
-// char-mode run splice (text shifts the underlying tty row right).
-fn handleInsert(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, key: vaxis.Key) void {
- if (t.file) |*f| {
- const c = modal.Cursor{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) };
- if (key.text) |text| {
- const new = modal.insertAt(gpa, f.content, c, text) catch return;
- setFileContentStaleSyntax(gpa, f, new);
- for (text) |ch| {
- if (ch == '\n') {
- t.cur_row += 1;
- t.cur_col = 0;
- } else t.cur_col += 1;
- }
- t.cur_pinned = true;
- ensureCursorVisible(t);
- return;
- }
- switch (key.codepoint) {
- vaxis.Key.enter => {
- const new = modal.insertAt(gpa, f.content, c, "\n") catch return;
- setFileContentStaleSyntax(gpa, f, new);
- t.cur_row += 1;
- t.cur_col = 0;
- t.cur_pinned = true;
- ensureCursorVisible(t);
- },
- vaxis.Key.backspace => {
- if (t.cur_col > 0) {
- const new = modal.deleteChar(gpa, f.content, .{ .row = c.row, .col = c.col - 1 }) catch return;
- setFileContentStaleSyntax(gpa, f, new);
- t.cur_col -= 1;
- } else if (t.cur_row > 0) {
- const prevlen = modal.lineSlice(f.content, c.row - 1).len;
- const new = modal.deleteChar(gpa, f.content, .{ .row = c.row - 1, .col = prevlen }) catch return;
- setFileContentStaleSyntax(gpa, f, new);
- t.cur_row -= 1;
- t.cur_col = @intCast(prevlen);
- }
- t.cur_pinned = true;
- ensureCursorVisible(t);
- },
- vaxis.Key.left, vaxis.Key.right, vaxis.Key.up, vaxis.Key.down => {
- const pl = paneCursorLines(talloc, t) catch return;
- defer freePaneLines(talloc, pl);
- const cur0 = toModalCursor(t, pl);
- const nc = switch (key.codepoint) {
- vaxis.Key.left => modal.charLeft(cur0),
- vaxis.Key.right => modal.charRight(pl.lines, cur0),
- vaxis.Key.up => modal.lineUp(pl.lines, cur0),
- vaxis.Key.down => modal.lineDown(pl.lines, cur0),
- else => cur0,
- };
- fromModalCursor(t, pl, nc);
- ensureCursorVisible(t);
- },
- else => {},
- }
- return;
- }
-
- // terminal: char-mode run insert. Editing only ever touches YOUR inserted runs
- // (the shell's output underneath is immutable), so backspace erases within the
- // run at the cursor and stops at its start.
- if (!t.cur_pinned) pinCursor(t);
- if (key.text) |text| {
- insertRunText(t, text);
- } else switch (key.codepoint) {
- vaxis.Key.backspace => {
- if (runAt(t, t.cur_row, t.cur_col)) |ei| {
- const e = &t.edits[ei];
- if (t.cur_off > 0 and e.len > 0) {
- var k: usize = t.cur_off - 1;
- while (k + 1 < e.len) : (k += 1) e.text[k] = e.text[k + 1];
- e.len -= 1;
- t.cur_off -= 1;
- }
- }
- },
- vaxis.Key.left => {
- if (t.cur_off > 0) t.cur_off -= 1 else {
- t.cur_col = @max(0, t.cur_col - 1);
- t.cur_off = if (runAt(t, t.cur_row, t.cur_col)) |i| t.edits[i].len else 0;
- }
- },
- vaxis.Key.right => {
- const rl: u16 = if (runAt(t, t.cur_row, t.cur_col)) |i| t.edits[i].len else 0;
- if (t.cur_off < rl) t.cur_off += 1 else {
- t.cur_col += 1;
- t.cur_off = 0;
- }
- },
- vaxis.Key.up => {
- const off = paneScroll(t);
- t.cur_row = @max(off, t.cur_row - 1);
- t.cur_off = 0;
- },
- vaxis.Key.down => {
- const off = paneScroll(t);
- t.cur_row = @min(off + @as(i32, t.rows) - 1, t.cur_row + 1);
- t.cur_off = 0;
- },
- else => {},
- }
-}
-
-fn isBatchableInsertKey(key: vaxis.Key) bool {
- if (key.mods.ctrl or key.mods.alt) return false;
- if (key.text != null) return true;
- return key.matches(vaxis.Key.backspace, .{});
-}
-
-fn isBatchableNormalMoveKey(key: vaxis.Key) bool {
- if (key.mods.alt) return false;
- if (key.matches('h', .{}) or key.matches(vaxis.Key.left, .{})) return true;
- if (key.matches('j', .{}) or key.matches(vaxis.Key.down, .{})) return true;
- if (key.matches('k', .{}) or key.matches(vaxis.Key.up, .{})) return true;
- if (key.matches('l', .{}) or key.matches(vaxis.Key.right, .{})) return true;
- if (key.matches('d', .{ .ctrl = true }) or key.matches(vaxis.Key.page_down, .{})) return true;
- if (key.matches('u', .{ .ctrl = true }) or key.matches(vaxis.Key.page_up, .{})) return true;
- if (key.matches('f', .{ .ctrl = true }) or key.matches('b', .{ .ctrl = true })) return true;
- return false;
-}
-
-// tty mode: forward the key to the pty as terminal input. Ctrl+letter is sent as
-// the raw control byte (so the shell sees Ctrl-D EOF, Ctrl-L clear, ...).
-fn forwardToPty(t: *Term, key: vaxis.Key) void {
- var cbuf: [1]u8 = undefined;
- const bytes: ?[]const u8 = blk: {
- if (key.mods.ctrl) {
- const cp = key.codepoint;
- if (cp >= 'a' and cp <= 'z') {
- cbuf[0] = @intCast(cp - 0x60);
- break :blk cbuf[0..1];
- }
- if (cp == '@') {
- cbuf[0] = 0;
- break :blk cbuf[0..1];
- }
- if (cp >= '[' and cp <= '_') {
- cbuf[0] = @intCast(cp - 0x40);
- break :blk cbuf[0..1];
- }
- if (cp >= 'A' and cp <= 'Z') {
- cbuf[0] = @intCast(cp - 0x40);
- break :blk cbuf[0..1];
- }
- }
- if (key.text) |text| break :blk text;
- break :blk switch (key.codepoint) {
- vaxis.Key.enter => "\r",
- vaxis.Key.backspace => "\x7f",
- vaxis.Key.tab => "\t",
- vaxis.Key.escape => "\x1b",
- vaxis.Key.up => "\x1b[A",
- vaxis.Key.down => "\x1b[B",
- vaxis.Key.right => "\x1b[C",
- vaxis.Key.left => "\x1b[D",
- else => null,
- };
- };
- if (bytes) |b| termWrite(t, b);
-}
-
-// move focus to the neighbor pane in `dir`, if any. Rects are the current tiling.
-fn focusDir(active: *usize, terms: *const [MAX_TERMS]?*Term, rects: *const [MAX_TERMS]Rect, dir: enum { left, right, up, down }) void {
- const a = rects[active.*];
- var best: ?usize = null;
- var best_d: i32 = 0;
- for (terms, 0..) |slot, i| {
- if (slot == null or i == active.*) continue;
- const r = rects[i];
- const vov = a.y < r.y + r.h and r.y < a.y + a.h; // vertical overlap
- const hov = a.x < r.x + r.w and r.x < a.x + a.w; // horizontal overlap
- const ok = switch (dir) {
- .left => r.x + r.w <= a.x and vov,
- .right => r.x >= a.x + a.w and vov,
- .up => r.y + r.h <= a.y and hov,
- .down => r.y >= a.y + a.h and hov,
- };
- if (!ok) continue;
- const d: i32 = switch (dir) {
- .left => a.x - (r.x + r.w),
- .right => r.x - (a.x + a.w),
- .up => a.y - (r.y + r.h),
- .down => r.y - (a.y + a.h),
- };
- if (best == null or d < best_d) {
- best = i;
- best_d = d;
- }
- }
- if (best) |b| active.* = b;
-}
-
-// Perform an acme button-2/3 chord with `txt` as the text, from pane `id`:
-// .right = "look" (open the file/dir under the word, peeling a :line suffix),
-// .middle = "execute" (a builtin, else run the text in a shell). Shared by the
-// mouse release and the normal-mode Enter (look) / Tab (execute) keys. Returns
-// true if the app should quit (the last pane was closed).
-//
-// `arg` is the middle+left chord argument: a kept left selection captured by a
-// left press during the middle drag. For a CLI (`.send`) it is appended as a
-// trailing argument; each builtin arm decides for itself whether to use it.
-fn actOnSelection(
- io: std.Io,
- gpa: std.mem.Allocator,
- loop: *Loop,
- terms: *[MAX_TERMS]?*Term,
- layout: *Layout,
- rects: *const [MAX_TERMS]Rect,
- active: *usize,
- sw: u16,
- sh: u16,
- images_ok: bool,
- button: Button,
- id: usize,
- txt: []const u8,
- arg: ?[]const u8,
-) bool {
- const t = terms[id] orelse return false;
- // image-pane tagline toggles (middle-click, acme execute): Petscii on/off, the
- // C64/Term color mode, and Ascii glyphs. Intercept before the builtin/run dispatch.
- if (button == .middle) if (t.image) |*iv| {
- const w = std.mem.trim(u8, txt, " \t\r\n");
- if (std.mem.eql(u8, w, "Petscii")) {
- iv.petscii = !iv.petscii;
- return false;
- }
- if (std.mem.eql(u8, w, "C64") or std.mem.eql(u8, w, "Term")) {
- iv.pmode = if (iv.pmode == .commodore) .terminal else .commodore;
- return false;
- }
- if (std.mem.eql(u8, w, "Ascii")) {
- iv.ascii = !iv.ascii;
- return false;
- }
- };
- if (button == .right) {
- // acme "look": resolve the selected word (peeling a :line suffix) relative
- // to the window's dir. A directory opens/focuses a terminal there + ls; a
- // file opens/focuses a file pane scrolled to :line.
- const word_raw = std.mem.trim(u8, txt, " \t\r\n");
- const pl = parsePathLine(word_raw);
- const word = pl.path;
- var cwdbuf: [1024]u8 = undefined;
- var joinbuf: [2048]u8 = undefined;
- var realbuf: [4096]u8 = undefined;
- const cwd = if (t.file) |f| (std.fs.path.dirname(f.path) orelse "/") else termCwd(t, &cwdbuf);
- const joined: ?[:0]u8 = if (word.len == 0) null else if (word[0] == '/')
- (std.fmt.bufPrintZ(&joinbuf, "{s}", .{word}) catch null)
- else
- (std.fmt.bufPrintZ(&joinbuf, "{s}/{s}", .{ cwd, word }) catch null);
- if (joined) |jz| if (realpath(jz.ptr, &realbuf)) |rp| if (isDir(rp)) {
- const dir = std.mem.span(rp);
- // focus an existing terminal on this dir
- var found: ?usize = null;
- for (terms, 0..) |*slot, i| if (slot.*) |tt| {
- var cb: [1024]u8 = undefined;
- if (std.mem.eql(u8, termCwd(tt, &cb), dir)) {
- found = i;
- break;
- }
- };
- if (found) |fid| {
- active.* = fid;
- if (terms[fid]) |tt| termWrite(tt, "ls\r");
- } else {
- const slot: ?usize = for (terms.*, 0..) |sl, i| {
- if (sl == null) break i;
- } else null;
- if (slot) |free| {
- if (forkTerm(io, gpa, sw, sh, rp)) |nt| {
- terms[free] = nt;
- startReader(io, gpa, loop, nt, free) catch {};
- const f = layoutFindTerm(layout, id).?;
- layoutInsert(layout, f.col, f.idx + 1, free);
- splitBelow(t, nt, rects[id].h);
- active.* = free;
- nt.greet = true;
- } else |_| {}
- }
- }
- } else {
- const fp = std.mem.span(rp);
- // image paths open as IMAGE panes; they render via kitty when supported,
- // else via the PETSCII fallback (also toggleable on the pane's tagline).
- const as_image = image.isImagePath(fp);
- // focus an existing pane already showing this path (file or image)
- var found: ?usize = null;
- for (terms, 0..) |*slot, i| if (slot.*) |tt| {
- const tp: ?[]const u8 = if (tt.file) |ff| ff.path else if (tt.image) |iv| iv.path else null;
- if (tp) |p| if (std.mem.eql(u8, p, fp)) {
- found = i;
- break;
- };
- };
- if (found) |fid| {
- active.* = fid;
- if (!as_image and pl.line > 0) if (terms[fid]) |tt| if (tt.file) |*ff|
- if (pl.line <= fileLineCount(ff.content)) {
- const next = pl.line - 1;
- if (next != ff.scroll) {
- ff.scroll = next;
- ff.syntax_dirty = true;
- }
- };
- } else {
- const slot: ?usize = for (terms.*, 0..) |sl, i| {
- if (sl == null) break i;
- } else null;
- if (slot) |free| {
- const opened: ?*Term = if (as_image)
- (openImageView(gpa, rp, sw, sh, !images_ok) catch null) // petscii by default when no kitty
- else
- (openFileView(io, gpa, rp, pl.line, sw, sh) catch null);
- if (opened) |nt| {
- terms[free] = nt;
- // If a doc pane (file/image) already exists, SPLIT it
- // (acme-style, minimal reflow): drop the new pane directly
- // below the source and shrink ONLY it — the rest of the
- // column keeps its sizes. Same rule as the tty right-click
- // split. Prefer the pane the look came from when it's a doc;
- // else the first doc pane (docs live in the LEFT column).
- var src_id: ?usize = null;
- if (t.file != null or t.image != null) src_id = id;
- if (src_id == null) for (terms.*, 0..) |sl, i| {
- if (sl) |p| if ((p.file != null or p.image != null) and i != free) {
- src_id = i;
- break;
- };
- };
- if (src_id) |sid| {
- const sf = layoutFindTerm(layout, sid).?;
- layoutInsert(layout, sf.col, sf.idx + 1, free);
- splitBelow(terms[sid].?, nt, rects[sid].h);
- active.* = free;
- } else {
- // No doc pane yet: stack at the top of the LEFT column
- // with a fair share, then drop a leftover unused shell.
- const lc = 0;
- const ln = layout.col_n[lc];
- if (ln > 0) {
- var vsum: f32 = 0;
- for (0..ln) |i| if (terms[layout.col_terms[lc][i]]) |p| {
- vsum += p.vweight;
- };
- nt.vweight = vsum / @as(f32, @floatFromInt(ln));
- }
- layoutInsert(layout, lc, 0, free);
- active.* = free;
- evictLonePristineTty(layout, terms, io, gpa, lc, free, active);
- }
- }
- }
- }
- };
- return false;
- }
- switch (onSelect(button, txt)) {
- .none => {},
- .debug => show_debug = !show_debug,
- .colors => colors_on = !colors_on,
- .nextcolor => paint.nextTheme(),
- .dump => dumpSnapshot(io, gpa, terms, layout, active.*, sw, sh) catch |err| {
- std.log.warn("dump {s}: {}", .{ dump.default_path, err });
- },
- .kill => return true, // close everything: signal the main loop to quit
- .save => {
- if (t.file) |*f| saveFile(io, f) catch |err| {
- std.log.warn("save {s}: {}", .{ f.path, err });
- };
- },
- .send => {
- // terminal: run in itself. file pane: run in a shell sitting in the
- // file's directory (found by cwd, else a fresh tty in the right column).
- const cmd = std.mem.trim(u8, txt, " \t\r\n");
- const dst: ?*Term = if (t.file) |f|
- ttyForDir(io, gpa, loop, terms, layout, sw, sh, std.fs.path.dirname(f.path) orelse "/")
- else
- t;
- if (dst) |dt| {
- padOutputBelowEdits(dt);
- termWrite(dt, cmd);
- // a chord argument (a kept left selection) is passed to the CLI as a
- // trailing argument; builtins above decide for themselves (most ignore).
- if (arg) |a| {
- const at = std.mem.trim(u8, a, " \t\r\n");
- if (at.len > 0) {
- termWrite(dt, " ");
- termWrite(dt, at);
- }
- }
- termWrite(dt, "\r");
- }
- },
- .newcol => {
- // new terminal in a fresh column; ls its directory.
- const slot: ?usize = for (terms.*, 0..) |sl, i| {
- if (sl == null) break i;
- } else null;
- if (slot) |free| if (layout.ncol < MAX_COLS) {
- if (forkTerm(io, gpa, sw, sh, null)) |nt| {
- terms[free] = nt;
- startReader(io, gpa, loop, nt, free) catch {};
- layoutAppendColumn(layout, free);
- active.* = free;
- nt.greet = true;
- } else |_| {}
- };
- },
- .del => {
- // close just this window
- absorbVWeight(layout, terms, id);
- layoutRemove(layout, id);
- t.deinit(io, gpa);
- gpa.destroy(t);
- terms[id] = null;
- if (active.* == id) active.* = for (terms.*, 0..) |sl, i| {
- if (sl != null) break i;
- } else return true;
- },
- .delcol => {
- // close every terminal in this one's column
- if (layoutFindTerm(layout, id)) |f| {
- var ids: [MAX_TERMS]usize = undefined;
- const nids = layout.col_n[f.col];
- for (0..nids) |k| ids[k] = layout.col_terms[f.col][k];
- for (ids[0..nids]) |tid| {
- if (terms[tid]) |tt| {
- layoutRemove(layout, tid);
- tt.deinit(io, gpa);
- gpa.destroy(tt);
- terms[tid] = null;
- }
- }
- if (terms[active.*] == null) active.* = for (terms.*, 0..) |sl, i| {
- if (sl != null) break i;
- } else return true;
- }
- },
- .tutor => {
- // spawn the Tutor as a file pane in the LEFT column (acme convention for
- // files), stacked at the top with a fair share of the column.
- const slot: ?usize = for (terms.*, 0..) |sl, i| {
- if (sl == null) break i;
- } else null;
- if (slot) |free| {
- if (openTutorView(gpa, sw, sh)) |nt| {
- terms[free] = nt;
- const lc = 0;
- const ln = layout.col_n[lc];
- if (ln > 0) {
- var vsum: f32 = 0;
- for (0..ln) |i| if (terms[layout.col_terms[lc][i]]) |p| {
- vsum += p.vweight;
- };
- nt.vweight = vsum / @as(f32, @floatFromInt(ln));
- }
- layoutInsert(layout, lc, 0, free);
- active.* = free;
- evictLonePristineTty(layout, terms, io, gpa, lc, free, active);
- } else |_| {}
- }
- },
- }
- return false;
-}
-
-// The word under the modal cursor as a pane-local selection in paneText coords
-// (row 0 is the tag; file panes carry the line-number prefix). Feed it through
-// expandSel + selectionText to get the acme chord text for the Enter/Tab keys.
-fn cursorWordSel(t: *Term) Term.Sel {
- const vrow = (t.cur_row - paneScroll(t)) + @as(i32, BOX_H);
- const vcol = if (t.file != null) t.cur_col + @as(i32, PREFIX_W) else t.cur_col;
- return .{ .state = .done, .c0 = vcol, .c1 = vcol, .r0 = vrow, .r1 = vrow };
-}
-
-pub fn main(init: std.process.Init) !void {
- const startup_main_t0 = nowNs();
- const io = init.io;
- const gpa = init.gpa;
-
- const args = try init.minimal.args.toSlice(init.arena.allocator());
- const cli = parseCli(args) catch |err| {
- try printHelp(io);
- return err;
- };
- if (cli.help) {
- try printHelp(io);
- return;
- }
- const tty_only = cli.tty_only;
- const tty_toggle = cli.tty_toggle;
- const perf_startup = cli.perf_startup;
- if (cli.debug_overlay) show_debug = true;
-
- var screen_w: u16 = 80;
- var screen_h: u16 = 24;
- var resize_count: usize = 0;
-
- var startup_alt_screen = false;
- if (vaxis.supports_terminal_prepaint) {
- paintStartupTopbar();
- if (perf_startup) writeStartupPerfMarker(startup_main_t0);
- startup_alt_screen = true;
- }
- errdefer if (startup_alt_screen)
- writeStdoutBestEffort("\x1b[?1049l");
-
- var tty_buf: [0x10000]u8 = undefined;
- var tty = try vaxis.Tty.init(io, &tty_buf);
-
- // stb_image allocator for image panes (see image.zig).
- image.start(io, gpa);
- defer image.stop();
-
- // per-frame scratch for plainString; reset (not freed) after each render so
- // the retained vaxis screen can keep referencing it through vx.render().
- var frame_arena: std.heap.ArenaAllocator = .init(gpa);
- defer frame_arena.deinit();
- defer freeYank(gpa);
- var ov_buf: [192]u8 = undefined; // overlay text; must outlive each vx.render()
-
- // system_clipboard_allocator lets the loop's reader thread decode OSC 52 paste
- // replies (delivered as .paste events). gpa is already used cross-thread by the
- // pty readers, so it's safe here too.
- var vx = try vaxis.init(io, gpa, init.environ_map, .{ .system_clipboard_allocator = gpa });
- defer vx.deinit(gpa, tty.writer());
- if (startup_alt_screen) {
- vaxis.adoptAltScreen(&vx);
- } else {
- try vx.enterAltScreen(tty.writer());
- }
- defer vx.exitAltScreen(tty.writer()) catch {};
- startup_alt_screen = false;
-
- try vx.setMouseMode(tty.writer(), true);
-
- // ---- terminals + layout ----
- var terms: [MAX_TERMS]?*Term = @splat(null);
- defer for (&terms) |*slot| {
- if (slot.*) |t| {
- t.deinit(io, gpa);
- gpa.destroy(t);
- slot.* = null;
- }
- };
-
- var layout: Layout = .{
- .ncol = 0,
- .col_weight = @splat(1),
- .col_terms = undefined,
- .col_n = @splat(0),
- };
-
- // drop the bash shell-integration rc so spawned shells emit OSC 133.
- writeFile(rc_path, bash_rc);
-
- // Fork the initial terminals BEFORE starting any io worker thread. forkpty
- // from a multithreaded process can leave the child wedged before exec; doing
- // it while still single-threaded avoids that.
- if (tty_only) {
- // tmux-style: a single shell, already in tty mode, filling one column.
- terms[0] = try forkTerm(io, gpa, screen_w, screen_h, null);
- terms[0].?.mode = .tty;
- layout.ncol = 1;
- layout.col_n[0] = 1;
- layout.col_terms[0][0] = 0;
- } else {
- // editor default: two columns, the left one split into two windows.
- terms[0] = try forkTerm(io, gpa, screen_w, screen_h, null);
- terms[1] = try forkTerm(io, gpa, screen_w, screen_h, null);
- terms[2] = try forkTerm(io, gpa, screen_w, screen_h, null);
- // greet each fresh shell with `ls` once it's been sized (see the resize
- // loop): sending it now wraps the listing to the full-screen width.
- for (terms[0..3]) |slot| if (slot) |t| {
- t.greet = true;
- };
- layout.ncol = 2;
- layout.col_n[0] = 2;
- layout.col_terms[0][0] = 0;
- layout.col_terms[0][1] = 1;
- layout.col_n[1] = 1;
- layout.col_terms[1][0] = 2;
- }
- var active: usize = 0;
-
- var loop: Loop = .init(io, &tty, &vx);
- try loop.start();
- defer loop.stop();
- try loop.installResizeHandler();
-
- // Ask the terminal what it supports; the loop's reader thread fills vx.caps
- // from the replies. We only need kitty graphics (for image panes). Keep the
- // timeout tiny — a real terminal answers in well under a millisecond, and we
- // don't want to stall startup on one that never will.
- vx.queryTerminal(tty.writer(), std.Io.Duration.fromMilliseconds(2)) catch {};
-
- // now safe to spawn the per-terminal reader tasks (no forking here)
- for (&terms, 0..) |*slot, id| {
- if (slot.*) |t| try startReader(io, gpa, &loop, t, id);
- }
-
- var rects: [MAX_TERMS]Rect = undefined;
- var col_x: [MAX_COLS]u16 = undefined;
- var col_w: [MAX_COLS]u16 = undefined;
- pane_ui.computeGeom(&layout, &terms, screen_w, screen_h, &rects, &col_x, &col_w, termVWeight);
-
- var drag: Drag = .none;
- var hover_col: u16 = 0; // last mouse position; used to tint the resize border under it
- var hover_row: u16 = 0;
- var ctrl_w_pending: bool = false; // Ctrl-w prefix for helix-style pane focus
- // middle+left chord argument: a kept left selection captured by a left press
- // during an active middle select-drag. gpa-owned; consumed (and freed) on the
- // middle release, freed on overwrite / app exit.
- var chord_arg: ?[]u8 = null;
- defer if (chord_arg) |a| gpa.free(a);
- // Paint the chrome once immediately. Without this, startup waits for the
- // first winsize/pty event before the user sees a frame.
- var pending_event: ?@TypeOf(Command.value) = .tick;
-
- while (true) {
- const event = if (pending_event) |ev| blk: {
- pending_event = null;
- break :blk ev;
- } else try loop.nextEvent();
- var drain_pty_after = false;
- var drain_insert_keys = false;
- var drain_scroll_events = false;
- var drain_normal_moves = false;
- switch (event) {
- .mouse => |mouse| {
- const mpos = pane_ui.clampMousePos(screen_w, screen_h, mouse);
- const mcol = mpos.col;
- const mrow = mpos.row;
- hover_col = mcol;
- hover_row = mrow;
-
- // which pane is under the cursor (if any)
- const hovered: ?usize = for (terms, 0..) |slot, i| {
- if (slot == null) continue;
- const r = 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;
-
- // Any press that is NOT the middle+left chord (a left press while a
- // middle select-drag is active) abandons a pending chord argument;
- // free it so it can't leak or feed a later, unrelated release.
- if (mouse.type == .press and
- !(mouse.button == .left and drag == .select and drag.select.button == .middle))
- {
- if (chord_arg) |a| {
- gpa.free(a);
- chord_arg = null;
- }
- }
-
- if (mouse.button == .wheel_down) {
- if (hovered) |id| paneScrollBy(terms[id].?, 1);
- drain_scroll_events = true;
- } else if (mouse.button == .wheel_up) {
- if (hovered) |id| paneScrollBy(terms[id].?, -1);
- drain_scroll_events = true;
- } else if (mouse.type == .press and mrow < TOPBAR_H) {
- // global tagbar: MIDDLE-click runs the command under the click
- // (acme "execute"; left-click must NOT execute — a stray left-click
- // on "Kill" would quit). id=active is just a valid pane for the
- // action context; Kill/Newcol/Tutor ignore it.
- if (mouse.button == .middle) {
- const word = wordAtCol(topbar_str, mcol);
- if (word.len > 0)
- if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, vx.caps.kitty_graphics, .middle, active, word, null)) break;
- }
- } else if (mouse.type == .press and mouse.button == .left) {
- var handled = false;
-
- // middle+left chord: a left press while a middle select-drag is
- // active captures the KEPT left selection as the argument for the
- // middle execute (fired on the middle release below). Don't start
- // a new left selection — the middle drag runs until its own
- // release, which dispatches the command with chord_arg.
- if (drag == .select and drag.select.button == .middle) {
- handled = true;
- if (terms[drag.select.id]) |t| {
- const sl = t.sel[@intFromEnum(Button.left)];
- if (sl.state == .done) {
- if (chord_arg) |old| gpa.free(old);
- chord_arg = selectionText(gpa, t, sl) catch null;
- }
- }
- }
-
- // vertical resize handle = the LEFT pane's last column (its own
- // content, not the right pane's gutter). The border check runs before
- // the pane hit test, so a press there resizes instead of clicking.
- var c: usize = 0;
- while (c + 1 < layout.ncol and !handled) : (c += 1) {
- if (mcol == col_x[c] + col_w[c] -| 1) {
- drag = .{ .border_v = .{ .left_col = c, .cur_x = mcol } };
- handled = true;
- }
- }
-
- // horizontal resize handle = the UPPER pane's last row (a's own last
- // line, sitting right above b's tag) — not b's tag row, so the tag +
- // its box-drag stay clean.
- if (!handled) {
- var cc: usize = 0;
- outer: while (cc < layout.ncol) : (cc += 1) {
- if (mcol < col_x[cc] or mcol >= col_x[cc] + col_w[cc]) continue;
- var k: usize = 0;
- while (k + 1 < layout.col_n[cc]) : (k += 1) {
- const id = layout.col_terms[cc][k];
- const r = rects[id];
- if (mrow == r.y + r.h -| 1) {
- drag = .{ .border_h = .{ .col = cc, .top_idx = k, .cur_y = mrow } };
- handled = true;
- break :outer;
- }
- }
- }
- }
-
- // inside a pane?
- if (!handled) {
- if (hovered) |id| {
- const r = rects[id];
- if (pane_ui.inMoveBox(r, mcol, mrow)) {
- drag = .{ .move = .{ .id = id, .cur_x = mcol, .cur_y = mrow } };
- } else if (pane_ui.scrollbarClickDelta(r, .left, mcol, mrow)) |delta| {
- active = id;
- if (terms[id]) |t| {
- paneScrollBy(t, delta); // left: scroll up to here
- }
- } else if (mrow < r.y + BOX_H and terms[id] != null and terms[id].?.image == null) {
- // left-click on the tag row (non-image): edit the tail.
- active = id;
- enterTagEdit(gpa, frame_arena.allocator(), terms[id].?, @as(i32, mcol) - @as(i32, r.x + GUTTER));
- } else {
- active = id;
- if (terms[id]) |t| {
- exitTagEdit(t); // clicking the body leaves tag editing
- const sc: i32 = @as(i32, mcol) - @as(i32, r.x + GUTTER);
- const v: i32 = @as(i32, mrow) - @as(i32, r.y);
- t.sel[@intFromEnum(Button.left)] = .{ .state = .dragging, .c0 = sc, .c1 = sc, .r0 = v, .r1 = v };
- drag = .{ .select = .{ .id = id, .button = .left } };
- }
- }
- }
- }
- } else if (mouse.type == .press and (mouse.button == .middle or mouse.button == .right)) {
- // middle/right select text too (own state + color), text area only.
- // Right focuses the pane (acme "look" lands you there); middle
- // (execute) does NOT — you stay where you were editing.
- const b: Button = if (mouse.button == .middle) .middle else .right;
- if (hovered) |id| {
- const r = rects[id];
- const sb_button: pane_ui.ScrollbarButton = if (b == .right) .right else .middle;
- if (pane_ui.scrollbarClickDelta(r, sb_button, mcol, mrow)) |delta| {
- // gutter (scrollbar): right scrolls down, mirroring left's up
- if (b == .right) active = id;
- if (terms[id]) |t| {
- paneScrollBy(t, delta);
- }
- } else if (mcol >= r.x + GUTTER) {
- if (b == .right) active = id;
- if (terms[id]) |t| {
- const c: i32 = @as(i32, mcol) - @as(i32, r.x + GUTTER);
- const v: i32 = @as(i32, mrow) - @as(i32, r.y);
- t.sel[@intFromEnum(b)] = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v };
- drag = .{ .select = .{ .id = id, .button = b } };
- }
- }
- }
- } else if (mouse.type == .drag) {
- switch (drag) {
- .border_v => |*d| {
- const c = d.left_col;
- if (c + 1 < layout.ncol) {
- const lo = col_x[c] + MINW;
- const hi = col_x[c] +| col_w[c] +| col_w[c + 1] -| MINW;
- d.cur_x = if (lo <= hi) std.math.clamp(mcol, lo, hi) else mcol;
- } else d.cur_x = mcol;
- },
- .border_h => |*d| {
- const cc = d.col;
- const k = d.top_idx;
- if (k + 1 < layout.col_n[cc]) {
- const a = layout.col_terms[cc][k];
- const b = layout.col_terms[cc][k + 1];
- const lo = rects[a].y + MINH;
- const hi = rects[a].y +| rects[a].h +| rects[b].h -| MINH;
- d.cur_y = if (lo <= hi) std.math.clamp(mrow, lo, hi) else mrow;
- } else d.cur_y = mrow;
- },
- .move => |*d| {
- d.cur_x = mcol;
- d.cur_y = mrow;
- },
- .select => |s| {
- if (terms[s.id]) |t| {
- const r = rects[s.id];
- t.sel[@intFromEnum(s.button)].c1 = @as(i32, mcol) - @as(i32, r.x + GUTTER);
- t.sel[@intFromEnum(s.button)].r1 = @as(i32, mrow) - @as(i32, r.y);
- }
- },
- .none => {},
- }
- } else if (mouse.type == .release) {
- var end_drag = true;
- switch (drag) {
- .border_v => |d| {
- const c = d.left_col;
- if (c + 1 < layout.ncol) {
- const combined: f32 = @floatFromInt(col_w[c] + col_w[c + 1]);
- // handle sits on the left pane's LAST column (inclusive),
- // so the new left width includes it: cur_x - col_x + 1.
- // A click with no drag then leaves the width unchanged.
- var nl: f32 = @floatFromInt((d.cur_x + 1) -| col_x[c]);
- const minw: f32 = MINW;
- nl = std.math.clamp(nl, minw, @max(minw, combined - minw));
- const pair = layout.col_weight[c] + layout.col_weight[c + 1];
- layout.col_weight[c] = pair * (nl / combined);
- layout.col_weight[c + 1] = pair - layout.col_weight[c];
- }
- },
- .border_h => |d| {
- const c = d.col;
- const k = d.top_idx;
- if (k + 1 < layout.col_n[c]) {
- const a = layout.col_terms[c][k];
- const b = layout.col_terms[c][k + 1];
- if (terms[a]) |ta| if (terms[b]) |tb| {
- const combined: f32 = @floatFromInt(rects[a].h + rects[b].h);
- // handle is the upper pane's LAST row (inclusive) ->
- // new upper height includes it: cur_y - y + 1.
- var nt: f32 = @floatFromInt((d.cur_y + 1) -| rects[a].y);
- const minh: f32 = MINH;
- nt = std.math.clamp(nt, minh, @max(minh, combined - minh));
- const pair = ta.vweight + tb.vweight;
- ta.vweight = pair * (nt / combined);
- tb.vweight = pair - ta.vweight;
- };
- }
- },
- .move => |d| {
- moveTerm(&layout, &terms, &rects, &col_x, &col_w, d.id, d.cur_x, d.cur_y, setTermVWeight);
- // a file moved into the left column evicts a lone unused shell there
- if (terms[d.id]) |mt| if (mt.file != null) {
- if (layoutFindTerm(&layout, d.id)) |f| if (f.col == 0)
- evictLonePristineTty(&layout, &terms, io, gpa, 0, d.id, &active);
- };
- },
- .select => |s| {
- // A release of a button OTHER than the one driving this
- // drag is the middle+left chord's left release: ignore it
- // so the middle drag keeps going until its own release
- // runs the command (with chord_arg). end_drag stays false.
- const released: ?Button = switch (mouse.button) {
- .left => .left,
- .middle => .middle,
- .right => .right,
- else => null,
- };
- if (released == null or released.? != s.button) {
- end_drag = false;
- } else if (terms[s.id]) |t| {
- t.sel[@intFromEnum(s.button)].state = .done;
- if (s.button == .left) {
- // left release: keep the selection highlighted if a
- // real drag selected a range (it stays until the next
- // left click / drag); a plain click (no movement)
- // clears it — the selection "goes away" on a left
- // click. Either way pin the cursor + enter normal
- // mode on text panes (tty keeps the mouse usable).
- const sl = t.sel[@intFromEnum(Button.left)];
- const dragged = sl.c0 != sl.c1 or sl.r0 != sl.r1;
- if (!dragged) t.sel[@intFromEnum(Button.left)].state = .none;
- const body_vis = sl.r1 - @as(i32, BOX_H);
- if (body_vis >= 0 and t.mode != .tty) {
- const off: i32 = paneScroll(t);
- t.cur_row = body_vis + off;
- // file panes render a line-number prefix; cur_col
- // is content-relative, so peel PREFIX_W off the click.
- t.cur_col = if (t.file != null) @max(0, sl.c1 - @as(i32, PREFIX_W)) else sl.c1;
- t.cur_off = if (runAt(t, t.cur_row, t.cur_col)) |i| t.edits[i].len else 0;
- t.cur_pinned = true;
- t.mode = .normal;
- t.msel.active = false;
- t.pending = 0;
- }
- } else {
- // middle/right: acme execute/look. A no-drag click
- // expands to the file-ish word under the cursor first.
- // chord_arg (a kept left selection captured by a left
- // press during this drag) is passed as the argument to
- // the command; builtins decide what to do with it.
- expandSel(gpa, t, &t.sel[@intFromEnum(s.button)]);
- const maybe_txt = selectionText(gpa, t, t.sel[@intFromEnum(s.button)]) catch null;
- const arg = chord_arg;
- chord_arg = null;
- defer if (arg) |a| gpa.free(a);
- if (maybe_txt) |txt| {
- defer gpa.free(txt);
- if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, vx.caps.kitty_graphics, s.button, s.id, txt, arg)) break;
- }
- }
- }
- },
- .none => {},
- }
- if (end_drag) drag = .none;
- }
- },
- .key_press => |key| kpress: {
- if (terms[active]) |at| {
- // Ctrl-w prefix: helix-style pane focus (Ctrl-w h/j/k/l).
- if (ctrl_w_pending) {
- ctrl_w_pending = false;
- if (key.matches('h', .{}) or key.matches(vaxis.Key.left, .{})) focusDir(&active, &terms, &rects, .left) else if (key.matches('l', .{}) or key.matches(vaxis.Key.right, .{})) focusDir(&active, &terms, &rects, .right) else if (key.matches('k', .{}) or key.matches(vaxis.Key.up, .{})) focusDir(&active, &terms, &rects, .up) else if (key.matches('j', .{}) or key.matches(vaxis.Key.down, .{})) focusDir(&active, &terms, &rects, .down);
- if (terms[active]) |nt| nt.pending = 0;
- break :kpress;
- }
- if (key.matches('w', .{ .ctrl = true })) {
- ctrl_w_pending = true;
- break :kpress;
- }
-
- // global window ops (any mode): Alt-n new terminal below,
- // Alt-c move active terminal into a fresh column.
- if (key.matches('n', .{ .alt = true })) {
- // new terminal below the active one, in its column.
- // ponytail: forks from the now-multithreaded process;
- // execv (not execvp) keeps the child async-signal-safe.
- const free_slot: ?usize = for (terms, 0..) |s, i| {
- if (s == null) break i;
- } else null;
- if (free_slot) |slot| {
- if (forkTerm(io, gpa, screen_w, screen_h, null)) |nt| {
- terms[slot] = nt;
- startReader(io, gpa, &loop, nt, slot) catch {};
- const f = layoutFindTerm(&layout, active).?;
- layoutInsert(&layout, f.col, f.idx + 1, slot);
- active = slot;
- nt.greet = true;
- } else |_| {}
- }
- break :kpress;
- }
- if (key.matches('c', .{ .alt = true })) {
- // move active terminal into a fresh column
- const f = layoutFindTerm(&layout, active).?;
- if (layout.ncol < MAX_COLS and layout.col_n[f.col] > 1) {
- layoutRemove(&layout, active);
- layoutAppendColumn(&layout, active);
- }
- break :kpress;
- }
-
- // Configured Ctrl key: hidden toggle in/out of raw tty mode
- // (terminals only).
- // tty forwards every key straight to the pty, so it's deliberately
- // off the normal editing path — this toggle is the only way in/out.
- if (at.isTerminal() and key.matches(tty_toggle, .{ .ctrl = true })) {
- if (at.mode == .tty) {
- at.mode = .normal;
- at.pending = 0;
- } else enterTty(at);
- break :kpress;
- }
-
- // Normal-mode Enter/Tab mirror the acme mouse chords: Enter =
- // right-click "look" (open the file/dir), Tab = middle-click
- // "execute" (run it / builtin). They act on the active selection,
- // else the file-ish word under the cursor — on the tag tail when
- // it's focused for editing, else the body.
- if (at.mode == .normal and (key.matches(vaxis.Key.enter, .{}) or key.matches(vaxis.Key.tab, .{}))) {
- const btn: Button = if (key.matches(vaxis.Key.tab, .{})) .middle else .right;
- if (at.tag_edit) {
- if (tagChordText(frame_arena.allocator(), at)) |txt| {
- at.tag_sel = false;
- if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, vx.caps.kitty_graphics, btn, active, txt, null)) break;
- }
- break :kpress;
- }
- pinCursor(at);
- if (currentSelText(frame_arena.allocator(), at)) |txt| {
- at.vsel.active = false;
- at.msel.active = false;
- if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, vx.caps.kitty_graphics, btn, active, txt, null)) break;
- } else {
- var sel = cursorWordSel(at);
- expandSel(gpa, at, &sel);
- if (selectionText(gpa, at, sel)) |word| {
- defer gpa.free(word);
- if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, vx.caps.kitty_graphics, btn, active, word, null)) break;
- } else |_| {}
- }
- break :kpress;
- }
-
- // tag editing intercepts every other key: a modal one-line editor
- // over the tail, sharing the pane's mode (handleTagKey).
- if (at.tag_edit) {
- handleTagKey(gpa, at, key);
- break :kpress;
- }
-
- // `p` (normal mode) pastes the SYSTEM clipboard: request it now;
- // the host replies with an OSC 52 sequence the loop surfaces as a
- // .paste event, which inserts the text at the cursor.
- if (at.mode == .normal and key.matches('p', .{})) {
- vx.requestSystemClipboard(tty.writer()) catch {};
- break :kpress;
- }
-
- // per-mode dispatch. Esc: insert -> normal (normal Esc is a
- // no-op, like helix). The configured Ctrl key toggles raw tty
- // on terminals (above).
- // tty forwards every key to the pty.
- switch (at.mode) {
- .normal => {
- drain_normal_moves = isBatchableNormalMoveKey(key);
- handleNormal(gpa, frame_arena.allocator(), at, key);
- },
- .insert => {
- if (key.matches(vaxis.Key.escape, .{})) {
- at.mode = .normal;
- at.msel.active = false;
- at.pending = 0;
- } else {
- drain_insert_keys = isBatchableInsertKey(key);
- handleInsert(gpa, frame_arena.allocator(), at, key);
- }
- },
- .tty => forwardToPty(at, key),
- }
- }
- },
- .pty_read => |pr| {
- feedPtyRead(gpa, &terms, active, pr);
- drain_pty_after = true;
- },
- .pty_eof => |id| {
- if (terms[id]) |t| {
- absorbVWeight(&layout, &terms, id);
- layoutRemove(&layout, id);
- t.deinit(io, gpa);
- gpa.destroy(t);
- terms[id] = null;
- // refocus the first surviving terminal; quit if none remain.
- if (active == id) active = for (terms, 0..) |s, i| {
- if (s != null) break i;
- } else break;
- }
- },
- .winsize => |ws| {
- resize_count += 1;
- screen_w = ws.cols;
- screen_h = ws.rows;
- try vx.resize(gpa, tty.writer(), ws);
- },
- .paste => |text| {
- // OSC 52 clipboard reply to our `p` request: load it into the yank
- // register and paste it at the active pane's cursor, reusing the
- // normal paste path. The register came FROM the clipboard, so don't
- // echo it straight back out.
- defer gpa.free(text);
- if (terms[active]) |t| {
- setYank(gpa, text);
- yank_dirty = false;
- normalPaste(gpa, t);
- }
- },
- .pinch => |scale| {
- ov_pinch_scale = scale;
- },
- .touch_scroll_delta => |delta| {
- ov_touch_scroll_delta = delta;
- },
- .quit => break,
- else => {},
- }
-
- if (drain_normal_moves and pending_event == null) {
- var drained: usize = 0;
- while (drained < MAX_KEY_DRAIN_EVENTS) : (drained += 1) {
- const next = try loop.tryEvent() orelse break;
- var handled = false;
- switch (next) {
- .key_press => |next_key| {
- if (isBatchableNormalMoveKey(next_key)) {
- if (terms[active]) |at| {
- if (!at.tag_edit and at.mode == .normal) {
- handleNormal(gpa, frame_arena.allocator(), at, next_key);
- handled = true;
- }
- }
- }
- },
- else => {},
- }
- if (!handled) {
- pending_event = next;
- break;
- }
- }
- }
-
- if (drain_scroll_events and pending_event == null) {
- var drained: usize = 0;
- while (drained < MAX_SCROLL_DRAIN_EVENTS) : (drained += 1) {
- const next = try loop.tryEvent() orelse break;
- var handled = false;
- switch (next) {
- .mouse => |next_mouse| {
- handled = handleWheelMouse(&terms, &rects, screen_w, screen_h, &hover_col, &hover_row, next_mouse);
- },
- .touch_scroll_delta => |delta| {
- ov_touch_scroll_delta = delta;
- handled = true;
- },
- else => {},
- }
- if (!handled) {
- pending_event = next;
- break;
- }
- }
- }
-
- if (drain_insert_keys and pending_event == null) {
- var drained: usize = 0;
- while (drained < MAX_KEY_DRAIN_EVENTS) : (drained += 1) {
- const next = try loop.tryEvent() orelse break;
- var handled = false;
- switch (next) {
- .key_press => |next_key| {
- if (isBatchableInsertKey(next_key)) {
- if (terms[active]) |at| {
- if (!at.tag_edit and at.mode == .insert) {
- handleInsert(gpa, frame_arena.allocator(), at, next_key);
- handled = true;
- }
- }
- }
- },
- else => {},
- }
- if (!handled) {
- pending_event = next;
- break;
- }
- }
- }
-
- if (drain_pty_after) {
- var drained: usize = 0;
- while (drained < MAX_PTY_DRAIN_EVENTS) : (drained += 1) {
- const next = try loop.tryEvent() orelse break;
- switch (next) {
- .pty_read => |pr| feedPtyRead(gpa, &terms, active, pr),
- else => {
- pending_event = next;
- break;
- },
- }
- }
- }
-
- // a fresh yank (y/d/c) mirrors out to the system clipboard via OSC 52. Emit
- // here where the tty writer + vx are in scope; the vx.render() below flushes.
- // ponytail: OSC 52 has a per-terminal size cap, so a huge yank may be
- // truncated by the host — the in-app register still holds the full text.
- if (yank_dirty) {
- yank_dirty = false;
- if (yank_reg) |y| vx.copyToSystemClipboard(tty.writer(), y, gpa) catch {};
- }
-
- // recompute geometry from the (possibly mutated) layout, then push any
- // grid-size change to each emulator + pty. Weights only change on a drag
- // *release*, so mid-drag the rects are unchanged and nothing resizes.
- pane_ui.computeGeom(&layout, &terms, screen_w, screen_h, &rects, &col_x, &col_w, termVWeight);
- for (&terms, 0..) |*slot, id| {
- if (slot.*) |t| {
- const r = rects[id];
- const cols = @max(1, r.w -| GUTTER);
- const rows = @max(1, r.h -| BOX_H); // the tag steals the top row
- if (cols != t.cols or rows != t.rows) {
- // file/image panes have no pty/emulator grid to reflow; just
- // record the size so bodyText renders the right number of rows.
- if (t.isTerminal()) {
- t.term.resize(gpa, cols, rows) catch {};
- pty_set_size(t.pty, rows, cols);
- }
- t.cols = cols;
- t.rows = rows;
- }
- // greet a freshly forked shell with `ls` once it has its real size
- // (after the first winsize), so the listing wraps to the pane width
- // and not the placeholder full-screen size it was forked at.
- if (t.greet and t.isTerminal() and resize_count > 0) {
- pty_set_size(t.pty, t.rows, t.cols);
- termWrite(t, "ls\r");
- t.greet = false;
- }
- }
- }
-
- _ = refreshDirtyFileHighlightsVisible(gpa, &terms);
-
- // ---- render ----
- const win = vx.window();
- win.clear();
- // paint the inter-pane gaps (and any uncovered area) with the scrollbar
- // track color so the gap reads as chrome, not raw black — most visible on
- // the light theme. Panes paint over their own rects on top of this.
- win.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = rgbColor(T().scroll_track) } });
-
- for (&terms, 0..) |*slot, id| {
- if (slot.*) |t| {
- // image panes transmit lazily (needs the tty writer, which lives
- // here on the render thread); renderPane then re-places the picture.
- if (t.image) |*iv| {
- image.ensureLoaded(iv, &vx, gpa, tty.writer());
- const r = rects[id];
- const bc = if (r.w > GUTTER) r.w - GUTTER else 0;
- const br = if (r.h > BOX_H) r.h - BOX_H else 0;
- image.ensureGrid(iv, gpa, bc, br);
- }
- try renderPane(win, frame_arena.allocator(), t, rects[id], id == active);
- }
- }
-
- // global tagbar (full width, top row): app name + window-agnostic commands.
- const bar = win.child(.{ .x_off = 0, .y_off = 0, .width = screen_w, .height = TOPBAR_H });
- bar.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = rgbColor(T().tag_bg) } });
- _ = bar.printSegment(.{ .text = topbar_str, .style = .{ .fg = rgbColor(T().tag_fg), .bg = rgbColor(T().tag_bg) } }, .{ .wrap = .none });
-
- // No static separators and no reserved gap: each pane's tagline (top) and
- // scrollbar gutter (left) read as the borders, and adjacent panes abut. The
- // resize handle is a pane's own trailing edge (left pane's last col / upper
- // pane's last row) — hinted by a dash overlay that keeps the cell's colors.
-
- // resize-handle hint: overlay a dash on the boundary cells, keeping their
- // colors (only the glyph changes). Shown while dragging a border and, in the
- // else branch, on hover.
- pane_ui.drawInteractionPreview(win, &layout, &rects, &col_x, &col_w, screen_h, drag, hover_col, hover_row, gray.mid);
-
- // debug overlay: a small virtual-text box drawn last, on top of everything.
- // ov_buf lives across vx.render() — vaxis keeps slices into it (retained).
- // Off by default; the "Debug" tagbar command toggles it.
- if (show_debug) if (terms[active]) |at| {
- var nlive: usize = 0;
- for (terms) |slot| {
- if (slot != null) nlive += 1;
- }
- var sb_off: usize = undefined;
- var sb_total: usize = undefined;
- if (at.file) |f| {
- sb_off = f.scroll;
- sb_total = fileLineCount(f.content);
- } else {
- const sb = at.term.screens.active.pages.scrollbar();
- sb_off = sb.offset;
- sb_total = sb.total;
- }
- const s = std.fmt.bufPrint(
- &ov_buf,
- "pardes resizes={d}\nterms {d} cols {d}\nactive #{d} {d}x{d}\nscroll {d}/{d}\npinch {d:6.4}\ntscroll {d:7.4}",
- .{ resize_count, nlive, layout.ncol, active, at.cols, at.rows, sb_off, sb_total, ov_pinch_scale, ov_touch_scroll_delta },
- ) catch "";
- const ow: u16 = 30;
- const oh: u16 = 8;
- const ox: u16 = if (screen_w > ow) screen_w - ow else 0;
- const ov = win.child(.{ .x_off = ox, .y_off = TOPBAR_H, .width = ow, .height = oh, .border = .{ .where = .all } });
- ov.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = gray.bar } } });
- _ = ov.printSegment(.{ .text = s, .style = .{ .fg = .{ .rgb = gray.txt } } }, .{});
- };
-
- try vx.render(tty.writer());
- _ = frame_arena.reset(.retain_capacity);
- }
-}
-
-// index of the run anchored exactly at (row, col), if any.
-fn runAt(t: *Term, row: i32, col: i32) ?usize {
- for (t.edits[0..t.nedits], 0..) |e, i| if (e.row == row and e.col == col) return i;
- return null;
-}
-
-// rendered column of a position: original col + earlier-on-row insertions + the
-// offset into the run at this col. (horizontal only; scrolling doesn't matter.)
-fn renderedCol(t: *Term, row: i32, col: i32, off: u16) i32 {
- var rc: i32 = col + @as(i32, off);
- for (t.edits[0..t.nedits]) |e| if (e.row == row and e.col < col) {
- rc += @as(i32, e.len);
- };
- return rc;
-}
-
-// Splice this row's insertion runs into `line` (the raw tty text for absolute
-// row `abs_row`), shifting the underlying text right rather than overwriting it.
-fn spliceRow(alloc: std.mem.Allocator, out: *std.ArrayList(u8), t: *Term, abs_row: i32, line: []const u8) !void {
- var idxs: [Term.MAX_EDITS]usize = undefined;
- var n: usize = 0;
- for (t.edits[0..t.nedits], 0..) |e, i| if (e.row == abs_row and e.len > 0) {
- idxs[n] = i;
- n += 1;
- };
- // insertion-sort the runs by column (n is tiny)
- var a: usize = 1;
- while (a < n) : (a += 1) {
- const key = idxs[a];
- var b = a;
- while (b > 0 and t.edits[idxs[b - 1]].col > t.edits[key].col) : (b -= 1) idxs[b] = idxs[b - 1];
- idxs[b] = key;
- }
- var vpos: usize = 0; // original-line columns consumed so far
- for (idxs[0..n]) |i| {
- const e = &t.edits[i];
- const c: usize = @intCast(@max(0, e.col));
- if (c <= line.len) {
- if (c > vpos) try out.appendSlice(alloc, line[vpos..c]);
- vpos = c;
- } else {
- // run anchored past the text: emit the rest, then pad with spaces
- if (vpos < line.len) {
- try out.appendSlice(alloc, line[vpos..]);
- vpos = line.len;
- }
- while (vpos < c) : (vpos += 1) try out.append(alloc, ' ');
- }
- try out.appendSlice(alloc, e.text[0..e.len]);
- }
- if (vpos < line.len) try out.appendSlice(alloc, line[vpos..]);
-}
-
-// The terminal body with all insertion runs spliced in, newline-joined. This is
-// the actual on-screen text: render and selection both read it, so there's no
-// tty-vs-typed distinction. Caller frees.
-// Per visible row, whether it's a shell prompt/input row (OSC 133). Used to hide
-// prompts in text mode. Rows beyond the iterator (blank tail) stay false.
-fn promptFlags(t: *Term, flags: []bool) void {
- @memset(flags, false);
- var it = t.term.screens.active.pages.rowIterator(.right_down, .{ .viewport = .{} }, null);
- var i: usize = 0;
- while (it.next()) |pin| : (i += 1) {
- if (i >= flags.len) break;
- flags[i] = pin.rowAndCell().row.semantic_prompt != .none;
- }
-}
-
-fn bodyText(alloc: std.mem.Allocator, t: *Term) ![]u8 {
- // image pane: no text body (the picture is drawn over it). Return blank rows so
- // the tag stays selectable — selectionText/expandSel read this, and the stub
- // emulator's plainString must not be touched here (that path broke tag clicks).
- if (t.image != null) {
- const buf = try alloc.alloc(u8, t.rows -| 1);
- @memset(buf, '\n');
- return buf;
- }
- // file pane: line-numbered content from f.scroll, edits spliced like the tty.
- if (t.file) |f| {
- var out: std.ArrayList(u8) = .empty;
- errdefer out.deinit(alloc);
- var lines = std.mem.splitScalar(u8, f.content, '\n');
- var skipped: usize = 0;
- while (skipped < f.scroll) : (skipped += 1) {
- if (lines.next() == null) break;
- }
- var i: usize = 0;
- while (i < t.rows) : (i += 1) {
- if (i > 0) try out.append(alloc, '\n');
- const abs: i32 = @as(i32, @intCast(f.scroll)) + @as(i32, @intCast(i));
- if (lines.next()) |text| {
- var lbuf: [16]u8 = undefined;
- // unsigned: Zig 0.16's {d} prints a leading '+' for signed ints.
- const lineno: usize = @intCast(abs + 1);
- const prefix = std.fmt.bufPrint(&lbuf, "{d: >4} ", .{lineno}) catch " ";
- var base: std.ArrayList(u8) = .empty;
- defer base.deinit(alloc);
- try base.appendSlice(alloc, prefix);
- try base.appendSlice(alloc, text);
- try spliceRow(alloc, &out, t, abs, base.items);
- } else try spliceRow(alloc, &out, t, abs, "");
- }
- return out.toOwnedSlice(alloc);
- }
- const off: i32 = @intCast(t.term.screens.active.pages.scrollbar().offset);
- const body = try t.term.plainString(alloc);
- defer alloc.free(body);
- // text mode (normal AND insert) hides shell prompt rows: a clean acme-style
- // page to navigate and type on. Only raw tty mode (the configured Ctrl-key toggle) shows
- // the real terminal with its prompts + typed input.
- var flagbuf: [512]bool = undefined;
- const nrows: usize = @min(t.rows, flagbuf.len);
- const flags = flagbuf[0..nrows];
- if (t.mode != .tty) promptFlags(t, flags) else @memset(flags, false);
- var out: std.ArrayList(u8) = .empty;
- errdefer out.deinit(alloc);
- // pad to the full grid height: plainString trims trailing blank rows, but we
- // must still be able to type (and splice) onto those blank rows.
- var it = std.mem.splitAny(u8, body, "\n");
- var i: usize = 0;
- while (i < nrows) : (i += 1) {
- if (i > 0) try out.append(alloc, '\n');
- const raw = it.next() orelse "";
- const line = if (flags[i]) "" else raw; // hidden prompt -> blank (edits still splice)
- // raw tty mode shows the REAL terminal: the char-mode insertion overlay is
- // hidden, so don't splice the runs — just emit the line as-is.
- if (t.mode == .tty) try out.appendSlice(alloc, line) else try spliceRow(alloc, &out, t, @as(i32, @intCast(i)) + off, line);
- }
- return out.toOwnedSlice(alloc);
-}
-
-fn terminalReplayText(alloc: std.mem.Allocator, t: *Term) ![]u8 {
- const full = try t.term.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} });
- defer alloc.free(full);
-
- var out: std.ArrayList(u8) = .empty;
- errdefer out.deinit(alloc);
- var lines = std.mem.splitAny(u8, full, "\n");
- var prompts = t.term.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null);
- var row: usize = 0;
- while (lines.next()) |raw| : (row += 1) {
- if (row > 0) try out.append(alloc, '\n');
- const is_prompt = if (t.mode != .tty)
- if (prompts.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false
- else
- false;
- const line = if (is_prompt) "" else raw;
- if (t.mode == .tty) {
- try out.appendSlice(alloc, line);
- } else {
- try spliceRow(alloc, &out, t, @intCast(row), line);
- }
- }
- return out.toOwnedSlice(alloc);
-}
-
-fn dumpSnapshot(
- io: std.Io,
- gpa: std.mem.Allocator,
- terms: *[MAX_TERMS]?*Term,
- layout: *const Layout,
- active: usize,
- sw: u16,
- sh: u16,
-) !void {
- var arena: std.heap.ArenaAllocator = .init(gpa);
- defer arena.deinit();
- const alloc = arena.allocator();
-
- var slot_to_pane: [MAX_TERMS]?usize = @splat(null);
- var panes: std.ArrayList(dump.Pane) = .empty;
- for (terms.*, 0..) |slot, id| {
- const t = slot orelse continue;
- const compact = panes.items.len;
- slot_to_pane[id] = compact;
-
- const tag = try fullTag(alloc, t);
- const body = try bodyText(alloc, t);
- const scroll: usize = @intCast(@max(0, paneScroll(t)));
- const pane: dump.Pane = if (t.file) |f| .{
- .kind = .file,
- .tag = tag,
- .body = body,
- .scroll = scroll,
- .cols = t.cols,
- .rows = t.rows,
- .vweight = t.vweight,
- .file = .{
- .path = f.path,
- .content = f.content,
- .content_b64 = try dump.encodeBytes(alloc, f.content),
- },
- } else if (t.image) |iv| blk: {
- const pz = try std.fmt.allocPrintSentinel(alloc, "{s}", .{iv.path}, 0);
- const bytes = readFileAlloc(io, alloc, pz) catch "";
- break :blk .{
- .kind = .image,
- .tag = tag,
- .body = body,
- .scroll = scroll,
- .cols = t.cols,
- .rows = t.rows,
- .vweight = t.vweight,
- .image = .{
- .path = iv.path,
- .bytes_b64 = if (bytes.len > 0) try dump.encodeBytes(alloc, bytes) else "",
- },
- };
- } else blk: {
- var cwdbuf: [1024]u8 = undefined;
- const cwd = try alloc.dupe(u8, termCwd(t, &cwdbuf));
- const stream = try terminalReplayText(alloc, t);
- break :blk .{
- .kind = .terminal,
- .tag = tag,
- .body = body,
- .scroll = scroll,
- .cols = t.cols,
- .rows = t.rows,
- .vweight = t.vweight,
- .terminal = .{
- .cwd = cwd,
- .stream = stream,
- .stream_b64 = try dump.encodeBytes(alloc, t.tty_stream.items),
- .cursor = .{
- .col = t.term.screens.active.cursor.x,
- .row = t.term.screens.active.cursor.y,
- },
- },
- };
- };
- try panes.append(alloc, pane);
- }
-
- var columns: std.ArrayList(dump.Column) = .empty;
- var c: usize = 0;
- while (c < layout.ncol) : (c += 1) {
- var ids: std.ArrayList(usize) = .empty;
- var k: usize = 0;
- while (k < layout.col_n[c]) : (k += 1) {
- const old_id = layout.col_terms[c][k];
- if (slot_to_pane[old_id]) |compact| try ids.append(alloc, compact);
- }
- if (ids.items.len > 0)
- try columns.append(alloc, .{ .weight = layout.col_weight[c], .panes = try ids.toOwnedSlice(alloc) });
- }
-
- const state: dump.State = .{
- .screen = .{ .cols = sw, .rows = sh },
- .active = slot_to_pane[active] orelse 0,
- .topbar = topbar_str,
- .theme = paint.currentThemeName(),
- .columns = try columns.toOwnedSlice(alloc),
- .panes = try panes.toOwnedSlice(alloc),
- };
- try dump.writeFile(io, gpa, dump.default_path, state);
-}
-
-// The full rendered pane: tag at visual row 0, spliced body below. Caller frees.
-fn paneText(gpa: std.mem.Allocator, t: *Term) ![]u8 {
- const tag = try fullTag(gpa, t);
- defer gpa.free(tag);
- const body = try bodyText(gpa, t);
- defer gpa.free(body);
- return std.fmt.allocPrint(gpa, "{s}\n{s}", .{ tag, body });
-}
-
-// Block-selected text, newline-joined per row; caller frees. Reads the rendered
-// screen, so selecting typed text and tty text works identically.
-fn selectionText(gpa: std.mem.Allocator, t: *Term, sl: Term.Sel) ![]u8 {
- const r0 = @min(sl.r0, sl.r1);
- const r1 = @max(sl.r0, sl.r1);
- const c0: usize = @intCast(@max(0, @min(sl.c0, sl.c1)));
- const c1: usize = @intCast(@max(0, @max(sl.c0, sl.c1)));
- const pane = try paneText(gpa, t);
- defer gpa.free(pane);
- var out: std.ArrayList(u8) = .empty;
- errdefer out.deinit(gpa);
- var it = std.mem.splitAny(u8, pane, "\n");
- var v: i32 = 0;
- var first = true;
- while (it.next()) |line| : (v += 1) {
- if (v < r0 or v > r1) continue;
- if (!first) try out.append(gpa, '\n');
- first = false;
- const b0 = @min(c0, line.len);
- const b1 = @min(c1 + 1, line.len);
- try out.appendSlice(gpa, line[b0..b1]);
- }
- return out.toOwnedSlice(gpa);
-}
-
-// file-ish word chars (acme isfilec): alnum + . - + / : @ _ ~
-fn isFileChar(c: u8) bool {
- return std.ascii.isAlphanumeric(c) or switch (c) {
- '.', '-', '+', '/', ':', '@', '_', '~' => true,
- else => false,
- };
-}
-
-// acme-style: a no-drag middle/right click expands to the file-ish word under the
-// cursor, in place. A real (dragged) selection is left untouched.
-fn expandSel(gpa: std.mem.Allocator, t: *Term, sl: *Term.Sel) void {
- if (sl.c0 != sl.c1 or sl.r0 != sl.r1) return;
- const pane = paneText(gpa, t) catch return;
- defer gpa.free(pane);
- var it = std.mem.splitAny(u8, pane, "\n");
- var v: i32 = 0;
- while (it.next()) |line| : (v += 1) {
- if (v != sl.r0) continue;
- const col: usize = @intCast(@max(0, sl.c0));
- if (col > line.len) return;
- var hi = col;
- while (hi < line.len and isFileChar(line[hi])) hi += 1;
- var lo = col;
- while (lo > 0 and isFileChar(line[lo - 1])) lo -= 1;
- if (hi > lo) {
- sl.c0 = @intCast(lo);
- sl.c1 = @intCast(hi - 1);
- }
- return;
- }
-}
-
-// Decide what a selection means. Middle button: a builtin name (comptime-matched
-// against `builtins`) runs that builtin; anything else is sent to the terminal.
-fn onSelect(button: Button, text: []const u8) SelectAction {
- if (button != .middle) return .none;
- const cmd = std.mem.trim(u8, text, " \t\r\n");
- if (cmd.len == 0) return .none;
- inline for (builtins) |b| {
- if (std.mem.eql(u8, cmd, b.name)) return b.action;
- }
- return .send;
-}
diff --git a/paint.zig b/paint.zig
deleted file mode 100644
index 97efc334..00000000
--- a/paint.zig
+++ /dev/null
@@ -1,340 +0,0 @@
-const std = @import("std");
-const ui = @import("ui");
-const app_config = @import("app_config");
-const ghostty_vt = @import("ghostty-vt");
-const syntax_highlighting = app_config.syntax_highlighting;
-const syntax_minimal_grammars = app_config.syntax_minimal_grammars;
-const syntax_full_grammars = app_config.syntax_full_grammars;
-const ts = if (syntax_highlighting) @import("tree-sitter") else struct {
- pub const Language = opaque {};
- pub const Query = opaque {};
-};
-const ts_queries = if (syntax_highlighting) @import("ts_queries") else struct {};
-
-extern fn tree_sitter_ada() callconv(.c) *const ts.Language;
-extern fn tree_sitter_bash() callconv(.c) *const ts.Language;
-extern fn tree_sitter_c() callconv(.c) *const ts.Language;
-extern fn tree_sitter_c_sharp() callconv(.c) *const ts.Language;
-extern fn tree_sitter_clojure() callconv(.c) *const ts.Language;
-extern fn tree_sitter_cpp() callconv(.c) *const ts.Language;
-extern fn tree_sitter_css() callconv(.c) *const ts.Language;
-extern fn tree_sitter_elixir() callconv(.c) *const ts.Language;
-extern fn tree_sitter_erlang() callconv(.c) *const ts.Language;
-extern fn tree_sitter_fortran() callconv(.c) *const ts.Language;
-extern fn tree_sitter_go() callconv(.c) *const ts.Language;
-extern fn tree_sitter_haskell() callconv(.c) *const ts.Language;
-extern fn tree_sitter_html() callconv(.c) *const ts.Language;
-extern fn tree_sitter_java() callconv(.c) *const ts.Language;
-extern fn tree_sitter_javascript() callconv(.c) *const ts.Language;
-extern fn tree_sitter_json() callconv(.c) *const ts.Language;
-extern fn tree_sitter_kotlin() callconv(.c) *const ts.Language;
-extern fn tree_sitter_ocaml() callconv(.c) *const ts.Language;
-extern fn tree_sitter_pascal() callconv(.c) *const ts.Language;
-extern fn tree_sitter_php() callconv(.c) *const ts.Language;
-extern fn tree_sitter_powershell() callconv(.c) *const ts.Language;
-extern fn tree_sitter_python() callconv(.c) *const ts.Language;
-extern fn tree_sitter_ruby() callconv(.c) *const ts.Language;
-extern fn tree_sitter_rust() callconv(.c) *const ts.Language;
-extern fn tree_sitter_scala() callconv(.c) *const ts.Language;
-extern fn tree_sitter_zig() callconv(.c) *const ts.Language;
-
-pub const accent = if (app_config.app_kind == .replay) [3]u8{ 0xc8, 0x58, 0x58 } else [3]u8{ 0x6e, 0x4f, 0xd0 };
-pub const accent_dim = if (app_config.app_kind == .replay) [3]u8{ 0x54, 0x38, 0x38 } else [3]u8{ 0x38, 0x3c, 0x54 };
-const dark_tag_bg = if (app_config.app_kind == .replay) [3]u8{ 0x3e, 0x2a, 0x2a } else [3]u8{ 0x2c, 0x2a, 0x3e };
-
-pub const Theme = struct {
- name: []const u8,
- bg: ?[3]u8,
- fg: ?[3]u8,
- tag_bg: [3]u8,
- tag_fg: [3]u8,
- kw: [3]u8,
- str: [3]u8,
- num: [3]u8,
- comment: [3]u8,
- lineno: [3]u8,
- scroll_track: [3]u8,
- scroll_thumb: [3]u8,
- palette: ?[16][3]u8,
-};
-
-pub const themes = [_]Theme{
- .{
- .name = "dark",
- .bg = null,
- .fg = null,
- .tag_bg = dark_tag_bg,
- .tag_fg = .{ 0x96, 0x96, 0x96 },
- .kw = .{ 0xcc, 0x70, 0xd8 },
- .str = .{ 0x8a, 0xb8, 0x7a },
- .num = .{ 0xd0, 0xc0, 0x70 },
- .comment = .{ 0xf0, 0xf0, 0xf0 },
- .lineno = .{ 0x5a, 0x5a, 0x5a },
- .scroll_track = .{ 0x2a, 0x2a, 0x2a },
- .scroll_thumb = .{ 0x52, 0x52, 0x52 },
- .palette = null,
- },
- .{
- .name = "acme",
- .bg = .{ 0xff, 0xff, 0xea },
- .fg = .{ 0x00, 0x00, 0x00 },
- .tag_bg = .{ 0xea, 0xff, 0xff },
- .tag_fg = .{ 0x00, 0x00, 0x00 },
- .kw = .{ 0x88, 0x22, 0x99 },
- .str = .{ 0x00, 0x77, 0x33 },
- .num = .{ 0xa0, 0x52, 0x00 },
- .comment = .{ 0x55, 0x55, 0x55 },
- .lineno = .{ 0x99, 0x99, 0x80 },
- .scroll_track = .{ 0x99, 0x99, 0x4c },
- .scroll_thumb = .{ 0xff, 0xff, 0xea },
- .palette = .{
- .{ 0x00, 0x00, 0x00 }, .{ 0xaa, 0x22, 0x22 }, .{ 0x22, 0x80, 0x22 }, .{ 0x88, 0x66, 0x00 },
- .{ 0x22, 0x44, 0xbb }, .{ 0x99, 0x33, 0x99 }, .{ 0x11, 0x88, 0x88 }, .{ 0x55, 0x55, 0x55 },
- .{ 0x88, 0x88, 0x88 }, .{ 0xcc, 0x33, 0x33 }, .{ 0x33, 0xaa, 0x33 }, .{ 0xaa, 0x77, 0x00 },
- .{ 0x33, 0x55, 0xdd }, .{ 0xbb, 0x44, 0xbb }, .{ 0x22, 0xaa, 0xaa }, .{ 0x00, 0x00, 0x00 },
- },
- },
-};
-
-var theme_idx: usize = 0;
-
-pub fn T() *const Theme {
- return &themes[theme_idx];
-}
-
-pub fn nextTheme() void {
- theme_idx = (theme_idx + 1) % themes.len;
-}
-
-pub fn currentThemeName() []const u8 {
- return T().name;
-}
-
-pub fn setThemeByName(name: []const u8) bool {
- for (themes, 0..) |theme, i| {
- if (std.mem.eql(u8, theme.name, name)) {
- theme_idx = i;
- return true;
- }
- }
- return false;
-}
-
-pub fn rgbColor(c: [3]u8) ui.Color {
- return .{ .rgb = c };
-}
-
-pub fn colorOpt(c: ?[3]u8) ui.Color {
- return if (c) |x| .{ .rgb = x } else .default;
-}
-
-pub const Syn = enum(u8) { none, keyword, string, number, comment };
-
-pub const SynStyle = struct {
- fg: [3]u8,
- bold: bool,
-};
-
-const HighlightLanguage = struct {
- lang: *const ts.Language,
- query: *ts.Query,
-};
-
-const HighlightTier = enum { minimal, full };
-
-const HighlightSpec = struct {
- tier: HighlightTier,
- exts: []const []const u8,
- language: *const fn () callconv(.c) *const ts.Language,
- query_src: []const u8,
- compiled_query: ?*ts.Query = null,
-};
-
-var highlight_specs = if (syntax_minimal_grammars) [_]HighlightSpec{
- .{ .tier = .minimal, .exts = &[_][]const u8{ ".c", ".h" }, .language = tree_sitter_c, .query_src = ts_queries.c_highlights },
- .{ .tier = .minimal, .exts = &[_][]const u8{ ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx" }, .language = tree_sitter_cpp, .query_src = ts_queries.cpp_highlights },
- .{ .tier = .minimal, .exts = &[_][]const u8{ ".zig", ".zon" }, .language = tree_sitter_zig, .query_src = ts_queries.zig_highlights },
-} else [_]HighlightSpec{};
-
-var full_highlight_specs = if (syntax_full_grammars) [_]HighlightSpec{
- .{ .tier = .full, .exts = &[_][]const u8{ ".adb", ".ads", ".ada" }, .language = tree_sitter_ada, .query_src = ts_queries.ada_highlights },
- .{ .tier = .full, .exts = &[_][]const u8{ ".sh", ".bash", ".zsh" }, .language = tree_sitter_bash, .query_src = ts_queries.bash_highlights },
- .{ .tier = .full, .exts = &[_][]const u8{ ".cs", ".csx" }, .language = tree_sitter_c_sharp, .query_src = ts_queries.c_sharp_highlights },
- .{ .tier = .full, .exts = &[_][]const u8{ ".clj", ".cljs", ".cljc", ".edn" }, .language = tree_sitter_clojure, .query_src = ts_queries.clojure_highlights },
- .{ .tier = .full, .exts = &[_][]const u8{".css"}, .language = tree_sitter_css, .query_src = ts_queries.css_highlights },
- .{ .tier = .full, .exts = &[_][]const u8{ ".ex", ".exs" }, .language = tree_sitter_elixir, .query_src = ts_queries.elixir_highlights },
- .{ .tier = .full, .exts = &[_][]const u8{ ".erl", ".hrl" }, .language = tree_sitter_erlang, .query_src = ts_queries.erlang_highlights },
- .{ .tier = .full, .exts = &[_][]const u8{ ".f", ".for", ".ftn", ".f90", ".f95", ".f03", ".f08" }, .language = tree_sitter_fortran, .query_src = ts_queries.fortran_highlights },
- .{ .tier = .full, .exts = &[_][]const u8{".go"}, .language = tree_sitter_go, .query_src = ts_queries.go_highlights },
- .{ .tier = .full, .exts = &[_][]const u8{ ".hs", ".lhs" }, .language = tree_sitter_haskell, .query_src = ts_queries.haskell_highlights },
- .{ .tier = .full, .exts = &[_][]const u8{ ".html", ".htm" }, .language = tree_sitter_html, .query_src = ts_queries.html_highlights },
- .{ .tier = .full, .exts = &[_][]const u8{".java"}, .language = tree_sitter_java, .query_src = ts_queries.java_highlights },
- .{ .tier = .full, .exts = &[_][]const u8{ ".js", ".jsx", ".mjs", ".cjs" }, .language = tree_sitter_javascript, .query_src = ts_queries.javascript_highlights },
- .{ .tier = .full, .exts = &[_][]const u8{".json"}, .language = tree_sitter_json, .query_src = ts_queries.json_highlights },
- .{ .tier = .full, .exts = &[_][]const u8{ ".kt", ".kts" }, .language = tree_sitter_kotlin, .query_src = ts_queries.kotlin_highlights },
- .{ .tier = .full, .exts = &[_][]const u8{ ".ml", ".mli" }, .language = tree_sitter_ocaml, .query_src = ts_queries.ocaml_highlights },
- .{ .tier = .full, .exts = &[_][]const u8{ ".pas", ".pp", ".p" }, .language = tree_sitter_pascal, .query_src = ts_queries.pascal_highlights },
- .{ .tier = .full, .exts = &[_][]const u8{ ".php", ".phtml", ".php3", ".php4", ".php5" }, .language = tree_sitter_php, .query_src = ts_queries.php_highlights },
- .{ .tier = .full, .exts = &[_][]const u8{ ".ps1", ".psm1", ".psd1" }, .language = tree_sitter_powershell, .query_src = ts_queries.powershell_highlights },
- .{ .tier = .full, .exts = &[_][]const u8{ ".py", ".pyw" }, .language = tree_sitter_python, .query_src = ts_queries.python_highlights },
- .{ .tier = .full, .exts = &[_][]const u8{ ".rb", ".rake" }, .language = tree_sitter_ruby, .query_src = ts_queries.ruby_highlights },
- .{ .tier = .full, .exts = &[_][]const u8{".rs"}, .language = tree_sitter_rust, .query_src = ts_queries.rust_highlights },
- .{ .tier = .full, .exts = &[_][]const u8{ ".scala", ".sc" }, .language = tree_sitter_scala, .query_src = ts_queries.scala_highlights },
-} else [_]HighlightSpec{};
-
-fn tierEnabled(tier: HighlightTier) bool {
- return switch (tier) {
- .minimal => syntax_minimal_grammars,
- .full => syntax_full_grammars,
- };
-}
-
-fn specMatchesExt(spec: HighlightSpec, ext: []const u8) bool {
- for (spec.exts) |choice| {
- if (std.ascii.eqlIgnoreCase(ext, choice)) return true;
- }
- return false;
-}
-
-fn ensureHighlightLanguage(spec: *HighlightSpec) !HighlightLanguage {
- const lang = spec.language();
- if (spec.compiled_query) |query| return .{ .lang = lang, .query = query };
- var error_offset: u32 = 0;
- const query = try ts.Query.create(lang, spec.query_src, &error_offset);
- spec.compiled_query = query;
- return .{ .lang = lang, .query = query };
-}
-
-fn highlightLanguageForExt(ext: []const u8) !?HighlightLanguage {
- for (&highlight_specs) |*spec| {
- if (!tierEnabled(spec.tier)) continue;
- if (!specMatchesExt(spec.*, ext)) continue;
- return try ensureHighlightLanguage(spec);
- }
- for (&full_highlight_specs) |*spec| {
- if (!tierEnabled(spec.tier)) continue;
- if (!specMatchesExt(spec.*, ext)) continue;
- return try ensureHighlightLanguage(spec);
- }
- return null;
-}
-
-pub fn compileStartupHighlightQueries() void {
- if (!syntax_highlighting) return;
- for (&highlight_specs) |*spec| {
- if (spec.tier != .minimal) continue;
- if (!tierEnabled(spec.tier)) continue;
- _ = ensureHighlightLanguage(spec) catch {};
- }
-}
-
-pub fn synStyle(s: Syn) ?SynStyle {
- return switch (s) {
- .none => null,
- .keyword => .{ .fg = T().kw, .bold = true },
- .string => .{ .fg = T().str, .bold = false },
- .number => .{ .fg = T().num, .bold = false },
- .comment => .{ .fg = T().comment, .bold = true },
- };
-}
-
-fn synFor(name: []const u8) Syn {
- for ([_]struct { []const u8, Syn }{
- .{ "comment", .comment },
- .{ "string", .string },
- .{ "character", .string },
- .{ "number", .number },
- .{ "float", .number },
- .{ "boolean", .number },
- .{ "keyword", .keyword },
- .{ "include", .keyword },
- .{ "conditional", .keyword },
- .{ "repeat", .keyword },
- }) |m| {
- if (std.mem.indexOf(u8, name, m[0]) != null) return m[1];
- }
- return .none;
-}
-
-pub fn highlightFile(gpa: std.mem.Allocator, path: []const u8, content: []const u8) ![]u8 {
- return highlightFileRange(gpa, path, content, 0, content.len);
-}
-
-pub fn highlightFileRange(gpa: std.mem.Allocator, path: []const u8, content: []const u8, start_byte_raw: usize, end_byte_raw: usize) ![]u8 {
- if (!syntax_highlighting) return &.{};
- const ext = std.fs.path.extension(path);
- const selected = (highlightLanguageForExt(ext) catch return &.{}) orelse return &.{};
-
- const start_byte = @min(start_byte_raw, content.len);
- const end_byte = @max(start_byte, @min(end_byte_raw, content.len));
- const source = content[start_byte..end_byte];
- const styles = try gpa.alloc(u8, source.len);
- errdefer gpa.free(styles);
- @memset(styles, 0);
-
- const parser = ts.Parser.create();
- defer parser.destroy();
- parser.setLanguage(selected.lang) catch return styles;
- const tree = parser.parseString(source, null) orelse return styles;
- defer tree.destroy();
-
- const cursor = ts.QueryCursor.create();
- defer cursor.destroy();
- cursor.exec(selected.query, tree.rootNode());
-
- while (cursor.nextMatch()) |match| {
- for (match.captures) |cap| {
- const syn = synFor(selected.query.captureNameForId(cap.index) orelse "");
- if (syn == .none) continue;
- var b: usize = cap.node.startByte();
- const end = @min(@as(usize, cap.node.endByte()), styles.len);
- while (b < end) : (b += 1) styles[b] = @intFromEnum(syn);
- }
- }
- return styles;
-}
-
-fn palColor(idx: u8) ui.Color {
- if (T().palette) |p| if (idx < 16) return rgbColor(p[idx]);
- return .{ .index = idx };
-}
-
-fn ghostColor(color: ghostty_vt.Style.Color, is_bg: bool) ui.Color {
- return switch (color) {
- .none => colorOpt(if (is_bg) T().bg else T().fg),
- .palette => |idx| palColor(idx),
- .rgb => |rgb| .{ .rgb = .{ rgb.r, rgb.g, rgb.b } },
- };
-}
-
-pub fn cellStyle(ci: ghostty_vt.PageList.Cell) ui.Style {
- const style = ci.style();
- var vs: ui.Style = .{
- .fg = ghostColor(style.fg_color, false),
- .bg = ghostColor(style.bg_color, true),
- .bold = style.flags.bold,
- .dim = style.flags.faint,
- .italic = style.flags.italic,
- .blink = style.flags.blink,
- .reverse = style.flags.inverse,
- .invisible = style.flags.invisible,
- .strikethrough = style.flags.strikethrough,
- .ul_style = switch (style.flags.underline) {
- .none => .off,
- .single => .single,
- .double => .double,
- .curly => .curly,
- .dotted => .dotted,
- .dashed => .dashed,
- },
- };
- switch (ci.cell.content_tag) {
- .bg_color_palette => vs.bg = palColor(ci.cell.content.color_palette.data),
- .bg_color_rgb => {
- const rgb = ci.cell.content.color_rgb;
- vs.bg = .{ .rgb = .{ rgb.r, rgb.g, rgb.b } };
- },
- else => {},
- }
- return vs;
-}
diff --git a/pane_ui.zig b/pane_ui.zig
deleted file mode 100644
index 629992b8..00000000
--- a/pane_ui.zig
+++ /dev/null
@@ -1,496 +0,0 @@
-const std = @import("std");
-
-pub const TOPBAR_H: u16 = 1;
-pub const GUTTER: u16 = 2;
-pub const BOX_H: u16 = 1;
-pub const MINW: u16 = 10;
-pub const MINH: u16 = 3;
-pub const PREFIX_W: u16 = 5;
-
-pub const Rect = struct { x: u16, y: u16, w: u16, h: u16 };
-
-pub const MousePos = struct { col: u16, row: u16 };
-
-pub const ScrollbarButton = enum { left, middle, right };
-
-pub const Scrollbar = struct {
- total: usize,
- offset: usize,
- len: usize,
-};
-
-pub fn Layout(comptime max_cols: usize, comptime max_panes: usize) type {
- return struct {
- ncol: usize,
- col_weight: [max_cols]f32,
- col_terms: [max_cols][max_panes]usize,
- col_n: [max_cols]usize,
- };
-}
-
-pub fn layoutFindTerm(l: anytype, id: usize) ?struct { col: usize, idx: usize } {
- var c: usize = 0;
- while (c < l.ncol) : (c += 1) {
- var k: usize = 0;
- while (k < l.col_n[c]) : (k += 1) {
- if (l.col_terms[c][k] == id) return .{ .col = c, .idx = k };
- }
- }
- return null;
-}
-
-pub fn layoutRemove(l: anytype, id: usize) void {
- const f = layoutFindTerm(l, id) orelse return;
- const c = f.col;
- var k = f.idx;
- while (k + 1 < l.col_n[c]) : (k += 1) l.col_terms[c][k] = l.col_terms[c][k + 1];
- l.col_n[c] -= 1;
- if (l.col_n[c] == 0) {
- if (l.ncol > 1) l.col_weight[if (c > 0) c - 1 else c + 1] += l.col_weight[c];
- var j = c;
- while (j + 1 < l.ncol) : (j += 1) {
- l.col_terms[j] = l.col_terms[j + 1];
- l.col_n[j] = l.col_n[j + 1];
- l.col_weight[j] = l.col_weight[j + 1];
- }
- l.ncol -= 1;
- }
-}
-
-pub fn layoutInsert(l: anytype, c: usize, idx: usize, id: usize) void {
- var k = l.col_n[c];
- while (k > idx) : (k -= 1) l.col_terms[c][k] = l.col_terms[c][k - 1];
- l.col_terms[c][idx] = id;
- l.col_n[c] += 1;
-}
-
-pub fn layoutAppendColumn(l: anytype, id: usize) void {
- if (l.ncol >= l.col_weight.len) return;
- const c = l.ncol;
- l.col_weight[c] = 1;
- l.col_terms[c][0] = id;
- l.col_n[c] = 1;
- l.ncol += 1;
-}
-
-pub const MovePlacement = struct {
- preview_col: usize,
- above_id: usize,
- row: u16,
- above_y: u16,
- above_h: u16,
-};
-
-pub fn computeGeom(
- l: anytype,
- items: anytype,
- sw: u16,
- sh: u16,
- rects: anytype,
- col_x: anytype,
- col_w: anytype,
- comptime vweightFn: anytype,
-) void {
- if (l.ncol == 0) return;
- var wsum: f32 = 0;
- for (0..l.ncol) |c| wsum += l.col_weight[c];
- if (wsum <= 0) wsum = 1;
-
- var x: u16 = 0;
- for (0..l.ncol) |c| {
- const last = c + 1 == l.ncol;
- const cw: u16 = if (last) (sw -| x) else @max(1, @as(u16, @intFromFloat(@round(@as(f32, @floatFromInt(sw)) * l.col_weight[c] / wsum))));
- col_x[c] = x;
- col_w[c] = cw;
-
- var vsum: f32 = 0;
- for (0..l.col_n[c]) |k| {
- if (vweightFn(items, l.col_terms[c][k])) |vweight| vsum += vweight;
- }
- if (vsum <= 0) vsum = 1;
-
- var y: u16 = TOPBAR_H;
- const avail_h = sh -| TOPBAR_H;
- for (0..l.col_n[c]) |k| {
- const id = l.col_terms[c][k];
- const vweight = vweightFn(items, id) orelse continue;
- const lastk = k + 1 == l.col_n[c];
- const ch: u16 = if (lastk) (sh -| y) else @max(1, @as(u16, @intFromFloat(@round(@as(f32, @floatFromInt(avail_h)) * vweight / vsum))));
- rects[id] = .{ .x = x, .y = y, .w = cw, .h = ch };
- y +|= ch;
- }
- x +|= cw;
- }
-}
-
-fn targetColumn(l: anytype, col_x: anytype, col_w: anytype, cur_x: u16) usize {
- var tc: usize = if (l.ncol > 0) l.ncol - 1 else 0;
- for (0..l.ncol) |c| {
- if (cur_x >= col_x[c] and cur_x < col_x[c] + col_w[c]) {
- tc = c;
- break;
- }
- }
- return tc;
-}
-
-fn splitRowForExtent(y: u16, h: u16, cur_y: u16) ?u16 {
- if (h < 2) return null;
- const min_each: u16 = if (h >= MINH * 2) MINH else 1;
- const lo = y +| min_each;
- const hi = y + h - min_each;
- if (lo > hi) return y + h / 2;
- return std.math.clamp(cur_y, lo, hi);
-}
-
-pub fn movePlacement(
- l: anytype,
- rects: anytype,
- col_x: anytype,
- col_w: anytype,
- id: usize,
- cur_x: u16,
- cur_y: u16,
-) ?MovePlacement {
- const src = layoutFindTerm(l, id) orelse return null;
- const tc = targetColumn(l, col_x, col_w, cur_x);
- if (tc == src.col and l.col_n[src.col] == 1) return null;
- if (tc == src.col) {
- const sr = rects[id];
- if (cur_y >= sr.y and cur_y < sr.y + sr.h) return null;
- }
-
- const max_panes = l.col_terms[0].len;
- var heights: [max_panes]u16 = @splat(0);
- for (0..l.ncol) |c| {
- var k: usize = 0;
- while (k < l.col_n[c]) : (k += 1) {
- const pid = l.col_terms[c][k];
- heights[pid] = rects[pid].h;
- }
- }
-
- if (l.col_n[src.col] > 1) {
- const sib = if (src.idx > 0) l.col_terms[src.col][src.idx - 1] else l.col_terms[src.col][src.idx + 1];
- heights[sib] +|= rects[id].h;
- }
-
- var y: u16 = TOPBAR_H;
- var last: ?MovePlacement = null;
- var k: usize = 0;
- while (k < l.col_n[tc]) : (k += 1) {
- const pid = l.col_terms[tc][k];
- if (pid == id) continue;
- const h = heights[pid];
- const row = splitRowForExtent(y, h, cur_y) orelse {
- y +|= h;
- continue;
- };
- const placement: MovePlacement = .{
- .preview_col = tc,
- .above_id = pid,
- .row = row,
- .above_y = y,
- .above_h = h,
- };
- last = placement;
- if (cur_y < y + h) return placement;
- y +|= h;
- }
- return last;
-}
-
-fn setColumnWeights(
- l: anytype,
- items: anytype,
- col: usize,
- heights: anytype,
- comptime setVWeight: anytype,
-) void {
- if (col >= l.ncol) return;
- var k: usize = 0;
- while (k < l.col_n[col]) : (k += 1) {
- const pid = l.col_terms[col][k];
- setVWeight(items, pid, @as(f32, @floatFromInt(@max(1, heights[pid]))));
- }
-}
-
-pub fn moveTerm(
- l: anytype,
- items: anytype,
- rects: anytype,
- col_x: anytype,
- col_w: anytype,
- id: usize,
- cur_x: u16,
- cur_y: u16,
- comptime setVWeight: anytype,
-) void {
- const placement = movePlacement(l, rects, col_x, col_w, id, cur_x, cur_y) orelse return;
- const src = layoutFindTerm(l, id) orelse return;
- const source_multi = l.col_n[src.col] > 1;
-
- const max_panes = l.col_terms[0].len;
- var heights: [max_panes]u16 = @splat(0);
- for (0..l.ncol) |c| {
- var k: usize = 0;
- while (k < l.col_n[c]) : (k += 1) {
- const pid = l.col_terms[c][k];
- heights[pid] = rects[pid].h;
- }
- }
-
- layoutRemove(l, id);
- if (source_multi and src.col < l.ncol and l.col_n[src.col] > 0) {
- const sib = if (src.idx > 0) l.col_terms[src.col][src.idx - 1] else l.col_terms[src.col][src.idx];
- heights[sib] +|= rects[id].h;
- }
-
- const af = layoutFindTerm(l, placement.above_id) orelse return;
- const upper_h = @max(1, placement.row -| placement.above_y);
- const lower_h = @max(1, placement.above_h -| upper_h);
- heights[placement.above_id] = upper_h;
- heights[id] = lower_h;
- layoutInsert(l, af.col, af.idx + 1, id);
-
- setColumnWeights(l, items, af.col, &heights, setVWeight);
- if (source_multi and src.col < l.ncol and src.col != af.col) setColumnWeights(l, items, src.col, &heights, setVWeight);
-}
-
-pub fn paneAt(rects: anytype, pane_count: usize, col: u16, row: u16) ?usize {
- for (0..pane_count) |i| {
- const r = rects[i];
- if (col >= r.x and col < r.x + r.w and row >= r.y and row < r.y + r.h) return i;
- }
- return null;
-}
-
-pub fn paneAtSlots(rects: anytype, slots: anytype, col: u16, row: u16) ?usize {
- for (slots.*, 0..) |slot, i| {
- if (slot == null) continue;
- const r = rects[i];
- if (col >= r.x and col < r.x + r.w and row >= r.y and row < r.y + r.h) return i;
- }
- return null;
-}
-
-pub fn clampMousePos(screen_w: u16, screen_h: u16, mouse: anytype) MousePos {
- return .{
- .col = if (mouse.col < 0) 0 else @min(@as(u16, @intCast(mouse.col)), screen_w -| 1),
- .row = if (mouse.row < 0) 0 else @min(@as(u16, @intCast(mouse.row)), screen_h -| 1),
- };
-}
-
-pub fn inMoveBox(r: Rect, col: u16, row: u16) bool {
- return col < r.x + GUTTER and row < r.y + BOX_H;
-}
-
-pub fn scrollbarClickDelta(r: Rect, button: ScrollbarButton, col: u16, row: u16) ?i32 {
- if (col >= r.x + GUTTER or row < r.y + BOX_H) return null;
- const local: i32 = @as(i32, row) - @as(i32, r.y + BOX_H);
- return switch (button) {
- .left, .middle => -local,
- .right => local,
- };
-}
-
-pub fn drawGutter(
- root: anytype,
- r: Rect,
- active: bool,
- active_box: [3]u8,
- inactive_box: [3]u8,
- track: [3]u8,
- thumb: [3]u8,
- scrollbar: ?Scrollbar,
-) void {
- const gut = root.child(.{ .x_off = r.x, .y_off = r.y, .width = GUTTER, .height = r.h });
- const box_bg = if (active) active_box else inactive_box;
- var gy: u16 = 0;
- while (gy < r.h) : (gy += 1) {
- const bg = if (gy < BOX_H) box_bg else track;
- var gx: u16 = 0;
- while (gx < GUTTER) : (gx += 1) {
- gut.writeCell(gx, gy, .{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = bg } } });
- }
- }
-
- const sb = scrollbar orelse return;
- const track_h: usize = if (r.h > BOX_H) r.h - BOX_H else 0;
- if (track_h == 0) return;
- const total = if (sb.total == 0) 1 else sb.total;
- const len = @max(1, (track_h * @max(1, sb.len)) / total);
- const pos = (track_h * sb.offset) / total;
- var sy: usize = 0;
- while (sy < track_h) : (sy += 1) {
- if (sy < pos or sy >= pos + len) continue;
- var gx: u16 = 0;
- while (gx < GUTTER) : (gx += 1) {
- gut.writeCell(gx, @intCast(BOX_H + sy), .{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = thumb } } });
- }
- }
-}
-
-pub fn overlayDash(win: anytype, col: u16, row: u16, glyph: []const u8) void {
- var cell = win.readCell(col, row) orelse return;
- cell.char.grapheme = glyph;
- cell.default = false;
- win.writeCell(col, row, cell);
-}
-
-pub fn drawBorderV(win: anytype, col: u16, screen_h: u16) void {
- var row: u16 = TOPBAR_H;
- while (row < screen_h) : (row += 1) overlayDash(win, col, row, "╎");
-}
-
-pub fn drawBorderH(win: anytype, x: u16, width: u16, row: u16) void {
- var col = x;
- while (col < x + width) : (col += 1) overlayDash(win, col, row, "╌");
-}
-
-pub fn drawMovePreview(win: anytype, l: anytype, rects: anytype, col_x: anytype, col_w: anytype, id: usize, cur_x: u16, cur_y: u16, color: [3]u8) void {
- const placement = movePlacement(l, rects, col_x, col_w, id, cur_x, cur_y) orelse return;
- var col: u16 = col_x[placement.preview_col];
- while (col < col_x[placement.preview_col] + col_w[placement.preview_col]) : (col += 1) {
- win.writeCell(col, placement.row, .{ .char = .{ .grapheme = "╌" }, .style = .{ .fg = .{ .rgb = color } } });
- }
- win.writeCell(cur_x, placement.row, .{ .char = .{ .grapheme = "▌" }, .style = .{ .fg = .{ .rgb = color } } });
-}
-
-pub fn drawHoverResizeHints(
- win: anytype,
- l: anytype,
- rects: anytype,
- col_x: anytype,
- col_w: anytype,
- screen_h: u16,
- hover_col: u16,
- hover_row: u16,
-) void {
- var c: usize = 0;
- while (c + 1 < l.ncol) : (c += 1) {
- if (hover_col == col_x[c] + col_w[c] -| 1) drawBorderV(win, hover_col, screen_h);
- }
- var cc: usize = 0;
- while (cc < l.ncol) : (cc += 1) {
- if (hover_col < col_x[cc] or hover_col >= col_x[cc] + col_w[cc]) continue;
- var k: usize = 0;
- while (k + 1 < l.col_n[cc]) : (k += 1) {
- const r = rects[l.col_terms[cc][k]];
- if (hover_row == r.y + r.h -| 1) drawBorderH(win, col_x[cc], col_w[cc], hover_row);
- }
- }
-}
-
-pub fn drawInteractionPreview(
- win: anytype,
- l: anytype,
- rects: anytype,
- col_x: anytype,
- col_w: anytype,
- screen_h: u16,
- drag: anytype,
- hover_col: u16,
- hover_row: u16,
- move_color: [3]u8,
-) void {
- switch (drag) {
- .border_v => |d| drawBorderV(win, d.cur_x, screen_h),
- .border_h => |d| drawBorderH(win, col_x[d.col], col_w[d.col], d.cur_y),
- .move => |d| drawMovePreview(win, l, rects, col_x, col_w, d.id, d.cur_x, d.cur_y, move_color),
- else => drawHoverResizeHints(win, l, rects, col_x, col_w, screen_h, hover_col, hover_row),
- }
-}
-
-const TestLayout = Layout(4, 8);
-const TestItem = struct { vweight: f32 = 1 };
-
-fn testVWeight(items: []const TestItem, id: usize) ?f32 {
- return items[id].vweight;
-}
-
-fn setTestVWeight(items: []TestItem, id: usize, weight: f32) void {
- items[id].vweight = weight;
-}
-
-fn expectYH(r: Rect, y: u16, h: u16) !void {
- try std.testing.expectEqual(y, r.y);
- try std.testing.expectEqual(h, r.h);
-}
-
-test "move into one-pane column appends at marker row" {
- var layout: TestLayout = .{
- .ncol = 2,
- .col_weight = @splat(1),
- .col_terms = undefined,
- .col_n = @splat(0),
- };
- layout.col_n[0] = 2;
- layout.col_terms[0][0] = 0;
- layout.col_terms[0][1] = 1;
- layout.col_n[1] = 1;
- layout.col_terms[1][0] = 2;
-
- var items = [_]TestItem{ .{ .vweight = 10 }, .{ .vweight = 20 }, .{ .vweight = 30 }, .{} };
- var rects: [8]Rect = undefined;
- var col_x: [4]u16 = undefined;
- var col_w: [4]u16 = undefined;
- computeGeom(&layout, items[0..], 100, 31, &rects, &col_x, &col_w, testVWeight);
-
- const placement = movePlacement(&layout, &rects, &col_x, &col_w, 0, 75, 18) orelse return error.NoMovePlacement;
- try std.testing.expectEqual(@as(usize, 2), placement.above_id);
- try std.testing.expectEqual(@as(u16, 18), placement.row);
-
- moveTerm(&layout, items[0..], &rects, &col_x, &col_w, 0, 75, 18, setTestVWeight);
- computeGeom(&layout, items[0..], 100, 31, &rects, &col_x, &col_w, testVWeight);
-
- try std.testing.expectEqual(@as(usize, 2), layout.ncol);
- try std.testing.expectEqual(@as(usize, 1), layout.col_n[0]);
- try std.testing.expectEqual(@as(usize, 2), layout.col_n[1]);
- try std.testing.expectEqual(@as(usize, 2), layout.col_terms[1][0]);
- try std.testing.expectEqual(@as(usize, 0), layout.col_terms[1][1]);
- try expectYH(rects[1], 1, 30);
- try expectYH(rects[2], 1, 17);
- try expectYH(rects[0], 18, 13);
-}
-
-test "move into multi-pane column only splits the pane above" {
- var layout: TestLayout = .{
- .ncol = 2,
- .col_weight = @splat(1),
- .col_terms = undefined,
- .col_n = @splat(0),
- };
- layout.col_n[0] = 3;
- layout.col_terms[0][0] = 0;
- layout.col_terms[0][1] = 1;
- layout.col_terms[0][2] = 2;
- layout.col_n[1] = 1;
- layout.col_terms[1][0] = 3;
-
- var items = [_]TestItem{ .{ .vweight = 10 }, .{ .vweight = 10 }, .{ .vweight = 10 }, .{ .vweight = 30 } };
- var rects: [8]Rect = undefined;
- var col_x: [4]u16 = undefined;
- var col_w: [4]u16 = undefined;
- computeGeom(&layout, items[0..], 100, 31, &rects, &col_x, &col_w, testVWeight);
- const before_top = rects[0];
- const before_bottom = rects[2];
-
- const placement = movePlacement(&layout, &rects, &col_x, &col_w, 3, 20, 15) orelse return error.NoMovePlacement;
- try std.testing.expectEqual(@as(usize, 1), placement.above_id);
- try std.testing.expectEqual(@as(u16, 15), placement.row);
-
- moveTerm(&layout, items[0..], &rects, &col_x, &col_w, 3, 20, 15, setTestVWeight);
- computeGeom(&layout, items[0..], 100, 31, &rects, &col_x, &col_w, testVWeight);
-
- try std.testing.expectEqual(@as(usize, 1), layout.ncol);
- try std.testing.expectEqual(@as(usize, 4), layout.col_n[0]);
- try std.testing.expectEqual(@as(usize, 0), layout.col_terms[0][0]);
- try std.testing.expectEqual(@as(usize, 1), layout.col_terms[0][1]);
- try std.testing.expectEqual(@as(usize, 3), layout.col_terms[0][2]);
- try std.testing.expectEqual(@as(usize, 2), layout.col_terms[0][3]);
- try expectYH(rects[0], before_top.y, before_top.h);
- try expectYH(rects[1], 11, 4);
- try expectYH(rects[3], 15, 6);
- try expectYH(rects[2], before_bottom.y, before_bottom.h);
-}
diff --git a/replay.zig b/replay.zig
deleted file mode 100644
index 4add78f0..00000000
--- a/replay.zig
+++ /dev/null
@@ -1,786 +0,0 @@
-const std = @import("std");
-const builtin = @import("builtin");
-const ui = @import("ui");
-const ghostty_vt = @import("ghostty-vt");
-const replay_config = @import("replay_config");
-const dump = @import("dump.zig");
-const paint = @import("paint.zig");
-const image = @import("image.zig");
-const pane_ui = @import("pane_ui.zig");
-
-const is_emscripten = builtin.os.tag == .emscripten;
-const embedded_dump = if (replay_config.embedded_dump) @embedFile("replay.dump.zon") else "";
-const wasm_gpa_buffer_size = 1024 * 1024 * 1024;
-
-var wasm_gpa_buffer: [if (is_emscripten) wasm_gpa_buffer_size else 0]u8 align(16) = undefined;
-var wasm_gpa_fba: std.heap.FixedBufferAllocator = undefined;
-
-extern "c" fn emscripten_console_error(utf8String: [*:0]const u8) void;
-
-pub const std_options_debug_threaded_io: ?*std.Io.Threaded = if (is_emscripten)
- null
-else
- std.Io.Threaded.global_single_threaded;
-pub const std_options_debug_io: std.Io = if (is_emscripten)
- std.Io.failing
-else
- std_options_debug_threaded_io.?.io();
-
-const TOPBAR_H: u16 = pane_ui.TOPBAR_H;
-const GUTTER: u16 = pane_ui.GUTTER;
-const BOX_H: u16 = pane_ui.BOX_H;
-const MINW: u16 = pane_ui.MINW;
-const MINH: u16 = pane_ui.MINH;
-const PREFIX_W: u16 = pane_ui.PREFIX_W;
-
-const Rect = pane_ui.Rect;
-
-const ReplayTerm = struct {
- term: ghostty_vt.Terminal,
- stream: ghostty_vt.TerminalStream,
-};
-
-const Pane = struct {
- kind: dump.PaneKind,
- tag: []const u8,
- content: []const u8,
- owned_content: ?[]u8 = null,
- path: []const u8,
- terminal: ?ReplayTerm = null,
- image_view: ?image.View = null,
- image_bytes: []const u8 = &.{},
- tty_mode: bool = false,
- highlights: []u8 = &.{},
- scroll: usize,
- cols: u16,
- rows: u16,
- vweight: f32,
-
- fn isTerminal(self: *const Pane) bool {
- return self.terminal != null;
- }
-};
-
-const Layout = pane_ui.Layout(dump.max_cols, dump.max_panes);
-const moveTerm = pane_ui.moveTerm;
-
-const Drag = union(enum) {
- none,
- border_v: struct { left_col: usize, cur_x: u16 },
- border_h: struct { col: usize, top_idx: usize, cur_y: u16 },
- move: struct { id: usize, cur_x: u16, cur_y: u16 },
-};
-
-pub const Command = struct {
- prev: ?*Command = null,
- pub var value: union(enum) {
- close,
- nop,
- quit,
- tick,
- key_press: ui.Key,
- winsize: ui.Winsize,
- mouse: ui.Mouse,
- paste: []const u8,
- pinch: f32,
- touch_scroll_delta: f32,
- } = .nop;
-};
-
-const Loop = ui.Loop(@TypeOf(Command.value));
-
-const accent = paint.accent;
-const accent_dim = paint.accent_dim;
-const T = paint.T;
-const rgbColor = paint.rgbColor;
-const colorOpt = paint.colorOpt;
-const synStyle = paint.synStyle;
-const highlightFile = paint.highlightFile;
-const cellStyle = paint.cellStyle;
-
-const SelectAction = enum { none, colors, nextcolor };
-const Builtin = struct { name: []const u8, action: SelectAction };
-const builtins = [_]Builtin{
- .{ .name = "Colors", .action = .colors },
- .{ .name = "NextColor", .action = .nextcolor },
-};
-
-fn topbarText(state: dump.State) []const u8 {
- return if (state.topbar.len > 0) state.topbar else "pardes replay";
-}
-
-fn wordAtCol(str: []const u8, col: u16) []const u8 {
- const idx: usize = col;
- if (idx >= str.len or str[idx] == ' ') return "";
- var lo: usize = idx;
- while (lo > 0 and str[lo - 1] != ' ') lo -= 1;
- var hi: usize = idx;
- while (hi < str.len and str[hi] != ' ') hi += 1;
- return str[lo..hi];
-}
-
-fn onSelect(text: []const u8) SelectAction {
- const cmd = std.mem.trim(u8, text, " \t\r\n");
- if (cmd.len == 0) return .none;
- inline for (builtins) |b| {
- if (std.mem.eql(u8, cmd, b.name)) return b.action;
- }
- return .none;
-}
-
-fn printUsage(io: std.Io) !void {
- try std.Io.File.stdout().writeStreamingAll(io, "Usage: pardes-replay <dump.zon>\n");
-}
-
-fn lineCount(text: []const u8) usize {
- return std.mem.count(u8, text, "\n") + 1;
-}
-
-fn lineAt(text: []const u8, row: usize) []const u8 {
- var it = std.mem.splitScalar(u8, text, '\n');
- var i: usize = 0;
- while (it.next()) |line| : (i += 1) {
- if (i == row) return line;
- }
- return "";
-}
-
-fn clampScroll(p: *Pane) void {
- if (p.isTerminal()) return;
- const total = lineCount(p.content);
- const max_scroll = total -| @max(1, p.rows);
- if (p.scroll > max_scroll) p.scroll = max_scroll;
-}
-
-fn scrollBy(p: *Pane, delta: i32) void {
- if (p.terminal) |*rt| {
- rt.term.screens.active.scroll(.{ .delta_row = @intCast(delta) });
- return;
- }
- const total = lineCount(p.content);
- const max_scroll: i64 = @intCast(total -| @max(1, p.rows));
- const next = std.math.clamp(@as(i64, @intCast(p.scroll)) + delta, 0, max_scroll);
- p.scroll = @intCast(next);
-}
-
-fn promptFlags(rt: *ReplayTerm, flags: []bool) void {
- @memset(flags, false);
- var it = rt.term.screens.active.pages.rowIterator(.right_down, .{ .viewport = .{} }, null);
- var i: usize = 0;
- while (it.next()) |pin| : (i += 1) {
- if (i >= flags.len) break;
- flags[i] = pin.rowAndCell().row.semantic_prompt != .none;
- }
-}
-
-fn terminalBodyText(alloc: std.mem.Allocator, p: *Pane) ![]const u8 {
- const rt = &p.terminal.?;
- const body = try rt.term.plainString(alloc);
- var flagbuf: [512]bool = undefined;
- const nrows: usize = @min(p.rows, flagbuf.len);
- const flags = flagbuf[0..nrows];
- if (p.tty_mode) @memset(flags, false) else promptFlags(rt, flags);
-
- var out: std.ArrayList(u8) = .empty;
- errdefer out.deinit(alloc);
- var it = std.mem.splitAny(u8, body, "\n");
- var i: usize = 0;
- while (i < nrows) : (i += 1) {
- if (i > 0) try out.append(alloc, '\n');
- const raw = it.next() orelse "";
- try out.appendSlice(alloc, if (flags[i]) "" else raw);
- }
- return out.toOwnedSlice(alloc);
-}
-
-fn paneVWeight(panes: []const Pane, id: usize) ?f32 {
- return panes[id].vweight;
-}
-
-fn setPaneVWeight(panes: []Pane, id: usize, weight: f32) void {
- panes[id].vweight = weight;
-}
-
-fn renderPane(alloc: std.mem.Allocator, root: ui.Window, p: *Pane, r: Rect, active: bool, colors_on: bool) !void {
- if (r.w <= GUTTER or r.h == 0) return;
- const text = root.child(.{ .x_off = r.x + GUTTER, .y_off = r.y, .width = r.w - GUTTER, .height = r.h });
- text.clear();
- if (T().bg) |bg| text.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = rgbColor(bg) } });
-
- const tagw = text.child(.{ .height = BOX_H });
- tagw.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = rgbColor(T().tag_bg) } });
- _ = tagw.printSegment(.{ .text = p.tag, .style = .{ .fg = rgbColor(T().tag_fg), .bg = rgbColor(T().tag_bg) } }, .{ .wrap = .none });
-
- const body_h = if (r.h > BOX_H) r.h - BOX_H else 0;
- const body_w = r.w - GUTTER;
- const body_style: ui.Style = .{ .fg = colorOpt(T().fg), .bg = colorOpt(T().bg) };
-
- if (p.image_view) |*iv| {
- if (body_h > 0) image.draw(iv, text.child(.{ .y_off = BOX_H, .height = body_h }), body_w, body_h);
- pane_ui.drawGutter(root, r, active, accent, accent_dim, T().scroll_track, T().scroll_thumb, null);
- return;
- }
-
- if (p.terminal != null) {
- const body = try terminalBodyText(alloc, p);
- var it = std.mem.splitAny(u8, body, "\n");
- var vr: u16 = 0;
- while (it.next()) |line| : (vr += 1) {
- if (vr >= body_h) break;
- _ = text.printSegment(.{ .text = line, .style = body_style }, .{ .row_offset = BOX_H + vr, .wrap = .none });
- }
-
- if (colors_on) {
- const rt = &p.terminal.?;
- vr = 0;
- while (vr < body_h and vr < p.rows) : (vr += 1) {
- var c: u16 = 0;
- while (c < body_w) : (c += 1) {
- const ci = rt.term.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(c), .y = @intCast(vr) } }) orelse continue;
- if (ci.cell.wide == .spacer_tail) continue;
- if (text.readCell(c, BOX_H + vr)) |cell| {
- var nc = cell;
- nc.default = false;
- nc.style = cellStyle(ci);
- text.writeCell(c, BOX_H + vr, nc);
- }
- }
- }
- }
- } else if (p.kind == .file) {
- var vr: u16 = 0;
- while (vr < body_h) : (vr += 1) {
- const abs = p.scroll + vr;
- const prefix = try std.fmt.allocPrint(alloc, "{d: >4} ", .{abs + 1});
- _ = text.printSegment(.{ .text = prefix, .style = body_style }, .{ .row_offset = BOX_H + vr, .wrap = .none });
- _ = text.printSegment(.{ .text = lineAt(p.content, abs), .style = body_style }, .{ .row_offset = BOX_H + vr, .col_offset = PREFIX_W, .wrap = .none });
- }
-
- const ln_fg = rgbColor(T().lineno);
- vr = 0;
- while (vr < body_h) : (vr += 1) {
- var c: u16 = 0;
- while (c < PREFIX_W and c < body_w) : (c += 1) {
- if (text.readCell(c, BOX_H + vr)) |cell| {
- var nc = cell;
- nc.default = false;
- nc.style.fg = ln_fg;
- text.writeCell(c, BOX_H + vr, nc);
- }
- }
- }
-
- if (colors_on and p.highlights.len > 0) {
- var lines = std.mem.splitScalar(u8, p.content, '\n');
- var skip: usize = 0;
- while (skip < p.scroll) : (skip += 1) if (lines.next() == null) break;
- vr = 0;
- while (vr < body_h) : (vr += 1) {
- const line = lines.next() orelse break;
- const base = @intFromPtr(line.ptr) - @intFromPtr(p.content.ptr);
- var c: usize = 0;
- while (c < line.len and PREFIX_W + c < body_w and base + c < p.highlights.len) : (c += 1) {
- const ss = synStyle(@enumFromInt(p.highlights[base + c])) orelse continue;
- const cx: u16 = @intCast(PREFIX_W + c);
- if (text.readCell(cx, BOX_H + vr)) |cell| {
- var nc = cell;
- nc.default = false;
- nc.style.fg = rgbColor(ss.fg);
- nc.style.bold = ss.bold;
- text.writeCell(cx, BOX_H + vr, nc);
- }
- }
- }
- }
- } else {
- var vr: u16 = 0;
- while (vr < body_h) : (vr += 1) {
- const abs = p.scroll + vr;
- _ = text.printSegment(.{ .text = lineAt(p.content, abs), .style = body_style }, .{ .row_offset = BOX_H + vr, .wrap = .none });
- }
- }
-
- const scrollbar: pane_ui.Scrollbar = if (p.terminal) |*rt| blk: {
- const sb = rt.term.screens.active.pages.scrollbar();
- break :blk .{ .total = sb.total, .offset = sb.offset, .len = sb.len };
- } else .{
- .total = lineCount(p.content),
- .offset = p.scroll,
- .len = p.rows,
- };
- pane_ui.drawGutter(root, r, active, accent, accent_dim, T().scroll_track, T().scroll_thumb, scrollbar);
-}
-
-fn render(
- vx: *ui.Vaxis,
- gpa: std.mem.Allocator,
- writer: *std.Io.Writer,
- state: dump.State,
- panes: []Pane,
- rects: *[dump.max_panes]Rect,
- layout: *const Layout,
- col_x: *const [dump.max_cols]u16,
- col_w: *const [dump.max_cols]u16,
- screen_w: u16,
- screen_h: u16,
- active: usize,
- drag: Drag,
- hover_col: u16,
- hover_row: u16,
- ov_pinch_scale: f32,
- ov_touch_scroll_delta: f32,
- colors_on: bool,
-) !void {
- var arena: std.heap.ArenaAllocator = .init(gpa);
- defer arena.deinit();
- const alloc = arena.allocator();
-
- const win = vx.window();
- win.clear();
- win.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = rgbColor(T().scroll_track) } });
-
- for (panes, 0..) |*p, id| {
- if (p.image_view) |*iv| {
- image.ensureLoadedFromMemory(iv, p.image_bytes, vx, gpa, writer);
- image.ensureGrid(iv, gpa, p.cols, p.rows);
- }
- try renderPane(alloc, win, p, rects[id], id == active, colors_on);
- }
-
- const bar = win.child(.{ .x_off = 0, .y_off = 0, .width = screen_w, .height = TOPBAR_H });
- bar.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = rgbColor(T().tag_bg) } });
- const topbar = topbarText(state);
- _ = bar.printSegment(.{ .text = topbar, .style = .{ .fg = rgbColor(T().tag_fg), .bg = rgbColor(T().tag_bg) } }, .{ .wrap = .none });
-
- pane_ui.drawInteractionPreview(win, layout, rects, col_x, col_w, screen_h, drag, hover_col, hover_row, T().scroll_thumb);
-
- // debug overlay with gesture telemetry
- if (ov_pinch_scale != 1.0 or ov_touch_scroll_delta != 0.0) {
- var ov_buf: [96]u8 = undefined;
- const s = std.fmt.bufPrint(&ov_buf, "pinch {d:6.4}\ntscroll {d:7.4}", .{ ov_pinch_scale, ov_touch_scroll_delta }) catch "";
- if (s.len > 0) {
- const ow: u16 = 18;
- const oh: u16 = 3;
- const ox: u16 = if (screen_w > ow) screen_w - ow else 0;
- const ov = win.child(.{ .x_off = ox, .y_off = TOPBAR_H, .width = ow, .height = oh, .border = .{ .where = .all } });
- ov.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = rgbColor(T().scroll_track) } });
- _ = ov.printSegment(.{ .text = s, .style = .{ .fg = rgbColor(accent) } }, .{});
- }
- }
-
- try vx.render(writer);
-}
-
-fn deinitPanes(gpa: std.mem.Allocator, panes: []Pane) void {
- for (panes) |*p| {
- if (p.terminal) |*rt| {
- rt.stream.deinit();
- rt.term.deinit(gpa);
- }
- if (p.image_view) |*iv| image.deinitView(iv, gpa);
- if (p.highlights.len > 0) gpa.free(p.highlights);
- if (p.owned_content) |bytes| gpa.free(bytes);
- }
-}
-
-fn terminalScrollbackLimit(src: dump.Pane) usize {
- if (!is_emscripten) return 16 * 1024 * 1024;
- const cols: usize = @max(1, src.cols);
- const rows: usize = @max(1, src.rows);
- const visible_and_scrolled = rows + src.scroll + 8;
- const estimated = visible_and_scrolled * cols * 128;
- return std.math.clamp(estimated, 64 * 1024, 1024 * 1024);
-}
-
-fn buildPane(gpa: std.mem.Allocator, src: dump.Pane, out: *Pane) !void {
- out.* = .{
- .kind = src.kind,
- .tag = src.tag,
- .content = src.body,
- .path = "",
- .tty_mode = std.mem.startsWith(u8, src.tag, "sy "),
- .scroll = src.scroll,
- .cols = src.cols,
- .rows = src.rows,
- .vweight = src.vweight,
- };
-
- switch (src.kind) {
- .file => if (src.file) |f| {
- out.path = f.path;
- if (f.content_b64.len > 0) {
- const bytes = try dump.decodeBytes(gpa, f.content_b64);
- out.owned_content = bytes;
- out.content = bytes;
- } else {
- out.content = f.content;
- }
- out.highlights = highlightFile(gpa, out.path, out.content) catch &.{};
- },
- .terminal => if (src.terminal) |t| {
- out.path = t.cwd;
- if (t.stream_b64.len > 0) {
- const bytes = try dump.decodeBytes(gpa, t.stream_b64);
- out.owned_content = bytes;
- out.content = bytes;
- out.terminal = .{
- .term = try ghostty_vt.Terminal.init(gpa, .{
- .cols = @max(1, src.cols),
- .rows = @max(1, src.rows),
- .max_scrollback = terminalScrollbackLimit(src),
- }),
- .stream = undefined,
- };
- if (out.terminal) |*rt| {
- rt.stream = rt.term.vtStream();
- rt.stream.nextSlice(bytes);
- rt.term.screens.active.scroll(.active);
- if (src.scroll > 0) rt.term.screens.active.scroll(.{ .delta_row = -@as(isize, @intCast(src.scroll)) });
- }
- } else {
- out.content = if (t.stream.len > 0) t.stream else src.body;
- }
- },
- .image => if (src.image) |im| {
- out.path = im.path;
- out.content = src.body;
- if (im.bytes_b64.len > 0) {
- const bytes = try dump.decodeBytes(gpa, im.bytes_b64);
- out.owned_content = bytes;
- out.image_bytes = bytes;
- }
- out.image_view = .{ .path = im.path };
- },
- }
- clampScroll(out);
-}
-
-const App = struct {
- io: std.Io,
- gpa: std.mem.Allocator,
- state: dump.State,
- panes_buf: [dump.max_panes]Pane,
- panes: []Pane,
- layout: Layout,
- screen_w: u16,
- screen_h: u16,
- active: usize,
- tty_buf: [0x10000]u8,
- tty: ui.Tty,
- vx: ui.Vaxis,
- loop: Loop,
- rects: [dump.max_panes]Rect,
- col_x: [dump.max_cols]u16,
- col_w: [dump.max_cols]u16,
- drag: Drag = .none,
- hover_col: u16 = 0,
- hover_row: u16 = 0,
- ov_pinch_scale: f32 = 1.0,
- ov_touch_scroll_delta: f32 = 0.0,
- colors_on: bool = true,
-
- fn init(
- self: *App,
- io: std.Io,
- gpa: std.mem.Allocator,
- env_map: *std.process.Environ.Map,
- args: []const [:0]const u8,
- ) !void {
- if (!replay_config.embedded_dump and args.len != 2) {
- try printUsage(io);
- return error.BadArgs;
- }
-
- self.io = io;
- self.gpa = gpa;
- self.state = if (replay_config.embedded_dump)
- try dump.readZonZ(gpa, embedded_dump, "embedded replay dump")
- else
- try dump.readFile(io, gpa, args[1]);
- _ = paint.setThemeByName(self.state.theme);
- image.start(io, gpa);
-
- for (self.state.panes, 0..) |src, i| {
- try buildPane(gpa, src, &self.panes_buf[i]);
- }
- self.panes = self.panes_buf[0..self.state.panes.len];
-
- self.layout = .{
- .ncol = self.state.columns.len,
- .col_weight = @splat(1),
- .col_terms = undefined,
- .col_n = @splat(0),
- };
- for (self.state.columns, 0..) |col, c| {
- self.layout.col_weight[c] = col.weight;
- self.layout.col_n[c] = col.panes.len;
- for (col.panes, 0..) |pane, k| self.layout.col_terms[c][k] = pane;
- }
-
- self.screen_w = if (self.state.screen.cols > 0) self.state.screen.cols else 80;
- self.screen_h = if (self.state.screen.rows > 0) self.state.screen.rows else 24;
- self.active = self.state.active;
- self.drag = .none;
- self.hover_col = 0;
- self.hover_row = 0;
- self.ov_touch_scroll_delta = 0.0;
- self.colors_on = true;
-
- self.tty = try ui.Tty.init(io, &self.tty_buf);
- self.vx = try ui.init(io, gpa, env_map, .{});
- try self.vx.setMouseMode(self.tty.writer(), true);
- try self.vx.enterAltScreen(self.tty.writer());
- if (is_emscripten) {
- try self.vx.resize(gpa, self.tty.writer(), .{
- .cols = self.screen_w,
- .rows = self.screen_h,
- .x_pixel = 0,
- .y_pixel = 0,
- });
- }
-
- self.loop = .init(io, &self.tty, &self.vx);
- try self.loop.start();
- try self.loop.installResizeHandler();
-
- pane_ui.computeGeom(&self.layout, self.panes, self.screen_w, self.screen_h, &self.rects, &self.col_x, &self.col_w, paneVWeight);
- }
-
- fn deinit(self: *App) void {
- self.loop.stop();
- self.vx.exitAltScreen(self.tty.writer()) catch {};
- self.vx.deinit(self.gpa, self.tty.writer());
- deinitPanes(self.gpa, self.panes);
- image.stop();
- dump.free(self.gpa, self.state);
- }
-
- fn run(self: *App) !void {
- while (try self.step(try self.loop.nextEvent())) {}
- }
-
- fn frame(self: *App) !bool {
- var had_event = false;
- while (try self.loop.tryEvent()) |event| {
- had_event = true;
- if (!try self.step(event)) return false;
- }
- if (!had_event) return try self.step(.tick);
- return true;
- }
-
- fn step(self: *App, event: @TypeOf(Command.value)) !bool {
- switch (event) {
- .winsize => |ws| {
- self.screen_w = ws.cols;
- self.screen_h = ws.rows;
- try self.vx.resize(self.gpa, self.tty.writer(), ws);
- },
- .mouse => |mouse| {
- const mpos = pane_ui.clampMousePos(self.screen_w, self.screen_h, mouse);
- const mcol = mpos.col;
- const mrow = mpos.row;
- self.hover_col = mcol;
- self.hover_row = mrow;
- const hovered = pane_ui.paneAt(&self.rects, self.panes.len, mcol, mrow);
-
- if (mouse.button == .wheel_down and mouse.type == .press) {
- if (hovered) |id| scrollBy(&self.panes[id], 1);
- } else if (mouse.button == .wheel_up and mouse.type == .press) {
- if (hovered) |id| scrollBy(&self.panes[id], -1);
- } else if (mouse.type == .press and mouse.button == .middle and mrow < TOPBAR_H) {
- self.actOnTopbarWord(mcol);
- } else if (mouse.type == .press and mouse.button == .left) {
- var handled = false;
- var c: usize = 0;
- while (c + 1 < self.layout.ncol and !handled) : (c += 1) {
- if (mcol == self.col_x[c] + self.col_w[c] -| 1) {
- self.drag = .{ .border_v = .{ .left_col = c, .cur_x = mcol } };
- handled = true;
- }
- }
- if (!handled) {
- var cc: usize = 0;
- outer: while (cc < self.layout.ncol) : (cc += 1) {
- if (mcol < self.col_x[cc] or mcol >= self.col_x[cc] + self.col_w[cc]) continue;
- var k: usize = 0;
- while (k + 1 < self.layout.col_n[cc]) : (k += 1) {
- const id = self.layout.col_terms[cc][k];
- const r = self.rects[id];
- if (mrow == r.y + r.h -| 1) {
- self.drag = .{ .border_h = .{ .col = cc, .top_idx = k, .cur_y = mrow } };
- handled = true;
- break :outer;
- }
- }
- }
- }
- if (!handled) if (hovered) |id| {
- self.active = id;
- const r = self.rects[id];
- if (pane_ui.inMoveBox(r, mcol, mrow)) {
- self.drag = .{ .move = .{ .id = id, .cur_x = mcol, .cur_y = mrow } };
- } else if (pane_ui.scrollbarClickDelta(r, .left, mcol, mrow)) |delta| {
- scrollBy(&self.panes[id], delta);
- }
- };
- } else if (mouse.type == .press and (mouse.button == .middle or mouse.button == .right)) {
- if (hovered) |id| {
- const r = self.rects[id];
- const sb_button: pane_ui.ScrollbarButton = if (mouse.button == .right) .right else .middle;
- if (pane_ui.scrollbarClickDelta(r, sb_button, mcol, mrow)) |delta| {
- if (mouse.button == .right) self.active = id;
- scrollBy(&self.panes[id], delta);
- } else {
- self.active = id;
- }
- }
- } else if (mouse.type == .press) {
- if (hovered) |id| self.active = id;
- } else if (mouse.type == .drag) {
- switch (self.drag) {
- .border_v => |*d| {
- const c = d.left_col;
- if (c + 1 < self.layout.ncol) {
- const lo = self.col_x[c] + MINW;
- const hi = self.col_x[c] +| self.col_w[c] +| self.col_w[c + 1] -| MINW;
- d.cur_x = if (lo <= hi) std.math.clamp(mcol, lo, hi) else mcol;
- }
- },
- .border_h => |*d| {
- const c = d.col;
- const k = d.top_idx;
- if (k + 1 < self.layout.col_n[c]) {
- const a = self.layout.col_terms[c][k];
- const b = self.layout.col_terms[c][k + 1];
- const lo = self.rects[a].y + MINH;
- const hi = self.rects[a].y +| self.rects[a].h +| self.rects[b].h -| MINH;
- d.cur_y = if (lo <= hi) std.math.clamp(mrow, lo, hi) else mrow;
- }
- },
- .move => |*d| {
- d.cur_x = mcol;
- d.cur_y = mrow;
- },
- .none => {},
- }
- } else if (mouse.type == .release) {
- switch (self.drag) {
- .border_v => |d| {
- const c = d.left_col;
- if (c + 1 < self.layout.ncol) {
- const combined: f32 = @floatFromInt(self.col_w[c] + self.col_w[c + 1]);
- var nl: f32 = @floatFromInt((d.cur_x + 1) -| self.col_x[c]);
- nl = std.math.clamp(nl, @as(f32, MINW), @max(@as(f32, MINW), combined - @as(f32, MINW)));
- const pair = self.layout.col_weight[c] + self.layout.col_weight[c + 1];
- self.layout.col_weight[c] = pair * (nl / combined);
- self.layout.col_weight[c + 1] = pair - self.layout.col_weight[c];
- }
- },
- .border_h => |d| {
- const c = d.col;
- const k = d.top_idx;
- if (k + 1 < self.layout.col_n[c]) {
- const a = self.layout.col_terms[c][k];
- const b = self.layout.col_terms[c][k + 1];
- const combined: f32 = @floatFromInt(self.rects[a].h + self.rects[b].h);
- var nt: f32 = @floatFromInt((d.cur_y + 1) -| self.rects[a].y);
- nt = std.math.clamp(nt, @as(f32, MINH), @max(@as(f32, MINH), combined - @as(f32, MINH)));
- const pair = self.panes[a].vweight + self.panes[b].vweight;
- self.panes[a].vweight = pair * (nt / combined);
- self.panes[b].vweight = pair - self.panes[a].vweight;
- }
- },
- .move => |d| moveTerm(&self.layout, self.panes, &self.rects, &self.col_x, &self.col_w, d.id, d.cur_x, d.cur_y, setPaneVWeight),
- .none => {},
- }
- self.drag = .none;
- }
- },
- .key_press => |key| {
- if (key.matches('q', .{}) or key.matches(ui.Key.escape, .{}) or key.matches('c', .{ .ctrl = true })) return false;
- if (key.matches(ui.Key.tab, .{}) and self.hover_row < TOPBAR_H) self.actOnTopbarWord(self.hover_col);
- },
- .paste => |text| self.gpa.free(text),
- .pinch => |scale| self.ov_pinch_scale = scale,
- .touch_scroll_delta => |delta| self.ov_touch_scroll_delta = delta,
- .quit, .close => return false,
- else => {},
- }
-
- pane_ui.computeGeom(&self.layout, self.panes, self.screen_w, self.screen_h, &self.rects, &self.col_x, &self.col_w, paneVWeight);
- for (self.panes, 0..) |*p, id| {
- const r = self.rects[id];
- const cols = @max(1, r.w -| GUTTER);
- const rows = @max(1, r.h -| BOX_H);
- if (p.terminal) |*rt| {
- if (cols != p.cols or rows != p.rows) rt.term.resize(self.gpa, cols, rows) catch {};
- }
- p.cols = cols;
- p.rows = rows;
- clampScroll(p);
- }
- try render(&self.vx, self.gpa, self.tty.writer(), self.state, self.panes, &self.rects, &self.layout, &self.col_x, &self.col_w, self.screen_w, self.screen_h, self.active, self.drag, self.hover_col, self.hover_row, self.ov_pinch_scale, self.ov_touch_scroll_delta, self.colors_on);
- return true;
- }
-
- fn actOnTopbarWord(self: *App, col: u16) void {
- switch (onSelect(wordAtCol(topbarText(self.state), col))) {
- .none => {},
- .colors => self.colors_on = !self.colors_on,
- .nextcolor => paint.nextTheme(),
- }
- }
-};
-
-fn wasmFrame(arg: ?*anyopaque) callconv(.c) void {
- const app: *App = @ptrCast(@alignCast(arg.?));
- if (app.frame() catch |err| {
- std.log.err("replay frame failed: {s}", .{@errorName(err)});
- std.os.emscripten.emscripten_cancel_main_loop();
- app.deinit();
- app.gpa.destroy(app);
- return;
- }) return;
- std.os.emscripten.emscripten_cancel_main_loop();
- app.deinit();
- app.gpa.destroy(app);
-}
-
-pub const main = if (is_emscripten) wasmMain else nativeMain;
-
-comptime {
- if (is_emscripten) @export(&emscriptenMain, .{ .name = "main" });
-}
-
-fn nativeMain(init: std.process.Init) !void {
- const args = try init.minimal.args.toSlice(init.arena.allocator());
- var app: App = undefined;
- try app.init(init.io, init.gpa, init.environ_map, args);
- defer app.deinit();
- try app.run();
-}
-
-fn emscriptenMain(argc: c_int, argv: [*]?[*:0]u8) callconv(.c) c_int {
- _ = argc;
- _ = argv;
- wasmMain() catch |err| {
- var buf: [128]u8 = undefined;
- const msg = std.fmt.bufPrintZ(&buf, "pardes replay wasm init failed: {s}", .{@errorName(err)}) catch "pardes replay wasm init failed";
- emscripten_console_error(msg.ptr);
- return 1;
- };
- return 0;
-}
-
-fn wasmMain() !void {
- wasm_gpa_fba = std.heap.FixedBufferAllocator.init(&wasm_gpa_buffer);
- const gpa = wasm_gpa_fba.allocator();
- var env_map = std.process.Environ.Map.init(gpa);
- defer env_map.deinit();
-
- const app = try gpa.create(App);
- errdefer gpa.destroy(app);
- try app.init(std.Io.failing, gpa, &env_map, &.{});
- std.os.emscripten.emscripten_set_main_loop_arg(wasmFrame, app, 0, 0);
-}
diff --git a/dump.zig b/src/dump.zig
index 9c1dceec..9c1dceec 100644
--- a/dump.zig
+++ b/src/dump.zig
diff --git a/src/gui.zig b/src/gui.zig
new file mode 100644
index 00000000..516a6b79
--- /dev/null
+++ b/src/gui.zig
@@ -0,0 +1,2399 @@
+//! The SDL3 GPU shell: owns an SDL window + event loop, translates SDL input
+//! into core events, performs the core's effects (fork ptys, write them,
+//! resize them — same duties as tty.zig, this is also native linux), and
+//! rasterizes the core's Surface: one instanced quad per cell, glyphs from an
+//! stb_truetype 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;
+const linux = std.os.linux;
+const vaxis = @import("vaxis"); // test modes only: the stdin escape-seq parser
+const ghostty_vt = @import("ghostty-vt"); // 256-color palette for .index cells
+const pardes = @import("pardes.zig");
+
+const is_emscripten = builtin.os.tag == .emscripten;
+
+pub const c = @cImport({
+ @cDefine("SDL_DISABLE_OLD_NAMES", "1");
+ @cInclude("SDL3/SDL.h");
+ if (is_emscripten) @cInclude("GLES3/gl3.h");
+ @cInclude("font.h");
+});
+
+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;
+extern "c" fn chdir(path: [*:0]const u8) c_int;
+extern "c" fn _exit(status: c_int) noreturn;
+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;
+
+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");
+// 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 "";
+
+// default page colors — the same "terminal native dark" the prototype used
+const bg_default = [3]u8{ 18, 18, 18 };
+const fg_default = [3]u8{ 204, 204, 204 };
+
+// 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;
+
+const atlas_w: u32 = if (is_emscripten) 2048 else 1024;
+const atlas_h: u32 = if (is_emscripten) 2048 else 1024;
+const Slot = struct { u: u32, v: u32 };
+
+const max_touch_points: usize = 16;
+const max_touch_trail_points: usize = 8;
+const overlay_circle_vertices: usize = 16 * 3;
+const touch_middle_flash_max_frames: u8 = 14;
+const touch_middle_flash_vertices: usize = 1400;
+const touch_scroll_tick: f32 = 0.02;
+const max_overlay_vertices: usize = touch_middle_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices);
+
+// One instance per cell; the vertex shader expands it to a 2-triangle quad
+// with gl_VertexIndex. Coords are NDC (y up), uv into the atlas, colors 0..1.
+const CellInstance = extern struct {
+ x0: f32,
+ y0: f32,
+ x1: f32,
+ y1: f32,
+ u0: f32,
+ v0: f32,
+ u1: f32,
+ v1: f32,
+ fr: f32,
+ fg: f32,
+ fb: f32,
+ br: f32,
+ bg: f32,
+ bb: f32,
+};
+
+const OverlayVertex = extern struct { x: f32, y: f32, r: f32, g: f32, b: f32, a: f32 };
+const OverlayColor = struct { r: f32, g: f32, b: f32, a: f32 };
+
+// ---- touch: per-finger tracking + the two-finger scroll/tap machine ----
+
+const TouchSample = struct { x: f32 = 0, y: f32 = 0, pressure: f32 = 1 };
+
+const TouchPoint = struct {
+ active: bool = false,
+ id: u64 = 0,
+ x: f32 = 0,
+ y: f32 = 0,
+ pressure: f32 = 1,
+ trail: [max_touch_trail_points]TouchSample = @splat(.{}),
+ trail_len: usize = 0,
+ trail_next: usize = 0,
+};
+
+const TouchNormPoint = struct { x: f32 = 0, y: f32 = 0 };
+
+/// A finger event with SDL's normalized 0..1 coordinates — the one shape both
+/// real SDL_EVENT_FINGER_* and the synthetic test OSC feed into the machine.
+const Finger = struct { kind: enum { down, motion, up, cancel }, id: u64, x: f32, y: f32, pressure: f32 };
+
+const Touch = struct {
+ points: [max_touch_points]TouchPoint = @splat(.{}),
+ scroll: struct {
+ active: bool = false,
+ ids: [2]u64 = .{ 0, 0 },
+ accum_y: f32 = 0,
+ scrolled: bool = false,
+ last_center: TouchNormPoint = .{},
+ } = .{},
+ middle_clicks: u32 = 0,
+ middle_flash_frames: u8 = 0,
+
+ fn findSlot(t: *const Touch, id: u64) ?usize {
+ for (&t.points, 0..) |*tp, i| if (tp.active and tp.id == id) return i;
+ return null;
+ }
+
+ fn updatePoint(t: *Touch, f: Finger) void {
+ switch (f.kind) {
+ .up, .cancel => if (t.findSlot(f.id)) |i| {
+ t.points[i].active = false;
+ },
+ .down, .motion => {
+ const idx = t.findSlot(f.id) orelse blk: {
+ for (&t.points, 0..) |*tp, i| if (!tp.active) break :blk i;
+ break :blk @as(usize, @intCast(f.id % max_touch_points));
+ };
+ const tp = &t.points[idx];
+ if (!tp.active or tp.id != f.id) tp.* = .{ .active = true, .id = f.id };
+ tp.x = f.x;
+ tp.y = f.y;
+ tp.pressure = f.pressure;
+ tp.trail[tp.trail_next] = .{ .x = f.x, .y = f.y, .pressure = f.pressure };
+ tp.trail_next = (tp.trail_next + 1) % max_touch_trail_points;
+ if (tp.trail_len < max_touch_trail_points) tp.trail_len += 1;
+ },
+ }
+ }
+
+ fn pairCenter(t: *const Touch, ids: [2]u64) ?TouchNormPoint {
+ var sum: TouchNormPoint = .{};
+ for (ids) |id| {
+ const i = t.findSlot(id) orelse return null;
+ sum.x += t.points[i].x;
+ sum.y += t.points[i].y;
+ }
+ return .{ .x = sum.x * 0.5, .y = sum.y * 0.5 };
+ }
+
+ /// keep scroll state in sync with the set of active fingers: exactly two
+ /// active fingers begin (or re-key) a pair; anything else resets it.
+ fn syncPair(t: *Touch) void {
+ var ids: [2]u64 = .{ 0, 0 };
+ var count: usize = 0;
+ for (&t.points) |*tp| {
+ if (!tp.active) continue;
+ if (count >= 2) {
+ t.scroll = .{};
+ return;
+ }
+ ids[count] = tp.id;
+ count += 1;
+ }
+ if (count != 2) {
+ t.scroll = .{};
+ return;
+ }
+ if (ids[0] > ids[1]) std.mem.swap(u64, &ids[0], &ids[1]);
+ if (t.scroll.active and t.scroll.ids[0] == ids[0] and t.scroll.ids[1] == ids[1]) return;
+ t.scroll = .{ .active = true, .ids = ids, .last_center = t.pairCenter(ids) orelse .{} };
+ }
+
+ /// finger lift: a pair that never scrolled is a two-finger TAP — returns
+ /// its center (computed with the lifting finger's final position).
+ fn finishPair(t: *Touch, f: Finger) ?TouchNormPoint {
+ if (!t.scroll.active or (t.scroll.ids[0] != f.id and t.scroll.ids[1] != f.id)) return null;
+ const tap = f.kind == .up and !t.scroll.scrolled;
+ var center: ?TouchNormPoint = null;
+ if (tap) {
+ var sum: TouchNormPoint = .{};
+ var ok = true;
+ for (t.scroll.ids) |id| {
+ if (id == f.id) {
+ sum.x += f.x;
+ sum.y += f.y;
+ } else if (t.findSlot(id)) |i| {
+ sum.x += t.points[i].x;
+ sum.y += t.points[i].y;
+ } else ok = false;
+ }
+ center = if (ok) .{ .x = sum.x * 0.5, .y = sum.y * 0.5 } else t.scroll.last_center;
+ }
+ t.scroll = .{};
+ return center;
+ }
+
+ fn noteMotion(t: *Touch, f: Finger) ?struct { center: TouchNormPoint, ticks: i32, delta_y: f32 } {
+ if (!t.scroll.active or (t.scroll.ids[0] != f.id and t.scroll.ids[1] != f.id)) return null;
+ const center = t.pairCenter(t.scroll.ids) orelse t.scroll.last_center;
+ const delta_y = center.y - t.scroll.last_center.y;
+ t.scroll.last_center = center;
+ t.scroll.accum_y += delta_y;
+ var ticks: i32 = 0;
+ while (t.scroll.accum_y >= touch_scroll_tick) : (ticks += 1) t.scroll.accum_y -= touch_scroll_tick;
+ while (t.scroll.accum_y <= -touch_scroll_tick) : (ticks -= 1) t.scroll.accum_y += touch_scroll_tick;
+ if (ticks == 0) return null;
+ t.scroll.scrolled = true;
+ return .{ .center = center, .ticks = ticks, .delta_y = @as(f32, @floatFromInt(ticks)) * touch_scroll_tick };
+ }
+};
+
+/// Two-finger drag → synthesized wheel presses + a touch_scroll event; a
+/// two-finger tap → synthesized middle press+release. win/cell dims map
+/// normalized coords to cells (grid test mode passes cols/rows with cell=1).
+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);
+ switch (f.kind) {
+ .down => {
+ t.updatePoint(f);
+ t.syncPair();
+ },
+ .motion => {
+ const had_pair = t.scroll.active;
+ t.updatePoint(f);
+ t.syncPair();
+ if (!had_pair) return;
+ const res = t.noteMotion(f) orelse return;
+ const col = normCell(res.center.x, win_w, cell_w);
+ const row = normCell(res.center.y, win_h, cell_h);
+ const button: pardes.Mouse.Button = if (res.ticks > 0) .wheel_up else .wheel_down;
+ var remaining: u32 = @abs(res.ticks);
+ while (remaining > 0) : (remaining -= 1)
+ core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = col, .row = row } });
+ core.update(.{ .touch_scroll = res.delta_y });
+ },
+ .up, .cancel => {
+ const tap_center = t.finishPair(f);
+ t.updatePoint(f);
+ t.syncPair();
+ if (tap_center) |center| {
+ t.middle_clicks +%= 1;
+ t.middle_flash_frames = touch_middle_flash_max_frames;
+ const col = normCell(center.x, win_w, cell_w);
+ const row = normCell(center.y, win_h, cell_h);
+ core.update(.{ .mouse = .{ .button = .middle, .kind = .press, .col = col, .row = row } });
+ core.update(.{ .mouse = .{ .button = .middle, .kind = .release, .col = col, .row = row } });
+ }
+ },
+ }
+}
+
+fn normCell(norm: f32, win: f32, cell: f32) u16 {
+ const px = std.math.clamp(norm, 0.0, 1.0) * win;
+ return @intFromFloat(@max(0, @floor(px / cell)));
+}
+
+// ---- pty plumbing: reader threads feed a mutex-protected queue ----
+
+const Pty = struct { fd: c_int, pid: linux.pid_t };
+
+const Msg = union(enum) { output: struct { pane: u8, bytes: []u8 }, eof: u8 };
+
+const Queue = struct {
+ gpa: std.mem.Allocator,
+ sdl_wake: bool, // wake a blocking SDL_WaitEventTimeout on cross-thread push
+ // 0.16 has no std.Thread.Mutex; critical sections here are a few
+ // instructions, so spinning on the lock-free std.atomic.Mutex is enough
+ mutex: std.atomic.Mutex = .unlocked,
+ items: std.ArrayList(Msg) = .empty,
+ closed: bool = false,
+
+ fn lock(q: *Queue) void {
+ while (!q.mutex.tryLock()) std.atomic.spinLoopHint();
+ }
+
+ fn push(q: *Queue, m: Msg) void {
+ q.lock();
+ if (q.closed) {
+ q.mutex.unlock();
+ switch (m) {
+ .output => |o| q.gpa.free(o.bytes),
+ .eof => {},
+ }
+ return;
+ }
+ q.items.append(q.gpa, m) catch {
+ q.mutex.unlock();
+ switch (m) {
+ .output => |o| q.gpa.free(o.bytes),
+ .eof => {},
+ }
+ return;
+ };
+ q.mutex.unlock();
+ if (q.sdl_wake) {
+ var sev = std.mem.zeroes(c.SDL_Event);
+ sev.type = c.SDL_EVENT_USER;
+ _ = c.SDL_PushEvent(&sev);
+ }
+ }
+
+ /// take the pending messages (caller iterates + deinits outside the lock)
+ fn take(q: *Queue) std.ArrayList(Msg) {
+ q.lock();
+ defer q.mutex.unlock();
+ const local = q.items;
+ q.items = .empty;
+ return local;
+ }
+
+ fn close(q: *Queue) void {
+ q.lock();
+ defer q.mutex.unlock();
+ q.closed = true;
+ for (q.items.items) |m| switch (m) {
+ .output => |o| q.gpa.free(o.bytes),
+ .eof => {},
+ };
+ q.items.deinit(q.gpa);
+ }
+};
+
+fn readPtyThread(gpa: std.mem.Allocator, fd: c_int, pane: u8, q: *Queue) void {
+ var buf: [0x10000]u8 = undefined;
+ while (true) {
+ const rc = linux.read(fd, &buf, buf.len);
+ const n: isize = @bitCast(rc);
+ if (n < 0) {
+ if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue;
+ break; // EIO when the child exits: treat as EOF
+ }
+ if (n == 0) break;
+ const bytes = gpa.dupe(u8, buf[0..@intCast(n)]) catch break;
+ q.push(.{ .output = .{ .pane = pane, .bytes = bytes } });
+ }
+ q.push(.{ .eof = pane });
+}
+
+fn spawnReader(gpa: std.mem.Allocator, pt: Pty, pane: u8, q: *Queue) void {
+ const th = std.Thread.spawn(.{}, readPtyThread, .{ gpa, pt.fd, pane, q }) catch return;
+ th.detach();
+}
+
+// ---- the renderer state ----
+
+const Gui = struct {
+ window: *c.SDL_Window,
+ device: *c.SDL_GPUDevice,
+ swapchain_format: c.SDL_GPUTextureFormat,
+ pipeline: *c.SDL_GPUGraphicsPipeline,
+ overlay_pipeline: *c.SDL_GPUGraphicsPipeline,
+ atlas_tex: *c.SDL_GPUTexture,
+ atlas_sampler: *c.SDL_GPUSampler,
+ atlas_xfer: *c.SDL_GPUTransferBuffer,
+ vbuf: ?*c.SDL_GPUBuffer = null,
+ vxfer: ?*c.SDL_GPUTransferBuffer = null,
+ vbuf_cells: u32 = 0,
+ overlay_vbuf: *c.SDL_GPUBuffer,
+ overlay_vxfer: *c.SDL_GPUTransferBuffer,
+ overlay_vertices: []OverlayVertex,
+
+ font: *c.UIFont,
+ scale: f32,
+ cell_w: u32,
+ cell_h: u32,
+ ascent: i32,
+
+ // glyph atlas: CPU staging bitmap + codepoint → texel slot, pen-walk alloc
+ atlas_stage: []u8,
+ glyphs: std.AutoHashMap(u32, 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,
+
+ // PARDES_TEST frame capture (render into an offscreen target, dump PPM)
+ capture: bool = false,
+ capture_dir: []const u8 = "",
+ capture_tex: ?*c.SDL_GPUTexture = null,
+ capture_tex_w: u32 = 0,
+ capture_tex_h: u32 = 0,
+ capture_xfer: ?*c.SDL_GPUTransferBuffer = null,
+ capture_xfer_size: u32 = 0,
+
+ // steamdeck: gamepad-driven virtual mouse cursor (pixel position)
+ gamepad: ?*c.SDL_Gamepad = null,
+ pad_x: f32 = 0,
+ pad_y: f32 = 0,
+ pad_scroll: f32 = 0,
+
+ // 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 = &.{},
+};
+
+/// 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.
+const CellLayout = struct { w: f32, h: f32, x_off: f32, y_off: f32 };
+
+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);
+ 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),
+ };
+}
+
+// =====================================================================
+// entry
+// =====================================================================
+
+pub const run = if (is_emscripten) runWeb else runNative;
+
+fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
+ const io = init.io;
+ const gpa = init.gpa;
+ const env = init.environ_map;
+ if (env.get("PARDES_TEST_GRID") != null) return runGrid(init, opts_in);
+
+ const test_mode = env.get("PARDES_TEST") != null;
+ const capture_dir = env.get("PARDES_TEST_CAPTURE_DIR");
+ if (test_mode and capture_dir == null) {
+ log.err("PARDES_TEST requires PARDES_TEST_CAPTURE_DIR", .{});
+ return error.SdlInit;
+ }
+
+ if (!c.SDL_Init(c.SDL_INIT_VIDEO | c.SDL_INIT_GAMEPAD)) {
+ log.err("SDL_Init: {s}", .{c.SDL_GetError()});
+ return error.SdlInit;
+ }
+ var win_flags: c.SDL_WindowFlags = c.SDL_WINDOW_RESIZABLE;
+ if (!test_mode) win_flags |= c.SDL_WINDOW_HIGH_PIXEL_DENSITY;
+ const window = c.SDL_CreateWindow("pardes", 1120, 720, win_flags) orelse {
+ log.err("SDL_CreateWindow: {s}", .{c.SDL_GetError()});
+ return error.SdlInit;
+ };
+ const device = c.SDL_CreateGPUDevice(c.SDL_GPU_SHADERFORMAT_SPIRV, true, null) orelse {
+ log.err("SDL_CreateGPUDevice: {s}", .{c.SDL_GetError()});
+ return error.SdlInit;
+ };
+ if (!c.SDL_ClaimWindowForGPUDevice(device, window)) {
+ log.err("ClaimWindowForGPUDevice: {s}", .{c.SDL_GetError()});
+ return error.SdlInit;
+ }
+ // present mode: PARDES_SDL_PRESENT env override, else immediate → mailbox → vsync
+ const present_mode: c.SDL_GPUPresentMode = blk: {
+ if (env.get("PARDES_SDL_PRESENT")) |raw| {
+ const want: ?c.SDL_GPUPresentMode = if (std.ascii.eqlIgnoreCase(raw, "immediate"))
+ c.SDL_GPU_PRESENTMODE_IMMEDIATE
+ else if (std.ascii.eqlIgnoreCase(raw, "mailbox"))
+ c.SDL_GPU_PRESENTMODE_MAILBOX
+ else if (std.ascii.eqlIgnoreCase(raw, "vsync"))
+ c.SDL_GPU_PRESENTMODE_VSYNC
+ else
+ null;
+ if (want) |mode| if (c.SDL_WindowSupportsGPUPresentMode(device, window, mode)) break :blk mode;
+ }
+ for ([_]c.SDL_GPUPresentMode{ c.SDL_GPU_PRESENTMODE_IMMEDIATE, c.SDL_GPU_PRESENTMODE_MAILBOX, c.SDL_GPU_PRESENTMODE_VSYNC }) |mode| {
+ if (c.SDL_WindowSupportsGPUPresentMode(device, window, mode)) break :blk mode;
+ }
+ break :blk c.SDL_GPU_PRESENTMODE_VSYNC;
+ };
+ if (!test_mode) _ = c.SDL_SetGPUAllowedFramesInFlight(device, 1);
+ _ = c.SDL_SetGPUSwapchainParameters(device, window, c.SDL_GPU_SWAPCHAINCOMPOSITION_SDR, present_mode);
+ const swapchain_format = c.SDL_GetGPUSwapchainTextureFormat(device, window);
+
+ // ---- font + cell metrics ----
+ 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 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, scale, &cw, &chh, &asc);
+ const cell_w: u32 = @intCast(@max(cw, 1));
+ const cell_h: u32 = @intCast(@max(chh, 1));
+ if (test_mode) {
+ const cols = envU16(env, "PARDES_TEST_COLS") orelse 80;
+ const rows = envU16(env, "PARDES_TEST_ROWS") orelse 24;
+ _ = c.SDL_SetWindowSize(window, @intCast(cols * cell_w), @intCast(rows * cell_h));
+ _ = c.SDL_SyncWindow(window);
+ }
+
+ // ---- glyph atlas (R8) + pipelines ----
+ var tex_info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo);
+ tex_info.type = c.SDL_GPU_TEXTURETYPE_2D;
+ tex_info.format = c.SDL_GPU_TEXTUREFORMAT_R8_UNORM;
+ tex_info.usage = c.SDL_GPU_TEXTUREUSAGE_SAMPLER;
+ tex_info.width = atlas_w;
+ tex_info.height = atlas_h;
+ tex_info.layer_count_or_depth = 1;
+ tex_info.num_levels = 1;
+ tex_info.sample_count = c.SDL_GPU_SAMPLECOUNT_1;
+ const atlas_tex = c.SDL_CreateGPUTexture(device, &tex_info) orelse return error.GpuCreate;
+
+ var samp_info = std.mem.zeroes(c.SDL_GPUSamplerCreateInfo);
+ samp_info.min_filter = c.SDL_GPU_FILTER_LINEAR;
+ samp_info.mag_filter = c.SDL_GPU_FILTER_LINEAR;
+ samp_info.mipmap_mode = c.SDL_GPU_FILTER_LINEAR;
+ samp_info.address_mode_u = c.SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE;
+ samp_info.address_mode_v = c.SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE;
+ samp_info.address_mode_w = c.SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE;
+ const atlas_sampler = c.SDL_CreateGPUSampler(device, &samp_info) orelse return error.GpuCreate;
+
+ var ax_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = atlas_w * atlas_h, .props = 0 };
+ const atlas_xfer = c.SDL_CreateGPUTransferBuffer(device, &ax_info) orelse return error.GpuCreate;
+
+ const pipeline = try makePipeline(device, swapchain_format);
+ const overlay_pipeline = try makeOverlayPipeline(device, swapchain_format);
+
+ const overlay_buffer_size: u32 = @intCast(max_overlay_vertices * @sizeOf(OverlayVertex));
+ var ovb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = overlay_buffer_size, .props = 0 };
+ const overlay_vbuf = c.SDL_CreateGPUBuffer(device, &ovb_info) orelse return error.GpuCreate;
+ var ovx_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = overlay_buffer_size, .props = 0 };
+ const overlay_vxfer = c.SDL_CreateGPUTransferBuffer(device, &ovx_info) orelse return error.GpuCreate;
+
+ const atlas_stage = try gpa.alloc(u8, atlas_w * atlas_h);
+ defer gpa.free(atlas_stage);
+ @memset(atlas_stage, 0);
+ const overlay_vertices = try gpa.alloc(OverlayVertex, max_overlay_vertices);
+ defer gpa.free(overlay_vertices);
+
+ var g: Gui = .{
+ .window = window,
+ .device = device,
+ .swapchain_format = swapchain_format,
+ .pipeline = pipeline,
+ .overlay_pipeline = overlay_pipeline,
+ .atlas_tex = atlas_tex,
+ .atlas_sampler = atlas_sampler,
+ .atlas_xfer = atlas_xfer,
+ .overlay_vbuf = overlay_vbuf,
+ .overlay_vxfer = overlay_vxfer,
+ .overlay_vertices = overlay_vertices,
+ .font = font,
+ .scale = scale,
+ .cell_w = cell_w,
+ .cell_h = cell_h,
+ .ascent = asc,
+ .atlas_stage = atlas_stage,
+ .glyphs = std.AutoHashMap(u32, Slot).init(gpa),
+ .capture = test_mode,
+ .capture_dir = capture_dir orelse "",
+ };
+ defer g.glyphs.deinit();
+ // 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), asc);
+ g.pen_x = cell_w;
+
+ // ---- the core ----
+ pardes.image.start(io, gpa); // stb_image allocator for image panes
+ defer pardes.image.stop();
+ // Live sessions initialize at the default 80x24 grid; the real window size
+ // arrives as a resize EVENT on the first loop pass. The core defers the
+ // shell greeting until after the first resize (so `ls` wraps to the real
+ // pane width) — pre-sizing at init would mean no resize ever fires and the
+ // greeting never runs (panes sat blank until the first interaction).
+ // Dump loads pre-size instead: replayed panes never greet, and sizing at
+ // init avoids reflowing their replayed content twice.
+ var opts = opts_in;
+ var pw: c_int = 0;
+ var ph: c_int = 0;
+ if (opts.load_path != null) {
+ _ = 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)));
+ }
+ const core = if (opts.load_path) |lp| blk: {
+ const bytes = try @import("look.zig").readFile(gpa, lp);
+ defer gpa.free(bytes);
+ break :blk try pardes.Pardes.initFromDump(gpa, opts, bytes);
+ } else try pardes.Pardes.init(gpa, opts);
+ defer core.deinit();
+ // core.kitty_ok stays false: no kitty graphics in SDL, petscii covers images.
+ // ponytail: Surface.images pixel attachments could blit as textures later.
+
+ // shells emit OSC 133 prompt marks via this rc (prompt hiding, click-move)
+ writeFile(pardes.bash_rc);
+
+ var ptys: [pardes.MAX_PANES]?Pty = @splat(null);
+ defer for (&ptys) |*slot| if (slot.*) |pt| {
+ _ = linux.close(pt.fd);
+ slot.* = null;
+ };
+ var queue: Queue = .{ .gpa = gpa, .sdl_wake = true };
+ defer queue.close();
+
+ // initial spawns BEFORE any worker thread exists: forkpty from a
+ // multithreaded process can wedge the child before exec (see tty.zig).
+ drainEffects(core, &ptys, gpa, &queue, &g, false);
+ for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), &queue);
+
+ _ = c.SDL_StartTextInput(window);
+
+ var feed: StdinFeed = .{};
+ if (test_mode) setStdinRaw() catch {};
+
+ var frame_arena: std.heap.ArenaAllocator = .init(gpa);
+ defer frame_arena.deinit();
+
+ while (!core.quit) {
+ // 1. SDL input: block briefly for the first event, then drain the rest
+ var sev = std.mem.zeroes(c.SDL_Event);
+ if (c.SDL_WaitEventTimeout(&sev, 16)) {
+ dispatch(&g, core, &sev);
+ while (c.SDL_PollEvent(&sev)) dispatch(&g, core, &sev);
+ }
+ // test mode: input comes from stdin escape sequences instead
+ if (test_mode) {
+ const r = feed.pump(gpa, core, &g) catch break;
+ if (r.eof) break;
+ }
+ // 2. pty output from the reader threads
+ var msgs = queue.take();
+ for (msgs.items) |m| switch (m) {
+ .output => |o| {
+ core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } });
+ gpa.free(o.bytes);
+ },
+ .eof => |id| {
+ if (ptys[id]) |pt| {
+ _ = linux.close(pt.fd);
+ ptys[id] = null;
+ }
+ core.update(.{ .eof = .{ .pane = id } });
+ },
+ };
+ msgs.deinit(gpa);
+ // 3. steamdeck: poll gamepad axes into virtual cursor / wheel events
+ pollGamepad(&g, core);
+ // 4. effects
+ drainEffects(core, &ptys, gpa, &queue, &g, true);
+ if (core.quit) break;
+ // 5. live cwd for tags/look: cheap /proc readlink per pane, per frame
+ pollCwds(core, &ptys);
+ // 6. the grid follows the window (covers WINDOW_RESIZED and test resizes)
+ _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph);
+ const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), cell_w)));
+ const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), cell_h)));
+ if (cols != core.screen_w or rows != core.screen_h)
+ core.update(.{ .resize = .{ .cols = cols, .rows = rows } });
+ // 7. render
+ _ = frame_arena.reset(.retain_capacity);
+ const surface = try core.render(frame_arena.allocator());
+ renderFrame(&g, gpa, surface) 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,
+};
+
+fn runWeb(opts_in: pardes.Options) !void {
+ web_fba = .init(&web_heap);
+ const gpa = web_fba.allocator();
+
+ 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,
+ .atlas_tex = undefined,
+ .atlas_sampler = undefined,
+ .atlas_xfer = undefined,
+ .overlay_vbuf = undefined,
+ .overlay_vxfer = undefined,
+ .overlay_vertices = overlay_vertices,
+ .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,
+ .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 = .init(gpa),
+ };
+ 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 ----
+ pardes.image.start(std.Io.failing, gpa); // decode is synchronous; no io on wasm
+ syncCanvasSize(g); // size the backing canvas before deriving the grid
+ var opts = opts_in;
+ 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(gpa, opts, embedded_dump);
+
+ _ = 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) return std.os.emscripten.emscripten_cancel_main_loop();
+ // 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 (cols != core.screen_w or rows != core.screen_h)
+ core.update(.{ .resize = .{ .cols = cols, .rows = rows } });
+ // 4. render
+ _ = st.frame_arena.reset(.retain_capacity);
+ const surface = core.render(st.frame_arena.allocator()) catch return;
+ renderFrameGl(g, st.gpa, surface) 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);
+ }
+}
+
+/// PARDES_TEST_GRID=1: headless. No SDL at all — stdin escape sequences in,
+/// the rendered Surface out as text frames (same framing as the prototype).
+fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
+ const io = init.io;
+ const gpa = init.gpa;
+ const env = init.environ_map;
+
+ pardes.image.start(io, gpa);
+ defer pardes.image.stop();
+
+ const grid_cols = envU16(env, "PARDES_TEST_COLS") orelse 80;
+ const grid_rows = envU16(env, "PARDES_TEST_ROWS") orelse 24;
+ var opts = opts_in;
+ if (opts.load_path != null) {
+ opts.cols = grid_cols;
+ opts.rows = grid_rows;
+ }
+ const core = if (opts.load_path) |lp| blk: {
+ const bytes = try @import("look.zig").readFile(gpa, lp);
+ defer gpa.free(bytes);
+ break :blk try pardes.Pardes.initFromDump(gpa, opts, bytes);
+ } else try pardes.Pardes.init(gpa, 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
+ if (opts.load_path == null)
+ core.update(.{ .resize = .{ .cols = grid_cols, .rows = grid_rows } });
+
+ writeFile(pardes.bash_rc);
+
+ var ptys: [pardes.MAX_PANES]?Pty = @splat(null);
+ defer for (&ptys) |*slot| if (slot.*) |pt| {
+ _ = linux.close(pt.fd);
+ slot.* = null;
+ };
+ var queue: Queue = .{ .gpa = gpa, .sdl_wake = false };
+ defer queue.close();
+ drainEffects(core, &ptys, gpa, &queue, null, false);
+ for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), &queue);
+
+ setStdinRaw() catch {}; // stdin may be a pipe, not a pty — best effort
+
+ var frame_arena: std.heap.ArenaAllocator = .init(gpa);
+ defer frame_arena.deinit();
+ var feed: StdinFeed = .{};
+
+ // first frame before touching stdin, so `printf '' | pardes` still shows one
+ {
+ const surface = try core.render(frame_arena.allocator());
+ try dumpGrid(gpa, surface);
+ }
+ while (!core.quit) {
+ const r = try feed.pump(gpa, core, null);
+ if (r.eof) break;
+ var n_events: usize = r.n_events;
+ var msgs = queue.take();
+ for (msgs.items) |m| switch (m) {
+ .output => |o| {
+ core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } });
+ gpa.free(o.bytes);
+ n_events += 1;
+ },
+ .eof => |id| {
+ if (ptys[id]) |pt| {
+ _ = linux.close(pt.fd);
+ ptys[id] = null;
+ }
+ core.update(.{ .eof = .{ .pane = id } });
+ n_events += 1;
+ },
+ };
+ msgs.deinit(gpa);
+ drainEffects(core, &ptys, gpa, &queue, null, true);
+ pollCwds(core, &ptys);
+ if (n_events == 0) continue; // idle tick: nothing changed, no frame
+ _ = frame_arena.reset(.retain_capacity);
+ const surface = try core.render(frame_arena.allocator());
+ try dumpGrid(gpa, surface);
+ }
+}
+
+// =====================================================================
+// test-mode stdin: terminal escape sequences (vaxis.Parser) → core events,
+// plus the private synthetic finger OSC:
+// ESC ] 777;finger;<type>;<id>;<x>;<y>;<pressure> BEL (normalized 0..1)
+// =====================================================================
+
+const StdinFeed = struct {
+ parser: vaxis.Parser = .{},
+ cache: vaxis.GraphemeCache = .{},
+ buf: [1024]u8 = undefined,
+ fill: usize = 0,
+ last_cols: u16 = 0,
+ last_rows: u16 = 0,
+
+ const Result = struct { eof: bool = false, n_events: usize = 0 };
+
+ /// Poll stdin briefly and translate what arrived. `g` is null in grid mode.
+ fn pump(f: *StdinFeed, gpa: std.mem.Allocator, core: *pardes.Pardes, g: ?*Gui) !Result {
+ var out: Result = .{};
+ // a pty stdin also carries the winsize; poll it in place of SIGWINCH
+ var ws: posix.winsize = std.mem.zeroes(posix.winsize);
+ if (posix.system.ioctl(0, posix.T.IOCGWINSZ, @intFromPtr(&ws)) == 0 and
+ ws.col > 0 and ws.row > 0 and ws.col <= 1000 and ws.row <= 1000 and
+ (ws.col != f.last_cols or ws.row != f.last_rows))
+ {
+ f.last_cols = ws.col;
+ f.last_rows = ws.row;
+ f.applyResize(core, g, ws.col, ws.row);
+ out.n_events += 1;
+ }
+
+ var fds = [_]posix.pollfd{.{ .fd = 0, .events = posix.POLL.IN, .revents = 0 }};
+ _ = posix.poll(&fds, 16) catch return out;
+ if ((fds[0].revents & posix.POLL.IN) == 0) {
+ if ((fds[0].revents & (posix.POLL.HUP | posix.POLL.ERR)) != 0) out.eof = true;
+ return out;
+ }
+ const rc = linux.read(0, f.buf[f.fill..].ptr, f.buf.len - f.fill);
+ const rn: isize = @bitCast(rc);
+ if (rn < 0) return out;
+ if (rn == 0) {
+ out.eof = true;
+ return out;
+ }
+ const len = f.fill + @as(usize, @intCast(rn));
+ f.fill = 0;
+
+ var seq_start: usize = 0;
+ while (seq_start < len) {
+ // private finger OSC first (vaxis would swallow it as unknown OSC)
+ const prefix = "\x1b]777;finger;";
+ if (std.mem.startsWith(u8, f.buf[seq_start..len], prefix)) {
+ const body = f.buf[seq_start + prefix.len .. len];
+ const end = std.mem.indexOfScalar(u8, body, 0x07) orelse {
+ std.mem.copyForwards(u8, f.buf[0 .. len - seq_start], f.buf[seq_start..len]);
+ f.fill = len - seq_start;
+ break;
+ };
+ if (parseFinger(body[0..end])) |finger| {
+ if (g) |gp| {
+ var wpw: c_int = 0;
+ var wph: c_int = 0;
+ _ = c.SDL_GetWindowSizeInPixels(gp.window, &wpw, &wph);
+ handleFinger(&gp.touch, core, finger, @floatFromInt(@max(wpw, 1)), @floatFromInt(@max(wph, 1)), @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h));
+ } else {
+ handleFinger(&grid_touch, core, finger, @floatFromInt(core.screen_w), @floatFromInt(core.screen_h), 1, 1);
+ }
+ out.n_events += 1;
+ }
+ seq_start += prefix.len + end + 1;
+ continue;
+ }
+ if (len - seq_start < prefix.len and std.mem.startsWith(u8, prefix, f.buf[seq_start..len])) {
+ std.mem.copyForwards(u8, f.buf[0 .. len - seq_start], f.buf[seq_start..len]);
+ f.fill = len - seq_start;
+ break;
+ }
+
+ const result = f.parser.parse(f.buf[seq_start..len], gpa) catch break;
+ if (result.n == 0) { // partial sequence: shift to front, read more
+ std.mem.copyForwards(u8, f.buf[0 .. len - seq_start], f.buf[seq_start..len]);
+ f.fill = len - seq_start;
+ break;
+ }
+ seq_start += result.n;
+ const event = result.event orelse continue;
+ switch (event) {
+ .key_press => |key| {
+ var text: []const u8 = "";
+ if (key.text) |t| text = f.cache.put(t);
+ core.update(.{ .key = .{
+ .cp = mapKey(effCp(key)),
+ .text = text,
+ .ctrl = key.mods.ctrl,
+ .alt = key.mods.alt,
+ } });
+ out.n_events += 1;
+ },
+ .mouse => |m| {
+ const button: ?pardes.Mouse.Button = switch (m.button) {
+ .left => .left,
+ .middle => .middle,
+ .right => .right,
+ .wheel_up => .wheel_up,
+ .wheel_down => .wheel_down,
+ .none => .none,
+ else => null,
+ };
+ if (button) |b| {
+ core.update(.{ .mouse = .{
+ .button = b,
+ .kind = switch (m.type) {
+ .press => .press,
+ .release => .release,
+ .motion => .motion,
+ .drag => .drag,
+ },
+ .col = @intCast(@max(m.col, 0)),
+ .row = @intCast(@max(m.row, 0)),
+ } });
+ out.n_events += 1;
+ }
+ },
+ .winsize => |vw| {
+ f.applyResize(core, g, @intCast(vw.cols), @intCast(vw.rows));
+ out.n_events += 1;
+ },
+ .paste => |text| {
+ core.update(.{ .paste = text });
+ gpa.free(@constCast(text));
+ out.n_events += 1;
+ },
+ else => {},
+ }
+ }
+ return out;
+ }
+
+ fn applyResize(f: *StdinFeed, core: *pardes.Pardes, g: ?*Gui, cols: u16, rows: u16) void {
+ _ = f;
+ if (g) |gp| {
+ // capture mode: resize the window; the frame loop resizes the core
+ _ = c.SDL_SetWindowSize(gp.window, @intCast(cols * gp.cell_w), @intCast(rows * gp.cell_h));
+ _ = c.SDL_SyncWindow(gp.window);
+ } else {
+ core.update(.{ .resize = .{ .cols = cols, .rows = rows } });
+ }
+ }
+};
+
+var grid_touch: Touch = .{}; // grid test mode has no Gui to hang it off
+
+fn parseFinger(payload: []const u8) ?Finger {
+ var parts = std.mem.splitScalar(u8, payload, ';');
+ const type_name = parts.next() orelse return null;
+ const id_s = parts.next() orelse return null;
+ const x_s = parts.next() orelse return null;
+ const y_s = parts.next() orelse return null;
+ const pressure_s = parts.next() orelse return null;
+ if (parts.next() != null) return null;
+ const kind: @FieldType(Finger, "kind") = if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_DOWN") or std.mem.eql(u8, type_name, "down"))
+ .down
+ else if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_MOTION") or std.mem.eql(u8, type_name, "motion"))
+ .motion
+ else if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_UP") or std.mem.eql(u8, type_name, "up"))
+ .up
+ else if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_CANCELED") or std.mem.eql(u8, type_name, "cancel"))
+ .cancel
+ else
+ return null;
+ return .{
+ .kind = kind,
+ .id = std.fmt.parseInt(u64, id_s, 10) catch return null,
+ .x = std.fmt.parseFloat(f32, x_s) catch return null,
+ .y = std.fmt.parseFloat(f32, y_s) catch return null,
+ .pressure = std.fmt.parseFloat(f32, pressure_s) catch return null,
+ };
+}
+
+fn setStdinRaw() !void {
+ var term = try posix.tcgetattr(0);
+ term.iflag.BRKINT = false;
+ term.iflag.ICRNL = false;
+ term.iflag.INPCK = false;
+ term.iflag.ISTRIP = false;
+ term.iflag.IXON = false;
+ term.oflag.OPOST = false;
+ term.lflag.ECHO = false;
+ term.lflag.ICANON = false;
+ term.lflag.IEXTEN = false;
+ term.lflag.ISIG = false;
+ term.cc[@intFromEnum(posix.V.MIN)] = 1;
+ term.cc[@intFromEnum(posix.V.TIME)] = 0;
+ try posix.tcsetattr(0, .NOW, term);
+}
+
+// PARDES_TEST_GRID: one text frame per render, prototype-compatible framing.
+fn dumpGrid(gpa: std.mem.Allocator, surface: *pardes.Surface) !void {
+ if (surface.cols == 0 or surface.rows == 0) return;
+ // build the whole frame in one buffer → one write → atomic snapshots
+ var buf: std.ArrayList(u8) = .empty;
+ defer buf.deinit(gpa);
+ const cur_x: u16 = if (surface.cursor) |cu| cu.x else 0;
+ const cur_y: u16 = if (surface.cursor) |cu| cu.y else 0;
+ var hdr: [128]u8 = undefined;
+ const hdr_str = std.fmt.bufPrint(&hdr, "\n---FRAME:{d}:{d}:{d}:{d}:{}---\n", .{
+ surface.rows, surface.cols, cur_x, cur_y, surface.cursor != null,
+ }) catch return;
+ try buf.appendSlice(gpa, hdr_str);
+ var y: u16 = 0;
+ while (y < surface.rows) : (y += 1) {
+ var x: u16 = 0;
+ while (x < surface.cols) : (x += 1) {
+ const g = surface.at(x, y).grapheme();
+ if (g.len > 0) try buf.appendSlice(gpa, g) else try buf.append(gpa, ' ');
+ }
+ try buf.append(gpa, '\n');
+ }
+ try buf.appendSlice(gpa, "---ENDFRAME---\n");
+ writeFd(1, buf.items);
+}
+
+// =====================================================================
+// SDL event dispatch
+// =====================================================================
+
+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,
+ // 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;
+ g.live_alt = (sev.key.mod & c.SDL_KMOD_ALT) != 0;
+ keyDown(core, sev.key.key, sev.key.mod);
+ },
+ c.SDL_EVENT_KEY_UP => {
+ g.live_ctrl = (sev.key.mod & c.SDL_KMOD_CTRL) != 0;
+ g.live_alt = (sev.key.mod & c.SDL_KMOD_ALT) != 0;
+ },
+ c.SDL_EVENT_TEXT_INPUT => {
+ const tptr = sev.text.text orelse return;
+ var tlen: usize = 0;
+ while (tptr[tlen] != 0) : (tlen += 1) {}
+ if (tlen == 0) return;
+ const text: []const u8 = tptr[0..tlen];
+ const cplen = std.unicode.utf8ByteSequenceLength(text[0]) catch return;
+ if (cplen > text.len) return;
+ const cp = std.unicode.utf8Decode(text[0..cplen]) catch return;
+ core.update(.{ .key = .{ .cp = cp, .text = text[0..cplen], .ctrl = g.live_ctrl, .alt = g.live_alt } });
+ },
+ 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);
+ core.update(.{ .mouse = .{
+ .button = button,
+ .kind = if (b.down) .press else .release,
+ .col = mc.col,
+ .row = mc.row,
+ } });
+ },
+ c.SDL_EVENT_MOUSE_MOTION => {
+ const m = sev.motion;
+ g.mouse_x = m.x;
+ g.mouse_y = m.y;
+ // 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
+ else if ((m.state & c.SDL_BUTTON_MMASK) != 0)
+ .middle
+ else if ((m.state & c.SDL_BUTTON_RMASK) != 0)
+ .right
+ else
+ null;
+ const mc = mouseCell(g, core, m.x, m.y);
+ core.update(.{ .mouse = .{
+ .button = held orelse .none,
+ .kind = if (held != null) .drag else .motion,
+ .col = mc.col,
+ .row = mc.row,
+ } });
+ },
+ c.SDL_EVENT_MOUSE_WHEEL => {
+ const w = sev.wheel;
+ if (w.y == 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);
+ core.update(.{ .mouse = .{
+ .button = if (w.y > 0) .wheel_up else .wheel_down,
+ .kind = .press,
+ .col = mc.col,
+ .row = mc.row,
+ } });
+ },
+ c.SDL_EVENT_FINGER_DOWN, c.SDL_EVENT_FINGER_MOTION, c.SDL_EVENT_FINGER_UP, c.SDL_EVENT_FINGER_CANCELED => {
+ const f = sev.tfinger;
+ var finger: Finger = .{
+ .kind = switch (sev.type) {
+ c.SDL_EVENT_FINGER_DOWN => .down,
+ c.SDL_EVENT_FINGER_MOTION => .motion,
+ c.SDL_EVENT_FINGER_UP => .up,
+ else => .cancel,
+ },
+ .id = @intCast(f.fingerID),
+ .x = f.x,
+ .y = f.y,
+ .pressure = f.pressure,
+ };
+ 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) {
+ // remap normalized coords into the letterboxed grid so the
+ // shared two-finger 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,
+ // axes polled per frame in pollGamepad) ----
+ c.SDL_EVENT_GAMEPAD_ADDED => {
+ if (g.gamepad == null) g.gamepad = c.SDL_OpenGamepad(sev.gdevice.which);
+ },
+ c.SDL_EVENT_GAMEPAD_REMOVED => {
+ if (g.gamepad) |pad| {
+ c.SDL_CloseGamepad(pad);
+ g.gamepad = null;
+ }
+ },
+ c.SDL_EVENT_GAMEPAD_BUTTON_DOWN, c.SDL_EVENT_GAMEPAD_BUTTON_UP => {
+ const down = sev.type == c.SDL_EVENT_GAMEPAD_BUTTON_DOWN;
+ const col = pixelCell(g.pad_x, g.cell_w);
+ const row = pixelCell(g.pad_y, g.cell_h);
+ const button: pardes.Mouse.Button = switch (sev.gbutton.button) {
+ c.SDL_GAMEPAD_BUTTON_SOUTH => .left, // A
+ c.SDL_GAMEPAD_BUTTON_EAST => .right, // B
+ c.SDL_GAMEPAD_BUTTON_NORTH => .middle, // Y
+ c.SDL_GAMEPAD_BUTTON_LEFT_SHOULDER => .wheel_up,
+ c.SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER => .wheel_down,
+ else => return,
+ };
+ switch (button) {
+ .wheel_up, .wheel_down => if (down) core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = col, .row = row } }),
+ else => core.update(.{ .mouse = .{ .button = button, .kind = if (down) .press else .release, .col = col, .row = row } }),
+ }
+ },
+ else => {},
+ }
+}
+
+/// Special keys + ctrl/alt shortcuts. Plain printable keys arrive as
+/// SDL_EVENT_TEXT_INPUT instead (so shift/layout map correctly).
+fn keyDown(core: *pardes.Pardes, sym: c.SDL_Keycode, mod: c.SDL_Keymod) void {
+ const ctrl = (mod & c.SDL_KMOD_CTRL) != 0;
+ const alt = (mod & c.SDL_KMOD_ALT) != 0;
+ const gui_mod = (mod & c.SDL_KMOD_GUI) != 0;
+ const cp: u21 = switch (sym) {
+ c.SDLK_RETURN, c.SDLK_KP_ENTER => pardes.Key.enter,
+ c.SDLK_BACKSPACE => pardes.Key.backspace,
+ c.SDLK_TAB => pardes.Key.tab,
+ c.SDLK_ESCAPE => pardes.Key.escape,
+ c.SDLK_LEFT => pardes.Key.left,
+ c.SDLK_RIGHT => pardes.Key.right,
+ c.SDLK_UP => pardes.Key.up,
+ c.SDLK_DOWN => pardes.Key.down,
+ c.SDLK_HOME => pardes.Key.home,
+ c.SDLK_END => pardes.Key.end,
+ c.SDLK_PAGEUP => pardes.Key.page_up,
+ c.SDLK_PAGEDOWN => pardes.Key.page_down,
+ else => blk: {
+ // letters / digits / punctuation only as a modifier shortcut;
+ // plain printable (incl. space) goes via TEXT_INPUT
+ if (!(ctrl or alt or gui_mod)) break :blk 0;
+ if (sym < 128 and sym >= ' ') break :blk @intCast(sym);
+ break :blk 0;
+ },
+ };
+ if (cp == 0) return;
+ core.update(.{ .key = .{ .cp = cp, .ctrl = ctrl, .alt = alt } });
+}
+
+fn pixelCell(px: f32, cell: u32) u16 {
+ return pixelCellF(px, @floatFromInt(cell));
+}
+
+fn pixelCellF(px: f32, cell: f32) u16 {
+ const idx = @floor(@max(px, 0) / @max(cell, 1));
+ return @intFromFloat(@min(idx, 10_000));
+}
+
+/// mouse coords → cell. Native windows are pixel-exact; web SDL reports CSS
+/// points into a letterboxed canvas backed at web_render_scale.
+fn mouseCell(g: *const Gui, core: *const pardes.Pardes, x: f32, y: f32) struct { col: u16, row: u16 } {
+ if (!is_emscripten) return .{ .col = pixelCell(x, g.cell_w), .row = pixelCell(y, 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),
+ };
+}
+
+/// steamdeck support: poll the sticks each frame. Left stick moves a virtual
+/// cursor at ~cell granularity (emits button-less motion so hover works),
+/// right stick Y accumulates into wheel ticks 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 deadzone: f32 = 8000;
+ const ax: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_LEFTX));
+ const ay: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_LEFTY));
+ const old_col = pixelCell(g.pad_x, g.cell_w);
+ const old_row = pixelCell(g.pad_y, g.cell_h);
+ // full tilt ≈ one cell-height per frame; ~60fps makes that a brisk glide
+ const speed: f32 = @floatFromInt(g.cell_h);
+ 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 col = pixelCell(g.pad_x, g.cell_w);
+ const row = pixelCell(g.pad_y, g.cell_h);
+ if (col != old_col or row != old_row)
+ core.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = col, .row = row } });
+ const ry: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_RIGHTY));
+ if (@abs(ry) > deadzone) g.pad_scroll += ry / 32767.0 * 0.15;
+ while (@abs(g.pad_scroll) >= 1.0) {
+ const button: pardes.Mouse.Button = if (g.pad_scroll < 0) .wheel_up else .wheel_down;
+ g.pad_scroll += if (g.pad_scroll < 0) 1.0 else -1.0;
+ core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = col, .row = row } });
+ }
+}
+
+// =====================================================================
+// effects — identical duties to tty.zig's drainEffects, plus SDL clipboard
+// =====================================================================
+
+fn drainEffects(
+ core: *pardes.Pardes,
+ ptys: *[pardes.MAX_PANES]?Pty,
+ gpa: std.mem.Allocator,
+ queue: *Queue,
+ g: ?*Gui, // null in grid test mode (no SDL: clipboard effects are no-ops)
+ threads_ok: bool,
+) void {
+ while (core.nextEffect()) |effect| switch (effect) {
+ .spawn => |sp| {
+ std.debug.assert(ptys[sp.pane] == null);
+ const cwd = sp.cwd.slice();
+ var cwd_buf: [256:0]u8 = undefined;
+ var cwd_z: ?[*:0]const u8 = null;
+ if (cwd.len > 0) {
+ @memcpy(cwd_buf[0..cwd.len], cwd);
+ cwd_buf[cwd.len] = 0;
+ cwd_z = @ptrCast(&cwd_buf);
+ }
+ const pt = forkShell(cwd_z, core.screen_h, core.screen_w);
+ ptys[sp.pane] = pt;
+ // report the pane's starting directory back to the core (tags)
+ var lbuf: [1024]u8 = undefined;
+ var pbuf: [64]u8 = undefined;
+ if (std.fmt.bufPrintSentinel(&pbuf, "/proc/{d}/cwd", .{pt.pid}, 0) catch null) |path| {
+ const rc = linux.readlinkat(linux.AT.FDCWD, path, &lbuf, lbuf.len);
+ const n: isize = @bitCast(rc);
+ if (n > 0) core.setCwd(sp.pane, lbuf[0..@intCast(n)]);
+ }
+ if (threads_ok) spawnReader(gpa, pt, sp.pane, queue);
+ },
+ .write => |w| {
+ if (ptys[w.pane]) |pt| writeFd(pt.fd, w.bytes.slice());
+ },
+ .resize_pty => |rs| {
+ if (ptys[rs.pane]) |pt| {
+ const ws: posix.winsize = .{ .row = rs.rows, .col = rs.cols, .xpixel = 0, .ypixel = 0 };
+ _ = posix.system.ioctl(pt.fd, posix.T.IOCSWINSZ, @intFromPtr(&ws));
+ }
+ },
+ .open_link => {}, // native: look opens panes instead; nothing to launch
+ .save_file => |sf| {
+ const pane = core.panes[sf.pane] orelse continue;
+ const f = pane.file orelse continue;
+ var pathbuf: [4096:0]u8 = undefined;
+ if (f.path.len >= pathbuf.len) continue;
+ @memcpy(pathbuf[0..f.path.len], f.path);
+ pathbuf[f.path.len] = 0;
+ const rc = linux.open(pathbuf[0..f.path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) continue;
+ const fd: c_int = @intCast(sfd);
+ writeFd(fd, f.content);
+ _ = linux.close(fd);
+ },
+ .write_dump => {
+ const out = core.dump_out orelse continue;
+ const rc = linux.open(pardes.dump.default_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) continue;
+ const fd: c_int = @intCast(sfd);
+ writeFd(fd, out);
+ _ = linux.close(fd);
+ },
+ .set_clipboard => {
+ if (g == null) continue;
+ const y = core.yank orelse continue;
+ const z = gpa.dupeZ(u8, y) catch continue;
+ defer gpa.free(z);
+ _ = c.SDL_SetClipboardText(z.ptr);
+ },
+ .get_clipboard => {
+ if (g == null) continue;
+ // synchronous, unlike the tty's OSC 52 round-trip: reply is a paste
+ const t = c.SDL_GetClipboardText();
+ defer c.SDL_free(t);
+ var tlen: usize = 0;
+ while (t[tlen] != 0) : (tlen += 1) {}
+ if (tlen > 0) core.update(.{ .paste = t[0..tlen] });
+ },
+ .quit => {},
+ };
+}
+
+fn forkShell(cwd: ?[*:0]const u8, rows: u16, cols: u16) Pty {
+ var master: c_int = undefined;
+ const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
+ const pid = forkpty(&master, null, null, &ws);
+ if (pid == 0) {
+ if (cwd) |cd| _ = chdir(cd);
+ const argv: [4:null]?[*:0]const u8 = .{ "/usr/bin/bash", "--rcfile", "/tmp/pardes-osc133.bash", null };
+ _ = execv("/usr/bin/bash", &argv);
+ _exit(127);
+ }
+ return .{ .fd = master, .pid = pid };
+}
+
+fn pollCwds(core: *pardes.Pardes, ptys: *[pardes.MAX_PANES]?Pty) void {
+ for (ptys, 0..) |slot, id| if (slot) |pt| {
+ var lbuf: [1024]u8 = undefined;
+ var pbuf: [64]u8 = undefined;
+ const path = std.fmt.bufPrintSentinel(&pbuf, "/proc/{d}/cwd", .{pt.pid}, 0) catch continue;
+ const rc = linux.readlinkat(linux.AT.FDCWD, path, &lbuf, lbuf.len);
+ const n: isize = @bitCast(rc);
+ if (n > 0) core.setCwd(id, lbuf[0..@intCast(n)]);
+ };
+}
+
+/// 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);
+ },
+ // look on a URL → a new tab
+ .open_link => |url| openLinkWeb(gpa, url.slice()),
+ // nothing to spawn/write/resize/save/dump into; get_clipboard would
+ // need an async permission round-trip — all no-ops
+ .spawn, .write, .resize_pty, .save_file, .write_dump, .get_clipboard, .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 {
+ var safe: std.ArrayList(u8) = .empty;
+ defer safe.deinit(gpa);
+ for (url) |ch| switch (ch) {
+ '\'' => safe.appendSlice(gpa, "%27") catch return,
+ '\\' => safe.appendSlice(gpa, "%5C") catch return,
+ else => safe.append(gpa, ch) catch return,
+ };
+ const script = std.fmt.allocPrintSentinel(gpa, "window.open('{s}','_blank')", .{safe.items}, 0) catch return;
+ defer gpa.free(script);
+ std.os.emscripten.emscripten_run_script(script.ptr);
+}
+
+// =====================================================================
+// render: Surface → instanced quads → SDL GPU
+// =====================================================================
+
+fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface) !void {
+ const cmd = c.SDL_AcquireGPUCommandBuffer(g.device) orelse return;
+ var sw: u32 = 0;
+ var sh: u32 = 0;
+ var target: *c.SDL_GPUTexture = undefined;
+ if (g.capture) {
+ // capture: render offscreen (the window may never present), dump PPM
+ sw = @as(u32, surface.cols) * g.cell_w;
+ sh = @as(u32, surface.rows) * g.cell_h;
+ if (sw == 0 or sh == 0) {
+ _ = c.SDL_SubmitGPUCommandBuffer(cmd);
+ return;
+ }
+ 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)) {
+ _ = c.SDL_SubmitGPUCommandBuffer(cmd);
+ return;
+ }
+ target = swap_tex orelse { // swapchain busy: skip the frame
+ _ = c.SDL_SubmitGPUCommandBuffer(cmd);
+ return;
+ };
+ }
+ const win_w: f32 = @floatFromInt(sw);
+ const win_h: f32 = @floatFromInt(sh);
+
+ const overlay_count = buildTouchOverlay(g, sw, sh);
+ if (overlay_count != 0) uploadOverlayGpu(g, cmd, overlay_count);
+
+ const cells: u32 = @as(u32, surface.cols) * surface.rows;
+ if (cells != 0) try ensureVbuf(g, cells);
+
+ var color_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo);
+ color_target.texture = target;
+ color_target.clear_color = .{
+ .r = @as(f32, @floatFromInt(bg_default[0])) / 255.0,
+ .g = @as(f32, @floatFromInt(bg_default[1])) / 255.0,
+ .b = @as(f32, @floatFromInt(bg_default[2])) / 255.0,
+ .a = 1.0,
+ };
+ 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) {
+ // 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 {
+ _ = c.SDL_SubmitGPUCommandBuffer(cmd);
+ return;
+ });
+ const instances: [*]CellInstance = @ptrCast(@alignCast(vptr));
+ const layout = fixedCellLayout(g);
+ const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else cells;
+ 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, instances, idx, col, row, layout, win_w, win_h, surface.at(col, row), idx == cursor_idx);
+ }
+ }
+ c.SDL_UnmapGPUTransferBuffer(g.device, g.vxfer.?);
+
+ // emitInstance may have rasterized new glyphs into the staging atlas;
+ // upload after vertex generation so this frame has what it references
+ if (g.atlas_dirty) uploadAtlas(g, cmd);
+
+ 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 * @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);
+ drawOverlayGpu(g, rp, overlay_count);
+ c.SDL_EndGPURenderPass(rp);
+ } else {
+ // no grid yet: just clear to the page bg
+ const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null);
+ drawOverlayGpu(g, rp, overlay_count);
+ c.SDL_EndGPURenderPass(rp);
+ }
+
+ if (g.capture) return captureFrame(g, gpa, cmd, target, sw, sh);
+ _ = c.SDL_SubmitGPUCommandBuffer(cmd);
+}
+
+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,
+) void {
+ // resolve the cell style to concrete fg/bg, matching the prototype's
+ // cellKey: reverse swaps, invisible hides, dim darkens. The cursor cell
+ // renders as reverse (cancelling an already-reversed cell).
+ var fg = fg_default;
+ var bg = bg_default;
+ var reverse = is_cursor;
+ if (!cell.default) {
+ const st = cell.style;
+ fg = switch (st.fg) {
+ .default => fg_default,
+ .index => |i| palColor(i),
+ .rgb => |rgb| rgb,
+ };
+ bg = switch (st.bg) {
+ .default => bg_default,
+ .index => |i| palColor(i),
+ .rgb => |rgb| rgb,
+ };
+ if (st.reverse) reverse = !reverse;
+ if (st.invisible) fg = bg;
+ if (st.dim) for (&fg) |*ch| {
+ ch.* = @intCast(@as(u16, ch.*) * 6 / 10);
+ };
+ }
+ if (reverse) std.mem.swap([3]u8, &fg, &bg);
+
+ // cell pixel rect (top-left origin) → NDC (y up); the layout letterboxes on web
+ const px0 = layout.x_off + @as(f32, @floatFromInt(col)) * layout.w;
+ const py0 = layout.y_off + @as(f32, @floatFromInt(row)) * layout.h;
+ 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 grapheme = cell.grapheme();
+ const cp: u32 = if (cell.default or grapheme.len == 0) ' ' else firstCp(grapheme);
+ const slot = if (cp == ' ') g.space_slot else ensureGlyph(g, cp);
+ const aw_f: f32 = @floatFromInt(atlas_w);
+ const ah_f: f32 = @floatFromInt(atlas_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,
+ };
+}
+
+fn palColor(idx: u8) [3]u8 {
+ const p = ghostty_vt.color.default[idx];
+ return .{ p.r, p.g, p.b };
+}
+
+// first codepoint of a UTF-8 grapheme; space on failure/empty
+fn firstCp(s: []const u8) u32 {
+ if (s.len == 0) return ' ';
+ const n = std.unicode.utf8ByteSequenceLength(s[0]) catch return ' ';
+ if (n > s.len) return ' ';
+ return std.unicode.utf8Decode(s[0..n]) catch ' ';
+}
+
+fn ensureGlyph(g: *Gui, cp: u32) Slot {
+ if (g.glyphs.get(cp)) |s| return s;
+ if (g.pen_x + g.cell_w > atlas_w) {
+ g.pen_x = 0;
+ g.pen_y += g.cell_h;
+ }
+ if (g.pen_y + g.cell_h > atlas_h) return g.space_slot; // atlas full: fall back
+ 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;
+ _ = c.ui_font_raster(g.font, 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;
+ g.pen_x += g.cell_w;
+ g.atlas_dirty = true;
+ return s;
+}
+
+fn uploadAtlas(g: *Gui, cmd: *c.SDL_GPUCommandBuffer) void {
+ const ptr = c.SDL_MapGPUTransferBuffer(g.device, g.atlas_xfer, false) orelse return;
+ const dst: [*]u8 = @ptrCast(ptr);
+ @memcpy(dst[0 .. atlas_w * atlas_h], g.atlas_stage);
+ c.SDL_UnmapGPUTransferBuffer(g.device, g.atlas_xfer);
+
+ const copy = c.SDL_BeginGPUCopyPass(cmd);
+ const src = c.SDL_GPUTextureTransferInfo{ .transfer_buffer = g.atlas_xfer, .offset = 0, .pixels_per_row = atlas_w, .rows_per_layer = atlas_h };
+ const dstregion = c.SDL_GPUTextureRegion{ .texture = g.atlas_tex, .mip_level = 0, .layer = 0, .x = 0, .y = 0, .z = 0, .w = atlas_w, .h = atlas_h, .d = 1 };
+ c.SDL_UploadToGPUTexture(copy, &src, &dstregion, false);
+ c.SDL_EndGPUCopyPass(copy);
+ g.atlas_dirty = false;
+}
+
+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);
+ 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;
+ 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;
+}
+
+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);
+ defer c.SDL_ReleaseGPUShader(device, vs);
+ const fs = try makeShader(device, frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1);
+ defer c.SDL_ReleaseGPUShader(device, fs);
+
+ var vbuf_desc = c.SDL_GPUVertexBufferDescription{
+ .slot = 0,
+ .pitch = @sizeOf(CellInstance),
+ .input_rate = c.SDL_GPU_VERTEXINPUTRATE_INSTANCE,
+ .instance_step_rate = 0,
+ };
+ var attrs = [_]c.SDL_GPUVertexAttribute{
+ .{ .location = 0, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "x0") },
+ .{ .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") },
+ };
+ var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = std.mem.zeroes(c.SDL_GPUColorTargetBlendState) };
+
+ var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo);
+ info.vertex_shader = vs;
+ info.fragment_shader = fs;
+ info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST;
+ info.vertex_input_state = .{
+ .vertex_buffer_descriptions = &vbuf_desc,
+ .num_vertex_buffers = 1,
+ .vertex_attributes = &attrs,
+ .num_vertex_attributes = attrs.len,
+ };
+ info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL;
+ info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE;
+ info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1;
+ info.target_info = .{ .color_target_descriptions = &col_desc, .num_color_targets = 1 };
+ return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate;
+}
+
+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);
+ defer c.SDL_ReleaseGPUShader(device, vs);
+ const fs = try makeShader(device, overlay_frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 0);
+ defer c.SDL_ReleaseGPUShader(device, fs);
+
+ var vbuf_desc = c.SDL_GPUVertexBufferDescription{
+ .slot = 0,
+ .pitch = @sizeOf(OverlayVertex),
+ .input_rate = c.SDL_GPU_VERTEXINPUTRATE_VERTEX,
+ .instance_step_rate = 0,
+ };
+ var attrs = [_]c.SDL_GPUVertexAttribute{
+ .{ .location = 0, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT2, .offset = @offsetOf(OverlayVertex, "x") },
+ .{ .location = 1, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(OverlayVertex, "r") },
+ };
+ var blend = std.mem.zeroes(c.SDL_GPUColorTargetBlendState);
+ blend.src_color_blendfactor = c.SDL_GPU_BLENDFACTOR_SRC_ALPHA;
+ blend.dst_color_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA;
+ blend.color_blend_op = c.SDL_GPU_BLENDOP_ADD;
+ blend.src_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE;
+ blend.dst_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA;
+ blend.alpha_blend_op = c.SDL_GPU_BLENDOP_ADD;
+ blend.enable_blend = true;
+ var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = blend };
+
+ var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo);
+ info.vertex_shader = vs;
+ info.fragment_shader = fs;
+ info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST;
+ info.vertex_input_state = .{
+ .vertex_buffer_descriptions = &vbuf_desc,
+ .num_vertex_buffers = 1,
+ .vertex_attributes = &attrs,
+ .num_vertex_attributes = attrs.len,
+ };
+ info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL;
+ info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE;
+ info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1;
+ info.target_info = .{ .color_target_descriptions = &col_desc, .num_color_targets = 1 };
+ 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 {
+ var info = std.mem.zeroes(c.SDL_GPUShaderCreateInfo);
+ info.code = code.ptr;
+ info.code_size = code.len;
+ info.entrypoint = "main";
+ info.format = c.SDL_GPU_SHADERFORMAT_SPIRV;
+ info.stage = stage;
+ info.num_samplers = n_samplers;
+ 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) !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));
+ c.glClearColor(
+ @as(f32, @floatFromInt(bg_default[0])) / 255.0,
+ @as(f32, @floatFromInt(bg_default[1])) / 255.0,
+ @as(f32, @floatFromInt(bg_default[2])) / 255.0,
+ 1.0,
+ );
+ c.glClear(c.GL_COLOR_BUFFER_BIT);
+
+ const overlay_count = buildTouchOverlay(g, sw, sh);
+ const cells: u32 = @as(u32, surface.cols) * surface.rows;
+ if (cells == 0) {
+ drawOverlayGl(g, overlay_count);
+ _ = c.SDL_GL_SwapWindow(g.window);
+ return;
+ }
+ if (g.gl_instances.len < cells) {
+ gpa.free(g.gl_instances);
+ g.gl_instances = try gpa.alloc(CellInstance, cells);
+ }
+
+ const win_w: f32 = @floatFromInt(sw);
+ const win_h: f32 = @floatFromInt(sh);
+ const layout = cellLayout(g, surface.cols, surface.rows, sw, sh);
+ const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else cells;
+ 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);
+ }
+ }
+ 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 * @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));
+ 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 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;
+ info.usage = c.SDL_GPU_TEXTUREUSAGE_COLOR_TARGET;
+ info.width = width;
+ info.height = height;
+ 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;
+ g.capture_tex_w = width;
+ g.capture_tex_h = height;
+}
+
+fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTexture, sw: u32, sh: u32) !void {
+ const size = c.SDL_CalculateGPUTextureFormatSize(g.swapchain_format, sw, sh, 1);
+ if (size == 0) {
+ _ = c.SDL_SubmitGPUCommandBuffer(cmd);
+ 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 {
+ _ = c.SDL_SubmitGPUCommandBuffer(cmd);
+ return error.GpuCreate;
+ };
+ g.capture_xfer_size = size;
+ }
+ const xfer = g.capture_xfer.?;
+ const copy = c.SDL_BeginGPUCopyPass(cmd);
+ const src = c.SDL_GPUTextureRegion{ .texture = target, .mip_level = 0, .layer = 0, .x = 0, .y = 0, .z = 0, .w = sw, .h = sh, .d = 1 };
+ const dst = c.SDL_GPUTextureTransferInfo{ .transfer_buffer = xfer, .offset = 0, .pixels_per_row = sw, .rows_per_layer = sh };
+ c.SDL_DownloadFromGPUTexture(copy, &src, &dst);
+ c.SDL_EndGPUCopyPass(copy);
+
+ const fence = c.SDL_SubmitGPUCommandBufferAndAcquireFence(cmd) orelse return error.GpuSubmit;
+ defer c.SDL_ReleaseGPUFence(g.device, fence);
+ var fences = [_]*c.SDL_GPUFence{fence};
+ if (!c.SDL_WaitForGPUFences(g.device, true, &fences, 1)) return error.GpuSubmit;
+
+ const mapped = c.SDL_MapGPUTransferBuffer(g.device, xfer, false) orelse return error.GpuMap;
+ defer c.SDL_UnmapGPUTransferBuffer(g.device, xfer);
+ const pixels: [*]const u8 = @ptrCast(mapped);
+ try writeCapturePpm(g, gpa, pixels[0..size], sw, sh);
+}
+
+fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u32, height: u32) !void {
+ if (g.capture_dir.len == 0) return;
+ const bpp = c.SDL_GPUTextureFormatTexelBlockSize(g.swapchain_format);
+ if (bpp != 4) return error.UnsupportedCaptureFormat;
+ const bgr = switch (g.swapchain_format) {
+ c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM, c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM_SRGB => false,
+ c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM, c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM_SRGB => true,
+ else => return error.UnsupportedCaptureFormat,
+ };
+
+ var tmp_buf: [4096]u8 = undefined;
+ var final_buf: [4096]u8 = undefined;
+ const tmp_path = std.fmt.bufPrintSentinel(&tmp_buf, "{s}/latest.ppm.tmp", .{g.capture_dir}, 0) catch return error.CapturePathTooLong;
+ const final_path = std.fmt.bufPrintSentinel(&final_buf, "{s}/latest.ppm", .{g.capture_dir}, 0) catch return error.CapturePathTooLong;
+
+ const orc = linux.open(tmp_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644);
+ const sfd: isize = @bitCast(orc);
+ if (sfd < 0) return error.CaptureWriteFailed;
+ const fd: c_int = @intCast(sfd);
+ defer _ = linux.close(fd);
+
+ var header: [64]u8 = undefined;
+ const hdr = std.fmt.bufPrint(&header, "P6\n{d} {d}\n255\n", .{ width, height }) catch return error.CaptureWriteFailed;
+ writeFd(fd, hdr);
+
+ const row_rgb = try gpa.alloc(u8, @as(usize, width) * 3);
+ defer gpa.free(row_rgb);
+ for (0..height) |row| {
+ const src = pixels[row * width * 4 ..][0 .. @as(usize, width) * 4];
+ for (0..width) |x| {
+ const si = x * 4;
+ const di = x * 3;
+ row_rgb[di + 0] = src[si + if (bgr) @as(usize, 2) else 0];
+ row_rgb[di + 1] = src[si + 1];
+ row_rgb[di + 2] = src[si + if (bgr) @as(usize, 0) else 2];
+ }
+ writeFd(fd, row_rgb);
+ }
+ const rrc = linux.rename(tmp_path, final_path);
+ const rs: isize = @bitCast(rrc);
+ if (rs < 0) return error.CaptureWriteFailed;
+}
+
+// =====================================================================
+// touch debug overlay: per-finger colored circles + trails + a middle-click
+// flash HUD, alpha-blended over the grid. Always active on touch input.
+// =====================================================================
+
+fn buildTouchOverlay(g: *Gui, sw: u32, sh: u32) 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 };
+ const t = &g.touch;
+
+ if (t.middle_flash_frames != 0) {
+ const flash = @as(f32, @floatFromInt(t.middle_flash_frames)) / @as(f32, @floatFromInt(touch_middle_flash_max_frames));
+ const label = touchMiddleClickLabel(t.middle_clicks);
+ const glyph_scale = 3.0;
+ const char_step = 4.0 * glyph_scale;
+ const pad = 5.0;
+ const stripe_w = 4.0;
+ const label_w = pad * 2.0 + stripe_w + 3.0 + @as(f32, @floatFromInt(label.len)) * char_step - glyph_scale;
+ const label_h = pad * 2.0 + 5.0 * glyph_scale;
+ builder.addRect(0.0, 0.0, win_w, win_h, .{ .r = 1.0, .g = 0.78, .b = 0.18, .a = 0.055 * flash });
+ builder.addRect(8.0, 8.0, 8.0 + label_w, 8.0 + label_h, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.54 + 0.22 * flash });
+ builder.addRect(8.0 + pad, 8.0 + pad, 8.0 + pad + stripe_w, 8.0 + label_h - pad, .{ .r = 1.0, .g = 0.78, .b = 0.18, .a = 0.96 });
+ builder.addMiniText(label[0..], 8.0 + pad + stripe_w + 3.0, 8.0 + pad, glyph_scale, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.96 });
+ t.middle_flash_frames -= 1;
+ }
+
+ for (&t.points) |*tp| {
+ if (!tp.active) continue;
+ const cx = std.math.clamp(tp.x, 0.0, 1.0) * win_w;
+ const cy = std.math.clamp(tp.y, 0.0, 1.0) * win_h;
+ const radius = (12.0 + std.math.clamp(tp.pressure, 0.0, 1.0) * 4.0) * 4.0;
+ const color = touchColor(tp.id);
+
+ if (tp.trail_len > 1) {
+ const first = if (tp.trail_len == max_touch_trail_points) tp.trail_next else 0;
+ for (0..tp.trail_len - 1) |trail_i| {
+ const sample = tp.trail[(first + trail_i) % max_touch_trail_points];
+ const fade = @as(f32, @floatFromInt(trail_i + 1)) / @as(f32, @floatFromInt(tp.trail_len));
+ var trail_color = color;
+ trail_color.a *= 0.42 * fade;
+ const tx = std.math.clamp(sample.x, 0.0, 1.0) * win_w;
+ const ty = std.math.clamp(sample.y, 0.0, 1.0) * win_h;
+ const trail_radius = (12.0 + std.math.clamp(sample.pressure, 0.0, 1.0) * 4.0) * 2.8;
+ builder.addCircle(tx, ty, trail_radius, trail_color);
+ }
+ }
+
+ builder.addCircle(cx, cy, radius + 4.0, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.62 });
+ builder.addCircle(cx, cy, radius, color);
+ builder.addCircle(cx, cy, 2.4, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.92 });
+ builder.addIdTag(tp.id, cx, cy, color);
+ }
+ return @intCast(builder.len);
+}
+
+fn uploadOverlayGpu(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, vertex_count: u32) void {
+ const byte_len: u32 = vertex_count * @sizeOf(OverlayVertex);
+ const ptr = c.SDL_MapGPUTransferBuffer(g.device, g.overlay_vxfer, false) orelse return;
+ const dst: [*]u8 = @ptrCast(ptr);
+ const src: [*]const u8 = @ptrCast(g.overlay_vertices.ptr);
+ @memcpy(dst[0..byte_len], src[0..byte_len]);
+ c.SDL_UnmapGPUTransferBuffer(g.device, g.overlay_vxfer);
+ const copy = c.SDL_BeginGPUCopyPass(cmd);
+ const src_loc = c.SDL_GPUTransferBufferLocation{ .transfer_buffer = g.overlay_vxfer, .offset = 0 };
+ 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);
+}
+
+fn drawOverlayGpu(g: *Gui, rp: ?*c.SDL_GPURenderPass, vertex_count: u32) void {
+ if (vertex_count == 0) return;
+ const pass = rp orelse return;
+ c.SDL_BindGPUGraphicsPipeline(pass, g.overlay_pipeline);
+ const binding = c.SDL_GPUBufferBinding{ .buffer = g.overlay_vbuf, .offset = 0 };
+ c.SDL_BindGPUVertexBuffers(pass, 0, &binding, 1);
+ c.SDL_DrawGPUPrimitives(pass, vertex_count, 1, 0, 0);
+}
+
+fn touchColor(id: u64) OverlayColor {
+ const palette = [_]OverlayColor{
+ .{ .r = 0.10, .g = 0.72, .b = 1.00, .a = 0.88 },
+ .{ .r = 1.00, .g = 0.32, .b = 0.50, .a = 0.88 },
+ .{ .r = 0.35, .g = 0.90, .b = 0.38, .a = 0.88 },
+ .{ .r = 1.00, .g = 0.78, .b = 0.18, .a = 0.88 },
+ .{ .r = 0.78, .g = 0.52, .b = 1.00, .a = 0.88 },
+ .{ .r = 0.10, .g = 0.88, .b = 0.75, .a = 0.88 },
+ .{ .r = 1.00, .g = 0.48, .b = 0.18, .a = 0.88 },
+ .{ .r = 0.78, .g = 0.90, .b = 1.00, .a = 0.88 },
+ };
+ return palette[@intCast(id % palette.len)];
+}
+
+const OverlayBuilder = struct {
+ vertices: []OverlayVertex,
+ len: usize = 0,
+ win_w: f32,
+ win_h: f32,
+
+ fn addVertex(b: *OverlayBuilder, px: f32, py: f32, color: OverlayColor) void {
+ if (b.len >= b.vertices.len) return;
+ b.vertices[b.len] = .{
+ .x = (px / b.win_w) * 2.0 - 1.0,
+ .y = 1.0 - (py / b.win_h) * 2.0,
+ .r = color.r,
+ .g = color.g,
+ .b = color.b,
+ .a = color.a,
+ };
+ b.len += 1;
+ }
+
+ fn addTri(b: *OverlayBuilder, x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32, color: OverlayColor) void {
+ if (b.len + 3 > b.vertices.len) return;
+ b.addVertex(x0, y0, color);
+ b.addVertex(x1, y1, color);
+ b.addVertex(x2, y2, color);
+ }
+
+ fn addRect(b: *OverlayBuilder, x0_in: f32, y0_in: f32, x1_in: f32, y1_in: f32, color: OverlayColor) void {
+ if (b.len + 6 > b.vertices.len) return;
+ const x0 = std.math.clamp(@min(x0_in, x1_in), 0.0, b.win_w);
+ const x1 = std.math.clamp(@max(x0_in, x1_in), 0.0, b.win_w);
+ const y0 = std.math.clamp(@min(y0_in, y1_in), 0.0, b.win_h);
+ const y1 = std.math.clamp(@max(y0_in, y1_in), 0.0, b.win_h);
+ if (x1 <= x0 or y1 <= y0) return;
+ b.addTri(x0, y0, x1, y0, x1, y1, color);
+ b.addTri(x0, y0, x1, y1, x0, y1, color);
+ }
+
+ fn addCircle(b: *OverlayBuilder, cx: f32, cy: f32, radius: f32, color: OverlayColor) void {
+ var i: usize = 0;
+ while (i + 1 < circle_points.len) : (i += 1) {
+ const p0 = circle_points[i];
+ const p1 = circle_points[i + 1];
+ b.addTri(cx, cy, cx + p0[0] * radius, cy + p0[1] * radius, cx + p1[0] * radius, cy + p1[1] * radius, color);
+ }
+ }
+
+ fn addIdTag(b: *OverlayBuilder, id: u64, cx: f32, cy: f32, color: OverlayColor) void {
+ const label = touchIdLabel(id);
+ const glyph_scale = 2.0;
+ const char_step = 4.0 * glyph_scale;
+ const pad = 4.0;
+ const stripe_w = 3.0;
+ const label_w = pad * 2.0 + stripe_w + 3.0 + @as(f32, @floatFromInt(label.len)) * char_step - glyph_scale;
+ const label_h = pad * 2.0 + 5.0 * glyph_scale;
+
+ var x = cx + 20.0;
+ if (x + label_w > b.win_w - 2.0) x = cx - label_w - 20.0;
+ x = std.math.clamp(x, 2.0, @max(2.0, b.win_w - label_w - 2.0));
+ var y = cy - label_h - 10.0;
+ if (y < 2.0) y = cy + 20.0;
+ y = std.math.clamp(y, 2.0, @max(2.0, b.win_h - label_h - 2.0));
+
+ b.addRect(x, y, x + label_w, y + label_h, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.72 });
+ b.addRect(x + pad, y + pad, x + pad + stripe_w, y + label_h - pad, color);
+ b.addMiniText(label[0..], x + pad + stripe_w + 3.0, y + pad, glyph_scale, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.95 });
+ }
+
+ fn addMiniText(b: *OverlayBuilder, text: []const u8, x: f32, y: f32, scale: f32, color: OverlayColor) void {
+ var cx = x;
+ for (text) |ch| {
+ const rows = miniGlyph(ch);
+ const masks = [_]u8{ 0b100, 0b010, 0b001 };
+ for (rows, 0..) |bits, row| {
+ for (masks, 0..) |mask, colidx| {
+ if ((bits & mask) == 0) continue;
+ const x0 = cx + @as(f32, @floatFromInt(colidx)) * scale;
+ const y0 = y + @as(f32, @floatFromInt(row)) * scale;
+ b.addRect(x0, y0, x0 + scale, y0 + scale, color);
+ }
+ }
+ cx += 4.0 * scale;
+ }
+ }
+};
+
+const circle_points = [_][2]f32{
+ .{ 1.0000000, 0.0000000 }, .{ 0.9238795, 0.3826834 }, .{ 0.7071068, 0.7071068 }, .{ 0.3826834, 0.9238795 },
+ .{ 0.0000000, 1.0000000 }, .{ -0.3826834, 0.9238795 }, .{ -0.7071068, 0.7071068 }, .{ -0.9238795, 0.3826834 },
+ .{ -1.0000000, 0.0000000 }, .{ -0.9238795, -0.3826834 }, .{ -0.7071068, -0.7071068 }, .{ -0.3826834, -0.9238795 },
+ .{ 0.0000000, -1.0000000 }, .{ 0.3826834, -0.9238795 }, .{ 0.7071068, -0.7071068 }, .{ 0.9238795, -0.3826834 },
+ .{ 1.0000000, 0.0000000 },
+};
+
+fn touchIdLabel(id: u64) [9]u8 {
+ var out: [9]u8 = undefined;
+ out[0] = '#';
+ const low: u32 = @truncate(id);
+ for (0..8) |i| {
+ const shift: u5 = @intCast((7 - i) * 4);
+ out[i + 1] = hexDigit((low >> shift) & 0xf);
+ }
+ return out;
+}
+
+fn touchMiddleClickLabel(count: u32) [13]u8 {
+ var out: [13]u8 = undefined;
+ @memcpy(out[0..5], "MID #");
+ for (0..8) |i| {
+ const shift: u5 = @intCast((7 - i) * 4);
+ out[i + 5] = hexDigit((count >> shift) & 0xf);
+ }
+ return out;
+}
+
+fn hexDigit(n: u32) u8 {
+ return if (n < 10) @intCast('0' + n) else @intCast('A' + (n - 10));
+}
+
+fn miniGlyph(ch: u8) [5]u8 {
+ return switch (ch) {
+ '#' => .{ 0b101, 0b111, 0b101, 0b111, 0b101 },
+ '0' => .{ 0b111, 0b101, 0b101, 0b101, 0b111 },
+ '1' => .{ 0b010, 0b110, 0b010, 0b010, 0b111 },
+ '2' => .{ 0b111, 0b001, 0b111, 0b100, 0b111 },
+ '3' => .{ 0b111, 0b001, 0b111, 0b001, 0b111 },
+ '4' => .{ 0b101, 0b101, 0b111, 0b001, 0b001 },
+ '5' => .{ 0b111, 0b100, 0b111, 0b001, 0b111 },
+ '6' => .{ 0b111, 0b100, 0b111, 0b101, 0b111 },
+ '7' => .{ 0b111, 0b001, 0b010, 0b010, 0b010 },
+ '8' => .{ 0b111, 0b101, 0b111, 0b101, 0b111 },
+ '9' => .{ 0b111, 0b101, 0b111, 0b001, 0b111 },
+ 'A' => .{ 0b111, 0b101, 0b111, 0b101, 0b101 },
+ 'B' => .{ 0b110, 0b101, 0b110, 0b101, 0b110 },
+ 'C' => .{ 0b111, 0b100, 0b100, 0b100, 0b111 },
+ 'D' => .{ 0b110, 0b101, 0b101, 0b101, 0b110 },
+ 'E' => .{ 0b111, 0b100, 0b111, 0b100, 0b111 },
+ 'F' => .{ 0b111, 0b100, 0b111, 0b100, 0b100 },
+ 'I' => .{ 0b111, 0b010, 0b010, 0b010, 0b111 },
+ 'M' => .{ 0b101, 0b111, 0b111, 0b101, 0b101 },
+ else => .{ 0, 0, 0, 0, 0 },
+ };
+}
+
+// =====================================================================
+// shared plumbing (same shapes as tty.zig)
+// =====================================================================
+
+/// The effective codepoint the way vaxis Key.matches sees it: a single-char
+/// text wins (shift resolved by the terminal), else the shifted codepoint.
+fn effCp(key: vaxis.Key) u21 {
+ if (key.text) |t| {
+ const view = std.unicode.Utf8View.init(t) catch return key.codepoint;
+ var it = view.iterator();
+ if (it.nextCodepoint()) |cp| {
+ if (it.nextCodepoint() == null) return cp;
+ }
+ }
+ return key.shifted_codepoint orelse key.codepoint;
+}
+
+/// vaxis functional-key codepoints -> core Key constants (ASCII ones already
+/// coincide: enter/tab/escape/backspace pass through).
+fn mapKey(cp: u21) u21 {
+ return switch (cp) {
+ vaxis.Key.up => pardes.Key.up,
+ vaxis.Key.down => pardes.Key.down,
+ vaxis.Key.left => pardes.Key.left,
+ vaxis.Key.right => pardes.Key.right,
+ vaxis.Key.home => pardes.Key.home,
+ vaxis.Key.end => pardes.Key.end,
+ vaxis.Key.page_up => pardes.Key.page_up,
+ vaxis.Key.page_down => pardes.Key.page_down,
+ else => cp,
+ };
+}
+
+fn envU16(env: *std.process.Environ.Map, name: []const u8) ?u16 {
+ const raw = env.get(name) orelse return null;
+ return std.fmt.parseInt(u16, raw, 10) catch null;
+}
+
+fn writeFile(data: []const u8) void {
+ const rc = linux.open("/tmp/pardes-osc133.bash", .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) return;
+ const fd: c_int = @intCast(sfd);
+ defer _ = linux.close(fd);
+ writeFd(fd, data);
+}
+
+fn writeFd(fd: c_int, data: []const u8) void {
+ var off: usize = 0;
+ while (off < data.len) {
+ const rc = linux.write(fd, data[off..].ptr, data.len - off);
+ const n: isize = @bitCast(rc);
+ if (n < 0) {
+ if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue;
+ return;
+ }
+ off += @intCast(n);
+ }
+}
diff --git a/src/image.zig b/src/image.zig
new file mode 100644
index 00000000..a3415019
--- /dev/null
+++ b/src/image.zig
@@ -0,0 +1,53 @@
+//! Image panes. Decoding is zstbi (C stb_image) to RGBA, downscaled; the kept
+//! pixels render as PETSCII glyph art directly into the Surface — or, when the
+//! host has kitty graphics (and Petscii isn't toggled), ride the Surface as a
+//! pixel attachment the shell transmits/places (tty: vaxis kitty; SDL: blit).
+const std = @import("std");
+const zstbi = @import("zstbi");
+const ghostty_vt = @import("ghostty-vt");
+pub const petscii = @import("petscii.zig");
+
+/// the terminal's own 16 ANSI colors — what the `terminal` palette mode
+/// scores against; cells then paint as indexed colors so the real terminal
+/// resolves the RGB
+pub fn ansiPalette() [16][3]u8 {
+ var p: [16][3]u8 = undefined;
+ for (0..16) |i| {
+ const c = ghostty_vt.color.default[i];
+ p[i] = .{ c.r, c.g, c.b };
+ }
+ return p;
+}
+
+/// cap the longest side before keeping/transmitting: a pane is at most a
+/// screenful of cells, multi-thousand-pixel photos waste decode/transmit time
+const MAX_DIM: u32 = 1280;
+
+pub const PaletteMode = enum { commodore, terminal };
+
+/// call once at startup / exit (stb_image's allocator shim)
+pub fn start(io: std.Io, gpa: std.mem.Allocator) void {
+ zstbi.init(io, gpa);
+}
+pub fn stop() void {
+ zstbi.deinit();
+}
+
+/// decode + downscale to RGBA, gpa-owned. Returns null on any failure — the
+/// pane then simply shows a blank body.
+pub fn decode(gpa: std.mem.Allocator, bytes: []const u8) ?struct { rgba: []u8, w: usize, h: usize } {
+ if (bytes.len == 0) return null;
+ var img = zstbi.Image.loadFromMemory(bytes, 4) catch return null; // 4 = RGBA
+ defer img.deinit();
+ var scaled: ?zstbi.Image = null;
+ defer if (scaled) |*s| s.deinit();
+ const longest = @max(img.width, img.height);
+ const src: *const zstbi.Image = if (longest > MAX_DIM) blk: {
+ const nw = @max(1, img.width * MAX_DIM / longest);
+ const nh = @max(1, img.height * MAX_DIM / longest);
+ scaled = img.resize(nw, nh);
+ break :blk &scaled.?;
+ } else &img;
+ const rgba = gpa.dupe(u8, src.data) catch return null;
+ return .{ .rgba = rgba, .w = src.width, .h = src.height };
+}
diff --git a/src/look.zig b/src/look.zig
new file mode 100644
index 00000000..67fbf8c5
--- /dev/null
+++ b/src/look.zig
@@ -0,0 +1,123 @@
+//! What a click on text MEANS. The acme "look" (right click / Enter): expand
+//! the click to a file-ish word, then resolve it against the pane's directory.
+//! This is the one deliberately platform-divergent file — the divergence is a
+//! comptime switch on pardes.platform, used the way the stdlib switches on
+//! os.tag, so every platform's behavior sits in the same screenful:
+//! tty/gui — the word resolves through the real filesystem (realpath,
+//! open(O_DIRECTORY)); dirs open shells, files open file panes.
+//! web — there is no filesystem; only URLs mean anything, and a URL
+//! opens in a new tab (the open_link effect).
+const std = @import("std");
+const linux = std.os.linux;
+const pardes = @import("pardes.zig");
+
+extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8;
+
+/// file-ish word chars (acme isfilec): alnum + . - + / : @ _ ~
+pub fn isFileChar(c: u8) bool {
+ return std.ascii.isAlphanumeric(c) or switch (c) {
+ '.', '-', '+', '/', ':', '@', '_', '~' => true,
+ else => false,
+ };
+}
+
+/// peel a trailing :NNN (or :NNN:) line suffix: main.zig:100 -> {main.zig, 100}
+pub fn parsePathLine(tok: []const u8) struct { path: []const u8, line: usize } {
+ var sep: usize = 0;
+ while (sep < tok.len) : (sep += 1) {
+ if (tok[sep] != ':') continue;
+ var j = sep + 1;
+ var line: usize = 0;
+ while (j < tok.len and std.ascii.isDigit(tok[j])) : (j += 1) line = line * 10 + (tok[j] - '0');
+ if (j == sep + 1) continue; // no digits after ':'
+ if (j < tok.len and tok[j] != ':') continue; // junk after the number
+ return .{ .path = tok[0..sep], .line = line };
+ }
+ return .{ .path = tok, .line = 0 };
+}
+
+pub const Target = union(enum) {
+ none,
+ dir: []const u8, // resolved absolute path, in caller's buf
+ file: struct { path: []const u8, line: usize },
+ image: struct { path: []const u8 },
+ url: []const u8,
+};
+
+const image_exts = [_][]const u8{ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ppm", ".pgm", ".tga" };
+
+pub fn isImagePath(path: []const u8) bool {
+ for (image_exts) |ext| {
+ if (std.ascii.endsWithIgnoreCase(path, ext)) return true;
+ }
+ return false;
+}
+
+/// Resolve a looked-at word against the pane's directory. `realbuf` must
+/// outlive the returned Target (dir/file/image paths point into it).
+pub fn resolve(word_raw: []const u8, cwd: []const u8, realbuf: *[4096]u8) Target {
+ const trimmed = std.mem.trim(u8, word_raw, " \t\r\n");
+ const pl = parsePathLine(trimmed);
+ const word = pl.path;
+ if (word.len == 0) return .none;
+
+ if (platform_has_fs) {
+ var joinbuf: [2048]u8 = undefined;
+ const joined: ?[:0]u8 = if (word[0] == '/')
+ (std.fmt.bufPrintSentinel(&joinbuf, "{s}", .{word}, 0) catch null)
+ else
+ (std.fmt.bufPrintSentinel(&joinbuf, "{s}/{s}", .{ cwd, word }, 0) catch null);
+ const jz = joined orelse return .none;
+ const rp = realpath(jz.ptr, realbuf) orelse return .none;
+ const resolved = std.mem.span(rp);
+ if (isDir(rp)) return .{ .dir = resolved };
+ if (isImagePath(resolved)) return .{ .image = .{ .path = resolved } };
+ return .{ .file = .{ .path = resolved, .line = pl.line } };
+ } else {
+ // web: no filesystem. A URL opens in a new tab; everything else is inert.
+ if (std.mem.startsWith(u8, trimmed, "http://") or std.mem.startsWith(u8, trimmed, "https://"))
+ return .{ .url = trimmed };
+ return .none;
+ }
+}
+
+const platform_has_fs = switch (pardes.platform) {
+ .tty, .gui => true,
+ .web => false,
+};
+
+/// true if `path` exists and is a directory (open(O_DIRECTORY), no stat needed)
+fn isDir(path: [*:0]const u8) bool {
+ const rc = linux.open(path, .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true }, 0);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) return false;
+ _ = linux.close(@intCast(sfd));
+ return true;
+}
+
+/// read a whole file (gpa-owned) — the look side of opening a file pane.
+/// Compiled out on web (no fs); dumps provide file content there instead.
+pub fn readFile(gpa: std.mem.Allocator, path: []const u8) ![]u8 {
+ if (!platform_has_fs) return error.NoFilesystem;
+ var pathbuf: [4096]u8 = undefined;
+ const path_z = std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0) catch return error.PathTooLong;
+ const rc = linux.open(path_z, .{ .ACCMODE = .RDONLY }, 0);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) return error.OpenFailed;
+ const fd: i32 = @intCast(sfd);
+ defer _ = linux.close(fd);
+ var buf: std.ArrayList(u8) = .empty;
+ errdefer buf.deinit(gpa);
+ var chunk: [16384]u8 = undefined;
+ while (true) {
+ const r = linux.read(fd, &chunk, chunk.len);
+ const n: isize = @bitCast(r);
+ if (n < 0) {
+ if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue;
+ return error.ReadFailed;
+ }
+ if (n == 0) break;
+ try buf.appendSlice(gpa, chunk[0..@intCast(n)]);
+ }
+ return buf.toOwnedSlice(gpa);
+}
diff --git a/src/main.zig b/src/main.zig
new file mode 100644
index 00000000..ff151323
--- /dev/null
+++ b/src/main.zig
@@ -0,0 +1,102 @@
+const std = @import("std");
+const builtin = @import("builtin");
+const pardes = @import("pardes.zig");
+
+const is_emscripten = builtin.os.tag == .emscripten;
+
+extern "c" fn emscripten_console_error(utf8: [*:0]const u8) void;
+
+// emscripten: std.debug's default threaded-io singleton doesn't run on wasm;
+// fail it (init errors surface via emscripten_console_error below). Native
+// values match the std defaults. Same shim as the prototype's replay.zig.
+pub const std_options_debug_threaded_io: ?*std.Io.Threaded = if (is_emscripten)
+ null
+else
+ std.Io.Threaded.global_single_threaded;
+pub const std_options_debug_io: std.Io = if (is_emscripten)
+ std.Io.failing
+else
+ std_options_debug_threaded_io.?.io();
+
+const help_text =
+ \\Usage: pardes [options]
+ \\
+ \\Options:
+ \\ --tty start as one shell pane already in tty mode
+ \\ --tty-toggle <key> use Ctrl-<key> to enter/leave tty mode
+ \\ -l <dump.zon> load a dump of another instance (see Dump)
+ \\ -h, --help show this help and exit
+ \\
+;
+
+// The browser runtime calls a C main (exported below); everything else keeps
+// the std.process.Init entry.
+pub const main = if (is_emscripten) webMain else nativeMain;
+
+comptime {
+ if (is_emscripten) @export(&emscriptenMain, .{ .name = "main" });
+}
+
+fn emscriptenMain(argc: c_int, argv: [*]?[*:0]u8) callconv(.c) c_int {
+ _ = argc;
+ _ = argv;
+ webMain() catch |err| {
+ var buf: [128]u8 = undefined;
+ const msg = std.fmt.bufPrintSentinel(&buf, "pardes web init failed: {t}", .{err}, 0) catch "pardes web init failed";
+ emscripten_console_error(msg.ptr);
+ return 1;
+ };
+ return 0;
+}
+
+// no argv in the browser: options stay default, state comes from the dump
+fn webMain() !void {
+ try @import("gui.zig").run(.{});
+}
+
+fn nativeMain(init: std.process.Init) !void {
+ var opts: pardes.Options = .{};
+ const args = try init.minimal.args.toSlice(init.arena.allocator());
+ var i: usize = 1;
+ while (i < args.len) : (i += 1) {
+ const a = args[i];
+ if (std.mem.eql(u8, a, "--tty")) {
+ opts.tty_only = true;
+ } else if (std.mem.startsWith(u8, a, "--tty-toggle=")) {
+ opts.tty_toggle = parseCtrlKey(a["--tty-toggle=".len..]) orelse return error.BadArgs;
+ } else if (std.mem.eql(u8, a, "--tty-toggle")) {
+ i += 1;
+ if (i >= args.len) return error.BadArgs;
+ opts.tty_toggle = parseCtrlKey(args[i]) orelse return error.BadArgs;
+ } else if (std.mem.eql(u8, a, "-l")) {
+ i += 1;
+ if (i >= args.len) return error.BadArgs;
+ opts.load_path = args[i];
+ } else if (std.mem.eql(u8, a, "-h") or std.mem.eql(u8, a, "--help")) {
+ try std.Io.File.stdout().writeStreamingAll(init.io, help_text);
+ return;
+ } else {
+ return error.BadArgs;
+ }
+ }
+ switch (pardes.platform) {
+ .tty => try @import("tty.zig").run(init, opts),
+ .gui => try @import("gui.zig").run(init, opts),
+ .web => unreachable, // web enters through webMain, never here
+ }
+}
+
+fn parseCtrlKey(raw: []const u8) ?u21 {
+ var value = std.mem.trim(u8, raw, " \t\r\n");
+ if (value.len >= 5 and std.ascii.eqlIgnoreCase(value[0..5], "ctrl-")) {
+ value = value[5..];
+ } else if (value.len >= 5 and std.ascii.eqlIgnoreCase(value[0..5], "ctrl+")) {
+ value = value[5..];
+ } else if (value.len == 2 and value[0] == '^') {
+ value = value[1..];
+ }
+ if (value.len != 1) return null;
+ const c = std.ascii.toLower(value[0]);
+ if (c < 'a' or c > 'z' or c == 'c') return null;
+ return c;
+}
diff --git a/modal.zig b/src/modal.zig
index 2a195432..2a195432 100644
--- a/modal.zig
+++ b/src/modal.zig
diff --git a/src/pardes.zig b/src/pardes.zig
new file mode 100644
index 00000000..c4893b25
--- /dev/null
+++ b/src/pardes.zig
@@ -0,0 +1,3728 @@
+//! 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:
+//! 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.
+//!
+//! 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.
+const std = @import("std");
+const ghostty_vt = @import("ghostty-vt");
+const uucode = @import("uucode");
+const modal = @import("modal.zig");
+const look = @import("look.zig");
+const syntax = @import("syntax.zig");
+pub const image = @import("image.zig");
+pub const dump = @import("dump.zig");
+
+const SYNTAX_CONTEXT_BEFORE_ROWS: usize = 0;
+const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2;
+
+pub const Platform = enum { tty, gui, web };
+pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config").platform));
+
+pub const MAX_PANES = 16;
+pub const MAX_COLS = 6;
+pub const TOPBAR_H: u16 = 1;
+pub const GUTTER: u16 = 2;
+pub const BOX_H: u16 = 1;
+pub const MINW: u16 = 10;
+pub const MINH: u16 = 3;
+pub const PREFIX_W: u16 = 5;
+
+pub const topbar_str = "Kill Newcol Tutor Debug Colors NextColor Dump";
+const pane_builtins_str = "Del";
+const file_pane_builtins_str = "Save Del";
+const pane_tail = " " ++ pane_builtins_str;
+const file_pane_tail = " " ++ file_pane_builtins_str;
+
+// Builtins: executing the name (middle-click / Tab) runs the action through the
+// ONE dispatcher (onSelect + actOnSelection), no matter where the name appears.
+const SelectAction = enum { none, send, newcol, delcol, del, save, tutor, kill, debug, colors, nextcolor, dump };
+const Builtin = struct { name: []const u8, action: SelectAction };
+const builtins = [_]Builtin{
+ .{ .name = "Kill", .action = .kill },
+ .{ .name = "Newcol", .action = .newcol },
+ .{ .name = "Tutor", .action = .tutor },
+ .{ .name = "Debug", .action = .debug },
+ .{ .name = "Colors", .action = .colors },
+ .{ .name = "NextColor", .action = .nextcolor },
+ .{ .name = "Dump", .action = .dump },
+ .{ .name = "Save", .action = .save },
+ .{ .name = "Del", .action = .del },
+ .{ .name = "Delcol", .action = .delcol },
+};
+
+fn onSelect(button: Mouse.Button, text: []const u8) SelectAction {
+ if (button != .middle) return .none;
+ const cmd = std.mem.trim(u8, text, " \t\r\n");
+ if (cmd.len == 0) return .none;
+ inline for (builtins) |b| {
+ if (std.mem.eql(u8, cmd, b.name)) return b.action;
+ }
+ return .send;
+}
+
+/// the whitespace-delimited word covering `col` in `str` (topbar dispatch)
+fn wordAtCol(str: []const u8, col: u16) []const u8 {
+ if (col >= str.len or str[col] == ' ') return "";
+ var lo: usize = col;
+ while (lo > 0 and str[lo - 1] != ' ') lo -= 1;
+ var hi: usize = col;
+ while (hi < str.len and str[hi] != ' ') hi += 1;
+ return str[lo..hi];
+}
+
+fn fileLineCount(content: []const u8) usize {
+ return std.mem.count(u8, content, "\n") + 1;
+}
+
+const tutor_text = @embedFile("tutor.txt");
+const rc_path = "/tmp/pardes-osc133.bash";
+pub 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}"
+ \\trap 'printf "\e]133;C\a"' DEBUG
+ \\
+;
+
+// ---- theme ----
+
+pub const accent = [3]u8{ 0x6e, 0x4f, 0xd0 };
+pub const accent_dim = [3]u8{ 0x38, 0x3c, 0x54 };
+// per-button selection highlight: distinct muted colors per mouse button
+const SEL_BG = [3][3]u8{ .{ 0x2c, 0x46, 0x74 }, .{ 0x2c, 0x60, 0x38 }, .{ 0x64, 0x2c, 0x58 } };
+const sel_fg = [3]u8{ 0xd2, 0xd2, 0xd2 };
+// modal line/char selection: warm tan, reads as keyboard-driven
+const msel_bg = [3]u8{ 0x4a, 0x3a, 0x1e };
+const msel_fg = [3]u8{ 0xd8, 0xc8, 0xa8 };
+const UNDO_MAX = 256;
+const EDIT_UNDO_MAX = 64; // terminal snapshots are ~2KB each; cap tighter
+const gray = struct {
+ const bar = [3]u8{ 0x2a, 0x2a, 0x2a }; // dim bars (overlay bg)
+ const mid = [3]u8{ 0x52, 0x52, 0x52 }; // move preview
+ const txt = [3]u8{ 0x96, 0x96, 0x96 }; // muted text
+};
+
+pub const Theme = struct {
+ name: []const u8,
+ bg: ?[3]u8,
+ fg: ?[3]u8,
+ tag_bg: [3]u8,
+ tag_fg: [3]u8,
+ kw: [3]u8,
+ str: [3]u8,
+ num: [3]u8,
+ comment: [3]u8,
+ lineno: [3]u8,
+ scroll_track: [3]u8,
+ scroll_thumb: [3]u8,
+ palette: ?[16][3]u8,
+};
+
+pub const themes = [_]Theme{
+ .{
+ .name = "dark",
+ .bg = null,
+ .fg = null,
+ .tag_bg = .{ 0x2c, 0x2a, 0x3e },
+ .tag_fg = .{ 0x96, 0x96, 0x96 },
+ .kw = .{ 0xcc, 0x70, 0xd8 },
+ .str = .{ 0x8a, 0xb8, 0x7a },
+ .num = .{ 0xd0, 0xc0, 0x70 },
+ .comment = .{ 0xf0, 0xf0, 0xf0 },
+ .lineno = .{ 0x5a, 0x5a, 0x5a },
+ .scroll_track = .{ 0x2a, 0x2a, 0x2a },
+ .scroll_thumb = .{ 0x52, 0x52, 0x52 },
+ .palette = null,
+ },
+ .{
+ .name = "acme",
+ .bg = .{ 0xff, 0xff, 0xea },
+ .fg = .{ 0x00, 0x00, 0x00 },
+ .tag_bg = .{ 0xea, 0xff, 0xff },
+ .tag_fg = .{ 0x00, 0x00, 0x00 },
+ .kw = .{ 0x88, 0x22, 0x99 },
+ .str = .{ 0x00, 0x77, 0x33 },
+ .num = .{ 0xa0, 0x52, 0x00 },
+ .comment = .{ 0x55, 0x55, 0x55 },
+ .lineno = .{ 0x99, 0x99, 0x80 },
+ .scroll_track = .{ 0x99, 0x99, 0x4c },
+ .scroll_thumb = .{ 0xff, 0xff, 0xea },
+ .palette = .{
+ .{ 0x00, 0x00, 0x00 }, .{ 0xaa, 0x22, 0x22 }, .{ 0x22, 0x80, 0x22 }, .{ 0x88, 0x66, 0x00 },
+ .{ 0x22, 0x44, 0xbb }, .{ 0x99, 0x33, 0x99 }, .{ 0x11, 0x88, 0x88 }, .{ 0x55, 0x55, 0x55 },
+ .{ 0x88, 0x88, 0x88 }, .{ 0xcc, 0x33, 0x33 }, .{ 0x33, 0xaa, 0x33 }, .{ 0xaa, 0x77, 0x00 },
+ .{ 0x33, 0x55, 0xdd }, .{ 0xbb, 0x44, 0xbb }, .{ 0x22, 0xaa, 0xaa }, .{ 0x00, 0x00, 0x00 },
+ },
+ },
+};
+
+// ---- the boundary types ----
+
+pub const Color = union(enum) { default, index: u8, rgb: [3]u8 };
+
+pub const CellStyle = struct {
+ fg: Color = .default,
+ bg: Color = .default,
+ bold: bool = false,
+ dim: bool = false,
+ italic: bool = false,
+ blink: bool = false,
+ reverse: bool = false,
+ invisible: bool = false,
+ strikethrough: bool = false,
+ ul: enum { off, single, double, curly, dotted, dashed } = .off,
+};
+
+/// One surface cell. `default = true` means "never painted this frame": the
+/// shell renders it as the terminal's default cell (vaxis clear semantics).
+pub const Cell = struct {
+ text: [7]u8 = @splat(' '),
+ len: u8 = 1,
+ style: CellStyle = .{},
+ default: bool = true,
+
+ pub fn grapheme(c: *const Cell) []const u8 {
+ return c.text[0..c.len];
+ }
+};
+
+/// A pixel image riding the surface: the shell transmits/places it over the
+/// given cell rect (tty: kitty graphics; SDL: texture blit).
+pub const ImagePlace = struct {
+ pane: u8, // stable key for the shell's transmit-once cache
+ x: u16,
+ y: u16,
+ w: u16,
+ h: u16,
+ rgba: []const u8,
+ iw: usize,
+ ih: usize,
+};
+
+/// The canonical interface: what a frame of pardes IS. The tty shell writes
+/// these cells to vaxis one-to-one; the SDL shells rasterize them.
+pub const Surface = struct {
+ cols: u16 = 0,
+ rows: u16 = 0,
+ cells: []Cell = &.{},
+ cursor: ?struct { x: u16, y: u16 } = null,
+ images: [MAX_PANES]?ImagePlace = @splat(null),
+ nimages: usize = 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 {
+ const c = s.at(x, y);
+ std.debug.assert(text.len <= c.text.len);
+ @memcpy(c.text[0..text.len], text);
+ c.len = @intCast(text.len);
+ c.style = style;
+ c.default = false;
+ }
+
+ /// Print UTF-8 text into a row, no wrap, clipped to [x, x+w). Returns the
+ /// column after the last written cell. Wide glyphs take two cells.
+ fn print(s: *Surface, x: u16, y: u16, w: u16, text: []const u8, style: CellStyle) u16 {
+ var col = x;
+ const end = x + w;
+ var it = std.unicode.Utf8View.initUnchecked(text).iterator();
+ while (it.nextCodepointSlice()) |cp_slice| {
+ if (col >= end) break;
+ const cp = std.unicode.utf8Decode(cp_slice) catch continue;
+ if (cp == '\r') continue;
+ const width: u16 = if (cp < 0x80) 1 else uucode.get(.width, cp);
+ if (width == 0) continue;
+ s.set(col, y, cp_slice, style);
+ if (width == 2 and col + 1 < end) {
+ // spacer: empty cell under the wide glyph's tail
+ s.set(col + 1, y, "", style);
+ }
+ col += width;
+ }
+ return col;
+ }
+
+ 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;
+ while (xx < x + w) : (xx += 1) s.set(xx, yy, " ", style);
+ }
+ }
+
+ fn clearRect(s: *Surface, x: u16, y: u16, w: u16, h: u16) void {
+ var yy = y;
+ while (yy < y + h) : (yy += 1) {
+ var xx = x;
+ while (xx < x + w) : (xx += 1) s.at(xx, yy).* = .{};
+ }
+ }
+
+ /// swap ONLY the glyph, keeping the cell's colors — the resize-handle hint
+ fn overlayDash(s: *Surface, x: u16, y: u16, glyph: []const u8) void {
+ const c = s.at(x, y);
+ @memcpy(c.text[0..glyph.len], glyph);
+ c.len = @intCast(glyph.len);
+ c.default = false;
+ }
+};
+
+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, none };
+ pub const Kind = enum { press, release, motion, drag };
+ button: Button,
+ kind: Kind,
+ col: u16,
+ row: u16,
+};
+
+pub const Key = struct {
+ cp: u21, // codepoint or one of the specials below
+ text: []const u8 = "",
+ ctrl: bool = false,
+ alt: bool = false,
+
+ pub const enter: u21 = 0x0D;
+ pub const escape: u21 = 0x1B;
+ pub const tab: u21 = 0x09;
+ pub const backspace: u21 = 0x7F;
+ // private-use plane for non-text keys, matching no real codepoint
+ pub const up: u21 = 0xF0001;
+ pub const down: u21 = 0xF0002;
+ pub const left: u21 = 0xF0003;
+ pub const right: u21 = 0xF0004;
+ pub const home: u21 = 0xF0005;
+ pub const end: u21 = 0xF0006;
+ pub const page_up: u21 = 0xF0007;
+ pub const page_down: u21 = 0xF0008;
+};
+
+fn is(key: Key, cp: u21) bool {
+ return key.cp == cp and !key.ctrl and !key.alt;
+}
+
+fn isC(key: Key, cp: u21) bool {
+ return key.cp == cp and key.ctrl and !key.alt;
+}
+
+pub const Event = union(enum) {
+ key: Key,
+ mouse: Mouse,
+ resize: struct { cols: u16, rows: u16 },
+ output: struct { pane: u8, bytes: []const u8 },
+ eof: struct { pane: u8 },
+ paste: []const u8,
+ pinch: f32,
+ touch_scroll: f32,
+ tick,
+};
+
+/// IO the core wants done. Payloads are inline (fixed buffers): effects are
+/// queued values with no lifetime ties back into the core.
+pub const Effect = union(enum) {
+ spawn: struct { pane: u8, cwd: Buf(256) },
+ write: struct { pane: u8, bytes: Buf(64) },
+ resize_pty: struct { pane: u8, cols: u16, rows: u16 },
+ open_link: Buf(256),
+ /// write this pane's file content to its path; the shell reads both off
+ /// the core (content is unbounded, effects are fixed-size values)
+ save_file: struct { pane: u8 },
+ /// a serialized state dump is ready in core.dump_out; write it to
+ /// dump.default_path (acme-style: another instance loads it with -l)
+ write_dump,
+ /// the yank register changed; the shell reads it off the core (OSC 52 out)
+ set_clipboard,
+ /// a normal-mode `p` wants the system clipboard; the shell requests it and
+ /// the reply comes back as Event.paste
+ get_clipboard,
+ quit,
+
+ fn Buf(comptime n: usize) type {
+ return struct {
+ data: [n]u8 = undefined,
+ len: u16 = 0,
+ pub fn slice(b: *const @This()) []const u8 {
+ return b.data[0..b.len];
+ }
+ fn from(text: []const u8) @This() {
+ var b: @This() = .{};
+ std.debug.assert(text.len <= n);
+ @memcpy(b.data[0..text.len], text);
+ b.len = @intCast(text.len);
+ return b;
+ }
+ };
+ }
+};
+
+pub const Mode = enum { normal, insert, tty };
+
+pub const EDIT_CAP = 128; // max chars in one insertion run
+pub const MAX_EDITS = 16; // max concurrent insertion runs per pane
+
+/// A char-mode insertion run, anchored at an ABSOLUTE body row (rides the
+/// scrollback, not the screen). Rendering splices it into the row, shifting
+/// the shell's text right — typed text never overwrites output.
+pub const Edit = struct {
+ row: i32 = 0,
+ col: i32 = 0,
+ len: u16 = 0,
+ text: [EDIT_CAP]u8 = undefined,
+};
+
+/// One mouse selection (block-shaped), per button. c/r are text-area relative;
+/// r counts from the tag row (body starts at BOX_H).
+pub const Sel = struct {
+ state: enum { none, dragging, done } = .none,
+ c0: i32 = 0,
+ c1: i32 = 0,
+ r0: i32 = 0,
+ r1: i32 = 0,
+};
+
+/// A modal line selection (helix `x`): whole rows [r0, r1], absolute.
+pub const LineSel = struct {
+ active: bool = false,
+ r0: i32 = 0,
+ r1: i32 = 0,
+};
+
+/// A modal char-range selection (helix `v`): the anchor lives here, the head is
+/// the pane cursor. Mutually exclusive with LineSel.
+pub const CharSel = struct {
+ active: bool = false,
+ row: i32 = 0,
+ col: i32 = 0,
+};
+
+/// Undo snapshot of a terminal pane's edit state (whole-state, dumb on purpose).
+pub const EditSnap = struct {
+ edits: [MAX_EDITS]Edit,
+ nedits: usize,
+ cur_row: i32,
+ cur_col: i32,
+ cur_off: u16,
+};
+
+/// A file pane's backing: content is gpa-owned; undo/redo are content snapshots.
+pub const File = struct {
+ path: []u8,
+ content: []u8,
+ scroll: usize = 0,
+ /// one syntax.Syn byte per content byte in the highlighted window
+ highlights: []u8 = &.{},
+ highlight_start: usize = 0,
+ syntax_dirty: bool = true,
+ undo: std.ArrayList([]u8) = .empty,
+ redo: std.ArrayList([]u8) = .empty,
+};
+
+/// 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 (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,
+};
+
+pub const Pane = struct {
+ vt: ghostty_vt.Terminal,
+ stream: ghostty_vt.TerminalStream,
+ mode: Mode = .normal,
+ vweight: f32 = 1,
+ cols: u16,
+ rows: u16,
+ greet: bool = false,
+ file: ?File = null,
+ image: ?Image = null,
+ msel: LineSel = .{},
+ vsel: CharSel = .{},
+ /// one pending prefix codepoint (0 = none): `g` and `z`
+ pending: u21 = 0,
+ /// the editable tag tail: seeded with the default builtins on first touch;
+ /// edited in place by a modal one-line editor sharing the pane's mode
+ tag_tail: std.ArrayList(u8) = .empty,
+ tag_init: bool = false,
+ tag_edit: bool = false,
+ tag_sel: bool = false,
+ tag_col: u16 = 0,
+ tag_anchor: u16 = 0,
+ ed_undo: std.ArrayList(EditSnap) = .empty,
+ ed_redo: std.ArrayList(EditSnap) = .empty,
+ /// live working directory, shell-reported (setCwd); shown in the tag and
+ /// the base for look-resolution of relative paths.
+ cwd: [1024]u8 = undefined,
+ cwd_len: u16 = 0,
+ /// modal cursor, anchored at ABSOLUTE body rows. Tracks the shell cursor
+ /// until pinned by a click or a key. In normal mode cur_col is a visual
+ /// column; in insert mode it is the run anchor and cur_off the offset in it.
+ cur_pinned: bool = false,
+ cur_row: i32 = 0,
+ cur_col: i32 = 0,
+ cur_off: u16 = 0,
+ sel: [3]Sel = @splat(.{}),
+ edits: [MAX_EDITS]Edit = @splat(.{}),
+ nedits: usize = 0,
+ /// every raw pty byte, in order — the dump/replay record.
+ tty_stream: std.ArrayList(u8) = .empty,
+ /// query replies ghostty computed (DSR, DA, kitty); the stream handler has
+ /// no path to the effect queue, so they land here and sync() drains them
+ /// into write effects. Bounded: replies are tiny escape sequences.
+ reply: [256]u8 = undefined,
+ reply_len: u16 = 0,
+
+ fn cwdSlice(p: *const Pane) []const u8 {
+ return p.cwd[0..p.cwd_len];
+ }
+
+ fn isTerminal(pane: *const Pane) bool {
+ return pane.file == null and pane.image == null;
+ }
+
+ /// current scroll offset: file top line, or the scrollback offset
+ fn scroll(pane: *Pane) i32 {
+ if (pane.file) |f| return @intCast(f.scroll);
+ return @intCast(pane.vt.screens.active.pages.scrollbar().offset);
+ }
+
+ fn scrollBy(pane: *Pane, delta: i32) void {
+ if (pane.file) |*f| {
+ const max: i64 = @intCast(modal.lineCount(f.content) -| 1);
+ const n = std.math.clamp(@as(i64, @intCast(f.scroll)) + delta, 0, max);
+ const next: usize = @intCast(n);
+ if (next != f.scroll) {
+ f.scroll = next;
+ f.syntax_dirty = true;
+ }
+ } else {
+ pane.vt.screens.active.scroll(.{ .delta_row = @intCast(delta) });
+ }
+ }
+
+ fn ensureCursorVisible(pane: *Pane) void {
+ const off = pane.scroll();
+ const last = off + @as(i32, pane.rows) - 1;
+ if (pane.cur_row < off) pane.scrollBy(pane.cur_row - off) else if (pane.cur_row > last) pane.scrollBy(pane.cur_row - last);
+ }
+
+ /// the last navigable row: file -> last content line; terminal -> viewport bottom
+ fn lastNavRow(pane: *Pane) i32 {
+ if (pane.file) |f| return @intCast(modal.lineCount(f.content) -| 1);
+ return pane.scroll() + @as(i32, pane.rows) - 1;
+ }
+
+ /// the insertion run anchored exactly at (row, col), if any
+ fn runAt(pane: *Pane, row: i32, col: i32) ?usize {
+ for (pane.edits[0..pane.nedits], 0..) |e, i| {
+ if (e.row == row and e.col == col) return i;
+ }
+ return null;
+ }
+
+ fn pinCursor(pane: *Pane) void {
+ if (pane.cur_pinned) return;
+ const off = pane.scroll();
+ if (pane.file != null) {
+ pane.cur_row = off;
+ pane.cur_col = 0;
+ } else {
+ pane.cur_row = @as(i32, @intCast(pane.vt.screens.active.cursor.y)) + off;
+ pane.cur_col = @intCast(pane.vt.screens.active.cursor.x);
+ }
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ }
+
+ /// rendered column of a position: original col + earlier-on-row insertions
+ /// + the offset into the run at this col.
+ fn renderedCol(pane: *Pane, row: i32, col: i32, off: u16) i32 {
+ var rc: i32 = col + @as(i32, off);
+ for (pane.edits[0..pane.nedits]) |e| {
+ if (e.row == row and e.col < col) rc += @as(i32, e.len);
+ }
+ return rc;
+ }
+};
+
+const Drag = union(enum) {
+ none,
+ border_v: struct { left_col: usize, cur_x: u16 },
+ border_h: struct { col: usize, top_idx: usize, cur_y: u16 },
+ move: struct { id: usize, cur_x: u16, cur_y: u16 },
+ select: struct { id: usize, button: Mouse.Button },
+};
+
+const Rect = struct { x: u16, y: u16, w: u16, h: u16 };
+
+pub const Options = struct {
+ tty_only: bool = false,
+ tty_toggle: u21 = 'b',
+ /// load a dump of another instance instead of spawning shells (acme -l)
+ load_path: ?[]const u8 = 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).
+ cols: u16 = 80,
+ rows: u16 = 24,
+};
+
+pub const Pardes = struct {
+ gpa: std.mem.Allocator,
+ opts: Options,
+ screen_w: u16,
+ screen_h: u16,
+ resize_count: usize = 0,
+
+ 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),
+ 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,
+
+ active: usize = 0,
+ theme_idx: usize = 0,
+ colors_on: bool = true,
+ show_debug: bool = false,
+ /// kitty graphics support, reported by the shell (image panes fall back
+ /// to the petscii matcher without it)
+ kitty_ok: bool = false,
+ quit: bool = false,
+ drag: Drag = .none,
+ hover_col: u16 = 0,
+ hover_row: u16 = 0,
+ ctrl_w_pending: bool = false,
+ ov_pinch_scale: f32 = 1.0,
+ ov_touch_scroll_delta: f32 = 0.0,
+ /// middle+left chord argument: a kept left selection captured by a left
+ /// press during an active middle select-drag; consumed on middle release
+ chord_arg: ?[]u8 = null,
+
+ effects: [64]Effect = undefined,
+ effects_head: usize = 0,
+ effects_tail: usize = 0,
+
+ /// modal yank register (gpa-owned); a yank also mirrors out to the system
+ /// clipboard via the set_clipboard effect.
+ yank: ?[]u8 = null,
+ /// the last serialized dump (gpa-owned), read by the write_dump effect
+ dump_out: ?[]u8 = null,
+
+ surface: Surface = .{},
+ /// per-update scratch (paneCursorLines, selection text); reset each update
+ scratch: std.heap.ArenaAllocator,
+
+ pub fn init(gpa: std.mem.Allocator, opts: Options) !*Pardes {
+ syntax.compileStartupQueries();
+ const p = try gpa.create(Pardes);
+ p.* = .{ .gpa = gpa, .opts = opts, .screen_w = opts.cols, .screen_h = opts.rows, .scratch = .init(gpa) };
+ if (opts.tty_only) {
+ _ = try p.newPane(0);
+ p.panes[0].?.mode = .tty;
+ p.ncol = 1;
+ p.col_n[0] = 1;
+ p.col_terms[0][0] = 0;
+ } else {
+ // editor default: two columns, the left one split in two.
+ _ = try p.newPane(0);
+ _ = try p.newPane(1);
+ _ = try p.newPane(2);
+ for (p.panes[0..3]) |slot| slot.?.greet = true;
+ p.ncol = 2;
+ p.col_n[0] = 2;
+ p.col_terms[0][0] = 0;
+ p.col_terms[0][1] = 1;
+ p.col_n[1] = 1;
+ p.col_terms[1][0] = 2;
+ }
+ p.sync();
+ return p;
+ }
+
+ pub fn deinit(p: *Pardes) void {
+ for (&p.panes) |*slot| if (slot.*) |pane| {
+ p.deinitPane(pane);
+ slot.* = null;
+ };
+ const gpa = p.gpa;
+ if (p.yank) |y| gpa.free(y);
+ if (p.dump_out) |d| gpa.free(d);
+ if (p.chord_arg) |a| gpa.free(a);
+ p.scratch.deinit();
+ gpa.free(p.surface.cells);
+ gpa.destroy(p);
+ }
+
+ fn deinitPane(p: *Pardes, pane: *Pane) void {
+ if (pane.image) |*iv| {
+ p.gpa.free(iv.path);
+ if (iv.rgba.len > 0) p.gpa.free(iv.rgba);
+ if (iv.raw.len > 0) p.gpa.free(iv.raw);
+ if (iv.grid.len > 0) p.gpa.free(iv.grid);
+ }
+ if (pane.file) |*f| {
+ p.gpa.free(f.path);
+ p.gpa.free(f.content);
+ if (f.highlights.len > 0) p.gpa.free(f.highlights);
+ for (f.undo.items) |s| p.gpa.free(s);
+ f.undo.deinit(p.gpa);
+ for (f.redo.items) |s| p.gpa.free(s);
+ f.redo.deinit(p.gpa);
+ }
+ pane.tag_tail.deinit(p.gpa);
+ pane.ed_undo.deinit(p.gpa);
+ pane.ed_redo.deinit(p.gpa);
+ pane.stream.deinit();
+ pane.vt.deinit(p.gpa);
+ pane.tty_stream.deinit(p.gpa);
+ p.gpa.destroy(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.
+ fn newPane(p: *Pardes, id: usize) !*Pane {
+ return p.newShell(id, "");
+ }
+
+ fn newShell(p: *Pardes, id: usize, cwd: []const u8) !*Pane {
+ std.debug.assert(p.panes[id] == null);
+ const pane = try p.gpa.create(Pane);
+ pane.* = .{
+ .vt = try ghostty_vt.Terminal.init(p.gpa, .{
+ .cols = p.screen_w,
+ .rows = p.screen_h,
+ .max_scrollback = 16 * 1024 * 1024,
+ }),
+ .stream = undefined,
+ .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 = ptyReport;
+ pane.stream.handler.effects.device_attributes = ptyDeviceAttrs;
+ p.panes[id] = pane;
+ p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(cwd) } });
+ return pane;
+ }
+
+ /// a doc pane (file/image): no pty, no spawn; a stub 1x1 emulator only
+ /// because the shared machinery touches its allocator-owned bits.
+ fn newDocPane(p: *Pardes, id: usize) !*Pane {
+ std.debug.assert(p.panes[id] == null);
+ const pane = try p.gpa.create(Pane);
+ pane.* = .{
+ .vt = try ghostty_vt.Terminal.init(p.gpa, .{ .cols = 1, .rows = 1 }),
+ .stream = undefined,
+ .cols = p.screen_w,
+ .rows = p.screen_h,
+ };
+ pane.stream = pane.vt.vtStream();
+ p.panes[id] = pane;
+ return pane;
+ }
+
+ fn freeSlot(p: *Pardes) ?usize {
+ return for (p.panes, 0..) |slot, i| {
+ if (slot == null) break i;
+ } else null;
+ }
+
+ /// chunk arbitrary-length bytes into fixed-size write effects (order kept)
+ fn emitWrite(p: *Pardes, id: usize, bytes: []const u8) void {
+ var off: usize = 0;
+ while (off < bytes.len) {
+ const n = @min(bytes.len - off, 64);
+ p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(bytes[off .. off + n]) } });
+ off += n;
+ }
+ }
+
+ /// The shell reports the spawned pane's working directory (and later cwd
+ /// changes it observes, e.g. via /proc/<pid>/cwd before each frame).
+ 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);
+ @memcpy(pane.cwd[0..n], cwd[0..n]);
+ pane.cwd_len = @intCast(n);
+ }
+
+ fn emit(p: *Pardes, e: Effect) void {
+ std.debug.assert(p.effects_tail - p.effects_head < p.effects.len);
+ p.effects[p.effects_tail % p.effects.len] = e;
+ p.effects_tail += 1;
+ }
+
+ pub fn nextEffect(p: *Pardes) ?Effect {
+ if (p.effects_head == p.effects_tail) return null;
+ const e = p.effects[p.effects_head % p.effects.len];
+ p.effects_head += 1;
+ return e;
+ }
+
+ pub fn update(p: *Pardes, ev: Event) void {
+ switch (ev) {
+ .resize => |sz| {
+ p.screen_w = sz.cols;
+ p.screen_h = sz.rows;
+ p.resize_count += 1;
+ },
+ .output => |o| {
+ const pane = p.panes[o.pane] orelse return;
+ pane.tty_stream.appendSlice(p.gpa, o.bytes) catch {};
+ pane.stream.nextSlice(o.bytes);
+ // follow new output to the bottom — except in the pane being
+ // actively navigated in a text mode.
+ if (!(o.pane == p.active and pane.mode != .tty))
+ pane.vt.screens.active.scroll(.active);
+ },
+ .eof => |e| p.removePane(e.pane),
+ .key => |key| p.handleKey(key),
+ .mouse => |m| p.handleMouse(m),
+ .paste => |bytes| {
+ // the OSC 52 system-clipboard reply for a normal-mode `p`
+ p.setYank(bytes);
+ if (p.panes[p.active]) |pane| p.normalPaste(pane);
+ },
+ .pinch => |scale| p.ov_pinch_scale = scale,
+ .touch_scroll => |delta| p.ov_touch_scroll_delta = delta,
+ .tick => {},
+ }
+ _ = p.scratch.reset(.retain_capacity);
+ p.sync();
+ }
+
+ fn setYank(p: *Pardes, text: []const u8) void {
+ if (p.yank) |y| p.gpa.free(y);
+ p.yank = p.gpa.dupe(u8, text) catch null;
+ p.emit(.{ .set_clipboard = {} });
+ }
+
+ // ---- tag + selection text (chord sources) ----
+
+ /// the live tag prefix: mode + cwd/path (image: the whole toggle tag)
+ fn tagPrefix(p: *Pardes, pane: *Pane) ![]u8 {
+ const arena = p.scratch.allocator();
+ if (pane.image) |iv| {
+ const mode = if (iv.pmode == .commodore) "C64" else "Term";
+ return std.fmt.allocPrint(arena, "img {s} Petscii {s} Ascii {s}", .{ iv.path, mode, pane_builtins_str });
+ }
+ if (pane.file) |f| {
+ const fmode = if (pane.mode == .insert) "in" else "nm";
+ return std.fmt.allocPrint(arena, "{s} {s}", .{ fmode, f.path });
+ }
+ const mode = switch (pane.mode) {
+ .normal => "nm",
+ .insert => "in",
+ .tty => "sy",
+ };
+ return std.fmt.allocPrint(arena, "{s} {s}", .{ mode, pane.cwdSlice() });
+ }
+
+ /// the editable tail: the user's edited buffer once touched, else defaults
+ fn curTail(pane: *Pane) []const u8 {
+ if (pane.image != null) return "";
+ if (pane.tag_init) return pane.tag_tail.items;
+ return if (pane.file != null) file_pane_tail else pane_tail;
+ }
+
+ /// focus the tag for editing, seeding the tail on first touch and placing
+ /// the cursor under the click (a column within the text area)
+ fn enterTagEdit(p: *Pardes, pane: *Pane, click_col: i32) void {
+ if (pane.image != null) return; // images keep a live toggle tag
+ const pfx = p.tagPrefix(pane) catch return;
+ if (!pane.tag_init) {
+ pane.tag_tail.appendSlice(p.gpa, if (pane.file != null) file_pane_tail else pane_tail) catch return;
+ pane.tag_init = true;
+ }
+ pane.tag_edit = true;
+ pane.tag_sel = false;
+ pane.mode = .normal;
+ pane.pending = 0;
+ const cc = std.math.clamp(click_col - @as(i32, @intCast(pfx.len)), 0, @as(i32, @intCast(pane.tag_tail.items.len)));
+ pane.tag_col = @intCast(cc);
+ }
+
+ fn exitTagEdit(pane: *Pane) void {
+ pane.tag_edit = false;
+ pane.tag_sel = false;
+ }
+
+ fn tagSelBounds(pane: *Pane) struct { lo: usize, hi: usize } {
+ const a: usize = pane.tag_anchor;
+ const c: usize = pane.tag_col;
+ return .{ .lo = @min(a, c), .hi = @max(a, c) };
+ }
+
+ /// the text a tag Enter/Tab chord acts on: the char selection, else the
+ /// file-ish word under the cursor
+ fn tagChordText(p: *Pardes, pane: *Pane) ?[]const u8 {
+ const arena = p.scratch.allocator();
+ const tail = pane.tag_tail.items;
+ if (pane.tag_sel) {
+ const b = tagSelBounds(pane);
+ const hi = @min(b.hi + 1, tail.len);
+ if (hi > b.lo) return arena.dupe(u8, tail[b.lo..hi]) catch null;
+ return null;
+ }
+ const col: usize = @min(@as(usize, pane.tag_col), tail.len);
+ var lo = col;
+ while (lo > 0 and look.isFileChar(tail[lo - 1])) lo -= 1;
+ var hi = col;
+ while (hi < tail.len and look.isFileChar(tail[hi])) hi += 1;
+ if (hi > lo) return arena.dupe(u8, tail[lo..hi]) catch null;
+ return null;
+ }
+
+ /// tag-edit key dispatch: a modal one-line editor over the tail, sharing
+ /// the pane's mode. Newlines are always dropped.
+ fn handleTagKey(p: *Pardes, pane: *Pane, key: Key) void {
+ if (pane.mode == .insert) return p.tagInsertKey(pane, key);
+ const lines = [1][]const u8{pane.tag_tail.items};
+ const cur: modal.Cursor = .{ .row = 0, .col = pane.tag_col };
+
+ if (pane.pending == 'g') {
+ pane.pending = 0;
+ const m: modal.Cursor = if (is(key, 'g')) modal.gotoFirst() else if (is(key, 'e')) modal.gotoLast(&lines) else if (is(key, 'h')) modal.lineStart(cur) else if (is(key, 'l')) modal.lineEnd(&lines, cur) else cur;
+ pane.tag_col = @intCast(m.col);
+ return;
+ }
+ if (is(key, 'g')) {
+ pane.pending = 'g';
+ return;
+ }
+ const moved: ?modal.Cursor =
+ if (is(key, 'h') or is(key, Key.left)) modal.charLeft(cur) else if (is(key, 'l') or is(key, Key.right)) modal.charRight(&lines, cur) else if (is(key, 'w')) modal.nextWordStart(&lines, cur, false) else if (is(key, 'W')) modal.nextWordStart(&lines, cur, true) else if (is(key, 'b')) modal.prevWordStart(&lines, cur, false) else if (is(key, 'B')) modal.prevWordStart(&lines, cur, true) else if (is(key, 'e')) modal.nextWordEnd(&lines, cur, false) else if (is(key, 'E')) modal.nextWordEnd(&lines, cur, true) else if (is(key, '0') or is(key, Key.home)) modal.lineStart(cur) else if (is(key, '$') or is(key, Key.end)) modal.lineEnd(&lines, cur) else if (is(key, '^')) modal.firstNonWsOf(&lines, cur) else null;
+ if (moved) |m| {
+ pane.tag_col = @intCast(m.col);
+ return;
+ }
+ if (is(key, 'i')) return tagEnterInsert(pane, pane.tag_col);
+ if (is(key, 'a')) return tagEnterInsert(pane, @intCast(modal.charRight(&lines, cur).col));
+ if (is(key, 'I')) return tagEnterInsert(pane, @intCast(modal.firstNonWsOf(&lines, cur).col));
+ if (is(key, 'A')) return tagEnterInsert(pane, @intCast(pane.tag_tail.items.len));
+ if (is(key, 'v')) {
+ pane.tag_sel = !pane.tag_sel;
+ if (pane.tag_sel) pane.tag_anchor = pane.tag_col;
+ return;
+ }
+ if (is(key, 'x')) { // select the whole one-line tail
+ const len = pane.tag_tail.items.len;
+ if (len > 0) {
+ pane.tag_sel = true;
+ pane.tag_anchor = 0;
+ pane.tag_col = @intCast(len - 1);
+ }
+ return;
+ }
+ if (is(key, 'd')) return p.tagDelete(pane, false);
+ if (is(key, 'c')) return p.tagDelete(pane, true);
+ if (is(key, 'y')) return p.tagYank(pane);
+ if (is(key, 'p')) return p.tagPaste(pane);
+ // Esc escalates: clear the selection, else hand focus back to the body
+ if (is(key, Key.escape)) {
+ if (pane.tag_sel) pane.tag_sel = false else exitTagEdit(pane);
+ return;
+ }
+ }
+
+ fn tagEnterInsert(pane: *Pane, col: u16) void {
+ pane.tag_col = @min(col, @as(u16, @intCast(pane.tag_tail.items.len)));
+ pane.tag_sel = false;
+ pane.mode = .insert;
+ pane.pending = 0;
+ }
+
+ fn tagInsertKey(p: *Pardes, pane: *Pane, key: Key) void {
+ if (is(key, Key.escape)) {
+ pane.mode = .normal;
+ return;
+ }
+ if (key.text.len > 0) {
+ for (key.text) |ch| {
+ if (ch == '\n' or ch == '\r') continue; // one-line tag
+ pane.tag_tail.insert(p.gpa, pane.tag_col, ch) catch return;
+ pane.tag_col += 1;
+ }
+ return;
+ }
+ switch (key.cp) {
+ Key.backspace => if (pane.tag_col > 0) {
+ _ = pane.tag_tail.orderedRemove(pane.tag_col - 1);
+ pane.tag_col -= 1;
+ },
+ Key.left => if (pane.tag_col > 0) {
+ pane.tag_col -= 1;
+ },
+ Key.right => if (pane.tag_col < pane.tag_tail.items.len) {
+ pane.tag_col += 1;
+ },
+ Key.home => pane.tag_col = 0,
+ Key.end => pane.tag_col = @intCast(pane.tag_tail.items.len),
+ else => {},
+ }
+ }
+
+ /// d/c: delete the selection (or the char under the cursor), yanking it;
+ /// c then enters insert
+ fn tagDelete(p: *Pardes, pane: *Pane, change: bool) void {
+ const tail = pane.tag_tail.items;
+ var lo: usize = undefined;
+ var count: usize = undefined;
+ if (pane.tag_sel) {
+ const b = tagSelBounds(pane);
+ lo = b.lo;
+ count = @min(b.hi + 1, tail.len) - b.lo;
+ } else {
+ lo = @min(@as(usize, pane.tag_col), tail.len);
+ count = if (lo < tail.len) 1 else 0;
+ }
+ if (count > 0) {
+ p.setYank(tail[lo .. lo + count]);
+ pane.tag_tail.replaceRange(p.gpa, lo, count, "") catch return;
+ }
+ pane.tag_col = @intCast(lo);
+ pane.tag_sel = false;
+ if (change) pane.mode = .insert;
+ }
+
+ fn tagYank(p: *Pardes, pane: *Pane) void {
+ const tail = pane.tag_tail.items;
+ if (pane.tag_sel) {
+ const b = tagSelBounds(pane);
+ const hi = @min(b.hi + 1, tail.len);
+ p.setYank(tail[b.lo..hi]);
+ pane.tag_col = @intCast(b.lo);
+ pane.tag_sel = false;
+ } else p.setYank(tail); // whole tail, like a line yank
+ }
+
+ fn tagPaste(p: *Pardes, pane: *Pane) void {
+ const y = p.yank orelse return;
+ const col: usize = @min(@as(usize, pane.tag_col), pane.tag_tail.items.len);
+ var at = col;
+ for (y) |ch| {
+ if (ch == '\n' or ch == '\r') continue;
+ pane.tag_tail.insert(p.gpa, at, ch) catch break;
+ at += 1;
+ }
+ pane.tag_col = @intCast(at);
+ }
+
+ /// tag + '\n' + body: the full selectable pane text (row 0 = the tag)
+ fn paneText(p: *Pardes, pane: *Pane) ![]u8 {
+ const arena = p.scratch.allocator();
+ const pfx = try p.tagPrefix(pane);
+ const body = try p.bodyText(arena, pane);
+ return std.fmt.allocPrint(arena, "{s}{s}\n{s}", .{ pfx, curTail(pane), body });
+ }
+
+ /// block-selected text, newline-joined per row; reads the rendered screen
+ /// so typed runs and tty output select identically. Scratch-owned.
+ fn selectionText(p: *Pardes, pane: *Pane, sl: Sel) ![]const u8 {
+ const arena = p.scratch.allocator();
+ const r0 = @min(sl.r0, sl.r1);
+ const r1 = @max(sl.r0, sl.r1);
+ const c0: usize = @intCast(@max(0, @min(sl.c0, sl.c1)));
+ const c1: usize = @intCast(@max(0, @max(sl.c0, sl.c1)));
+ const text = try p.paneText(pane);
+ var out: std.ArrayList(u8) = .empty;
+ var it = std.mem.splitAny(u8, text, "\n");
+ var v: i32 = 0;
+ var first = true;
+ while (it.next()) |line| : (v += 1) {
+ if (v < r0 or v > r1) continue;
+ if (!first) try out.append(arena, '\n');
+ first = false;
+ const b0 = @min(c0, line.len);
+ const b1 = @min(c1 + 1, line.len);
+ try out.appendSlice(arena, line[b0..b1]);
+ }
+ return out.items;
+ }
+
+ /// acme: a no-drag middle/right click expands to the file-ish word under it
+ 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;
+ var it = std.mem.splitAny(u8, text, "\n");
+ var v: i32 = 0;
+ while (it.next()) |line| : (v += 1) {
+ if (v != sl.r0) continue;
+ const col: usize = @intCast(@max(0, sl.c0));
+ if (col > line.len) return;
+ var hi = col;
+ while (hi < line.len and look.isFileChar(line[hi])) hi += 1;
+ var lo = col;
+ while (lo > 0 and look.isFileChar(line[lo - 1])) lo -= 1;
+ if (hi > lo) {
+ sl.c0 = @intCast(lo);
+ sl.c1 = @intCast(hi - 1);
+ }
+ return;
+ }
+ }
+
+ /// the word under the modal cursor as a pane-local selection (paneText
+ /// coords: row 0 is the tag; file panes carry the line-number prefix)
+ fn cursorWordSel(pane: *Pane) Sel {
+ const vrow = (pane.cur_row - pane.scroll()) + @as(i32, BOX_H);
+ const vcol = if (pane.file != null) pane.cur_col + @as(i32, PREFIX_W) else pane.cur_col;
+ return .{ .state = .done, .c0 = vcol, .c1 = vcol, .r0 = vrow, .r1 = vrow };
+ }
+
+ /// the active modal selection as text (v range or x lines), if any
+ fn currentSelText(p: *Pardes, pane: *Pane) ?[]const u8 {
+ if (pane.vsel.active) return p.vselText(pane);
+ if (!pane.msel.active) return null;
+ const r0 = @min(pane.msel.r0, pane.msel.r1);
+ const r1 = @max(pane.msel.r0, pane.msel.r1);
+ if (pane.file) |f| {
+ const arena = p.scratch.allocator();
+ var buf: std.ArrayList(u8) = .empty;
+ const lo: usize = @intCast(@max(0, r0));
+ const hi: usize = @intCast(@max(0, r1));
+ var r: usize = lo;
+ while (r <= hi) : (r += 1) {
+ if (r > lo) buf.append(arena, '\n') catch break;
+ buf.appendSlice(arena, modal.lineSlice(f.content, r)) catch break;
+ }
+ return buf.items;
+ }
+ return p.yankRows(pane, r0, r1);
+ }
+
+ fn handleKey(p: *Pardes, key: Key) void {
+ const pane = p.panes[p.active] orelse return;
+ // Ctrl-w prefix: helix-style directional pane focus (h/j/k/l)
+ if (p.ctrl_w_pending) {
+ p.ctrl_w_pending = false;
+ if (is(key, 'h') or is(key, Key.left)) p.focusDir(.left) else if (is(key, 'l') or is(key, Key.right)) p.focusDir(.right) else if (is(key, 'k') or is(key, Key.up)) p.focusDir(.up) else if (is(key, 'j') or is(key, Key.down)) p.focusDir(.down);
+ if (p.panes[p.active]) |nt| nt.pending = 0;
+ return;
+ }
+ if (isC(key, 'w')) {
+ p.ctrl_w_pending = true;
+ return;
+ }
+ // global window ops (any mode): Alt-n new terminal below, Alt-c move
+ // the active terminal into a fresh column
+ if (key.alt and !key.ctrl and key.cp == 'n') {
+ const free = p.freeSlot() orelse return;
+ const nt = p.newShell(free, "") catch return;
+ nt.greet = true;
+ const f = p.layoutFindTerm(p.active).?;
+ p.layoutInsert(f.col, f.idx + 1, free);
+ p.active = free;
+ return;
+ }
+ if (key.alt and !key.ctrl and key.cp == 'c') {
+ const f = p.layoutFindTerm(p.active).?;
+ if (p.ncol < MAX_COLS and p.col_n[f.col] > 1) {
+ p.layoutRemove(p.active);
+ p.layoutAppendColumn(p.active);
+ }
+ return;
+ }
+ // the configured Ctrl-key toggles raw tty mode in and out (terminals
+ // only); tty is deliberately off the normal editing path
+ if (pane.isTerminal() and key.ctrl and key.cp == p.opts.tty_toggle) {
+ if (pane.mode == .tty) {
+ pane.mode = .normal;
+ pane.pending = 0;
+ } else p.enterTty(p.active);
+ return;
+ }
+ // normal-mode Enter/Tab: the acme chords — look at (Enter) or execute
+ // (Tab) the modal selection, else the file-ish word under the cursor
+ // (on the tag tail when it's focused for editing, else the body).
+ if (pane.mode == .normal and (is(key, Key.enter) or is(key, Key.tab))) {
+ const btn: Mouse.Button = if (is(key, Key.tab)) .middle else .right;
+ if (pane.tag_edit) {
+ if (p.tagChordText(pane)) |txt| {
+ pane.tag_sel = false;
+ p.actOnSelection(btn, p.active, txt, null);
+ }
+ return;
+ }
+ pane.pinCursor();
+ if (p.currentSelText(pane)) |txt| {
+ pane.vsel.active = false;
+ pane.msel.active = false;
+ p.actOnSelection(btn, p.active, txt, null);
+ } else {
+ var sel = cursorWordSel(pane);
+ p.expandSel(pane, &sel);
+ const word = p.selectionText(pane, sel) catch return;
+ p.actOnSelection(btn, p.active, word, null);
+ }
+ return;
+ }
+ // tag editing intercepts every other key: a modal one-line editor
+ // over the tail, sharing the pane's mode.
+ if (pane.tag_edit) {
+ p.handleTagKey(pane, key);
+ return;
+ }
+ // normal-mode `p` pastes the SYSTEM clipboard: an async OSC 52 round
+ // trip — request here, the reply arrives later as Event.paste.
+ if (pane.mode == .normal and is(key, 'p')) {
+ p.emit(.get_clipboard);
+ return;
+ }
+ switch (pane.mode) {
+ .normal => p.handleNormal(pane, key),
+ .insert => {
+ if (key.cp == Key.escape and !key.ctrl and !key.alt) {
+ pane.mode = .normal;
+ pane.msel.active = false;
+ pane.pending = 0;
+ return;
+ }
+ p.handleInsert(pane, key);
+ },
+ .tty => p.forwardKey(p.active, key),
+ }
+ }
+
+ /// 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.vt.cursorIsAtPrompt()) {
+ const off = pane.scroll();
+ const vp_row: i32 = pane.cur_row - off;
+ if (vp_row >= 0) {
+ if (screen.pages.pin(.{ .viewport = .{ .x = @intCast(@max(0, pane.cur_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.pending = 0;
+ }
+
+ /// move focus to the nearest pane in `dir` (overlap-aware nearest edge)
+ fn focusDir(p: *Pardes, dir: enum { left, right, up, down }) void {
+ const a = p.rects[p.active];
+ var best: ?usize = null;
+ var best_d: i32 = 0;
+ for (p.panes, 0..) |slot, i| {
+ if (slot == null or i == p.active) continue;
+ const r = p.rects[i];
+ const vov = a.y < r.y + r.h and r.y < a.y + a.h;
+ const hov = a.x < r.x + r.w and r.x < a.x + a.w;
+ const ok = switch (dir) {
+ .left => r.x + r.w <= a.x and vov,
+ .right => r.x >= a.x + a.w and vov,
+ .up => r.y + r.h <= a.y and hov,
+ .down => r.y >= a.y + a.h and hov,
+ };
+ if (!ok) continue;
+ const d: i32 = switch (dir) {
+ .left => @as(i32, a.x) - @as(i32, r.x + r.w),
+ .right => @as(i32, r.x) - @as(i32, a.x + a.w),
+ .up => @as(i32, a.y) - @as(i32, r.y + r.h),
+ .down => @as(i32, r.y) - @as(i32, a.y + a.h),
+ };
+ if (best == null or d < best_d) {
+ best = i;
+ best_d = d;
+ }
+ }
+ if (best) |b| p.active = b;
+ }
+
+ // ---- move-drag placement ----
+
+ const MovePlacement = struct {
+ preview_col: usize,
+ above_id: usize,
+ row: u16,
+ above_y: u16,
+ above_h: u16,
+ };
+
+ fn targetColumn(p: *Pardes, cur_x: u16) usize {
+ var tc: usize = if (p.ncol > 0) p.ncol - 1 else 0;
+ for (0..p.ncol) |c| {
+ if (cur_x >= p.col_x[c] and cur_x < p.col_x[c] + p.col_w[c]) {
+ tc = c;
+ break;
+ }
+ }
+ return tc;
+ }
+
+ fn splitRowForExtent(y: u16, h: u16, cur_y: u16) ?u16 {
+ if (h < 2) return null;
+ const min_each: u16 = if (h >= MINH * 2) MINH else 1;
+ const lo = y +| min_each;
+ const hi = y + h - min_each;
+ if (lo > hi) return y + h / 2;
+ return std.math.clamp(cur_y, lo, hi);
+ }
+
+ fn movePlacement(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) ?MovePlacement {
+ const src = p.layoutFindTerm(id) orelse return null;
+ const tc = p.targetColumn(cur_x);
+ if (tc == src.col and p.col_n[src.col] == 1) return null;
+ if (tc == src.col) {
+ const sr = p.rects[id];
+ if (cur_y >= sr.y and cur_y < sr.y + sr.h) return null;
+ }
+ var heights: [MAX_PANES]u16 = @splat(0);
+ for (0..p.ncol) |c| {
+ for (0..p.col_n[c]) |k| {
+ const pid = p.col_terms[c][k];
+ heights[pid] = p.rects[pid].h;
+ }
+ }
+ if (p.col_n[src.col] > 1) {
+ const sib = if (src.idx > 0) p.col_terms[src.col][src.idx - 1] else p.col_terms[src.col][src.idx + 1];
+ heights[sib] +|= p.rects[id].h;
+ }
+ var y: u16 = TOPBAR_H;
+ var last: ?MovePlacement = null;
+ for (0..p.col_n[tc]) |k| {
+ const pid = p.col_terms[tc][k];
+ if (pid == id) continue;
+ const h = heights[pid];
+ const row = splitRowForExtent(y, h, cur_y) orelse {
+ y +|= h;
+ continue;
+ };
+ const placement: MovePlacement = .{
+ .preview_col = tc,
+ .above_id = pid,
+ .row = row,
+ .above_y = y,
+ .above_h = h,
+ };
+ last = placement;
+ if (cur_y < y + h) return placement;
+ y +|= h;
+ }
+ return last;
+ }
+
+ /// drop pane `id` below the pane under the cursor, converting on-screen
+ /// heights to weights so ONLY the split pane changes size
+ fn moveTerm(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) void {
+ const placement = p.movePlacement(id, cur_x, cur_y) orelse return;
+ const src = p.layoutFindTerm(id) orelse return;
+ const source_multi = p.col_n[src.col] > 1;
+ var heights: [MAX_PANES]u16 = @splat(0);
+ for (0..p.ncol) |c| {
+ for (0..p.col_n[c]) |k| {
+ const pid = p.col_terms[c][k];
+ heights[pid] = p.rects[pid].h;
+ }
+ }
+ p.layoutRemove(id);
+ if (source_multi and src.col < p.ncol and p.col_n[src.col] > 0) {
+ const sib = if (src.idx > 0) p.col_terms[src.col][src.idx - 1] else p.col_terms[src.col][src.idx];
+ heights[sib] +|= p.rects[id].h;
+ }
+ const af = p.layoutFindTerm(placement.above_id) orelse return;
+ const upper_h = @max(1, placement.row -| placement.above_y);
+ const lower_h = @max(1, placement.above_h -| upper_h);
+ heights[placement.above_id] = upper_h;
+ heights[id] = lower_h;
+ p.layoutInsert(af.col, af.idx + 1, id);
+ p.setColumnWeights(af.col, &heights);
+ if (source_multi and src.col < p.ncol and src.col != af.col) p.setColumnWeights(src.col, &heights);
+ }
+
+ fn setColumnWeights(p: *Pardes, col: usize, heights: *const [MAX_PANES]u16) void {
+ if (col >= p.ncol) return;
+ for (0..p.col_n[col]) |k| {
+ const pid = p.col_terms[col][k];
+ if (p.panes[pid]) |pane| pane.vweight = @floatFromInt(@max(1, heights[pid]));
+ }
+ }
+
+ // ---- helix-modal normal mode ----
+
+ const PaneLines = struct {
+ lines: []const []const u8,
+ row0: i32, // absolute row of lines[0]
+ };
+
+ /// The lines the cursor moves over, absolute rows. File: all content lines.
+ /// Terminal: the WHOLE history+active grid (prompt rows hidden, runs
+ /// spliced) so motions ride the scrollback. Scratch-arena backed.
+ fn paneCursorLines(p: *Pardes, pane: *Pane) !PaneLines {
+ const arena = p.scratch.allocator();
+ if (pane.file) |f| {
+ var ls: std.ArrayList([]const u8) = .empty;
+ var it = std.mem.splitScalar(u8, f.content, '\n');
+ while (it.next()) |ln| try ls.append(arena, ln);
+ return .{ .lines = ls.items, .row0 = 0 };
+ }
+ // ponytail: O(scrollback) dump+scan per keystroke — window it around the
+ // viewport if a multi-MB scrollback ever makes navigation lag.
+ const full = try pane.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} });
+ var out: std.ArrayList(u8) = .empty;
+ var it = std.mem.splitAny(u8, full, "\n");
+ var pit = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null);
+ var i: usize = 0;
+ while (it.next()) |raw| : (i += 1) {
+ if (i > 0) try out.append(arena, '\n');
+ const is_prompt = if (pit.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false;
+ try spliceRow(arena, &out, pane, @intCast(i), if (is_prompt) "" else raw);
+ }
+ var ls: std.ArrayList([]const u8) = .empty;
+ var lit = std.mem.splitScalar(u8, out.items, '\n');
+ while (lit.next()) |ln| try ls.append(arena, ln);
+ return .{ .lines = ls.items, .row0 = 0 };
+ }
+
+ fn toModalCursor(pane: *Pane, pl: PaneLines) modal.Cursor {
+ const r: i32 = pane.cur_row - pl.row0;
+ return .{ .row = @intCast(@max(0, r)), .col = @intCast(@max(0, pane.cur_col)) };
+ }
+
+ fn fromModalCursor(pane: *Pane, pl: PaneLines, c: modal.Cursor) void {
+ pane.cur_row = @as(i32, @intCast(c.row)) + pl.row0;
+ pane.cur_col = @intCast(c.col);
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ }
+
+ /// apply a motion: move the cursor, keep it visible. A line selection
+ /// collapses on motion; a char selection extends (its head is the cursor).
+ fn applyMove(pane: *Pane, pl: PaneLines, c: modal.Cursor) void {
+ fromModalCursor(pane, pl, c);
+ pane.msel.active = false;
+ pane.pending = 0;
+ pane.ensureCursorVisible();
+ }
+
+ fn handleNormal(p: *Pardes, pane: *Pane, key: Key) void {
+ pane.pinCursor();
+ const pl = p.paneCursorLines(pane) catch return;
+ const cur = toModalCursor(pane, pl);
+ const lines = pl.lines;
+ const half: usize = @max(1, pane.rows / 2);
+
+ // Esc collapses any selection / pending prefix; it does NOT drop to tty
+ if (is(key, Key.escape)) {
+ pane.vsel.active = false;
+ pane.msel.active = false;
+ pane.pending = 0;
+ return;
+ }
+ // prefix continuations
+ if (pane.pending == 'g') {
+ pane.pending = 0;
+ if (is(key, 'g')) return applyMove(pane, pl, modal.gotoFirst());
+ if (is(key, 'e')) return applyMove(pane, pl, modal.gotoLast(lines));
+ if (is(key, 'h')) return applyMove(pane, pl, modal.lineStart(cur));
+ if (is(key, 'l')) return applyMove(pane, pl, modal.lineEnd(lines, cur));
+ return;
+ }
+ if (pane.pending == 'z') {
+ pane.pending = 0;
+ if (is(key, 't')) return pane.scrollBy(pane.cur_row - pane.scroll());
+ if (is(key, 'z')) return pane.scrollBy(pane.cur_row - (pane.scroll() + @as(i32, pane.rows / 2)));
+ if (is(key, 'b')) return pane.scrollBy(pane.cur_row - (pane.scroll() + @as(i32, pane.rows) - 1));
+ return;
+ }
+ if (is(key, 'g')) {
+ pane.pending = 'g';
+ return;
+ }
+ if (is(key, 'z')) {
+ pane.pending = 'z';
+ return;
+ }
+ // movement
+ if (is(key, 'h') or is(key, Key.left)) return applyMove(pane, pl, modal.charLeft(cur));
+ if (is(key, 'l') or is(key, Key.right)) return applyMove(pane, pl, modal.charRight(lines, cur));
+ if (is(key, 'j') or is(key, Key.down)) return applyMove(pane, pl, modal.lineDown(lines, cur));
+ if (is(key, 'k') or is(key, Key.up)) return applyMove(pane, pl, modal.lineUp(lines, cur));
+ if (is(key, 'w')) return applyMove(pane, pl, modal.nextWordStart(lines, cur, false));
+ if (is(key, 'b')) return applyMove(pane, pl, modal.prevWordStart(lines, cur, false));
+ if (is(key, 'e')) return applyMove(pane, pl, modal.nextWordEnd(lines, cur, false));
+ if (is(key, 'W')) return applyMove(pane, pl, modal.nextWordStart(lines, cur, true));
+ if (is(key, 'B')) return applyMove(pane, pl, modal.prevWordStart(lines, cur, true));
+ if (is(key, 'E')) return applyMove(pane, pl, modal.nextWordEnd(lines, cur, true));
+ if (is(key, '0') or is(key, Key.home)) return applyMove(pane, pl, modal.lineStart(cur));
+ if (is(key, '$') or is(key, Key.end)) return applyMove(pane, pl, modal.lineEnd(lines, cur));
+ if (is(key, '^')) return applyMove(pane, pl, modal.firstNonWsOf(lines, cur));
+ if (is(key, 'G')) return applyMove(pane, pl, modal.gotoLast(lines));
+ if (isC(key, 'd') or is(key, Key.page_down))
+ return applyMove(pane, pl, modal.clampToChar(lines, modal.pageDown(cur, half)));
+ if (isC(key, 'u') or is(key, Key.page_up))
+ return applyMove(pane, pl, modal.clampToChar(lines, modal.pageUp(cur, half)));
+ if (isC(key, 'f'))
+ return applyMove(pane, pl, modal.clampToChar(lines, modal.pageDown(cur, @as(usize, pane.rows))));
+ if (isC(key, 'b'))
+ return applyMove(pane, pl, modal.clampToChar(lines, modal.pageUp(cur, @as(usize, pane.rows))));
+ // insert entry
+ if (is(key, 'i')) return p.enterInsert(pane, .at);
+ if (is(key, 'a')) return p.enterInsert(pane, .append);
+ if (is(key, 'I')) return p.enterInsert(pane, .line_start);
+ if (is(key, 'A')) return p.enterInsert(pane, .line_end);
+ if (is(key, 'o')) return p.enterInsert(pane, .open_below);
+ if (is(key, 'O')) return p.enterInsert(pane, .open_above);
+ // selection + edit
+ if (is(key, 'v')) return charSelect(pane);
+ if (is(key, 'x')) return lineSelect(pane);
+ if (is(key, 'd')) return p.normalDelete(pane);
+ if (is(key, 'c')) return p.normalChange(pane);
+ if (is(key, 'y')) return p.normalYank(pane);
+ if (is(key, 'u')) return p.doUndo(pane);
+ if (is(key, 'U')) return p.doRedo(pane);
+ }
+
+ const InsertAt = enum { at, append, line_start, line_end, open_below, open_above };
+
+ fn enterInsert(p: *Pardes, pane: *Pane, where: InsertAt) void {
+ pane.pinCursor();
+ // snapshot once per insert session so `u` undoes the whole session
+ p.pushUndo(pane);
+ const pl = p.paneCursorLines(pane) catch {
+ pane.mode = .insert;
+ pane.msel.active = false;
+ pane.vsel.active = false;
+ pane.pending = 0;
+ return;
+ };
+ const cur = toModalCursor(pane, pl);
+ const llen: usize = if (cur.row < pl.lines.len) pl.lines[cur.row].len else 0;
+ switch (where) {
+ .at => {},
+ .append => fromModalCursor(pane, pl, modal.charRight(pl.lines, cur)),
+ .line_start => fromModalCursor(pane, pl, modal.firstNonWsOf(pl.lines, cur)),
+ .line_end => {
+ pane.cur_col = @intCast(llen);
+ pane.cur_pinned = true;
+ },
+ .open_below => {
+ if (pane.file) |*f| {
+ const new = modal.insertAt(p.gpa, f.content, .{ .row = cur.row, .col = llen }, "\n") catch return;
+ p.setFileContent(f, new);
+ pane.cur_row = @as(i32, @intCast(cur.row + 1)) + pl.row0;
+ pane.cur_col = 0;
+ } else {
+ const bottom = pane.scroll() + @as(i32, pane.rows) - 1;
+ pane.cur_row = @min(bottom, pane.cur_row + 1);
+ pane.cur_col = 0;
+ }
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ },
+ .open_above => {
+ if (pane.file) |*f| {
+ const new = modal.insertAt(p.gpa, f.content, .{ .row = cur.row, .col = 0 }, "\n") catch return;
+ p.setFileContent(f, new);
+ pane.cur_col = 0;
+ } else {
+ const top = pane.scroll();
+ pane.cur_row = @max(top, pane.cur_row - 1);
+ pane.cur_col = 0;
+ }
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ },
+ }
+ // `i` inserts before the run at the cursor; the others append after it
+ pane.cur_off = if (where == .at) 0 else if (pane.runAt(pane.cur_row, pane.cur_col)) |i| pane.edits[i].len else 0;
+ pane.mode = .insert;
+ pane.msel.active = false;
+ pane.vsel.active = false;
+ pane.pending = 0;
+ pane.ensureCursorVisible();
+ }
+
+ /// insert mode: file panes get real content edits; terminals get run
+ /// splices — edits only ever touch YOUR runs, shell output is immutable.
+ fn handleInsert(p: *Pardes, pane: *Pane, key: Key) void {
+ if (pane.file) |*f| {
+ const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) };
+ if (key.text.len > 0) {
+ const new = modal.insertAt(p.gpa, f.content, c, key.text) catch return;
+ p.setFileContent(f, new);
+ for (key.text) |ch| {
+ if (ch == '\n') {
+ pane.cur_row += 1;
+ pane.cur_col = 0;
+ } else pane.cur_col += 1;
+ }
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ return;
+ }
+ switch (key.cp) {
+ Key.enter => {
+ const new = modal.insertAt(p.gpa, f.content, c, "\n") catch return;
+ p.setFileContent(f, new);
+ pane.cur_row += 1;
+ pane.cur_col = 0;
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ },
+ Key.backspace => {
+ if (pane.cur_col > 0) {
+ const new = modal.deleteChar(p.gpa, f.content, .{ .row = c.row, .col = c.col - 1 }) catch return;
+ p.setFileContent(f, new);
+ pane.cur_col -= 1;
+ } else if (pane.cur_row > 0) {
+ const prevlen = modal.lineSlice(f.content, c.row - 1).len;
+ const new = modal.deleteChar(p.gpa, f.content, .{ .row = c.row - 1, .col = prevlen }) catch return;
+ p.setFileContent(f, new);
+ pane.cur_row -= 1;
+ pane.cur_col = @intCast(prevlen);
+ }
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ },
+ Key.left, Key.right, Key.up, Key.down => {
+ const pl = p.paneCursorLines(pane) catch return;
+ const cur0 = toModalCursor(pane, pl);
+ const nc = switch (key.cp) {
+ Key.left => modal.charLeft(cur0),
+ Key.right => modal.charRight(pl.lines, cur0),
+ Key.up => modal.lineUp(pl.lines, cur0),
+ Key.down => modal.lineDown(pl.lines, cur0),
+ else => cur0,
+ };
+ fromModalCursor(pane, pl, nc);
+ pane.ensureCursorVisible();
+ },
+ else => {},
+ }
+ return;
+ }
+
+ if (!pane.cur_pinned) pane.pinCursor();
+ if (key.text.len > 0) {
+ insertRunText(pane, key.text);
+ return;
+ }
+ switch (key.cp) {
+ Key.backspace => {
+ const ei = pane.runAt(pane.cur_row, pane.cur_col) orelse return;
+ const e = &pane.edits[ei];
+ if (pane.cur_off > 0 and e.len > 0) {
+ var k: usize = pane.cur_off - 1;
+ while (k + 1 < e.len) : (k += 1) e.text[k] = e.text[k + 1];
+ e.len -= 1;
+ pane.cur_off -= 1;
+ }
+ },
+ else => {},
+ }
+ }
+
+ fn insertRunText(pane: *Pane, text: []const u8) void {
+ var idx: ?usize = pane.runAt(pane.cur_row, pane.cur_col);
+ if (idx == null and pane.nedits < MAX_EDITS) {
+ pane.edits[pane.nedits] = .{ .row = pane.cur_row, .col = pane.cur_col };
+ pane.nedits += 1;
+ idx = pane.nedits - 1;
+ }
+ const ei = idx orelse return;
+ const e = &pane.edits[ei];
+ for (text) |ch| {
+ if (e.len >= EDIT_CAP) break;
+ if (pane.cur_off > e.len) pane.cur_off = e.len;
+ var k: usize = e.len;
+ while (k > pane.cur_off) : (k -= 1) e.text[k] = e.text[k - 1];
+ e.text[pane.cur_off] = ch;
+ e.len += 1;
+ pane.cur_off += 1;
+ }
+ }
+
+ // ---- selections ----
+
+ /// helix `v`: toggle char-range select, anchored at the cursor
+ fn charSelect(pane: *Pane) void {
+ if (pane.vsel.active) {
+ pane.vsel.active = false;
+ } else {
+ pane.vsel = .{ .active = true, .row = pane.cur_row, .col = pane.cur_col };
+ }
+ pane.msel.active = false;
+ pane.cur_pinned = true;
+ pane.pending = 0;
+ }
+
+ /// helix `x`: extend the line selection down by one (or start it)
+ fn lineSelect(pane: *Pane) void {
+ if (!pane.msel.active) {
+ pane.msel = .{ .active = true, .r0 = pane.cur_row, .r1 = pane.cur_row };
+ } else {
+ const last = pane.lastNavRow();
+ pane.msel.r1 = @min(pane.msel.r1 + 1, last);
+ pane.cur_row = pane.msel.r1;
+ }
+ pane.vsel.active = false;
+ pane.cur_col = 0;
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.pending = 0;
+ }
+
+ const Bounds = struct { lo_row: i32, lo_col: i32, hi_row: i32, hi_col: i32 };
+
+ /// the char selection [anchor, cursor] normalized to document order
+ fn vselBounds(pane: *Pane) Bounds {
+ var ar = pane.vsel.row;
+ var ac = pane.vsel.col;
+ var br = pane.cur_row;
+ var bc = pane.cur_col;
+ if (br < ar or (br == ar and bc < ac)) {
+ const sr = ar;
+ const sc = ac;
+ ar = br;
+ ac = bc;
+ br = sr;
+ bc = sc;
+ }
+ return .{ .lo_row = ar, .lo_col = ac, .hi_row = br, .hi_col = bc };
+ }
+
+ fn vselText(p: *Pardes, pane: *Pane) []const u8 {
+ const arena = p.scratch.allocator();
+ const b = vselBounds(pane);
+ if (pane.file) |f| {
+ return modal.rangeText(arena, f.content, .{ .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 "";
+ }
+ const body = p.bodyText(arena, pane) catch return "";
+ const off = pane.scroll();
+ var out: std.ArrayList(u8) = .empty;
+ var it = std.mem.splitAny(u8, body, "\n");
+ var i: i32 = 0;
+ while (it.next()) |ln| : (i += 1) {
+ const abs = i + off;
+ if (abs < b.lo_row or abs > b.hi_row) continue;
+ const lo: usize = if (abs == b.lo_row) @intCast(@max(0, b.lo_col)) else 0;
+ const hi_excl: usize = if (abs == b.hi_row) @as(usize, @intCast(@max(0, b.hi_col))) + 1 else ln.len;
+ if (abs > b.lo_row) out.append(arena, '\n') catch return "";
+ out.appendSlice(arena, ln[@min(lo, ln.len)..@min(hi_excl, ln.len)]) catch return "";
+ }
+ return out.items;
+ }
+
+ /// join the rendered body rows [r0, r1] (absolute) with '\n'; scratch-owned
+ fn yankRows(p: *Pardes, pane: *Pane, r0: i32, r1: i32) []const u8 {
+ const arena = p.scratch.allocator();
+ const body = p.bodyText(arena, pane) catch return "";
+ const off = pane.scroll();
+ var out: std.ArrayList(u8) = .empty;
+ var it = std.mem.splitAny(u8, body, "\n");
+ var i: i32 = 0;
+ while (it.next()) |ln| : (i += 1) {
+ const abs = i + off;
+ if (abs < r0 or abs > r1) continue;
+ if (abs > r0) out.append(arena, '\n') catch return "";
+ out.appendSlice(arena, ln) catch return "";
+ }
+ return out.items;
+ }
+
+ /// drop all insertion runs whose row is in [r0, r1]
+ fn clearEditsInRange(pane: *Pane, r0: i32, r1: i32) void {
+ var w: usize = 0;
+ for (pane.edits[0..pane.nedits]) |e| {
+ if (e.row >= r0 and e.row <= r1) continue;
+ pane.edits[w] = e;
+ w += 1;
+ }
+ pane.nedits = w;
+ }
+
+ // ---- edit operations (d / c / y / p) ----
+
+ fn normalDelete(p: *Pardes, pane: *Pane) void {
+ if (pane.vsel.active) {
+ const b = vselBounds(pane);
+ p.setYank(p.vselText(pane)); // read the range before the move
+ p.pushUndo(pane);
+ if (pane.file) |*f| {
+ const d = modal.deleteRange(p.gpa, f.content, .{ .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 {
+ pane.vsel.active = false;
+ return;
+ };
+ p.setFileContent(f, d.content);
+ p.gpa.free(d.deleted);
+ pane.cur_row = b.lo_row;
+ pane.cur_col = b.lo_col;
+ clampCursorToFile(pane, f);
+ } else {
+ // terminal: can't delete shell output — only drop typed runs
+ clearEditsInRange(pane, b.lo_row, b.hi_row);
+ pane.cur_row = b.lo_row;
+ pane.cur_col = b.lo_col;
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.vsel.active = false;
+ pane.ensureCursorVisible();
+ }
+ return;
+ }
+ if (pane.msel.active) {
+ const r0 = @min(pane.msel.r0, pane.msel.r1);
+ const r1 = @max(pane.msel.r0, pane.msel.r1);
+ p.pushUndo(pane);
+ if (pane.file) |*f| {
+ const d = modal.deleteLines(p.gpa, f.content, @intCast(@max(0, r0)), @intCast(@max(0, r1))) catch {
+ pane.msel.active = false;
+ return;
+ };
+ p.setFileContent(f, d.content);
+ p.setYank(d.deleted);
+ p.gpa.free(d.deleted);
+ const n = modal.lineCount(f.content);
+ pane.cur_row = @intCast(@min(@as(usize, @intCast(r0)), if (n == 0) 0 else n - 1));
+ pane.cur_col = 0;
+ } else {
+ clearEditsInRange(pane, r0, r1);
+ p.setYank(p.yankRows(pane, r0, r1));
+ }
+ pane.msel.active = false;
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ } else if (pane.file) |*f| {
+ p.pushUndo(pane);
+ const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) };
+ const new = modal.deleteChar(p.gpa, f.content, c) catch return;
+ p.setFileContent(f, new);
+ } else {
+ p.deleteRunCharAtCursor(pane);
+ }
+ }
+
+ /// delete the inserted char under the (visual) cursor from its run, if any
+ fn deleteRunCharAtCursor(p: *Pardes, pane: *Pane) void {
+ for (pane.edits[0..pane.nedits]) |*e| {
+ if (e.row != pane.cur_row or e.len == 0) continue;
+ const vstart = pane.renderedCol(e.row, e.col, 0);
+ if (pane.cur_col < vstart or pane.cur_col >= vstart + @as(i32, e.len)) continue;
+ p.pushUndo(pane);
+ var k: usize = @intCast(pane.cur_col - vstart);
+ while (k + 1 < e.len) : (k += 1) e.text[k] = e.text[k + 1];
+ e.len -= 1;
+ pane.cur_pinned = true;
+ return;
+ }
+ }
+
+ fn normalYank(p: *Pardes, pane: *Pane) void {
+ if (pane.vsel.active) {
+ const b = vselBounds(pane);
+ p.setYank(p.vselText(pane));
+ pane.cur_row = b.lo_row;
+ pane.cur_col = b.lo_col;
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.vsel.active = false;
+ pane.ensureCursorVisible();
+ return;
+ }
+ if (pane.msel.active) {
+ const r0 = @min(pane.msel.r0, pane.msel.r1);
+ const r1 = @max(pane.msel.r0, pane.msel.r1);
+ if (pane.file) |f| {
+ var buf: std.ArrayList(u8) = .empty;
+ const arena = p.scratch.allocator();
+ var r: usize = @intCast(@max(0, r0));
+ const hi: usize = @intCast(@max(0, r1));
+ const lo: usize = @intCast(@max(0, r0));
+ while (r <= hi) : (r += 1) {
+ if (r > lo) buf.append(arena, '\n') catch break;
+ buf.appendSlice(arena, modal.lineSlice(f.content, r)) catch break;
+ }
+ p.setYank(buf.items);
+ } else {
+ p.setYank(p.yankRows(pane, r0, r1));
+ }
+ pane.cur_row = r0;
+ pane.cur_col = 0;
+ pane.msel.active = false;
+ } else if (pane.file) |f| {
+ p.setYank(modal.lineSlice(f.content, @intCast(@max(0, pane.cur_row))));
+ } else {
+ p.setYank(p.yankRows(pane, pane.cur_row, pane.cur_row));
+ }
+ }
+
+ fn normalPaste(p: *Pardes, pane: *Pane) void {
+ const y = p.yank orelse return;
+ p.pushUndo(pane);
+ if (pane.file) |*f| {
+ const new = modal.pasteLineBelow(p.gpa, f.content, @intCast(@max(0, pane.cur_row)), y) catch return;
+ p.setFileContent(f, new);
+ pane.cur_row += 1;
+ pane.cur_col = 0;
+ } else {
+ insertRunText(pane, y);
+ }
+ pane.cur_off = if (pane.runAt(pane.cur_row, pane.cur_col)) |i| pane.edits[i].len else 0;
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ }
+
+ fn normalChange(p: *Pardes, pane: *Pane) void {
+ if (pane.vsel.active) {
+ const b = vselBounds(pane);
+ p.setYank(p.vselText(pane));
+ p.pushUndo(pane);
+ if (pane.file) |*f| {
+ const d = modal.deleteRange(p.gpa, f.content, .{ .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 {
+ pane.vsel.active = false;
+ return;
+ };
+ p.setFileContent(f, d.content);
+ p.gpa.free(d.deleted);
+ pane.cur_row = b.lo_row;
+ pane.cur_col = b.lo_col;
+ clampCursorToFile(pane, f);
+ } else {
+ clearEditsInRange(pane, b.lo_row, b.hi_row);
+ pane.cur_row = b.lo_row;
+ pane.cur_col = b.lo_col;
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.vsel.active = false;
+ pane.ensureCursorVisible();
+ }
+ pane.mode = .insert;
+ pane.pending = 0;
+ return;
+ }
+ if (pane.msel.active) {
+ const r0 = @min(pane.msel.r0, pane.msel.r1);
+ const r1 = @max(pane.msel.r0, pane.msel.r1);
+ p.pushUndo(pane);
+ if (pane.file) |*f| {
+ // keep one empty line at r0, drop the rest; yank the removed
+ var buf: std.ArrayList(u8) = .empty;
+ const arena = p.scratch.allocator();
+ var r: usize = @intCast(@max(0, r0));
+ const hi: usize = @intCast(@max(0, r1));
+ const lo: usize = @intCast(@max(0, r0));
+ while (r <= hi) : (r += 1) {
+ if (r > lo) buf.append(arena, '\n') catch break;
+ buf.appendSlice(arena, modal.lineSlice(f.content, r)) catch break;
+ }
+ p.setYank(buf.items);
+ if (r1 > r0) {
+ const d = modal.deleteLines(p.gpa, f.content, @intCast(@max(0, r0 + 1)), @intCast(@max(0, r1))) catch {
+ pane.msel.active = false;
+ return;
+ };
+ p.setFileContent(f, d.content);
+ p.gpa.free(d.deleted);
+ }
+ const cl = modal.clearLine(p.gpa, f.content, @intCast(@max(0, r0))) catch {
+ pane.msel.active = false;
+ return;
+ };
+ p.setFileContent(f, cl);
+ pane.cur_row = r0;
+ pane.cur_col = 0;
+ } else {
+ clearEditsInRange(pane, r0, r1);
+ p.setYank(p.yankRows(pane, r0, r1));
+ pane.cur_row = r0;
+ pane.cur_col = 0;
+ }
+ pane.msel.active = false;
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.mode = .insert;
+ pane.pending = 0;
+ pane.ensureCursorVisible();
+ } else if (pane.file) |*f| {
+ p.pushUndo(pane);
+ const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) };
+ const new = modal.deleteChar(p.gpa, f.content, c) catch return;
+ p.setFileContent(f, new);
+ pane.mode = .insert;
+ pane.cur_off = 0;
+ pane.pending = 0;
+ } else {
+ p.pushUndo(pane);
+ pane.mode = .insert;
+ pane.cur_off = 0;
+ pane.pending = 0;
+ }
+ }
+
+ // ---- dumb undo/redo: whole-state snapshots, one per edit op ----
+
+ fn setFileContent(p: *Pardes, f: *File, new: []u8) void {
+ p.gpa.free(f.content);
+ f.content = new;
+ f.syntax_dirty = true;
+ }
+
+ fn clampCursorToFile(pane: *Pane, f: *File) void {
+ const n = modal.lineCount(f.content);
+ const row: usize = @min(@as(usize, @intCast(@max(0, pane.cur_row))), if (n == 0) 0 else n - 1);
+ const llen = modal.lineSlice(f.content, row).len;
+ pane.cur_row = @intCast(row);
+ pane.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, pane.cur_col))), llen));
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.vsel.active = false;
+ pane.msel.active = false;
+ pane.ensureCursorVisible();
+ }
+
+ fn snapTerm(pane: *Pane) EditSnap {
+ return .{ .edits = pane.edits, .nedits = pane.nedits, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .cur_off = pane.cur_off };
+ }
+
+ fn editSnapEq(a: EditSnap, b: EditSnap) bool {
+ if (a.nedits != b.nedits) return false;
+ for (a.edits[0..a.nedits], b.edits[0..b.nedits]) |x, y| {
+ if (x.row != y.row or x.col != y.col or x.len != y.len) return false;
+ if (!std.mem.eql(u8, x.text[0..x.len], y.text[0..y.len])) return false;
+ }
+ return true;
+ }
+
+ fn pushUndo(p: *Pardes, pane: *Pane) void {
+ if (pane.file) |*f| {
+ if (f.undo.items.len > 0 and std.mem.eql(u8, f.undo.items[f.undo.items.len - 1], f.content)) return;
+ const snap = p.gpa.dupe(u8, f.content) catch return;
+ f.undo.append(p.gpa, snap) catch {
+ p.gpa.free(snap);
+ return;
+ };
+ if (f.undo.items.len > UNDO_MAX) p.gpa.free(f.undo.orderedRemove(0));
+ for (f.redo.items) |s| p.gpa.free(s);
+ f.redo.clearRetainingCapacity();
+ } else {
+ const snap = snapTerm(pane);
+ if (pane.ed_undo.items.len > 0 and editSnapEq(pane.ed_undo.items[pane.ed_undo.items.len - 1], snap)) return;
+ pane.ed_undo.append(p.gpa, snap) catch return;
+ if (pane.ed_undo.items.len > EDIT_UNDO_MAX) _ = pane.ed_undo.orderedRemove(0);
+ pane.ed_redo.clearRetainingCapacity();
+ }
+ }
+
+ fn doUndo(p: *Pardes, pane: *Pane) void {
+ if (pane.file) |*f| {
+ if (f.undo.items.len == 0) return;
+ const cur = p.gpa.dupe(u8, f.content) catch return;
+ f.redo.append(p.gpa, cur) catch {
+ p.gpa.free(cur);
+ return;
+ };
+ const prev = f.undo.pop() orelse return;
+ p.setFileContent(f, prev);
+ clampCursorToFile(pane, f);
+ return;
+ }
+ if (pane.ed_undo.items.len == 0) return;
+ pane.ed_redo.append(p.gpa, snapTerm(pane)) catch return;
+ restoreTerm(pane, pane.ed_undo.pop() orelse return);
+ }
+
+ fn doRedo(p: *Pardes, pane: *Pane) void {
+ if (pane.file) |*f| {
+ if (f.redo.items.len == 0) return;
+ const cur = p.gpa.dupe(u8, f.content) catch return;
+ f.undo.append(p.gpa, cur) catch {
+ p.gpa.free(cur);
+ return;
+ };
+ const next = f.redo.pop() orelse return;
+ p.setFileContent(f, next);
+ clampCursorToFile(pane, f);
+ return;
+ }
+ if (pane.ed_redo.items.len == 0) return;
+ pane.ed_undo.append(p.gpa, snapTerm(pane)) catch return;
+ restoreTerm(pane, pane.ed_redo.pop() orelse return);
+ }
+
+ fn restoreTerm(pane: *Pane, snap: EditSnap) void {
+ pane.edits = snap.edits;
+ pane.nedits = snap.nedits;
+ pane.cur_row = snap.cur_row;
+ pane.cur_col = snap.cur_col;
+ pane.cur_off = snap.cur_off;
+ pane.cur_pinned = true;
+ pane.vsel.active = false;
+ pane.msel.active = false;
+ pane.ensureCursorVisible();
+ }
+
+ 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);
+ 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;
+
+ // any press that is NOT the middle+left chord abandons a pending
+ // chord argument
+ if (m.kind == .press and
+ !(m.button == .left and p.drag == .select and p.drag.select.button == .middle))
+ {
+ if (p.chord_arg) |a| {
+ p.gpa.free(a);
+ p.chord_arg = null;
+ }
+ }
+
+ switch (m.button) {
+ .none => {}, // hover tracked above; motion has no other meaning
+ .wheel_up, .wheel_down => {
+ if (m.kind != .press) return;
+ const id = hovered orelse return;
+ p.panes[id].?.scrollBy(if (m.button == .wheel_up) -1 else 1);
+ },
+ .left => switch (m.kind) {
+ .press => {
+ // middle+left chord: a left press during a middle select-
+ // drag captures the KEPT left selection as the execute's
+ // argument; the middle drag keeps running to its release.
+ if (p.drag == .select and p.drag.select.button == .middle) {
+ if (p.panes[p.drag.select.id]) |t| {
+ const sl = t.sel[0];
+ if (sl.state == .done) {
+ if (p.chord_arg) |old| p.gpa.free(old);
+ const txt = p.selectionText(t, sl) catch null;
+ p.chord_arg = if (txt) |tx| (p.gpa.dupe(u8, tx) catch null) else null;
+ }
+ }
+ return;
+ }
+ if (mrow < TOPBAR_H) return; // topbar: left click deliberately inert
+ // resize handles first: a pane's own trailing edge (v: the
+ // left column's last col; h: the upper pane's last row)
+ for (0..p.ncol -| 1) |c| {
+ if (mcol == p.col_x[c] + p.col_w[c] -| 1) {
+ p.drag = .{ .border_v = .{ .left_col = c, .cur_x = mcol } };
+ return;
+ }
+ }
+ for (0..p.ncol) |cc| {
+ if (mcol < p.col_x[cc] or mcol >= p.col_x[cc] + p.col_w[cc]) continue;
+ for (0..p.col_n[cc] -| 1) |k| {
+ const r = p.rects[p.col_terms[cc][k]];
+ if (mrow == r.y + r.h -| 1) {
+ p.drag = .{ .border_h = .{ .col = cc, .top_idx = k, .cur_y = mrow } };
+ return;
+ }
+ }
+ }
+ const id = hovered orelse return;
+ const r = p.rects[id];
+ const pane = p.panes[id] orelse return;
+ if (mcol < r.x + GUTTER and mrow < r.y + BOX_H) {
+ p.drag = .{ .move = .{ .id = id, .cur_x = mcol, .cur_y = mrow } };
+ } else if (mcol < r.x + GUTTER) {
+ // gutter scrollbar: a left click scrolls UP to that row
+ p.active = id;
+ pane.scrollBy(-(@as(i32, mrow) - @as(i32, r.y + BOX_H)));
+ } else if (mrow < r.y + BOX_H and pane.image == null) {
+ // left click on the tag row: edit the tail in place
+ p.active = id;
+ p.enterTagEdit(pane, @as(i32, mcol) - @as(i32, r.x + GUTTER));
+ } else {
+ p.active = id;
+ exitTagEdit(pane); // clicking the body leaves tag editing
+ const sc: i32 = @as(i32, mcol) - @as(i32, r.x + GUTTER);
+ const v: i32 = @as(i32, mrow) - @as(i32, r.y);
+ pane.sel[0] = .{ .state = .dragging, .c0 = sc, .c1 = sc, .r0 = v, .r1 = v };
+ p.drag = .{ .select = .{ .id = id, .button = .left } };
+ }
+ },
+ .drag => p.dragUpdate(mcol, mrow),
+ .release => p.dragRelease(.left),
+ .motion => {},
+ },
+ .middle, .right => switch (m.kind) {
+ .press => {
+ if (mrow < TOPBAR_H) {
+ // global tagbar: MIDDLE runs the command under the click
+ // (a stray left click must never Kill)
+ if (m.button == .middle) {
+ const word = wordAtCol(topbar_str, mcol);
+ if (word.len > 0) p.actOnSelection(.middle, p.active, word, null);
+ }
+ return;
+ }
+ const id = hovered orelse return;
+ const r = p.rects[id];
+ const pane = p.panes[id] orelse return;
+ if (mcol < r.x + GUTTER and mrow >= r.y + BOX_H) {
+ // gutter: right scrolls DOWN to here, mirroring left's up;
+ // middle matches left. Right focuses (look lands you
+ // there); middle does not.
+ const local = @as(i32, mrow) - @as(i32, r.y + BOX_H);
+ if (m.button == .right) p.active = id;
+ pane.scrollBy(if (m.button == .right) local else -local);
+ } else if (mcol >= r.x + GUTTER) {
+ if (m.button == .right) p.active = id;
+ const c: i32 = @as(i32, mcol) - @as(i32, r.x + GUTTER);
+ const v: i32 = @as(i32, mrow) - @as(i32, r.y);
+ pane.sel[@intFromEnum(m.button)] = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v };
+ p.drag = .{ .select = .{ .id = id, .button = m.button } };
+ }
+ },
+ .drag => p.dragUpdate(mcol, mrow),
+ .release => p.dragRelease(m.button),
+ .motion => {},
+ },
+ }
+ }
+
+ fn dragUpdate(p: *Pardes, mcol: u16, mrow: u16) void {
+ switch (p.drag) {
+ .border_v => |*d| {
+ const c = d.left_col;
+ if (c + 1 < p.ncol) {
+ const lo = p.col_x[c] + MINW;
+ const hi = p.col_x[c] +| p.col_w[c] +| p.col_w[c + 1] -| MINW;
+ d.cur_x = if (lo <= hi) std.math.clamp(mcol, lo, hi) else mcol;
+ } else d.cur_x = mcol;
+ },
+ .border_h => |*d| {
+ const cc = d.col;
+ const k = d.top_idx;
+ 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]];
+ const lo = a.y + MINH;
+ const hi = a.y +| a.h +| b.h -| MINH;
+ d.cur_y = if (lo <= hi) std.math.clamp(mrow, lo, hi) else mrow;
+ } else d.cur_y = mrow;
+ },
+ .move => |*d| {
+ d.cur_x = mcol;
+ d.cur_y = mrow;
+ },
+ .select => |s| {
+ if (p.panes[s.id]) |pane| {
+ const r = p.rects[s.id];
+ const b = @intFromEnum(s.button);
+ pane.sel[b].c1 = @as(i32, mcol) - @as(i32, r.x + GUTTER);
+ pane.sel[b].r1 = @as(i32, mrow) - @as(i32, r.y);
+ }
+ },
+ .none => {},
+ }
+ }
+
+ 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;
+ 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]);
+ // 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, MINW), @max(@as(f32, MINW), combined - 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];
+ }
+ },
+ .border_h => |d| {
+ const cc = d.col;
+ const k = d.top_idx;
+ if (k + 1 < p.col_n[cc]) {
+ const a = p.panes[p.col_terms[cc][k]] orelse return;
+ const b = p.panes[p.col_terms[cc][k + 1]] orelse return;
+ const ra = p.rects[p.col_terms[cc][k]];
+ const rb = p.rects[p.col_terms[cc][k + 1]];
+ const combined: f32 = @floatFromInt(ra.h + rb.h);
+ var nt: f32 = @floatFromInt((d.cur_y + 1) -| ra.y);
+ nt = std.math.clamp(nt, @as(f32, MINH), @max(@as(f32, MINH), combined - MINH));
+ const pair = a.vweight + b.vweight;
+ a.vweight = pair * (nt / combined);
+ b.vweight = pair - a.vweight;
+ }
+ },
+ .move => |d| {
+ p.moveTerm(d.id, d.cur_x, d.cur_y);
+ // a file moved into the left column evicts a lone unused shell
+ if (p.panes[d.id]) |mt| if (mt.file != null) {
+ if (p.layoutFindTerm(d.id)) |f| if (f.col == 0)
+ p.evictLonePristineTty(0, d.id);
+ };
+ },
+ .select => |s| {
+ const pane = p.panes[s.id] orelse return;
+ const b = @intFromEnum(s.button);
+ pane.sel[b].state = .done;
+ if (s.button == .left) {
+ // keep a dragged selection highlighted; a plain click clears
+ // it. Either way pin the cursor + enter normal mode on text
+ // panes (tty keeps the mouse usable without dropping modes).
+ const sl = pane.sel[0];
+ const dragged = sl.c0 != sl.c1 or sl.r0 != sl.r1;
+ if (!dragged) pane.sel[0].state = .none;
+ const body_vis = sl.r1 - @as(i32, BOX_H);
+ if (body_vis >= 0 and pane.mode != .tty) {
+ pane.cur_row = body_vis + pane.scroll();
+ pane.cur_col = if (pane.file != null) @max(0, sl.c1 - @as(i32, PREFIX_W)) else sl.c1;
+ pane.cur_off = if (pane.runAt(pane.cur_row, pane.cur_col)) |i| pane.edits[i].len else 0;
+ pane.cur_pinned = true;
+ pane.mode = .normal;
+ pane.msel.active = false;
+ pane.pending = 0;
+ }
+ } else {
+ // acme execute (middle) / look (right): a no-drag click
+ // expands to the file-ish word first; a captured chord
+ // argument rides along and is consumed here.
+ p.expandSel(pane, &pane.sel[b]);
+ const txt = 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);
+ if (txt) |tx| p.actOnSelection(s.button, s.id, tx, arg);
+ }
+ },
+ .none => {},
+ }
+ }
+
+ /// 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",
+ else => null,
+ };
+ };
+ if (bytes) |b| p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(b) } });
+ }
+
+ // ---- layout surgery ----
+
+ fn layoutFindTerm(p: *Pardes, id: usize) ?struct { col: usize, idx: usize } {
+ for (0..p.ncol) |c| {
+ for (0..p.col_n[c]) |k| {
+ if (p.col_terms[c][k] == id) return .{ .col = c, .idx = k };
+ }
+ }
+ return null;
+ }
+
+ fn layoutInsert(p: *Pardes, c: usize, idx: usize, id: usize) void {
+ var k = p.col_n[c];
+ while (k > idx) : (k -= 1) p.col_terms[c][k] = p.col_terms[c][k - 1];
+ p.col_terms[c][idx] = id;
+ p.col_n[c] += 1;
+ }
+
+ /// Remove from the layout. An emptied column hands its width to a neighbor
+ /// (else every surviving column reflows sideways) before shifting down.
+ fn layoutRemove(p: *Pardes, id: usize) void {
+ const f = p.layoutFindTerm(id) orelse return;
+ const c = f.col;
+ var k = f.idx;
+ 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];
+ var j = c;
+ while (j + 1 < p.ncol) : (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.ncol -= 1;
+ }
+ }
+
+ fn layoutAppendColumn(p: *Pardes, id: usize) void {
+ if (p.ncol >= MAX_COLS) return;
+ const c = p.ncol;
+ p.col_weight[c] = 1;
+ p.col_terms[c][0] = id;
+ p.col_n[c] = 1;
+ p.ncol += 1;
+ }
+
+ /// Hand a dying pane's vertical weight to ONE sibling (the pane above, or
+ /// below for the topmost) so the rest of the column keeps its sizes — the
+ /// deletion mirror of splitBelow. Call while `id` is still in the layout.
+ fn absorbVWeight(p: *Pardes, id: usize) void {
+ const f = p.layoutFindTerm(id) orelse return;
+ if (p.col_n[f.col] <= 1) return;
+ const gone = p.panes[id] orelse return;
+ const sib = if (f.idx > 0) p.col_terms[f.col][f.idx - 1] else p.col_terms[f.col][f.idx + 1];
+ if (p.panes[sib]) |s| s.vweight += gone.vweight;
+ }
+
+ /// Open a window BELOW `src` (acme-style) without rebalancing the column:
+ /// shrink ONLY src to its content height (cursor row kept visible) and hand
+ /// the freed rows to `nw` — together they fill src's old slot.
+ fn splitBelow(p: *Pardes, src_id: usize, nw: *Pane) void {
+ const src = p.panes[src_id] orelse return;
+ const src_h = p.rects[src_id].h;
+ const body: u16 = if (src_h > BOX_H) src_h - BOX_H else 1;
+ const cur: u16 = if (src.file != null) body / 2 else @as(u16, @intCast(src.vt.screens.active.cursor.y)) + 1;
+ const keep = std.math.clamp(cur, 1, @max(1, body -| 1));
+ const w = src.vweight;
+ src.vweight = w * @as(f32, @floatFromInt(BOX_H + keep)) / @as(f32, @floatFromInt(src_h));
+ nw.vweight = w - src.vweight;
+ }
+
+ /// 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.nedits != 0) 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 {
+ var n_tty: usize = 0;
+ var tty_id: usize = 0;
+ for (0..p.col_n[col]) |k| {
+ const cid = p.col_terms[col][k];
+ if (p.panes[cid]) |ct| if (ct.file == null) {
+ n_tty += 1;
+ tty_id = cid;
+ };
+ }
+ if (n_tty != 1) return;
+ const tt = p.panes[tty_id] orelse return;
+ if (!isPristineTty(tt)) return;
+ p.absorbVWeight(tty_id);
+ p.layoutRemove(tty_id);
+ p.deinitPane(tt);
+ p.panes[tty_id] = null;
+ if (p.active == tty_id) p.active = keep_id;
+ }
+
+ /// a terminal already in `dir`, else a fresh shell there at the bottom of
+ /// the rightmost column. Backs middle-click send from a file pane. Does NOT
+ /// focus (execute keeps you where you were; look focuses).
+ fn ttyForDir(p: *Pardes, dir: []const u8) ?usize {
+ for (p.panes, 0..) |slot, i| if (slot) |tt| {
+ if (tt.file != null or tt.image != null) continue;
+ if (std.mem.eql(u8, tt.cwdSlice(), dir)) return i;
+ };
+ const free = p.freeSlot() orelse return null;
+ const nt = p.newShell(free, dir) catch return null;
+ nt.greet = false;
+ const rc = if (p.ncol > 0) p.ncol - 1 else 0;
+ p.layoutInsert(rc, p.col_n[rc], free);
+ return free;
+ }
+
+ /// executing at a prompt with typed runs below the prompt: pad the output
+ /// area with newlines so the command's output doesn't overwrite the runs
+ fn padOutputBelowEdits(p: *Pardes, id: usize) void {
+ const pane = p.panes[id] orelse return;
+ if (!pane.isTerminal() or pane.nedits == 0) return;
+ if (!pane.vt.cursorIsAtPrompt()) return;
+ var max_row: i32 = std.math.minInt(i32);
+ for (pane.edits[0..pane.nedits]) |e| {
+ if (e.len > 0 and e.row > max_row) max_row = e.row;
+ }
+ if (max_row == std.math.minInt(i32)) return;
+ const cursor_abs: i32 = pane.scroll() + @as(i32, @intCast(pane.vt.screens.active.cursor.y));
+ const pad = std.math.clamp(max_row - cursor_abs, 0, @as(i32, pane.rows));
+ var i: i32 = 0;
+ while (i < pad) : (i += 1) p.emitWrite(id, "\r");
+ }
+
+ // ---- doc panes ----
+
+ fn openFileView(p: *Pardes, id: usize, path: []const u8, line: usize) !*Pane {
+ const content = try look.readFile(p.gpa, path);
+ errdefer p.gpa.free(content);
+ const path_copy = try p.gpa.dupe(u8, path);
+ errdefer p.gpa.free(path_copy);
+ const pane = try p.newDocPane(id);
+ const total = fileLineCount(content);
+ const scroll: usize = if (line > 0 and line <= total) line - 1 else 0;
+ pane.file = .{ .path = path_copy, .content = content, .scroll = scroll };
+ pane.cur_pinned = true;
+ pane.cur_row = @intCast(scroll);
+ return pane;
+ }
+
+ fn openTutorView(p: *Pardes, id: usize) !*Pane {
+ const content = try p.gpa.dupe(u8, tutor_text);
+ errdefer p.gpa.free(content);
+ const path = try p.gpa.dupe(u8, "/Tutor");
+ errdefer p.gpa.free(path);
+ const pane = try p.newDocPane(id);
+ pane.file = .{ .path = path, .content = content };
+ pane.cur_pinned = true;
+ return pane;
+ }
+
+ fn openImageView(p: *Pardes, id: usize, path: []const u8, petscii: bool) !*Pane {
+ const path_copy = try p.gpa.dupe(u8, path);
+ errdefer p.gpa.free(path_copy);
+ const pane = try p.newDocPane(id);
+ pane.image = .{ .path = path_copy, .petscii = petscii };
+ return pane;
+ }
+
+ /// stack a fresh doc pane at the top of the LEFT column (acme convention:
+ /// files left, terminals right) with a fair share, then evict a leftover
+ /// pristine shell
+ fn stackDocLeft(p: *Pardes, free: usize, nt: *Pane) void {
+ const lc = 0;
+ const ln = p.col_n[lc];
+ if (ln > 0) {
+ var vsum: f32 = 0;
+ for (0..ln) |i| if (p.panes[p.col_terms[lc][i]]) |pp| {
+ vsum += pp.vweight;
+ };
+ nt.vweight = vsum / @as(f32, @floatFromInt(ln));
+ }
+ p.layoutInsert(lc, 0, free);
+ p.active = free;
+ p.evictLonePristineTty(lc, free);
+ }
+
+ // ---- the ONE dispatcher: look (right/Enter) and execute (middle/Tab) ----
+
+ fn actOnSelection(p: *Pardes, button: Mouse.Button, id: usize, txt: []const u8, arg: ?[]const u8) void {
+ const pane = p.panes[id] orelse return;
+ // image tagline toggles (execute), before the builtin/run dispatch
+ if (button == .middle) if (pane.image) |*iv| {
+ const w = std.mem.trim(u8, txt, " \t\r\n");
+ if (std.mem.eql(u8, w, "Petscii")) {
+ iv.petscii = !iv.petscii;
+ return;
+ }
+ if (std.mem.eql(u8, w, "C64") or std.mem.eql(u8, w, "Term")) {
+ iv.pmode = if (iv.pmode == .commodore) .terminal else .commodore;
+ return;
+ }
+ if (std.mem.eql(u8, w, "Ascii")) {
+ iv.ascii = !iv.ascii;
+ return;
+ }
+ };
+ if (button == .right) {
+ var realbuf: [4096]u8 = undefined;
+ var cwdbuf: [1024]u8 = undefined;
+ const cwd: []const u8 = if (pane.file) |f|
+ (std.fs.path.dirname(f.path) orelse "/")
+ else blk: {
+ const c = pane.cwdSlice();
+ @memcpy(cwdbuf[0..c.len], c);
+ break :blk cwdbuf[0..c.len];
+ };
+ switch (look.resolve(txt, cwd, &realbuf)) {
+ .none => {},
+ .url => |u| p.emit(.{ .open_link = .from(u) }),
+ .dir => |dir| {
+ // focus an existing terminal on this dir, else fork one below
+ for (p.panes, 0..) |slot, i| {
+ if (slot) |tt| if (tt.isTerminal() and std.mem.eql(u8, tt.cwdSlice(), dir)) {
+ p.active = i;
+ p.emitWrite(i, "ls\r");
+ return;
+ };
+ }
+ const free = p.freeSlot() orelse return;
+ const nt = p.newShell(free, dir) catch return;
+ nt.greet = true;
+ const f = p.layoutFindTerm(id).?;
+ p.layoutInsert(f.col, f.idx + 1, free);
+ p.splitBelow(id, nt);
+ p.active = free;
+ },
+ .file => |target| {
+ // focus an existing pane on this path (rescrolled), else open
+ for (p.panes, 0..) |slot, i| {
+ if (slot) |tt| if (tt.file) |*ff| if (std.mem.eql(u8, ff.path, target.path)) {
+ p.active = i;
+ if (target.line > 0 and target.line <= fileLineCount(ff.content)) {
+ const next = target.line - 1;
+ if (next != ff.scroll) {
+ ff.scroll = next;
+ ff.syntax_dirty = true;
+ }
+ }
+ return;
+ };
+ }
+ const free = p.freeSlot() orelse return;
+ const nt = p.openFileView(free, target.path, target.line) catch return;
+ p.placeDoc(id, free, nt);
+ },
+ .image => |target| {
+ for (p.panes, 0..) |slot, i| {
+ if (slot) |tt| if (tt.image) |iv| if (std.mem.eql(u8, iv.path, target.path)) {
+ p.active = i;
+ return;
+ };
+ }
+ const free = p.freeSlot() orelse return;
+ // petscii by default when the host has no kitty graphics
+ const nt = p.openImageView(free, target.path, !p.kitty_ok) catch return;
+ p.placeDoc(id, free, nt);
+ },
+ }
+ return;
+ }
+ switch (onSelect(button, txt)) {
+ .none => {},
+ .debug => p.show_debug = !p.show_debug,
+ .colors => p.colors_on = !p.colors_on,
+ .nextcolor => p.theme_idx = (p.theme_idx + 1) % themes.len,
+ .dump => p.dumpState() catch {},
+ .kill => {
+ p.quit = true;
+ p.emit(.quit);
+ },
+ .save => {
+ if (pane.file != null) p.emit(.{ .save_file = .{ .pane = @intCast(id) } });
+ },
+ .send => {
+ // terminal: run in itself. file pane: run in a shell in the
+ // file's directory (found by cwd, else freshly forked).
+ const cmd = std.mem.trim(u8, txt, " \t\r\n");
+ const dst: ?usize = if (pane.file) |f|
+ p.ttyForDir(std.fs.path.dirname(f.path) orelse "/")
+ else
+ id;
+ if (dst) |di| {
+ p.padOutputBelowEdits(di);
+ p.emitWrite(di, cmd);
+ // a chord argument (kept left selection) rides as a
+ // trailing CLI argument
+ if (arg) |a| {
+ const at = std.mem.trim(u8, a, " \t\r\n");
+ if (at.len > 0) {
+ p.emitWrite(di, " ");
+ p.emitWrite(di, at);
+ }
+ }
+ p.emitWrite(di, "\r");
+ }
+ },
+ .newcol => {
+ const free = p.freeSlot() orelse return;
+ if (p.ncol >= MAX_COLS) return;
+ const nt = p.newShell(free, "") catch return;
+ nt.greet = true;
+ p.layoutAppendColumn(free);
+ p.active = free;
+ },
+ .del => {
+ p.absorbVWeight(id);
+ p.layoutRemove(id);
+ p.deinitPane(pane);
+ p.panes[id] = null;
+ if (p.active == id) p.active = for (p.panes, 0..) |sl, i| {
+ if (sl != null) break i;
+ } else {
+ p.quit = true;
+ p.emit(.quit);
+ return;
+ };
+ },
+ .delcol => {
+ const f = p.layoutFindTerm(id) orelse return;
+ var ids: [MAX_PANES]usize = undefined;
+ const nids = p.col_n[f.col];
+ for (0..nids) |k| ids[k] = p.col_terms[f.col][k];
+ for (ids[0..nids]) |tid| {
+ if (p.panes[tid]) |tt| {
+ p.layoutRemove(tid);
+ p.deinitPane(tt);
+ p.panes[tid] = null;
+ }
+ }
+ if (p.panes[p.active] == null) p.active = for (p.panes, 0..) |sl, i| {
+ if (sl != null) break i;
+ } else {
+ p.quit = true;
+ p.emit(.quit);
+ return;
+ };
+ },
+ .tutor => {
+ const free = p.freeSlot() orelse return;
+ const nt = p.openTutorView(free) catch return;
+ p.stackDocLeft(free, nt);
+ },
+ }
+ }
+
+ /// place a fresh doc pane: split an existing doc (the source if it's one,
+ /// else the first found), or take the left column when no doc exists yet
+ fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void {
+ var src_id: ?usize = null;
+ if (p.panes[from_id]) |src| if (src.file != null or src.image != null) {
+ src_id = from_id;
+ };
+ if (src_id == null) for (p.panes, 0..) |sl, i| {
+ if (sl) |pp| if ((pp.file != null or pp.image != null) and i != free) {
+ src_id = i;
+ break;
+ };
+ };
+ if (src_id) |sid| {
+ const sf = p.layoutFindTerm(sid).?;
+ p.layoutInsert(sf.col, sf.idx + 1, free);
+ p.splitBelow(sid, nt);
+ p.active = free;
+ } else {
+ p.stackDocLeft(free, nt);
+ }
+ }
+
+ // ---- dump / load (acme-style: `pardes -l state.zon` restores a session) ----
+
+ fn dumpState(p: *Pardes) !void {
+ const arena = p.scratch.allocator();
+ var slot_to_pane: [MAX_PANES]?usize = @splat(null);
+ var panes: std.ArrayList(dump.Pane) = .empty;
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ slot_to_pane[id] = panes.items.len;
+ const pfx = try p.tagPrefix(pane);
+ const tag = try std.fmt.allocPrint(arena, "{s}{s}", .{ pfx, curTail(pane) });
+ const body = try p.bodyText(arena, pane);
+ const scroll: usize = @intCast(@max(0, pane.scroll()));
+ 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),
+ },
+ } 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, runs spliced
+ const full = try pane.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} });
+ var stream_text: std.ArrayList(u8) = .empty;
+ var lines = std.mem.splitAny(u8, full, "\n");
+ var prompts = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null);
+ var row: usize = 0;
+ while (lines.next()) |raw| : (row += 1) {
+ if (row > 0) try stream_text.append(arena, '\n');
+ const is_prompt = if (pane.mode != .tty)
+ if (prompts.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false
+ else
+ false;
+ const line = if (is_prompt) "" else raw;
+ if (pane.mode == .tty) {
+ try stream_text.appendSlice(arena, line);
+ } else {
+ try spliceRow(arena, &stream_text, pane, @intCast(row), line);
+ }
+ }
+ 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_text.items,
+ .stream_b64 = try dump.encodeBytes(arena, pane.tty_stream.items),
+ .cursor = .{
+ .col = pane.vt.screens.active.cursor.x,
+ .row = pane.vt.screens.active.cursor.y,
+ },
+ },
+ };
+ };
+ try panes.append(arena, dp);
+ }
+
+ var columns: std.ArrayList(dump.Column) = .empty;
+ for (0..p.ncol) |c| {
+ var ids: std.ArrayList(usize) = .empty;
+ for (0..p.col_n[c]) |k| {
+ if (slot_to_pane[p.col_terms[c][k]]) |compact| try ids.append(arena, compact);
+ }
+ if (ids.items.len > 0)
+ try columns.append(arena, .{ .weight = p.col_weight[c], .panes = ids.items });
+ }
+
+ const state: dump.State = .{
+ .screen = .{ .cols = p.screen_w, .rows = p.screen_h },
+ .active = slot_to_pane[p.active] orelse 0,
+ .topbar = topbar_str,
+ .theme = p.theme().name,
+ .columns = columns.items,
+ .panes = panes.items,
+ };
+ try dump.validate(state);
+ var out: std.Io.Writer.Allocating = .init(p.gpa);
+ errdefer out.deinit();
+ try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer);
+ if (p.dump_out) |d| p.gpa.free(d);
+ p.dump_out = try out.toOwnedSlice();
+ p.emit(.write_dump);
+ }
+
+ /// Initialize from another instance's dump: panes reconstructed (terminals
+ /// by replaying their raw VT streams into fresh emulators), no spawns —
+ /// loaded terminals are dead replays, scrollable and selectable.
+ pub fn initFromDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8) !*Pardes {
+ const p = try gpa.create(Pardes);
+ p.* = .{ .gpa = gpa, .opts = opts, .screen_w = opts.cols, .screen_h = opts.rows, .scratch = .init(gpa) };
+ errdefer p.deinit();
+ const st = try dump.readZon(gpa, zon_bytes, "load");
+ defer dump.free(gpa, st);
+
+ for (themes, 0..) |t, i| {
+ if (std.mem.eql(u8, t.name, st.theme)) p.theme_idx = i;
+ }
+ for (st.panes, 0..) |src, i| {
+ if (i >= MAX_PANES) break;
+ switch (src.kind) {
+ .terminal => {
+ const t = src.terminal.?;
+ const pane = try gpa.create(Pane);
+ pane.* = .{
+ .vt = try ghostty_vt.Terminal.init(gpa, .{
+ .cols = @max(1, src.cols),
+ .rows = @max(1, src.rows),
+ .max_scrollback = 16 * 1024 * 1024,
+ }),
+ .stream = undefined,
+ .cols = @max(1, src.cols),
+ .rows = @max(1, src.rows),
+ };
+ pane.stream = pane.vt.vtStream();
+ pane.stream.handler.effects.write_pty = ptyReport;
+ pane.stream.handler.effects.device_attributes = ptyDeviceAttrs;
+ p.panes[i] = pane;
+ if (t.stream_b64.len > 0) {
+ const bytes = try dump.decodeBytes(p.scratch.allocator(), t.stream_b64);
+ pane.tty_stream.appendSlice(gpa, bytes) catch {};
+ 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)) });
+ }
+ p.setCwd(i, t.cwd);
+ if (std.mem.startsWith(u8, src.tag, "sy ")) 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);
+ pane.file = .{ .path = path, .content = content, .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);
+ p.restoreTail(pane, src.tag);
+ },
+ .image => {
+ const im = src.image.?;
+ const path = try gpa.dupe(u8, im.path);
+ errdefer gpa.free(path);
+ const pane = try p.newDocPane(i);
+ const raw: []u8 = if (im.bytes_b64.len > 0)
+ dump.decodeBytes(gpa, im.bytes_b64) catch &.{}
+ else
+ &.{};
+ pane.image = .{ .path = path, .petscii = true, .raw = raw };
+ pane.cols = @max(1, src.cols);
+ pane.rows = @max(1, src.rows);
+ },
+ }
+ p.panes[i].?.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;
+ 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;
+ }
+ p.active = @min(st.active, MAX_PANES - 1);
+ p.sync();
+ return p;
+ }
+
+ /// restore a custom tag tail from a saved tag: whatever follows the live
+ /// prefix, unless it's just the defaults
+ fn restoreTail(p: *Pardes, pane: *Pane, saved_tag: []const u8) void {
+ const pfx = p.tagPrefix(pane) catch return;
+ if (!std.mem.startsWith(u8, saved_tag, pfx)) return;
+ const rest = saved_tag[pfx.len..];
+ const default_tail: []const u8 = if (pane.file != null) file_pane_tail else pane_tail;
+ if (std.mem.eql(u8, rest, default_tail)) return;
+ pane.tag_tail.appendSlice(p.gpa, rest) catch return;
+ pane.tag_init = true;
+ }
+
+ fn removePane(p: *Pardes, id: usize) void {
+ const pane = p.panes[id] orelse return;
+ p.absorbVWeight(id);
+ p.layoutRemove(id);
+ p.deinitPane(pane);
+ p.panes[id] = null;
+ if (p.active == id) p.active = for (p.panes, 0..) |slot, i| {
+ if (slot != null) break i;
+ } else {
+ p.quit = true;
+ p.emit(.quit);
+ return;
+ };
+ }
+
+ /// Recompute geometry, push grid-size changes to each emulator + pty, fire
+ /// deferred greetings. The mirror of the prototype's loop epilogue.
+ fn sync(p: *Pardes) void {
+ p.computeGeom();
+ for (&p.panes, 0..) |*slot, id| {
+ const pane = slot.* orelse continue;
+ if (pane.reply_len > 0) {
+ var off: u16 = 0;
+ while (off < pane.reply_len) {
+ const n = @min(pane.reply_len - off, 64);
+ p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(pane.reply[off .. off + n]) } });
+ off += n;
+ }
+ pane.reply_len = 0;
+ }
+ const r = p.rects[id];
+ const cols = @max(1, r.w -| GUTTER);
+ const rows = @max(1, r.h -| BOX_H); // the tag steals the top row
+ if (cols != pane.cols or rows != pane.rows) {
+ // doc panes have no pty/emulator grid to reflow; just record
+ // the size so bodyText renders the right number of rows
+ if (pane.isTerminal()) {
+ pane.vt.resize(p.gpa, cols, rows) catch {};
+ p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } });
+ }
+ 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.items.len > 0) {
+ 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;
+ }
+ }
+ }
+
+ fn computeGeom(p: *Pardes) void {
+ if (p.ncol == 0) return;
+ var wsum: f32 = 0;
+ for (0..p.ncol) |c| wsum += p.col_weight[c];
+ if (wsum <= 0) wsum = 1;
+
+ var x: u16 = 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 cw: u16 = if (last) (p.screen_w -| x) else @max(1, @as(u16, @intFromFloat(@round(fw))));
+ p.col_x[c] = x;
+ p.col_w[c] = cw;
+
+ var vsum: f32 = 0;
+ for (0..p.col_n[c]) |k| {
+ if (p.panes[p.col_terms[c][k]]) |pane| vsum += pane.vweight;
+ }
+ if (vsum <= 0) vsum = 1;
+
+ var y: u16 = TOPBAR_H;
+ const avail_h = p.screen_h -| TOPBAR_H;
+ for (0..p.col_n[c]) |k| {
+ const id = p.col_terms[c][k];
+ const pane = p.panes[id] orelse continue;
+ const lastk = k + 1 == p.col_n[c];
+ const fh = @as(f32, @floatFromInt(avail_h)) * pane.vweight / vsum;
+ const ch: u16 = if (lastk) (p.screen_h -| y) else @max(1, @as(u16, @intFromFloat(@round(fh))));
+ p.rects[id] = .{ .x = x, .y = y, .w = cw, .h = ch };
+ y +|= ch;
+ }
+ x +|= cw;
+ }
+ }
+
+ fn theme(p: *const Pardes) *const Theme {
+ return &themes[p.theme_idx];
+ }
+
+ // ---- render: build the canonical surface ----
+
+ /// re-highlight the visible window of any file whose syntax went stale
+ /// (edit, scroll, load) — visible-range-first so big files stay snappy
+ fn refreshDirtyHighlights(p: *Pardes) void {
+ for (p.panes) |slot| {
+ const pane = slot orelse continue;
+ if (pane.file == null) continue;
+ const f = &pane.file.?;
+ if (!f.syntax_dirty) continue;
+ if (!p.colors_on) {
+ if (f.highlights.len > 0) p.gpa.free(f.highlights);
+ f.highlights = &.{};
+ f.highlight_start = 0;
+ f.syntax_dirty = false;
+ continue;
+ }
+ const start_row = f.scroll -| SYNTAX_CONTEXT_BEFORE_ROWS;
+ const start = modal.lineStartOffset(f.content, start_row);
+ const end_row = start_row + SYNTAX_CONTEXT_BEFORE_ROWS + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS;
+ const end = @max(start, modal.lineStartOffset(f.content, end_row));
+ const new_highlights = syntax.highlightFileRange(p.gpa, f.path, f.content, start, end) catch {
+ f.syntax_dirty = false;
+ continue;
+ };
+ if (f.highlights.len > 0) p.gpa.free(f.highlights);
+ f.highlights = new_highlights;
+ f.highlight_start = if (f.highlights.len > 0) start else 0;
+ f.syntax_dirty = false;
+ }
+ }
+
+ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
+ p.refreshDirtyHighlights();
+ const s = &p.surface;
+ const ncells = @as(usize, p.screen_w) * p.screen_h;
+ if (s.cells.len != ncells) {
+ p.gpa.free(s.cells);
+ s.cells = try p.gpa.alloc(Cell, ncells);
+ }
+ s.cols = p.screen_w;
+ s.rows = p.screen_h;
+ s.cursor = null;
+ s.images = @splat(null);
+ s.nimages = 0;
+ const th = p.theme();
+ // gaps between panes read as chrome (scrollbar track), not raw default
+ s.fill(0, 0, s.cols, s.rows, .{ .bg = .{ .rgb = th.scroll_track } });
+
+ for (&p.panes, 0..) |*slot, id| {
+ const pane = slot.* orelse continue;
+ try p.renderPane(arena, pane, p.rects[id], id == p.active);
+ }
+
+ // global tagbar: full width, top row
+ s.fill(0, 0, s.cols, TOPBAR_H, .{ .bg = .{ .rgb = th.tag_bg } });
+ _ = s.print(0, 0, s.cols, topbar_str, .{ .fg = .{ .rgb = th.tag_fg }, .bg = .{ .rgb = th.tag_bg } });
+
+ // resize-handle hint / drag previews: a dash overlay that keeps the
+ // underlying colors (border drags + hover), or the move indicator
+ switch (p.drag) {
+ .border_v => |d| {
+ var row: u16 = TOPBAR_H;
+ while (row < s.rows) : (row += 1) s.overlayDash(d.cur_x, row, "╎");
+ },
+ .border_h => |d| {
+ var col = p.col_x[d.col];
+ while (col < p.col_x[d.col] + p.col_w[d.col]) : (col += 1) s.overlayDash(col, d.cur_y, "╌");
+ },
+ .move => |d| {
+ if (p.movePlacement(d.id, d.cur_x, d.cur_y)) |placement| {
+ var col: u16 = p.col_x[placement.preview_col];
+ while (col < p.col_x[placement.preview_col] + p.col_w[placement.preview_col]) : (col += 1) {
+ s.set(col, placement.row, "╌", .{ .fg = .{ .rgb = gray.mid } });
+ }
+ s.set(d.cur_x, placement.row, "▌", .{ .fg = .{ .rgb = gray.mid } });
+ }
+ },
+ .none, .select => {
+ // hover hints on a pane's own trailing edge
+ var c: usize = 0;
+ while (c + 1 < p.ncol) : (c += 1) {
+ if (p.hover_col == p.col_x[c] + p.col_w[c] -| 1) {
+ var row: u16 = TOPBAR_H;
+ while (row < s.rows) : (row += 1) s.overlayDash(p.hover_col, row, "╎");
+ }
+ }
+ for (0..p.ncol) |cc| {
+ if (p.hover_col < p.col_x[cc] or p.hover_col >= p.col_x[cc] + p.col_w[cc]) continue;
+ for (0..p.col_n[cc] -| 1) |k| {
+ const r = p.rects[p.col_terms[cc][k]];
+ if (p.hover_row == r.y + r.h -| 1) {
+ var col = p.col_x[cc];
+ while (col < p.col_x[cc] + p.col_w[cc]) : (col += 1) s.overlayDash(col, p.hover_row, "╌");
+ }
+ }
+ }
+ },
+ }
+
+ // debug overlay: a small stats box drawn last, top-right ("Debug")
+ if (p.show_debug) if (p.panes[p.active]) |at| {
+ var nlive: usize = 0;
+ for (p.panes) |slot| {
+ if (slot != null) nlive += 1;
+ }
+ var sb_off: usize = undefined;
+ var sb_total: usize = undefined;
+ if (at.file) |f| {
+ sb_off = f.scroll;
+ sb_total = fileLineCount(f.content);
+ } else {
+ const sb = at.vt.screens.active.pages.scrollbar();
+ sb_off = sb.offset;
+ sb_total = sb.total;
+ }
+ var ov_buf: [192]u8 = undefined;
+ const text = std.fmt.bufPrint(
+ &ov_buf,
+ "pardes resizes={d}\nterms {d} cols {d}\nactive #{d} {d}x{d}\nscroll {d}/{d}\npinch {d:6.4}\ntscroll {d:7.4}",
+ .{ p.resize_count, nlive, p.ncol, p.active, at.cols, at.rows, sb_off, sb_total, p.ov_pinch_scale, p.ov_touch_scroll_delta },
+ ) catch "";
+ const ow: u16 = 30;
+ const oh: u16 = 8;
+ const ox: u16 = if (s.cols > ow) s.cols - ow else 0;
+ if (ox + ow <= s.cols and TOPBAR_H + oh <= s.rows) {
+ // vaxis single_rounded border ring, default style
+ s.set(ox, TOPBAR_H, "╭", .{});
+ s.set(ox + ow - 1, TOPBAR_H, "╮", .{});
+ s.set(ox, TOPBAR_H + oh - 1, "╰", .{});
+ s.set(ox + ow - 1, TOPBAR_H + oh - 1, "╯", .{});
+ var bx = ox + 1;
+ while (bx < ox + ow - 1) : (bx += 1) {
+ s.set(bx, TOPBAR_H, "─", .{});
+ s.set(bx, TOPBAR_H + oh - 1, "─", .{});
+ }
+ var by = TOPBAR_H + 1;
+ while (by < TOPBAR_H + oh - 1) : (by += 1) {
+ s.set(ox, by, "│", .{});
+ s.set(ox + ow - 1, by, "│", .{});
+ }
+ s.fill(ox + 1, TOPBAR_H + 1, ow - 2, oh - 2, .{ .bg = .{ .rgb = gray.bar } });
+ var lines = std.mem.splitScalar(u8, text, '\n');
+ var ly: u16 = TOPBAR_H + 1;
+ while (lines.next()) |line| : (ly += 1) {
+ if (ly >= TOPBAR_H + oh - 1) break;
+ _ = s.print(ox + 1, ly, ow - 2, line, .{ .fg = .{ .rgb = gray.txt } });
+ }
+ }
+ };
+ return s;
+ }
+
+ fn renderPane(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, active: bool) !void {
+ if (r.w <= GUTTER or r.h == 0) return;
+ const s = &p.surface;
+ const th = p.theme();
+ const tx = r.x + GUTTER; // text area (tag + body), right of the gutter
+ const tw = r.w - GUTTER;
+
+ // text area resets to terminal-default cells (vaxis clear semantics);
+ // light themes paint the page over it.
+ s.clearRect(tx, r.y, tw, r.h);
+ if (th.bg) |bg| s.fill(tx, r.y, tw, r.h, .{ .bg = .{ .rgb = bg } });
+
+ // tag: live prefix (mode + cwd/path) + editable tail, on a dim bar
+ const tag_style: CellStyle = .{ .fg = .{ .rgb = th.tag_fg }, .bg = .{ .rgb = th.tag_bg } };
+ s.fill(tx, r.y, tw, BOX_H, .{ .bg = .{ .rgb = th.tag_bg } });
+ const pfx = try p.tagPrefix(pane);
+ const tag = try std.fmt.allocPrint(arena, "{s}{s}", .{ pfx, curTail(pane) });
+ _ = s.print(tx, r.y, tw, tag, tag_style);
+ // tag-tail char selection highlight (helix v/x), inclusive [lo, hi]
+ if (pane.tag_edit and pane.tag_sel) {
+ const b = tagSelBounds(pane);
+ var col: usize = pfx.len + b.lo;
+ const end: usize = pfx.len + b.hi;
+ while (col <= end and col < tw) : (col += 1) {
+ const cell = s.at(tx + @as(u16, @intCast(col)), r.y);
+ cell.default = false;
+ cell.style.bg = .{ .rgb = msel_bg };
+ cell.style.fg = .{ .rgb = msel_fg };
+ }
+ }
+ // cursor while editing the tail: prefix length + column within it
+ if (active and pane.tag_edit) {
+ const cx: usize = pfx.len + pane.tag_col;
+ if (cx < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(cx)), .y = r.y };
+ }
+
+ // 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);
+ const box_bg2 = if (active) accent else accent_dim;
+ s.fill(r.x, r.y, GUTTER, BOX_H, .{ .bg = .{ .rgb = box_bg2 } });
+ if (r.h > BOX_H) s.fill(r.x, r.y + BOX_H, GUTTER, r.h - BOX_H, .{ .bg = .{ .rgb = th.scroll_track } });
+ return;
+ }
+
+ // body: emulator rows, prompt rows hidden outside tty mode
+ const body_h = if (r.h > BOX_H) r.h - BOX_H else 0;
+ const body_style: CellStyle = .{
+ .fg = if (th.fg) |c| .{ .rgb = c } else .default,
+ .bg = if (th.bg) |c| .{ .rgb = c } else .default,
+ };
+ const body = try p.bodyText(arena, pane);
+ var it = std.mem.splitScalar(u8, body, '\n');
+ var i: u16 = 0;
+ while (it.next()) |line| : (i += 1) {
+ if (i >= body_h) break;
+ _ = s.print(tx, r.y + BOX_H + i, tw, line, body_style);
+ }
+
+ // file line-number gutter: mute the first PREFIX_W columns. Cheap
+ // chrome, not gated on colors_on; selection/cursor passes still win.
+ if (pane.file != null) {
+ var vr: u16 = 0;
+ while (vr < body_h) : (vr += 1) {
+ var c: u16 = 0;
+ while (c < PREFIX_W and c < tw) : (c += 1) {
+ const cell = s.at(tx + c, r.y + BOX_H + vr);
+ cell.default = false; // paints blank gutter rows too
+ cell.style.fg = .{ .rgb = th.lineno };
+ }
+ }
+ }
+ // syntax colors (file panes): recolor each content cell from its
+ // tree-sitter style byte; content starts after the lineno gutter
+ if (p.colors_on) if (pane.file) |*f| {
+ if (f.highlights.len > 0) {
+ var flines = std.mem.splitScalar(u8, f.content, '\n');
+ var skip: usize = 0;
+ while (skip < f.scroll) : (skip += 1) {
+ if (flines.next() == null) break;
+ }
+ var vr: u16 = 0;
+ while (vr < body_h) : (vr += 1) {
+ const line = flines.next() orelse break;
+ const base = @intFromPtr(line.ptr) - @intFromPtr(f.content.ptr);
+ var c: usize = 0;
+ while (c < line.len and PREFIX_W + c < tw) : (c += 1) {
+ const idx = base + c;
+ if (idx < f.highlight_start) continue;
+ const hidx = idx - f.highlight_start;
+ if (hidx >= f.highlights.len) continue;
+ const ss = p.synStyle(@enumFromInt(f.highlights[hidx])) orelse continue;
+ const cell = s.at(tx + @as(u16, @intCast(PREFIX_W + c)), r.y + BOX_H + vr);
+ if (cell.default) continue;
+ cell.style.fg = .{ .rgb = ss.fg };
+ cell.style.bold = ss.bold;
+ }
+ }
+ }
+ };
+ // tty colors: recolor each body cell from the emulator's own style so
+ // shell output keeps its ansi colors (gated on the Colors toggle).
+ if (p.colors_on and pane.isTerminal()) {
+ var vr: u16 = 0;
+ while (vr < body_h and vr < pane.rows) : (vr += 1) {
+ var c: u16 = 0;
+ while (c < tw) : (c += 1) {
+ const ci = pane.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(c), .y = @intCast(vr) } }) orelse continue;
+ if (ci.cell.wide == .spacer_tail) continue;
+ const cell = s.at(tx + c, r.y + BOX_H + vr);
+ if (cell.default) continue;
+ cell.style = p.cellStyle(ci);
+ }
+ }
+ }
+
+ // repaint inserted cells with the plain body style so typed text reads
+ // exactly like normal text: the tty recolor above painted cell (c,vr)
+ // from emulator column c, but runs shifted the row right.
+ const off = pane.scroll();
+ if (pane.mode != .tty) for (pane.edits[0..pane.nedits]) |*e| {
+ if (e.len == 0) continue;
+ const prow = e.row - off + @as(i32, BOX_H);
+ if (prow < BOX_H or prow >= r.h) continue;
+ const start = pane.renderedCol(e.row, e.col, 0);
+ var k: i32 = 0;
+ while (k < e.len) : (k += 1) {
+ const cx = start + k;
+ if (cx < 0 or cx >= tw) continue;
+ const cell = s.at(tx + @as(u16, @intCast(cx)), r.y + @as(u16, @intCast(prow)));
+ if (!cell.default) cell.style = body_style;
+ }
+ };
+
+ // mouse selections (pane-local coords), one pass per button — later
+ // buttons win on overlap. A left .done stays highlighted after release;
+ // middle/right .done are transient (they fire their action on release).
+ 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;
+ var col: i32 = if (pane.file != null) @max(c0, @as(i32, PREFIX_W)) else c0;
+ while (col <= c1 and col < tw) : (col += 1) {
+ const cell = s.at(tx + @as(u16, @intCast(col)), r.y + row);
+ cell.default = false;
+ cell.style.bg = .{ .rgb = SEL_BG[b] };
+ cell.style.fg = .{ .rgb = sel_fg };
+ }
+ }
+ }
+ // modal line selection (helix `x`): whole body rows, normal mode only
+ if (pane.mode == .normal and pane.msel.active) {
+ const lo = @min(pane.msel.r0, pane.msel.r1) - off + @as(i32, BOX_H);
+ const hi = @max(pane.msel.r0, pane.msel.r1) - off + @as(i32, BOX_H);
+ var row: u16 = 0;
+ while (row < r.h) : (row += 1) {
+ if (@as(i32, row) < lo or @as(i32, row) > hi) continue;
+ var col: u16 = if (pane.file != null) PREFIX_W else 0;
+ while (col < tw) : (col += 1) {
+ const cell = s.at(tx + col, r.y + row);
+ cell.default = false;
+ cell.style.bg = .{ .rgb = msel_bg };
+ cell.style.fg = .{ .rgb = msel_fg };
+ }
+ }
+ }
+ // modal char selection (helix `v`): stream-shaped anchor..head highlight
+ if (pane.mode == .normal and pane.vsel.active) {
+ const bnd = vselBounds(pane);
+ const vpfx: i32 = if (pane.file != null) PREFIX_W else 0;
+ var ar: i32 = bnd.lo_row;
+ while (ar <= bnd.hi_row) : (ar += 1) {
+ const prow = ar - off + @as(i32, BOX_H);
+ if (prow < 0 or prow >= @as(i32, r.h)) continue;
+ const cstart: i32 = if (ar == bnd.lo_row) bnd.lo_col + vpfx else vpfx;
+ const cend: i32 = if (ar == bnd.hi_row) bnd.hi_col + vpfx else @as(i32, tw) - 1;
+ var col: i32 = @max(cstart, vpfx);
+ while (col <= cend and col < tw) : (col += 1) {
+ const cell = s.at(tx + @as(u16, @intCast(col)), r.y + @as(u16, @intCast(prow)));
+ cell.default = false;
+ cell.style.bg = .{ .rgb = msel_bg };
+ cell.style.fg = .{ .rgb = msel_fg };
+ }
+ }
+ }
+
+ // cursor: tracks the shell cursor until pinned by a click or a key
+ // (the tag cursor above wins while the tag is focused)
+ if (active and !pane.tag_edit) {
+ const cur = pane.vt.screens.active.cursor;
+ if (pane.mode != .tty) {
+ const crow = if (pane.cur_pinned) pane.cur_row else @as(i32, @intCast(cur.y)) + off;
+ const ccol = if (pane.cur_pinned) pane.cur_col else @as(i32, @intCast(cur.x));
+ const coff = if (pane.cur_pinned) pane.cur_off else 0;
+ const prow = crow - off + @as(i32, BOX_H);
+ // files: cur_col is content-relative, add the lineno prefix;
+ // insert mode maps the run anchor to its visual column; normal
+ // mode keeps cur_col already-visual.
+ const cx = if (pane.file != null)
+ @as(i32, PREFIX_W) + ccol + @as(i32, coff)
+ else if (pane.mode == .insert)
+ pane.renderedCol(crow, ccol, coff)
+ else
+ ccol;
+ if (prow >= 0 and cx >= 0 and prow < r.h and cx < tw)
+ s.cursor = .{ .x = tx + @as(u16, @intCast(cx)), .y = r.y + @as(u16, @intCast(prow)) };
+ } else if (cur.y + BOX_H < r.h and cur.x < tw) {
+ s.cursor = .{ .x = tx + cur.x, .y = r.y + cur.y + BOX_H };
+ }
+ }
+
+ // gutter: move box on top, scrollbar track + thumb below
+ const box_bg = if (active) accent else accent_dim;
+ s.fill(r.x, r.y, GUTTER, BOX_H, .{ .bg = .{ .rgb = box_bg } });
+ if (r.h > BOX_H) {
+ s.fill(r.x, r.y + BOX_H, GUTTER, r.h - BOX_H, .{ .bg = .{ .rgb = th.scroll_track } });
+ const sb: struct { total: usize, offset: usize, len: usize } = if (pane.file) |f| .{
+ .total = fileLineCount(f.content),
+ .offset = f.scroll,
+ .len = pane.rows,
+ } else blk: {
+ const gsb = pane.vt.screens.active.pages.scrollbar();
+ break :blk .{ .total = gsb.total, .offset = gsb.offset, .len = gsb.len };
+ };
+ const track_h: usize = r.h - BOX_H;
+ const total = if (sb.total == 0) 1 else sb.total;
+ const len = @max(1, (track_h * @max(1, sb.len)) / total);
+ const pos = (track_h * sb.offset) / total;
+ var sy: usize = pos;
+ while (sy < track_h and sy < pos + len) : (sy += 1) {
+ s.fill(r.x, r.y + BOX_H + @as(u16, @intCast(sy)), GUTTER, 1, .{ .bg = .{ .rgb = th.scroll_thumb } });
+ }
+ }
+ }
+
+ /// 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.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 use_petscii = iv.petscii or !p.kitty_ok;
+ if (!use_petscii) {
+ s.images[s.nimages] = .{
+ .pane = @intCast(for (p.panes, 0..) |slot, i| {
+ if (slot == pane) break i;
+ } else 0),
+ .x = tx,
+ .y = r.y + BOX_H,
+ .w = body_cols,
+ .h = body_rows,
+ .rgba = iv.rgba,
+ .iw = iv.iw,
+ .ih = iv.ih,
+ };
+ s.nimages += 1;
+ 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.gpa.free(iv.grid);
+ const pal = switch (iv.pmode) {
+ .commodore => image.petscii.commodore,
+ .terminal => image.ansiPalette(),
+ };
+ const g = image.petscii.render(p.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 = r.y + BOX_H + @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, edits spliced like the tty.
+ /// Terminal: viewport rows, padded to the grid height, prompt rows blanked
+ /// outside tty mode (OSC 133), typed insertion runs spliced in.
+ fn bodyText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
+ _ = p;
+ if (pane.image != null) {
+ const buf = try arena.alloc(u8, pane.rows -| 1);
+ @memset(buf, '\n');
+ return buf;
+ }
+ if (pane.file) |f| {
+ var out: std.ArrayList(u8) = .empty;
+ var flines = std.mem.splitScalar(u8, f.content, '\n');
+ var skipped: usize = 0;
+ while (skipped < f.scroll) : (skipped += 1) {
+ if (flines.next() == null) break;
+ }
+ var i: usize = 0;
+ while (i < pane.rows) : (i += 1) {
+ if (i > 0) try out.append(arena, '\n');
+ const abs: i32 = @as(i32, @intCast(f.scroll)) + @as(i32, @intCast(i));
+ if (flines.next()) |text| {
+ var lbuf: [16]u8 = undefined;
+ // unsigned: {d} prints a leading '+' for signed ints
+ const lineno: usize = @intCast(abs + 1);
+ const prefix = std.fmt.bufPrint(&lbuf, "{d: >4} ", .{lineno}) catch " ";
+ var base: std.ArrayList(u8) = .empty;
+ try base.appendSlice(arena, prefix);
+ try base.appendSlice(arena, text);
+ try spliceRow(arena, &out, pane, abs, base.items);
+ } else try spliceRow(arena, &out, pane, abs, "");
+ }
+ return out.items;
+ }
+ const raw = try pane.vt.plainString(arena);
+ var flags: [512]bool = @splat(false);
+ const nrows: usize = @min(pane.rows, flags.len);
+ if (pane.mode != .tty) {
+ var it = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .viewport = .{} }, null);
+ var i: usize = 0;
+ while (it.next()) |pin| : (i += 1) {
+ if (i >= nrows) break;
+ flags[i] = pin.rowAndCell().row.semantic_prompt != .none;
+ }
+ }
+ const off = pane.scroll();
+ var out: std.ArrayList(u8) = .empty;
+ var lines = std.mem.splitScalar(u8, raw, '\n');
+ var i: usize = 0;
+ while (i < nrows) : (i += 1) {
+ if (i > 0) try out.append(arena, '\n');
+ const line = if (flags[i]) "" else lines.next() orelse "";
+ if (flags[i]) _ = lines.next();
+ if (pane.mode == .tty) {
+ try out.appendSlice(arena, line);
+ } else {
+ try spliceRow(arena, &out, pane, @as(i32, @intCast(i)) + off, line);
+ }
+ }
+ return out.items;
+ }
+
+ fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell) CellStyle {
+ const style = ci.style();
+ var cs: CellStyle = .{
+ .fg = p.ghostColor(style.fg_color, false),
+ .bg = p.ghostColor(style.bg_color, true),
+ .bold = style.flags.bold,
+ .dim = style.flags.faint,
+ .italic = style.flags.italic,
+ .blink = style.flags.blink,
+ .reverse = style.flags.inverse,
+ .invisible = style.flags.invisible,
+ .strikethrough = style.flags.strikethrough,
+ .ul = switch (style.flags.underline) {
+ .none => .off,
+ .single => .single,
+ .double => .double,
+ .curly => .curly,
+ .dotted => .dotted,
+ .dashed => .dashed,
+ },
+ };
+ switch (ci.cell.content_tag) {
+ .bg_color_palette => cs.bg = p.palColor(ci.cell.content.color_palette.data),
+ .bg_color_rgb => {
+ const rgb = ci.cell.content.color_rgb;
+ cs.bg = .{ .rgb = .{ rgb.r, rgb.g, rgb.b } };
+ },
+ else => {},
+ }
+ return cs;
+ }
+
+ const SynStyle = struct { fg: [3]u8, bold: bool };
+
+ fn synStyle(p: *Pardes, sy: syntax.Syn) ?SynStyle {
+ return switch (sy) {
+ .none => null,
+ .keyword => .{ .fg = p.theme().kw, .bold = true },
+ .string => .{ .fg = p.theme().str, .bold = false },
+ .number => .{ .fg = p.theme().num, .bold = false },
+ .comment => .{ .fg = p.theme().comment, .bold = true },
+ };
+ }
+
+ fn palColor(p: *Pardes, idx: u8) Color {
+ if (p.theme().palette) |pal| if (idx < 16) return .{ .rgb = pal[idx] };
+ return .{ .index = idx };
+ }
+
+ fn ghostColor(p: *Pardes, color: ghostty_vt.Style.Color, is_bg: bool) Color {
+ return switch (color) {
+ .none => blk: {
+ const t = if (is_bg) p.theme().bg else p.theme().fg;
+ break :blk if (t) |c| .{ .rgb = c } else .default;
+ },
+ .palette => |idx| p.palColor(idx),
+ .rgb => |rgb| .{ .rgb = .{ rgb.r, rgb.g, rgb.b } },
+ };
+ }
+};
+
+// 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).
+fn ptyReport(handler: *ghostty_vt.TerminalStream.Handler, data: [:0]const u8) void {
+ const pane: *Pane = @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]);
+ pane.reply_len += @intCast(n);
+}
+
+/// Splice this row's insertion runs into `line` (the raw tty text for absolute
+/// row `abs_row`), shifting the underlying text right rather than overwriting.
+fn spliceRow(arena: std.mem.Allocator, out: *std.ArrayList(u8), pane: *Pane, abs_row: i32, line: []const u8) !void {
+ var idxs: [MAX_EDITS]usize = undefined;
+ var n: usize = 0;
+ for (pane.edits[0..pane.nedits], 0..) |e, i| {
+ if (e.row == abs_row and e.len > 0) {
+ idxs[n] = i;
+ n += 1;
+ }
+ }
+ // insertion-sort the runs by column (n is tiny)
+ var a: usize = 1;
+ while (a < n) : (a += 1) {
+ const key = idxs[a];
+ var b = a;
+ while (b > 0 and pane.edits[idxs[b - 1]].col > pane.edits[key].col) : (b -= 1) idxs[b] = idxs[b - 1];
+ idxs[b] = key;
+ }
+ var vpos: usize = 0; // original-line columns consumed so far
+ for (idxs[0..n]) |i| {
+ const e = &pane.edits[i];
+ const c: usize = @intCast(@max(0, e.col));
+ if (c <= line.len) {
+ if (c > vpos) try out.appendSlice(arena, line[vpos..c]);
+ vpos = c;
+ } else {
+ // run anchored past the text: emit the rest, then pad with spaces
+ if (vpos < line.len) {
+ try out.appendSlice(arena, line[vpos..]);
+ vpos = line.len;
+ }
+ while (vpos < c) : (vpos += 1) try out.append(arena, ' ');
+ }
+ try out.appendSlice(arena, e.text[0..e.len]);
+ }
+ if (vpos < line.len) try out.appendSlice(arena, line[vpos..]);
+}
+
+const DeviceAttrs = @typeInfo(@typeInfo(@typeInfo(
+ @FieldType(ghostty_vt.TerminalStream.Handler.Effects, "device_attributes"),
+).optional.child).pointer.child).@"fn".return_type.?;
+fn ptyDeviceAttrs(_: *ghostty_vt.TerminalStream.Handler) DeviceAttrs {
+ return .{};
+}
diff --git a/petscii.zig b/src/petscii.zig
index 136c4ead..136c4ead 100644
--- a/petscii.zig
+++ b/src/petscii.zig
diff --git a/src/syntax.zig b/src/syntax.zig
new file mode 100644
index 00000000..a9914fd7
--- /dev/null
+++ b/src/syntax.zig
@@ -0,0 +1,173 @@
+//! Tree-sitter syntax highlighting: one style byte per content byte, filled by
+//! running each grammar's highlights.scm query (slurped at build time into the
+//! ts_queries options module). Grammar set is tiered: `minimal` (c, cpp, zig)
+//! compiles queries at startup; `full` adds ~23 languages lazily on first use.
+const std = @import("std");
+const config = @import("pardes_config");
+
+pub const enabled = config.syntax_highlighting;
+const minimal_grammars = config.syntax_minimal_grammars;
+const full_grammars = config.syntax_full_grammars;
+
+const ts = if (enabled) @import("tree-sitter") else struct {
+ pub const Language = opaque {};
+ pub const Query = opaque {};
+};
+const ts_queries = if (enabled) @import("ts_queries") else struct {};
+
+extern fn tree_sitter_ada() callconv(.c) *const ts.Language;
+extern fn tree_sitter_bash() callconv(.c) *const ts.Language;
+extern fn tree_sitter_c() callconv(.c) *const ts.Language;
+extern fn tree_sitter_c_sharp() callconv(.c) *const ts.Language;
+extern fn tree_sitter_clojure() callconv(.c) *const ts.Language;
+extern fn tree_sitter_cpp() callconv(.c) *const ts.Language;
+extern fn tree_sitter_css() callconv(.c) *const ts.Language;
+extern fn tree_sitter_elixir() callconv(.c) *const ts.Language;
+extern fn tree_sitter_erlang() callconv(.c) *const ts.Language;
+extern fn tree_sitter_fortran() callconv(.c) *const ts.Language;
+extern fn tree_sitter_go() callconv(.c) *const ts.Language;
+extern fn tree_sitter_haskell() callconv(.c) *const ts.Language;
+extern fn tree_sitter_html() callconv(.c) *const ts.Language;
+extern fn tree_sitter_java() callconv(.c) *const ts.Language;
+extern fn tree_sitter_javascript() callconv(.c) *const ts.Language;
+extern fn tree_sitter_json() callconv(.c) *const ts.Language;
+extern fn tree_sitter_kotlin() callconv(.c) *const ts.Language;
+extern fn tree_sitter_ocaml() callconv(.c) *const ts.Language;
+extern fn tree_sitter_pascal() callconv(.c) *const ts.Language;
+extern fn tree_sitter_php() callconv(.c) *const ts.Language;
+extern fn tree_sitter_powershell() callconv(.c) *const ts.Language;
+extern fn tree_sitter_python() callconv(.c) *const ts.Language;
+extern fn tree_sitter_ruby() callconv(.c) *const ts.Language;
+extern fn tree_sitter_rust() callconv(.c) *const ts.Language;
+extern fn tree_sitter_scala() callconv(.c) *const ts.Language;
+extern fn tree_sitter_zig() callconv(.c) *const ts.Language;
+
+pub const Syn = enum(u8) { none, keyword, string, number, comment };
+
+const Spec = struct {
+ exts: []const []const u8,
+ language: *const fn () callconv(.c) *const ts.Language,
+ query_src: []const u8,
+ compiled_query: ?*ts.Query = null,
+};
+
+var minimal_specs = if (minimal_grammars) [_]Spec{
+ .{ .exts = &.{ ".c", ".h" }, .language = tree_sitter_c, .query_src = ts_queries.c_highlights },
+ .{ .exts = &.{ ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx" }, .language = tree_sitter_cpp, .query_src = ts_queries.cpp_highlights },
+ .{ .exts = &.{ ".zig", ".zon" }, .language = tree_sitter_zig, .query_src = ts_queries.zig_highlights },
+} else [_]Spec{};
+
+var full_specs = if (full_grammars) [_]Spec{
+ .{ .exts = &.{ ".adb", ".ads", ".ada" }, .language = tree_sitter_ada, .query_src = ts_queries.ada_highlights },
+ .{ .exts = &.{ ".sh", ".bash", ".zsh" }, .language = tree_sitter_bash, .query_src = ts_queries.bash_highlights },
+ .{ .exts = &.{ ".cs", ".csx" }, .language = tree_sitter_c_sharp, .query_src = ts_queries.c_sharp_highlights },
+ .{ .exts = &.{ ".clj", ".cljs", ".cljc", ".edn" }, .language = tree_sitter_clojure, .query_src = ts_queries.clojure_highlights },
+ .{ .exts = &.{".css"}, .language = tree_sitter_css, .query_src = ts_queries.css_highlights },
+ .{ .exts = &.{ ".ex", ".exs" }, .language = tree_sitter_elixir, .query_src = ts_queries.elixir_highlights },
+ .{ .exts = &.{ ".erl", ".hrl" }, .language = tree_sitter_erlang, .query_src = ts_queries.erlang_highlights },
+ .{ .exts = &.{ ".f", ".for", ".ftn", ".f90", ".f95", ".f03", ".f08" }, .language = tree_sitter_fortran, .query_src = ts_queries.fortran_highlights },
+ .{ .exts = &.{".go"}, .language = tree_sitter_go, .query_src = ts_queries.go_highlights },
+ .{ .exts = &.{ ".hs", ".lhs" }, .language = tree_sitter_haskell, .query_src = ts_queries.haskell_highlights },
+ .{ .exts = &.{ ".html", ".htm" }, .language = tree_sitter_html, .query_src = ts_queries.html_highlights },
+ .{ .exts = &.{".java"}, .language = tree_sitter_java, .query_src = ts_queries.java_highlights },
+ .{ .exts = &.{ ".js", ".jsx", ".mjs", ".cjs" }, .language = tree_sitter_javascript, .query_src = ts_queries.javascript_highlights },
+ .{ .exts = &.{".json"}, .language = tree_sitter_json, .query_src = ts_queries.json_highlights },
+ .{ .exts = &.{ ".kt", ".kts" }, .language = tree_sitter_kotlin, .query_src = ts_queries.kotlin_highlights },
+ .{ .exts = &.{ ".ml", ".mli" }, .language = tree_sitter_ocaml, .query_src = ts_queries.ocaml_highlights },
+ .{ .exts = &.{ ".pas", ".pp", ".p" }, .language = tree_sitter_pascal, .query_src = ts_queries.pascal_highlights },
+ .{ .exts = &.{ ".php", ".phtml", ".php3", ".php4", ".php5" }, .language = tree_sitter_php, .query_src = ts_queries.php_highlights },
+ .{ .exts = &.{ ".ps1", ".psm1", ".psd1" }, .language = tree_sitter_powershell, .query_src = ts_queries.powershell_highlights },
+ .{ .exts = &.{ ".py", ".pyw" }, .language = tree_sitter_python, .query_src = ts_queries.python_highlights },
+ .{ .exts = &.{ ".rb", ".rake" }, .language = tree_sitter_ruby, .query_src = ts_queries.ruby_highlights },
+ .{ .exts = &.{".rs"}, .language = tree_sitter_rust, .query_src = ts_queries.rust_highlights },
+ .{ .exts = &.{ ".scala", ".sc" }, .language = tree_sitter_scala, .query_src = ts_queries.scala_highlights },
+} else [_]Spec{};
+
+const Selected = struct { lang: *const ts.Language, query: *ts.Query };
+
+// NOTE: don't lang.destroy() — the tree_sitter_*() languages are static
+// singletons reused on every open; destroying one use-after-frees the next.
+fn ensure(spec: *Spec) !Selected {
+ const lang = spec.language();
+ if (spec.compiled_query) |query| return .{ .lang = lang, .query = query };
+ var error_offset: u32 = 0;
+ const query = try ts.Query.create(lang, spec.query_src, &error_offset);
+ spec.compiled_query = query;
+ return .{ .lang = lang, .query = query };
+}
+
+fn forExt(ext: []const u8) !?Selected {
+ for (&minimal_specs) |*spec| {
+ for (spec.exts) |choice| {
+ if (std.ascii.eqlIgnoreCase(ext, choice)) return try ensure(spec);
+ }
+ }
+ for (&full_specs) |*spec| {
+ for (spec.exts) |choice| {
+ if (std.ascii.eqlIgnoreCase(ext, choice)) return try ensure(spec);
+ }
+ }
+ return null;
+}
+
+/// compile the minimal-tier queries eagerly so the first .zig/.c open is fast
+pub fn compileStartupQueries() void {
+ if (!enabled) return;
+ for (&minimal_specs) |*spec| _ = ensure(spec) catch {};
+}
+
+fn synFor(name: []const u8) Syn {
+ for ([_]struct { []const u8, Syn }{
+ .{ "comment", .comment },
+ .{ "string", .string },
+ .{ "character", .string },
+ .{ "number", .number },
+ .{ "float", .number },
+ .{ "boolean", .number },
+ .{ "keyword", .keyword },
+ .{ "include", .keyword },
+ .{ "conditional", .keyword },
+ .{ "repeat", .keyword },
+ }) |m| {
+ if (std.mem.indexOf(u8, name, m[0]) != null) return m[1];
+ }
+ 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 {
+ if (!enabled) return &.{};
+ const ext = std.fs.path.extension(path);
+ const selected = (forExt(ext) catch return &.{}) orelse return &.{};
+
+ const start_byte = @min(start_byte_raw, content.len);
+ const end_byte = @max(start_byte, @min(end_byte_raw, content.len));
+ const source = content[start_byte..end_byte];
+ const styles = try gpa.alloc(u8, source.len);
+ errdefer gpa.free(styles);
+ @memset(styles, 0);
+
+ const parser = ts.Parser.create();
+ defer parser.destroy();
+ parser.setLanguage(selected.lang) catch return styles;
+ const tree = parser.parseString(source, null) orelse return styles;
+ defer tree.destroy();
+
+ const cursor = ts.QueryCursor.create();
+ defer cursor.destroy();
+ cursor.exec(selected.query, tree.rootNode());
+ while (cursor.nextMatch()) |match| {
+ for (match.captures) |cap| {
+ const syn = synFor(selected.query.captureNameForId(cap.index) orelse "");
+ if (syn == .none) continue;
+ var b: usize = cap.node.startByte();
+ const end = @min(@as(usize, cap.node.endByte()), styles.len);
+ while (b < end) : (b += 1) styles[b] = @intFromEnum(syn);
+ }
+ }
+ return styles;
+}
diff --git a/src/tty.zig b/src/tty.zig
new file mode 100644
index 00000000..d9821343
--- /dev/null
+++ b/src/tty.zig
@@ -0,0 +1,386 @@
+//! The terminal shell: owns the event loop and all IO. Translates vaxis
+//! events into core events, performs the core's effects (fork ptys, write
+//! them, resize them), and hands the core's Surface to vaxis cell-for-cell —
+//! the canonical interface rendered with no interpretation.
+const std = @import("std");
+const posix = std.posix;
+const linux = std.os.linux;
+const vaxis = @import("vaxis");
+const pardes = @import("pardes.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;
+extern "c" fn chdir(path: [*:0]const u8) c_int;
+extern "c" fn _exit(status: c_int) noreturn;
+
+pub const Command = struct {
+ pub var value: union(enum) {
+ nop,
+ quit,
+ tick,
+ key_press: vaxis.Key,
+ pty_read: struct { id: usize, bytes: []u8 },
+ pty_eof: usize,
+ winsize: vaxis.Winsize,
+ mouse: vaxis.Mouse,
+ paste: []const u8,
+ } = .nop;
+};
+const Loop = vaxis.Loop(@TypeOf(Command.value));
+
+const Pty = struct {
+ file: std.Io.File,
+ pid: posix.pid_t,
+ reader: std.Io.Future(anyerror!void),
+};
+
+pub fn run(init: std.process.Init, opts: pardes.Options) !void {
+ const io = init.io;
+ const gpa = init.gpa;
+
+ var tty_buf: [0x10000]u8 = undefined;
+ var tty = try vaxis.Tty.init(io, &tty_buf);
+ var vx = try vaxis.init(io, gpa, init.environ_map, .{ .system_clipboard_allocator = gpa });
+ defer vx.deinit(gpa, tty.writer());
+ try vx.enterAltScreen(tty.writer());
+ defer vx.exitAltScreen(tty.writer()) catch {};
+ try vx.setMouseMode(tty.writer(), true);
+
+ var frame_arena: std.heap.ArenaAllocator = .init(gpa);
+ defer frame_arena.deinit();
+
+ // stb_image allocator for image panes
+ pardes.image.start(io, gpa);
+ defer pardes.image.stop();
+
+ const core = if (opts.load_path) |lp| blk: {
+ const bytes = try @import("look.zig").readFile(gpa, lp);
+ defer gpa.free(bytes);
+ break :blk try pardes.Pardes.initFromDump(gpa, opts, bytes);
+ } else try pardes.Pardes.init(gpa, opts);
+ defer core.deinit();
+
+ // shells emit OSC 133 prompt marks via this rc (prompt hiding, click-move)
+ writeFile(pardes.bash_rc);
+
+ var kitty_handles: [pardes.MAX_PANES]?vaxis.Image = @splat(null);
+ var ptys: [pardes.MAX_PANES]?Pty = @splat(null);
+ defer for (&ptys) |*slot| if (slot.*) |*pt| {
+ _ = linux.close(pt.file.handle);
+ slot.* = null;
+ };
+
+ // Perform the initial spawns BEFORE any worker thread exists: forkpty from
+ // a multithreaded process can wedge the child before exec.
+ var loop: Loop = .init(io, &tty, &vx);
+ drainEffects(core, &ptys, io, gpa, &loop, &vx, &tty, false);
+
+ try loop.start();
+ defer loop.stop();
+ try loop.installResizeHandler();
+ vx.queryTerminal(tty.writer(), std.Io.Duration.fromMilliseconds(2)) catch {};
+
+ // now threads are fine: start a reader task per pty
+ for (&ptys, 0..) |*slot, id| if (slot.*) |*pt| {
+ pt.reader = try io.concurrent(readPty, .{ io, gpa, pt.file, id, &loop });
+ };
+
+ var pending: ?@TypeOf(Command.value) = .tick;
+ while (!core.quit) {
+ const event = if (pending) |ev| blk: {
+ pending = null;
+ break :blk ev;
+ } else try loop.nextEvent();
+ switch (event) {
+ .nop, .tick => {},
+ .quit => break,
+ .winsize => |ws| {
+ try vx.resize(gpa, tty.writer(), ws);
+ core.update(.{ .resize = .{ .cols = @intCast(ws.cols), .rows = @intCast(ws.rows) } });
+ },
+ .pty_read => |pr| {
+ core.update(.{ .output = .{ .pane = @intCast(pr.id), .bytes = pr.bytes } });
+ gpa.free(pr.bytes);
+ },
+ .pty_eof => |id| {
+ if (ptys[id]) |*pt| {
+ _ = linux.close(pt.file.handle);
+ ptys[id] = null;
+ }
+ core.update(.{ .eof = .{ .pane = @intCast(id) } });
+ },
+ .key_press => |key| core.update(.{ .key = .{
+ .cp = mapKey(effCp(key)),
+ .text = key.text orelse "",
+ .ctrl = key.mods.ctrl,
+ .alt = key.mods.alt,
+ } }),
+ .mouse => |m| {
+ const button: ?pardes.Mouse.Button = switch (m.button) {
+ .left => .left,
+ .middle => .middle,
+ .right => .right,
+ .wheel_up => .wheel_up,
+ .wheel_down => .wheel_down,
+ .none => .none, // button-less motion: hover tracking
+ else => null,
+ };
+ if (button) |b| core.update(.{ .mouse = .{
+ .button = b,
+ .kind = switch (m.type) {
+ .press => .press,
+ .release => .release,
+ .motion => .motion,
+ .drag => .drag,
+ },
+ .col = @intCast(m.col),
+ .row = @intCast(m.row),
+ } });
+ },
+ .paste => |bytes| {
+ core.update(.{ .paste = bytes });
+ gpa.free(@constCast(bytes));
+ },
+ }
+
+ drainEffects(core, &ptys, io, gpa, &loop, &vx, &tty, true);
+ if (core.quit) break;
+
+ // live cwd for tags/look: cheap /proc readlink per pane, per frame
+ for (&ptys, 0..) |*slot, id| if (slot.*) |pt| {
+ var lbuf: [1024]u8 = undefined;
+ var pbuf: [64]u8 = undefined;
+ const path = std.fmt.bufPrintSentinel(&pbuf, "/proc/{d}/cwd", .{pt.pid}, 0) catch continue;
+ const rc = linux.readlinkat(linux.AT.FDCWD, path, &lbuf, lbuf.len);
+ const n: isize = @bitCast(rc);
+ if (n > 0) core.setCwd(id, lbuf[0..@intCast(n)]);
+ };
+
+ // ---- render: surface -> vaxis, cell for cell ----
+ _ = frame_arena.reset(.retain_capacity);
+ const surface = try core.render(frame_arena.allocator());
+ const win = vx.window();
+ win.clear();
+ var y: u16 = 0;
+ while (y < surface.rows) : (y += 1) {
+ var x: u16 = 0;
+ while (x < surface.cols) : (x += 1) {
+ const cell = surface.at(x, y);
+ if (cell.default) continue;
+ win.writeCell(x, y, .{
+ .char = .{ .grapheme = cell.grapheme() },
+ .style = vaxisStyle(cell.style),
+ });
+ }
+ }
+ // pixel attachments (kitty graphics): transmit once per pane, then
+ // re-place every frame (placements aren't persistent)
+ for (surface.images[0..surface.nimages]) |maybe| {
+ const place = maybe orelse continue;
+ if (kitty_handles[place.pane] == null and vx.caps.kitty_graphics) {
+ const enc = std.base64.standard.Encoder;
+ if (gpa.alloc(u8, enc.calcSize(place.rgba.len))) |b64| {
+ defer gpa.free(b64);
+ _ = enc.encode(b64, place.rgba);
+ kitty_handles[place.pane] = vx.transmitPreEncodedImage(tty.writer(), b64, @intCast(place.iw), @intCast(place.ih), .rgba) catch null;
+ } else |_| {}
+ }
+ if (kitty_handles[place.pane]) |h| {
+ const child = win.child(.{ .x_off = place.x, .y_off = place.y, .width = place.w, .height = place.h });
+ h.draw(child, .{ .scale = .contain }) catch {};
+ }
+ }
+ if (surface.cursor) |cur| win.showCursor(cur.x, cur.y);
+ try vx.render(tty.writer());
+ }
+}
+
+fn drainEffects(
+ core: *pardes.Pardes,
+ ptys: *[pardes.MAX_PANES]?Pty,
+ io: std.Io,
+ gpa: std.mem.Allocator,
+ loop: *Loop,
+ vx: *vaxis.Vaxis,
+ tty: *vaxis.Tty,
+ threads_ok: bool,
+) void {
+ while (core.nextEffect()) |effect| switch (effect) {
+ .spawn => |sp| {
+ std.debug.assert(ptys[sp.pane] == null);
+ const cwd = sp.cwd.slice();
+ var cwd_buf: [256:0]u8 = undefined;
+ var cwd_z: ?[*:0]const u8 = null;
+ if (cwd.len > 0) {
+ @memcpy(cwd_buf[0..cwd.len], cwd);
+ cwd_buf[cwd.len] = 0;
+ cwd_z = @ptrCast(&cwd_buf);
+ }
+ const child = forkShell(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)
+ var lbuf: [1024]u8 = undefined;
+ var pbuf: [64]u8 = undefined;
+ if (std.fmt.bufPrintSentinel(&pbuf, "/proc/{d}/cwd", .{child.pid}, 0) catch null) |path| {
+ const rc = linux.readlinkat(linux.AT.FDCWD, path, &lbuf, lbuf.len);
+ const n: isize = @bitCast(rc);
+ if (n > 0) core.setCwd(sp.pane, lbuf[0..@intCast(n)]);
+ }
+ if (threads_ok) {
+ if (ptys[sp.pane]) |*pt| {
+ pt.reader = io.concurrent(readPty, .{ io, gpa, pt.file, @as(usize, sp.pane), loop }) catch pt.reader;
+ }
+ }
+ },
+ .write => |w| {
+ if (ptys[w.pane]) |pt| writeFd(pt.file.handle, w.bytes.slice());
+ },
+ .resize_pty => |rs| {
+ if (ptys[rs.pane]) |pt| {
+ const ws: posix.winsize = .{ .row = rs.rows, .col = rs.cols, .xpixel = 0, .ypixel = 0 };
+ _ = posix.system.ioctl(pt.file.handle, posix.T.IOCSWINSZ, @intFromPtr(&ws));
+ }
+ },
+ .open_link => {}, // tty: no link handling (look opens panes instead)
+ .save_file => |sf| {
+ const pane = core.panes[sf.pane] orelse continue;
+ const f = pane.file orelse continue;
+ var pathbuf: [4096:0]u8 = undefined;
+ if (f.path.len >= pathbuf.len) continue;
+ @memcpy(pathbuf[0..f.path.len], f.path);
+ pathbuf[f.path.len] = 0;
+ const rc = linux.open(pathbuf[0..f.path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) continue;
+ const fd: c_int = @intCast(sfd);
+ writeFd(fd, f.content);
+ _ = linux.close(fd);
+ },
+ .write_dump => {
+ const out = core.dump_out orelse continue;
+ const rc = linux.open(pardes.dump.default_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) continue;
+ const fd: c_int = @intCast(sfd);
+ writeFd(fd, out);
+ _ = linux.close(fd);
+ },
+ .set_clipboard => {
+ // mirror the core's yank register out via OSC 52
+ if (core.yank) |y| vx.copyToSystemClipboard(tty.writer(), y, gpa) catch {};
+ },
+ .get_clipboard => vx.requestSystemClipboard(tty.writer()) catch {},
+ .quit => {},
+ };
+}
+
+fn forkShell(cwd: ?[*:0]const u8, rows: u16, cols: u16) struct { file: std.Io.File, pid: posix.pid_t } {
+ var master: c_int = undefined;
+ const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
+ const pid = forkpty(&master, null, null, &ws);
+ if (pid == 0) {
+ if (cwd) |c| _ = chdir(c);
+ const argv: [4:null]?[*:0]const u8 = .{ "/usr/bin/bash", "--rcfile", "/tmp/pardes-osc133.bash", null };
+ _ = execv("/usr/bin/bash", &argv);
+ _exit(127);
+ }
+ return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid };
+}
+
+fn readPty(io: std.Io, gpa: std.mem.Allocator, pty: std.Io.File, id: usize, loop: *Loop) anyerror!void {
+ var read_buf: [0x10000]u8 = undefined;
+ var reader = pty.readerStreaming(io, &read_buf);
+ while (true) {
+ var buf: [0x10000]u8 = undefined;
+ var vec = [_][]u8{&buf};
+ const n = reader.interface.readVec(&vec) catch break;
+ if (n == 0) break;
+ const bytes = try gpa.dupe(u8, buf[0..n]);
+ loop.postEvent(.{ .pty_read = .{ .id = id, .bytes = bytes } }) catch {
+ gpa.free(bytes);
+ break;
+ };
+ }
+ loop.postEvent(.{ .pty_eof = id }) catch {};
+}
+
+/// The effective codepoint the way vaxis Key.matches sees it: a single-char
+/// text wins (shift resolved by the terminal), else the shifted codepoint.
+fn effCp(key: vaxis.Key) u21 {
+ if (key.text) |t| {
+ const view = std.unicode.Utf8View.init(t) catch return key.codepoint;
+ var it = view.iterator();
+ if (it.nextCodepoint()) |c| {
+ if (it.nextCodepoint() == null) return c;
+ }
+ }
+ return key.shifted_codepoint orelse key.codepoint;
+}
+
+/// vaxis functional-key codepoints -> core Key constants (ASCII ones already
+/// coincide: enter/tab/escape/backspace pass through).
+fn mapKey(cp: u21) u21 {
+ return switch (cp) {
+ vaxis.Key.up => pardes.Key.up,
+ vaxis.Key.down => pardes.Key.down,
+ vaxis.Key.left => pardes.Key.left,
+ vaxis.Key.right => pardes.Key.right,
+ vaxis.Key.home => pardes.Key.home,
+ vaxis.Key.end => pardes.Key.end,
+ vaxis.Key.page_up => pardes.Key.page_up,
+ vaxis.Key.page_down => pardes.Key.page_down,
+ else => cp,
+ };
+}
+
+fn vaxisStyle(s: pardes.CellStyle) vaxis.Style {
+ return .{
+ .fg = vaxisColor(s.fg),
+ .bg = vaxisColor(s.bg),
+ .bold = s.bold,
+ .dim = s.dim,
+ .italic = s.italic,
+ .blink = s.blink,
+ .reverse = s.reverse,
+ .invisible = s.invisible,
+ .strikethrough = s.strikethrough,
+ .ul_style = switch (s.ul) {
+ .off => .off,
+ .single => .single,
+ .double => .double,
+ .curly => .curly,
+ .dotted => .dotted,
+ .dashed => .dashed,
+ },
+ };
+}
+
+fn vaxisColor(c: pardes.Color) vaxis.Color {
+ return switch (c) {
+ .default => .default,
+ .index => |i| .{ .index = i },
+ .rgb => |rgb| .{ .rgb = rgb },
+ };
+}
+
+fn writeFile(data: []const u8) void {
+ const rc = linux.open("/tmp/pardes-osc133.bash", .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) return;
+ const fd: c_int = @intCast(sfd);
+ defer _ = linux.close(fd);
+ writeFd(fd, data);
+}
+
+fn writeFd(fd: c_int, data: []const u8) void {
+ var off: usize = 0;
+ while (off < data.len) {
+ const rc = linux.write(fd, data[off..].ptr, data.len - off);
+ const n: isize = @bitCast(rc);
+ if (n < 0) {
+ if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue;
+ return;
+ }
+ off += @intCast(n);
+ }
+}
diff --git a/tutor.txt b/src/tutor.txt
index 5a93d388..5a93d388 100644
--- a/tutor.txt
+++ b/src/tutor.txt
diff --git a/term.zig b/term.zig
deleted file mode 100644
index 7201c7c7..00000000
--- a/term.zig
+++ /dev/null
@@ -1,213 +0,0 @@
-const std = @import("std");
-const ghostty_vt = @import("ghostty-vt");
-const image_mod = @import("image.zig");
-
-/// One text selection (block-shaped), in pane-local coords; the row carries the
-/// scrollback offset. .none == nothing selected.
-pub const Sel = struct {
- state: enum { none, dragging, done } = .none,
- c0: i32 = 0,
- c1: i32 = 0,
- r0: i32 = 0,
- r1: i32 = 0,
-};
-
-/// Modal input mode. normal/insert are the helix-style text-editing modes; tty
-/// forwards keys to the shell (terminal panes only — file panes never use it).
-/// normal -> helix NORMAL: hjkl/w/b/e/... move a block cursor; i/a/o -> insert;
-/// v/x select; Esc collapses the selection; configured Ctrl key -> tty (terminals).
-/// insert -> keys insert text at the cursor (acme click-and-type); Esc -> normal.
-/// tty -> keys -> pty as terminal sequences. The mouse stays usable here (it
-/// selects/copies without dropping you back to normal); the configured Ctrl key exits.
-pub const Mode = enum { normal, insert, tty };
-
-/// A modal line selection (helix `x` extends the selection down line by line).
-/// r0/r1 are ABSOLUTE body rows (visual + scroll off). active == false means the
-/// cursor is a plain 1-char selection (normal mode default).
-pub const LineSel = struct {
- active: bool = false,
- r0: i32 = 0,
- r1: i32 = 0,
-};
-
-/// A modal character-range selection (helix `v` select mode). The anchor is here
-/// (row/col in the same coords as the cursor: ABSOLUTE body row; content col for
-/// files, visual col for terminals); the head is the cursor. Motions extend it;
-/// d/c/y act on [anchor, cursor] inclusive. Mutually exclusive with LineSel.
-pub const CharSel = struct {
- active: bool = false,
- row: i32 = 0,
- col: i32 = 0,
-};
-
-pub const EDIT_CAP = 128; // max chars in one insertion run
-pub const MAX_EDITS = 16; // max concurrent insertion runs per window
-
-/// A file pane's backing state (see the `file` field below).
-pub const File = struct {
- path: []const u8, // gpa-owned absolute path
- content: []u8, // gpa-owned file bytes
- scroll: usize = 0, // top visible file line
- /// tree-sitter syntax styles, one byte per `content` byte (0 = none). Empty
- /// when the file's language isn't highlighted. gpa-owned. `highlight_start`
- /// is the content byte offset represented by `highlights[0]`.
- highlights: []u8 = &.{},
- highlight_start: usize = 0,
- /// The content or viewport changed since `highlights` was refreshed for the
- /// visible byte window.
- syntax_dirty: bool = false,
- /// dumb whole-content undo/redo: each entry is a gpa-owned snapshot of
- /// `content`. main.pushUndo snapshots before an edit (one per insert session /
- /// delete / paste); `u`/`U` move between them. Bounded (oldest dropped).
- undo: std.ArrayList([]u8) = .empty,
- redo: std.ArrayList([]u8) = .empty,
-};
-
-/// A run of inserted ("char mode") text. `col` is the ORIGINAL tty column it is
-/// spliced in *before* — the run shifts the underlying row right, it doesn't
-/// overwrite. `row`/`col` are pane-local (row carries the BOX_H tag offset).
-pub const Edit = struct {
- row: i32 = 0,
- col: i32 = 0,
- len: u16 = 0,
- text: [EDIT_CAP]u8 = undefined,
-};
-
-/// A dumb whole-state snapshot of a terminal pane's char-mode insertions, for
-/// undo/redo (the terminal analogue of File's content snapshots). Plain value —
-/// the edits array has no pointers — so it copies by assignment, no allocation.
-pub const EditSnap = struct {
- edits: [MAX_EDITS]Edit,
- nedits: usize,
- cur_row: i32,
- cur_col: i32,
- cur_off: u16,
-};
-
-pub const CwdOscState = enum { normal, esc, osc, osc7, collect, st_esc };
-
-/// Persistent per-terminal state: the pty handle, the ghostty emulator and its
-/// (stateful) VT parser, the background reader task, plus its layout weight and
-/// text selection.
-///
-/// Heap-allocate these and never move them: `stream`'s handler holds a pointer
-/// back into `term`, so the address must stay stable for parser state split
-/// across pty reads to keep working.
-pub const Term = @This();
-
-pty: std.Io.File,
-pid: std.posix.pid_t, // shell child; Linux uses /proc/<pid>/cwd for live cwd tags
-term: ghostty_vt.Terminal,
-stream: ghostty_vt.TerminalStream,
-reader: std.Io.Future(anyerror!void),
-/// Last known shell cwd for platforms without Linux procfs. macOS updates this
-/// from OSC 7 emitted by our shell rc; BSDs use the spawn cwd as a fallback.
-cwd: ?[]u8 = null,
-cwd_osc_state: CwdOscState = .normal,
-cwd_osc: std.ArrayList(u8) = .empty,
-
-/// When set, this pane is a (read/editable) text FILE view, not a terminal: its
-/// body comes from `content` instead of the emulator, and all the tty-specific
-/// handling (pty io, modes, prompt hiding) is skipped. Everything else — layout,
-/// selection, char-mode editing — is shared. pty.handle is -1, reader is a no-op.
-file: ?File = null,
-
-/// When set, this pane displays an image (kitty graphics) instead of text. Like a
-/// file pane it has no pty/reader (handle -1) and a stub emulator; the render loop
-/// transmits + draws it. See image.zig. Mutually exclusive with `file`.
-image: ?image_mod.View = null,
-
-/// A freshly forked terminal greets with `ls` once it has its real geometry. Set
-/// at fork; the resize loop fires the `ls` after sizing the pty, so the listing
-/// wraps to the right width (sending it at fork time uses the wrong, full size).
-greet: bool = false,
-
-/// current emulator grid size; we only push a resize when it actually changes.
-cols: u16,
-rows: u16,
-
-/// Raw bytes read from the pty and fed into ghostty. Dump/replay uses this as the
-/// backend-neutral terminal recording instead of serialized rendered cells.
-tty_stream: std.ArrayList(u8) = .empty,
-
-/// vertical weight within the column (siblings split the column height by this).
-vweight: f32 = 1,
-
-/// One independent selection per mouse button: 0=left, 1=middle, 2=right.
-sel: [3]Sel = .{ .{}, .{}, .{} },
-
-/// modal input mode (see Mode). DEFAULT is normal (helix): a block cursor that
-/// moves with hjkl; i/a/o enter insert; Esc reaches tty on terminals.
-mode: Mode = .normal,
-/// one pending prefix codepoint (0 = none): `g` (gg/ge/...) and `z` (zt/zz/zb).
-pending: u21 = 0,
-/// modal line selection from `x` (helix extend-line). In absolute body rows.
-msel: LineSel = .{},
-/// modal character-range selection from `v` (helix select). Anchor is here, head
-/// is the cursor. Mutually exclusive with `msel`.
-vsel: CharSel = .{},
-/// cursor and edits are anchored at ABSOLUTE body rows (visual row + scroll
-/// offset) so they ride the scrollback, not the screen. `cur_off` is the offset
-/// into the run sitting at (cur_row, cur_col). `cur_pinned` is false until the
-/// user clicks or types, while which the cursor tracks the shell's cursor.
-///
-/// For terminals in normal mode cur_col is a VISUAL column; in insert mode it is
-/// the original-tty column the run is anchored at (renderedCol maps it to visual).
-/// For file panes cur_col is a content column (no edit runs; edits stay empty).
-cur_pinned: bool = false,
-cur_row: i32 = 0,
-cur_col: i32 = 0,
-cur_off: u16 = 0,
-nedits: usize = 0,
-edits: [MAX_EDITS]Edit = @splat(.{}),
-/// terminal char-mode undo/redo: snapshots of the edit state, one per edit op
-/// (insert session / delete / paste). File panes use File.undo/redo instead;
-/// these stay empty for them. See main.pushUndo/doUndo.
-ed_undo: std.ArrayList(EditSnap) = .empty,
-ed_redo: std.ArrayList(EditSnap) = .empty,
-
-/// Editable "tail" of the tag line: everything after the live mode + cwd/path
-/// prefix (the builtin command names and anything the user types). Seeded with
-/// the default builtins the first time the tag is edited (`tag_init`), then
-/// freely editable — no newlines. `tag_col` is the cursor's byte index within
-/// the tail; `tag_edit` is true while the tag is focused for editing. Image
-/// panes keep a live (non-editable) tag, so these stay unused for them.
-tag_tail: std.ArrayList(u8) = .empty,
-tag_col: u16 = 0,
-tag_init: bool = false,
-tag_edit: bool = false,
-/// helix `v` char-select within the tag: inclusive [anchor, tag_col]. The tag
-/// shares the pane's `mode` (normal/insert), so no separate mode is kept.
-tag_sel: bool = false,
-tag_anchor: u16 = 0,
-
-/// A real shell pane (has a pty/emulator), as opposed to a file or image view.
-pub fn isTerminal(self: *const Term) bool {
- return self.file == null and self.image == null;
-}
-
-pub fn deinit(self: *Term, io: std.Io, gpa: std.mem.Allocator) void {
- if (self.file) |*f| {
- gpa.free(f.path);
- gpa.free(f.content);
- if (f.highlights.len > 0) gpa.free(f.highlights);
- for (f.undo.items) |s| gpa.free(s);
- for (f.redo.items) |s| gpa.free(s);
- f.undo.deinit(gpa);
- f.redo.deinit(gpa);
- } else if (self.image) |*iv| {
- gpa.free(iv.path);
- image_mod.deinitView(iv, gpa);
- } else {
- _ = self.reader.cancel(io) catch {};
- self.pty.close(io);
- }
- self.tty_stream.deinit(gpa);
- if (self.cwd) |cwd| gpa.free(cwd);
- self.cwd_osc.deinit(gpa);
- self.ed_undo.deinit(gpa);
- self.ed_redo.deinit(gpa);
- self.tag_tail.deinit(gpa);
- self.stream.deinit();
- self.term.deinit(gpa);
-}
diff --git a/e2e_harness.zig b/test/e2e_harness.zig
index cd4ffd63..6354aa4b 100644
--- a/e2e_harness.zig
+++ b/test/e2e_harness.zig
@@ -51,11 +51,20 @@ pub const Harness = struct {
/// Like init, but passes one extra CLI arg to the app (e.g. "--tty").
pub fn initArgs(gpa: std.mem.Allocator, exe: [*:0]const u8, rows: u16, cols: u16, arg: ?[*:0]const u8) !Harness {
+ var argv: [2]?[*:0]const u8 = .{ null, null };
+ if (arg) |a| argv[0] = a;
+ return initArgv(gpa, exe, rows, cols, argv[0..(if (arg != null) @as(usize, 1) else 0)]);
+ }
+
+ /// Like init, but passes extra CLI args (e.g. "-l", "dump.zon").
+ pub fn initArgv(gpa: std.mem.Allocator, exe: [*:0]const u8, rows: u16, cols: u16, args: []const ?[*:0]const u8) !Harness {
var master: c_int = undefined;
const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
const pid = forkpty(&master, null, null, &ws);
if (pid == 0) {
- const argv: [3:null]?[*:0]const u8 = .{ exe, arg, null };
+ var argv: [8:null]?[*:0]const u8 = @splat(null);
+ argv[0] = exe;
+ for (args, 0..) |a, i| argv[i + 1] = a;
_ = execvp(exe, &argv);
_exit(127);
}
diff --git a/test/snapshot.zig b/test/snapshot.zig
new file mode 100644
index 00000000..c1bf03c0
--- /dev/null
+++ b/test/snapshot.zig
@@ -0,0 +1,553 @@
+// pardes-snap: drive a pardes binary with a scripted input trace in a pty and
+// capture the rendered grid at named sync points. The same script run against
+// the prototype and the rewrite must produce byte-identical captures — this is
+// 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 -- snapshots/boot.snap one script
+//
+// Script commands (line-oriented, # comments):
+// file <name> <content> create file in the script's cwd (before start)
+// lines <name> <n> create file with n numbered lines
+// dirmk <name> create a subdirectory
+// start <rows> <cols> [arg] fork the app in a pty (optional extra CLI arg)
+// wait <ms> <needle...> pump until needle appears on the grid (fails hard)
+// settle <ms> pump for a fixed duration
+// stable <quiet_ms> <timeout_ms> pump until the grid stops changing
+// text <literal> send literal bytes
+// send <escaped> send with \e \r \n \t \\ \xNN escapes
+// key <names...> enter esc tab bs space up down left right home end
+// pgup pgdn c-<ch> a-<ch> (after esc: settle >=600ms,
+// or vaxis parses the next byte as alt-<ch>)
+// press|release <left|middle|right> <col> <row> SGR mouse, 1-based
+// drag <btn> <col> <row> motion with button held
+// motion <col> <row> button-less motion (hover)
+// wheel <up|down> <col> <row>
+// resize <rows> <cols>
+// snap <label> capture grid text + cursor
+// snapstyle <label> capture per-cell style runs (fg,bg,attrs)
+const std = @import("std");
+const posix = std.posix;
+const linux = std.os.linux;
+const ghostty_vt = @import("ghostty-vt");
+const eh = @import("e2e_harness.zig");
+
+extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
+extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
+
+const SNAP_BASE = "/tmp/pardes-snap";
+const gpa = std.heap.page_allocator;
+
+pub fn main(init: std.process.Init) !void {
+ var arena_state = std.heap.ArenaAllocator.init(gpa);
+ defer arena_state.deinit();
+ const arena = arena_state.allocator();
+ const args = try init.minimal.args.toSlice(init.arena.allocator());
+
+ // argv: exe-under-test, then flags/scripts. All paths made absolute up
+ // front because runScript chdirs into a per-script workdir.
+ var cwd_buf: [4096]u8 = undefined;
+ const cwd_rc: isize = @bitCast(linux.getcwd(&cwd_buf, cwd_buf.len));
+ if (cwd_rc <= 0) fatal("getcwd failed", .{});
+ const orig_cwd = cwd_buf[0..@intCast(cwd_rc - 1)];
+
+ var exe: ?[]const u8 = null;
+ var update = false;
+ var scripts: std.ArrayList([]const u8) = .empty;
+ for (args[1..]) |a| {
+ if (std.mem.eql(u8, a, "--update")) {
+ update = true;
+ } else if (exe == null) {
+ exe = try absPath(arena, orig_cwd, a);
+ } else {
+ try scripts.append(arena, try absPath(arena, orig_cwd, a));
+ }
+ }
+ if (exe == null) fatal("usage: pardes-snap <exe> [scripts...] [--update]", .{});
+ const exe_z = try arena.dupeZ(u8, exe.?);
+
+ if (scripts.items.len == 0) {
+ try listSnaps(arena, try absPath(arena, orig_cwd, "test/snapshots"), &scripts);
+ std.mem.sort([]const u8, scripts.items, {}, struct {
+ fn lt(_: void, x: []const u8, y: []const u8) bool {
+ return std.mem.lessThan(u8, x, y);
+ }
+ }.lt);
+ }
+ if (scripts.items.len == 0) fatal("no .snap scripts found", .{});
+
+ var failed: usize = 0;
+ for (scripts.items) |script_path| {
+ const stem = std.fs.path.stem(script_path);
+ const base_path = script_path[0 .. script_path.len - ".snap".len];
+ const golden_path = try std.fmt.allocPrint(arena, "{s}.golden", .{base_path});
+ const out = runScript(arena, exe_z, script_path, stem) catch |e| {
+ std.debug.print("FAIL {s}: script error {s}\n", .{ stem, @errorName(e) });
+ failed += 1;
+ continue;
+ };
+ if (update) {
+ try eh.writeFile(try arena.dupeZ(u8, golden_path), out);
+ std.debug.print("UPDATED {s} ({d} bytes)\n", .{ stem, out.len });
+ } else {
+ const golden = readFileAlloc(arena, golden_path) catch {
+ std.debug.print("FAIL {s}: no golden (run with -- --update)\n", .{stem});
+ failed += 1;
+ continue;
+ };
+ if (std.mem.eql(u8, golden, out)) {
+ std.debug.print("PASS {s}\n", .{stem});
+ } else {
+ const actual_path = try std.fmt.allocPrint(arena, "{s}.actual", .{base_path});
+ try eh.writeFile(try arena.dupeZ(u8, actual_path), out);
+ std.debug.print("FAIL {s}: differs from golden (actual written to {s})\n", .{ stem, actual_path });
+ printFirstDiff(golden, out);
+ failed += 1;
+ }
+ }
+ }
+ if (failed > 0) {
+ std.debug.print("{d}/{d} snapshot scripts FAILED\n", .{ failed, scripts.items.len });
+ std.process.exit(1);
+ }
+ std.debug.print("all {d} snapshot scripts ok\n", .{scripts.items.len});
+}
+
+fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const u8, stem: []const u8) ![]u8 {
+ const src = try readFileAlloc(arena, script_path);
+
+ // Fixed, recreated-per-run workdir: its PATH shows up in pane tags, so it
+ // must be identical across runs and across old/new binaries.
+ const base = try std.fmt.allocPrint(arena, "{s}/{s}", .{ SNAP_BASE, stem });
+ rmrf(base);
+ try mkdir(arena, SNAP_BASE, true);
+ try mkdir(arena, base, false);
+ const home = try std.fmt.allocPrint(arena, "{s}/home", .{base});
+ const work = try std.fmt.allocPrint(arena, "{s}/cwd", .{base});
+ try mkdir(arena, home, false);
+ try mkdir(arena, work, false);
+ // deterministic shell: fixed prompt, no history, no user rc leakage
+ try eh.writeFile(try std.fmt.allocPrintSentinel(arena, "{s}/.bashrc", .{home}, 0), "PS1='$ '\nHISTFILE=\n");
+ _ = setenv("HOME", try arena.dupeZ(u8, home), 1);
+ _ = setenv("TERM", "xterm-256color", 1);
+ _ = setenv("LC_ALL", "C", 1);
+ if (chdirRc(try arena.dupeZ(u8, work)) < 0) return error.ChdirFailed;
+
+ var out: std.ArrayList(u8) = .empty;
+ var h: ?eh.Harness = null;
+ defer if (h) |*hp| hp.deinit();
+
+ var lines_it = std.mem.splitScalar(u8, src, '\n');
+ var lineno: usize = 0;
+ while (lines_it.next()) |raw_line| {
+ lineno += 1;
+ const line = std.mem.trim(u8, raw_line, " \t\r");
+ if (line.len == 0 or line[0] == '#') continue;
+ var tok = std.mem.tokenizeScalar(u8, line, ' ');
+ const cmd = tok.next().?;
+ errdefer std.debug.print(" at {s}:{d}: {s}\n", .{ script_path, lineno, line });
+
+ if (std.mem.eql(u8, cmd, "file")) {
+ const name = tok.next() orelse return error.BadScript;
+ try eh.writeFile(try arena.dupeZ(u8, name), try unescape(arena, tok.rest()));
+ } else if (std.mem.eql(u8, cmd, "lines")) {
+ const name = tok.next() orelse return error.BadScript;
+ const n = try std.fmt.parseInt(usize, tok.next() orelse return error.BadScript, 10);
+ var buf: std.ArrayList(u8) = .empty;
+ for (1..n + 1) |i| try buf.appendSlice(arena, try std.fmt.allocPrint(arena, "line {d}\n", .{i}));
+ try eh.writeFile(try arena.dupeZ(u8, name), buf.items);
+ } else if (std.mem.eql(u8, cmd, "ppmdiag")) {
+ // the 64x64 diagonal-split test PPM (white/blue) from the old e2e
+ const name = tok.next() orelse return error.BadScript;
+ var ppm: std.ArrayList(u8) = .empty;
+ try ppm.appendSlice(arena, "P6\n64 64\n255\n");
+ var py: usize = 0;
+ while (py < 64) : (py += 1) {
+ var px: usize = 0;
+ while (px < 64) : (px += 1)
+ try ppm.appendSlice(arena, if (px + py < 64) &[_]u8{ 255, 255, 255 } else &[_]u8{ 0, 0, 255 });
+ }
+ try eh.writeFile(try arena.dupeZ(u8, name), ppm.items);
+ } else if (std.mem.eql(u8, cmd, "dirmk")) {
+ try mkdir(arena, tok.next() orelse return error.BadScript, false);
+ } else if (std.mem.eql(u8, cmd, "start")) {
+ const rows = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ const cols = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ var extra: [6]?[*:0]const u8 = @splat(null);
+ var nextra: usize = 0;
+ while (tok.next()) |a| : (nextra += 1) {
+ if (nextra >= extra.len) return error.BadScript;
+ extra[nextra] = (try arena.dupeZ(u8, a)).ptr;
+ }
+ if (h) |*hp| hp.deinit(); // scripts may restart (e.g. dump then -l)
+ h = try eh.Harness.initArgv(gpa, exe_z.ptr, rows, cols, extra[0..nextra]);
+ } else if (std.mem.eql(u8, cmd, "snapfile")) {
+ const label = tok.next() orelse return error.BadScript;
+ const contents = try readFileAlloc(arena, tok.rest());
+ try out.appendSlice(arena, try std.fmt.allocPrint(arena, "== file {s}\n", .{label}));
+ // elide base64 payloads: they encode raw pty byte HISTORY, whose
+ // micro-timing (prompt redraw cycles) legitimately differs between
+ // implementations; the cleaned text fields are the contract.
+ var flines = std.mem.splitScalar(u8, contents, '\n');
+ while (flines.next()) |fl| {
+ if (std.mem.indexOf(u8, fl, "_b64 = \"") != null) {
+ const eq = std.mem.indexOf(u8, fl, "=").?;
+ try out.appendSlice(arena, fl[0 .. eq + 1]);
+ try out.appendSlice(arena, " <elided>\n");
+ } else {
+ try out.appendSlice(arena, fl);
+ try out.append(arena, '\n');
+ }
+ }
+ try out.appendSlice(arena, "== end file\n");
+ } else if (std.mem.eql(u8, cmd, "wait")) {
+ const ms = try std.fmt.parseInt(i64, tok.next() orelse return error.BadScript, 10);
+ try live(&h).expectWaitFor(tok.rest(), ms, "snapshot wait");
+ } else if (std.mem.eql(u8, cmd, "settle")) {
+ try live(&h).pump(try std.fmt.parseInt(i64, tok.next() orelse return error.BadScript, 10));
+ } else if (std.mem.eql(u8, cmd, "stable")) {
+ const quiet = try std.fmt.parseInt(i64, tok.next() orelse return error.BadScript, 10);
+ const timeout = try std.fmt.parseInt(i64, tok.next() orelse return error.BadScript, 10);
+ try waitStable(live(&h), quiet, timeout);
+ } else if (std.mem.eql(u8, cmd, "text")) {
+ try live(&h).send(tok.rest());
+ } else if (std.mem.eql(u8, cmd, "send")) {
+ try live(&h).send(try unescape(arena, tok.rest()));
+ } else if (std.mem.eql(u8, cmd, "key")) {
+ while (tok.next()) |name| try live(&h).send(keyBytes(name) orelse return error.BadKey);
+ } else if (std.mem.eql(u8, cmd, "press") or std.mem.eql(u8, cmd, "release") or std.mem.eql(u8, cmd, "drag")) {
+ const btn = buttonCode(tok.next() orelse return error.BadScript) orelse return error.BadScript;
+ const col = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ const row = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ const b: u16 = if (std.mem.eql(u8, cmd, "drag")) btn + 32 else btn;
+ const fin: u8 = if (std.mem.eql(u8, cmd, "release")) 'm' else 'M';
+ try live(&h).send(try std.fmt.allocPrint(arena, "\x1b[<{d};{d};{d}{c}", .{ b, col, row, fin }));
+ } else if (std.mem.eql(u8, cmd, "motion")) {
+ const col = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ const row = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ try live(&h).send(try std.fmt.allocPrint(arena, "\x1b[<35;{d};{d}M", .{ col, row }));
+ } else if (std.mem.eql(u8, cmd, "wheel")) {
+ const dir = tok.next() orelse return error.BadScript;
+ const b: u16 = if (std.mem.eql(u8, dir, "up")) 64 else 65;
+ const col = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ const row = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ try live(&h).send(try std.fmt.allocPrint(arena, "\x1b[<{d};{d};{d}M", .{ b, col, row }));
+ } else if (std.mem.eql(u8, cmd, "resize")) {
+ const rows = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ const cols = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ try live(&h).resize(rows, cols);
+ } else if (std.mem.eql(u8, cmd, "snap")) {
+ try snapText(arena, &out, live(&h), tok.rest());
+ } else if (std.mem.eql(u8, cmd, "snapstyle")) {
+ try snapStyle(arena, &out, live(&h), tok.rest());
+ } else {
+ std.debug.print("unknown command {s}\n", .{cmd});
+ return error.BadScript;
+ }
+ }
+ return out.items;
+}
+
+fn live(h: *?eh.Harness) *eh.Harness {
+ if (h.*) |*v| return v;
+ fatal("script used the app before `start`", .{});
+}
+
+/// Pump until the grid is unchanged for `quiet` ms (bounded by `timeout`).
+/// The one sync primitive that needs no needle — used before every snap so
+/// old and new binaries are compared at rest, not mid-repaint.
+fn waitStable(h: *eh.Harness, quiet: i64, timeout: i64) !void {
+ var total: i64 = 0;
+ var prev = try h.screenText();
+ defer h.gpa.free(prev);
+ while (total < timeout) {
+ try h.pump(quiet);
+ total += quiet;
+ const cur = try h.screenText();
+ if (std.mem.eql(u8, prev, cur)) {
+ h.gpa.free(cur);
+ return;
+ }
+ h.gpa.free(prev);
+ prev = cur;
+ }
+ return error.NeverStable;
+}
+
+fn snapText(arena: std.mem.Allocator, out: *std.ArrayList(u8), h: *eh.Harness, label: []const u8) !void {
+ const text = try h.screenText();
+ defer h.gpa.free(text);
+ const c = h.cursor();
+ try out.appendSlice(arena, try std.fmt.allocPrint(arena, "== snap {s} grid={d}x{d} cursor={d},{d}\n", .{ label, h.cols, h.rows, c.x, c.y }));
+ var it = std.mem.splitScalar(u8, text, '\n');
+ var row: usize = 0;
+ while (row < h.rows) : (row += 1) {
+ const line = it.next() orelse "";
+ try out.append(arena, '|');
+ try out.appendSlice(arena, std.mem.trimEnd(u8, line, " "));
+ try out.append(arena, '\n');
+ }
+}
+
+/// Per-row style runs from the harness emulator: `|y: x0-x1 fg,bg,attrs ...`.
+/// fg/bg: d (default) | pN (palette) | #rrggbb. attrs: b f i k r v x + u U ~ . -
+/// for underline single/double/curly/dotted/dashed.
+fn snapStyle(arena: std.mem.Allocator, out: *std.ArrayList(u8), h: *eh.Harness, label: []const u8) !void {
+ try out.appendSlice(arena, try std.fmt.allocPrint(arena, "== style {s}\n", .{label}));
+ var y: u16 = 0;
+ while (y < h.rows) : (y += 1) {
+ try out.appendSlice(arena, try std.fmt.allocPrint(arena, "|{d}:", .{y}));
+ var run_buf: [64]u8 = undefined;
+ var key_buf: [64]u8 = undefined;
+ var run_key: []const u8 = "";
+ var run_start: u16 = 0;
+ var x: u16 = 0;
+ while (x <= h.cols) : (x += 1) {
+ const key: []const u8 = if (x == h.cols) "\x00end" else cellKey(h, x, y, &key_buf);
+ if (x == 0) {
+ run_key = try arena.dupe(u8, key);
+ continue;
+ }
+ if (std.mem.eql(u8, key, run_key)) continue;
+ try out.appendSlice(arena, try std.fmt.bufPrint(&run_buf, " {d}-{d} ", .{ run_start, x - 1 }));
+ try out.appendSlice(arena, run_key);
+ run_key = try arena.dupe(u8, key);
+ run_start = x;
+ }
+ try out.append(arena, '\n');
+ }
+}
+
+fn cellKey(h: *eh.Harness, x: u16, y: u16, buf: *[64]u8) []const u8 {
+ const ci = h.term.screens.active.pages.getCell(.{ .viewport = .{ .x = x, .y = y } }) orelse return "d,d,";
+ if (ci.cell.wide == .spacer_tail) return "d,d,";
+ const style = ci.style();
+ var n: usize = 0;
+ var tmp: [16]u8 = undefined;
+ const fg = colorStr(style.fg_color, &tmp);
+ @memcpy(buf[n..][0..fg.len], fg);
+ n += fg.len;
+ buf[n] = ',';
+ n += 1;
+ var tmp2: [16]u8 = undefined;
+ const bg: []const u8 = switch (ci.cell.content_tag) {
+ .bg_color_palette => std.fmt.bufPrint(&tmp2, "p{d}", .{ci.cell.content.color_palette.data}) catch "?",
+ .bg_color_rgb => blk: {
+ const rgb = ci.cell.content.color_rgb;
+ break :blk std.fmt.bufPrint(&tmp2, "#{x:0>2}{x:0>2}{x:0>2}", .{ rgb.r, rgb.g, rgb.b }) catch "?";
+ },
+ else => colorStr(style.bg_color, &tmp2),
+ };
+ @memcpy(buf[n..][0..bg.len], bg);
+ n += bg.len;
+ buf[n] = ',';
+ n += 1;
+ if (style.flags.bold) {
+ buf[n] = 'b';
+ n += 1;
+ }
+ if (style.flags.faint) {
+ buf[n] = 'f';
+ n += 1;
+ }
+ if (style.flags.italic) {
+ buf[n] = 'i';
+ n += 1;
+ }
+ if (style.flags.blink) {
+ buf[n] = 'k';
+ n += 1;
+ }
+ if (style.flags.inverse) {
+ buf[n] = 'r';
+ n += 1;
+ }
+ if (style.flags.invisible) {
+ buf[n] = 'v';
+ n += 1;
+ }
+ if (style.flags.strikethrough) {
+ buf[n] = 'x';
+ n += 1;
+ }
+ const ul: []const u8 = switch (style.flags.underline) {
+ .none => "",
+ .single => "u",
+ .double => "U",
+ .curly => "~",
+ .dotted => ".",
+ .dashed => "-",
+ };
+ @memcpy(buf[n..][0..ul.len], ul);
+ n += ul.len;
+ return buf[0..n];
+}
+
+fn colorStr(color: ghostty_vt.Style.Color, buf: *[16]u8) []const u8 {
+ return switch (color) {
+ .none => "d",
+ .palette => |idx| std.fmt.bufPrint(buf, "p{d}", .{idx}) catch "?",
+ .rgb => |rgb| std.fmt.bufPrint(buf, "#{x:0>2}{x:0>2}{x:0>2}", .{ rgb.r, rgb.g, rgb.b }) catch "?",
+ };
+}
+
+fn keyBytes(name: []const u8) ?[]const u8 {
+ const map = .{
+ .{ "enter", "\r" }, .{ "esc", "\x1b" }, .{ "tab", "\t" },
+ .{ "bs", "\x7f" }, .{ "space", " " }, .{ "up", "\x1b[A" },
+ .{ "down", "\x1b[B" }, .{ "right", "\x1b[C" }, .{ "left", "\x1b[D" },
+ .{ "home", "\x1b[H" }, .{ "end", "\x1b[F" }, .{ "pgup", "\x1b[5~" },
+ .{ "pgdn", "\x1b[6~" },
+ };
+ inline for (map) |e| if (std.mem.eql(u8, name, e[0])) return e[1];
+ const S = struct {
+ var buf: [2]u8 = undefined;
+ };
+ if (name.len == 1) {
+ S.buf[0] = name[0];
+ return S.buf[0..1];
+ }
+ if (name.len == 3 and name[1] == '-') {
+ if (name[0] == 'c' and name[2] >= 'a' and name[2] <= 'z') {
+ S.buf[0] = name[2] - 'a' + 1;
+ return S.buf[0..1];
+ }
+ if (name[0] == 'a') {
+ S.buf = .{ 0x1b, name[2] };
+ return S.buf[0..2];
+ }
+ }
+ return null;
+}
+
+fn buttonCode(name: []const u8) ?u16 {
+ if (std.mem.eql(u8, name, "left")) return 0;
+ if (std.mem.eql(u8, name, "middle")) return 1;
+ if (std.mem.eql(u8, name, "right")) return 2;
+ return null;
+}
+
+fn unescape(arena: std.mem.Allocator, s: []const u8) ![]const u8 {
+ var buf: std.ArrayList(u8) = .empty;
+ var i: usize = 0;
+ while (i < s.len) : (i += 1) {
+ if (s[i] != '\\' or i + 1 >= s.len) {
+ try buf.append(arena, s[i]);
+ continue;
+ }
+ i += 1;
+ switch (s[i]) {
+ 'e' => try buf.append(arena, 0x1b),
+ 'r' => try buf.append(arena, '\r'),
+ 'n' => try buf.append(arena, '\n'),
+ 't' => try buf.append(arena, '\t'),
+ '\\' => try buf.append(arena, '\\'),
+ 'x' => {
+ if (i + 2 >= s.len) return error.BadEscape;
+ try buf.append(arena, try std.fmt.parseInt(u8, s[i + 1 .. i + 3], 16));
+ i += 2;
+ },
+ else => return error.BadEscape,
+ }
+ }
+ return buf.items;
+}
+
+fn absPath(arena: std.mem.Allocator, cwd: []const u8, p: []const u8) ![]const u8 {
+ if (p.len > 0 and p[0] == '/') return try arena.dupe(u8, p);
+ return try std.fmt.allocPrint(arena, "{s}/{s}", .{ cwd, p });
+}
+
+fn listSnaps(arena: std.mem.Allocator, dir_path: []const u8, scripts: *std.ArrayList([]const u8)) !void {
+ const dir_z = try arena.dupeZ(u8, dir_path);
+ const rc = linux.open(dir_z, .{ .ACCMODE = .RDONLY, .DIRECTORY = true }, 0);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) fatal("no snapshots/ dir at {s}", .{dir_path});
+ const fd: i32 = @intCast(sfd);
+ defer _ = linux.close(fd);
+ var buf: [8192]u8 align(@alignOf(linux.dirent64)) = undefined;
+ while (true) {
+ const r = linux.getdents64(fd, &buf, buf.len);
+ const n: isize = @bitCast(r);
+ if (n <= 0) break;
+ var off: usize = 0;
+ while (off < @as(usize, @intCast(n))) {
+ const d: *align(1) linux.dirent64 = @ptrCast(&buf[off]);
+ const name = std.mem.span(@as([*:0]const u8, @ptrCast(&d.name)));
+ if (std.mem.endsWith(u8, name, ".snap"))
+ try scripts.append(arena, try std.fmt.allocPrint(arena, "{s}/{s}", .{ dir_path, name }));
+ off += d.reclen;
+ }
+ }
+}
+
+fn readFileAlloc(arena: std.mem.Allocator, path: []const u8) ![]u8 {
+ const path_z = try arena.dupeZ(u8, path);
+ const rc = linux.open(path_z, .{ .ACCMODE = .RDONLY }, 0);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) return error.OpenFailed;
+ const fd: i32 = @intCast(sfd);
+ defer _ = linux.close(fd);
+ var buf: std.ArrayList(u8) = .empty;
+ var chunk: [16384]u8 = undefined;
+ while (true) {
+ const r = linux.read(fd, &chunk, chunk.len);
+ const n: isize = @bitCast(r);
+ if (n < 0) {
+ if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue;
+ return error.ReadFailed;
+ }
+ if (n == 0) break;
+ try buf.appendSlice(arena, chunk[0..@intCast(n)]);
+ }
+ return buf.items;
+}
+
+fn mkdir(arena: std.mem.Allocator, path: []const u8, exist_ok: bool) !void {
+ const path_z = try arena.dupeZ(u8, path);
+ const n: isize = @bitCast(linux.mkdir(path_z, 0o755));
+ if (n < 0) {
+ if (exist_ok and n == -@as(isize, @intFromEnum(linux.E.EXIST))) return;
+ return error.MkdirFailed;
+ }
+}
+
+fn chdirRc(path_z: [:0]const u8) isize {
+ return @bitCast(linux.chdir(path_z.ptr));
+}
+
+fn rmrf(path: []const u8) void {
+ var path_buf: [4096:0]u8 = undefined;
+ if (path.len >= path_buf.len) return;
+ @memcpy(path_buf[0..path.len], path);
+ path_buf[path.len] = 0;
+ const pid: isize = @bitCast(linux.fork());
+ if (pid == 0) {
+ const argv: [4:null]?[*:0]const u8 = .{ "rm", "-rf", @as([*:0]const u8, @ptrCast(&path_buf)), null };
+ _ = execvp("rm", &argv);
+ linux.exit_group(127);
+ }
+ if (pid < 0) return;
+ var status: u32 = 0;
+ _ = linux.wait4(@intCast(pid), &status, 0, null);
+}
+
+fn printFirstDiff(golden: []const u8, actual: []const u8) void {
+ var gi = std.mem.splitScalar(u8, golden, '\n');
+ var ai = std.mem.splitScalar(u8, actual, '\n');
+ var n: usize = 1;
+ while (true) : (n += 1) {
+ const g = gi.next();
+ const a = ai.next();
+ if (g == null and a == null) return;
+ if (g != null and a != null and std.mem.eql(u8, g.?, a.?)) continue;
+ std.debug.print(" first diff at golden line {d}:\n -{s}\n +{s}\n", .{ n, g orelse "<eof>", a orelse "<eof>" });
+ return;
+ }
+}
+
+fn fatal(comptime fmt: []const u8, args: anytype) noreturn {
+ std.debug.print(fmt ++ "\n", args);
+ std.process.exit(1);
+}
diff --git a/test/snapshots/boot.golden b/test/snapshots/boot.golden
new file mode 100644
index 00000000..85bac8dd
--- /dev/null
+++ b/test/snapshots/boot.golden
@@ -0,0 +1,62 @@
+== snap boot grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/boot/cwd Del nm /tmp/pardes-snap/boot/cwd Del
+|
+| hello.txt hello.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/boot/cwd Del
+|
+| hello.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== style boot
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-33 #969696,#2c2a3e, 34-49 d,#2c2a3e, 50-51 d,#383c54, 52-83 #969696,#2c2a3e, 84-99 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#383c54, 2-33 #969696,#2c2a3e, 34-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
diff --git a/test/snapshots/boot.snap b/test/snapshots/boot.snap
new file mode 100644
index 00000000..33b12be0
--- /dev/null
+++ b/test/snapshots/boot.snap
@@ -0,0 +1,8 @@
+# default startup: 2 columns, 3 shells, each greets with ls; prompts hidden
+file hello.txt marker-content
+start 30 100
+wait 8000 Kill Newcol
+wait 8000 hello.txt
+stable 700 20000
+snap boot
+snapstyle boot
diff --git a/test/snapshots/dump.actual b/test/snapshots/dump.actual
new file mode 100644
index 00000000..cf39f324
--- /dev/null
+++ b/test/snapshots/dump.actual
@@ -0,0 +1,63 @@
+== file dump (3004 bytes)
+.{
+ .magic = "pardes-dump",
+ .version = 1,
+ .screen = .{ .cols = 100, .rows = 30 },
+ .active = 0,
+ .topbar = "Kill Newcol Tutor Debug Colors NextColor Dump",
+ .theme = "dark",
+ .columns = .{ .{ .weight = 1, .panes = .{ 0, 1 } }, .{ .weight = 1, .panes = .{2} } },
+ .panes = .{
+ .{
+ .kind = .terminal,
+ .tag = "nm /tmp/pardes-snap/dump/cwd Del",
+ .body = "\nd.txt\n\n\n\n typed-marker\n\n\n\n\n\n\n\n",
+ .scroll = 0,
+ .cols = 48,
+ .rows = 14,
+ .vweight = 1,
+ .terminal = .{
+ .cwd = "/tmp/pardes-snap/dump/cwd",
+ .stream = "\nd.txt\n",
+ .stream_b64 = "G10xMzM7QwcbXTEzMztEBxtdMTMzO0MHG10wO2dvYmxpbkBwYXJkZXM6L3RtcC9wYXJkZXMtc25hcC9kdW1wL2N3ZAcbWz8yMDA0aBtdMTMzO0E7Y2w9bGluZQckIBtdMTMzO0IHDRtbSw0bXTEzMztBO2NsPWxpbmUHJCAbXTEzMztCB2xzDQobWz8yMDA0bA0bXTEzMztDB2QudHh0DQobXTEzMztDBxtdMTMzO0QHG10xMzM7QwcbXTA7Z29ibGluQHBhcmRlczovdG1wL3BhcmRlcy1zbmFwL2R1bXAvY3dkBxtbPzIwMDRoG10xMzM7QTtjbD1saW5lByQgG10xMzM7Qgc=",
+ .cursor = .{ .col = 2, .row = 2 },
+ },
+ .file = null,
+ .image = null,
+ },
+ .{
+ .kind = .terminal,
+ .tag = "nm /tmp/pardes-snap/dump/cwd Del",
+ .body = "\nd.txt\n\n\n\n\n\n\n\n\n\n\n",
+ .scroll = 0,
+ .cols = 48,
+ .rows = 13,
+ .vweight = 1,
+ .terminal = .{
+ .cwd = "/tmp/pardes-snap/dump/cwd",
+ .stream = "\nd.txt\n",
+ .stream_b64 = "G10xMzM7QwcbXTEzMztEBxtdMTMzO0MHG10wO2dvYmxpbkBwYXJkZXM6L3RtcC9wYXJkZXMtc25hcC9kdW1wL2N3ZAcbWz8yMDA0aBtdMTMzO0E7Y2w9bGluZQckIBtdMTMzO0IHDRtbSw0bXTEzMztBO2NsPWxpbmUHJCAbXTEzMztCB2xzDQobWz8yMDA0bA0bXTEzMztDB2QudHh0DQobXTEzMztDBxtdMTMzO0QHG10xMzM7QwcbXTA7Z29ibGluQHBhcmRlczovdG1wL3BhcmRlcy1zbmFwL2R1bXAvY3dkBxtbPzIwMDRoG10xMzM7QTtjbD1saW5lByQgG10xMzM7Qgc=",
+ .cursor = .{ .col = 2, .row = 2 },
+ },
+ .file = null,
+ .image = null,
+ },
+ .{
+ .kind = .terminal,
+ .tag = "nm /tmp/pardes-snap/dump/cwd Del",
+ .body = "\nd.txt\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",
+ .scroll = 0,
+ .cols = 48,
+ .rows = 28,
+ .vweight = 1,
+ .terminal = .{
+ .cwd = "/tmp/pardes-snap/dump/cwd",
+ .stream = "\nd.txt\n",
+ .stream_b64 = "G10xMzM7QwcbXTEzMztEBxtdMTMzO0MHG10wO2dvYmxpbkBwYXJkZXM6L3RtcC9wYXJkZXMtc25hcC9kdW1wL2N3ZAcbWz8yMDA0aBtdMTMzO0E7Y2w9bGluZQckIBtdMTMzO0IHDRtbSw0bXTEzMztBO2NsPWxpbmUHJCAbXTEzMztCB2xzDQobWz8yMDA0bA0bXTEzMztDB2QudHh0DQobXTEzMztDBxtdMTMzO0QHG10xMzM7QwcbXTA7Z29ibGluQHBhcmRlczovdG1wL3BhcmRlcy1zbmFwL2R1bXAvY3dkBxtbPzIwMDRoG10xMzM7QTtjbD1saW5lByQgG10xMzM7Qgc=",
+ .cursor = .{ .col = 2, .row = 2 },
+ },
+ .file = null,
+ .image = null,
+ },
+ },
+}== end file
diff --git a/test/snapshots/dump.golden b/test/snapshots/dump.golden
new file mode 100644
index 00000000..c18003d5
--- /dev/null
+++ b/test/snapshots/dump.golden
@@ -0,0 +1,64 @@
+== file dump
+.{
+ .magic = "pardes-dump",
+ .version = 1,
+ .screen = .{ .cols = 100, .rows = 30 },
+ .active = 0,
+ .topbar = "Kill Newcol Tutor Debug Colors NextColor Dump",
+ .theme = "dark",
+ .columns = .{ .{ .weight = 1, .panes = .{ 0, 1 } }, .{ .weight = 1, .panes = .{2} } },
+ .panes = .{
+ .{
+ .kind = .terminal,
+ .tag = "nm /tmp/pardes-snap/dump/cwd Del",
+ .body = "\nd.txt\n\n\n\n typed-marker\n\n\n\n\n\n\n\n",
+ .scroll = 0,
+ .cols = 48,
+ .rows = 14,
+ .vweight = 1,
+ .terminal = .{
+ .cwd = "/tmp/pardes-snap/dump/cwd",
+ .stream = "\nd.txt\n",
+ .stream_b64 = <elided>
+ .cursor = .{ .col = 2, .row = 2 },
+ },
+ .file = null,
+ .image = null,
+ },
+ .{
+ .kind = .terminal,
+ .tag = "nm /tmp/pardes-snap/dump/cwd Del",
+ .body = "\nd.txt\n\n\n\n\n\n\n\n\n\n\n",
+ .scroll = 0,
+ .cols = 48,
+ .rows = 13,
+ .vweight = 1,
+ .terminal = .{
+ .cwd = "/tmp/pardes-snap/dump/cwd",
+ .stream = "\nd.txt\n",
+ .stream_b64 = <elided>
+ .cursor = .{ .col = 2, .row = 2 },
+ },
+ .file = null,
+ .image = null,
+ },
+ .{
+ .kind = .terminal,
+ .tag = "nm /tmp/pardes-snap/dump/cwd Del",
+ .body = "\nd.txt\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",
+ .scroll = 0,
+ .cols = 48,
+ .rows = 28,
+ .vweight = 1,
+ .terminal = .{
+ .cwd = "/tmp/pardes-snap/dump/cwd",
+ .stream = "\nd.txt\n",
+ .stream_b64 = <elided>
+ .cursor = .{ .col = 2, .row = 2 },
+ },
+ .file = null,
+ .image = null,
+ },
+ },
+}
+== end file
diff --git a/test/snapshots/dump.snap b/test/snapshots/dump.snap
new file mode 100644
index 00000000..85664c2f
--- /dev/null
+++ b/test/snapshots/dump.snap
@@ -0,0 +1,18 @@
+# the Dump builtin serializes the session to /tmp/pardes-dump.zon; the ZON must
+# be byte-identical between old and new (golden generated from the OLD binary)
+file d.txt x
+start 30 100
+wait 8000 d.txt
+stable 700 20000
+press left 5 8
+release left 5 8
+stable 400 5000
+key i
+text typed-marker
+key esc
+settle 700
+stable 400 5000
+press middle 43 1
+release middle 43 1
+settle 800
+snapfile dump /tmp/pardes-dump.zon
diff --git a/test/snapshots/edit.actual b/test/snapshots/edit.actual
new file mode 100644
index 00000000..e45687a2
--- /dev/null
+++ b/test/snapshots/edit.actual
@@ -0,0 +1,62 @@
+== snap inserted grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/edit/cwd Del nm /tmp/pardes-snap/edit/cwd Del
+|
+| e.txt e.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/edit/cwd Del
+|
+| e.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== style inserted
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-33 #969696,#2c2a3e, 34-49 d,#2c2a3e, 50-51 d,#383c54, 52-83 #969696,#2c2a3e, 84-99 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#383c54, 2-33 #969696,#2c2a3e, 34-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
diff --git a/test/snapshots/edit.golden b/test/snapshots/edit.golden
new file mode 100644
index 00000000..846bfa30
--- /dev/null
+++ b/test/snapshots/edit.golden
@@ -0,0 +1,62 @@
+== snap inserted grid=100x30 cursor=4,9
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/edit/cwd Del nm /tmp/pardes-snap/edit/cwd Del
+|
+| e.txt e.txt
+|
+|
+|
+|
+|
+| hello-insert
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/edit/cwd Del
+|
+| e.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== style inserted
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-33 #969696,#2c2a3e, 34-49 d,#2c2a3e, 50-51 d,#383c54, 52-83 #969696,#2c2a3e, 84-99 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#383c54, 2-33 #969696,#2c2a3e, 34-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
diff --git a/test/snapshots/edit.snap b/test/snapshots/edit.snap
new file mode 100644
index 00000000..660add70
--- /dev/null
+++ b/test/snapshots/edit.snap
@@ -0,0 +1,15 @@
+# insert-mode splice: click into a body row, type, escape back to normal
+file e.txt x
+start 30 100
+wait 8000 e.txt
+stable 700 20000
+press left 5 10
+release left 5 10
+settle 400
+key i
+text hello-insert
+key esc
+settle 700
+stable 500 8000
+snap inserted
+snapstyle inserted
diff --git a/test/snapshots/exec.golden b/test/snapshots/exec.golden
new file mode 100644
index 00000000..23b1305b
--- /dev/null
+++ b/test/snapshots/exec.golden
@@ -0,0 +1,124 @@
+== snap exec grid=100x30 cursor=4,7
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/exec/cwd Del nm /tmp/pardes-snap/exec/cwd Del
+|
+| e.txt e.txt
+|
+|
+|
+| echo MID''OK
+| MIDOK
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/exec/cwd Del
+|
+| e.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap kept grid=100x30 cursor=6,2
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/exec/cwd Del nm /tmp/pardes-snap/exec/cwd Del
+| ZZARG
+| ./c.sh e.txt
+|
+|
+|
+| echo MID''OK
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/exec/cwd Del
+|
+| e.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap chord grid=100x30 cursor=6,2
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/exec/cwd Del nm /tmp/pardes-snap/exec/cwd Del
+| ZZARG
+| ./c.sh e.txt
+|
+|
+|
+| echo MID''OK
+| CHORDARG:ZZARG
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/exec/cwd Del
+|
+| e.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap deleted grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/exec/cwd Del nm /tmp/pardes-snap/exec/cwd Del
+|
+| e.txt e.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
diff --git a/test/snapshots/exec.snap b/test/snapshots/exec.snap
new file mode 100644
index 00000000..dd8c24ee
--- /dev/null
+++ b/test/snapshots/exec.snap
@@ -0,0 +1,46 @@
+# middle-drag executes the selection in the pane's shell; a left press during
+# the middle drag captures the kept left selection as a trailing argument;
+# middle-click Del on the pane tag closes the pane
+file e.txt x
+start 30 100
+wait 8000 e.txt
+stable 700 20000
+press left 5 8
+release left 5 8
+stable 400 5000
+key i
+text echo MID''OK
+key esc
+settle 700
+stable 400 5000
+press middle 5 8
+drag middle 16 8
+release middle 16 8
+wait 10000 MIDOK
+stable 700 10000
+snap exec
+# chord (canonical sequence, as in the prototype e2e): print an arg word and a
+# script path, left-drag-keep the arg, then middle-press on the path + left
+# press (captures) + middle release (runs with the arg appended)
+key c-b
+stable 600 8000
+text printf '#!/bin/sh\necho CHORDARG:"$1"\n' > c.sh; chmod +x c.sh; clear; printf 'ZZARG\n./c.sh\n'
+key enter
+wait 10000 ./c.sh
+stable 700 10000
+key c-b
+stable 600 8000
+press left 3 3
+drag left 7 3
+release left 7 3
+stable 400 5000
+snap kept
+send \x1b[<1;4;4M\x1b[<0;4;4M\x1b[<1;4;4m
+wait 10000 CHORDARG:ZZARG
+stable 700 10000
+snap chord
+# middle-click Del in this pane's tag (after cwd prefix) closes it
+press middle 32 2
+release middle 32 2
+stable 700 10000
+snap deleted
diff --git a/test/snapshots/fileedit.golden b/test/snapshots/fileedit.golden
new file mode 100644
index 00000000..c6fb40aa
--- /dev/null
+++ b/test/snapshots/fileedit.golden
@@ -0,0 +1,116 @@
+== snap edited grid=100x30 cursor=17,2
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/fileedit/cwd/f.txt Save Del nm /tmp/pardes-snap/fileedit/cwd Del
+| 1 liEDIT''MEne 1
+| 2 line 2 f.txt
+| 3 line 3
+| 4 line 4
+| 5 line 5
+| 6 line 6
+| 7 line 7
+| 8 line 8
+| 9 line 9
+| nm /tmp/pardes-snap/fileedit/cwd Del
+|
+| f.txt
+|
+|
+|
+| f.txt
+|
+|
+|
+| nm /tmp/pardes-snap/fileedit/cwd Del
+|
+| f.txt
+|
+|
+|
+|
+|
+|
+== snap deleted grid=100x30 cursor=7,3
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/fileedit/cwd/f.txt Save Del nm /tmp/pardes-snap/fileedit/cwd Del
+| 1 liEDIT''MEne 1
+| 2 line 3 f.txt
+| 3 line 4
+| 4 line 5
+| 5 line 6
+| 6 line 7
+| 7 line 8
+| 8 line 9
+| 9 line 10
+| nm /tmp/pardes-snap/fileedit/cwd Del
+|
+| f.txt
+|
+|
+|
+| f.txt
+|
+|
+|
+| nm /tmp/pardes-snap/fileedit/cwd Del
+|
+| f.txt
+|
+|
+|
+|
+|
+|
+== snap undone grid=100x30 cursor=7,3
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/fileedit/cwd/f.txt Save Del nm /tmp/pardes-snap/fileedit/cwd Del
+| 1 liEDIT''MEne 1
+| 2 line 2 f.txt
+| 3 line 3
+| 4 line 4
+| 5 line 5
+| 6 line 6
+| 7 line 7
+| 8 line 8
+| 9 line 9
+| nm /tmp/pardes-snap/fileedit/cwd Del
+|
+| f.txt
+|
+|
+|
+| f.txt
+|
+|
+|
+| nm /tmp/pardes-snap/fileedit/cwd Del
+|
+| f.txt
+|
+|
+|
+|
+|
+|
+== file saved
+liEDIT''MEne 1
+line 2
+line 3
+line 4
+line 5
+line 6
+line 7
+line 8
+line 9
+line 10
+line 11
+line 12
+line 13
+line 14
+line 15
+line 16
+line 17
+line 18
+line 19
+line 20
+
+== end file
diff --git a/test/snapshots/fileedit.snap b/test/snapshots/fileedit.snap
new file mode 100644
index 00000000..7bafa358
--- /dev/null
+++ b/test/snapshots/fileedit.snap
@@ -0,0 +1,40 @@
+# file panes: modal editing on real content, undo, Save writes to disk
+lines f.txt 20
+start 30 100
+wait 8000 f.txt
+stable 700 20000
+press left 5 8
+release left 5 8
+stable 400 5000
+key i
+text f.txt
+key esc
+settle 700
+press right 6 8
+release right 6 8
+wait 10000 line 3
+stable 700 15000
+# edit line 1: click it, insert, esc
+press left 10 3
+release left 10 3
+stable 400 5000
+key i
+text EDIT''ME
+key esc
+settle 700
+stable 400 5000
+snap edited
+# x-select line 2 and delete it
+key j g h
+stable 400 5000
+key x d
+stable 400 5000
+snap deleted
+key u
+stable 400 5000
+snap undone
+# Save via the tag: middle-click the Save word (after "nm <path> ")
+press middle 42 2
+release middle 42 2
+settle 800
+snapfile saved /tmp/pardes-snap/fileedit/cwd/f.txt
diff --git a/test/snapshots/images.golden b/test/snapshots/images.golden
new file mode 100644
index 00000000..b6e75fbf
--- /dev/null
+++ b/test/snapshots/images.golden
@@ -0,0 +1,124 @@
+== snap img-open grid=100x30 cursor=4,7
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| img /tmp/pardes-snap/images/cwd/pic.ppm Petscii nm /tmp/pardes-snap/images/cwd Del
+| 🬝🬹
+| 🬝▀█ pic.ppm
+| 🬝▀█
+| 🬝▀█
+| 🬝▀█
+| █🬆█
+| █🬆█
+| █🬆█
+| 🬝🬹█
+| nm /tmp/pardes-snap/images/cwd Del
+|
+| pic.ppm
+|
+|
+|
+| pic.ppm
+|
+|
+|
+| nm /tmp/pardes-snap/images/cwd Del
+|
+| pic.ppm
+|
+|
+|
+|
+|
+|
+== style img-open
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-49 #969696,#2c2a3e, 50-51 d,#383c54, 52-85 #969696,#2c2a3e, 86-99 d,#2c2a3e,
+|2: 0-1 d,#2a2a2a, 2-16 d,d, 17-32 #ffffff,#ffffff, 33-33 #ffffff,#352879, 34-34 #352879,#ffffff, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#2a2a2a, 2-16 d,d, 17-30 #ffffff,#ffffff, 31-32 #ffffff,#352879, 33-33 #352879,#ffffff, 34-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#2a2a2a, 2-16 d,d, 17-28 #ffffff,#ffffff, 29-30 #ffffff,#352879, 31-31 #352879,#ffffff, 32-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#2a2a2a, 2-16 d,d, 17-26 #ffffff,#ffffff, 27-28 #ffffff,#352879, 29-29 #352879,#ffffff, 30-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#2a2a2a, 2-16 d,d, 17-24 #ffffff,#ffffff, 25-26 #ffffff,#352879, 27-27 #352879,#ffffff, 28-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#2a2a2a, 2-16 d,d, 17-22 #ffffff,#ffffff, 23-24 #ffffff,#352879, 25-25 #352879,#ffffff, 26-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#2a2a2a, 2-16 d,d, 17-20 #ffffff,#ffffff, 21-22 #ffffff,#352879, 23-23 #352879,#ffffff, 24-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#2a2a2a, 2-16 d,d, 17-18 #ffffff,#ffffff, 19-20 #ffffff,#352879, 21-21 #352879,#ffffff, 22-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#2a2a2a, 2-16 d,d, 17-17 #ffffff,#352879, 18-19 #352879,#ffffff, 20-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+== style term-palette
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-49 #969696,#2c2a3e, 50-51 d,#383c54, 52-85 #969696,#2c2a3e, 86-99 d,#2c2a3e,
+|2: 0-1 d,#2a2a2a, 2-16 d,d, 17-32 #ffffff,#ffffff, 33-33 #ffffff,#352879, 34-34 #352879,#ffffff, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#2a2a2a, 2-16 d,d, 17-30 #ffffff,#ffffff, 31-32 #ffffff,#352879, 33-33 #352879,#ffffff, 34-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#2a2a2a, 2-16 d,d, 17-28 #ffffff,#ffffff, 29-30 #ffffff,#352879, 31-31 #352879,#ffffff, 32-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#2a2a2a, 2-16 d,d, 17-26 #ffffff,#ffffff, 27-28 #ffffff,#352879, 29-29 #352879,#ffffff, 30-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#2a2a2a, 2-16 d,d, 17-24 #ffffff,#ffffff, 25-26 #ffffff,#352879, 27-27 #352879,#ffffff, 28-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#2a2a2a, 2-16 d,d, 17-22 #ffffff,#ffffff, 23-24 #ffffff,#352879, 25-25 #352879,#ffffff, 26-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#2a2a2a, 2-16 d,d, 17-20 #ffffff,#ffffff, 21-22 #ffffff,#352879, 23-23 #352879,#ffffff, 24-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#2a2a2a, 2-16 d,d, 17-18 #ffffff,#ffffff, 19-20 #ffffff,#352879, 21-21 #352879,#ffffff, 22-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#2a2a2a, 2-16 d,d, 17-17 #ffffff,#352879, 18-19 #352879,#ffffff, 20-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+== style no-ascii
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-49 #969696,#2c2a3e, 50-51 d,#383c54, 52-85 #969696,#2c2a3e, 86-99 d,#2c2a3e,
+|2: 0-1 d,#2a2a2a, 2-16 d,d, 17-32 #ffffff,#ffffff, 33-33 #ffffff,#352879, 34-34 #352879,#ffffff, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#2a2a2a, 2-16 d,d, 17-30 #ffffff,#ffffff, 31-32 #ffffff,#352879, 33-33 #352879,#ffffff, 34-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#2a2a2a, 2-16 d,d, 17-28 #ffffff,#ffffff, 29-30 #ffffff,#352879, 31-31 #352879,#ffffff, 32-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#2a2a2a, 2-16 d,d, 17-26 #ffffff,#ffffff, 27-28 #ffffff,#352879, 29-29 #352879,#ffffff, 30-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#2a2a2a, 2-16 d,d, 17-24 #ffffff,#ffffff, 25-26 #ffffff,#352879, 27-27 #352879,#ffffff, 28-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#2a2a2a, 2-16 d,d, 17-22 #ffffff,#ffffff, 23-24 #ffffff,#352879, 25-25 #352879,#ffffff, 26-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#2a2a2a, 2-16 d,d, 17-20 #ffffff,#ffffff, 21-22 #ffffff,#352879, 23-23 #352879,#ffffff, 24-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#2a2a2a, 2-16 d,d, 17-18 #ffffff,#ffffff, 19-20 #ffffff,#352879, 21-21 #352879,#ffffff, 22-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#2a2a2a, 2-16 d,d, 17-17 #ffffff,#352879, 18-19 #352879,#ffffff, 20-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
diff --git a/test/snapshots/images.snap b/test/snapshots/images.snap
new file mode 100644
index 00000000..1b9bf715
--- /dev/null
+++ b/test/snapshots/images.snap
@@ -0,0 +1,29 @@
+# image panes: right-click a .ppm opens petscii glyph art (no kitty in the
+# harness); the live tag toggles palette (C64<->Term) and Ascii glyphs
+ppmdiag pic.ppm
+start 30 100
+wait 8000 pic.ppm
+stable 700 20000
+press left 5 8
+release left 5 8
+stable 400 5000
+key i
+text pic.ppm
+key esc
+settle 700
+press right 6 8
+release right 6 8
+wait 10000 img
+stable 700 20000
+snap img-open
+snapstyle img-open
+# toggle palette to terminal colors: middle-click the C64 word in the tag
+press middle 57 2
+release middle 57 2
+stable 700 15000
+snapstyle term-palette
+# toggle Ascii off
+press middle 62 2
+release middle 62 2
+stable 700 15000
+snapstyle no-ascii
diff --git a/test/snapshots/load.golden b/test/snapshots/load.golden
new file mode 100644
index 00000000..ade03fe3
--- /dev/null
+++ b/test/snapshots/load.golden
@@ -0,0 +1,62 @@
+== snap loaded grid=100x30 cursor=4,15
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/load/cwd Del nm /tmp/pardes-snap/load/cwd Del
+|
+| LOADMARK d.txt
+| 1
+| 2
+| 3
+| 4
+| 5
+| 6
+| 7
+| 8
+| 9
+| 10
+| 11
+| 12
+| nm /tmp/pardes-snap/load/cwd Del
+|
+| d.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== style loaded
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-33 #969696,#2c2a3e, 34-49 d,#2c2a3e, 50-51 d,#383c54, 52-83 #969696,#2c2a3e, 84-99 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#383c54, 2-33 #969696,#2c2a3e, 34-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
diff --git a/test/snapshots/load.snap b/test/snapshots/load.snap
new file mode 100644
index 00000000..0a9c9e55
--- /dev/null
+++ b/test/snapshots/load.snap
@@ -0,0 +1,27 @@
+# pardes -l restores another instance's dump (NEW behavior — the old app had a
+# separate replay viewer; this golden is generated from the NEW binary as its
+# own regression baseline). Shell output survives the replay; typed insert runs
+# are an overlay and do not (same semantics as the old replay viewer).
+file d.txt x
+start 30 100
+wait 8000 d.txt
+stable 700 20000
+key c-b
+stable 600 8000
+text echo LOAD''MARK; seq 1 40
+key enter
+wait 8000 LOADMARK
+stable 700 10000
+key c-b
+stable 600 8000
+wheel up 10 8
+wheel up 10 8
+stable 500 8000
+press middle 43 1
+release middle 43 1
+settle 800
+start 30 100 -l /tmp/pardes-dump.zon
+wait 8000 LOADMARK
+stable 700 20000
+snap loaded
+snapstyle loaded
diff --git a/test/snapshots/look-dir.actual b/test/snapshots/look-dir.actual
new file mode 100644
index 00000000..523560ba
--- /dev/null
+++ b/test/snapshots/look-dir.actual
@@ -0,0 +1,31 @@
+== snap dir-open grid=100x30 cursor=4,9
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/look-dir/cwd Del nm /tmp/pardes-snap/look-dir/cwd Del
+|
+| sub top.txt sub top.txt
+|
+| nm /tmp/pardes-snap/look-dir/cwd/sub Del
+| ls
+|
+| inner.txt
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/look-dir/cwd Del
+|
+| sub top.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
diff --git a/test/snapshots/look-dir.golden b/test/snapshots/look-dir.golden
new file mode 100644
index 00000000..c4337878
--- /dev/null
+++ b/test/snapshots/look-dir.golden
@@ -0,0 +1,31 @@
+== snap dir-open grid=100x30 cursor=4,8
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/look-dir/cwd Del nm /tmp/pardes-snap/look-dir/cwd Del
+|
+| sub top.txt sub top.txt
+|
+| nm /tmp/pardes-snap/look-dir/cwd/sub Del
+|
+| inner.txt
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/look-dir/cwd Del
+|
+| sub top.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
diff --git a/test/snapshots/look-dir.snap b/test/snapshots/look-dir.snap
new file mode 100644
index 00000000..04b43f6e
--- /dev/null
+++ b/test/snapshots/look-dir.snap
@@ -0,0 +1,20 @@
+# right-click look on a typed dir name opens a shell there (split below) + ls
+dirmk sub
+file sub/inner.txt inner-marker
+file top.txt x
+start 30 100
+wait 8000 top.txt
+stable 700 20000
+press left 5 8
+release left 5 8
+stable 400 5000
+key i
+text sub
+key esc
+settle 700
+stable 400 5000
+press right 6 8
+release right 6 8
+wait 10000 inner.txt
+stable 700 20000
+snap dir-open
diff --git a/test/snapshots/look-file.golden b/test/snapshots/look-file.golden
new file mode 100644
index 00000000..4f6d4a71
--- /dev/null
+++ b/test/snapshots/look-file.golden
@@ -0,0 +1,62 @@
+== snap file-open grid=100x30 cursor=7,2
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/look-file/cwd/big.txt Save D nm /tmp/pardes-snap/look-file/cwd Del
+| 40 line 40
+| 41 line 41 big.txt
+| 42 line 42
+| 43 line 43
+| 44 line 44
+| 45 line 45
+| 46 line 46
+| 47 line 47
+| 48 line 48
+| nm /tmp/pardes-snap/look-file/cwd Del
+|
+| big.txt
+|
+|
+|
+| big.txt:40
+|
+|
+|
+| nm /tmp/pardes-snap/look-file/cwd Del
+|
+| big.txt
+|
+|
+|
+|
+|
+|
+== style file-open
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-49 #969696,#2c2a3e, 50-51 d,#383c54, 52-88 #969696,#2c2a3e, 89-99 d,#2c2a3e,
+|2: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#383c54, 2-38 #969696,#2c2a3e, 39-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#383c54, 2-38 #969696,#2c2a3e, 39-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
diff --git a/test/snapshots/look-file.snap b/test/snapshots/look-file.snap
new file mode 100644
index 00000000..83010317
--- /dev/null
+++ b/test/snapshots/look-file.snap
@@ -0,0 +1,19 @@
+# right-click look on a typed path:NN opens a file pane scrolled to the line;
+# Enter chord on the same path focuses the existing pane (dedup)
+lines big.txt 60
+start 30 100
+wait 8000 big.txt
+stable 700 20000
+press left 5 8
+release left 5 8
+stable 400 5000
+key i
+text big.txt:40
+key esc
+settle 700
+stable 400 5000
+press right 7 8
+release right 7 8
+stable 700 15000
+snap file-open
+snapstyle file-open
diff --git a/test/snapshots/modal.golden b/test/snapshots/modal.golden
new file mode 100644
index 00000000..b4922f12
--- /dev/null
+++ b/test/snapshots/modal.golden
@@ -0,0 +1,372 @@
+== snap top grid=100x30 cursor=2,2
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/modal/cwd Del nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt words.txt
+|
+|
+| one two three
+| four five six
+| seven eight nine
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap moved grid=100x30 cursor=2,6
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/modal/cwd Del nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt words.txt
+|
+|
+| one two three
+| four five six
+| seven eight nine
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap xsel grid=100x30 cursor=2,6
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/modal/cwd Del nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt words.txt
+|
+|
+| one two three
+| four five six
+| seven eight nine
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== style xsel
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-34 #969696,#2c2a3e, 35-49 d,#2c2a3e, 50-51 d,#383c54, 52-84 #969696,#2c2a3e, 85-99 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#525252, 2-49 #d8c8a8,#4a3a1e, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#383c54, 2-34 #969696,#2c2a3e, 35-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+== snap xsel2 grid=100x30 cursor=2,7
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/modal/cwd Del nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt words.txt
+|
+|
+| one two three
+| four five six
+| seven eight nine
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap yanked grid=100x30 cursor=2,6
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/modal/cwd Del nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt words.txt
+|
+|
+| one two three
+| four five six
+| seven eight nine
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap vsel grid=100x30 cursor=3,7
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/modal/cwd Del nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt words.txt
+|
+|
+| one two three
+| four five six
+| seven eight nine
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== style vsel
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-34 #969696,#2c2a3e, 35-49 d,#2c2a3e, 50-51 d,#383c54, 52-84 #969696,#2c2a3e, 85-99 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#525252, 2-49 #d8c8a8,#4a3a1e, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#525252, 2-3 #d8c8a8,#4a3a1e, 4-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#383c54, 2-34 #969696,#2c2a3e, 35-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+== snap typed grid=100x30 cursor=3,7
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/modal/cwd Del nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt words.txt
+|
+|
+| one two three
+| fTYPEDour five six
+| seven eight nine
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap undone grid=100x30 cursor=3,7
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/modal/cwd Del nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt words.txt
+|
+|
+| one two three
+| four five six
+| seven eight nine
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap redone grid=100x30 cursor=3,7
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/modal/cwd Del nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt words.txt
+|
+|
+| one two three
+| fTYPEDour five six
+| seven eight nine
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap bottom grid=100x30 cursor=2,9
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/modal/cwd Del nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt words.txt
+|
+|
+| one two three
+| fTYPEDour five six
+| seven eight nine
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
diff --git a/test/snapshots/modal.snap b/test/snapshots/modal.snap
new file mode 100644
index 00000000..adbfd533
--- /dev/null
+++ b/test/snapshots/modal.snap
@@ -0,0 +1,53 @@
+# helix-modal normal mode: motions, x/v selections, y/d, insert, undo/redo
+file words.txt alpha beta
+start 30 100
+wait 8000 words.txt
+stable 700 20000
+key c-b
+stable 600 8000
+text printf 'one two three\nfour five six\nseven eight nine\n'
+key enter
+wait 8000 seven
+stable 700 10000
+key c-b
+stable 600 8000
+press left 5 6
+release left 5 6
+stable 400 5000
+key g g
+stable 400 5000
+snap top
+key j j w
+stable 400 5000
+snap moved
+key x
+stable 400 5000
+snap xsel
+snapstyle xsel
+key x
+stable 400 5000
+snap xsel2
+key y
+stable 400 5000
+snap yanked
+key v j l
+stable 400 5000
+snap vsel
+snapstyle vsel
+key esc
+settle 700
+key i
+text TYPED
+key esc
+settle 700
+stable 400 5000
+snap typed
+key u
+stable 400 5000
+snap undone
+key U
+stable 400 5000
+snap redone
+key G
+stable 400 5000
+snap bottom
diff --git a/test/snapshots/scroll.golden b/test/snapshots/scroll.golden
new file mode 100644
index 00000000..899c7dec
--- /dev/null
+++ b/test/snapshots/scroll.golden
@@ -0,0 +1,186 @@
+== snap bottom grid=100x30 cursor=4,15
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/scroll/cwd Del nm /tmp/pardes-snap/scroll/cwd Del
+| 188
+| 189 s.txt
+| 190
+| 191
+| 192
+| 193
+| 194
+| 195
+| 196
+| 197
+| 198
+| 199
+| 200
+|
+| nm /tmp/pardes-snap/scroll/cwd Del
+|
+| s.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap wheel3 grid=100x30 cursor=4,15
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/scroll/cwd Del nm /tmp/pardes-snap/scroll/cwd Del
+| 185
+| 186 s.txt
+| 187
+| 188
+| 189
+| 190
+| 191
+| 192
+| 193
+| 194
+| 195
+| 196
+| 197
+| 198
+| nm /tmp/pardes-snap/scroll/cwd Del
+|
+| s.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap gutter-up grid=100x30 cursor=4,15
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/scroll/cwd Del nm /tmp/pardes-snap/scroll/cwd Del
+| 178
+| 179 s.txt
+| 180
+| 181
+| 182
+| 183
+| 184
+| 185
+| 186
+| 187
+| 188
+| 189
+| 190
+| 191
+| nm /tmp/pardes-snap/scroll/cwd Del
+|
+| s.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap gutter-down grid=100x30 cursor=4,15
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/scroll/cwd Del nm /tmp/pardes-snap/scroll/cwd Del
+| 180
+| 181 s.txt
+| 182
+| 183
+| 184
+| 185
+| 186
+| 187
+| 188
+| 189
+| 190
+| 191
+| 192
+| 193
+| nm /tmp/pardes-snap/scroll/cwd Del
+|
+| s.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap wheel-down grid=100x30 cursor=4,15
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/scroll/cwd Del nm /tmp/pardes-snap/scroll/cwd Del
+| 181
+| 182 s.txt
+| 183
+| 184
+| 185
+| 186
+| 187
+| 188
+| 189
+| 190
+| 191
+| 192
+| 193
+| 194
+| nm /tmp/pardes-snap/scroll/cwd Del
+|
+| s.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== style wheel-down
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#383c54, 52-85 #969696,#2c2a3e, 86-99 d,#2c2a3e,
+|2: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
diff --git a/test/snapshots/scroll.snap b/test/snapshots/scroll.snap
new file mode 100644
index 00000000..8e55224d
--- /dev/null
+++ b/test/snapshots/scroll.snap
@@ -0,0 +1,31 @@
+# scrolling: wheel + scrollbar gutter clicks over real scrollback
+file s.txt x
+start 30 100
+wait 8000 s.txt
+stable 700 20000
+key c-b
+stable 600 8000
+text seq 1 200
+key enter
+wait 8000 199
+stable 700 15000
+key c-b
+stable 600 8000
+snap bottom
+wheel up 10 8
+wheel up 10 8
+wheel up 10 8
+stable 500 8000
+snap wheel3
+press left 1 10
+release left 1 10
+stable 500 8000
+snap gutter-up
+press right 1 5
+release right 1 5
+stable 500 8000
+snap gutter-down
+wheel down 10 8
+stable 500 8000
+snap wheel-down
+snapstyle wheel-down
diff --git a/test/snapshots/syntax.actual b/test/snapshots/syntax.actual
new file mode 100644
index 00000000..d8c537b7
--- /dev/null
+++ b/test/snapshots/syntax.actual
@@ -0,0 +1,124 @@
+== snap zig-open grid=100x30 cursor=7,2
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/syntax/cwd/demo.zig Save Del nm /tmp/pardes-snap/syntax/cwd Del
+| 1 const std = @import("std"); // greet
+| 2 pub fn main() void { demo.zig
+| 3 var x: u32 = 42;
+| 4 _ = x;
+| 5 }
+|
+|
+|
+|
+| nm /tmp/pardes-snap/syntax/cwd Del
+|
+| demo.zig
+|
+|
+|
+| demo.zig
+|
+|
+|
+| nm /tmp/pardes-snap/syntax/cwd Del
+|
+| demo.zig
+|
+|
+|
+|
+|
+|
+== style zig-open
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-49 #969696,#2c2a3e, 50-51 d,#383c54, 52-85 #969696,#2c2a3e, 86-99 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-18 d,d, 19-25 #cc70d8,d,b 26-26 d,d, 27-31 #8ab87a,d, 32-34 d,d, 35-42 #f0f0f0,d,b 43-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-10 d,d, 11-12 #cc70d8,d,b 13-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-23 d,d, 24-25 #d0c070,d, 26-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+== style colors-off
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-49 #969696,#2c2a3e, 50-51 d,#383c54, 52-85 #969696,#2c2a3e, 86-99 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+== style colors-on
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-49 #969696,#2c2a3e, 50-51 d,#383c54, 52-85 #969696,#2c2a3e, 86-99 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-18 d,d, 19-25 #cc70d8,d,b 26-26 d,d, 27-31 #8ab87a,d, 32-34 d,d, 35-42 #f0f0f0,d,b 43-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-10 d,d, 11-12 #cc70d8,d,b 13-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-23 d,d, 24-25 #d0c070,d, 26-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
diff --git a/test/snapshots/syntax.golden b/test/snapshots/syntax.golden
new file mode 100644
index 00000000..310d6d64
--- /dev/null
+++ b/test/snapshots/syntax.golden
@@ -0,0 +1,124 @@
+== snap zig-open grid=100x30 cursor=7,2
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/syntax/cwd/demo.zig Save Del nm /tmp/pardes-snap/syntax/cwd Del
+| 1 const std = @import("std"); // greet
+| 2 pub fn main() void { demo.zig
+| 3 var x: u32 = 42;
+| 4 _ = x;
+| 5 }
+|
+|
+|
+|
+| nm /tmp/pardes-snap/syntax/cwd Del
+|
+| demo.zig
+|
+|
+|
+| demo.zig
+|
+|
+|
+| nm /tmp/pardes-snap/syntax/cwd Del
+|
+| demo.zig
+|
+|
+|
+|
+|
+|
+== style zig-open
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-49 #969696,#2c2a3e, 50-51 d,#383c54, 52-85 #969696,#2c2a3e, 86-99 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-18 d,d, 19-25 #cc70d8,d,b 26-26 d,d, 27-31 #8ab87a,d, 32-34 d,d, 35-42 #f0f0f0,d,b 43-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-10 d,d, 11-12 #cc70d8,d,b 13-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-23 d,d, 24-25 #d0c070,d, 26-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+== style colors-off
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-49 #969696,#2c2a3e, 50-51 d,#383c54, 52-85 #969696,#2c2a3e, 86-99 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+== style colors-on
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-49 #969696,#2c2a3e, 50-51 d,#383c54, 52-85 #969696,#2c2a3e, 86-99 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-18 d,d, 19-25 #cc70d8,d,b 26-26 d,d, 27-31 #8ab87a,d, 32-34 d,d, 35-42 #f0f0f0,d,b 43-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-10 d,d, 11-12 #cc70d8,d,b 13-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-23 d,d, 24-25 #d0c070,d, 26-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
diff --git a/test/snapshots/syntax.snap b/test/snapshots/syntax.snap
new file mode 100644
index 00000000..690e5d7d
--- /dev/null
+++ b/test/snapshots/syntax.snap
@@ -0,0 +1,27 @@
+# tree-sitter: open a .zig file, keyword/string/number/comment colors on;
+# Colors off clears them; edit re-highlights
+file demo.zig const std = @import("std"); // greet\npub fn main() void {\n var x: u32 = 42;\n _ = x;\n}
+start 30 100
+wait 8000 demo.zig
+stable 700 20000
+press left 5 8
+release left 5 8
+stable 400 5000
+key i
+text demo.zig
+key esc
+settle 700
+press right 6 8
+release right 6 8
+wait 10000 @import
+stable 700 15000
+snap zig-open
+snapstyle zig-open
+press middle 25 1
+release middle 25 1
+stable 500 10000
+snapstyle colors-off
+press middle 25 1
+release middle 25 1
+stable 500 10000
+snapstyle colors-on
diff --git a/test/snapshots/tag.golden b/test/snapshots/tag.golden
new file mode 100644
index 00000000..9275777b
--- /dev/null
+++ b/test/snapshots/tag.golden
@@ -0,0 +1,217 @@
+== snap tag-focus grid=100x30 cursor=33,1
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/tag/cwd Del nm /tmp/pardes-snap/tag/cwd Del
+|
+| t.txt t.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/tag/cwd Del
+|
+| t.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap tag-typed grid=100x30 cursor=46,1
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/tag/cwd Del echo TAG''OK nm /tmp/pardes-snap/tag/cwd Del
+|
+| t.txt t.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/tag/cwd Del
+|
+| t.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap tag-exec grid=100x30 cursor=45,1
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/tag/cwd Del echo TAG''OK nm /tmp/pardes-snap/tag/cwd Del
+|
+| t.txt t.txt
+|
+| TAGOK
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/tag/cwd Del
+|
+| t.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap tag-vsel grid=100x30 cursor=31,1
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/tag/cwd Del echo TAG''OK nm /tmp/pardes-snap/tag/cwd Del
+|
+| t.txt t.txt
+|
+| TAGOK
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/tag/cwd Del
+|
+| t.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== style tag-vsel
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-28 #969696,#2c2a3e, 29-31 #d8c8a8,#4a3a1e, 32-45 #969696,#2c2a3e, 46-49 d,#2c2a3e, 50-51 d,#383c54, 52-82 #969696,#2c2a3e, 83-99 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#383c54, 2-32 #969696,#2c2a3e, 33-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+== snap tag-del grid=100x30 cursor=29,1
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/tag/cwdl echo TAG''OK nm /tmp/pardes-snap/tag/cwd Del
+|
+| t.txt t.txt
+|
+| TAGOK
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/tag/cwd Del
+|
+| t.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap tag-exit grid=100x30 cursor=4,6
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/tag/cwdl echo TAG''OK nm /tmp/pardes-snap/tag/cwd Del
+|
+| t.txt t.txt
+|
+| TAGOK
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/tag/cwd Del
+|
+| t.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
diff --git a/test/snapshots/tag.snap b/test/snapshots/tag.snap
new file mode 100644
index 00000000..f77808cb
--- /dev/null
+++ b/test/snapshots/tag.snap
@@ -0,0 +1,41 @@
+# tag editor: click the tag row, edit the tail modally, execute a typed word
+file t.txt x
+start 30 100
+wait 8000 t.txt
+stable 700 20000
+# click into the tag tail (after "Del", col past prefix) of pane 0
+press left 40 2
+release left 40 2
+stable 400 5000
+snap tag-focus
+# append a word: A enters insert at end of tail
+key A
+send \x20echo\x20TAG''OK
+key esc
+settle 700
+stable 400 5000
+snap tag-typed
+# v-select "echo TAG''OK" (col 4 .. line end) and execute it with Tab
+key g h
+key l l l l
+key v
+key g l
+stable 400 5000
+key tab
+wait 10000 TAGOK
+stable 700 10000
+snap tag-exec
+# v-select a few chars and delete them
+key g h
+stable 400 5000
+key v l l
+stable 400 5000
+snap tag-vsel
+snapstyle tag-vsel
+key d
+stable 400 5000
+snap tag-del
+# esc: first collapses selection state, then leaves tag editing
+key esc
+settle 700
+snap tag-exit
diff --git a/test/snapshots/theme.golden b/test/snapshots/theme.golden
new file mode 100644
index 00000000..b90e0011
--- /dev/null
+++ b/test/snapshots/theme.golden
@@ -0,0 +1,186 @@
+== snap acme grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/theme/cwd Del nm /tmp/pardes-snap/theme/cwd Del
+|
+| t.txt t.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/theme/cwd Del
+|
+| t.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== style acme
+|0: 0-44 #000000,#eaffff, 45-99 d,#eaffff,
+|1: 0-1 d,#6e4fd0, 2-34 #000000,#eaffff, 35-49 d,#eaffff, 50-51 d,#383c54, 52-84 #000000,#eaffff, 85-99 d,#eaffff,
+|2: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|3: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|4: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|5: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|6: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|7: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|8: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|9: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|10: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|11: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|12: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|13: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|14: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|15: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|16: 0-1 d,#383c54, 2-34 #000000,#eaffff, 35-49 d,#eaffff, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|17: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|18: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|19: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|20: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|21: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|22: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|23: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|24: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|25: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|26: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|27: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|28: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|29: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+== snap colors-off grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/theme/cwd Del nm /tmp/pardes-snap/theme/cwd Del
+|
+| t.txt t.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/theme/cwd Del
+|
+| t.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== style colors-off
+|0: 0-44 #000000,#eaffff, 45-99 d,#eaffff,
+|1: 0-1 d,#6e4fd0, 2-34 #000000,#eaffff, 35-49 d,#eaffff, 50-51 d,#383c54, 52-84 #000000,#eaffff, 85-99 d,#eaffff,
+|2: 0-99 d,#ffffea,
+|3: 0-1 d,#ffffea, 2-6 #000000,#ffffea, 7-51 d,#ffffea, 52-56 #000000,#ffffea, 57-99 d,#ffffea,
+|4: 0-99 d,#ffffea,
+|5: 0-99 d,#ffffea,
+|6: 0-99 d,#ffffea,
+|7: 0-99 d,#ffffea,
+|8: 0-99 d,#ffffea,
+|9: 0-99 d,#ffffea,
+|10: 0-99 d,#ffffea,
+|11: 0-99 d,#ffffea,
+|12: 0-99 d,#ffffea,
+|13: 0-99 d,#ffffea,
+|14: 0-99 d,#ffffea,
+|15: 0-99 d,#ffffea,
+|16: 0-1 d,#383c54, 2-34 #000000,#eaffff, 35-49 d,#eaffff, 50-99 d,#ffffea,
+|17: 0-99 d,#ffffea,
+|18: 0-1 d,#ffffea, 2-6 #000000,#ffffea, 7-99 d,#ffffea,
+|19: 0-99 d,#ffffea,
+|20: 0-99 d,#ffffea,
+|21: 0-99 d,#ffffea,
+|22: 0-99 d,#ffffea,
+|23: 0-99 d,#ffffea,
+|24: 0-99 d,#ffffea,
+|25: 0-99 d,#ffffea,
+|26: 0-99 d,#ffffea,
+|27: 0-99 d,#ffffea,
+|28: 0-99 d,#ffffea,
+|29: 0-99 d,#ffffea,
+== snap dark-again grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/theme/cwd Del nm /tmp/pardes-snap/theme/cwd Del
+|
+| t.txt t.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/theme/cwd Del
+|
+| t.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== style dark-again
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-34 #969696,#2c2a3e, 35-49 d,#2c2a3e, 50-51 d,#383c54, 52-84 #969696,#2c2a3e, 85-99 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#383c54, 2-34 #969696,#2c2a3e, 35-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
diff --git a/test/snapshots/theme.snap b/test/snapshots/theme.snap
new file mode 100644
index 00000000..90ce5f85
--- /dev/null
+++ b/test/snapshots/theme.snap
@@ -0,0 +1,24 @@
+# topbar builtins: NextColor cycles to acme-light and back; Colors toggles
+# the recolor passes off and on
+file t.txt x
+start 30 100
+wait 8000 t.txt
+stable 700 20000
+press middle 32 1
+release middle 32 1
+stable 700 10000
+snap acme
+snapstyle acme
+press middle 25 1
+release middle 25 1
+stable 700 10000
+snap colors-off
+snapstyle colors-off
+press middle 25 1
+release middle 25 1
+stable 700 10000
+press middle 32 1
+release middle 32 1
+stable 700 10000
+snap dark-again
+snapstyle dark-again
diff --git a/test/snapshots/tty.golden b/test/snapshots/tty.golden
new file mode 100644
index 00000000..7e6bebc5
--- /dev/null
+++ b/test/snapshots/tty.golden
@@ -0,0 +1,93 @@
+== snap tty-on grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| sy /tmp/pardes-snap/tty/cwd Del nm /tmp/pardes-snap/tty/cwd Del
+| $ ls
+| m.txt m.txt
+| $
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/tty/cwd Del
+|
+| m.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap tty-echo grid=100x30 cursor=4,6
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| sy /tmp/pardes-snap/tty/cwd Del nm /tmp/pardes-snap/tty/cwd Del
+| $ ls
+| m.txt m.txt
+| $ echo SNAP''OK
+| SNAPOK
+| $
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/tty/cwd Del
+|
+| m.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap tty-off grid=100x30 cursor=4,6
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/tty/cwd Del nm /tmp/pardes-snap/tty/cwd Del
+|
+| m.txt m.txt
+|
+| SNAPOK
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/tty/cwd Del
+|
+| m.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
diff --git a/test/snapshots/tty.snap b/test/snapshots/tty.snap
new file mode 100644
index 00000000..6d6e0f3e
--- /dev/null
+++ b/test/snapshots/tty.snap
@@ -0,0 +1,16 @@
+# tty mode round-trip: Ctrl-b in, run a command, Ctrl-b out
+file m.txt marker
+start 30 100
+wait 8000 m.txt
+stable 700 20000
+key c-b
+stable 600 8000
+snap tty-on
+text echo SNAP''OK
+key enter
+wait 8000 SNAPOK
+stable 700 10000
+snap tty-echo
+key c-b
+stable 600 8000
+snap tty-off
diff --git a/test/snapshots/ttyonly.golden b/test/snapshots/ttyonly.golden
new file mode 100644
index 00000000..20ddf792
--- /dev/null
+++ b/test/snapshots/ttyonly.golden
@@ -0,0 +1,62 @@
+== snap ttyonly grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| sy /tmp/pardes-snap/ttyonly/cwd Del
+| $ echo TTY''ONLY
+| TTYONLY
+| $
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== style ttyonly
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-36 #969696,#2c2a3e, 37-99 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-99 d,d,
+|3: 0-1 d,#525252, 2-99 d,d,
+|4: 0-1 d,#525252, 2-99 d,d,
+|5: 0-1 d,#525252, 2-99 d,d,
+|6: 0-1 d,#525252, 2-99 d,d,
+|7: 0-1 d,#525252, 2-99 d,d,
+|8: 0-1 d,#525252, 2-99 d,d,
+|9: 0-1 d,#525252, 2-99 d,d,
+|10: 0-1 d,#525252, 2-99 d,d,
+|11: 0-1 d,#525252, 2-99 d,d,
+|12: 0-1 d,#525252, 2-99 d,d,
+|13: 0-1 d,#525252, 2-99 d,d,
+|14: 0-1 d,#525252, 2-99 d,d,
+|15: 0-1 d,#525252, 2-99 d,d,
+|16: 0-1 d,#525252, 2-99 d,d,
+|17: 0-1 d,#525252, 2-99 d,d,
+|18: 0-1 d,#525252, 2-99 d,d,
+|19: 0-1 d,#525252, 2-99 d,d,
+|20: 0-1 d,#525252, 2-99 d,d,
+|21: 0-1 d,#525252, 2-99 d,d,
+|22: 0-1 d,#525252, 2-99 d,d,
+|23: 0-1 d,#525252, 2-99 d,d,
+|24: 0-1 d,#525252, 2-99 d,d,
+|25: 0-1 d,#525252, 2-99 d,d,
+|26: 0-1 d,#525252, 2-99 d,d,
+|27: 0-1 d,#525252, 2-99 d,d,
+|28: 0-1 d,#525252, 2-99 d,d,
+|29: 0-1 d,#525252, 2-99 d,d,
diff --git a/test/snapshots/ttyonly.snap b/test/snapshots/ttyonly.snap
new file mode 100644
index 00000000..c3d44063
--- /dev/null
+++ b/test/snapshots/ttyonly.snap
@@ -0,0 +1,11 @@
+# --tty: a single fullscreen shell already in raw tty mode, prompts visible
+file t.txt x
+start 30 100 --tty
+wait 8000 $
+stable 700 20000
+text echo TTY''ONLY
+key enter
+wait 8000 TTYONLY
+stable 700 10000
+snap ttyonly
+snapstyle ttyonly
diff --git a/test/snapshots/tutor.golden b/test/snapshots/tutor.golden
new file mode 100644
index 00000000..55d66c9b
--- /dev/null
+++ b/test/snapshots/tutor.golden
@@ -0,0 +1,31 @@
+== snap tutor grid=100x30 cursor=7,2
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /Tutor Save Del nm /tmp/pardes-snap/tutor/cwd Del
+| 1
+| 2 PARDES TUTOR
+| 3 tmux + vi + acme, from
+| 4
+| 5 The model is acme's: EVERYTHING is
+| 6 the same way. Modal editing (helix-
+| 7 Press j until you reach the introdu
+| 8
+| 9
+| nm /tmp/pardes-snap/tutor/cwd Del
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/tutor/cwd Del
+|
+|
+|
+|
+|
+|
+|
+|
diff --git a/test/snapshots/tutor.snap b/test/snapshots/tutor.snap
new file mode 100644
index 00000000..6322c92a
--- /dev/null
+++ b/test/snapshots/tutor.snap
@@ -0,0 +1,8 @@
+# topbar middle-click Tutor opens the embedded tutor as a file pane top-left
+start 30 100
+wait 8000 Kill Newcol
+stable 700 20000
+press middle 13 1
+release middle 13 1
+stable 700 15000
+snap tutor
diff --git a/test/snapshots/windowops.golden b/test/snapshots/windowops.golden
new file mode 100644
index 00000000..061ceca9
--- /dev/null
+++ b/test/snapshots/windowops.golden
@@ -0,0 +1,372 @@
+== snap hover-v grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/windowops/cwd Del ╎ nm /tmp/pardes-snap/windowops/cwd Del
+| ╎
+| w.txt ╎ w.txt
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| nm /tmp/pardes-snap/windowops/cwd Del ╎
+| ╎
+| w.txt ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+== snap hover-h grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/windowops/cwd Del nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt w.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
+| nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap border-drag grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/windowops/cwd Del╎ nm /tmp/pardes-snap/windowops/cwd Del
+| ╎
+| w.txt ╎ w.txt
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| nm /tmp/pardes-snap/windowops/cwd Del╎
+| ╎
+| w.txt ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+== snap border-done grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/windowops/cwd Del╎ nm /tmp/pardes-snap/windowops/cwd Del
+| ╎
+| w.txt ╎ w.txt
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| nm /tmp/pardes-snap/windowops/cwd Del╎
+| ╎
+| w.txt ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+== snap hborder-done grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/windowops/cwd Del nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt w.txt
+|
+|
+|
+|
+|
+|
+|
+|╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
+| nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap move-preview grid=100x30 cursor=4,15
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/windowops/cwd Del nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt w.txt
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap moved grid=100x30 cursor=4,15
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/windowops/cwd Del nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt w.txt
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap altn grid=100x30 cursor=4,23
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/windowops/cwd Del nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt w.txt
+|
+|
+|
+|
+| nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt
+|
+|
+|
+|
+|
+|
+|
+== snap focus-left grid=100x30 cursor=4,23
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/windowops/cwd Del nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt w.txt
+|
+|
+|
+|
+| nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt
+|
+|
+|
+|
+|
+|
+|
+== snap debug-on grid=100x30 cursor=4,23
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/windowops/cwd Del nm /tmp/pardes-snap/windowop╭────────────────────────────╮
+| │pardes resizes=1 │
+| w.txt w.txt │terms 4 cols 2 │
+| │active #3 38x9 │
+| │scroll 0/9 │
+| │pinch 1.0000 │
+| │tscroll 0.0000 │
+| nm /tmp/pardes-snap/windowops/cwd Del ╰────────────────────────────╯
+|
+| w.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt
+|
+|
+|
+|
+|
+|
+|
+== style debug-on
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#383c54, 2-38 #969696,#2c2a3e, 39-39 d,#2c2a3e, 40-41 d,#383c54, 42-69 #969696,#2c2a3e, 70-99 d,d,
+|2: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-70 d,d, 71-87 #969696,d, 88-98 d,#2a2a2a, 99-99 d,d,
+|3: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-70 d,d, 71-85 #969696,d, 86-98 d,#2a2a2a, 99-99 d,d,
+|4: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-70 d,d, 71-85 #969696,d, 86-98 d,#2a2a2a, 99-99 d,d,
+|5: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-70 d,d, 71-80 #969696,d, 81-98 d,#2a2a2a, 99-99 d,d,
+|6: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-70 d,d, 71-83 #969696,d, 84-98 d,#2a2a2a, 99-99 d,d,
+|7: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-70 d,d, 71-85 #969696,d, 86-98 d,#2a2a2a, 99-99 d,d,
+|8: 0-1 d,#383c54, 2-38 #969696,#2c2a3e, 39-39 d,#2c2a3e, 40-41 d,#525252, 42-99 d,d,
+|9: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|10: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|11: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|12: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|13: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|14: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|15: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|16: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|17: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|18: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|19: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|20: 0-1 d,#6e4fd0, 2-38 #969696,#2c2a3e, 39-39 d,#2c2a3e, 40-41 d,#525252, 42-99 d,d,
+|21: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|22: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|23: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|24: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|25: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|26: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|27: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|28: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|29: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+== snap debug-off grid=100x30 cursor=4,23
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/windowops/cwd Del nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt w.txt
+|
+|
+|
+|
+| nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt
+|
+|
+|
+|
+|
+|
+|
diff --git a/test/snapshots/windowops.snap b/test/snapshots/windowops.snap
new file mode 100644
index 00000000..7e6cbee4
--- /dev/null
+++ b/test/snapshots/windowops.snap
@@ -0,0 +1,53 @@
+# hover resize hints, border drags, move drag, Alt-n, Ctrl-w focus, Debug box
+file w.txt x
+start 30 100
+wait 8000 w.txt
+stable 700 20000
+# hover over the left column's last col: vertical dash hint
+motion 50 10
+stable 400 5000
+snap hover-v
+# hover over the top-left pane's last row: horizontal dash hint
+motion 20 16
+stable 400 5000
+snap hover-h
+# drag the vertical border 10 cols left
+press left 50 10
+drag left 40 10
+stable 400 5000
+snap border-drag
+release left 40 10
+stable 500 8000
+snap border-done
+# drag the horizontal border up 4 rows (pane 0's new last row)
+press left 20 16
+drag left 20 12
+release left 20 12
+stable 500 8000
+snap hborder-done
+# move pane 1 (its gutter box) into the right column
+press left 1 17
+drag left 70 8
+stable 400 5000
+snap move-preview
+release left 70 8
+stable 500 8000
+snap moved
+# Alt-n: a new shell below the active pane
+key a-n
+stable 700 15000
+snap altn
+# Ctrl-w h: focus left
+key c-w h
+stable 400 5000
+snap focus-left
+# Debug overlay on (topbar middle-click on Debug), then off
+press middle 19 1
+release middle 19 1
+stable 400 8000
+snap debug-on
+snapstyle debug-on
+press middle 19 1
+release middle 19 1
+stable 400 8000
+snap debug-off
diff --git a/tests.zig b/tests.zig
deleted file mode 100644
index 6080aa3b..00000000
--- a/tests.zig
+++ /dev/null
@@ -1,1061 +0,0 @@
-const std = @import("std");
-const h = @import("e2e_harness.zig");
-const Harness = h.Harness;
-const config = @import("config");
-const dump = @import("dump.zig");
-
-const ls_ready = "tests.zig";
-
-// E2E tests for pardes. The harness forks the app in a pty and parses its
-// output with a ghostty terminal emulator, so all assertions read the 2D screen
-// GRID (plainString) — not raw byte streams. This is decoupled from the app's
-// rendering (vaxis diff-rendering, repaints, escape sequences): the grid is
-// always current after pump/waitFor.
-//
-// Input is sent as raw bytes (keystrokes, SGR mouse sequences) that the app's
-// vaxis parses. The harness terminal only reads output.
-//
-// Mouse events use SGR pixel mode: \x1b[<button;col;row M (press) / m (release).
-// Cols/rows are 1-based screen coords. Button 0=left, 1=middle, 2=right.
-// A drag is press at start + release at end (with different coords).
-
-// Row index (0-based) of the first screen line containing `needle`, or null.
-fn rowOf(text: []const u8, needle: []const u8) ?usize {
- var it = std.mem.splitScalar(u8, text, '\n');
- var i: usize = 0;
- while (it.next()) |line| : (i += 1) {
- if (std.mem.indexOf(u8, line, needle) != null) return i;
- }
- return null;
-}
-
-// Column (0-based byte offset within its line) of the first `needle` on screen.
-// The grid is monospace ASCII here, so the byte offset is the screen column.
-fn colOf(text: []const u8, needle: []const u8) ?usize {
- var it = std.mem.splitScalar(u8, text, '\n');
- while (it.next()) |line| {
- if (std.mem.indexOf(u8, line, needle)) |off| return off;
- }
- return null;
-}
-
-fn clearRightColumnTerminal(hs: *Harness) !void {
- try hs.send("\x1b[<0;60;20M\x1b[<0;60;20m"); // focus the right-column terminal
- try hs.pump(300);
- try hs.send("\x02"); // normal -> tty
- try hs.pump(400);
- try hs.send("clear\r");
- try hs.pump(800);
- try hs.send("\x02"); // tty -> normal
- try hs.pump(400);
-}
-
-fn writeReplayFixture(io: std.Io, gpa: std.mem.Allocator, path: []const u8) !void {
- const file_content =
- "const std = @import(\"std\");\n" ++
- "\n" ++
- "// utf8: \xe2\x80\x94\n";
- const content_b64 = try dump.encodeBytes(gpa, file_content);
- defer gpa.free(content_b64);
- const panes = [_]dump.Pane{.{
- .kind = .file,
- .tag = "nm /tmp/replay_fixture.zig Save Del",
- .body = "",
- .cols = 78,
- .rows = 22,
- .file = .{
- .path = "/tmp/replay_fixture.zig",
- .content = file_content,
- .content_b64 = content_b64,
- },
- }};
- const col_panes = [_]usize{0};
- const columns = [_]dump.Column{.{ .weight = 1, .panes = &col_panes }};
- try dump.writeFile(io, gpa, path, .{
- .screen = .{ .cols = 80, .rows = 24 },
- .topbar = "Kill Newcol Tutor Debug Colors NextColor Dump",
- .theme = "dark",
- .columns = &columns,
- .panes = &panes,
- });
-}
-
-fn writeReplayScrollFixture(io: std.Io, gpa: std.mem.Allocator, path: []const u8) !void {
- var file_content: std.ArrayList(u8) = .empty;
- defer file_content.deinit(gpa);
- var line_no: usize = 1;
- while (line_no <= 80) : (line_no += 1) {
- const line = try std.fmt.allocPrint(gpa, "replay scroll line {d}\n", .{line_no});
- defer gpa.free(line);
- try file_content.appendSlice(gpa, line);
- }
- const content_b64 = try dump.encodeBytes(gpa, file_content.items);
- defer gpa.free(content_b64);
- const panes = [_]dump.Pane{.{
- .kind = .file,
- .tag = "nm /tmp/replay_scroll_fixture.zig Save Del",
- .body = "",
- .cols = 78,
- .rows = 22,
- .file = .{
- .path = "/tmp/replay_scroll_fixture.zig",
- .content = file_content.items,
- .content_b64 = content_b64,
- },
- }};
- const col_panes = [_]usize{0};
- const columns = [_]dump.Column{.{ .weight = 1, .panes = &col_panes }};
- try dump.writeFile(io, gpa, path, .{
- .screen = .{ .cols = 80, .rows = 24 },
- .topbar = "Kill Newcol Tutor Debug Colors NextColor Dump",
- .theme = "dark",
- .columns = &columns,
- .panes = &panes,
- });
-}
-
-fn writeReplayHoverFixture(io: std.Io, gpa: std.mem.Allocator, path: []const u8) !void {
- const panes = [_]dump.Pane{
- .{
- .kind = .file,
- .tag = "nm /tmp/replay_left.txt Save Del",
- .body = "",
- .cols = 48,
- .rows = 22,
- .file = .{
- .path = "/tmp/replay_left.txt",
- .content = "left\n",
- },
- },
- .{
- .kind = .file,
- .tag = "nm /tmp/replay_right.txt Save Del",
- .body = "",
- .cols = 48,
- .rows = 22,
- .file = .{
- .path = "/tmp/replay_right.txt",
- .content = "right\n",
- },
- },
- };
- const left_col = [_]usize{0};
- const right_col = [_]usize{1};
- const columns = [_]dump.Column{
- .{ .weight = 1, .panes = &left_col },
- .{ .weight = 1, .panes = &right_col },
- };
- try dump.writeFile(io, gpa, path, .{
- .screen = .{ .cols = 100, .rows = 24 },
- .topbar = "Kill Newcol Tutor Debug Colors NextColor Dump",
- .theme = "dark",
- .columns = &columns,
- .panes = &panes,
- });
-}
-
-fn writeReplayImageFixture(io: std.Io, gpa: std.mem.Allocator, path: []const u8) !void {
- const png_b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
- const panes = [_]dump.Pane{.{
- .kind = .image,
- .tag = "img /tmp/replay.png Petscii Del",
- .body = "",
- .cols = 78,
- .rows = 22,
- .image = .{
- .path = "/tmp/replay.png",
- .bytes_b64 = png_b64,
- },
- }};
- const col_panes = [_]usize{0};
- const columns = [_]dump.Column{.{ .weight = 1, .panes = &col_panes }};
- try dump.writeFile(io, gpa, path, .{
- .screen = .{ .cols = 80, .rows = 24 },
- .topbar = "Kill Newcol Tutor Debug Colors NextColor Dump",
- .theme = "dark",
- .columns = &columns,
- .panes = &panes,
- });
-}
-
-pub fn main(init: std.process.Init) !void {
- const gpa = init.gpa;
- const io = init.io;
- const args = try init.minimal.args.toSlice(init.arena.allocator());
- if (args.len != 3) {
- std.debug.print("usage: {s} <pardes-exe> <pardes-replay-exe>\n", .{args[0]});
- return error.BadArgs;
- }
- const exe = args[1];
- const replay_exe = args[2];
-
- var hs = try Harness.init(gpa, exe.ptr, 24, 80);
- hs.trace = config.trace;
- defer hs.deinit();
-
- // 0. The stats overlay is off by default; turn it on via the "Debug" command in
- // the global tagbar (top row). "Debug" sits at cols 18-22, so SGR col ~20,
- // row 1. This also exercises the global tagbar + the Debug toggle. Give the
- // app a moment to come up and render the bar first.
- try hs.pump(800);
- try hs.send("\x1b[<1;20;1M\x1b[<1;20;1m"); // toggle the Debug overlay on
-
- // 1. The app posts an initial winsize on startup. Its winsize handler bumps
- // the resize counter to 1 and repaints, so "resizes=1" shows in the debug
- // overlay. Confirms the app is up and handled its first size.
- try hs.expectWaitFor("resizes=1", 6000, "app never handled its initial winsize (no 'resizes=1')");
- try hs.expectWaitFor(ls_ready, 8000, "new terminals did not auto-ls their directory on open");
-
- // 1b. Dump is a global-tagbar builtin. It writes a backend-independent ZON
- // snapshot with compact pane ids, layout columns, rendered pane text, and
- // pane backing contents.
- std.Io.Dir.deleteFileAbsolute(io, dump.default_path) catch {};
- try hs.send("\x1b[<1;43;1M\x1b[<1;43;1m"); // middle-click "Dump" in the topbar
- try hs.pump(800);
- {
- const dumped = dump.readFile(io, gpa, dump.default_path) catch {
- hs.dump("Dump did not write a parseable /tmp/pardes-dump.zon");
- return error.ExpectFailed;
- };
- defer dump.free(gpa, dumped);
- if (dumped.panes.len == 0 or dumped.columns.len == 0 or dumped.active >= dumped.panes.len) {
- hs.dump("Dump wrote an invalid empty layout");
- return error.ExpectFailed;
- }
- if (std.mem.indexOf(u8, dumped.topbar, "Dump") == null) {
- hs.dump("Dump snapshot did not include the main tagline");
- return error.ExpectFailed;
- }
- if (!std.mem.eql(u8, dumped.theme, "dark")) {
- hs.dump("Dump snapshot did not include the active theme");
- return error.ExpectFailed;
- }
- if (dumped.panes[0].kind != .terminal or dumped.panes[0].terminal == null) {
- hs.dump("Dump snapshot did not capture the terminal pane stream");
- return error.ExpectFailed;
- }
- if (dumped.panes[0].terminal.?.stream_b64.len == 0) {
- hs.dump("Dump snapshot did not include base64 terminal bytes");
- return error.ExpectFailed;
- }
- const tty_bytes = try dump.decodeBytes(gpa, dumped.panes[0].terminal.?.stream_b64);
- defer gpa.free(tty_bytes);
- if (std.mem.indexOf(u8, tty_bytes, ls_ready) == null) {
- hs.dump("Dump terminal byte stream does not replay the visible ls output");
- return error.ExpectFailed;
- }
- }
- {
- var replay_h = try Harness.initArgs(gpa, replay_exe.ptr, 24, 80, dump.default_path);
- replay_h.trace = config.trace;
- defer replay_h.deinit();
- try replay_h.pump(1200);
- try replay_h.expectContains("Kill Newcol", "replay did not render the dump topbar");
- try replay_h.expectContains(ls_ready, "replay did not rebuild the terminal from the dumped byte stream");
- try replay_h.expectNotContains("\xc2\xaa", "replay rendered corrupt prefix bytes");
- }
- {
- const fixture_path = "/tmp/pardes-replay-fixture.zon";
- try writeReplayFixture(io, gpa, fixture_path);
- var replay_file = try Harness.initArgs(gpa, replay_exe.ptr, 24, 80, fixture_path);
- replay_file.trace = config.trace;
- defer replay_file.deinit();
- try replay_file.pump(1200);
- try replay_file.expectContains(" 1 const std", "replay file pane line numbers are broken");
- try replay_file.expectNotContains("\xc2\xaa", "replay file line numbers used invalid stack bytes");
- }
- {
- const fixture_path = "/tmp/pardes-replay-hover.zon";
- try writeReplayHoverFixture(io, gpa, fixture_path);
- var replay_hover = try Harness.initArgs(gpa, replay_exe.ptr, 24, 100, fixture_path);
- replay_hover.trace = config.trace;
- defer replay_hover.deinit();
- try replay_hover.pump(1200);
- try replay_hover.send("\x1b[<35;50;10M");
- try replay_hover.pump(400);
- try replay_hover.expectContains("╎", "replay hover did not overlay the dashed resize handle");
- }
- {
- const fixture_path = "/tmp/pardes-replay-scroll-fixture.zon";
- try writeReplayScrollFixture(io, gpa, fixture_path);
- var replay_scroll = try Harness.initArgs(gpa, replay_exe.ptr, 24, 80, fixture_path);
- replay_scroll.trace = config.trace;
- defer replay_scroll.deinit();
- try replay_scroll.pump(1200);
- try replay_scroll.expectContains(" 1 replay scroll line 1", "replay scrollbar fixture did not start at the top");
- try replay_scroll.send("\x1b[<2;1;23M\x1b[<2;1;23m");
- try replay_scroll.pump(300);
- try replay_scroll.expectContains(" 21 replay scroll line 21", "replay right-click scrollbar did not scroll down");
- try replay_scroll.expectNotContains(" 1 replay scroll line 1", "replay right-click scrollbar left the top line visible");
- try replay_scroll.send("\x1b[<0;1;23M\x1b[<0;1;23m");
- try replay_scroll.pump(300);
- try replay_scroll.expectContains(" 1 replay scroll line 1", "replay left-click scrollbar did not scroll back up");
- }
- {
- const fixture_path = "/tmp/pardes-replay-image.zon";
- try writeReplayImageFixture(io, gpa, fixture_path);
- var replay_image = try Harness.initArgs(gpa, replay_exe.ptr, 24, 80, fixture_path);
- replay_image.trace = config.trace;
- defer replay_image.deinit();
- try replay_image.pump(1200);
- try replay_image.expectContains("/tmp/replay.png", "replay image pane did not render from dumped bytes");
- }
-
- // 2. Regression: grow the pty -> SIGWINCH -> app re-resizes. Counter bumps
- // to 2 only if the resize handler is installed.
- try hs.resize(30, 100);
- try hs.expectWaitFor("resizes=2", 8000, "SIGWINCH was not turned into a resize (counter stuck at 1)");
-
- // 2b. RECLAIMED GAP + HOVER HANDLE: no dead gap column between panes now — the
- // resize handle is the LEFT pane's last column, and hovering it overlays a
- // dash (keeping the cell's colors). 2 equal columns at 100 wide -> left width
- // 50, last col = 49 (SGR 50). A no-button motion there paints the "╎".
- try hs.send("\x1b[<35;50;10M"); // motion (no button) over the left pane's last col
- try hs.pump(400);
- try hs.expectContains("╎", "hovering the column boundary did not overlay the dashed resize handle");
-
- // 3. Default is NORMAL mode: the tag shows "nm" (helix-style block cursor).
- try hs.expectContains("nm /", "default tag is not 'nm' (normal mode not default)");
-
- // 3b. ls-on-open: a fresh shell greets with its directory listing. The e2e app
- // boots in the project dir, so a known file shows up as command output
- // (visible even in normal mode — only prompt rows are hidden).
- try hs.expectWaitFor(ls_ready, 8000, "new terminals did not auto-ls their directory on open");
-
- // 3c. Normal-mode TAB = acme middle-click "execute" the word under the cursor.
- // Drop a one-line executable, echo its path onto body row 0 (clear homes the
- // cursor first), left-click the path to enter normal + pin the cursor on it,
- // then TAB. It runs the word in place (no new pane) -> the script's marker.
- try hs.send("\x02"); // normal -> tty (Ctrl-b)
- try hs.pump(600);
- try hs.send("printf '#!/bin/sh\\necho ZZTABRAN\\n' > /tmp/zztabcmd; chmod +x /tmp/zztabcmd; clear; echo /tmp/zztabcmd\r");
- try hs.pump(1200);
- try hs.send("\x02"); // tty -> normal (Ctrl-b): the mouse no longer exits tty mode
- try hs.pump(600);
- try hs.send("\x1b[<0;8;3M\x1b[<0;8;3m"); // left-click the path on body row 0 -> pin the cursor on it (+1 row: global tagbar)
- try hs.pump(600);
- try hs.send("\t"); // TAB: execute "/tmp/zztabcmd"
- try hs.expectWaitFor("ZZTABRAN", 8000, "normal-mode Tab did not execute the word under the cursor");
- try hs.pump(400);
-
- // 3c-bis. CENTRAL DISPATCH: a builtin executes from a PANE BODY, not only the
- // tagbar. Echo "Debug" as output (visible in normal mode), click it, TAB it:
- // the same .debug action runs through the one dispatcher and toggles the
- // overlay OFF (step 0 turned it on) -> "resizes=" disappears. Proves onSelect
- // matches builtins wherever the word appears (tty here, not the tagbar).
- try hs.send("\x02"); // normal -> tty
- try hs.pump(400);
- try hs.send("clear; echo Debug\r");
- try hs.pump(900);
- try hs.send("\x02"); // tty -> normal
- try hs.pump(400);
- try hs.send("\x1b[<0;5;3M\x1b[<0;5;3m"); // left-click "Debug" on body row 0 (col 3=body 0; GUTTER 2)
- try hs.pump(400);
- try hs.send("\t"); // TAB: execute the builtin "Debug"
- try hs.pump(600);
- try hs.expectNotContains("resizes=", "executing 'Debug' from a pane body did not toggle the overlay (central dispatch broken)");
-
- // 3d. OUTPUT PADDING: a command composed (typed as an overlay) far below the
- // prompt runs BELOW where it was typed, not back up at the prompt. The app
- // pads the shell down with newlines to max(last-edit row, prompt row); the
- // padding prompts are hidden in text mode, so the output flows under the
- // composed line. Clear the pane (prompt -> row 0), type a command on a low
- // row whose OUTPUT marker (PAD13Z) differs from its source (echo PAD...),
- // run it, and assert the output row is BELOW the typed row. (Screen is 30x100
- // from step 2; terms[0] is the ~15-row top-left pane.)
- try hs.send("\x02"); // normal -> tty
- try hs.pump(400);
- try hs.send("clear\r"); // prompt back to row 0
- try hs.pump(900);
- try hs.send("\x02"); // tty -> normal
- try hs.pump(400);
- try hs.send("\x1b[<0;5;11M\x1b[<0;5;11m"); // left-click a low blank row -> normal + pin (+1 row: global tagbar)
- try hs.pump(400);
- try hs.send("i"); // insert
- try hs.pump(400);
- try hs.send("echo PAD$((6+7))Z"); // overlay; output is "PAD13Z"
- try hs.pump(600);
- try hs.send("\x1b"); // -> normal
- try hs.pump(400);
- try hs.send("\x1b[<1;5;11M\x1b[<33;21;11M\x1b[<1;21;11m"); // middle-drag-select the line -> run (padded)
- try hs.expectWaitFor("PAD13Z", 8000, "padded command did not run");
- try hs.pump(600);
- {
- const text = try hs.screenText();
- defer gpa.free(text);
- const typed = rowOf(text, "echo PAD") orelse {
- hs.dump("composed line vanished before the padding check");
- return error.ExpectFailed;
- };
- const outp = rowOf(text, "PAD13Z") orelse {
- hs.dump("padded output not found on screen");
- return error.ExpectFailed;
- };
- if (outp <= typed) {
- hs.dump("output did not land below the composed command (padding failed)");
- return error.ExpectFailed;
- }
- // and it should land RIGHT under the composed line (tight alignment), not
- // somewhere far below — the hidden padding prompts fill the gap above it.
- if (outp - typed > 2) {
- hs.dump("padded output landed too far below the composed line (misaligned)");
- return error.ExpectFailed;
- }
- }
- // tidy up: drop the lingering overlay so it doesn't ride the scrollback into
- // later steps (cursor is on the typed row; x selects it, d clears the run).
- try hs.send("x");
- try hs.pump(300);
- try hs.send("d");
- try hs.pump(300);
-
- // 4. Ctrl-b toggles raw TTY mode from normal and forwards keystrokes to the
- // shell. tty is a hidden toggle (Ctrl-b), off the normal editing workflow.
- const marker = "PARDES_E2E";
- try hs.send("\x02"); // normal -> tty (Ctrl-b)
- try hs.pump(600);
- try hs.send("echo " ++ marker ++ "\r");
- try hs.expectWaitFor(marker, 8000, "tty mode did not forward the marker to the shell");
- try hs.expectContains("sy /", "no 'sy' indicator after Ctrl-b (tty mode)");
-
- // 4a. The mouse stays in tty mode: a body left-click (press+release) must NOT
- // drop to normal — only Ctrl-b exits tty now. The tag stays "sy".
- try hs.send("\x1b[<0;6;4M\x1b[<0;6;4m"); // left-click + release in the body
- try hs.pump(500);
- try hs.expectContains("sy /", "a left-click in tty mode wrongly returned to normal");
-
- // 4b. Ctrl-b toggles back: tty -> normal. Send Ctrl-b again and confirm the
- // tag flips off "sy" (raw tty) back to "nm" (normal).
- try hs.send("\x02"); // tty -> normal (Ctrl-b)
- try hs.pump(600);
- try hs.expectContains("nm /", "Ctrl-b did not toggle tty back to normal");
- try hs.send("\x02"); // normal -> tty again for the prompt-hiding step
- try hs.pump(600);
-
- // 5. Prompt hiding in TEXT mode. tty shows the real terminal (command line +
- // output). BOTH normal and insert hide the prompt/command rows (the acme
- // compose surface) while keeping output. Run a command in tty, Ctrl-b to
- // NORMAL, and check: output present, the command's prompt row hidden.
- try hs.send("echo CMDHIDEZZ\r");
- try hs.expectWaitFor("CMDHIDEZZ", 8000, "tty mode did not run the command");
- try hs.pump(800); // let tty-mode frames settle
- try hs.send("\x02"); // tty -> normal (Ctrl-b): the mouse no longer exits tty mode
- try hs.pump(600);
- try hs.send("\x1b[<0;6;4M\x1b[<0;6;4m"); // left-click body to pin the cursor (already normal) (+1 row: global tagbar)
- try hs.pump(600);
- try hs.expectContains("CMDHIDEZZ", "command output not shown in normal mode");
- // The prompt row (with the typed command) is hidden in normal mode now too. We
- // check "echo CMDHIDEZZ" (this pane's prompt row) rather than a shell prompt (e.g. "user@host") since a
- // raw-tty pane elsewhere could show its own prompt.
- try hs.expectNotContains("echo CMDHIDEZZ", "command line still visible in normal mode (prompt row not hidden)");
-
- // 6. Insert-mode editing + middle-click execute. Click a blank row to pin the
- // cursor at text col 0, type a computed echo, then middle-select those 15
- // cells and release. "Q7Z" appears only if the SELECTED typed text ran —
- // proving selection reads the rendered screen.
- try hs.send("\x1b[<0;3;15M\x1b[<0;3;15m"); // click blank row, col 0 -> normal + pin (+1 row: global tagbar)
- try hs.pump(400);
- try hs.send("i"); // normal -> insert so typing composes the command
- try hs.pump(400);
- try hs.send("echo Q$((3+4))Z");
- try hs.pump(600); // let the run build
- try hs.send("\x1b[<1;3;15M\x1b[<33;17;15M\x1b[<1;17;15m"); // middle-select it (+1 row: global tagbar)
- try hs.expectWaitFor("Q7Z", 8000, "middle-click did not execute the selected typed text");
- // leave insert -> normal so the next step can reach tty via Ctrl-b
- try hs.send("\x1b");
- try hs.pump(400);
-
- // 6b. FOLLOW OUTPUT: new tty output scrolls the pane to the bottom, so a result
- // shows even after you've scrolled up. Fill the pane, wheel UP off the
- // bottom (LN40 disappears), then run a command — the pane snaps back and the
- // new marker is visible. Done in tty mode so the active pane follows too.
- try hs.send("\x02"); // normal -> tty
- try hs.pump(400);
- try hs.send("clear; for i in $(seq 1 40); do echo LN$i; done\r");
- try hs.expectWaitFor("LN40", 8000, "filler output never appeared");
- try hs.pump(500);
- {
- var w: usize = 0;
- while (w < 30) : (w += 1) try hs.send("\x1b[<64;5;6M"); // wheel UP over the pane (+1 row: global tagbar)
- }
- try hs.pump(500);
- try hs.expectNotContains("LN40", "wheel-up did not scroll the pane off the bottom");
- try hs.send("echo ZZFOLLOW\r");
- try hs.expectWaitFor("ZZFOLLOW", 8000, "new output did not scroll the pane back to the bottom");
- try hs.send("\x02"); // tty -> normal (step 7 re-enters via Ctrl-b)
- try hs.pump(400);
-
- // 6c. NORMAL-MODE MOTION SCROLLS THE TTY like a file pane: the pane is at the
- // bottom (ZZFOLLOW visible). `gg` rides the cursor to the top of the
- // scrollback and the viewport follows, so the bottom marker scrolls off;
- // `G` rides it back down and the marker returns.
- try hs.send("gg");
- try hs.pump(500);
- try hs.expectNotContains("ZZFOLLOW", "normal-mode gg did not scroll the tty up off the bottom");
- try hs.send("G");
- try hs.pump(500);
- try hs.expectContains("ZZFOLLOW", "normal-mode G did not scroll the tty back to the bottom");
-
- // 6d. YANK MIRRORS TO THE SYSTEM CLIPBOARD (OSC 52): select the line with `x`
- // and yank with `y`; the app writes an OSC 52 clipboard-copy to the host.
- // (The paste direction needs a host that answers OSC 52 reads, which this
- // emulator can't, so it isn't covered here.)
- try hs.send("xy");
- try hs.pump(400);
- try hs.expectRawContains("\x1b]52;c;", "yank did not emit an OSC 52 system-clipboard copy");
-
- // 7. Right-click a directory opens (acme "look") a terminal there and ls's
- // it. Make a dir with a unique entry, then `clear` and echo its path so the
- // path lands as contiguous output on body row 0. Right-click (no drag) on
- // it: the new terminal lists the dir, so the unique entry appears only if
- // the directory was actually opened.
- try hs.send("\x02"); // normal -> tty (Ctrl-b)
- try hs.pump(700);
- try hs.send("rm -rf /tmp/pardesE2E; mkdir -p /tmp/pardesE2E/ZZUNIQDIR; clear; echo /tmp/pardesE2E\r");
- try hs.pump(2000); // let it run + settle
- try hs.send("\x1b[<2;5;3M\x1b[<2;5;3m"); // right-click the path (+1 row: global tagbar)
- try hs.expectWaitFor("ZZUNIQDIR", 20000, "right-click on a directory did not open+ls it");
-
- // 8. Right-click a "path:line" opens a line-numbered FILE pane SCROLLED to
- // that line. Build a tall file whose deep marker (line 150) is only
- // reachable by scrolling, type the path:line on a blank row of the right
- // column, then right-click it. The file pane opens, scrolls to line 150 ->
- // the marker shows.
- {
- var fbuf: std.ArrayList(u8) = .empty;
- defer fbuf.deinit(gpa);
- var ln: usize = 1;
- while (ln <= 200) : (ln += 1)
- try fbuf.appendSlice(gpa, if (ln == 150) "ZZDEEPMARK\n" else "filler\n");
- try h.writeFile("/tmp/pardesE2Efile.txt", fbuf.items);
- }
- try hs.resize(30, 100); // clean geometry (right column starts ~col 51)
- try hs.pump(700); // settle the reflow
- try clearRightColumnTerminal(&hs);
- try hs.send("\x1b[<0;60;26M\x1b[<0;60;26m"); // left-click a blank row well below the ls output, right column -> normal + pin (+1 row: global tagbar)
- try hs.pump(400);
- try hs.send("i"); // normal -> insert so typing composes the path:line
- try hs.pump(400);
- try hs.send("/tmp/pardesE2Efile.txt:150");
- try hs.pump(800); // build the run
- try hs.send("\x1b[<2;70;26M\x1b[<2;70;26m"); // right-click on the typed path (+1 row: global tagbar)
- try hs.expectWaitFor("ZZDEEPMARK", 20000, "right-click 'path:150' did not open the file scrolled to line 150");
- try hs.expectContains("Save Del", "file pane tag did not include the default Save command");
- try hs.expectNotContains("Delcol", "Delcol is still shown in the default pane tag");
-
- // 9. Modal modes on the active FILE pane (left open & focused by step 8):
- // `i` -> INSERT (tag shows "in"), Esc -> NORMAL ("nm"). Validates the
- // 3-mode model end to end via the tag indicator. No content changes here.
- try hs.send("i"); // normal -> insert
- try hs.pump(600);
- try hs.expectContains("in /", "no 'in' indicator after i (insert mode)");
- try hs.send("\x1b"); // insert -> normal
- try hs.pump(600);
- try hs.expectContains("nm /", "no 'nm' indicator after Esc (back to normal)");
-
- // 10. Helix-style file editing on that same file pane (still focused, normal,
- // cursor on the ZZDEEPMARK line). `o` opens a line below and enters insert;
- // type a unique marker, Esc to normal, then `x` (select line) + `d` (delete
- // selection) removes it. Assert the marker appears then is gone, while the
- // surrounding file content survives — proving real content mutation.
- try hs.send("o"); // open line below -> insert
- try hs.pump(600);
- try hs.send("ZZMODALNEW");
- try hs.pump(600);
- try hs.send("\x1b"); // back to normal
- try hs.pump(600);
- try hs.expectContains("ZZMODALNEW", "'o' did not open a line / typed text not shown");
- // cursor sits on the ZZMODALNEW line: `x` selects it, `d` deletes it.
- try hs.send("x");
- try hs.pump(400);
- try hs.send("d");
- try hs.pump(800);
- try hs.expectNotContains("ZZMODALNEW", "ZZMODALNEW still present after x+d (line delete failed)");
- // sanity: the deep marker (line 150, above the opened/deleted line) survives.
- try hs.expectContains("ZZDEEPMARK", "ZZDEEPMARK vanished — file pane content was clobbered");
-
- // 10b. Char-wise select (`v`) + delete, then undo/redo. Open a line, type a
- // marker, `0` to the line start, `v` to start a char selection, `$` to
- // extend to the line end, `d` to delete the range (gone), `u` to undo it
- // (back), `U` to redo (gone again). Proves v-select, real char-range
- // deletion, and the dumb undo/redo stack on a file pane.
- try hs.send("o"); // open line below -> insert
- try hs.pump(500);
- try hs.send("ZZVSELMARK");
- try hs.pump(500);
- try hs.send("\x1b"); // -> normal, cursor on the new line
- try hs.pump(500);
- try hs.expectContains("ZZVSELMARK", "char-select setup: typed marker not shown");
- try hs.send("0"); // line start
- try hs.pump(300);
- try hs.send("v"); // start char selection
- try hs.pump(300);
- try hs.send("$"); // extend the selection to the line end
- try hs.pump(300);
- try hs.send("d"); // delete the selected char range
- try hs.pump(800);
- try hs.expectNotContains("ZZVSELMARK", "v + $ + d did not delete the char selection");
- try hs.send("u"); // undo the delete
- try hs.pump(800);
- try hs.expectContains("ZZVSELMARK", "u (undo) did not restore the deleted text");
- try hs.send("U"); // redo the delete
- try hs.pump(800);
- try hs.expectNotContains("ZZVSELMARK", "U (redo) did not re-apply the delete");
-
- // 10c. Save writes the current file pane back to disk. Add a durable marker,
- // middle-click the default "Save" command in the file tag, then read the
- // real file from /tmp and confirm the edit landed there.
- try hs.send("o"); // open a new line below -> insert
- try hs.pump(500);
- try hs.send("ZZSAVEDISK");
- try hs.pump(500);
- try hs.send("\x1b"); // -> normal
- try hs.pump(500);
- try hs.expectContains("ZZSAVEDISK", "save setup: marker not visible in the file pane");
- try hs.send("\x1b[<1;31;2M\x1b[<1;31;2m"); // middle-click "Save" in the file tag
- try hs.pump(1000);
- const saved = try std.Io.Dir.cwd().readFileAlloc(io, "/tmp/pardesE2Efile.txt", gpa, .limited(1 << 20));
- defer gpa.free(saved);
- if (std.mem.indexOf(u8, saved, "ZZSAVEDISK") == null) {
- hs.dump("Save command did not write the edited file content to disk");
- return error.SaveDidNotWriteDisk;
- }
-
- // 10d. Focused save regression: saving a shorter edited buffer must replace the
- // file exactly, not merely leave a marker somewhere on disk. This catches
- // missed Save dispatch and non-truncating writes.
- {
- var hs_save = try Harness.init(gpa, exe.ptr, 24, 80);
- hs_save.trace = config.trace;
- defer hs_save.deinit();
- try h.writeFile("/tmp/pardesE2Esave.txt", "ZZORIG_SAVE_SHOULD_DISAPPEAR_AND_TAIL\n");
- try hs_save.expectWaitFor(ls_ready, 8000, "save regression app never auto-ls'd");
- try hs_save.resize(30, 100);
- try hs_save.pump(700);
- try clearRightColumnTerminal(&hs_save);
- try hs_save.send("\x1b[<0;60;20M\x1b[<0;60;20m"); // pin a blank row, right column -> normal
- try hs_save.pump(400);
- try hs_save.send("i");
- try hs_save.pump(300);
- try hs_save.send("/tmp/pardesE2Esave.txt");
- try hs_save.pump(500);
- try hs_save.send("\x1b"); // -> normal
- try hs_save.pump(400);
- try hs_save.send("\x1b[<2;70;20M\x1b[<2;70;20m"); // right-click the path -> open file
- try hs_save.expectWaitFor("ZZORIG_SAVE_SHOULD_DISAPPEAR", 20000, "save regression file did not open");
- try hs_save.send("x"); // select the original line
- try hs_save.pump(300);
- try hs_save.send("c"); // clear it and enter insert
- try hs_save.pump(300);
- try hs_save.send("ZZSAVED_EXACT");
- try hs_save.pump(500);
- try hs_save.send("\x1b"); // -> normal
- try hs_save.pump(500);
- try hs_save.expectContains("ZZSAVED_EXACT", "save regression edit is not visible before saving");
- try hs_save.send("\x1b[<1;31;2M\x1b[<1;31;2m"); // middle-click "Save" in the file tag
- try hs_save.pump(1000);
- const exact = try std.Io.Dir.cwd().readFileAlloc(io, "/tmp/pardesE2Esave.txt", gpa, .limited(1 << 20));
- defer gpa.free(exact);
- if (!std.mem.eql(u8, exact, "ZZSAVED_EXACT\n")) {
- hs_save.dump("Save command did not replace the file with the exact edited bytes");
- std.debug.print("saved file bytes: {any}\n", .{exact});
- return error.SaveDidNotWriteExactDisk;
- }
- }
-
- // 11. The Tutor builtin now lives in the GLOBAL tagbar (top row, full width):
- // "Kill Newcol Tutor Debug". Clicking "Tutor" there spawns the tutor as a
- // file pane. The tutor is embedded in the binary (@embedFile), so its
- // content (the PARDES TUTOR header + the acme mouse intro) appears. "Tutor"
- // sits at cols 12-16 (0-based) -> SGR col ~14, the tagbar is screen row 1.
- try hs.send("\x1b[<1;14;1M\x1b[<1;14;1m"); // middle-click "Tutor" in the global tagbar
- try hs.expectWaitFor("PARDES TUTOR", 10000, "global-tagbar 'Tutor' did not spawn the tutor pane");
- try hs.expectContains("tmux + vi + acme", "tutor pane content is wrong (no tagline)");
-
- // Tell the app to quit (ctrl-c is the app's own exit key).
- try hs.send("\x03");
-
- // 12. The --tty flag: start tmux-style as a single shell pane already in tty
- // mode. Confirm the (only) pane's tag shows "sy" (tty) and that keystrokes
- // go straight to the shell. A fresh app instance with the flag.
- {
- var hs2 = try Harness.initArgs(gpa, exe.ptr, 24, 80, "--tty");
- defer hs2.deinit();
- try hs2.expectWaitFor("sy /", 6000, "--tty did not start in tty mode (no 'sy' tag)");
- try hs2.send("echo ZZTTYFLAG\r");
- try hs2.expectWaitFor("ZZTTYFLAG", 8000, "--tty single shell did not forward input to the shell");
- }
-
- // 12b. -h/--help print usage and exit before entering the TUI.
- {
- var hsh = try Harness.initArgs(gpa, exe.ptr, 12, 80, "-h");
- defer hsh.deinit();
- try hsh.expectWaitFor("Usage: pardes", 4000, "-h did not print usage");
- try hsh.expectContains("--tty-toggle", "-h help did not mention --tty-toggle");
- }
- {
- var hsh2 = try Harness.initArgs(gpa, exe.ptr, 12, 80, "--help");
- defer hsh2.deinit();
- try hsh2.expectWaitFor("Usage: pardes", 4000, "--help did not print usage");
- }
-
- // 12c. The tty toggle can be moved off Ctrl-b. With --tty-toggle=g, Ctrl-b
- // should no longer enter tty; Ctrl-g should.
- {
- var hsg = try Harness.initArgs(gpa, exe.ptr, 24, 80, "--tty-toggle=g");
- defer hsg.deinit();
- try hsg.expectWaitFor("nm /", 6000, "--tty-toggle=g app did not start in normal mode");
- try hsg.send("\x02"); // Ctrl-b: now just a normal-mode key, not the tty toggle
- try hsg.pump(600);
- try hsg.expectNotContains("sy /", "Ctrl-b still toggled tty after --tty-toggle=g");
- try hsg.send("\x07"); // Ctrl-g: configured tty toggle
- try hsg.expectWaitFor("sy /", 6000, "Ctrl-g did not toggle tty after --tty-toggle=g");
- try hsg.send("\x07");
- try hsg.expectWaitFor("nm /", 6000, "Ctrl-g did not toggle tty back to normal");
- }
-
- // 13. Terminal char-mode editing end to end (fresh instance for a clean pane):
- // insert an overlay run, backspace part of it, Ctrl-b to tty (overlay hidden)
- // and back (overlay returns), then u (undo the whole insert) / U (redo).
- {
- var hs3 = try Harness.init(gpa, exe.ptr, 24, 80);
- defer hs3.deinit();
- try hs3.expectWaitFor(ls_ready, 8000, "fresh terminal never auto-ls'd");
- // pin the cursor on the top-left pane and type a marker (insert mode).
- try hs3.send("\x1b[<0;5;8M\x1b[<0;5;8m"); // left-click -> normal + pin (+1 row: global tagbar)
- try hs3.pump(400);
- try hs3.send("i");
- try hs3.pump(300);
- try hs3.send("ZZTERMINSXYZ");
- try hs3.pump(500);
- try hs3.expectContains("ZZTERMINSXYZ", "terminal insert did not show the typed overlay");
- // insert-mode backspace erases within the run: drop the trailing "XYZ".
- try hs3.send("\x7f\x7f\x7f");
- try hs3.pump(500);
- try hs3.expectNotContains("ZZTERMINSXYZ", "terminal insert-mode backspace did not erase typed text");
- try hs3.expectContains("ZZTERMINS", "backspace erased too much of the run");
- try hs3.send("\x1b"); // insert -> normal
- try hs3.pump(400);
- try hs3.send("\x02"); // normal -> tty: the overlay is hidden
- try hs3.pump(700);
- try hs3.expectNotContains("ZZTERMINS", "tty mode did not hide the inserted-text overlay");
- try hs3.send("\x02"); // tty -> normal: the overlay returns
- try hs3.pump(700);
- try hs3.expectContains("ZZTERMINS", "overlay did not return after leaving tty mode");
- try hs3.send("u"); // undo the whole insert session
- try hs3.pump(600);
- try hs3.expectNotContains("ZZTERMINS", "u (undo) did not remove the terminal insertion");
- try hs3.send("U"); // redo
- try hs3.pump(600);
- try hs3.expectContains("ZZTERMINS", "U (redo) did not restore the terminal insertion");
- }
-
- // 14. Normal-mode Tab/Enter act on the SELECTION when one is active (not the
- // word under the cursor). Clear a fresh pane, type a computed command as an
- // overlay, `x` to select the whole line, Tab -> the SELECTION runs. The
- // output marker (ZZSEL5X) differs from the source ($((2+3))) so it only
- // matches if the selection actually executed.
- {
- var hs4 = try Harness.init(gpa, exe.ptr, 24, 80);
- defer hs4.deinit();
- try hs4.expectWaitFor(ls_ready, 8000, "fresh terminal never auto-ls'd");
- try hs4.send("\x02"); // normal -> tty
- try hs4.pump(500);
- try hs4.send("clear\r"); // blank the pane, prompt to row 0
- try hs4.pump(900);
- try hs4.send("\x02"); // tty -> normal
- try hs4.pump(400);
- try hs4.send("\x1b[<0;2;6M\x1b[<0;2;6m"); // pin a blank low row near col 0
- try hs4.pump(400);
- try hs4.send("i");
- try hs4.pump(300);
- try hs4.send("echo ZZSEL$((2+3))X"); // overlay; output is "ZZSEL5X"
- try hs4.pump(500);
- try hs4.send("\x1b"); // -> normal
- try hs4.pump(400);
- try hs4.send("x"); // select the whole typed line
- try hs4.pump(400);
- try hs4.send("\t"); // Tab: execute the SELECTION (not the word under cursor)
- try hs4.expectWaitFor("ZZSEL5X", 8000, "Tab on a selection did not execute the selected text");
- }
-
- // 15. Image panes open even without kitty graphics, rendering via the PETSCII
- // fallback, and carry a "Petscii" toggle in their tagline. Write a small PPM,
- // open it with an acme right-click, confirm the image pane + toggle appear,
- // and pump frames (smoke-testing the glyph draw doesn't crash).
- {
- var hs5 = try Harness.init(gpa, exe.ptr, 24, 80);
- defer hs5.deinit();
- try hs5.expectWaitFor(ls_ready, 8000, "fresh terminal never auto-ls'd");
- {
- var ppm: std.ArrayList(u8) = .empty;
- defer ppm.deinit(gpa);
- try ppm.appendSlice(gpa, "P6\n64 64\n255\n"); // diagonal split -> per-cell glyphs
- var y: usize = 0;
- while (y < 64) : (y += 1) {
- var x: usize = 0;
- while (x < 64) : (x += 1)
- try ppm.appendSlice(gpa, if (x + y < 64) &[_]u8{ 255, 255, 255 } else &[_]u8{ 0, 0, 255 });
- }
- try h.writeFile("/tmp/pardesE2Epet.ppm", ppm.items);
- }
- try hs5.send("\x02"); // normal -> tty
- try hs5.pump(500);
- try hs5.send("clear\r"); // blank the pane
- try hs5.pump(900);
- try hs5.send("\x02"); // tty -> normal
- try hs5.pump(400);
- try hs5.send("\x1b[<0;5;8M\x1b[<0;5;8m"); // pin a blank row near col 0
- try hs5.pump(400);
- try hs5.send("i");
- try hs5.pump(300);
- try hs5.send("/tmp/pardesE2Epet.ppm");
- try hs5.pump(500);
- try hs5.send("\x1b"); // -> normal
- try hs5.pump(400);
- try hs5.send("\x1b[<2;10;8M\x1b[<2;10;8m"); // right-click the typed path -> open image pane
- try hs5.expectWaitFor("Petscii", 16000, "right-click on an image path did not open an image pane with the Petscii toggle");
- try hs5.pump(800); // let the petscii grid render across frames
- // the diagonal split renders as real block glyphs — assert the full block
- // shows (a blank body here would mean the grid didn't blit, e.g. a dangling
- // grapheme slice into a stack-local cell).
- try hs5.expectContains("█", "petscii image body is blank — no block glyphs rendered");
- }
-
- // 16. Opening a SECOND file SPLITS the existing file pane (acme-style minimal
- // reflow) instead of stacking at the top of the column: the new file lands
- // directly BELOW the source file, so A is above B. The OLD stack-at-top
- // behavior would put B ABOVE A — so asserting rowOf(A) < rowOf(B) is a
- // regression test for the split.
- {
- var hs6 = try Harness.init(gpa, exe.ptr, 24, 80);
- defer hs6.deinit();
- try hs6.expectWaitFor(ls_ready, 8000, "fresh terminal never auto-ls'd");
- try h.writeFile("/tmp/pardesE2Efa.txt", "ZZFILEA\nfa1\nfa2\nfa3\nfa4\nfa5\nfa6\nfa7\nfa8\nfa9\n");
- try h.writeFile("/tmp/pardesE2Efb.txt", "ZZFILEB\nfb1\nfb2\nfb3\nfb4\nfb5\nfb6\nfb7\nfb8\nfb9\n");
- try hs6.resize(30, 100); // clean geometry: right column starts ~col 51
- try hs6.pump(700);
- try clearRightColumnTerminal(&hs6);
- // open file A from the right-column terminal: the FIRST doc pane, so it
- // takes the stack-at-top fallback (no existing doc pane to split).
- try hs6.send("\x1b[<0;60;20M\x1b[<0;60;20m"); // pin a blank row, right column -> normal
- try hs6.pump(400);
- try hs6.send("i");
- try hs6.pump(300);
- try hs6.send("/tmp/pardesE2Efa.txt");
- try hs6.pump(500);
- try hs6.send("\x1b"); // -> normal
- try hs6.pump(400);
- try hs6.send("\x1b[<2;70;20M\x1b[<2;70;20m"); // right-click the path -> open file A
- try hs6.expectWaitFor("ZZFILEA", 20000, "right-click did not open file A");
- // open file B likewise: a doc pane now exists, so B SPLITS it (lands below A).
- try hs6.send("\x1b[<0;60;22M\x1b[<0;60;22m"); // pin another blank row, right column
- try hs6.pump(400);
- try hs6.send("i");
- try hs6.pump(300);
- try hs6.send("/tmp/pardesE2Efb.txt");
- try hs6.pump(500);
- try hs6.send("\x1b");
- try hs6.pump(400);
- try hs6.send("\x1b[<2;70;22M\x1b[<2;70;22m"); // right-click the path -> open file B
- try hs6.expectWaitFor("ZZFILEB", 20000, "right-click did not open file B");
- // both markers sit on each file's TOP line (scroll 0). The split puts B
- // under A -> A's row < B's row. Stack-at-top would invert this.
- const text = try hs6.screenText();
- defer gpa.free(text);
- const ra = rowOf(text, "ZZFILEA") orelse return error.NoFileAOnScreen;
- const rb = rowOf(text, "ZZFILEB") orelse return error.NoFileBOnScreen;
- if (!(ra < rb)) {
- hs6.dump("second file did not split BELOW the first (B not under A)");
- return error.FileSplitOrderWrong;
- }
- }
-
- // 17. Deleting a pane that EMPTIES its column hands the column's width to a
- // neighbor, so the OTHER columns keep their widths (only the neighbor
- // grows). Regression for the extra horizontal movement: without the
- // col-weight handoff, wsum drops and every surviving column reflows
- // sideways. Build 3 columns, put a marker in the rightmost, delete the
- // MIDDLE column's shell (exit), and assert the marker's screen column is
- // unchanged (the middle column's width flowed LEFT, not to the right one).
- {
- var hs7 = try Harness.init(gpa, exe.ptr, 24, 80);
- defer hs7.deinit();
- try hs7.expectWaitFor(ls_ready, 8000, "fresh terminal never auto-ls'd");
- try hs7.resize(30, 120); // 3 columns of ~40 each
- try hs7.pump(700);
- // add a 3rd column via the global-tagbar "Newcol" (cols 5-10, 0-based).
- try hs7.send("\x1b[<1;8;1M\x1b[<1;8;1m"); // middle-click "Newcol"
- try hs7.pump(2500); // let the new shell come up
- // mark the rightmost column (now the active pane) with a unique token.
- try hs7.send("\x02"); // normal -> tty
- try hs7.pump(500);
- try hs7.send("clear\r");
- try hs7.pump(800);
- // split the token so it appears ONLY in the OUTPUT (at the pane's left edge),
- // never in the command text — else colOf would match the prompt-relative echo.
- try hs7.send("echo ZZFAR''COL\r");
- try hs7.expectWaitFor("ZZFARCOL", 12000, "new column's shell did not echo the marker");
- const before = try hs7.screenText();
- const x1 = colOf(before, "ZZFARCOL") orelse {
- gpa.free(before);
- return error.NoFarColBefore;
- };
- gpa.free(before);
- // focus the MIDDLE column (x 40..80) and exit its shell -> the column
- // empties and collapses. pty_eof deletes the pane and reflows.
- try hs7.send("\x1b[<0;58;12M\x1b[<0;58;12m"); // left-click inside the middle column
- try hs7.pump(500);
- try hs7.send("\x02"); // -> tty
- try hs7.pump(400);
- try hs7.send("exit\r"); // shell EOF -> pane removed -> middle column collapses
- try hs7.pump(2000); // let pty_eof + the reflow settle
- try hs7.expectContains("ZZFARCOL", "marker vanished after collapsing the middle column");
- const after = try hs7.screenText();
- defer gpa.free(after);
- const x2 = colOf(after, "ZZFARCOL") orelse return error.NoFarColAfter;
- // with the width handoff the rightmost column stays put (x2 ~= x1); without
- // it, every column reflows and x2 shrinks by ~one column width.
- if (x2 + 2 < x1) {
- hs7.dump("rightmost column moved when the middle column collapsed (no width handoff)");
- std.debug.print("ZZFARCOL col: before={d} after={d}\n", .{ x1, x2 });
- return error.ColumnCollapseReflow;
- }
- }
-
- // 18. DEVICE-QUERY REPLIES: full-screen apps (nushell/reedline, helix, fzf — all
- // crossterm) send a DSR cursor-position report (ESC[6n) at startup and BLOCK
- // until the terminal answers. pardes must reply into the pty; without the
- // write_pty effect the reply is dropped and the app spins forever ("nushell
- // never opens"). Mode-independent. Run a command that (in raw mode, like a
- // real app) emits ESC[6n and only echoes its marker AFTER it reads the reply
- // — the marker appears only if pardes answered the query.
- {
- var hs8 = try Harness.init(gpa, exe.ptr, 24, 80);
- defer hs8.deinit();
- try hs8.expectWaitFor(ls_ready, 8000, "fresh terminal never auto-ls'd");
- try hs8.send("\x02"); // normal -> tty
- try hs8.pump(600);
- // -icanon so the reply's bytes reach `read` without a newline; read until the
- // reply terminator 'R' (ESC[<row>;<col>R) with a timeout, and print the marker
- // ONLY if the read SUCCEEDS (a reply arrived). The marker is split with ''
- // so it appears solely in the command's OUTPUT, never in the echoed command
- // line — otherwise the on-screen command text would false-match it.
- try hs8.send("stty -icanon -echo min 1 time 0; printf '\\033[6n'; read -r -d R -t 3 _c && printf 'ZZCPR''OK\\n'; stty sane\r");
- try hs8.expectWaitFor("ZZCPROK", 8000, "app blocked on its ESC[6n query — pardes dropped the reply (device queries not answered)");
- }
-
- // 19. PRIMARY-DA TERMINATOR (helix regression). crossterm probes capabilities with
- // `ESC[?u ESC[c` (kitty-keyboard query + primary DA) and waits on the DA reply
- // `ESC[?...c` as the TERMINATOR of the whole probe. Answering ESC[6n alone (test
- // 18) is not enough: without primary DA, helix enters its alt screen and then
- // hangs on a BLANK pane. Send the real probe, read until the DA reply's 'c',
- // and print the split marker only on success.
- {
- var hs9 = try Harness.init(gpa, exe.ptr, 24, 80);
- defer hs9.deinit();
- try hs9.expectWaitFor(ls_ready, 8000, "fresh terminal never auto-ls'd");
- try hs9.send("\x02"); // normal -> tty
- try hs9.pump(600);
- // read until 'c' (primary DA ends in 'c'); the kitty reply ESC[?0u has no 'c',
- // so the read only completes once primary DA is answered.
- try hs9.send("stty -icanon -echo min 1 time 0; printf '\\033[?u\\033[c'; read -r -d c -t 3 _r && printf 'ZZDA''OK\\n'; stty sane\r");
- try hs9.expectWaitFor("ZZDAOK", 8000, "app blocked on its ESC[c device-attributes probe — pardes never answered primary DA (helix hangs on a blank pane)");
- }
-
- // 20. KEPT LEFT SELECTION + MIDDLE+LEFT CHORD. A left-drag selection now PERSISTS
- // after release (it clears only on a plain left click or a new left drag). The
- // middle+left chord: press middle (on a command word), press left BEFORE
- // releasing middle -> the KEPT left selection's text is passed as the command's
- // argument. Here a script echoes CHORDARG:"$1"; left-select ZZCHORDARG, then
- // chord the script -> output "CHORDARG:ZZCHORDARG" only if the kept selection
- // fed the arg. Top-left pane body row 0 = SGR row 3; body col 0 = SGR col 3.
- {
- var hsc = try Harness.init(gpa, exe.ptr, 24, 80);
- defer hsc.deinit();
- try hsc.expectWaitFor(ls_ready, 8000, "chord test: fresh terminal never auto-ls'd");
- try h.writeFile("/tmp/pardesE2Echord.sh", "#!/bin/sh\necho CHORDARG:\"$1\"\n");
- try hsc.send("\x02"); // normal -> tty
- try hsc.pump(500);
- try hsc.send("chmod +x /tmp/pardesE2Echord.sh; clear; printf 'ZZCHORDARG\\n/tmp/pardesE2Echord.sh\\n'\r");
- try hsc.expectWaitFor("/tmp/pardesE2Echord.sh", 8000, "chord test: command path not rendered");
- // left-drag-select "ZZCHORDARG" (10 chars, body cols 0-9 = SGR cols 3-12) on
- // body row 0 (SGR row 3) -> kept (.done) after release.
- try hsc.send("\x1b[<0;3;3M\x1b[<32;12;3M\x1b[<0;12;3m");
- try hsc.pump(400);
- // middle+left chord: middle-press on the script path (body row 1 = SGR row 4),
- // left-press (captures the kept selection as the arg), middle-release (runs it).
- try hsc.send("\x1b[<1;8;4M\x1b[<0;8;4M\x1b[<1;8;4m");
- try hsc.expectWaitFor("CHORDARG:ZZCHORDARG", 8000, "middle+left chord did not pass the kept left selection as the command argument");
- }
-
- // 21. A plain LEFT CLICK (no drag) clears the kept left selection, so a later
- // middle+left chord passes NO argument. Left-select ZZCLRARG, left-click a
- // blank cell (clears), then chord the script -> output "CHORDARG:" (empty),
- // with no ZZCLRARG after it.
- {
- var hscl = try Harness.init(gpa, exe.ptr, 24, 80);
- defer hscl.deinit();
- try hscl.expectWaitFor(ls_ready, 8000, "clear test: fresh terminal never auto-ls'd");
- try h.writeFile("/tmp/pardesE2Echord.sh", "#!/bin/sh\necho CHORDARG:\"$1\"\n");
- try hscl.send("\x02");
- try hscl.pump(500);
- try hscl.send("chmod +x /tmp/pardesE2Echord.sh; clear; printf 'ZZCLRARG\\n/tmp/pardesE2Echord.sh\\n'\r");
- try hscl.expectWaitFor("/tmp/pardesE2Echord.sh", 8000, "clear test: command path not rendered");
- // left-drag-select "ZZCLRARG" (8 chars, body cols 0-7 = SGR cols 3-10) -> kept.
- try hscl.send("\x1b[<0;3;3M\x1b[<32;10;3M\x1b[<0;10;3m");
- try hscl.pump(400);
- // plain left click on a blank row (body row 3 = SGR row 6), no drag -> clears.
- try hscl.send("\x1b[<0;3;6M\x1b[<0;3;6m");
- try hscl.pump(400);
- // chord the script; the kept selection is gone -> no arg -> "CHORDARG:" (empty).
- try hscl.send("\x1b[<1;8;4M\x1b[<0;8;4M\x1b[<1;8;4m");
- try hscl.pump(900);
- try hscl.expectContains("CHORDARG:", "clear test: chord did not run the script");
- try hscl.expectNotContains("CHORDARG:ZZCLRARG", "a plain left click did not clear the kept left selection (chord still passed it)");
- }
-
- // 22. A NEW left drag REPLACES the previous kept left selection. Left-select
- // ZZTXTONE, then left-select ZZTXTTWO (replaces), then chord the script ->
- // output "CHORDARG:ZZTXTTWO" (the NEW selection), NOT "CHORDARG:ZZTXTONE".
- {
- var hsr = try Harness.init(gpa, exe.ptr, 24, 80);
- defer hsr.deinit();
- try hsr.expectWaitFor(ls_ready, 8000, "replace test: fresh terminal never auto-ls'd");
- try h.writeFile("/tmp/pardesE2Echord.sh", "#!/bin/sh\necho CHORDARG:\"$1\"\n");
- try hsr.send("\x02");
- try hsr.pump(500);
- try hsr.send("chmod +x /tmp/pardesE2Echord.sh; clear; printf 'ZZTXTONE\\nZZTXTTWO\\n/tmp/pardesE2Echord.sh\\n'\r");
- try hsr.expectWaitFor("/tmp/pardesE2Echord.sh", 8000, "replace test: command path not rendered");
- // left-drag-select "ZZTXTONE" on body row 0 (SGR row 3; 8 chars -> SGR cols 3-10).
- try hsr.send("\x1b[<0;3;3M\x1b[<32;10;3M\x1b[<0;10;3m");
- try hsr.pump(400);
- // left-drag-select "ZZTXTTWO" on body row 1 (SGR row 4) -> REPLACES the kept one.
- try hsr.send("\x1b[<0;3;4M\x1b[<32;10;4M\x1b[<0;10;4m");
- try hsr.pump(400);
- // chord the script on body row 2 (SGR row 5) -> arg is the NEW selection ZZTXTTWO.
- try hsr.send("\x1b[<1;8;5M\x1b[<0;8;5M\x1b[<1;8;5m");
- try hsr.expectWaitFor("CHORDARG:ZZTXTTWO", 8000, "a new left drag did not replace the old kept selection (chord used the wrong arg)");
- try hsr.expectNotContains("CHORDARG:ZZTXTONE", "the old kept selection leaked into the chord arg (not replaced)");
- }
-
- // 23. A BUILTIN with a chord arg ignores the arg (each builtin decides; none use
- // it yet). Left-select some text, then chord middle-click "Debug" in the body
- // -> the Debug overlay toggles on (arg ignored), so "resizes=" appears.
- {
- var hsb = try Harness.init(gpa, exe.ptr, 24, 80);
- defer hsb.deinit();
- try hsb.expectWaitFor(ls_ready, 8000, "builtin-chord test: fresh terminal never auto-ls'd");
- try hsb.send("\x02");
- try hsb.pump(500);
- try hsb.send("clear; printf 'ZZBUILTARG\\nDebug\\n'\r");
- try hsb.expectWaitFor("Debug", 8000, "builtin-chord test: 'Debug' word not rendered");
- // left-select "ZZBUILTARG" (10 chars, SGR cols 3-12) on body row 0 -> kept.
- try hsb.send("\x1b[<0;3;3M\x1b[<32;12;3M\x1b[<0;12;3m");
- try hsb.pump(400);
- // chord middle-click "Debug" on body row 1 (SGR row 4, body col 2 = SGR col 5).
- try hsb.send("\x1b[<1;5;4M\x1b[<0;5;4M\x1b[<1;5;4m");
- try hsb.pump(900);
- try hsb.expectContains("resizes=", "builtin with a chord arg did not toggle (arg broke builtin dispatch)");
- }
-}
diff --git a/tests/browser_replay_dump.zon b/tests/browser_replay_dump.zon
deleted file mode 100644
index bee296d7..00000000
--- a/tests/browser_replay_dump.zon
+++ /dev/null
@@ -1,54 +0,0 @@
-.{
- .screen = .{ .cols = 80, .rows = 24 },
- .active = 0,
- .topbar = "pardes replay browser e2e",
- .theme = "dark",
- .columns = .{
- .{ .weight = 1, .panes = .{ 0 } },
- .{ .weight = 1, .panes = .{ 1 } },
- },
- .panes = .{
- .{
- .kind = .file,
- .tag = "nm browser_replay.zig Del",
- .body =
- \\const message = "browser replay shader path";
- \\pub fn main() void {
- \\ // This fixture should render in WebGL.
- \\}
- ,
- .scroll = 0,
- .cols = 80,
- .rows = 12,
- .vweight = 1,
- .file = .{
- .path = "browser_replay.zig",
- .content =
- \\const message = "browser replay shader path";
- \\pub fn main() void {
- \\ // This fixture should render in WebGL.
- \\}
- ,
- },
- },
- .{
- .kind = .terminal,
- .tag = "sh /tmp Del",
- .body =
- \\$ zig build replay -Dtarget=wasm32-emscripten
- \\browser e2e marker 4200
- ,
- .scroll = 0,
- .cols = 80,
- .rows = 10,
- .vweight = 1,
- .terminal = .{
- .cwd = "/tmp",
- .stream =
- \\$ zig build replay -Dtarget=wasm32-emscripten
- \\browser e2e marker 4200
- ,
- },
- },
- },
-}
diff --git a/tests/browser_replay_e2e.mjs b/tests/browser_replay_e2e.mjs
deleted file mode 100644
index 2d8e202c..00000000
--- a/tests/browser_replay_e2e.mjs
+++ /dev/null
@@ -1,356 +0,0 @@
-import { createServer } from "node:http";
-import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
-import { spawn } from "node:child_process";
-import { createRequire } from "node:module";
-import { tmpdir } from "node:os";
-import { resolve, join, extname } from "node:path";
-import { inflateSync } from "node:zlib";
-
-const webDir = resolve(process.argv[2] ?? "zig-out/web");
-const screenshotPath = resolve(process.argv[3] ?? "zig-out/browser-replay.png");
-const backingScale = Number(process.argv[4] ?? "1.5");
-if (!Number.isFinite(backingScale) || backingScale <= 0) {
- throw new Error(`invalid browser backing scale: ${process.argv[4]}`);
-}
-
-const mime = new Map([
- [".html", "text/html; charset=utf-8"],
- [".js", "text/javascript; charset=utf-8"],
- [".wasm", "application/wasm"],
- [".png", "image/png"],
-]);
-
-function run(cmd, args) {
- return new Promise((resolvePromise, reject) => {
- const child = spawn(cmd, args, { stdio: "inherit" });
- child.on("error", reject);
- child.on("exit", (code) => {
- if (code === 0) resolvePromise();
- else reject(new Error(`${cmd} ${args.join(" ")} exited with ${code}`));
- });
- });
-}
-
-async function loadPlaywright() {
- try {
- return { api: await import("playwright"), cleanup: async () => {} };
- } catch (err) {
- if (process.env.PARDES_BROWSER_SKIP_INSTALL === "1") throw err;
- }
-
- const dir = await mkdtemp(join(tmpdir(), "pardes-playwright-"));
- try {
- await run("npm", ["--prefix", dir, "install", "--silent", "playwright"]);
- await run("npm", ["--prefix", dir, "exec", "--", "playwright", "install", "chromium"]);
- const require = createRequire(join(dir, "package.json"));
- return {
- api: require("playwright"),
- cleanup: async () => {
- await rm(dir, { recursive: true, force: true });
- },
- };
- } catch (err) {
- await rm(dir, { recursive: true, force: true });
- throw err;
- }
-}
-
-async function serve(root) {
- await stat(join(root, "replay.html"));
- await stat(join(root, "pardes-replay.js"));
-
- const server = createServer(async (req, res) => {
- try {
- const url = new URL(req.url ?? "/", "http://127.0.0.1");
- const path = url.pathname === "/" ? "/replay.html" : url.pathname;
- if (path.includes("..")) throw new Error("bad path");
- const file = join(root, path.slice(1));
- const body = await readFile(file);
- res.writeHead(200, {
- "content-type": mime.get(extname(file)) ?? "application/octet-stream",
- "cache-control": "no-store",
- });
- res.end(body);
- } catch {
- res.writeHead(404);
- res.end("not found");
- }
- });
-
- await new Promise((resolvePromise) => server.listen(0, "127.0.0.1", resolvePromise));
- const address = server.address();
- if (!address || typeof address === "string") throw new Error("failed to bind test server");
- return { server, url: `http://127.0.0.1:${address.port}/replay.html` };
-}
-
-function parsePng(buf) {
- const sig = "89504e470d0a1a0a";
- if (buf.subarray(0, 8).toString("hex") !== sig) throw new Error("not a png");
-
- let offset = 8;
- let width = 0;
- let height = 0;
- let bitDepth = 0;
- let colorType = 0;
- const idat = [];
- while (offset < buf.length) {
- const len = buf.readUInt32BE(offset);
- const type = buf.subarray(offset + 4, offset + 8).toString("ascii");
- const data = buf.subarray(offset + 8, offset + 8 + len);
- offset += 12 + len;
- if (type === "IHDR") {
- width = data.readUInt32BE(0);
- height = data.readUInt32BE(4);
- bitDepth = data[8];
- colorType = data[9];
- } else if (type === "IDAT") {
- idat.push(data);
- } else if (type === "IEND") {
- break;
- }
- }
- if (bitDepth !== 8) throw new Error(`unsupported png bit depth ${bitDepth}`);
- const channels = colorType === 6 ? 4 : colorType === 2 ? 3 : colorType === 0 ? 1 : 0;
- if (channels === 0) throw new Error(`unsupported png color type ${colorType}`);
-
- const raw = inflateSync(Buffer.concat(idat));
- const stride = width * channels;
- const rows = Buffer.alloc(width * height * channels);
- let rawOffset = 0;
- for (let y = 0; y < height; y += 1) {
- const filter = raw[rawOffset];
- rawOffset += 1;
- const rowStart = y * stride;
- for (let x = 0; x < stride; x += 1) {
- const left = x >= channels ? rows[rowStart + x - channels] : 0;
- const up = y > 0 ? rows[rowStart + x - stride] : 0;
- const upLeft = y > 0 && x >= channels ? rows[rowStart + x - stride - channels] : 0;
- const value = raw[rawOffset + x];
- let recon;
- if (filter === 0) recon = value;
- else if (filter === 1) recon = value + left;
- else if (filter === 2) recon = value + up;
- else if (filter === 3) recon = value + Math.floor((left + up) / 2);
- else if (filter === 4) {
- const p = left + up - upLeft;
- const pa = Math.abs(p - left);
- const pb = Math.abs(p - up);
- const pc = Math.abs(p - upLeft);
- recon = value + (pa <= pb && pa <= pc ? left : pb <= pc ? up : upLeft);
- } else {
- throw new Error(`unsupported png filter ${filter}`);
- }
- rows[rowStart + x] = recon & 0xff;
- }
- rawOffset += stride;
- }
- return { width, height, channels, rows };
-}
-
-function validateScreenshot(png) {
- let bright = 0;
- let colored = 0;
- let nonDark = 0;
- const pixels = png.width * png.height;
- for (let i = 0; i < pixels; i += 1) {
- const off = i * png.channels;
- const r = png.rows[off];
- const g = png.channels === 1 ? r : png.rows[off + 1];
- const b = png.channels === 1 ? r : png.rows[off + 2];
- if (r > 90 || g > 90 || b > 90) bright += 1;
- if (Math.max(r, g, b) - Math.min(r, g, b) > 20) colored += 1;
- if (r > 35 || g > 35 || b > 35) nonDark += 1;
- }
- if (png.width < 640 || png.height < 360) {
- throw new Error(`screenshot too small: ${png.width}x${png.height}`);
- }
- if (bright < 500 || nonDark < 2000) {
- throw new Error(`browser replay appears blank: bright=${bright} nonDark=${nonDark} colored=${colored}`);
- }
- console.log(`browser replay screenshot ok: ${png.width}x${png.height}, bright=${bright}, nonDark=${nonDark}, colored=${colored}`);
-}
-
-function pixelRgb(png, x, y) {
- const off = (y * png.width + x) * png.channels;
- const r = png.rows[off];
- return {
- r,
- g: png.channels === 1 ? r : png.rows[off + 1],
- b: png.channels === 1 ? r : png.rows[off + 2],
- };
-}
-
-function countChangedPixels(a, b, region = {}) {
- if (a.width !== b.width || a.height !== b.height) {
- throw new Error(`png sizes differ: ${a.width}x${a.height} vs ${b.width}x${b.height}`);
- }
- const x0 = Math.max(0, Math.floor(region.x0 ?? 0));
- const y0 = Math.max(0, Math.floor(region.y0 ?? 0));
- const x1 = Math.min(a.width, Math.ceil(region.x1 ?? a.width));
- const y1 = Math.min(a.height, Math.ceil(region.y1 ?? a.height));
- let changed = 0;
- for (let y = y0; y < y1; y += 1) {
- for (let x = x0; x < x1; x += 1) {
- const ap = pixelRgb(a, x, y);
- const bp = pixelRgb(b, x, y);
- const dr = Math.abs(ap.r - bp.r);
- const dg = Math.abs(ap.g - bp.g);
- const db = Math.abs(ap.b - bp.b);
- if (dr + dg + db > 45 && Math.max(dr, dg, db) > 12) changed += 1;
- }
- }
- return changed;
-}
-
-async function screenshotPng(page) {
- return parsePng(await page.screenshot({ fullPage: true }));
-}
-
-async function waitForNextDraw(page, action) {
- const before = await page.evaluate(() => window.__pardesReplayE2E?.draws ?? 0);
- await action();
- await page.waitForFunction((draws) => (window.__pardesReplayE2E?.draws ?? 0) > draws, before, { timeout: 5000 });
- await page.waitForTimeout(50);
-}
-
-async function findHoverBoundary(page, baseline) {
- const canvasSize = await page.evaluate(() => {
- const canvas = document.querySelector("canvas");
- return { width: canvas.clientWidth, height: canvas.clientHeight };
- });
- const y = Math.floor(canvasSize.height * 0.55);
- const center = canvasSize.width / 2;
- const offsets = [];
- for (let dx = -48; dx <= 48; dx += 3) offsets.push(dx);
-
- for (const dx of offsets) {
- const x = Math.round(center + dx);
- await waitForNextDraw(page, () => page.mouse.move(x, y));
- const hover = await screenshotPng(page);
- const changed = countChangedPixels(baseline, hover, {
- x0: x - 20,
- x1: x + 20,
- y0: 20,
- y1: canvasSize.height - 20,
- });
- if (changed >= 80) return { x, y, changed, canvasSize };
- }
-
- throw new Error("browser replay hover did not visibly draw the dashed resize handle");
-}
-
-const { server, url } = await serve(webDir);
-let cleanupPlaywright = async () => {};
-let browser;
-try {
- const { api, cleanup } = await loadPlaywright();
- cleanupPlaywright = cleanup;
- const { chromium } = api;
-
- browser = await chromium.launch({
- headless: true,
- args: ["--use-angle=swiftshader", "--enable-unsafe-swiftshader"],
- });
- const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
- const consoleMessages = [];
- const pageErrors = [];
- page.on("console", (msg) => consoleMessages.push(`${msg.type()}: ${msg.text()}`));
- page.on("pageerror", (err) => pageErrors.push(err.stack ?? err.message));
-
- await page.addInitScript(() => {
- window.__pardesReplayE2E = { draws: 0, contextLost: 0 };
- window.addEventListener("webglcontextlost", () => {
- window.__pardesReplayE2E.contextLost += 1;
- }, true);
-
- const wrapDraws = (target) => {
- if (!target) return;
- for (const name of ["drawArraysInstanced", "drawArrays", "drawElements", "drawElementsInstanced"]) {
- if (typeof target[name] !== "function" || target[name].__pardesReplayE2E) continue;
- const oldDraw = target[name];
- target[name] = function(...args) {
- window.__pardesReplayE2E.draws += 1;
- return oldDraw.apply(this, args);
- };
- target[name].__pardesReplayE2E = true;
- }
- };
-
- wrapDraws(globalThis.WebGLRenderingContext?.prototype);
- wrapDraws(globalThis.WebGL2RenderingContext?.prototype);
-
- const oldGetContext = HTMLCanvasElement.prototype.getContext;
- HTMLCanvasElement.prototype.getContext = function(type, attrs) {
- const ctx = oldGetContext.call(this, type, attrs);
- if (ctx && !ctx.__pardesReplayE2E && (type === "webgl2" || type === "webgl" || type === "experimental-webgl")) {
- ctx.__pardesReplayE2E = true;
- wrapDraws(ctx);
- }
- return ctx;
- };
- });
-
- await page.goto(url, { waitUntil: "load" });
- await page.waitForSelector("canvas", { timeout: 5000 });
- await page.waitForFunction(() => window.__pardesReplayE2E?.draws >= 2, null, { timeout: 5000 });
- await page.waitForTimeout(250);
- const trace = await page.evaluate(() => window.__pardesReplayE2E);
- if (trace.contextLost !== 0) {
- throw new Error(`browser WebGL context was lost during replay: ${JSON.stringify(trace)}`);
- }
- if (pageErrors.length !== 0) {
- throw new Error(`browser page error: ${pageErrors[0]}`);
- }
- const lostMessage = consoleMessages.find((msg) => msg.includes("CONTEXT_LOST_WEBGL"));
- if (lostMessage) throw new Error(`browser WebGL context lost: ${lostMessage}`);
-
- const initialCanvasSize = await page.evaluate(() => {
- const canvas = document.querySelector("canvas");
- return {
- clientWidth: canvas.clientWidth,
- clientHeight: canvas.clientHeight,
- width: canvas.width,
- height: canvas.height,
- };
- });
- if (
- initialCanvasSize.clientWidth !== 1280 ||
- initialCanvasSize.clientHeight !== 720 ||
- initialCanvasSize.width !== Math.round(1280 * backingScale) ||
- initialCanvasSize.height !== Math.round(720 * backingScale)
- ) {
- throw new Error(`browser replay canvas did not fill initial viewport: ${JSON.stringify(initialCanvasSize)}`);
- }
-
- await page.setViewportSize({ width: 913, height: 617 });
- await page.waitForFunction((scale) => {
- const canvas = document.querySelector("canvas");
- return canvas?.clientWidth === 913 &&
- canvas?.clientHeight === 617 &&
- canvas?.width === Math.round(913 * scale) &&
- canvas?.height === Math.round(617 * scale);
- }, backingScale, { timeout: 5000 });
- await page.waitForFunction(() => window.__pardesReplayE2E?.draws >= 3, null, { timeout: 5000 });
-
- const baseline = await screenshotPng(page);
- const hover = await findHoverBoundary(page, baseline);
- console.log(`browser replay hover ok: changed=${hover.changed} x=${hover.x} y=${hover.y}`);
-
- const dragToX = Math.min(hover.canvasSize.width - 24, hover.x + 120);
- await waitForNextDraw(page, () => page.mouse.down());
- await waitForNextDraw(page, () => page.mouse.move(dragToX, hover.y, { steps: 8 }));
- await waitForNextDraw(page, () => page.mouse.up());
- const dragged = await screenshotPng(page);
- const dragChanged = countChangedPixels(baseline, dragged);
- if (dragChanged < 2500) {
- throw new Error(`browser replay drag did not visibly resize panes: changed=${dragChanged}`);
- }
- console.log(`browser replay drag ok: changed=${dragChanged}`);
-
- await page.screenshot({ path: screenshotPath, fullPage: true });
- validateScreenshot(parsePng(await readFile(screenshotPath)));
-} finally {
- if (browser) await browser.close();
- await cleanupPlaywright();
- server.close();
-}
diff --git a/tests_sdl.zig b/tests_sdl.zig
deleted file mode 100644
index 4bb8ab96..00000000
--- a/tests_sdl.zig
+++ /dev/null
@@ -1,185 +0,0 @@
-// E2E tests for the SDL3 GPU backend.
-//
-// Unlike the terminal e2e suite, this harness validates the rendered framebuffer:
-// the SDL app runs its real GPU/font/shader path and writes the latest frame as
-// a PPM image in a temp capture directory. These assertions are intentionally
-// visual/pixel-level, not retained-grid text assertions.
-const std = @import("std");
-const h = @import("e2e_harness_sdl.zig");
-const config = @import("config");
-const Harness = h.SdlHarness;
-const Image = h.Image;
-
-fn expectPixelSize(hs: *Harness, width: u32, height: u32, msg: []const u8) !void {
- const img = hs.image orelse {
- hs.dump(msg);
- return error.ExpectFailed;
- };
- if (img.width != width or img.height != height) {
- hs.dump(msg);
- return error.ExpectFailed;
- }
-}
-
-fn expectChangedPixels(hs: *Harness, before: Image, min_changed: usize, msg: []const u8) !void {
- const img = hs.image orelse {
- hs.dump(msg);
- return error.ExpectFailed;
- };
- if (img.changedPixels(before, 30) < min_changed) {
- hs.dump(msg);
- return error.ExpectFailed;
- }
-}
-
-pub fn main(init: std.process.Init) !void {
- const gpa = init.gpa;
- const io = init.io;
- const args = try init.minimal.args.toSlice(init.arena.allocator());
- if (args.len != 2) {
- std.debug.print("usage: {s} <pardes-sdl-exe>\n", .{args[0]});
- return error.BadArgs;
- }
- const exe = args[1];
-
- var hs = try Harness.init(gpa, io, exe.ptr, 24, 80);
- defer hs.deinit();
- hs.trace = config.trace;
-
- try hs.expectWaitForImage(8000, "SDL: app never produced a rendered frame");
- try hs.pump(2000);
- try hs.expectGridPixels("SDL: initial rendered frame is not aligned to the pty grid");
- const cw = hs.cellWidth();
- const ch = hs.cellHeight();
- try hs.expectBrightRows(0, 1, 80, "SDL: top bar text did not render as bright glyph pixels");
- try hs.expectBrightRows(2, 20, 3000, "SDL: terminal body did not render directory-list glyph pixels");
-
- // Resize must change the actual framebuffer dimensions, not just retained
- // state. This catches the real SDL window/swapchain path.
- try hs.resize(30, 100);
- const before_resize = hs.imageHash();
- try hs.expectWaitForChange(before_resize, 8000, "SDL: resize did not produce a new rendered frame");
- try expectPixelSize(&hs, cw * 100, ch * 30, "SDL: resized framebuffer does not match 100x30 cells");
- try hs.expectBrightRows(0, 1, 80, "SDL: top bar disappeared after resize");
-
- // TTY toggle should visibly change the active pane tag/body because prompts
- // become visible and the mode tag flips.
- var before = try hs.imageCopy();
- defer before.deinit(gpa);
- try hs.send("\x02");
- try hs.expectWaitForChange(before.hash(), 6000, "SDL: Ctrl-b did not visibly enter tty mode");
- try expectChangedPixels(&hs, before, 100, "SDL: tty mode visual delta was too small");
-
- // Shell output should show up in the real rendered framebuffer.
- before.deinit(gpa);
- before = try hs.imageCopy();
- try hs.send("echo SDL_E2E_MARKER_42\r");
- try hs.expectWaitForChange(before.hash(), 8000, "SDL: shell command did not visibly update rendered output");
- try expectChangedPixels(&hs, before, 100, "SDL: shell output visual delta was too small");
-
- // Leaving tty should hide prompt/command rows again, producing another real
- // rendered change.
- before.deinit(gpa);
- before = try hs.imageCopy();
- try hs.send("\x02");
- try hs.expectWaitForChange(before.hash(), 6000, "SDL: Ctrl-b did not visibly return to normal mode");
- try expectChangedPixels(&hs, before, 100, "SDL: normal-mode visual delta was too small");
-
- // Global tagbar mouse execution: middle-click Tutor should visibly add a
- // file-like tutor pane.
- before.deinit(gpa);
- before = try hs.imageCopy();
- try hs.send("\x1b[<1;14;1M\x1b[<1;14;1m");
- try hs.expectWaitForChange(before.hash(), 10000, "SDL: Tutor tagbar action did not visibly change the render");
- try expectChangedPixels(&hs, before, 1000, "SDL: Tutor visual delta was too small");
- try hs.expectBrightRows(1, 8, 300, "SDL: Tutor pane did not render bright text in the top rows");
-
- // Touch-gesture parity at the app boundary: in PARDES_TEST the SDL app is
- // driven by vaxis.Parser reading stdin, so SDL_EVENT_FINGER_* is not part of
- // this e2e path. Simulate the gesture result instead: a two-finger tap should
- // become a middle-click, and middle-clicking the global "Tutor" word should
- // execute the builtin and render the tutor pane.
- {
- var hs_mid = try Harness.init(gpa, io, exe.ptr, 24, 80);
- defer hs_mid.deinit();
- hs_mid.trace = config.trace;
- try hs_mid.expectWaitForImage(8000, "SDL middle-click result: no initial frame");
- try hs_mid.pump(1000);
-
- var mid_before = try hs_mid.imageCopy();
- defer mid_before.deinit(gpa);
- try hs_mid.send("\x1b[<1;14;1M\x1b[<1;14;1m");
- try hs_mid.expectWaitForChange(mid_before.hash(), 10000, "SDL middle-click result: Tutor did not visibly change the render");
- try expectChangedPixels(&hs_mid, mid_before, 1000, "SDL middle-click result: Tutor visual delta was too small");
- try hs_mid.expectBrightRows(1, 8, 300, "SDL middle-click result: Tutor pane did not render bright text in the top rows");
- }
-
- // Fresh app: terminal char-mode overlay is visible, can be edited, hides in
- // raw tty, and returns after leaving tty. Every step is asserted through
- // rendered pixels.
- {
- var hs2 = try Harness.init(gpa, io, exe.ptr, 24, 80);
- defer hs2.deinit();
- hs2.trace = config.trace;
- try hs2.expectWaitForImage(8000, "SDL overlay: no initial frame");
- try hs2.send("\x1b[<0;5;8M\x1b[<0;5;8m");
- try hs2.pump(400);
- try hs2.send("i");
- try hs2.pump(300);
-
- var base = try hs2.imageCopy();
- defer base.deinit(gpa);
- try hs2.send("ZZSDLINSXYZ");
- try hs2.expectWaitForChange(base.hash(), 5000, "SDL: typed overlay did not render");
- try expectChangedPixels(&hs2, base, 80, "SDL: typed overlay visual delta was too small");
-
- base.deinit(gpa);
- base = try hs2.imageCopy();
- try hs2.send("\x7f\x7f\x7f");
- try hs2.expectWaitForChange(base.hash(), 5000, "SDL: overlay backspace did not render");
-
- try hs2.send("\x1b");
- try hs2.pump(300);
- base.deinit(gpa);
- base = try hs2.imageCopy();
- try hs2.send("\x02");
- try hs2.expectWaitForChange(base.hash(), 6000, "SDL: tty mode did not hide overlay visually");
- try expectChangedPixels(&hs2, base, 40, "SDL: tty-hide overlay delta was too small");
-
- base.deinit(gpa);
- base = try hs2.imageCopy();
- try hs2.send("\x02");
- try hs2.expectWaitForChange(base.hash(), 6000, "SDL: leaving tty did not restore overlay visually");
- }
-
- // CLI mode parity: --tty starts as a raw shell; typed shell input must
- // visibly alter the framebuffer.
- {
- var hs3 = try Harness.initArgs(gpa, io, exe.ptr, 24, 80, "--tty");
- defer hs3.deinit();
- hs3.trace = config.trace;
- try hs3.expectWaitForImage(8000, "SDL --tty: no initial frame");
- before.deinit(gpa);
- before = try hs3.imageCopy();
- try hs3.send("i=0; while [ $i -lt 30 ]; do echo ZZSDLTTYFLAG_$i; i=$((i+1)); done\r");
- try hs3.pump(2500);
- try expectChangedPixels(&hs3, before, 80, "SDL --tty: shell visual delta was too small");
- try hs3.expectBrightRows(18, 23, 350, "SDL --tty: lower terminal rows were not rendered");
- }
-
- // Configurable tty toggle: Ctrl-g should visibly enter tty mode when the
- // toggle is moved off Ctrl-b.
- {
- var hs4 = try Harness.initArgs(gpa, io, exe.ptr, 24, 80, "--tty-toggle=g");
- defer hs4.deinit();
- hs4.trace = config.trace;
- try hs4.expectWaitForImage(8000, "SDL --tty-toggle=g: no initial frame");
- before.deinit(gpa);
- before = try hs4.imageCopy();
- try hs4.send("\x07");
- try hs4.expectWaitForChange(before.hash(), 6000, "SDL --tty-toggle=g: Ctrl-g did not visibly enter tty mode");
- try expectChangedPixels(&hs4, before, 80, "SDL --tty-toggle=g: visual delta was too small");
- }
-
- std.debug.print("SDL rendered-image e2e: all tests passed\n", .{});
-}
diff --git a/tests_syntax_perf.zig b/tests_syntax_perf.zig
deleted file mode 100644
index 4179cd38..00000000
--- a/tests_syntax_perf.zig
+++ /dev/null
@@ -1,795 +0,0 @@
-const std = @import("std");
-const h = @import("e2e_harness.zig");
-
-const Harness = h.Harness;
-const linux = std.os.linux;
-const posix = std.posix;
-
-const payload_len = 64;
-const wheel_events = 240;
-const j_events = 240;
-const ctrl_d_events = 60;
-const delete_line_input = "xxxxxxxxxxxxxxxxd";
-const delete_select_lines = delete_line_input.len - 1;
-const scroll_marker_line = wheel_events;
-const marker_wait_ms = 60_000;
-const scroll_quiet_ms = 1000;
-const large_stdlib_path = "/usr/lib/zig/std/Io/Threaded.zig";
-const target_ratio_x100 = 150;
-const movement_ratio_floor_us = 20_000;
-const startup_first_frame_target_us = 15_000;
-const startup_samples = 5;
-
-fn nowNs() u64 {
- var ts: linux.timespec = undefined;
- _ = linux.clock_gettime(linux.CLOCK.MONOTONIC, &ts);
- return @intCast(ts.sec * 1_000_000_000 + ts.nsec);
-}
-
-fn fillPayload(buf: *[payload_len]u8, marker: []const u8) []const u8 {
- const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
- for (buf, 0..) |*ch, i| ch.* = alphabet[(i * 7 + marker.len * 11) % alphabet.len];
- @memcpy(buf[0..marker.len], marker);
- return buf[0..marker.len];
-}
-
-fn waitRawGrowth(hs: *Harness, start_len: usize, ms: i64) !bool {
- const deadline = nowNs() + @as(u64, @intCast(ms)) * 1_000_000;
- var buf: [4096]u8 = undefined;
- while (nowNs() < deadline) {
- var fds = [_]posix.pollfd{.{ .fd = hs.master, .events = posix.POLL.IN, .revents = 0 }};
- _ = posix.poll(&fds, 5) catch {};
- if ((fds[0].revents & posix.POLL.IN) != 0) {
- const n = posix.read(hs.master, &buf) catch break;
- if (n == 0) break;
- try hs.raw.appendSlice(hs.gpa, buf[0..n]);
- hs.stream.nextSlice(buf[0..n]);
- if (hs.raw.items.len > start_len) return true;
- }
- }
- return hs.raw.items.len > start_len;
-}
-
-fn waitForOutputQuiet(hs: *Harness, quiet_ms: i64, max_ms: i64) !bool {
- const deadline = nowNs() + @as(u64, @intCast(max_ms)) * 1_000_000;
- var quiet_deadline = nowNs() + @as(u64, @intCast(quiet_ms)) * 1_000_000;
- var buf: [4096]u8 = undefined;
- while (nowNs() < deadline) {
- if (nowNs() >= quiet_deadline) return true;
- var fds = [_]posix.pollfd{.{ .fd = hs.master, .events = posix.POLL.IN, .revents = 0 }};
- _ = posix.poll(&fds, 10) catch {};
- if ((fds[0].revents & posix.POLL.IN) != 0) {
- const n = posix.read(hs.master, &buf) catch break;
- if (n == 0) break;
- try hs.raw.appendSlice(hs.gpa, buf[0..n]);
- hs.stream.nextSlice(buf[0..n]);
- quiet_deadline = nowNs() + @as(u64, @intCast(quiet_ms)) * 1_000_000;
- }
- }
- return false;
-}
-
-fn leftPaneTopLine(hs: *Harness) !usize {
- const text = try hs.screenText();
- defer hs.gpa.free(text);
- var rows = std.mem.splitScalar(u8, text, '\n');
- var row_idx: usize = 0;
- while (rows.next()) |row| : (row_idx += 1) {
- if (row_idx < 2) continue;
- if (row_idx > 2) break;
- const trimmed = std.mem.trimStart(u8, row, " ");
- var end: usize = 0;
- while (end < trimmed.len and std.ascii.isDigit(trimmed[end])) : (end += 1) {}
- if (end > 0) return std.fmt.parseInt(usize, trimmed[0..end], 10) catch 0;
- }
- return 0;
-}
-
-fn waitForTopLineAbove(hs: *Harness, start_line: usize, ms: i64) !?usize {
- const deadline = nowNs() + @as(u64, @intCast(ms)) * 1_000_000;
- var buf: [4096]u8 = undefined;
- while (nowNs() < deadline) {
- var fds = [_]posix.pollfd{.{ .fd = hs.master, .events = posix.POLL.IN, .revents = 0 }};
- _ = posix.poll(&fds, 10) catch {};
- if ((fds[0].revents & posix.POLL.IN) != 0) {
- const n = posix.read(hs.master, &buf) catch break;
- if (n == 0) break;
- try hs.raw.appendSlice(hs.gpa, buf[0..n]);
- hs.stream.nextSlice(buf[0..n]);
- }
- const top_line = try leftPaneTopLine(hs);
- if (top_line > start_line) return top_line;
- }
- return null;
-}
-
-fn waitForScreenMissing(hs: *Harness, needle: []const u8, ms: i64) !bool {
- {
- const text = try hs.screenText();
- defer hs.gpa.free(text);
- if (std.mem.indexOf(u8, text, needle) == null) return true;
- }
- const deadline = nowNs() + @as(u64, @intCast(ms)) * 1_000_000;
- var buf: [4096]u8 = undefined;
- while (nowNs() < deadline) {
- var fds = [_]posix.pollfd{.{ .fd = hs.master, .events = posix.POLL.IN, .revents = 0 }};
- _ = posix.poll(&fds, 10) catch {};
- if ((fds[0].revents & posix.POLL.IN) != 0) {
- const n = posix.read(hs.master, &buf) catch break;
- if (n == 0) break;
- try hs.raw.appendSlice(hs.gpa, buf[0..n]);
- hs.stream.nextSlice(buf[0..n]);
- }
- const text = try hs.screenText();
- defer hs.gpa.free(text);
- if (std.mem.indexOf(u8, text, needle) == null) return true;
- }
- return false;
-}
-
-fn waitForScreenContainsPoll(hs: *Harness, needle: []const u8, ms: i64, poll_ms: i32) !bool {
- {
- const text = try hs.screenText();
- defer hs.gpa.free(text);
- if (std.mem.indexOf(u8, text, needle) != null) return true;
- }
- const deadline = nowNs() + @as(u64, @intCast(ms)) * 1_000_000;
- var buf: [4096]u8 = undefined;
- while (nowNs() < deadline) {
- var fds = [_]posix.pollfd{.{ .fd = hs.master, .events = posix.POLL.IN, .revents = 0 }};
- _ = posix.poll(&fds, poll_ms) catch {};
- if ((fds[0].revents & posix.POLL.IN) != 0) {
- const n = posix.read(hs.master, &buf) catch break;
- if (n == 0) break;
- try hs.raw.appendSlice(hs.gpa, buf[0..n]);
- hs.stream.nextSlice(buf[0..n]);
- }
- const text = try hs.screenText();
- defer hs.gpa.free(text);
- if (std.mem.indexOf(u8, text, needle) != null) return true;
- }
- return false;
-}
-
-fn waitForRawContainsPoll(hs: *Harness, needle: []const u8, ms: i64, poll_ms: i32) !bool {
- if (std.mem.indexOf(u8, hs.raw.items, needle) != null) return true;
- const deadline = nowNs() + @as(u64, @intCast(ms)) * 1_000_000;
- var buf: [4096]u8 = undefined;
- while (nowNs() < deadline) {
- var fds = [_]posix.pollfd{.{ .fd = hs.master, .events = posix.POLL.IN, .revents = 0 }};
- _ = posix.poll(&fds, poll_ms) catch {};
- if ((fds[0].revents & posix.POLL.IN) != 0) {
- const n = posix.read(hs.master, &buf) catch break;
- if (n == 0) break;
- try hs.raw.appendSlice(hs.gpa, buf[0..n]);
- hs.stream.nextSlice(buf[0..n]);
- if (std.mem.indexOf(u8, hs.raw.items, needle) != null) return true;
- }
- }
- return std.mem.indexOf(u8, hs.raw.items, needle) != null;
-}
-
-const startup_metric_prefix = "PARDES_STARTUP_US=";
-
-fn parseStartupMetric(raw: []const u8) ?u64 {
- const start = std.mem.indexOf(u8, raw, startup_metric_prefix) orelse return null;
- var i = start + startup_metric_prefix.len;
- const digit_start = i;
- while (i < raw.len and std.ascii.isDigit(raw[i])) : (i += 1) {}
- if (i == digit_start) return null;
- return std.fmt.parseInt(u64, raw[digit_start..i], 10) catch null;
-}
-
-fn waitForStartupMetric(hs: *Harness, ms: i64, poll_ms: i32) !?u64 {
- if (parseStartupMetric(hs.raw.items)) |value| return value;
- const deadline = nowNs() + @as(u64, @intCast(ms)) * 1_000_000;
- var buf: [4096]u8 = undefined;
- while (nowNs() < deadline) {
- var fds = [_]posix.pollfd{.{ .fd = hs.master, .events = posix.POLL.IN, .revents = 0 }};
- _ = posix.poll(&fds, poll_ms) catch {};
- if ((fds[0].revents & posix.POLL.IN) != 0) {
- const n = posix.read(hs.master, &buf) catch break;
- if (n == 0) break;
- try hs.raw.appendSlice(hs.gpa, buf[0..n]);
- if (parseStartupMetric(hs.raw.items)) |value| return value;
- }
- }
- return parseStartupMetric(hs.raw.items);
-}
-
-fn wheelDownBurst(gpa: std.mem.Allocator) ![]u8 {
- var out: std.ArrayList(u8) = .empty;
- errdefer out.deinit(gpa);
- var i: usize = 0;
- while (i < wheel_events) : (i += 1)
- try out.appendSlice(gpa, "\x1b[<65;10;6M");
- return out.toOwnedSlice(gpa);
-}
-
-const Movement = enum { wheel, j, ctrl_d };
-
-fn movementName(movement: Movement) []const u8 {
- return switch (movement) {
- .wheel => "wheel",
- .j => "j",
- .ctrl_d => "ctrl_d",
- };
-}
-
-fn movementEventCount(movement: Movement) usize {
- return switch (movement) {
- .wheel => wheel_events,
- .j => j_events,
- .ctrl_d => ctrl_d_events,
- };
-}
-
-fn movementInput(gpa: std.mem.Allocator, movement: Movement) ![]u8 {
- if (movement == .wheel) return wheelDownBurst(gpa);
- var out: std.ArrayList(u8) = .empty;
- errdefer out.deinit(gpa);
- var i: usize = 0;
- const bytes = switch (movement) {
- .wheel => unreachable,
- .j => "j",
- .ctrl_d => "\x04",
- };
- while (i < movementEventCount(movement)) : (i += 1)
- try out.appendSlice(gpa, bytes);
- return out.toOwnedSlice(gpa);
-}
-
-fn readFileAlloc(gpa: std.mem.Allocator, path: [*:0]const u8) ![]u8 {
- const rc = linux.open(path, .{ .ACCMODE = .RDONLY }, 0);
- const sfd: isize = @bitCast(rc);
- if (sfd < 0) return error.OpenFailed;
- const fd: c_int = @intCast(sfd);
- defer _ = linux.close(fd);
-
- var out: std.ArrayList(u8) = .empty;
- errdefer out.deinit(gpa);
- var buf: [16 * 1024]u8 = undefined;
- while (true) {
- const n = posix.read(fd, &buf) catch return error.ReadFailed;
- if (n == 0) break;
- try out.appendSlice(gpa, buf[0..n]);
- }
- return out.toOwnedSlice(gpa);
-}
-
-fn appendZigMarker(gpa: std.mem.Allocator, out: *std.ArrayList(u8), marker: []const u8) !void {
- const line = try std.fmt.allocPrint(gpa, "const {s} = \"visible\";\n", .{marker});
- defer gpa.free(line);
- try out.appendSlice(gpa, line);
-}
-
-fn appendTextMarker(gpa: std.mem.Allocator, out: *std.ArrayList(u8), marker: []const u8) !void {
- const line = try std.fmt.allocPrint(gpa, "{s} visible\n", .{marker});
- defer gpa.free(line);
- try out.appendSlice(gpa, line);
-}
-
-fn appendMarker(gpa: std.mem.Allocator, out: *std.ArrayList(u8), marker: []const u8, highlighted: bool) !void {
- if (highlighted) {
- try appendZigMarker(gpa, out, marker);
- } else {
- try appendTextMarker(gpa, out, marker);
- }
-}
-
-fn makeSmallFixture(gpa: std.mem.Allocator, open_marker: []const u8, scroll_marker: []const u8, highlighted: bool) ![]u8 {
- var out: std.ArrayList(u8) = .empty;
- errdefer out.deinit(gpa);
- try appendMarker(gpa, &out, open_marker, highlighted);
- if (highlighted) {
- try out.appendSlice(gpa, "const std = @import(\"std\");\n");
- try out.appendSlice(gpa, "pub fn perf_entry() usize {\n");
- try out.appendSlice(gpa, " return perf_func_0() + perf_func_199();\n");
- try out.appendSlice(gpa, "}\n");
- }
- var line_no: usize = if (highlighted) 5 else 1;
- var i: usize = 0;
- while (line_no < scroll_marker_line) : ({
- i += 1;
- line_no += 1;
- }) {
- const line = if (highlighted)
- try std.fmt.allocPrint(gpa, "fn perf_func_{d}() usize {{ return {d} + @as(usize, {d}); }}\n", .{ i, i, i % 17 })
- else
- try std.fmt.allocPrint(gpa, "plain perf line {d}: words numbers strings comments\n", .{i});
- defer gpa.free(line);
- try out.appendSlice(gpa, line);
- }
- try appendMarker(gpa, &out, scroll_marker, highlighted);
- line_no += 1;
- while (line_no < scroll_marker_line + 40) : ({
- i += 1;
- line_no += 1;
- }) {
- const line = if (highlighted)
- try std.fmt.allocPrint(gpa, "fn perf_tail_{d}() usize {{ return {d}; }}\n", .{ i, i })
- else
- try std.fmt.allocPrint(gpa, "plain perf tail {d}: words numbers strings comments\n", .{i});
- defer gpa.free(line);
- try out.appendSlice(gpa, line);
- }
- return out.toOwnedSlice(gpa);
-}
-
-fn makeStdlibFixture(gpa: std.mem.Allocator, source: []const u8, open_marker: []const u8, scroll_marker: []const u8, highlighted: bool) ![]u8 {
- var out: std.ArrayList(u8) = .empty;
- errdefer out.deinit(gpa);
- try appendMarker(gpa, &out, open_marker, highlighted);
- var line_no: usize = 1;
- var lines = std.mem.splitScalar(u8, source, '\n');
- while (lines.next()) |line| {
- if (line_no == scroll_marker_line) {
- try appendMarker(gpa, &out, scroll_marker, highlighted);
- line_no += 1;
- }
- try out.appendSlice(gpa, line);
- try out.append(gpa, '\n');
- line_no += 1;
- }
- while (line_no <= scroll_marker_line) : (line_no += 1) {
- if (line_no == scroll_marker_line) {
- try appendMarker(gpa, &out, scroll_marker, highlighted);
- } else if (highlighted) {
- const line = try std.fmt.allocPrint(gpa, "fn perf_padding_{d}() void {{}}\n", .{line_no});
- defer gpa.free(line);
- try out.appendSlice(gpa, line);
- } else {
- const line = try std.fmt.allocPrint(gpa, "plain stdlib padding {d}\n", .{line_no});
- defer gpa.free(line);
- try out.appendSlice(gpa, line);
- }
- }
- return out.toOwnedSlice(gpa);
-}
-
-fn lineCount(content: []const u8) usize {
- return std.mem.count(u8, content, "\n") + 1;
-}
-
-const Fixture = struct {
- name: []const u8,
- path: []const u8,
- open_marker: []const u8,
- edit_marker: []const u8,
- scroll_marker: []const u8,
- highlighted: bool,
- bytes: usize,
- lines: usize,
-};
-
-const BenchResult = struct {
- startup_app_first_frame_us: u64,
- startup_spawn_first_frame_us: u64,
- startup_shell_ready_us: u64,
- open_visible_us: u64,
- open_repaint_us: ?u64,
- edit_us: u64,
- delete_line_us: u64,
- undo_us: u64,
- move_first_us: u64,
- move_done_us: u64,
- move_active_us: u64,
- move_top_line: usize,
-};
-
-const StartupResult = struct {
- internal_first_frame_us: u64,
- harness_first_frame_us: u64,
- spawn_first_frame_us: u64,
-};
-
-const BestOps = struct {
- open_visible_us: u64 = std.math.maxInt(u64),
- edit_us: u64 = std.math.maxInt(u64),
- delete_line_us: u64 = std.math.maxInt(u64),
- undo_us: u64 = std.math.maxInt(u64),
-
- fn add(self: *BestOps, result: BenchResult) void {
- self.open_visible_us = @min(self.open_visible_us, result.open_visible_us);
- self.edit_us = @min(self.edit_us, result.edit_us);
- self.delete_line_us = @min(self.delete_line_us, result.delete_line_us);
- self.undo_us = @min(self.undo_us, result.undo_us);
- }
-};
-
-const StartupStats = struct {
- best_us: u64 = std.math.maxInt(u64),
- worst_us: u64 = 0,
- total_us: u64 = 0,
- count: usize = 0,
-
- fn add(self: *StartupStats, us: u64) void {
- self.best_us = @min(self.best_us, us);
- self.worst_us = @max(self.worst_us, us);
- self.total_us += us;
- self.count += 1;
- }
-
- fn avg(self: StartupStats) u64 {
- if (self.count == 0) return 0;
- return self.total_us / self.count;
- }
-};
-
-fn runFixture(gpa: std.mem.Allocator, exe: [*:0]const u8, fixture: Fixture, movement: Movement) !BenchResult {
- const startup_spawn_t0 = nowNs();
- var hs = try Harness.init(gpa, exe, 30, 100);
- defer hs.deinit();
- const startup_app_t0 = nowNs();
-
- if (!try waitForRawContainsPoll(&hs, "Kill Newcol", 8000, 1)) {
- hs.dump("app first frame did not render before syntax perf");
- return error.StartupFirstFrameTimeout;
- }
- const startup_app_first_frame_us = (nowNs() - startup_app_t0) / 1000;
- const startup_spawn_first_frame_us = (nowNs() - startup_spawn_t0) / 1000;
- try hs.expectWaitFor("tests.zig", 8000, "app never auto-ls'd before syntax perf");
- const startup_shell_ready_us = (nowNs() - startup_spawn_t0) / 1000;
- try hs.send("\x02"); // normal -> tty
- try hs.pump(500);
- {
- var cmd_buf: [256]u8 = undefined;
- const cmd = try std.fmt.bufPrint(&cmd_buf, "clear; echo {s}\r", .{fixture.path});
- try hs.send(cmd);
- }
- try hs.expectWaitFor(fixture.path, 8000, "perf path did not render in tty");
- try hs.send("\x02"); // tty -> normal
- try hs.expectWaitFor("nm /", 6000, "failed to leave tty before syntax perf open");
-
- const open_t0 = nowNs();
- try hs.send("\x1b[<2;8;3M\x1b[<2;8;3m"); // right-click body row 0 path
- try hs.expectWaitFor(fixture.open_marker, marker_wait_ms, "syntax perf file did not open");
- const open_visible_us = (nowNs() - open_t0) / 1000;
- var open_repaint_us: ?u64 = null;
- if (fixture.highlighted) {
- open_repaint_us = open_visible_us;
- }
- if (!try waitForOutputQuiet(&hs, 250, marker_wait_ms)) {
- hs.dump("syntax perf open did not go quiet");
- return error.OpenQuietTimeout;
- }
-
- try hs.send("i");
- try hs.expectWaitFor("in /tmp/pardes_perf_", 5000, "syntax perf did not enter file insert mode");
-
- var payload: [payload_len]u8 = undefined;
- const edit_marker = fillPayload(&payload, fixture.edit_marker);
- const edit_t0 = nowNs();
- try hs.send(&payload);
- try hs.expectWaitFor(edit_marker, marker_wait_ms, "syntax perf edit marker did not appear");
- const edit_us = (nowNs() - edit_t0) / 1000;
-
- try hs.send("\x1b"); // insert -> normal
- try hs.expectWaitFor("nm /tmp/pardes_perf_", 5000, "syntax perf did not leave insert mode");
- if (!try waitForOutputQuiet(&hs, 250, marker_wait_ms)) {
- hs.dump("syntax perf edit repaint did not go quiet");
- return error.EditRepaintQuietTimeout;
- }
-
- const delete_t0 = nowNs();
- try hs.send(delete_line_input);
- if (!try waitForScreenMissing(&hs, edit_marker, marker_wait_ms)) {
- hs.dump("syntax perf line delete marker did not disappear");
- return error.DeleteLineTimeout;
- }
- const delete_line_us = (nowNs() - delete_t0) / 1000;
- if (!try waitForOutputQuiet(&hs, 250, marker_wait_ms)) {
- hs.dump("syntax perf line delete repaint did not go quiet");
- return error.DeleteLineQuietTimeout;
- }
-
- const undo_t0 = nowNs();
- try hs.send("u");
- try hs.expectWaitFor(edit_marker, marker_wait_ms, "syntax perf undo did not restore edit marker");
- const undo_us = (nowNs() - undo_t0) / 1000;
- if (!try waitForOutputQuiet(&hs, 250, marker_wait_ms)) {
- hs.dump("syntax perf undo repaint did not go quiet");
- return error.UndoQuietTimeout;
- }
-
- const move_input = try movementInput(gpa, movement);
- defer gpa.free(move_input);
-
- const start_top_line = @max(try leftPaneTopLine(&hs), 1);
- const move_t0 = nowNs();
- try hs.send(move_input);
- if (try waitForTopLineAbove(&hs, start_top_line, marker_wait_ms)) |_| {
- // Progress observed; keep draining below until the movement settles.
- } else {
- hs.dump("syntax perf movement did not move the visible file window");
- return error.MovementDidNotMove;
- }
- const move_first_us = (nowNs() - move_t0) / 1000;
- if (!try waitForOutputQuiet(&hs, scroll_quiet_ms, marker_wait_ms)) {
- hs.dump("syntax perf movement did not go quiet");
- return error.MovementQuietTimeout;
- }
- const move_done_us = (nowNs() - move_t0) / 1000;
- const move_top_line = try leftPaneTopLine(&hs);
-
- return .{
- .startup_app_first_frame_us = startup_app_first_frame_us,
- .startup_spawn_first_frame_us = startup_spawn_first_frame_us,
- .startup_shell_ready_us = startup_shell_ready_us,
- .open_visible_us = open_visible_us,
- .open_repaint_us = open_repaint_us,
- .edit_us = edit_us,
- .delete_line_us = delete_line_us,
- .undo_us = undo_us,
- .move_first_us = move_first_us,
- .move_done_us = move_done_us,
- .move_active_us = move_done_us -| (@as(u64, scroll_quiet_ms) * 1000),
- .move_top_line = move_top_line,
- };
-}
-
-fn runStartup(gpa: std.mem.Allocator, exe: [*:0]const u8) !StartupResult {
- const startup_spawn_t0 = nowNs();
- var hs = try Harness.initArgs(gpa, exe, 30, 100, "--perf-startup");
- defer hs.deinit();
- const startup_app_t0 = nowNs();
-
- const internal_first_frame_us = (try waitForStartupMetric(&hs, 8000, 1)) orelse {
- hs.dump("app first frame did not render before startup perf");
- return error.StartupFirstFrameTimeout;
- };
- if (!try waitForRawContainsPoll(&hs, "Kill Newcol", 8000, 1)) {
- hs.dump("app first frame marker rendered without topbar text");
- return error.StartupFirstFrameTimeout;
- }
- return .{
- .internal_first_frame_us = internal_first_frame_us,
- .harness_first_frame_us = (nowNs() - startup_app_t0) / 1000,
- .spawn_first_frame_us = (nowNs() - startup_spawn_t0) / 1000,
- };
-}
-
-fn printResult(fixture: Fixture, movement: Movement, result: BenchResult) void {
- std.debug.print(
- "case={s} movement={s} highlight={d} bytes={d} lines={d} startup_app_first_frame_us={d} startup_spawn_first_frame_us={d} startup_shell_ready_us={d} open_visible_us={d} open_repaint_us={d} edit_us={d} edit_us_per_char={d} delete_line_us={d} undo_us={d} move_first_us={d} move_done_us={d} move_active_us={d} move_top_line={d}\n",
- .{
- fixture.name,
- movementName(movement),
- @intFromBool(fixture.highlighted),
- fixture.bytes,
- fixture.lines,
- result.startup_app_first_frame_us,
- result.startup_spawn_first_frame_us,
- result.startup_shell_ready_us,
- result.open_visible_us,
- result.open_repaint_us orelse 0,
- result.edit_us,
- result.edit_us / payload_len,
- result.delete_line_us,
- result.undo_us,
- result.move_first_us,
- result.move_done_us,
- result.move_active_us,
- result.move_top_line,
- },
- );
-}
-
-fn ratioX100(highlight_us: u64, plain_us: u64) u64 {
- if (plain_us == 0) return 0;
- return (highlight_us * 100 + plain_us / 2) / plain_us;
-}
-
-fn movementRatioX100(highlight_us: u64, plain_us: u64) u64 {
- return ratioX100(highlight_us, @max(plain_us, movement_ratio_floor_us));
-}
-
-fn printRatioSummary(movement: Movement, small_txt: BenchResult, small_zig: BenchResult, large_txt: BenchResult, large_zig: BenchResult) void {
- std.debug.print(
- "ratio movement={s} small_move_active_x100={d} large_move_active_x100={d} target_x100={d} small_open_visible_x100={d} large_open_visible_x100={d} small_edit_x100={d} large_edit_x100={d} small_delete_line_x100={d} large_delete_line_x100={d} small_undo_x100={d} large_undo_x100={d}\n",
- .{
- movementName(movement),
- movementRatioX100(small_zig.move_active_us, small_txt.move_active_us),
- movementRatioX100(large_zig.move_active_us, large_txt.move_active_us),
- target_ratio_x100,
- ratioX100(small_zig.open_visible_us, small_txt.open_visible_us),
- ratioX100(large_zig.open_visible_us, large_txt.open_visible_us),
- ratioX100(small_zig.edit_us, small_txt.edit_us),
- ratioX100(large_zig.edit_us, large_txt.edit_us),
- ratioX100(small_zig.delete_line_us, small_txt.delete_line_us),
- ratioX100(large_zig.delete_line_us, large_txt.delete_line_us),
- ratioX100(small_zig.undo_us, small_txt.undo_us),
- ratioX100(large_zig.undo_us, large_txt.undo_us),
- },
- );
-}
-
-fn checkMovementRatio(movement: Movement, size_name: []const u8, highlighted_us: u64, plain_us: u64) !void {
- const ratio = movementRatioX100(highlighted_us, plain_us);
- if (ratio > target_ratio_x100) {
- std.debug.print(
- "FAIL movement={s} size={s} move_active_x100={d} target_x100={d} highlighted_us={d} plain_us={d}\n",
- .{ movementName(movement), size_name, ratio, target_ratio_x100, highlighted_us, plain_us },
- );
- return error.HighlightMovementPerfRegression;
- }
-}
-
-fn checkMovementRatios(movement: Movement, small_txt: BenchResult, small_zig: BenchResult, large_txt: BenchResult, large_zig: BenchResult) !void {
- try checkMovementRatio(movement, "small", small_zig.move_active_us, small_txt.move_active_us);
- try checkMovementRatio(movement, "large", large_zig.move_active_us, large_txt.move_active_us);
-}
-
-fn printBestOpsRatioSummary(best: [4]BestOps) void {
- std.debug.print(
- "ratio aggregate=best_of_movements target_x100={d} small_open_visible_x100={d} large_open_visible_x100={d} small_edit_x100={d} large_edit_x100={d} small_delete_line_x100={d} large_delete_line_x100={d} small_undo_x100={d} large_undo_x100={d}\n",
- .{
- target_ratio_x100,
- ratioX100(best[1].open_visible_us, best[0].open_visible_us),
- ratioX100(best[3].open_visible_us, best[2].open_visible_us),
- ratioX100(best[1].edit_us, best[0].edit_us),
- ratioX100(best[3].edit_us, best[2].edit_us),
- ratioX100(best[1].delete_line_us, best[0].delete_line_us),
- ratioX100(best[3].delete_line_us, best[2].delete_line_us),
- ratioX100(best[1].undo_us, best[0].undo_us),
- ratioX100(best[3].undo_us, best[2].undo_us),
- },
- );
-}
-
-fn printStartupSummary(name: []const u8, target_us: ?u64, stats: StartupStats) void {
- if (target_us) |target| {
- std.debug.print(
- "startup summary metric={s} target_us={d} samples={d} best_us={d} avg_us={d} worst_us={d}\n",
- .{ name, target, stats.count, stats.best_us, stats.avg(), stats.worst_us },
- );
- return;
- }
- std.debug.print(
- "startup summary metric={s} samples={d} best_us={d} avg_us={d} worst_us={d}\n",
- .{ name, stats.count, stats.best_us, stats.avg(), stats.worst_us },
- );
-}
-
-fn checkStartupAverage(name: []const u8, target_us: u64, stats: StartupStats) !void {
- const avg_us = stats.avg();
- if (avg_us <= target_us) return;
- std.debug.print(
- "FAIL startup metric={s} avg_us={d} target_us={d} samples={d} best_us={d} worst_us={d}\n",
- .{ name, avg_us, target_us, stats.count, stats.best_us, stats.worst_us },
- );
- return error.StartupPerfRegression;
-}
-
-pub fn main(init: std.process.Init) !void {
- const gpa = init.gpa;
- const args = try init.minimal.args.toSlice(init.arena.allocator());
- if (args.len != 2) {
- std.debug.print("usage: {s} <pardes-exe>\n", .{args[0]});
- return error.BadArgs;
- }
-
- const small_txt_path = "/tmp/pardes_perf_small_plain.txt";
- const small_zig_path = "/tmp/pardes_perf_small_code.zig";
- const large_txt_path = "/tmp/pardes_perf_large_plain.txt";
- const large_zig_path = "/tmp/pardes_perf_large_code.zig";
-
- const stdlib_content = try readFileAlloc(gpa, large_stdlib_path);
- defer gpa.free(stdlib_content);
- const small_txt_content = try makeSmallFixture(gpa, "SMALLTXTOPENPERFEND", "SMALLTXTSCROLLPERFEND", false);
- defer gpa.free(small_txt_content);
- const small_zig_content = try makeSmallFixture(gpa, "SMALLZIGOPENPERFEND", "SMALLZIGSCROLLPERFEND", true);
- defer gpa.free(small_zig_content);
- const large_txt_content = try makeStdlibFixture(gpa, stdlib_content, "LARGETXTOPENPERFEND", "LARGETXTSCROLLPERFEND", false);
- defer gpa.free(large_txt_content);
- const large_zig_content = try makeStdlibFixture(gpa, stdlib_content, "LARGEZIGOPENPERFEND", "LARGEZIGSCROLLPERFEND", true);
- defer gpa.free(large_zig_content);
-
- try h.writeFile(small_txt_path, small_txt_content);
- try h.writeFile(small_zig_path, small_zig_content);
- try h.writeFile(large_txt_path, large_txt_content);
- try h.writeFile(large_zig_path, large_zig_content);
-
- const fixtures = [_]Fixture{
- .{
- .name = "small_txt",
- .path = small_txt_path,
- .open_marker = "SMALLTXTOPENPERFEND",
- .edit_marker = "SMALLTXTEDITPERFEND",
- .scroll_marker = "SMALLTXTSCROLLPERFEND",
- .highlighted = false,
- .bytes = small_txt_content.len,
- .lines = lineCount(small_txt_content),
- },
- .{
- .name = "small_zig",
- .path = small_zig_path,
- .open_marker = "SMALLZIGOPENPERFEND",
- .edit_marker = "SMALLZIGEDITPERFEND",
- .scroll_marker = "SMALLZIGSCROLLPERFEND",
- .highlighted = true,
- .bytes = small_zig_content.len,
- .lines = lineCount(small_zig_content),
- },
- .{
- .name = "large_txt",
- .path = large_txt_path,
- .open_marker = "LARGETXTOPENPERFEND",
- .edit_marker = "LARGETXTEDITPERFEND",
- .scroll_marker = "LARGETXTSCROLLPERFEND",
- .highlighted = false,
- .bytes = large_txt_content.len,
- .lines = lineCount(large_txt_content),
- },
- .{
- .name = "large_zig",
- .path = large_zig_path,
- .open_marker = "LARGEZIGOPENPERFEND",
- .edit_marker = "LARGEZIGEDITPERFEND",
- .scroll_marker = "LARGEZIGSCROLLPERFEND",
- .highlighted = true,
- .bytes = large_zig_content.len,
- .lines = lineCount(large_zig_content),
- },
- };
-
- std.debug.print(
- "syntax-perf backend=vaxis chars_per_edit={d} delete_select_lines={d} wheel_events={d} j_events={d} ctrl_d_events={d} scroll_quiet_ms={d} scroll_marker_line={d} large_stdlib={s}\n",
- .{ payload_len, delete_select_lines, wheel_events, j_events, ctrl_d_events, scroll_quiet_ms, scroll_marker_line, large_stdlib_path },
- );
- var startup_only_internal_stats: StartupStats = .{};
- var startup_only_harness_stats: StartupStats = .{};
- var startup_only_spawn_stats: StartupStats = .{};
- for (0..startup_samples) |sample| {
- const startup = try runStartup(gpa, args[1].ptr);
- startup_only_internal_stats.add(startup.internal_first_frame_us);
- startup_only_harness_stats.add(startup.harness_first_frame_us);
- startup_only_spawn_stats.add(startup.spawn_first_frame_us);
- std.debug.print(
- "startup_sample={d} internal_first_frame_us={d} harness_first_frame_us={d} spawn_first_frame_us={d}\n",
- .{ sample, startup.internal_first_frame_us, startup.harness_first_frame_us, startup.spawn_first_frame_us },
- );
- }
- printStartupSummary("startup_only_internal_first_frame", null, startup_only_internal_stats);
- printStartupSummary("startup_only_harness_first_frame", startup_first_frame_target_us, startup_only_harness_stats);
- printStartupSummary("startup_only_spawn_first_frame", null, startup_only_spawn_stats);
- try checkStartupAverage("startup_only_harness_first_frame", startup_first_frame_target_us, startup_only_harness_stats);
-
- const movements = [_]Movement{ .wheel, .j, .ctrl_d };
- var best_ops: [4]BestOps = .{ .{}, .{}, .{}, .{} };
- var startup_app_first_frame_stats: StartupStats = .{};
- var startup_spawn_first_frame_stats: StartupStats = .{};
- var startup_shell_ready_stats: StartupStats = .{};
- for (movements) |movement| {
- const small_txt = try runFixture(gpa, args[1].ptr, fixtures[0], movement);
- startup_app_first_frame_stats.add(small_txt.startup_app_first_frame_us);
- startup_spawn_first_frame_stats.add(small_txt.startup_spawn_first_frame_us);
- startup_shell_ready_stats.add(small_txt.startup_shell_ready_us);
- best_ops[0].add(small_txt);
- printResult(fixtures[0], movement, small_txt);
- const small_zig = try runFixture(gpa, args[1].ptr, fixtures[1], movement);
- startup_app_first_frame_stats.add(small_zig.startup_app_first_frame_us);
- startup_spawn_first_frame_stats.add(small_zig.startup_spawn_first_frame_us);
- startup_shell_ready_stats.add(small_zig.startup_shell_ready_us);
- best_ops[1].add(small_zig);
- printResult(fixtures[1], movement, small_zig);
- const large_txt = try runFixture(gpa, args[1].ptr, fixtures[2], movement);
- startup_app_first_frame_stats.add(large_txt.startup_app_first_frame_us);
- startup_spawn_first_frame_stats.add(large_txt.startup_spawn_first_frame_us);
- startup_shell_ready_stats.add(large_txt.startup_shell_ready_us);
- best_ops[2].add(large_txt);
- printResult(fixtures[2], movement, large_txt);
- const large_zig = try runFixture(gpa, args[1].ptr, fixtures[3], movement);
- startup_app_first_frame_stats.add(large_zig.startup_app_first_frame_us);
- startup_spawn_first_frame_stats.add(large_zig.startup_spawn_first_frame_us);
- startup_shell_ready_stats.add(large_zig.startup_shell_ready_us);
- best_ops[3].add(large_zig);
- printResult(fixtures[3], movement, large_zig);
- printRatioSummary(movement, small_txt, small_zig, large_txt, large_zig);
- try checkMovementRatios(movement, small_txt, small_zig, large_txt, large_zig);
- }
- printBestOpsRatioSummary(best_ops);
- printStartupSummary("app_first_frame", null, startup_app_first_frame_stats);
- printStartupSummary("spawn_first_frame", null, startup_spawn_first_frame_stats);
- printStartupSummary("shell_ready", null, startup_shell_ready_stats);
-}
diff --git a/tests_tty_perf.zig b/tests_tty_perf.zig
deleted file mode 100644
index 06ce9cc3..00000000
--- a/tests_tty_perf.zig
+++ /dev/null
@@ -1,88 +0,0 @@
-const std = @import("std");
-const h = @import("e2e_harness.zig");
-
-const Harness = h.Harness;
-const linux = std.os.linux;
-
-const samples = 5;
-const payload_len = 64;
-
-fn nowNs() u64 {
- var ts: linux.timespec = undefined;
- _ = linux.clock_gettime(linux.CLOCK.MONOTONIC, &ts);
- return @intCast(ts.sec * 1_000_000_000 + ts.nsec);
-}
-
-fn fillPayload(buf: *[payload_len]u8, sample: usize) []const u8 {
- const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
- for (buf, 0..) |*ch, i| ch.* = alphabet[(i + sample * 13) % alphabet.len];
-
- var marker_buf: [16]u8 = undefined;
- const marker = std.fmt.bufPrint(&marker_buf, "ZTPERF{d}END", .{sample}) catch unreachable;
- const start = payload_len - marker.len;
- @memcpy(buf[start..][0..marker.len], marker);
- return buf[start..payload_len];
-}
-
-fn clickBodyRow(hs: *Harness, screen_row: usize) !void {
- var seq_buf: [64]u8 = undefined;
- const seq = try std.fmt.bufPrint(&seq_buf, "\x1b[<0;3;{d}M\x1b[<0;3;{d}m", .{ screen_row, screen_row });
- try hs.send(seq);
-}
-
-pub fn main(init: std.process.Init) !void {
- const gpa = init.gpa;
- const args = try init.minimal.args.toSlice(init.arena.allocator());
- if (args.len != 2) {
- std.debug.print("usage: {s} <pardes-exe>\n", .{args[0]});
- return error.BadArgs;
- }
-
- var hs = try Harness.initArgs(gpa, args[1].ptr, 24, 80, "--tty");
- defer hs.deinit();
-
- try hs.expectWaitFor("sy /", 6000, "--tty did not start in tty mode");
- try hs.send("clear\r");
- try hs.pump(900);
- try hs.send("\x02");
- try hs.expectWaitFor("nm /", 6000, "Ctrl-b did not leave tty mode");
-
- var best_us: u64 = std.math.maxInt(u64);
- var total_us: u64 = 0;
-
- std.debug.print("tty-edit-perf backend=vaxis samples={d} chars_per_sample={d}\n", .{ samples, payload_len });
-
- var sample: usize = 0;
- while (sample < samples) : (sample += 1) {
- const row = 5 + sample * 3;
- try clickBodyRow(&hs, row);
- try hs.pump(80);
- try hs.send("i");
- try hs.expectWaitFor("in /", 3000, "failed to enter insert mode");
-
- var payload_buf: [payload_len]u8 = undefined;
- const marker = fillPayload(&payload_buf, sample);
-
- const t0 = nowNs();
- try hs.send(&payload_buf);
- try hs.expectWaitFor(marker, 10_000, "typed marker never appeared");
- const elapsed_us = (nowNs() - t0) / 1000;
-
- best_us = @min(best_us, elapsed_us);
- total_us += elapsed_us;
- std.debug.print(" sample {d}: elapsed_us={d} us_per_char={d}\n", .{
- sample + 1,
- elapsed_us,
- elapsed_us / payload_len,
- });
-
- try hs.send("\x1b");
- try hs.expectWaitFor("nm /", 3000, "failed to leave insert mode");
- }
-
- std.debug.print("tty-edit-perf summary: best_us={d} avg_us={d} best_us_per_char={d}\n", .{
- best_us,
- total_us / samples,
- best_us / payload_len,
- });
-}
diff --git a/tutor_gen.zig b/tutor_gen.zig
deleted file mode 100644
index 09875e64..00000000
--- a/tutor_gen.zig
+++ /dev/null
@@ -1,386 +0,0 @@
-const std = @import("std");
-const modal = @import("modal.zig");
-
-// tutor_gen: a separate binary (NOT comptime) that parses tutor.txt and writes
-// tutor_cases.zig — a file of standard Zig `test` blocks, one per practice block.
-// `zig build tutor-gen` runs it (writing the file); `zig build unit-test` then
-// compiles+runs those tests against modal.stepFile.
-//
-// Format of tutor.txt practice blocks (all directive lines start with "# "):
-// # keys: l l l i X esc <- space-separated keystroke tokens
-// # at: 0,4 <- optional starting cursor "row,col" (default 0,0)
-// # before <- the lines below are the starting buffer
-// <clean line>
-// # after <- the lines below are the expected buffer
-// <clean line>
-// Everything not under a # before/# after is prose (ignored). A `# keys:` starts
-// a new case; cases end at the next `# keys:` or a non-practice line.
-//
-// Keystroke tokens: each char of a token is typed in turn, EXCEPT the named
-// specials `esc`, `enter`, `bs` (backspace), `left/right/up/down`, `home/end`,
-// `pgup/pgdn`. `Ctrl-d`/`Ctrl-u`/`Ctrl-f`/`Ctrl-b` map to half/full page.
-
-const Case = struct {
- keys: []const u8,
- at: ?[]const u8 = null,
- before: []const u8,
- after: []const u8,
-};
-
-fn parseCases(alloc: std.mem.Allocator, src: []const u8) ![]Case {
- var cases: std.ArrayList(Case) = .empty;
- var it = std.mem.splitScalar(u8, src, '\n');
- // accumulate the current case
- var cur_keys: ?[]const u8 = null;
- var cur_at: ?[]const u8 = null;
- var before: ?std.ArrayList(u8) = null;
- var after: ?std.ArrayList(u8) = null;
- // which section is actively collecting content (so # after stops before from
- // absorbing the after lines). `none` = neither.
- var section: enum { none, before, after } = .none;
- defer if (before) |*b| b.deinit(alloc);
- defer if (after) |*a| a.deinit(alloc);
- // NOTE: cur_keys / cur_at ownership is handled explicitly at each flush/drop
- // (moved into a case, or freed + nulled). We do NOT defer-free them here:
- // a defer would double-free a value that was moved into a case (the move
- // leaves the local pointing at the now-case-owned memory until it's nulled,
- // and a function-exit defer can't tell).
-
- while (it.next()) |raw_line| {
- // Practice-block lines in tutor.txt are indented by one leading space
- // (the tutor's prose style). Strip exactly that one space so directives
- // match AND content lines don't carry the indent into the buffer.
- var line = raw_line;
- if (line.len > 0 and line[0] == ' ') line = line[1..];
- line = std.mem.trimEnd(u8, line, "\r");
- if (std.mem.startsWith(u8, line, "# keys:")) {
- // flush previous case
- if (cur_keys != null and before != null and after != null) {
- try cases.append(alloc, .{
- .keys = cur_keys.?,
- .at = cur_at,
- .before = try before.?.toOwnedSlice(alloc),
- .after = try after.?.toOwnedSlice(alloc),
- });
- before = null;
- after = null;
- } else if (cur_keys != null) {
- // a # keys: with no before/after — drop it (incomplete): free the
- // owned keys/at so they don't leak.
- if (cur_keys) |k| alloc.free(k);
- if (cur_at) |at| alloc.free(at);
- before = null;
- after = null;
- }
- cur_keys = try alloc.dupe(u8, std.mem.trim(u8, line["# keys:".len..], " "));
- // cur_at from a prior case was either moved (flushed) or freed (drop);
- // start fresh.
- cur_at = null;
- before = null;
- after = null;
- section = .none;
- continue;
- }
- if (std.mem.startsWith(u8, line, "# at:")) {
- if (cur_at) |old| alloc.free(old);
- cur_at = try alloc.dupe(u8, std.mem.trim(u8, line["# at:".len..], " "));
- continue;
- }
- if (std.mem.startsWith(u8, line, "# before")) {
- before = .empty;
- after = null;
- section = .before;
- continue;
- }
- if (std.mem.startsWith(u8, line, "# after")) {
- after = .empty;
- section = .after;
- continue;
- }
- // any other "# ..." directive that isn't keys/at/before/after ends a case
- if (std.mem.startsWith(u8, line, "#")) {
- if (cur_keys != null and before != null and after != null and after.?.items.len > 0) {
- try cases.append(alloc, .{
- .keys = cur_keys.?,
- .at = cur_at,
- .before = try before.?.toOwnedSlice(alloc),
- .after = try after.?.toOwnedSlice(alloc),
- });
- } else {
- // incomplete: free the owned keys/at + any partial content
- if (cur_keys) |k| alloc.free(k);
- if (cur_at) |at| alloc.free(at);
- if (before) |*b| b.deinit(alloc);
- if (after) |*a| a.deinit(alloc);
- }
- cur_keys = null;
- cur_at = null;
- before = null;
- after = null;
- section = .none;
- continue;
- }
- // a blank line: if we have a complete case (keys + before + after with
- // content), flush it now — the practice block is delimited by blank lines
- // in the tutor, so prose after the blank line must NOT be absorbed.
- if (line.len == 0) {
- if (cur_keys != null and before != null and after != null and after.?.items.len > 0) {
- try cases.append(alloc, .{
- .keys = cur_keys.?,
- .at = cur_at,
- .before = try before.?.toOwnedSlice(alloc),
- .after = try after.?.toOwnedSlice(alloc),
- });
- cur_keys = null;
- before = null;
- after = null;
- section = .none;
- } else {
- section = .none; // a blank line closes the active section
- }
- continue;
- }
- // a content line: belongs to the ACTIVE section only
- if (section == .before) {
- if (before) |*b| {
- if (b.items.len > 0) try b.append(alloc, '\n');
- try b.appendSlice(alloc, line);
- }
- continue;
- }
- if (section == .after) {
- if (after) |*a| {
- if (a.items.len > 0) try a.append(alloc, '\n');
- try a.appendSlice(alloc, line);
- }
- continue;
- }
- // prose line, ignore
- }
- // flush trailing case
- if (cur_keys != null and before != null and after != null and after.?.items.len > 0) {
- try cases.append(alloc, .{
- .keys = cur_keys.?,
- .at = cur_at,
- .before = try before.?.toOwnedSlice(alloc),
- .after = try after.?.toOwnedSlice(alloc),
- });
- // ownership transferred; clear so the defers above don't double-free.
- cur_keys = null;
- cur_at = null;
- before = null;
- after = null;
- }
- return cases.toOwnedSlice(alloc);
-}
-
-// Emit a Zig string literal for `s`, escaping per Zig string syntax.
-fn emitStringLiteral(out: *std.ArrayList(u8), alloc: std.mem.Allocator, s: []const u8) !void {
- try out.append(alloc, '"');
- for (s) |c| {
- switch (c) {
- '"' => try out.appendSlice(alloc, "\\\""),
- '\\' => try out.appendSlice(alloc, "\\\\"),
- '\n' => try out.appendSlice(alloc, "\\n"),
- '\r' => try out.appendSlice(alloc, "\\r"),
- '\t' => try out.appendSlice(alloc, "\\t"),
- else => {
- if (c >= 0x20 and c < 0x7f) {
- try out.append(alloc, c);
- } else {
- try out.print(alloc, "\\x{x:0>2}", .{c});
- }
- },
- }
- }
- try out.append(alloc, '"');
-}
-
-fn writeCases(io: std.Io, alloc: std.mem.Allocator, cases: []const Case, out_path: []const u8) !void {
- // Build the generated source in a buffer, then write it in one shot.
- var out: std.ArrayList(u8) = .empty;
- defer out.deinit(alloc);
-
- try out.appendSlice(alloc, "// AUTO-GENERATED by tutor_gen from tutor.txt. DO NOT EDIT.\n");
- try out.appendSlice(alloc, "// One `test` block per practice block in the tutor. Re-run: zig build tutor-gen\n");
- try out.appendSlice(alloc, "const std = @import(\"std\");\n");
- try out.appendSlice(alloc, "const modal = @import(\"modal\");\n\n");
-
- for (cases, 0..) |c, i| {
- // Derive a short human label from the keys for the test name. Sanitize
- // it to a valid Zig identifier fragment (spaces/specials -> '_').
- var name_buf: [128]u8 = undefined;
- const printed = std.fmt.bufPrint(&name_buf, "tutor_case_{d:0>2}_{s}", .{ i, c.keys }) catch "tutor_case";
- var san_buf: [128]u8 = undefined;
- const san_len = @min(printed.len, san_buf.len);
- for (printed[0..san_len], 0..) |ch, j| {
- san_buf[j] = switch (ch) {
- 'a'...'z', 'A'...'Z', '0'...'9', '_' => ch,
- else => '_',
- };
- }
- const name_san = san_buf[0..san_len];
- try out.print(alloc, "test \"{s}\" {{\n", .{name_san});
- try out.appendSlice(alloc, " const a = std.testing.allocator;\n");
- try out.appendSlice(alloc, " var yank: ?[]u8 = null;\n");
- try out.appendSlice(alloc, " defer if (yank) |y| a.free(y);\n");
- try out.appendSlice(alloc, " var scroll: usize = 0;\n");
- try out.appendSlice(alloc, " var st = modal.FileState{};\n");
- try out.appendSlice(alloc, " const view = modal.View{ .rows = 200, .scroll = &scroll };\n");
-
- // starting content
- try out.appendSlice(alloc, " var content: []u8 = try a.dupe(u8, ");
- try emitStringLiteral(&out, alloc, c.before);
- try out.appendSlice(alloc, ");\n");
- try out.appendSlice(alloc, " defer a.free(content);\n");
-
- // starting cursor
- if (c.at) |at| {
- try out.print(alloc, " st.cur = .{{ .row = {s}, .col = {s} }};\n", .{
- splitField(at, 0),
- splitField(at, 1),
- });
- }
-
- // keystrokes
- var tit = std.mem.tokenizeAny(u8, c.keys, " ");
- while (tit.next()) |tok| {
- try emitKey(&out, alloc, tok);
- }
-
- // assertion
- try out.appendSlice(alloc, " try std.testing.expectEqualStrings(");
- try emitStringLiteral(&out, alloc, c.after);
- try out.appendSlice(alloc, ", content);\n");
- try out.appendSlice(alloc, "}\n\n");
- }
- try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = out_path, .data = out.items });
-}
-
-// "row,col" -> the row part (idx 0) or col part (idx 1).
-fn splitField(at: []const u8, idx: usize) []const u8 {
- var it = std.mem.splitScalar(u8, at, ',');
- var i: usize = 0;
- while (it.next()) |part| : (i += 1) {
- if (i == idx) return std.mem.trim(u8, part, " ");
- }
- return "0";
-}
-
-// Emit the stepFile calls for one keystroke token.
-// One stepFile call, as a generated line. `key_expr` is the Zig Key payload
-// expression (e.g. `.esc`, `.ch = 'x'`).
-fn emitStep(out: *std.ArrayList(u8), alloc: std.mem.Allocator, key_expr: []const u8) !void {
- try out.print(alloc, " {{ const n = try modal.stepFile(a, content, &st, view, {s}, &yank); if (n) |nn| {{ a.free(content); content = nn; }} }}\n", .{key_expr});
-}
-
-fn emitKey(out: *std.ArrayList(u8), alloc: std.mem.Allocator, tok: []const u8) !void {
- // named specials -> a single stepFile with that key
- if (std.mem.eql(u8, tok, "esc")) return emitStep(out, alloc, ".esc");
- if (std.mem.eql(u8, tok, "enter")) return emitStep(out, alloc, ".enter");
- if (std.mem.eql(u8, tok, "bs")) return emitStep(out, alloc, ".backspace");
- if (std.mem.eql(u8, tok, "left")) return emitStep(out, alloc, ".left");
- if (std.mem.eql(u8, tok, "right")) return emitStep(out, alloc, ".right");
- if (std.mem.eql(u8, tok, "up")) return emitStep(out, alloc, ".up");
- if (std.mem.eql(u8, tok, "down")) return emitStep(out, alloc, ".down");
- if (std.mem.eql(u8, tok, "home")) return emitStep(out, alloc, ".home");
- if (std.mem.eql(u8, tok, "end")) return emitStep(out, alloc, ".end");
- if (std.mem.eql(u8, tok, "pgdn") or std.mem.eql(u8, tok, "pagedown")) return emitStep(out, alloc, ".page_down");
- if (std.mem.eql(u8, tok, "pgup") or std.mem.eql(u8, tok, "pageup")) return emitStep(out, alloc, ".page_up");
- if (std.mem.eql(u8, tok, "Ctrl-d")) return emitStep(out, alloc, ".half_down");
- if (std.mem.eql(u8, tok, "Ctrl-u")) return emitStep(out, alloc, ".half_up");
- if (std.mem.eql(u8, tok, "Ctrl-f")) return emitStep(out, alloc, ".page_down");
- if (std.mem.eql(u8, tok, "Ctrl-b")) return emitStep(out, alloc, ".page_up");
- // otherwise: each byte of the token is a typed char (in order)
- for (tok) |c| {
- var expr_buf: [32]u8 = undefined;
- const expr = std.fmt.bufPrint(&expr_buf, ".{{ .ch = {d} }}", .{c}) catch return;
- try emitStep(out, alloc, expr);
- }
-}
-
-pub fn main(init: std.process.Init) !void {
- const io = init.io;
- const gpa = init.gpa;
- const args = try init.minimal.args.toSlice(init.arena.allocator());
- const in_path = if (args.len > 1) args[1] else "tutor.txt";
- const out_path = if (args.len > 2) args[2] else "tutor_cases.zig";
-
- const src = try std.Io.Dir.cwd().readFileAlloc(io, in_path, gpa, .limited(1 << 20));
- defer gpa.free(src);
-
- const cases = try parseCases(gpa, src);
- defer {
- for (cases) |c| {
- gpa.free(c.keys);
- if (c.at) |at| gpa.free(at);
- gpa.free(c.before);
- gpa.free(c.after);
- }
- gpa.free(cases);
- }
-
- try writeCases(io, gpa, cases, out_path);
- std.debug.print("tutor_gen: wrote {d} test cases to {s}\n", .{ cases.len, out_path });
-}
-
-// ---- self-test: the parser on tiny fixtures ----
-test "parseCases extracts a before/after block" {
- const a = std.testing.allocator;
- const src =
- \\# keys: l l l
- \\# before
- \\abcdef
- \\# after
- \\abcdef
- \\
- ;
- const cases = try parseCases(a, src);
- defer {
- for (cases) |c| {
- a.free(c.keys);
- if (c.at) |at| a.free(at);
- a.free(c.before);
- a.free(c.after);
- }
- a.free(cases);
- }
- try std.testing.expectEqual(@as(usize, 1), cases.len);
- try std.testing.expectEqualStrings("l l l", cases[0].keys);
- try std.testing.expectEqualStrings("abcdef", cases[0].before);
- try std.testing.expectEqualStrings("abcdef", cases[0].after);
-}
-
-test "parseCases handles # at: and multiple cases" {
- const a = std.testing.allocator;
- const src =
- \\# keys: b i Z esc
- \\# at: 0,4
- \\# before
- \\foo bar
- \\# after
- \\Zfoo bar
- \\
- \\# keys: x d
- \\# before
- \\keep
- \\gone
- \\# after
- \\keep
- \\
- ;
- const cases = try parseCases(a, src);
- defer {
- for (cases) |c| {
- a.free(c.keys);
- if (c.at) |at| a.free(at);
- a.free(c.before);
- a.free(c.after);
- }
- a.free(cases);
- }
- try std.testing.expectEqual(@as(usize, 2), cases.len);
- try std.testing.expectEqualStrings("0,4", cases[0].at.?);
- try std.testing.expectEqualStrings("keep\ngone", cases[1].before);
- try std.testing.expectEqualStrings("keep", cases[1].after);
-}
diff --git a/ui_sdl.zig b/ui_sdl.zig
deleted file mode 100644
index 2e967fb8..00000000
--- a/ui_sdl.zig
+++ /dev/null
@@ -1,2916 +0,0 @@
-// The SDL3 GPU UI backend (`-Dui=sdl`). Same surface as `ui_vaxis.zig` — the
-// app (main.zig / image.zig) imports `"ui"` and talks to `Vaxis`/`Tty`/`Loop`/
-// `init` plus the shared cell types — but here those are backed by an SDL
-// window + SDL3 GPU renderer instead of a real terminal.
-//
-// The retained model is identical to libvaxis: `main` builds a 2D grid of
-// `Cell`s each frame (via `vaxis.Window` over a `vaxis.Screen` — both pure,
-// I/O-free libvaxis types we reuse verbatim) and hands it to `render()`. Instead
-// of diffing it into escape sequences, `render()` rasterizes the grid with SDL3
-// GPU: one textured-quad draw per cell, glyphs pulled from an stb_truetype
-// monospace atlas. Input flows the other way through `SdlLoop`, which polls SDL
-// events and converts them to the same `Key`/`Mouse`/`Winsize` the app already
-// handles, posting them on a thread-safe queue (so the per-terminal pty readers
-// work unchanged). Kitty image panes are unsupported (caps.kitty_graphics is
-// false), so image.zig falls back to its PETSCII cell-art path — which renders
-// fine here, since it just writes cells.
-//
-// This is the quick-and-dirty first cut: ASCII + box-drawing + Nerd Font glyphs via
-// a fixed 1024² R8 atlas (dynamic, grows as new codepoints appear), reverse /
-// invisible / dim honored, bold/italic/underline/strikethrough ignored. Terminal
-// appearance is preserved: a monospace grid of styled cells on a dark page.
-const std = @import("std");
-const builtin = @import("builtin");
-const vaxis = @import("vaxis");
-const ui_sdl_config = @import("ui_sdl_config");
-const posix = std.posix;
-const linux = std.os.linux;
-const os_tag = builtin.os.tag;
-const is_emscripten = builtin.os.tag == .emscripten;
-
-pub const c = @cImport({
- @cDefine("SDL_DISABLE_OLD_NAMES", "1");
- @cInclude("SDL3/SDL.h");
- if (is_emscripten) @cInclude("GLES3/gl3.h");
- @cInclude("font.h");
-});
-
-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;
-
-// ---- shared (backend-agnostic) types: re-exported straight from libvaxis ----
-pub const Key = vaxis.Key;
-pub const Mouse = vaxis.Mouse;
-pub const Winsize = vaxis.Winsize;
-pub const Color = vaxis.Color;
-pub const Style = vaxis.Style;
-pub const Cell = vaxis.Cell;
-pub const Segment = vaxis.Segment;
-pub const Window = vaxis.Window;
-pub const Screen = vaxis.Screen;
-pub const Image = vaxis.Image;
-pub const Event = vaxis.Event;
-pub const Capabilities = vaxis.Vaxis.Capabilities;
-pub const CursorShape = vaxis.Cell.CursorShape;
-
-// `Options` mirrors the subset of vaxis.Vaxis.Options the app constructs. Only
-// system_clipboard_allocator is used (for OSC 52 paste in the terminal backend);
-// here it's accepted and unused — SDL reads the clipboard synchronously.
-pub const Options = struct {
- system_clipboard_allocator: ?std.mem.Allocator = null,
-};
-
-// The backend struct is named `Vaxis` so `image.zig`'s `*vaxis.Vaxis` resolves.
-pub const Vaxis = SdlBackend;
-pub const Tty = SdlTty;
-pub const supports_terminal_prepaint = false;
-pub fn Loop(comptime EventType: type) type {
- return SdlLoop(EventType);
-}
-
-pub fn adoptAltScreen(vx: *Vaxis) void {
- _ = vx;
-}
-
-const font_ttf = @embedFile("assets/AdwaitaMono-Regular.ttf");
-const vert_spv_bytes = if (is_emscripten) "" else @embedFile("ui.vert.spv");
-const frag_spv_bytes = if (is_emscripten) "" else @embedFile("ui.frag.spv");
-const overlay_vert_spv_bytes = if (is_emscripten) "" else @embedFile("overlay.vert.spv");
-const overlay_frag_spv_bytes = if (is_emscripten) "" else @embedFile("overlay.frag.spv");
-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 "";
-
-const log = std.log.scoped(.ui_sdl);
-const web_render_scale: f32 = ui_sdl_config.web_render_scale;
-const web_render_scale_f64: f64 = @floatCast(web_render_scale);
-
-// A standard 16-color ANSI palette (xterm), used to resolve vaxis.Color.index
-// cells — the dark theme passes palette indices through (palColor), and there's
-// no real terminal here to resolve them.
-const ansi = [16][3]u8{
- .{ 0, 0, 0 }, .{ 205, 0, 0 }, .{ 0, 205, 0 }, .{ 205, 205, 0 },
- .{ 0, 0, 238 }, .{ 205, 0, 205 }, .{ 0, 205, 205 }, .{ 229, 229, 229 },
- .{ 127, 127, 127 }, .{ 255, 0, 0 }, .{ 0, 255, 0 }, .{ 255, 255, 0 },
- .{ 92, 92, 255 }, .{ 255, 0, 255 }, .{ 0, 255, 255 }, .{ 255, 255, 255 },
-};
-
-// =====================================================================
-// Backend
-// =====================================================================
-
-const SdlBackend = @This();
-
-io: std.Io,
-alloc: std.mem.Allocator,
-
-sdl_window: *c.SDL_Window,
-device: *c.SDL_GPUDevice,
-swapchain_format: c.SDL_GPUTextureFormat,
-present_mode: c.SDL_GPUPresentMode,
-
-pipeline: *c.SDL_GPUGraphicsPipeline,
-overlay_pipeline: *c.SDL_GPUGraphicsPipeline,
-atlas_tex: *c.SDL_GPUTexture,
-atlas_sampler: *c.SDL_GPUSampler,
-atlas_xfer: *c.SDL_GPUTransferBuffer,
-
-vbuf: *c.SDL_GPUBuffer,
-vxfer: *c.SDL_GPUTransferBuffer,
-overlay_vbuf: *c.SDL_GPUBuffer,
-overlay_vxfer: *c.SDL_GPUTransferBuffer,
-vbuf_cells: u32 = 0, // capacity (cols*rows) the vbuf/vxfer were sized for
-cell_keys: []CellKey = &.{},
-dirty_ranges: []DirtyRange = &.{},
-cell_cache_valid: bool = false,
-last_render_w: u32 = 0,
-last_render_h: u32 = 0,
-
-// Emscripten/browser path: SDL window + WebGL2 shader renderer. Handles are GL
-// GLuint/GLint values, kept as plain C integers so native builds need no GL API.
-gl_context: ?c.SDL_GLContext = null,
-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 = &.{},
-overlay_vertices: []OverlayVertex = &.{},
-touches: [max_touch_points]TouchPoint = [_]TouchPoint{.{}} ** max_touch_points,
-touch_scroll: TouchScrollState = .{},
-touch_middle_clicks: u32 = 0,
-touch_middle_flash_frames: u8 = 0,
-
-// font + cell metrics
-font: ?*c.UIFont,
-scale: f32,
-cell_w: u32,
-cell_h: u32,
-ascent: i32,
-
-// retained cell grid (the same type vaxis uses; main writes here via window())
-screen: vaxis.Screen,
-caps: vaxis.Vaxis.Capabilities = .{}, // kitty_graphics stays false → PETSCII images
-test_mode: bool = false, // PARDES_TEST=1: stdin→events for e2e
-grid_test_mode: bool = false, // PARDES_TEST_GRID=1: old no-SDL text-grid dump path
-capture_images: bool = false, // PARDES_TEST=1: capture real rendered framebuffer images
-capture_dir: ?[]const u8 = null,
-capture_tex: ?*c.SDL_GPUTexture = null,
-capture_tex_w: u32 = 0,
-capture_tex_h: u32 = 0,
-capture_xfer: ?*c.SDL_GPUTransferBuffer = null,
-capture_xfer_size: u32 = 0,
-capture_frame: u64 = 0,
-
-trace_enabled: bool = false,
-trace_fd: posix.fd_t = -1,
-trace_every: u32 = 1,
-trace_frame: u64 = 0,
-
-// glyph atlas: a 1024² R8 staging bitmap + a map codepoint → texel slot.
-atlas_stage: []u8,
-glyphs: std.AutoHashMap(u32, Slot),
-pen_x: u32 = 0,
-pen_y: u32 = 0,
-atlas_dirty: bool = true,
-space_slot: Slot = .{ .u = 0, .v = 0 },
-
-// default page colors (the dark-theme "terminal native" the app expects when a
-// cell's color is .default).
-bg_default: [3]u8 = .{ 18, 18, 18 },
-fg_default: [3]u8 = .{ 204, 204, 204 },
-
-// last grid size we reported to the app (so SDL only posts winsize on change)
-reported_cols: u16 = 0,
-reported_rows: u16 = 0,
-
-// ---- constants + helper types (must come after all fields in Zig 0.16) ----
-pub const atlas_w: u32 = if (is_emscripten) 2048 else 1024;
-pub const atlas_h: u32 = if (is_emscripten) 2048 else 1024;
-pub const Slot = struct { u: u32, v: u32 };
-pub const CellKey = packed struct { cp: u32, fg: u32, bg: u32 };
-pub const DirtyRange = struct { start: u32, count: u32 };
-pub const max_touch_points: usize = 16;
-pub const max_touch_trail_points: usize = 8;
-pub const overlay_circle_vertices: usize = 16 * 3;
-pub const touch_middle_flash_max_frames: u8 = 14;
-pub const touch_middle_flash_vertices: usize = 1400;
-pub const touch_scroll_tick: f32 = 0.02;
-pub const max_overlay_vertices: usize = touch_middle_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices);
-
-// One instance per cell. The vertex shader expands each instance into a 2-triangle
-// quad using gl_VertexIndex, so dirty uploads move 56 B/cell instead of 240 B/cell.
-const CellInstance = extern struct {
- x0: f32,
- y0: f32,
- x1: f32,
- y1: f32,
- u0: f32,
- v0: f32,
- u1: f32,
- v1: f32,
- fr: f32,
- fg: f32,
- fb: f32,
- br: f32,
- bg: f32,
- bb: f32,
-};
-
-const OverlayVertex = extern struct {
- x: f32,
- y: f32,
- r: f32,
- g: f32,
- b: f32,
- a: f32,
-};
-
-const TouchSample = struct {
- x: f32 = 0,
- y: f32 = 0,
- pressure: f32 = 1,
-};
-
-const TouchPoint = struct {
- active: bool = false,
- id: u64 = 0,
- x: f32 = 0,
- y: f32 = 0,
- pressure: f32 = 1,
- trail: [max_touch_trail_points]TouchSample = [_]TouchSample{.{}} ** max_touch_trail_points,
- trail_len: usize = 0,
- trail_next: usize = 0,
-
- fn pushTrail(self: *TouchPoint, x: f32, y: f32, pressure: f32) void {
- self.trail[self.trail_next] = .{ .x = x, .y = y, .pressure = pressure };
- self.trail_next = (self.trail_next + 1) % max_touch_trail_points;
- if (self.trail_len < max_touch_trail_points) self.trail_len += 1;
- }
-};
-
-const TouchNormPoint = struct {
- x: f32 = 0,
- y: f32 = 0,
-};
-
-const TouchScrollState = struct {
- active: bool = false,
- ids: [2]u64 = .{ 0, 0 },
- accum_y: f32 = 0,
- scrolled: bool = false,
- last_center: TouchNormPoint = .{},
-};
-
-const TouchScrollResult = struct {
- center: TouchNormPoint,
- ticks: i32,
- delta_y: f32,
-};
-
-const OverlayColor = struct {
- r: f32,
- g: f32,
- b: f32,
- a: f32,
-};
-
-const CellLayout = struct {
- w: f32,
- h: f32,
- x_off: f32,
- y_off: f32,
-};
-
-const MouseCell = struct {
- col: i16,
- row: i16,
-};
-
-fn webGlFilter() c_int {
- return switch (ui_sdl_config.web_filter) {
- .linear => c.GL_LINEAR,
- .nearest => c.GL_NEAREST,
- };
-}
-
-fn envU16(env_map: *std.process.Environ.Map, name: []const u8) ?u16 {
- const raw = env_map.get(name) orelse return null;
- return std.fmt.parseInt(u16, raw, 10) catch null;
-}
-
-fn envU32(env_map: *std.process.Environ.Map, name: []const u8) ?u32 {
- const raw = env_map.get(name) orelse return null;
- return std.fmt.parseInt(u32, raw, 10) catch null;
-}
-
-fn envFlag(env_map: *std.process.Environ.Map, name: []const u8) bool {
- const raw = env_map.get(name) orelse return false;
- return !(raw.len == 0 or std.mem.eql(u8, raw, "0") or std.ascii.eqlIgnoreCase(raw, "false"));
-}
-
-fn nowNs() i128 {
- if (is_emscripten) return @intCast(c.SDL_GetTicksNS());
- switch (os_tag) {
- .linux => {
- var ts: linux.timespec = undefined;
- _ = linux.clock_gettime(linux.CLOCK.MONOTONIC, &ts);
- return @as(i128, ts.sec) * 1_000_000_000 + @as(i128, ts.nsec);
- },
- else => {
- var ts: posix.timespec = undefined;
- switch (posix.errno(posix.system.clock_gettime(posix.CLOCK.MONOTONIC, &ts))) {
- .SUCCESS => return @as(i128, ts.sec) * 1_000_000_000 + @as(i128, ts.nsec),
- else => return 0,
- }
- },
- }
-}
-
-fn openCreateTrunc(path: [*:0]const u8) ?posix.fd_t {
- switch (os_tag) {
- .linux => {
- const fd_rc = linux.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644);
- const fd_s: isize = @bitCast(fd_rc);
- return if (fd_s < 0) null else @intCast(fd_s);
- },
- else => return posix.openatZ(posix.AT.FDCWD, path, .{
- .ACCMODE = .WRONLY,
- .CREAT = true,
- .TRUNC = true,
- }, 0o644) catch null,
- }
-}
-
-fn closeFdBestEffort(fd: posix.fd_t) void {
- switch (os_tag) {
- .linux => _ = linux.close(fd),
- else => switch (posix.errno(posix.system.close(fd))) {
- .SUCCESS, .INTR => {},
- else => {},
- },
- }
-}
-
-fn writeAllFd(fd: posix.fd_t, bytes: []const u8) !void {
- var off: usize = 0;
- while (off < bytes.len) {
- switch (os_tag) {
- .linux => {
- const rc = linux.write(fd, bytes[off..].ptr, bytes.len - off);
- const n: isize = @bitCast(rc);
- if (n < 0) {
- if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue;
- return error.WriteFailed;
- }
- if (n == 0) return error.WriteFailed;
- off += @intCast(n);
- },
- else => {
- const rc = posix.system.write(fd, bytes[off..].ptr, bytes.len - off);
- switch (posix.errno(rc)) {
- .SUCCESS => {
- const n: usize = @intCast(rc);
- if (n == 0) return error.WriteFailed;
- off += n;
- },
- .INTR => continue,
- else => return error.WriteFailed,
- }
- },
- }
- }
-}
-
-fn writeAllFdIgnore(fd: posix.fd_t, bytes: []const u8) void {
- writeAllFd(fd, bytes) catch {};
-}
-
-fn renameZ(old_path: [*:0]const u8, new_path: [*:0]const u8) !void {
- switch (os_tag) {
- .linux => {
- const rr = linux.rename(old_path, new_path);
- const rs: isize = @bitCast(rr);
- if (rs < 0) return error.RenameFailed;
- },
- else => switch (posix.errno(posix.system.rename(old_path, new_path))) {
- .SUCCESS => {},
- else => return error.RenameFailed,
- },
- }
-}
-
-fn nsToUs(delta: i128) u64 {
- if (delta <= 0) return 0;
- return @intCast(@divTrunc(delta, 1000));
-}
-
-fn parsePresentMode(raw: []const u8) ?c.SDL_GPUPresentMode {
- if (std.ascii.eqlIgnoreCase(raw, "immediate")) return c.SDL_GPU_PRESENTMODE_IMMEDIATE;
- if (std.ascii.eqlIgnoreCase(raw, "mailbox")) return c.SDL_GPU_PRESENTMODE_MAILBOX;
- if (std.ascii.eqlIgnoreCase(raw, "vsync")) return c.SDL_GPU_PRESENTMODE_VSYNC;
- return null;
-}
-
-fn presentModeName(mode: c.SDL_GPUPresentMode) []const u8 {
- return switch (mode) {
- c.SDL_GPU_PRESENTMODE_IMMEDIATE => "immediate",
- c.SDL_GPU_PRESENTMODE_MAILBOX => "mailbox",
- c.SDL_GPU_PRESENTMODE_VSYNC => "vsync",
- else => "unknown",
- };
-}
-
-fn choosePresentMode(env_map: *std.process.Environ.Map, device: *c.SDL_GPUDevice, sdl_win: *c.SDL_Window) c.SDL_GPUPresentMode {
- if (env_map.get("PARDES_SDL_PRESENT")) |raw| {
- if (parsePresentMode(raw)) |mode| {
- if (c.SDL_WindowSupportsGPUPresentMode(device, sdl_win, mode)) return mode;
- }
- }
- const preferred = [_]c.SDL_GPUPresentMode{
- c.SDL_GPU_PRESENTMODE_IMMEDIATE,
- c.SDL_GPU_PRESENTMODE_MAILBOX,
- c.SDL_GPU_PRESENTMODE_VSYNC,
- };
- for (preferred) |mode| {
- if (c.SDL_WindowSupportsGPUPresentMode(device, sdl_win, mode)) return mode;
- }
- return c.SDL_GPU_PRESENTMODE_VSYNC;
-}
-
-pub fn init(
- io: std.Io,
- alloc: std.mem.Allocator,
- env_map: *std.process.Environ.Map,
- opts: Options,
-) !SdlBackend {
- _ = opts;
-
- // ---- test modes -------------------------------------------------------
- // PARDES_TEST=1 keeps the real SDL/GPU/window renderer active, but drives
- // input from stdin (raw terminal escape sequences parsed by vaxis.Parser,
- // plus private synthetic SDL event OSCs) and writes rendered-frame captures
- // into PARDES_TEST_CAPTURE_DIR.
- //
- // PARDES_TEST_GRID=1 is retained as a debug escape hatch: no SDL/GPU/window,
- // stdin→events, retained cell grid→stdout. The real SDL e2e suite does not
- // use it because it bypasses the renderer.
- const test_mode = env_map.get("PARDES_TEST") != null;
- const grid_test_mode = env_map.get("PARDES_TEST_GRID") != null;
- if (grid_test_mode) return initTest(io, alloc);
- const capture_dir = env_map.get("PARDES_TEST_CAPTURE_DIR");
- if (test_mode and capture_dir == null) {
- log.err("PARDES_TEST requires PARDES_TEST_CAPTURE_DIR", .{});
- return error.SdlInit;
- }
- const trace_enabled = envFlag(env_map, "PARDES_SDL_TRACE");
- const trace_every = envU32(env_map, "PARDES_SDL_TRACE_EVERY") orelse 1;
- var trace_fd: posix.fd_t = -1;
- if (!is_emscripten and trace_enabled) {
- if (env_map.get("PARDES_SDL_TRACE_FILE")) |path| {
- trace_fd = openTraceFile(path);
- } else if (capture_dir) |dir| {
- var path_buf: [4096]u8 = undefined;
- const path = std.fmt.bufPrintZ(&path_buf, "{s}/perf.log", .{dir}) catch null;
- trace_fd = if (path) |p| openTraceFile(p) else 2;
- } else {
- trace_fd = 2;
- }
- }
-
- if (!c.SDL_Init(c.SDL_INIT_VIDEO)) {
- log.err("SDL_Init: {s}", .{c.SDL_GetError()});
- return error.SdlInit;
- }
-
- const win_w: c_int = 1120;
- const win_h: c_int = 720;
- var win_flags: c.SDL_WindowFlags = c.SDL_WINDOW_RESIZABLE;
- if (!test_mode) win_flags |= c.SDL_WINDOW_HIGH_PIXEL_DENSITY;
- if (is_emscripten) win_flags |= c.SDL_WINDOW_OPENGL;
- const sdl_win = c.SDL_CreateWindow(
- "pardes",
- win_w,
- win_h,
- win_flags,
- ) orelse {
- log.err("SDL_CreateWindow: {s}", .{c.SDL_GetError()});
- return error.SdlInit;
- };
- if (is_emscripten) {
- return initGl(io, alloc, sdl_win, test_mode, capture_dir, trace_enabled, trace_fd, trace_every);
- }
-
- const device = c.SDL_CreateGPUDevice(
- c.SDL_GPU_SHADERFORMAT_SPIRV,
- true,
- null,
- ) orelse {
- log.err("SDL_CreateGPUDevice: {s}", .{c.SDL_GetError()});
- return error.SdlInit;
- };
- if (!c.SDL_ClaimWindowForGPUDevice(device, sdl_win)) {
- log.err("ClaimWindowForGPUDevice: {s}", .{c.SDL_GetError()});
- return error.SdlInit;
- }
- const present_mode = choosePresentMode(env_map, device, sdl_win);
- if (!test_mode) _ = c.SDL_SetGPUAllowedFramesInFlight(device, 1);
- _ = c.SDL_SetGPUSwapchainParameters(
- device,
- sdl_win,
- c.SDL_GPU_SWAPCHAINCOMPOSITION_SDR,
- present_mode,
- );
- const swapchain_format = c.SDL_GetGPUSwapchainTextureFormat(device, sdl_win);
-
- // ---- font + cell metrics ----
- 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 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, scale, &cw, &chh, &asc);
- if (test_mode) {
- const cols = envU16(env_map, "PARDES_TEST_COLS") orelse 80;
- const rows = envU16(env_map, "PARDES_TEST_ROWS") orelse 24;
- _ = c.SDL_SetWindowSize(
- sdl_win,
- @intCast(@as(u32, cols) * @as(u32, @intCast(@max(cw, 1)))),
- @intCast(@as(u32, rows) * @as(u32, @intCast(@max(chh, 1)))),
- );
- _ = c.SDL_SyncWindow(sdl_win);
- }
-
- // ---- glyph atlas (R8) ----
- var tex_info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo);
- tex_info.type = c.SDL_GPU_TEXTURETYPE_2D;
- tex_info.format = c.SDL_GPU_TEXTUREFORMAT_R8_UNORM;
- tex_info.usage = c.SDL_GPU_TEXTUREUSAGE_SAMPLER;
- tex_info.width = atlas_w;
- tex_info.height = atlas_h;
- tex_info.layer_count_or_depth = 1;
- tex_info.num_levels = 1;
- tex_info.sample_count = c.SDL_GPU_SAMPLECOUNT_1;
- const atlas_tex = c.SDL_CreateGPUTexture(device, &tex_info) orelse return error.GpuCreate;
-
- var samp_info = std.mem.zeroes(c.SDL_GPUSamplerCreateInfo);
- samp_info.min_filter = c.SDL_GPU_FILTER_LINEAR;
- samp_info.mag_filter = c.SDL_GPU_FILTER_LINEAR;
- samp_info.mipmap_mode = c.SDL_GPU_FILTER_LINEAR;
- samp_info.address_mode_u = c.SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE;
- samp_info.address_mode_v = c.SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE;
- samp_info.address_mode_w = c.SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE;
- const atlas_sampler = c.SDL_CreateGPUSampler(device, &samp_info) orelse return error.GpuCreate;
-
- var ax_info = c.SDL_GPUTransferBufferCreateInfo{
- .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD,
- .size = atlas_w * atlas_h,
- .props = 0,
- };
- const atlas_xfer = c.SDL_CreateGPUTransferBuffer(device, &ax_info) orelse return error.GpuCreate;
-
- // ---- pipeline ----
- const pipeline = try makePipeline(device, swapchain_format);
- const overlay_pipeline = try makeOverlayPipeline(device, swapchain_format);
-
- const overlay_buffer_size: u32 = @intCast(max_overlay_vertices * @sizeOf(OverlayVertex));
- var ovb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = overlay_buffer_size, .props = 0 };
- const overlay_vbuf = c.SDL_CreateGPUBuffer(device, &ovb_info) orelse return error.GpuCreate;
- var ovx_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = overlay_buffer_size, .props = 0 };
- const overlay_vxfer = c.SDL_CreateGPUTransferBuffer(device, &ovx_info) orelse return error.GpuCreate;
-
- const atlas_stage = try alloc.alloc(u8, atlas_w * atlas_h);
- @memset(atlas_stage, 0);
- const overlay_vertices = try alloc.alloc(OverlayVertex, max_overlay_vertices);
-
- var self: SdlBackend = .{
- .io = io,
- .alloc = alloc,
- .sdl_window = sdl_win,
- .device = device,
- .swapchain_format = swapchain_format,
- .present_mode = present_mode,
- .pipeline = pipeline,
- .overlay_pipeline = overlay_pipeline,
- .atlas_tex = atlas_tex,
- .atlas_sampler = atlas_sampler,
- .atlas_xfer = atlas_xfer,
- .vbuf = undefined,
- .vxfer = undefined,
- .overlay_vbuf = overlay_vbuf,
- .overlay_vxfer = overlay_vxfer,
- .overlay_vertices = overlay_vertices,
- .font = font,
- .scale = scale,
- .cell_w = @intCast(@max(cw, 1)),
- .cell_h = @intCast(@max(chh, 1)),
- .ascent = asc,
- .screen = .{},
- .atlas_stage = atlas_stage,
- .glyphs = std.AutoHashMap(u32, Slot).init(alloc),
- .test_mode = test_mode,
- .capture_images = test_mode,
- .capture_dir = capture_dir,
- .trace_enabled = trace_enabled,
- .trace_fd = trace_fd,
- .trace_every = @max(trace_every, 1),
- };
- // reserve slot (0,0) for the space glyph (an empty cell), start the pen after it.
- _ = c.ui_font_raster(font, scale, ' ', atlas_stage.ptr, @intCast(atlas_w), @intCast(self.cell_w), @intCast(self.cell_h), asc);
- self.space_slot = .{ .u = 0, .v = 0 };
- self.pen_x = self.cell_w;
- self.tracef("sdl.init present={s} frames_in_flight={d} trace_every={d}", .{
- presentModeName(self.present_mode),
- if (test_mode) @as(u32, 2) else @as(u32, 1),
- self.trace_every,
- });
- return self;
-}
-
-fn initGl(
- io: std.Io,
- alloc: std.mem.Allocator,
- sdl_win: *c.SDL_Window,
- test_mode: bool,
- capture_dir: ?[]const u8,
- trace_enabled: bool,
- trace_fd: posix.fd_t,
- trace_every: u32,
-) !SdlBackend {
- _ = 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(sdl_win) orelse {
- log.err("SDL_GL_CreateContext: {s}", .{c.SDL_GetError()});
- return error.SdlInit;
- };
- if (!c.SDL_GL_MakeCurrent(sdl_win, gl_context)) {
- log.err("SDL_GL_MakeCurrent: {s}", .{c.SDL_GetError()});
- return error.SdlInit;
- }
- _ = c.SDL_GL_SetSwapInterval(1);
-
- 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: c_int = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(asc, 1))) * web_render_scale));
-
- const program = try makeGlProgram();
- const overlay_program = try makeOverlayGlProgram();
- var atlas_tex: c_uint = 0;
- c.glGenTextures(1, &atlas_tex);
- c.glBindTexture(c.GL_TEXTURE_2D, atlas_tex);
- const filter = webGlFilter();
- c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_MIN_FILTER, filter);
- c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_MAG_FILTER, filter);
- 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 alloc.alloc(u8, atlas_w * atlas_h);
- @memset(atlas_stage, 0);
- const overlay_vertices = try alloc.alloc(OverlayVertex, max_overlay_vertices);
-
- var self: SdlBackend = .{
- .io = io,
- .alloc = alloc,
- .sdl_window = sdl_win,
- .device = undefined,
- .swapchain_format = undefined,
- .present_mode = c.SDL_GPU_PRESENTMODE_VSYNC,
- .pipeline = undefined,
- .overlay_pipeline = undefined,
- .atlas_tex = undefined,
- .atlas_sampler = undefined,
- .atlas_xfer = undefined,
- .vbuf = undefined,
- .vxfer = undefined,
- .overlay_vbuf = undefined,
- .overlay_vxfer = undefined,
- .gl_context = gl_context,
- .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"),
- .overlay_vertices = overlay_vertices,
- .font = font,
- .scale = scale,
- .cell_w = cell_w,
- .cell_h = cell_h,
- .ascent = ascent,
- .screen = .{},
- .atlas_stage = atlas_stage,
- .glyphs = std.AutoHashMap(u32, Slot).init(alloc),
- .test_mode = test_mode,
- .capture_images = false,
- .capture_dir = capture_dir,
- .trace_enabled = trace_enabled,
- .trace_fd = trace_fd,
- .trace_every = @max(trace_every, 1),
- };
- _ = c.ui_font_raster(font, scale, ' ', atlas_stage.ptr, @intCast(atlas_w), @intCast(self.cell_w), @intCast(self.cell_h), self.ascent);
- self.space_slot = .{ .u = 0, .v = 0 };
- self.pen_x = self.cell_w;
- self.tracef("sdl.init browser=webgl2 trace_every={d}", .{self.trace_every});
- return self;
-}
-
-// Headless debug init for PARDES_TEST_GRID=1: no SDL/GPU/window, just the cell
-// grid. Input comes from stdin (the pty), output goes to stdout (grid text
-// snapshots). Cell metrics are dummies: the pty gives us rows/cols directly.
-fn initTest(io: std.Io, alloc: std.mem.Allocator) !SdlBackend {
- return .{
- .io = io,
- .alloc = alloc,
- .sdl_window = undefined,
- .device = undefined,
- .swapchain_format = undefined,
- .present_mode = c.SDL_GPU_PRESENTMODE_VSYNC,
- .pipeline = undefined,
- .overlay_pipeline = undefined,
- .atlas_tex = undefined,
- .atlas_sampler = undefined,
- .atlas_xfer = undefined,
- .vbuf = undefined,
- .vxfer = undefined,
- .overlay_vbuf = undefined,
- .overlay_vxfer = undefined,
- .font = null,
- .scale = 1,
- .cell_w = 10,
- .cell_h = 20,
- .ascent = 16,
- .screen = .{},
- .atlas_stage = &.{},
- .glyphs = std.AutoHashMap(u32, Slot).init(alloc),
- .test_mode = true,
- .grid_test_mode = true,
- };
-}
-
-pub fn deinit(self: *SdlBackend, alloc: ?std.mem.Allocator, writer: *std.Io.Writer) void {
- _ = writer;
- if (self.grid_test_mode) {
- self.screen.deinit(self.alloc);
- self.glyphs.deinit();
- return;
- }
- if (is_emscripten) {
- if (self.gl_overlay_vbo != 0) c.glDeleteBuffers(1, &self.gl_overlay_vbo);
- if (self.gl_vbo != 0) c.glDeleteBuffers(1, &self.gl_vbo);
- if (self.gl_atlas_tex != 0) c.glDeleteTextures(1, &self.gl_atlas_tex);
- if (self.gl_overlay_program != 0) c.glDeleteProgram(self.gl_overlay_program);
- if (self.gl_program != 0) c.glDeleteProgram(self.gl_program);
- self.alloc.free(self.gl_instances);
- self.alloc.free(self.overlay_vertices);
- if (self.font) |f| c.ui_font_free(f);
- self.screen.deinit(self.alloc);
- self.glyphs.deinit();
- if (alloc) |a| a.free(self.atlas_stage);
- if (self.gl_context) |ctx| _ = c.SDL_GL_DestroyContext(ctx);
- c.SDL_DestroyWindow(self.sdl_window);
- c.SDL_Quit();
- return;
- }
- if (self.capture_tex) |t| c.SDL_ReleaseGPUTexture(self.device, t);
- if (self.capture_xfer) |x| c.SDL_ReleaseGPUTransferBuffer(self.device, x);
- if (self.trace_fd >= 0 and self.trace_fd != 2) closeFdBestEffort(self.trace_fd);
- c.SDL_ReleaseGPUBuffer(self.device, self.overlay_vbuf);
- c.SDL_ReleaseGPUTransferBuffer(self.device, self.overlay_vxfer);
- c.SDL_ReleaseGPUBuffer(self.device, self.vbuf);
- c.SDL_ReleaseGPUTransferBuffer(self.device, self.vxfer);
- self.alloc.free(self.overlay_vertices);
- self.alloc.free(self.cell_keys);
- self.alloc.free(self.dirty_ranges);
- c.SDL_ReleaseGPUTransferBuffer(self.device, self.atlas_xfer);
- c.SDL_ReleaseGPUSampler(self.device, self.atlas_sampler);
- c.SDL_ReleaseGPUTexture(self.device, self.atlas_tex);
- c.SDL_ReleaseGPUGraphicsPipeline(self.device, self.overlay_pipeline);
- c.SDL_ReleaseGPUGraphicsPipeline(self.device, self.pipeline);
- if (self.font) |f| c.ui_font_free(f);
- self.screen.deinit(self.alloc);
- self.glyphs.deinit();
- if (alloc) |a| a.free(self.atlas_stage);
- c.SDL_ReleaseWindowFromGPUDevice(self.device, self.sdl_window);
- c.SDL_DestroyWindow(self.sdl_window);
- c.SDL_DestroyGPUDevice(self.device);
- c.SDL_Quit();
-}
-
-// ---- vaxis-compatible surface ----
-
-pub fn resize(self: *SdlBackend, alloc: std.mem.Allocator, writer: *std.Io.Writer, ws: Winsize) !void {
- _ = writer;
- if (ws.cols == 0 or ws.rows == 0) return; // ignore zero-size (no pty yet)
- self.screen.deinit(alloc);
- self.screen = try vaxis.Screen.init(alloc, ws);
- self.screen.width_method = .wcwidth;
- if (self.capture_images) {
- _ = c.SDL_SetWindowSize(
- self.sdl_window,
- @intCast(@as(u32, ws.cols) * self.cell_w),
- @intCast(@as(u32, ws.rows) * self.cell_h),
- );
- _ = c.SDL_SyncWindow(self.sdl_window);
- }
- if (is_emscripten) return;
- if (!self.grid_test_mode) try self.ensureVbuf(ws.cols * ws.rows);
-}
-
-pub fn window(self: *SdlBackend) vaxis.Window {
- return .{
- .x_off = 0,
- .y_off = 0,
- .parent_x_off = 0,
- .parent_y_off = 0,
- .width = self.screen.width,
- .height = self.screen.height,
- .screen = &self.screen,
- };
-}
-
-pub fn render(self: *SdlBackend, writer: *std.Io.Writer) !void {
- _ = writer;
- if (!is_emscripten and self.grid_test_mode) return self.dumpGrid();
- if (is_emscripten) return self.renderGl();
-
- const trace_this = self.trace_enabled and (self.trace_frame % self.trace_every == 0);
- const t0 = nowNs();
- const cmd = c.SDL_AcquireGPUCommandBuffer(self.device) orelse return;
- const t_cmd = nowNs();
- var sw: u32 = 0;
- var sh: u32 = 0;
- var target: *c.SDL_GPUTexture = undefined;
- if (self.capture_images) {
- sw = @as(u32, self.screen.width) * self.cell_w;
- sh = @as(u32, self.screen.height) * self.cell_h;
- if (sw == 0 or sh == 0) {
- _ = c.SDL_SubmitGPUCommandBuffer(cmd);
- return;
- }
- try self.ensureCaptureTexture(sw, sh);
- target = self.capture_tex orelse {
- _ = c.SDL_SubmitGPUCommandBuffer(cmd);
- return;
- };
- } else {
- var swap_tex: ?*c.SDL_GPUTexture = null;
- const t_acquire0 = nowNs();
- if (!c.SDL_AcquireGPUSwapchainTexture(cmd, self.sdl_window, &swap_tex, &sw, &sh)) {
- _ = c.SDL_SubmitGPUCommandBuffer(cmd);
- return;
- }
- const t_acquire1 = nowNs();
- if (trace_this) self.tracef("sdl.event swapchain_acquire_us={d}", .{nsToUs(t_acquire1 - t_acquire0)});
- target = swap_tex orelse {
- if (trace_this) {
- self.tracef("sdl.frame n={d} skipped=swapchain_busy acquire_us={d}", .{ self.trace_frame, nsToUs(t_acquire1 - t_acquire0) });
- }
- self.trace_frame += 1;
- _ = c.SDL_SubmitGPUCommandBuffer(cmd);
- return;
- };
- }
- const t_acquired = nowNs();
- const win_w: f32 = @floatFromInt(sw);
- const win_h: f32 = @floatFromInt(sh);
- const overlay_vertex_count = self.buildTouchOverlay(sw, sh);
- if (overlay_vertex_count != 0) self.uploadOverlayGpu(cmd, overlay_vertex_count);
-
- const cols = self.screen.width;
- const rows = self.screen.height;
- const cells: u32 = @as(u32, cols) * @as(u32, rows);
- var t_mapped = t_acquired;
- var t_built = t_acquired;
- var t_atlas = t_acquired;
- var t_uploaded = t_acquired;
- var t_drawn = t_acquired;
- var dirty_cells: u32 = 0;
- var dirty_ranges: u32 = 0;
- var atlas_uploaded = false;
-
- // 1. build + upload the vertex buffer (one quad per cell).
- if (cells != 0 and self.vbuf_cells >= cells) {
- try self.ensureVbuf(cells); // no-op if already sized; safe guard
- const force_full_upload = !self.cell_cache_valid or self.last_render_w != sw or self.last_render_h != sh;
- const vptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(self.device, self.vxfer, false) orelse {
- _ = c.SDL_SubmitGPUCommandBuffer(cmd);
- return;
- });
- const instances: [*]CellInstance = @ptrCast(@alignCast(vptr));
- t_mapped = nowNs();
- const layout = self.fixedCellLayout();
- var r: u16 = 0;
- while (r < rows) : (r += 1) {
- var col: u16 = 0;
- while (col < cols) : (col += 1) {
- const cell_index: u32 = @as(u32, r) * @as(u32, cols) + @as(u32, col);
- const key = self.cellKey(self.screen.buf[cell_index]);
- if (!force_full_upload and sameCellKey(self.cell_keys[cell_index], key)) continue;
- self.cell_keys[cell_index] = key;
- self.addDirtyRange(&dirty_ranges, cell_index);
- dirty_cells += 1;
- self.emitInstance(instances, cell_index, col, r, layout, win_w, win_h, key);
- }
- }
- c.SDL_UnmapGPUTransferBuffer(self.device, self.vxfer);
- t_built = nowNs();
- self.cell_cache_valid = true;
- self.last_render_w = sw;
- self.last_render_h = sh;
-
- // `emitQuad` calls ensureGlyph, which may rasterize new glyphs into the
- // atlas staging buffer. Upload after vertex generation so the first
- // rendered frame has the glyphs it references.
- if (self.atlas_dirty) {
- self.uploadAtlas(cmd);
- atlas_uploaded = true;
- }
- t_atlas = nowNs();
-
- if (dirty_cells != 0) {
- const copy = c.SDL_BeginGPUCopyPass(cmd);
- var i: u32 = 0;
- while (i < dirty_ranges) : (i += 1) {
- const range = self.dirty_ranges[i];
- const byte_offset: u32 = range.start * @sizeOf(CellInstance);
- const byte_size: u32 = range.count * @sizeOf(CellInstance);
- const src = c.SDL_GPUTransferBufferLocation{ .transfer_buffer = self.vxfer, .offset = byte_offset };
- const dst = c.SDL_GPUBufferRegion{ .buffer = self.vbuf, .offset = byte_offset, .size = byte_size };
- c.SDL_UploadToGPUBuffer(copy, &src, &dst, false);
- }
- c.SDL_EndGPUCopyPass(copy);
- }
- t_uploaded = nowNs();
-
- // 3. render pass: clear to the page bg, draw every cell's quad.
- var color_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo);
- color_target.texture = target;
- color_target.clear_color = .{
- .r = @as(f32, @floatFromInt(self.bg_default[0])) / 255.0,
- .g = @as(f32, @floatFromInt(self.bg_default[1])) / 255.0,
- .b = @as(f32, @floatFromInt(self.bg_default[2])) / 255.0,
- .a = 1.0,
- };
- color_target.load_op = c.SDL_GPU_LOADOP_CLEAR;
- color_target.store_op = c.SDL_GPU_STOREOP_STORE;
-
- const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null);
- c.SDL_BindGPUGraphicsPipeline(rp, self.pipeline);
- const samp_binding = c.SDL_GPUTextureSamplerBinding{ .texture = self.atlas_tex, .sampler = self.atlas_sampler };
- c.SDL_BindGPUFragmentSamplers(rp, 0, &samp_binding, 1);
- const vbinding = c.SDL_GPUBufferBinding{ .buffer = self.vbuf, .offset = 0 };
- c.SDL_BindGPUVertexBuffers(rp, 0, &vbinding, 1);
- c.SDL_DrawGPUPrimitives(rp, 6, cells, 0, 0);
- self.drawOverlayGpu(rp, overlay_vertex_count);
- c.SDL_EndGPURenderPass(rp);
- t_drawn = nowNs();
- } else {
- // no grid yet (or vbuf not sized): just clear the swapchain to the page bg.
- var color_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo);
- color_target.texture = target;
- color_target.clear_color = .{
- .r = @as(f32, @floatFromInt(self.bg_default[0])) / 255.0,
- .g = @as(f32, @floatFromInt(self.bg_default[1])) / 255.0,
- .b = @as(f32, @floatFromInt(self.bg_default[2])) / 255.0,
- .a = 1.0,
- };
- color_target.load_op = c.SDL_GPU_LOADOP_CLEAR;
- color_target.store_op = c.SDL_GPU_STOREOP_STORE;
- const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null);
- self.drawOverlayGpu(rp, overlay_vertex_count);
- c.SDL_EndGPURenderPass(rp);
- t_drawn = nowNs();
- }
-
- if (self.capture_images) {
- const t_capture0 = nowNs();
- try self.captureSwapchain(cmd, target, sw, sh);
- const t_done = nowNs();
- if (trace_this) self.traceFrame(cells, dirty_cells, dirty_ranges, sw, sh, atlas_uploaded, t0, t_cmd, t_acquired, t_mapped, t_built, t_atlas, t_uploaded, t_drawn, t_capture0, t_done);
- self.trace_frame += 1;
- return;
- }
-
- const t_submit0 = nowNs();
- _ = c.SDL_SubmitGPUCommandBuffer(cmd);
- const t_done = nowNs();
- if (trace_this) self.traceFrame(cells, dirty_cells, dirty_ranges, sw, sh, atlas_uploaded, t0, t_cmd, t_acquired, t_mapped, t_built, t_atlas, t_uploaded, t_drawn, t_submit0, t_done);
- self.trace_frame += 1;
-}
-
-fn renderGl(self: *SdlBackend) !void {
- var pw: c_int = 0;
- var ph: c_int = 0;
- _ = c.SDL_GetWindowSizeInPixels(self.sdl_window, &pw, &ph);
- const sw: u32 = @max(1, @as(u32, @intCast(pw)));
- const sh: u32 = @max(1, @as(u32, @intCast(ph)));
- c.glViewport(0, 0, @intCast(sw), @intCast(sh));
- c.glClearColor(
- @as(f32, @floatFromInt(self.bg_default[0])) / 255.0,
- @as(f32, @floatFromInt(self.bg_default[1])) / 255.0,
- @as(f32, @floatFromInt(self.bg_default[2])) / 255.0,
- 1.0,
- );
- c.glClear(c.GL_COLOR_BUFFER_BIT);
-
- const cols = self.screen.width;
- const rows = self.screen.height;
- const cells: u32 = @as(u32, cols) * @as(u32, rows);
- const overlay_vertex_count = self.buildTouchOverlay(sw, sh);
- if (cells == 0) {
- self.drawOverlayGl(overlay_vertex_count);
- _ = c.SDL_GL_SwapWindow(self.sdl_window);
- return;
- }
-
- try self.ensureGlInstances(cells);
- const layout = self.cellLayout(sw, sh);
- const win_w: f32 = @floatFromInt(sw);
- const win_h: f32 = @floatFromInt(sh);
- var r: u16 = 0;
- while (r < rows) : (r += 1) {
- var col: u16 = 0;
- while (col < cols) : (col += 1) {
- const cell_index: u32 = @as(u32, r) * @as(u32, cols) + @as(u32, col);
- self.emitInstance(self.gl_instances.ptr, cell_index, col, r, layout, win_w, win_h, self.cellKey(self.screen.buf[cell_index]));
- }
- }
- if (self.atlas_dirty) self.uploadAtlasGl();
-
- c.glUseProgram(self.gl_program);
- c.glActiveTexture(c.GL_TEXTURE0);
- c.glBindTexture(c.GL_TEXTURE_2D, self.gl_atlas_tex);
- c.glUniform1i(self.gl_u_atlas, 0);
-
- c.glBindBuffer(c.GL_ARRAY_BUFFER, self.gl_vbo);
- c.glBufferData(c.GL_ARRAY_BUFFER, @intCast(cells * @sizeOf(CellInstance)), self.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));
- self.drawOverlayGl(overlay_vertex_count);
- _ = c.SDL_GL_SwapWindow(self.sdl_window);
-}
-
-fn fixedCellLayout(self: *const SdlBackend) CellLayout {
- return .{
- .w = @floatFromInt(self.cell_w),
- .h = @floatFromInt(self.cell_h),
- .x_off = 0,
- .y_off = 0,
- };
-}
-
-fn cellLayout(self: *const SdlBackend, sw: u32, sh: u32) CellLayout {
- const fixed = self.fixedCellLayout();
- if (!is_emscripten) return fixed;
-
- const cols = self.screen.width;
- const rows = self.screen.height;
- if (cols == 0 or rows == 0) return fixed;
-
- const cols_f: f32 = @floatFromInt(cols);
- const rows_f: f32 = @floatFromInt(rows);
- const grid_w = cols_f * fixed.w;
- const grid_h = rows_f * fixed.h;
- if (grid_w <= 0 or grid_h <= 0) return fixed;
-
- const win_w: f32 = @floatFromInt(sw);
- const win_h: f32 = @floatFromInt(sh);
- const scale = @min(win_w / grid_w, win_h / grid_h);
- const scaled_grid_w = grid_w * scale;
- const scaled_grid_h = grid_h * scale;
- return .{
- .w = fixed.w * scale,
- .h = fixed.h * scale,
- .x_off = if (win_w > scaled_grid_w) (win_w - scaled_grid_w) * 0.5 else 0,
- .y_off = if (win_h > scaled_grid_h) (win_h - scaled_grid_h) * 0.5 else 0,
- };
-}
-
-fn fixedMouseCell(self: *const SdlBackend, x: f32, y: f32) MouseCell {
- return .{
- .col = @as(i16, @intCast(@divTrunc(@as(i32, @intFromFloat(x)), @as(i32, @intCast(self.cell_w))))),
- .row = @as(i16, @intCast(@divTrunc(@as(i32, @intFromFloat(y)), @as(i32, @intCast(self.cell_h))))),
- };
-}
-
-fn floorToI16(value: f32) i16 {
- const floored = @floor(value);
- const min: f32 = @floatFromInt(std.math.minInt(i16));
- const max: f32 = @floatFromInt(std.math.maxInt(i16));
- if (floored <= min) return std.math.minInt(i16);
- if (floored >= max) return std.math.maxInt(i16);
- return @intFromFloat(floored);
-}
-
-fn mousePixel(self: *const SdlBackend, x: f32, y: f32, pixel_w: u32, pixel_h: u32) struct { x: f32, y: f32 } {
- if (!is_emscripten) return .{ .x = x, .y = y };
-
- var ww: c_int = 0;
- var wh: c_int = 0;
- _ = c.SDL_GetWindowSize(self.sdl_window, &ww, &wh);
- if (ww <= 0 or wh <= 0) return .{ .x = x, .y = y };
-
- return .{
- .x = x * (@as(f32, @floatFromInt(pixel_w)) / @as(f32, @floatFromInt(ww))),
- .y = y * (@as(f32, @floatFromInt(pixel_h)) / @as(f32, @floatFromInt(wh))),
- };
-}
-
-fn mouseCell(self: *const SdlBackend, x: f32, y: f32) MouseCell {
- if (!is_emscripten) return self.fixedMouseCell(x, y);
-
- var pw: c_int = 0;
- var ph: c_int = 0;
- _ = c.SDL_GetWindowSizeInPixels(self.sdl_window, &pw, &ph);
- const sw: u32 = @max(1, @as(u32, @intCast(pw)));
- const sh: u32 = @max(1, @as(u32, @intCast(ph)));
- const layout = self.cellLayout(sw, sh);
- const pixel = self.mousePixel(x, y, sw, sh);
- return .{
- .col = floorToI16((pixel.x - layout.x_off) / layout.w),
- .row = floorToI16((pixel.y - layout.y_off) / layout.h),
- };
-}
-
-fn clampF32(value: f32, lo: f32, hi: f32) f32 {
- if (value < lo) return lo;
- if (value > hi) return hi;
- return value;
-}
-
-fn findTouchSlot(self: *const SdlBackend, id: u64) ?usize {
- var i: usize = 0;
- while (i < self.touches.len) : (i += 1) {
- if (self.touches[i].active and self.touches[i].id == id) return i;
- }
- return null;
-}
-
-fn inactiveTouchSlot(self: *SdlBackend) ?usize {
- var i: usize = 0;
- while (i < self.touches.len) : (i += 1) {
- if (!self.touches[i].active) return i;
- }
- return null;
-}
-
-fn sortedTouchPair(a: u64, b: u64) [2]u64 {
- return if (a <= b) .{ a, b } else .{ b, a };
-}
-
-fn sameTouchPair(a: [2]u64, b: [2]u64) bool {
- return a[0] == b[0] and a[1] == b[1];
-}
-
-fn touchPairHas(ids: [2]u64, id: u64) bool {
- return ids[0] == id or ids[1] == id;
-}
-
-fn activeTouchPair(self: *const SdlBackend) ?[2]u64 {
- var ids: [2]u64 = .{ 0, 0 };
- var count: usize = 0;
- for (self.touches) |tp| {
- if (!tp.active) continue;
- if (count >= 2) return null;
- ids[count] = tp.id;
- count += 1;
- }
- return if (count == 2) sortedTouchPair(ids[0], ids[1]) else null;
-}
-
-fn touchPairCenter(self: *const SdlBackend, ids: [2]u64) ?TouchNormPoint {
- var sum_x: f32 = 0;
- var sum_y: f32 = 0;
- var count: usize = 0;
- for (ids) |id| {
- const idx = self.findTouchSlot(id) orelse return null;
- sum_x += self.touches[idx].x;
- sum_y += self.touches[idx].y;
- count += 1;
- }
- if (count != 2) return null;
- return .{ .x = sum_x * 0.5, .y = sum_y * 0.5 };
-}
-
-fn touchPairCenterWithEvent(self: *const SdlBackend, ids: [2]u64, tfinger: c.SDL_TouchFingerEvent) ?TouchNormPoint {
- const event_id: u64 = @intCast(tfinger.fingerID);
- var sum_x: f32 = 0;
- var sum_y: f32 = 0;
- var count: usize = 0;
- for (ids) |id| {
- if (id == event_id) {
- sum_x += tfinger.x;
- sum_y += tfinger.y;
- } else {
- const idx = self.findTouchSlot(id) orelse return null;
- sum_x += self.touches[idx].x;
- sum_y += self.touches[idx].y;
- }
- count += 1;
- }
- if (count != 2) return null;
- return .{ .x = sum_x * 0.5, .y = sum_y * 0.5 };
-}
-
-fn resetTouchScroll(self: *SdlBackend) void {
- self.touch_scroll = .{};
-}
-
-fn beginTouchScrollPair(self: *SdlBackend, ids: [2]u64) void {
- self.touch_scroll = .{
- .active = true,
- .ids = ids,
- .last_center = self.touchPairCenter(ids) orelse .{},
- };
-}
-
-fn syncTouchScrollPair(self: *SdlBackend) void {
- const ids = self.activeTouchPair() orelse {
- self.resetTouchScroll();
- return;
- };
- if (!self.touch_scroll.active or !sameTouchPair(self.touch_scroll.ids, ids)) {
- self.beginTouchScrollPair(ids);
- }
-}
-
-fn finishTouchScrollPair(self: *SdlBackend, tfinger: c.SDL_TouchFingerEvent) ?TouchNormPoint {
- const id: u64 = @intCast(tfinger.fingerID);
- if (!self.touch_scroll.active or !touchPairHas(self.touch_scroll.ids, id)) return null;
-
- const tap = tfinger.type == c.SDL_EVENT_FINGER_UP and !self.touch_scroll.scrolled;
- const center = if (tap)
- self.touchPairCenterWithEvent(self.touch_scroll.ids, tfinger) orelse self.touch_scroll.last_center
- else
- null;
- self.resetTouchScroll();
- return center;
-}
-
-fn noteTouchScrollMotion(self: *SdlBackend, tfinger: c.SDL_TouchFingerEvent) ?TouchScrollResult {
- const id: u64 = @intCast(tfinger.fingerID);
- if (!self.touch_scroll.active or !touchPairHas(self.touch_scroll.ids, id)) return null;
-
- const prev_center = self.touch_scroll.last_center;
- const center = self.touchPairCenter(self.touch_scroll.ids) orelse self.touch_scroll.last_center;
- self.touch_scroll.last_center = center;
- const delta_y = center.y - prev_center.y;
- self.touch_scroll.accum_y += delta_y;
-
- var ticks: i32 = 0;
- while (self.touch_scroll.accum_y >= touch_scroll_tick) {
- self.touch_scroll.accum_y -= touch_scroll_tick;
- ticks += 1;
- }
- while (self.touch_scroll.accum_y <= -touch_scroll_tick) {
- self.touch_scroll.accum_y += touch_scroll_tick;
- ticks -= 1;
- }
- if (ticks == 0) return null;
-
- self.touch_scroll.scrolled = true;
- return .{
- .center = center,
- .ticks = ticks,
- .delta_y = @as(f32, @floatFromInt(ticks)) * touch_scroll_tick,
- };
-}
-
-fn updateTouch(self: *SdlBackend, tfinger: c.SDL_TouchFingerEvent) void {
- const id: u64 = @intCast(tfinger.fingerID);
- switch (tfinger.type) {
- c.SDL_EVENT_FINGER_UP, c.SDL_EVENT_FINGER_CANCELED => {
- if (self.findTouchSlot(id)) |idx| self.touches[idx].active = false;
- },
- else => {
- const n: u64 = self.touches.len;
- const idx = self.findTouchSlot(id) orelse inactiveTouchSlot(self) orelse @as(usize, @intCast(id % n));
- if (!self.touches[idx].active or self.touches[idx].id != id) {
- self.touches[idx] = .{ .active = true, .id = id };
- } else {
- self.touches[idx].active = true;
- }
- self.touches[idx].x = tfinger.x;
- self.touches[idx].y = tfinger.y;
- self.touches[idx].pressure = tfinger.pressure;
- self.touches[idx].pushTrail(tfinger.x, tfinger.y, tfinger.pressure);
- },
- }
-}
-
-fn noteTouchMiddleClick(self: *SdlBackend, is_press: bool) void {
- if (is_press) self.touch_middle_clicks +%= 1;
- self.touch_middle_flash_frames = touch_middle_flash_max_frames;
-}
-
-fn fingerMousePoint(self: *const SdlBackend, x_norm: f32, y_norm: f32) struct { x: f32, y: f32 } {
- var ww: c_int = 0;
- var wh: c_int = 0;
- if (is_emscripten) {
- _ = c.SDL_GetWindowSize(self.sdl_window, &ww, &wh);
- } else {
- _ = c.SDL_GetWindowSizeInPixels(self.sdl_window, &ww, &wh);
- }
- const w: f32 = @floatFromInt(if (ww > 0) ww else 1);
- const h: f32 = @floatFromInt(if (wh > 0) wh else 1);
- return .{
- .x = clampF32(x_norm, 0.0, 1.0) * w,
- .y = clampF32(y_norm, 0.0, 1.0) * h,
- };
-}
-
-fn touchColor(id: u64) OverlayColor {
- const palette = [_]OverlayColor{
- .{ .r = 0.10, .g = 0.72, .b = 1.00, .a = 0.88 },
- .{ .r = 1.00, .g = 0.32, .b = 0.50, .a = 0.88 },
- .{ .r = 0.35, .g = 0.90, .b = 0.38, .a = 0.88 },
- .{ .r = 1.00, .g = 0.78, .b = 0.18, .a = 0.88 },
- .{ .r = 0.78, .g = 0.52, .b = 1.00, .a = 0.88 },
- .{ .r = 0.10, .g = 0.88, .b = 0.75, .a = 0.88 },
- .{ .r = 1.00, .g = 0.48, .b = 0.18, .a = 0.88 },
- .{ .r = 0.78, .g = 0.90, .b = 1.00, .a = 0.88 },
- };
- const n: u64 = palette.len;
- return palette[@intCast(id % n)];
-}
-
-fn buildTouchOverlay(self: *SdlBackend, sw: u32, sh: u32) u32 {
- if (self.overlay_vertices.len == 0 or sw == 0 or sh == 0) return 0;
-
- const win_w: f32 = @floatFromInt(sw);
- const win_h: f32 = @floatFromInt(sh);
- const ui_scale: f32 = if (is_emscripten) web_render_scale else 1.0;
- var builder = OverlayBuilder{
- .vertices = self.overlay_vertices,
- .win_w = win_w,
- .win_h = win_h,
- };
-
- if (self.touch_middle_flash_frames != 0) {
- const flash = @as(f32, @floatFromInt(self.touch_middle_flash_frames)) / @as(f32, @floatFromInt(touch_middle_flash_max_frames));
- const label = touchMiddleClickLabel(self.touch_middle_clicks);
- const glyph_scale = 3.0 * ui_scale;
- const char_step = 4.0 * glyph_scale;
- const pad = 5.0 * ui_scale;
- const stripe_w = 4.0 * ui_scale;
- const label_w = pad * 2.0 + stripe_w + 3.0 * ui_scale + @as(f32, @floatFromInt(label.len)) * char_step - glyph_scale;
- const label_h = pad * 2.0 + 5.0 * glyph_scale;
- const x = 8.0 * ui_scale;
- const y = 8.0 * ui_scale;
-
- builder.addRect(0.0, 0.0, win_w, win_h, .{ .r = 1.0, .g = 0.78, .b = 0.18, .a = 0.055 * flash });
- builder.addRect(x, y, x + label_w, y + label_h, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.54 + 0.22 * flash });
- builder.addRect(x + pad, y + pad, x + pad + stripe_w, y + label_h - pad, .{ .r = 1.0, .g = 0.78, .b = 0.18, .a = 0.96 });
- builder.addMiniText(label[0..], x + pad + stripe_w + 3.0 * ui_scale, y + pad, glyph_scale, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.96 });
- self.touch_middle_flash_frames -= 1;
- }
-
- for (self.touches) |tp| {
- if (!tp.active) continue;
- const cx = clampF32(tp.x, 0.0, 1.0) * win_w;
- const cy = clampF32(tp.y, 0.0, 1.0) * win_h;
- const radius = (12.0 + clampF32(tp.pressure, 0.0, 1.0) * 4.0) * 4.0 * ui_scale;
- const color = touchColor(tp.id);
-
- if (tp.trail_len > 1) {
- const first = if (tp.trail_len == max_touch_trail_points) tp.trail_next else 0;
- const trail_count = tp.trail_len - 1;
- var trail_i: usize = 0;
- while (trail_i < trail_count) : (trail_i += 1) {
- const sample = tp.trail[(first + trail_i) % max_touch_trail_points];
- const fade = @as(f32, @floatFromInt(trail_i + 1)) / @as(f32, @floatFromInt(tp.trail_len));
- var trail_color = color;
- trail_color.a *= 0.42 * fade;
- const tx = clampF32(sample.x, 0.0, 1.0) * win_w;
- const ty = clampF32(sample.y, 0.0, 1.0) * win_h;
- const trail_radius = (12.0 + clampF32(sample.pressure, 0.0, 1.0) * 4.0) * 2.8 * ui_scale;
- builder.addCircle(tx, ty, trail_radius, trail_color);
- }
- }
-
- builder.addCircle(cx, cy, radius + 4.0 * ui_scale, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.62 });
- builder.addCircle(cx, cy, radius, color);
- builder.addCircle(cx, cy, 2.4 * ui_scale, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.92 });
- builder.addIdTag(tp.id, cx, cy, ui_scale, color);
- }
-
- return @intCast(builder.len);
-}
-
-fn uploadOverlayGpu(self: *SdlBackend, cmd: *c.SDL_GPUCommandBuffer, vertex_count: u32) void {
- if (vertex_count == 0 or is_emscripten) return;
- const byte_len: u32 = vertex_count * @sizeOf(OverlayVertex);
- const ptr = c.SDL_MapGPUTransferBuffer(self.device, self.overlay_vxfer, false) orelse return;
- const dst: [*]u8 = @ptrCast(ptr);
- const src: [*]const u8 = @ptrCast(self.overlay_vertices.ptr);
- @memcpy(dst[0..byte_len], src[0..byte_len]);
- c.SDL_UnmapGPUTransferBuffer(self.device, self.overlay_vxfer);
-
- const copy = c.SDL_BeginGPUCopyPass(cmd);
- const src_loc = c.SDL_GPUTransferBufferLocation{ .transfer_buffer = self.overlay_vxfer, .offset = 0 };
- const dst_region = c.SDL_GPUBufferRegion{ .buffer = self.overlay_vbuf, .offset = 0, .size = byte_len };
- c.SDL_UploadToGPUBuffer(copy, &src_loc, &dst_region, false);
- c.SDL_EndGPUCopyPass(copy);
-}
-
-fn drawOverlayGpu(self: *SdlBackend, rp: ?*c.SDL_GPURenderPass, vertex_count: u32) void {
- if (vertex_count == 0 or is_emscripten) return;
- const pass = rp orelse return;
- c.SDL_BindGPUGraphicsPipeline(pass, self.overlay_pipeline);
- const binding = c.SDL_GPUBufferBinding{ .buffer = self.overlay_vbuf, .offset = 0 };
- c.SDL_BindGPUVertexBuffers(pass, 0, &binding, 1);
- c.SDL_DrawGPUPrimitives(pass, vertex_count, 1, 0, 0);
-}
-
-fn drawOverlayGl(self: *SdlBackend, vertex_count: u32) void {
- if (!is_emscripten or vertex_count == 0) return;
- c.glUseProgram(self.gl_overlay_program);
- c.glBindBuffer(c.GL_ARRAY_BUFFER, self.gl_overlay_vbo);
- c.glBufferData(c.GL_ARRAY_BUFFER, @intCast(vertex_count * @sizeOf(OverlayVertex)), self.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);
-}
-
-const OverlayBuilder = struct {
- vertices: []OverlayVertex,
- len: usize = 0,
- win_w: f32,
- win_h: f32,
-
- fn addVertex(self: *OverlayBuilder, px: f32, py: f32, color: OverlayColor) void {
- if (self.len >= self.vertices.len) return;
- self.vertices[self.len] = .{
- .x = (px / self.win_w) * 2.0 - 1.0,
- .y = 1.0 - (py / self.win_h) * 2.0,
- .r = color.r,
- .g = color.g,
- .b = color.b,
- .a = color.a,
- };
- self.len += 1;
- }
-
- fn addTri(self: *OverlayBuilder, x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32, color: OverlayColor) void {
- if (self.len + 3 > self.vertices.len) return;
- self.addVertex(x0, y0, color);
- self.addVertex(x1, y1, color);
- self.addVertex(x2, y2, color);
- }
-
- fn addRect(self: *OverlayBuilder, x0_in: f32, y0_in: f32, x1_in: f32, y1_in: f32, color: OverlayColor) void {
- if (self.len + 6 > self.vertices.len) return;
- const x0 = clampF32(@min(x0_in, x1_in), 0.0, self.win_w);
- const x1 = clampF32(@max(x0_in, x1_in), 0.0, self.win_w);
- const y0 = clampF32(@min(y0_in, y1_in), 0.0, self.win_h);
- const y1 = clampF32(@max(y0_in, y1_in), 0.0, self.win_h);
- if (x1 <= x0 or y1 <= y0) return;
- self.addTri(x0, y0, x1, y0, x1, y1, color);
- self.addTri(x0, y0, x1, y1, x0, y1, color);
- }
-
- fn addCircle(self: *OverlayBuilder, cx: f32, cy: f32, radius: f32, color: OverlayColor) void {
- var i: usize = 0;
- while (i + 1 < circle_points.len) : (i += 1) {
- const p0 = circle_points[i];
- const p1 = circle_points[i + 1];
- self.addTri(
- cx,
- cy,
- cx + p0[0] * radius,
- cy + p0[1] * radius,
- cx + p1[0] * radius,
- cy + p1[1] * radius,
- color,
- );
- }
- }
-
- fn addIdTag(self: *OverlayBuilder, id: u64, cx: f32, cy: f32, ui_scale: f32, color: OverlayColor) void {
- const label = touchIdLabel(id);
- const glyph_scale = 2.0 * ui_scale;
- const char_step = 4.0 * glyph_scale;
- const pad = 4.0 * ui_scale;
- const stripe_w = 3.0 * ui_scale;
- const label_w = pad * 2.0 + stripe_w + 3.0 * ui_scale + @as(f32, @floatFromInt(label.len)) * char_step - glyph_scale;
- const label_h = pad * 2.0 + 5.0 * glyph_scale;
-
- var x = cx + 20.0 * ui_scale;
- if (x + label_w > self.win_w - 2.0 * ui_scale) x = cx - label_w - 20.0 * ui_scale;
- x = clampF32(x, 2.0 * ui_scale, @max(2.0 * ui_scale, self.win_w - label_w - 2.0 * ui_scale));
-
- var y = cy - label_h - 10.0 * ui_scale;
- if (y < 2.0 * ui_scale) y = cy + 20.0 * ui_scale;
- y = clampF32(y, 2.0 * ui_scale, @max(2.0 * ui_scale, self.win_h - label_h - 2.0 * ui_scale));
-
- self.addRect(x, y, x + label_w, y + label_h, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.72 });
- self.addRect(x + pad, y + pad, x + pad + stripe_w, y + label_h - pad, color);
- self.addMiniText(label[0..], x + pad + stripe_w + 3.0 * ui_scale, y + pad, glyph_scale, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.95 });
- }
-
- fn addMiniText(self: *OverlayBuilder, text: []const u8, x: f32, y: f32, scale: f32, color: OverlayColor) void {
- var cx = x;
- for (text) |ch| {
- self.addMiniGlyph(ch, cx, y, scale, color);
- cx += 4.0 * scale;
- }
- }
-
- fn addMiniGlyph(self: *OverlayBuilder, ch: u8, x: f32, y: f32, scale: f32, color: OverlayColor) void {
- const rows = miniGlyph(ch);
- const masks = [_]u8{ 0b100, 0b010, 0b001 };
- var row: usize = 0;
- while (row < rows.len) : (row += 1) {
- var col: usize = 0;
- while (col < masks.len) : (col += 1) {
- if ((rows[row] & masks[col]) == 0) continue;
- const x0 = x + @as(f32, @floatFromInt(col)) * scale;
- const y0 = y + @as(f32, @floatFromInt(row)) * scale;
- self.addRect(x0, y0, x0 + scale, y0 + scale, color);
- }
- }
- }
-};
-
-const circle_points = [_][2]f32{
- .{ 1.0000000, 0.0000000 },
- .{ 0.9238795, 0.3826834 },
- .{ 0.7071068, 0.7071068 },
- .{ 0.3826834, 0.9238795 },
- .{ 0.0000000, 1.0000000 },
- .{ -0.3826834, 0.9238795 },
- .{ -0.7071068, 0.7071068 },
- .{ -0.9238795, 0.3826834 },
- .{ -1.0000000, 0.0000000 },
- .{ -0.9238795, -0.3826834 },
- .{ -0.7071068, -0.7071068 },
- .{ -0.3826834, -0.9238795 },
- .{ 0.0000000, -1.0000000 },
- .{ 0.3826834, -0.9238795 },
- .{ 0.7071068, -0.7071068 },
- .{ 0.9238795, -0.3826834 },
- .{ 1.0000000, 0.0000000 },
-};
-
-fn touchIdLabel(id: u64) [9]u8 {
- var out: [9]u8 = undefined;
- out[0] = '#';
- const low: u32 = @truncate(id);
- var i: usize = 0;
- while (i < 8) : (i += 1) {
- const shift: u5 = @intCast((7 - i) * 4);
- out[i + 1] = hexDigit((low >> shift) & 0xf);
- }
- return out;
-}
-
-fn touchMiddleClickLabel(count: u32) [13]u8 {
- var out: [13]u8 = undefined;
- out[0] = 'M';
- out[1] = 'I';
- out[2] = 'D';
- out[3] = ' ';
- out[4] = '#';
- var i: usize = 0;
- while (i < 8) : (i += 1) {
- const shift: u5 = @intCast((7 - i) * 4);
- out[i + 5] = hexDigit((count >> shift) & 0xf);
- }
- return out;
-}
-
-fn hexDigit(n: u32) u8 {
- return if (n < 10) @intCast('0' + n) else @intCast('A' + (n - 10));
-}
-
-fn miniGlyph(ch: u8) [5]u8 {
- return switch (ch) {
- '#' => .{ 0b101, 0b111, 0b101, 0b111, 0b101 },
- '0' => .{ 0b111, 0b101, 0b101, 0b101, 0b111 },
- '1' => .{ 0b010, 0b110, 0b010, 0b010, 0b111 },
- '2' => .{ 0b111, 0b001, 0b111, 0b100, 0b111 },
- '3' => .{ 0b111, 0b001, 0b111, 0b001, 0b111 },
- '4' => .{ 0b101, 0b101, 0b111, 0b001, 0b001 },
- '5' => .{ 0b111, 0b100, 0b111, 0b001, 0b111 },
- '6' => .{ 0b111, 0b100, 0b111, 0b101, 0b111 },
- '7' => .{ 0b111, 0b001, 0b010, 0b010, 0b010 },
- '8' => .{ 0b111, 0b101, 0b111, 0b101, 0b111 },
- '9' => .{ 0b111, 0b101, 0b111, 0b001, 0b111 },
- 'A' => .{ 0b111, 0b101, 0b111, 0b101, 0b101 },
- 'B' => .{ 0b110, 0b101, 0b110, 0b101, 0b110 },
- 'C' => .{ 0b111, 0b100, 0b100, 0b100, 0b111 },
- 'D' => .{ 0b110, 0b101, 0b101, 0b101, 0b110 },
- 'E' => .{ 0b111, 0b100, 0b111, 0b100, 0b111 },
- 'F' => .{ 0b111, 0b100, 0b111, 0b100, 0b100 },
- 'I' => .{ 0b111, 0b010, 0b010, 0b010, 0b111 },
- 'M' => .{ 0b101, 0b111, 0b111, 0b101, 0b101 },
- else => .{ 0, 0, 0, 0, 0 },
- };
-}
-
-// PARDES_TEST_GRID debug mode: dump the retained cell grid as text to stdout
-// (fd 1). One frame per render, delimited by ---FRAME---/---ENDFRAME--- markers.
-fn dumpGrid(self: *SdlBackend) !void {
- const w = self.screen.width;
- const h = self.screen.height;
- if (w == 0 or h == 0) return;
-
- // Build the whole frame into one buffer, then write it in one syscall so
- // the harness sees an atomic snapshot (no torn frames).
- var buf: std.ArrayList(u8) = .empty;
- defer buf.deinit(self.alloc);
- // header: rows cols cursor_x cursor_y cursor_vis
- var hdr: [128]u8 = undefined;
- const hdr_str = std.fmt.bufPrint(&hdr, "\n---FRAME:{d}:{d}:{d}:{d}:{}---\n", .{
- h, w, self.screen.cursor.col, self.screen.cursor.row, self.screen.cursor_vis,
- }) catch return;
- buf.appendSlice(self.alloc, hdr_str) catch return;
- for (0..h) |row| {
- for (0..w) |col| {
- const cell = self.screen.buf[row * w + col];
- const g = cell.char.grapheme;
- if (g.len > 0) buf.appendSlice(self.alloc, g) catch return else buf.append(self.alloc, ' ') catch return;
- }
- buf.append(self.alloc, '\n') catch return;
- }
- buf.appendSlice(self.alloc, "---ENDFRAME---\n") catch return;
-
- try writeAllFd(1, buf.items);
-}
-
-fn captureSwapchain(self: *SdlBackend, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTexture, sw: u32, sh: u32) !void {
- if (sw == 0 or sh == 0) {
- _ = c.SDL_SubmitGPUCommandBuffer(cmd);
- return;
- }
- const size = c.SDL_CalculateGPUTextureFormatSize(self.swapchain_format, sw, sh, 1);
- if (size == 0) {
- _ = c.SDL_SubmitGPUCommandBuffer(cmd);
- return error.UnsupportedCaptureFormat;
- }
- try self.ensureCaptureXfer(size);
-
- const xfer = self.capture_xfer orelse {
- _ = c.SDL_SubmitGPUCommandBuffer(cmd);
- return error.GpuCreate;
- };
- const copy = c.SDL_BeginGPUCopyPass(cmd);
- const src = c.SDL_GPUTextureRegion{
- .texture = target,
- .mip_level = 0,
- .layer = 0,
- .x = 0,
- .y = 0,
- .z = 0,
- .w = sw,
- .h = sh,
- .d = 1,
- };
- const dst = c.SDL_GPUTextureTransferInfo{
- .transfer_buffer = xfer,
- .offset = 0,
- .pixels_per_row = sw,
- .rows_per_layer = sh,
- };
- c.SDL_DownloadFromGPUTexture(copy, &src, &dst);
- c.SDL_EndGPUCopyPass(copy);
-
- const fence = c.SDL_SubmitGPUCommandBufferAndAcquireFence(cmd) orelse return error.GpuSubmit;
- defer c.SDL_ReleaseGPUFence(self.device, fence);
- var fences = [_]*c.SDL_GPUFence{fence};
- if (!c.SDL_WaitForGPUFences(self.device, true, &fences, 1)) return error.GpuSubmit;
-
- const mapped = c.SDL_MapGPUTransferBuffer(self.device, xfer, false) orelse return error.GpuMap;
- defer c.SDL_UnmapGPUTransferBuffer(self.device, xfer);
- const pixels: [*]const u8 = @ptrCast(mapped);
- try self.writeCapturePpm(pixels[0..size], sw, sh);
-}
-
-fn ensureCaptureTexture(self: *SdlBackend, width: u32, height: u32) !void {
- if (self.capture_tex != null and self.capture_tex_w == width and self.capture_tex_h == height) return;
- if (self.capture_tex) |t| c.SDL_ReleaseGPUTexture(self.device, t);
- var info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo);
- info.type = c.SDL_GPU_TEXTURETYPE_2D;
- info.format = self.swapchain_format;
- info.usage = c.SDL_GPU_TEXTUREUSAGE_COLOR_TARGET;
- info.width = width;
- info.height = height;
- info.layer_count_or_depth = 1;
- info.num_levels = 1;
- info.sample_count = c.SDL_GPU_SAMPLECOUNT_1;
- self.capture_tex = c.SDL_CreateGPUTexture(self.device, &info) orelse return error.GpuCreate;
- self.capture_tex_w = width;
- self.capture_tex_h = height;
-}
-
-fn ensureCaptureXfer(self: *SdlBackend, size: u32) !void {
- if (self.capture_xfer != null and self.capture_xfer_size >= size) return;
- if (self.capture_xfer) |x| c.SDL_ReleaseGPUTransferBuffer(self.device, x);
- var info = c.SDL_GPUTransferBufferCreateInfo{
- .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_DOWNLOAD,
- .size = size,
- .props = 0,
- };
- self.capture_xfer = c.SDL_CreateGPUTransferBuffer(self.device, &info) orelse return error.GpuCreate;
- self.capture_xfer_size = size;
-}
-
-fn writeCapturePpm(self: *SdlBackend, pixels: []const u8, width: u32, height: u32) !void {
- const dir = self.capture_dir orelse return;
- const bpp = c.SDL_GPUTextureFormatTexelBlockSize(self.swapchain_format);
- if (bpp != 4) return error.UnsupportedCaptureFormat;
-
- var tmp_buf: [4096]u8 = undefined;
- var final_buf: [4096]u8 = undefined;
- const tmp_path = std.fmt.bufPrintZ(&tmp_buf, "{s}/latest.ppm.tmp", .{dir}) catch return error.CapturePathTooLong;
- const final_path = std.fmt.bufPrintZ(&final_buf, "{s}/latest.ppm", .{dir}) catch return error.CapturePathTooLong;
-
- const fd = openCreateTrunc(tmp_path.ptr) orelse return error.CaptureWriteFailed;
- defer closeFdBestEffort(fd);
-
- var header: [64]u8 = undefined;
- const hdr = std.fmt.bufPrint(&header, "P6\n{d} {d}\n255\n", .{ width, height }) catch return error.CaptureWriteFailed;
- try writeAllFd(fd, hdr);
-
- const row_rgb = try self.alloc.alloc(u8, @as(usize, width) * 3);
- defer self.alloc.free(row_rgb);
- for (0..height) |row| {
- const row_start = @as(usize, row) * @as(usize, width) * 4;
- const src = pixels[row_start .. row_start + @as(usize, width) * 4];
- var x: usize = 0;
- while (x < @as(usize, width)) : (x += 1) {
- const si = x * 4;
- const di = x * 3;
- switch (self.swapchain_format) {
- c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM,
- c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM_SRGB,
- => {
- row_rgb[di + 0] = src[si + 0];
- row_rgb[di + 1] = src[si + 1];
- row_rgb[di + 2] = src[si + 2];
- },
- c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM,
- c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM_SRGB,
- => {
- row_rgb[di + 0] = src[si + 2];
- row_rgb[di + 1] = src[si + 1];
- row_rgb[di + 2] = src[si + 0];
- },
- else => return error.UnsupportedCaptureFormat,
- }
- }
- try writeAllFd(fd, row_rgb);
- }
-
- renameZ(tmp_path.ptr, final_path.ptr) catch return error.CaptureWriteFailed;
- self.capture_frame += 1;
-}
-
-fn openTraceFile(path: []const u8) posix.fd_t {
- var buf: [4096]u8 = undefined;
- const z = std.fmt.bufPrintZ(&buf, "{s}", .{path}) catch return 2;
- return openCreateTrunc(z.ptr) orelse 2;
-}
-
-fn tracef(self: *SdlBackend, comptime fmt: []const u8, args: anytype) void {
- if (is_emscripten) return;
- if (!self.trace_enabled or self.trace_fd < 0) return;
- var buf: [1024]u8 = undefined;
- const line = std.fmt.bufPrint(&buf, fmt ++ "\n", args) catch return;
- writeAllFdIgnore(self.trace_fd, line);
-}
-
-fn traceFrame(
- self: *SdlBackend,
- cells: u32,
- dirty_cells: u32,
- dirty_ranges: u32,
- sw: u32,
- sh: u32,
- atlas_uploaded: bool,
- t0: i128,
- t_cmd: i128,
- t_acquired: i128,
- t_mapped: i128,
- t_built: i128,
- t_atlas: i128,
- t_uploaded: i128,
- t_drawn: i128,
- t_submit0: i128,
- t_done: i128,
-) void {
- self.tracef(
- "sdl.frame n={d} size={d}x{d} cells={d} dirty={d} ranges={d} atlas={} cmd_us={d} acquire_us={d} map_us={d} build_us={d} atlas_us={d} upload_us={d} draw_us={d} submit_or_capture_us={d} total_us={d}",
- .{
- self.trace_frame,
- sw,
- sh,
- cells,
- dirty_cells,
- dirty_ranges,
- atlas_uploaded,
- nsToUs(t_cmd - t0),
- nsToUs(t_acquired - t_cmd),
- nsToUs(t_mapped - t_acquired),
- nsToUs(t_built - t_mapped),
- nsToUs(t_atlas - t_built),
- nsToUs(t_uploaded - t_atlas),
- nsToUs(t_drawn - t_uploaded),
- nsToUs(t_done - t_submit0),
- nsToUs(t_done - t0),
- },
- );
-}
-
-// no-ops for the terminal-protocol surface; SDL needs none of them.
-pub fn setMouseMode(self: *SdlBackend, writer: *std.Io.Writer, enable: bool) !void {
- _ = self;
- _ = writer;
- _ = enable;
-}
-pub fn enterAltScreen(self: *SdlBackend, writer: *std.Io.Writer) !void {
- _ = self;
- _ = writer;
-}
-pub fn exitAltScreen(self: *SdlBackend, writer: *std.Io.Writer) !void {
- _ = self;
- _ = writer;
-}
-pub fn queryTerminal(self: *SdlBackend, writer: *std.Io.Writer, timeout: std.Io.Duration) !void {
- _ = self;
- _ = writer;
- _ = timeout;
-}
-pub fn requestSystemClipboard(self: *SdlBackend, writer: *std.Io.Writer) !void {
- _ = self;
- _ = writer;
- // ponytail: the terminal backend gets the clipboard asynchronously via an
- // OSC 52 reply (.paste event). SDL could read it synchronously here, but the
- // result can't be posted without a loop pointer — left as a TODO for now.
-}
-pub fn copyToSystemClipboard(self: *SdlBackend, writer: *std.Io.Writer, text: []const u8, encode_allocator: std.mem.Allocator) !void {
- _ = self;
- _ = writer;
- _ = encode_allocator;
- var buf: [4096]u8 = undefined;
- const z = std.fmt.bufPrintZ(&buf, "{s}", .{text}) catch return;
- _ = c.SDL_SetClipboardText(z.ptr);
-}
-pub fn transmitPreEncodedImage(
- self: *SdlBackend,
- writer: *std.Io.Writer,
- bytes: []const u8,
- width: u16,
- height: u16,
- format: vaxis.Image.TransmitFormat,
-) !vaxis.Image {
- _ = self;
- _ = writer;
- _ = bytes;
- _ = width;
- _ = height;
- _ = format;
- return error.NoGraphicsCapability;
-}
-
-// ---- internals ----
-
-fn ensureVbuf(self: *SdlBackend, cells: u32) !void {
- if (self.vbuf_cells == cells and cells != 0) return;
- if (self.vbuf_cells != 0) {
- c.SDL_ReleaseGPUBuffer(self.device, self.vbuf);
- c.SDL_ReleaseGPUTransferBuffer(self.device, self.vxfer);
- }
- self.alloc.free(self.cell_keys);
- self.alloc.free(self.dirty_ranges);
- self.cell_keys = &.{};
- self.dirty_ranges = &.{};
- self.cell_cache_valid = false;
- self.last_render_w = 0;
- self.last_render_h = 0;
- const size = cells * @sizeOf(CellInstance);
- var vb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = size, .props = 0 };
- self.vbuf = c.SDL_CreateGPUBuffer(self.device, &vb_info) orelse return error.GpuCreate;
- var xf_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = size, .props = 0 };
- self.vxfer = c.SDL_CreateGPUTransferBuffer(self.device, &xf_info) orelse return error.GpuCreate;
- self.cell_keys = try self.alloc.alloc(CellKey, cells);
- self.dirty_ranges = try self.alloc.alloc(DirtyRange, cells);
- self.vbuf_cells = cells;
-}
-
-fn ensureGlInstances(self: *SdlBackend, cells: u32) !void {
- if (self.gl_instances.len >= cells) return;
- self.alloc.free(self.gl_instances);
- self.gl_instances = try self.alloc.alloc(CellInstance, cells);
-}
-
-fn resolveColor(self: *const SdlBackend, col: vaxis.Color, is_bg: bool) [3]u8 {
- return switch (col) {
- .default => if (is_bg) self.bg_default else self.fg_default,
- .index => |i| ansi[i & 15],
- .rgb => |r| r,
- };
-}
-
-fn packRgb(rgb: [3]u8) u32 {
- return (@as(u32, rgb[0]) << 16) | (@as(u32, rgb[1]) << 8) | @as(u32, rgb[2]);
-}
-
-fn unpackRgb(rgb: u32) [3]u8 {
- return .{
- @intCast((rgb >> 16) & 0xff),
- @intCast((rgb >> 8) & 0xff),
- @intCast(rgb & 0xff),
- };
-}
-
-fn isDefaultColor(color: vaxis.Color) bool {
- return switch (color) {
- .default => true,
- else => false,
- };
-}
-
-// Decode the first codepoint of a UTF-8 grapheme; 0x20 (" ") on failure/empty.
-fn firstCp(s: []const u8) u32 {
- if (s.len == 0) return ' ';
- const cp = std.unicode.utf8Decode(s[0 .. std.unicode.utf8ByteSequenceLength(s[0]) catch return ' ']) catch return ' ';
- return @intCast(cp);
-}
-
-fn cellKey(self: *const SdlBackend, cell: vaxis.Cell) CellKey {
- if (cell.char.grapheme.len == 0 and
- isDefaultColor(cell.style.fg) and
- isDefaultColor(cell.style.bg) and
- !cell.style.reverse and
- !cell.style.invisible and
- !cell.style.dim)
- {
- return .{
- .cp = ' ',
- .fg = packRgb(self.fg_default),
- .bg = packRgb(self.bg_default),
- };
- }
-
- var fg = self.resolveColor(cell.style.fg, false);
- var bg = self.resolveColor(cell.style.bg, true);
- if (cell.style.reverse) {
- const t = fg;
- fg = bg;
- bg = t;
- }
- if (cell.style.invisible) fg = bg;
- if (cell.style.dim) {
- fg[0] = fg[0] * 6 / 10;
- fg[1] = fg[1] * 6 / 10;
- fg[2] = fg[2] * 6 / 10;
- }
- const cp = if (cell.char.grapheme.len == 0) ' ' else firstCp(cell.char.grapheme);
- return .{ .cp = cp, .fg = packRgb(fg), .bg = packRgb(bg) };
-}
-
-fn sameCellKey(a: CellKey, b: CellKey) bool {
- return a.cp == b.cp and a.fg == b.fg and a.bg == b.bg;
-}
-
-fn addDirtyRange(self: *SdlBackend, range_count: *u32, cell_index: u32) void {
- if (range_count.* > 0) {
- const last = &self.dirty_ranges[range_count.* - 1];
- if (last.start + last.count == cell_index) {
- last.count += 1;
- return;
- }
- }
- self.dirty_ranges[range_count.*] = .{ .start = cell_index, .count = 1 };
- range_count.* += 1;
-}
-
-fn ensureGlyph(self: *SdlBackend, cp: u32) Slot {
- if (self.glyphs.get(cp)) |s| return s;
- if (self.pen_x + self.cell_w > atlas_w) {
- self.pen_x = 0;
- self.pen_y += self.cell_h;
- }
- if (self.pen_y + self.cell_h > atlas_h) return self.space_slot; // atlas full: fall back
- const su = self.pen_x;
- const sv = self.pen_y;
- const out = self.atlas_stage.ptr + @as(usize, sv) * atlas_w + su;
- _ = c.ui_font_raster(
- self.font.?,
- self.scale,
- @intCast(cp),
- out,
- @intCast(atlas_w),
- @intCast(self.cell_w),
- @intCast(self.cell_h),
- self.ascent,
- );
- const s = Slot{ .u = su, .v = sv };
- self.glyphs.put(cp, s) catch return self.space_slot;
- self.pen_x += self.cell_w;
- self.atlas_dirty = true;
- return s;
-}
-
-fn emitInstance(
- self: *SdlBackend,
- instances: [*]CellInstance,
- cell_index: u32,
- col: u16,
- row: u16,
- layout: CellLayout,
- win_w: f32,
- win_h: f32,
- key: CellKey,
-) void {
- // cell pixel rect (top-left origin)
- const px0 = layout.x_off + @as(f32, @floatFromInt(col)) * layout.w;
- const py0 = layout.y_off + @as(f32, @floatFromInt(row)) * layout.h;
- const px1 = px0 + layout.w;
- const py1 = py0 + layout.h;
- // -> NDC (SDL GPU clip space: x right, y up; framebuffer row 0 = top)
- const x0 = (px0 / win_w) * 2.0 - 1.0;
- const x1 = (px1 / win_w) * 2.0 - 1.0;
- const y0 = 1.0 - (py0 / win_h) * 2.0;
- const y1 = 1.0 - (py1 / win_h) * 2.0;
-
- const fg = unpackRgb(key.fg);
- const bg = unpackRgb(key.bg);
- const fgn: [3]f32 = .{
- @as(f32, @floatFromInt(fg[0])) / 255.0,
- @as(f32, @floatFromInt(fg[1])) / 255.0,
- @as(f32, @floatFromInt(fg[2])) / 255.0,
- };
- const bgn: [3]f32 = .{
- @as(f32, @floatFromInt(bg[0])) / 255.0,
- @as(f32, @floatFromInt(bg[1])) / 255.0,
- @as(f32, @floatFromInt(bg[2])) / 255.0,
- };
-
- // glyph slot (the space slot covers blanks, which sample alpha=0 → bg only)
- const slot = if (key.cp == ' ') self.space_slot else self.ensureGlyph(key.cp);
- const aw_f: f32 = @floatFromInt(atlas_w);
- const ah_f: f32 = @floatFromInt(atlas_h);
- const u_start = @as(f32, @floatFromInt(slot.u)) / aw_f;
- const v_start = @as(f32, @floatFromInt(slot.v)) / ah_f;
- const u_end = @as(f32, @floatFromInt(slot.u + self.cell_w)) / aw_f;
- const v_end = @as(f32, @floatFromInt(slot.v + self.cell_h)) / ah_f;
-
- instances[cell_index] = .{
- .x0 = x0,
- .y0 = y0,
- .x1 = x1,
- .y1 = y1,
- .u0 = u_start,
- .v0 = v_start,
- .u1 = u_end,
- .v1 = v_end,
- .fr = fgn[0],
- .fg = fgn[1],
- .fb = fgn[2],
- .br = bgn[0],
- .bg = bgn[1],
- .bb = bgn[2],
- };
-}
-
-fn uploadAtlas(self: *SdlBackend, cmd: *c.SDL_GPUCommandBuffer) void {
- const ptr = c.SDL_MapGPUTransferBuffer(self.device, self.atlas_xfer, false) orelse return;
- const dst: [*]u8 = @ptrCast(ptr);
- @memcpy(dst[0 .. atlas_w * atlas_h], self.atlas_stage);
- c.SDL_UnmapGPUTransferBuffer(self.device, self.atlas_xfer);
-
- const copy = c.SDL_BeginGPUCopyPass(cmd);
- const src = c.SDL_GPUTextureTransferInfo{
- .transfer_buffer = self.atlas_xfer,
- .offset = 0,
- .pixels_per_row = atlas_w,
- .rows_per_layer = atlas_h,
- };
- const dstregion = c.SDL_GPUTextureRegion{
- .texture = self.atlas_tex,
- .mip_level = 0,
- .layer = 0,
- .x = 0,
- .y = 0,
- .z = 0,
- .w = atlas_w,
- .h = atlas_h,
- .d = 1,
- };
- c.SDL_UploadToGPUTexture(copy, &src, &dstregion, false);
- c.SDL_EndGPUCopyPass(copy);
- self.atlas_dirty = false;
-}
-
-fn uploadAtlasGl(self: *SdlBackend) void {
- c.glBindTexture(c.GL_TEXTURE_2D, self.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, self.atlas_stage.ptr);
- self.atlas_dirty = false;
-}
-
-fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline {
- const vs = try makeShader(device, vert_spv_bytes, c.SDL_GPU_SHADERSTAGE_VERTEX, 0);
- defer c.SDL_ReleaseGPUShader(device, vs);
- const fs = try makeShader(device, frag_spv_bytes, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1);
- defer c.SDL_ReleaseGPUShader(device, fs);
-
- var vbuf_desc = c.SDL_GPUVertexBufferDescription{
- .slot = 0,
- .pitch = @sizeOf(CellInstance),
- .input_rate = c.SDL_GPU_VERTEXINPUTRATE_INSTANCE,
- .instance_step_rate = 0,
- };
- var attrs = [_]c.SDL_GPUVertexAttribute{
- .{ .location = 0, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "x0") },
- .{ .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") },
- };
- var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = std.mem.zeroes(c.SDL_GPUColorTargetBlendState) };
-
- var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo);
- info.vertex_shader = vs;
- info.fragment_shader = fs;
- info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST;
- info.vertex_input_state = .{
- .vertex_buffer_descriptions = &vbuf_desc,
- .num_vertex_buffers = 1,
- .vertex_attributes = &attrs,
- .num_vertex_attributes = attrs.len,
- };
- info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL;
- info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE;
- info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1;
- info.target_info = .{
- .color_target_descriptions = &col_desc,
- .num_color_targets = 1,
- };
- return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate;
-}
-
-fn makeOverlayPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline {
- const vs = try makeShader(device, overlay_vert_spv_bytes, c.SDL_GPU_SHADERSTAGE_VERTEX, 0);
- defer c.SDL_ReleaseGPUShader(device, vs);
- const fs = try makeShader(device, overlay_frag_spv_bytes, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 0);
- defer c.SDL_ReleaseGPUShader(device, fs);
-
- var vbuf_desc = c.SDL_GPUVertexBufferDescription{
- .slot = 0,
- .pitch = @sizeOf(OverlayVertex),
- .input_rate = c.SDL_GPU_VERTEXINPUTRATE_VERTEX,
- .instance_step_rate = 0,
- };
- var attrs = [_]c.SDL_GPUVertexAttribute{
- .{ .location = 0, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT2, .offset = @offsetOf(OverlayVertex, "x") },
- .{ .location = 1, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(OverlayVertex, "r") },
- };
- var blend = std.mem.zeroes(c.SDL_GPUColorTargetBlendState);
- blend.src_color_blendfactor = c.SDL_GPU_BLENDFACTOR_SRC_ALPHA;
- blend.dst_color_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA;
- blend.color_blend_op = c.SDL_GPU_BLENDOP_ADD;
- blend.src_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE;
- blend.dst_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA;
- blend.alpha_blend_op = c.SDL_GPU_BLENDOP_ADD;
- blend.enable_blend = true;
- var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = blend };
-
- var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo);
- info.vertex_shader = vs;
- info.fragment_shader = fs;
- info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST;
- info.vertex_input_state = .{
- .vertex_buffer_descriptions = &vbuf_desc,
- .num_vertex_buffers = 1,
- .vertex_attributes = &attrs,
- .num_vertex_attributes = attrs.len,
- };
- info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL;
- info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE;
- info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1;
- info.target_info = .{
- .color_target_descriptions = &col_desc,
- .num_color_targets = 1,
- };
- 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 {
- var info = std.mem.zeroes(c.SDL_GPUShaderCreateInfo);
- info.code = code.ptr;
- info.code_size = code.len;
- info.entrypoint = "main";
- info.format = c.SDL_GPU_SHADERFORMAT_SPIRV;
- info.stage = stage;
- info.num_samplers = n_samplers;
- return c.SDL_CreateGPUShader(device, &info) orelse error.GpuCreate;
-}
-
-fn makeGlProgram() !c_uint {
- return makeGlProgramFrom(vert_glsl_es, frag_glsl_es);
-}
-
-fn makeOverlayGlProgram() !c_uint {
- return makeGlProgramFrom(overlay_vert_glsl_es, overlay_frag_glsl_es);
-}
-
-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;
-}
-
-// =====================================================================
-// Tty stub — the app threads a `*std.Io.Writer` through every backend call; SDL
-// needs none of it, so we back it with /dev/null. The pointer just has to stay
-// valid for the app's lifetime.
-// =====================================================================
-
-const SdlTty = struct {
- const File = if (is_emscripten) void else std.Io.File;
- const Writer = if (is_emscripten) std.Io.Writer.Discarding else std.Io.File.Writer;
-
- file: File,
- tty_writer: Writer,
-
- pub fn init(io: std.Io, buffer: []u8) !SdlTty {
- if (is_emscripten) return .{
- .file = {},
- .tty_writer = .init(buffer),
- };
- const f = try std.Io.Dir.openFileAbsolute(io, "/dev/null", .{ .mode = .read_write });
- return .{ .file = f, .tty_writer = f.writerStreaming(io, buffer) };
- }
- pub fn deinit(self: SdlTty, io: std.Io) void {
- if (is_emscripten) return;
- self.file.close(io);
- }
- pub fn writer(self: *SdlTty) *std.Io.Writer {
- if (is_emscripten) return &self.tty_writer.writer;
- return &self.tty_writer.interface;
- }
-};
-
-// =====================================================================
-// Event loop — polls SDL on the main thread and feeds the same `Event` union
-// the app expects onto a thread-safe queue (so per-terminal pty readers, which
-// call postEvent from their own threads, keep working unchanged).
-// =====================================================================
-
-fn SdlLoop(comptime EventType: type) type {
- return struct {
- const Self = @This();
- const Reader = if (is_emscripten) void else ?std.Io.Future(void);
-
- io: std.Io,
- vx: *SdlBackend,
- queue: vaxis.Queue(EventType, 512),
- // live keyboard modifier state (updated on every key down/up), so
- // SDL_TEXT_INPUT — which carries no modifiers — can attach them.
- live_mods: vaxis.Key.Modifiers = .{},
- last_cols: u16 = 0,
- last_rows: u16 = 0,
- // test-mode stdin reader task + quit flag
- reader: Reader = if (is_emscripten) {} else null,
- should_quit: bool = false,
-
- pub fn init(io: std.Io, tty: *SdlTty, vx: *SdlBackend) Self {
- _ = tty;
- return .{ .io = io, .vx = vx, .queue = .init(io) };
- }
-
- pub fn start(self: *Self) !void {
- if (!is_emscripten and self.vx.test_mode) {
- setStdinRaw() catch {};
- // e2e: spawn a stdin reader that parses terminal escape sequences
- // (vaxis.Parser) into Key/Mouse/Winsize events and posts them.
- self.reader = try self.io.concurrent(Self.stdinRun, .{self});
- return;
- }
- // start delivering text input events (for printable keys)
- _ = c.SDL_StartTextInput(self.vx.sdl_window);
- // report the initial window size so the app sizes its grid
- self.postWinsize();
- }
- pub fn stop(self: *Self) void {
- if (!is_emscripten and self.vx.test_mode) {
- self.should_quit = true;
- if (self.reader) |*r| r.await(self.io);
- self.reader = null;
- return;
- }
- }
- pub fn installResizeHandler(self: *Self) !void {
- if (!is_emscripten and self.vx.test_mode) {
- var act = posix.Sigaction{
- .handler = .{ .handler = winchHandler },
- .mask = switch (builtin.os.tag) {
- .macos => 0,
- else => posix.sigemptyset(),
- },
- .flags = 0,
- };
- posix.sigaction(posix.SIG.WINCH, &act, null);
- return;
- }
- }
-
- pub fn postEvent(self: *Self, event: EventType) !void {
- try self.queue.push(event);
- if (!self.vx.test_mode) {
- // wake a blocking SDL_WaitEventTimeout in nextEvent (pty reads come
- // from other threads; without this the app wouldn't notice them
- // until the timeout elapses).
- var sev = std.mem.zeroes(c.SDL_Event);
- sev.type = c.SDL_EVENT_USER;
- _ = c.SDL_PushEvent(&sev);
- }
- }
-
- pub fn nextEvent(self: *Self) !EventType {
- if (!is_emscripten and self.vx.test_mode) return try self.queue.pop();
- while (true) {
- // pty/queued events first (they're already decoded)
- if (try self.queue.tryPop()) |ev| {
- if (self.vx.trace_enabled) self.vx.tracef("sdl.event source=queue wait_us=0", .{});
- return ev;
- }
- var sev = std.mem.zeroes(c.SDL_Event);
- const t0 = nowNs();
- if (c.SDL_WaitEventTimeout(&sev, 16)) {
- const t1 = nowNs();
- if (try self.dispatch(&sev)) |ev| {
- if (self.vx.trace_enabled) self.vx.tracef("sdl.event source=sdl type={d} wait_us={d}", .{ sev.type, nsToUs(t1 - t0) });
- return ev;
- }
- // else: an SDL event we ignore — loop and re-check the queue
- } else {
- if (self.vx.trace_enabled) self.vx.tracef("sdl.event source=tick wait_us={d}", .{nsToUs(nowNs() - t0)});
- return .tick; // timeout: a no-op that drives a render
- }
- }
- }
-
- pub fn tryEvent(self: *Self) !?EventType {
- if (try self.queue.tryPop()) |ev| return ev;
- if (!is_emscripten and self.vx.test_mode) return null;
- var sev = std.mem.zeroes(c.SDL_Event);
- while (c.SDL_PollEvent(&sev)) {
- if (try self.dispatch(&sev)) |ev| return ev;
- if (try self.queue.tryPop()) |ev| return ev;
- }
- if (is_emscripten) {
- self.postWinsize();
- if (try self.queue.tryPop()) |ev| return ev;
- }
- return null;
- }
-
- fn postWinsize(self: *Self) void {
- if (!is_emscripten and self.vx.test_mode) {
- // read winsize from stdin (fd 0 = the pty slave)
- var ws: posix.winsize = std.mem.zeroes(posix.winsize);
- const iorc = posix.system.ioctl(0, tiocgwinszReq(), @intFromPtr(&ws));
- if (iorc < 0) return; // fd 0 is not a pty
- const cols: u16 = ws.col;
- const rows: u16 = ws.row;
- if (cols == 0 or rows == 0 or cols > 1000 or rows > 1000) return;
- if (cols == self.last_cols and rows == self.last_rows) return;
- self.last_cols = cols;
- self.last_rows = rows;
- self.postEvent(.{ .winsize = .{
- .cols = cols,
- .rows = rows,
- .x_pixel = ws.xpixel,
- .y_pixel = ws.ypixel,
- } }) catch {};
- return;
- }
- var pw: c_int = 0;
- var ph: c_int = 0;
- if (is_emscripten) {
- var css_w: f64 = 0;
- var css_h: f64 = 0;
- if (emscripten_get_element_css_size("#canvas", &css_w, &css_h) == EMSCRIPTEN_RESULT_SUCCESS and css_w > 0 and css_h > 0) {
- pw = @intFromFloat(@round(css_w * web_render_scale_f64));
- ph = @intFromFloat(@round(css_h * web_render_scale_f64));
- var cur_w: c_int = 0;
- var cur_h: c_int = 0;
- _ = c.SDL_GetWindowSizeInPixels(self.vx.sdl_window, &cur_w, &cur_h);
- if (cur_w != pw or cur_h != ph) {
- _ = c.SDL_SetWindowSize(self.vx.sdl_window, pw, ph);
- _ = c.SDL_SyncWindow(self.vx.sdl_window);
- }
- } else {
- _ = c.SDL_GetWindowSizeInPixels(self.vx.sdl_window, &pw, &ph);
- }
- } else {
- _ = c.SDL_GetWindowSizeInPixels(self.vx.sdl_window, &pw, &ph);
- }
- const cw: u32 = self.vx.cell_w;
- const ch: u32 = self.vx.cell_h;
- const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(pw)), cw)));
- const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(ph)), ch)));
- if (cols == self.last_cols and rows == self.last_rows) return;
- self.last_cols = cols;
- self.last_rows = rows;
- self.postEvent(.{ .winsize = .{
- .cols = cols,
- .rows = rows,
- .x_pixel = @intCast(pw),
- .y_pixel = @intCast(ph),
- } }) catch {};
- }
-
- fn mouseEvent(self: *Self, cell: MouseCell, button: vaxis.Mouse.Button, t: vaxis.Mouse.Type) EventType {
- return EventType{ .mouse = .{
- .col = cell.col,
- .row = cell.row,
- .button = button,
- .mods = sdlMouseMods(self.live_mods),
- .type = t,
- } };
- }
-
- fn dispatch(self: *Self, sev: *const c.SDL_Event) !?EventType {
- switch (sev.type) {
- c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => return .quit,
- c.SDL_EVENT_WINDOW_RESIZED, c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED => {
- self.postWinsize();
- return null;
- },
- c.SDL_EVENT_WINDOW_EXPOSED => return .tick,
- c.SDL_EVENT_KEY_DOWN => {
- const k = sev.key;
- self.live_mods = sdlMods(k.mod);
- return self.keyDown(k.key, k.mod);
- },
- c.SDL_EVENT_KEY_UP => {
- self.live_mods = sdlMods(sev.key.mod);
- return null;
- },
- c.SDL_EVENT_TEXT_INPUT => {
- const tptr: [*c]const u8 = sev.text.text;
- if (tptr == null) return null;
- var tlen: usize = 0;
- while (tptr[tlen] != 0) : (tlen += 1) {}
- const text: []const u8 = tptr[0..tlen];
- if (text.len == 0) return null;
- const len = std.unicode.utf8ByteSequenceLength(text[0]) catch return null;
- if (len > text.len) return null;
- const cp = std.unicode.utf8Decode(text[0..len]) catch return null;
- return EventType{ .key_press = .{
- .codepoint = @intCast(cp),
- .text = text[0..len],
- .mods = self.live_mods,
- } };
- },
- c.SDL_EVENT_MOUSE_BUTTON_DOWN, c.SDL_EVENT_MOUSE_BUTTON_UP => {
- const b = sev.button;
- const t: vaxis.Mouse.Type = if (b.down) .press else .release;
- const button = mouseButton(b.button) orelse return null;
- const cell = self.vx.mouseCell(b.x, b.y);
- return self.mouseEvent(cell, button, t);
- },
- c.SDL_EVENT_MOUSE_MOTION => {
- const m = sev.motion;
- // drag = motion with a button held; the app keys selection
- // extension off .drag (it never reads .motion).
- const held = heldButton(m.state);
- const t: vaxis.Mouse.Type = if (held != null) .drag else .motion;
- const button = held orelse .none;
- const cell = self.vx.mouseCell(m.x, m.y);
- return self.mouseEvent(cell, button, t);
- },
- c.SDL_EVENT_MOUSE_WHEEL => {
- const w = sev.wheel;
- const dy = w.y;
- const button: vaxis.Mouse.Button = if (dy > 0) .wheel_up else if (dy < 0) .wheel_down else return null;
- const cell = self.vx.mouseCell(w.mouse_x, w.mouse_y);
- return self.mouseEvent(cell, button, .press);
- },
- c.SDL_EVENT_FINGER_DOWN, c.SDL_EVENT_FINGER_MOTION, c.SDL_EVENT_FINGER_UP, c.SDL_EVENT_FINGER_CANCELED => {
- const f = sev.tfinger;
- switch (f.type) {
- c.SDL_EVENT_FINGER_DOWN => {
- self.vx.updateTouch(f);
- self.vx.syncTouchScrollPair();
- return .tick;
- },
- c.SDL_EVENT_FINGER_MOTION => {
- const had_pair = self.vx.touch_scroll.active;
- self.vx.updateTouch(f);
- self.vx.syncTouchScrollPair();
- if (had_pair) {
- if (self.vx.noteTouchScrollMotion(f)) |scroll| {
- const point = self.vx.fingerMousePoint(scroll.center.x, scroll.center.y);
- const cell = self.vx.mouseCell(point.x, point.y);
- const button: vaxis.Mouse.Button = if (scroll.ticks > 0) .wheel_up else .wheel_down;
- var remaining: u32 = @intCast(if (scroll.ticks > 0) scroll.ticks else -scroll.ticks);
- while (remaining > 0) : (remaining -= 1) {
- try self.postEvent(self.mouseEvent(cell, button, .press));
- }
- try self.postEvent(.{ .touch_scroll_delta = scroll.delta_y });
- return null;
- }
- }
- return .tick;
- },
- c.SDL_EVENT_FINGER_UP, c.SDL_EVENT_FINGER_CANCELED => {
- const tap_center = self.vx.finishTouchScrollPair(f);
- self.vx.updateTouch(f);
- self.vx.syncTouchScrollPair();
- if (tap_center) |center| {
- self.vx.noteTouchMiddleClick(true);
- const point = self.vx.fingerMousePoint(center.x, center.y);
- const cell = self.vx.mouseCell(point.x, point.y);
- try self.postEvent(self.mouseEvent(cell, .middle, .press));
- self.vx.noteTouchMiddleClick(false);
- try self.postEvent(self.mouseEvent(cell, .middle, .release));
- return null;
- }
- return .tick;
- },
- else => return .tick,
- }
- },
- c.SDL_EVENT_PINCH_BEGIN, c.SDL_EVENT_PINCH_UPDATE, c.SDL_EVENT_PINCH_END => {
- return .{ .pinch = sev.pinch.scale };
- },
- else => return null,
- }
- }
-
- // KEY_DOWN: special keys + ctrl/alt shortcuts. Plain printable keys
- // arrive as SDL_TEXT_INPUT instead (so shift/layout map correctly).
- fn keyDown(self: *Self, sym: c.SDL_Keycode, mod: c.SDL_Keymod) ?EventType {
- const mods = sdlMods(mod);
- const cp: u21 = switch (sym) {
- c.SDLK_RETURN => vaxis.Key.enter,
- c.SDLK_KP_ENTER => vaxis.Key.enter,
- c.SDLK_BACKSPACE => vaxis.Key.backspace,
- c.SDLK_TAB => vaxis.Key.tab,
- c.SDLK_ESCAPE => vaxis.Key.escape,
- c.SDLK_SPACE => ' ',
- c.SDLK_LEFT => vaxis.Key.left,
- c.SDLK_RIGHT => vaxis.Key.right,
- c.SDLK_UP => vaxis.Key.up,
- c.SDLK_DOWN => vaxis.Key.down,
- c.SDLK_HOME => vaxis.Key.home,
- c.SDLK_END => vaxis.Key.end,
- c.SDLK_PAGEUP => vaxis.Key.page_up,
- c.SDLK_PAGEDOWN => vaxis.Key.page_down,
- c.SDLK_INSERT => vaxis.Key.insert,
- c.SDLK_DELETE => vaxis.Key.delete,
- c.SDLK_F1 => vaxis.Key.f1,
- c.SDLK_F2 => vaxis.Key.f2,
- c.SDLK_F3 => vaxis.Key.f3,
- c.SDLK_F4 => vaxis.Key.f4,
- c.SDLK_F5 => vaxis.Key.f5,
- c.SDLK_F6 => vaxis.Key.f6,
- c.SDLK_F7 => vaxis.Key.f7,
- c.SDLK_F8 => vaxis.Key.f8,
- c.SDLK_F9 => vaxis.Key.f9,
- c.SDLK_F10 => vaxis.Key.f10,
- c.SDLK_F11 => vaxis.Key.f11,
- c.SDLK_F12 => vaxis.Key.f12,
- else => blk: {
- // letters / digits / punctuation: only emit on a modifier
- // shortcut (ctrl/alt/gui); plain printable goes via TEXT_INPUT.
- const has_mod = (mod & (c.SDL_KMOD_CTRL | c.SDL_KMOD_ALT | c.SDL_KMOD_GUI)) != 0;
- if (!has_mod) break :blk 0;
- if (sym >= 'a' and sym <= 'z') break :blk @intCast(sym);
- if (sym >= '0' and sym <= '9') break :blk @intCast(sym);
- if (sym < 128) break :blk @intCast(sym);
- break :blk 0;
- },
- };
- if (cp == 0) return null;
- _ = self;
- return EventType{ .key_press = .{ .codepoint = cp, .mods = mods } };
- }
-
- // ---- test-mode stdin reader ----
- // Parses terminal escape sequences from fd 0 (the pty slave) into the
- // app's Event union and posts them to the queue. This mirrors vaxis's
- // ttyRun while also recognizing private PARDES_TEST SDL event OSCs.
- fn stdinRun(self: *Self) void {
- self.stdinRunTyped() catch {};
- }
-
- fn stdinRunTyped(self: *Self) !void {
- // post the initial winsize (read from stdin)
- self.postWinsize();
-
- var parser: vaxis.Parser = .{};
- var cache: vaxis.GraphemeCache = .{};
- var buf: [1024]u8 = undefined;
- var read_start: usize = 0;
-
- while (!self.should_quit) {
- // check for a pending SIGWINCH (flag set by the signal handler)
- if (g_winsize_dirty) {
- g_winsize_dirty = false;
- self.postWinsize();
- }
-
- var fds = [_]posix.pollfd{.{ .fd = 0, .events = posix.POLL.IN, .revents = 0 }};
- _ = posix.poll(&fds, 50) catch continue;
- if ((fds[0].revents & posix.POLL.IN) == 0) continue;
-
- const n = readStdin(buf[read_start..]) catch break;
- if (n == 0) break;
- const len: usize = read_start + n;
- var seq_start: usize = 0;
- while (seq_start < len) {
- switch (try self.parseTestSdlEvent(buf[seq_start..len])) {
- .no_match => {},
- .need_more => {
- const initial = seq_start;
- while (seq_start < len) : (seq_start += 1) {
- buf[seq_start - initial] = buf[seq_start];
- }
- read_start = len - initial;
- break;
- },
- .consumed => |consumed| {
- read_start = 0;
- seq_start += consumed;
- continue;
- },
- }
-
- const result = parser.parse(buf[seq_start..len], null) catch break;
- if (result.n == 0) {
- // partial sequence — shift to front of buffer
- const initial = seq_start;
- while (seq_start < len) : (seq_start += 1) {
- buf[seq_start - initial] = buf[seq_start];
- }
- read_start = len - initial;
- break;
- }
- read_start = 0;
- seq_start += result.n;
- const event = result.event orelse continue;
- // convert vaxis.Event → app EventType and post
- switch (event) {
- .key_press => |key| {
- var mut_key = key;
- if (key.text) |text| mut_key.text = cache.put(text);
- try self.postEvent(.{ .key_press = mut_key });
- },
- .mouse => |mouse| try self.postEvent(.{ .mouse = mouse }),
- .winsize => |ws| try self.postEvent(.{ .winsize = ws }),
- .paste => |text| {
- try self.postEvent(.{ .paste = text });
- },
- else => {},
- }
- }
- }
- }
-
- const TestSdlParse = union(enum) {
- no_match,
- need_more,
- consumed: usize,
- };
-
- // Private OSC used only by PARDES_TEST:
- // ESC ] 777;finger;SDL_EVENT_FINGER_DOWN;<id>;<x>;<y>;<pressure> BEL
- // x/y/pressure use SDL's normalized 0..1 touch coordinates.
- fn parseTestSdlEvent(self: *Self, bytes: []const u8) !TestSdlParse {
- const prefix = "\x1b]777;finger;";
- if (!std.mem.startsWith(u8, bytes, prefix)) {
- if (bytes.len >= 2 and bytes.len < prefix.len and std.mem.eql(u8, bytes, prefix[0..bytes.len])) {
- return .need_more;
- }
- return .no_match;
- }
-
- const body = bytes[prefix.len..];
- const end = std.mem.indexOfScalar(u8, body, '\x07') orelse return .need_more;
- try self.postTestFingerEvent(body[0..end]);
- return .{ .consumed = prefix.len + end + 1 };
- }
-
- fn postTestFingerEvent(self: *Self, payload: []const u8) !void {
- var parts = std.mem.splitScalar(u8, payload, ';');
- const type_name = parts.next() orelse return error.BadTestSdlEvent;
- const finger_id_s = parts.next() orelse return error.BadTestSdlEvent;
- const x_s = parts.next() orelse return error.BadTestSdlEvent;
- const y_s = parts.next() orelse return error.BadTestSdlEvent;
- const pressure_s = parts.next() orelse return error.BadTestSdlEvent;
- if (parts.next() != null) return error.BadTestSdlEvent;
-
- const event_type = testFingerEventType(type_name) orelse return error.BadTestSdlEvent;
- const finger_id = try std.fmt.parseInt(u64, finger_id_s, 10);
- const x = try std.fmt.parseFloat(f32, x_s);
- const y = try std.fmt.parseFloat(f32, y_s);
- const pressure = try std.fmt.parseFloat(f32, pressure_s);
-
- var sev = std.mem.zeroes(c.SDL_Event);
- sev.type = event_type;
- sev.tfinger = .{
- .type = event_type,
- .reserved = 0,
- .timestamp = 0,
- .touchID = 1,
- .fingerID = finger_id,
- .x = x,
- .y = y,
- .dx = 0,
- .dy = 0,
- .pressure = pressure,
- .windowID = c.SDL_GetWindowID(self.vx.sdl_window),
- };
- if (try self.dispatch(&sev)) |event| try self.postEvent(event);
- }
-
- fn testFingerEventType(name: []const u8) ?c.SDL_EventType {
- if (std.mem.eql(u8, name, "SDL_EVENT_FINGER_DOWN") or std.mem.eql(u8, name, "down")) return c.SDL_EVENT_FINGER_DOWN;
- if (std.mem.eql(u8, name, "SDL_EVENT_FINGER_MOTION") or std.mem.eql(u8, name, "motion")) return c.SDL_EVENT_FINGER_MOTION;
- if (std.mem.eql(u8, name, "SDL_EVENT_FINGER_UP") or std.mem.eql(u8, name, "up")) return c.SDL_EVENT_FINGER_UP;
- if (std.mem.eql(u8, name, "SDL_EVENT_FINGER_CANCELED") or std.mem.eql(u8, name, "cancel")) return c.SDL_EVENT_FINGER_CANCELED;
- return null;
- }
- };
-}
-
-fn setStdinRaw() !void {
- var term = try posix.tcgetattr(0);
- term.iflag.BRKINT = false;
- term.iflag.ICRNL = false;
- term.iflag.INPCK = false;
- term.iflag.ISTRIP = false;
- term.iflag.IXON = false;
- term.oflag.OPOST = false;
- term.lflag.ECHO = false;
- term.lflag.ICANON = false;
- term.lflag.IEXTEN = false;
- term.lflag.ISIG = false;
- term.cc[@intFromEnum(posix.V.MIN)] = 1;
- term.cc[@intFromEnum(posix.V.TIME)] = 0;
- try posix.tcsetattr(0, .NOW, term);
-}
-
-fn ioctlReq(bits: u32) c_int {
- return @bitCast(bits);
-}
-
-fn tiocgwinszReq() c_int {
- return switch (os_tag) {
- .linux => @intCast(posix.T.IOCGWINSZ),
- .macos, .freebsd, .openbsd, .netbsd, .dragonfly => ioctlReq(0x40087468),
- else => @intCast(posix.T.IOCGWINSZ),
- };
-}
-
-fn readStdin(buf: []u8) !usize {
- switch (os_tag) {
- .linux => {
- while (true) {
- const rc = linux.read(0, buf.ptr, buf.len);
- const n: isize = @bitCast(rc);
- if (n < 0) {
- if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue;
- return error.ReadFailed;
- }
- return @intCast(n);
- }
- },
- else => return posix.read(0, buf),
- }
-}
-
-// ---- test-mode SIGWINCH handler ----
-// Signal handlers can't take closures, so we just set a flag; the stdin reader
-// (SdlLoop.stdinRunTyped) checks it and reads + posts the new winsize.
-var g_winsize_dirty: bool = false;
-
-fn winchHandler(_: posix.SIG) callconv(.c) void {
- g_winsize_dirty = true;
-}
-
-fn sdlMouseMods(km: vaxis.Key.Modifiers) vaxis.Mouse.Modifiers {
- return .{ .shift = km.shift, .alt = km.alt, .ctrl = km.ctrl };
-}
-
-fn sdlMods(mod: c.SDL_Keymod) vaxis.Key.Modifiers {
- return .{
- .shift = (mod & c.SDL_KMOD_SHIFT) != 0,
- .ctrl = (mod & c.SDL_KMOD_CTRL) != 0,
- .alt = (mod & c.SDL_KMOD_ALT) != 0,
- .super = (mod & c.SDL_KMOD_GUI) != 0,
- };
-}
-
-fn mouseButton(b: u8) ?vaxis.Mouse.Button {
- return switch (b) {
- 1 => .left,
- 2 => .middle,
- 3 => .right,
- else => null,
- };
-}
-
-// the SDL mouse button bitmask (`state` in a motion event) → the single button
-// being dragged with, if any. Left wins on a chord, matching the app's
-// "later buttons win" only loosely — good enough for selection drags.
-fn heldButton(state: u32) ?vaxis.Mouse.Button {
- if ((state & c.SDL_BUTTON_LMASK) != 0) return .left;
- if ((state & c.SDL_BUTTON_MMASK) != 0) return .middle;
- if ((state & c.SDL_BUTTON_RMASK) != 0) return .right;
- return null;
-}
diff --git a/ui_vaxis.zig b/ui_vaxis.zig
deleted file mode 100644
index 1a3ce0af..00000000
--- a/ui_vaxis.zig
+++ /dev/null
@@ -1,38 +0,0 @@
-// The UI abstraction surface. `main.zig` and `image.zig` program against THIS
-// module (importing `"ui"`) instead of libvaxis directly, so the rendering +
-// input backend can be swapped at build time (`-Dui=vaxis|sdl`).
-//
-// This is the *vaxis* (real terminal) backend: a transparent passthrough. It
-// re-exports the libvaxis declarations the app uses, so every type (`Key`,
-// `Mouse`, `Window`, `Cell`, …), the `Vaxis` renderer/loop, `Tty`, and `init`
-// are exactly libvaxis's. All the UI logic therefore behaves identically to the
-// pre-refactor app — this file exists purely as the seam a different backend
-// (`ui_sdl.zig`) can replace.
-//
-// (Zig 0.16 dropped `pub usingnamespace`, so the re-export is explicit.)
-const vaxis = @import("vaxis");
-
-pub const Vaxis = vaxis.Vaxis;
-pub const Tty = vaxis.Tty;
-pub const Loop = vaxis.Loop;
-pub const init = vaxis.init;
-
-pub const Key = vaxis.Key;
-pub const Mouse = vaxis.Mouse;
-pub const Winsize = vaxis.Winsize;
-pub const Color = vaxis.Color;
-pub const Style = vaxis.Style;
-pub const Cell = vaxis.Cell;
-pub const Segment = vaxis.Segment;
-pub const Window = vaxis.Window;
-pub const Screen = vaxis.Screen;
-pub const Image = vaxis.Image;
-pub const Event = vaxis.Event;
-pub const Capabilities = vaxis.Capabilities;
-pub const Options = vaxis.Options;
-pub const CursorShape = vaxis.Cell.CursorShape;
-pub const supports_terminal_prepaint = true;
-
-pub fn adoptAltScreen(vx: *Vaxis) void {
- vx.state.alt_screen = true;
-}