From de4def3548a6729b0dfd2120495a61beef8c8c2c Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 5 Jul 2026 20:21:50 -0300 Subject: 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). --- .gitignore | 1 + assets/AdwaitaMono-Regular.ttf | Bin 0 -> 1435180 bytes assets/DejaVuSansMono.ttf | Bin 343140 -> 0 bytes build.zig | 840 +++------ build.zig.zon | 4 +- dbg.txt | 25 - docs/design.pdf | 1380 ++++++++++++++ docs/design.typ | 246 +++ docs/ideas.typ | 13 + dump.zig | 218 --- e2e_harness.zig | 269 --- e2e_harness_sdl.zig | 332 ---- ideas.typ | 13 - image.zig | 199 -- main.zig | 3940 --------------------------------------- modal.zig | 964 ---------- paint.zig | 340 ---- pane_ui.zig | 496 ----- petscii.zig | 446 ----- replay.zig | 786 -------- src/dump.zig | 218 +++ src/gui.zig | 2399 ++++++++++++++++++++++++ src/image.zig | 53 + src/look.zig | 123 ++ src/main.zig | 102 + src/modal.zig | 964 ++++++++++ src/pardes.zig | 3728 ++++++++++++++++++++++++++++++++++++ src/petscii.zig | 446 +++++ src/syntax.zig | 173 ++ src/tty.zig | 386 ++++ src/tutor.txt | 412 ++++ term.zig | 213 --- test/e2e_harness.zig | 278 +++ test/snapshot.zig | 553 ++++++ test/snapshots/boot.golden | 62 + test/snapshots/boot.snap | 8 + test/snapshots/dump.actual | 63 + test/snapshots/dump.golden | 64 + test/snapshots/dump.snap | 18 + test/snapshots/edit.actual | 62 + test/snapshots/edit.golden | 62 + test/snapshots/edit.snap | 15 + test/snapshots/exec.golden | 124 ++ test/snapshots/exec.snap | 46 + test/snapshots/fileedit.golden | 116 ++ test/snapshots/fileedit.snap | 40 + test/snapshots/images.golden | 124 ++ test/snapshots/images.snap | 29 + test/snapshots/load.golden | 62 + test/snapshots/load.snap | 27 + test/snapshots/look-dir.actual | 31 + test/snapshots/look-dir.golden | 31 + test/snapshots/look-dir.snap | 20 + test/snapshots/look-file.golden | 62 + test/snapshots/look-file.snap | 19 + test/snapshots/modal.golden | 372 ++++ test/snapshots/modal.snap | 53 + test/snapshots/scroll.golden | 186 ++ test/snapshots/scroll.snap | 31 + test/snapshots/syntax.actual | 124 ++ test/snapshots/syntax.golden | 124 ++ test/snapshots/syntax.snap | 27 + test/snapshots/tag.golden | 217 +++ test/snapshots/tag.snap | 41 + test/snapshots/theme.golden | 186 ++ test/snapshots/theme.snap | 24 + test/snapshots/tty.golden | 93 + test/snapshots/tty.snap | 16 + test/snapshots/ttyonly.golden | 62 + test/snapshots/ttyonly.snap | 11 + test/snapshots/tutor.golden | 31 + test/snapshots/tutor.snap | 8 + test/snapshots/windowops.golden | 372 ++++ test/snapshots/windowops.snap | 53 + tests.zig | 1061 ----------- tests/browser_replay_dump.zon | 54 - tests/browser_replay_e2e.mjs | 356 ---- tests_sdl.zig | 185 -- tests_syntax_perf.zig | 795 -------- tests_tty_perf.zig | 88 - tutor.txt | 412 ---- tutor_gen.zig | 386 ---- ui_sdl.zig | 2916 ----------------------------- ui_vaxis.zig | 38 - 84 files changed, 14854 insertions(+), 15113 deletions(-) create mode 100644 assets/AdwaitaMono-Regular.ttf delete mode 100644 assets/DejaVuSansMono.ttf delete mode 100644 dbg.txt create mode 100644 docs/design.pdf create mode 100644 docs/design.typ create mode 100644 docs/ideas.typ delete mode 100644 dump.zig delete mode 100644 e2e_harness.zig delete mode 100644 e2e_harness_sdl.zig delete mode 100644 ideas.typ delete mode 100644 image.zig delete mode 100644 main.zig delete mode 100644 modal.zig delete mode 100644 paint.zig delete mode 100644 pane_ui.zig delete mode 100644 petscii.zig delete mode 100644 replay.zig create mode 100644 src/dump.zig create mode 100644 src/gui.zig create mode 100644 src/image.zig create mode 100644 src/look.zig create mode 100644 src/main.zig create mode 100644 src/modal.zig create mode 100644 src/pardes.zig create mode 100644 src/petscii.zig create mode 100644 src/syntax.zig create mode 100644 src/tty.zig create mode 100644 src/tutor.txt delete mode 100644 term.zig create mode 100644 test/e2e_harness.zig create mode 100644 test/snapshot.zig create mode 100644 test/snapshots/boot.golden create mode 100644 test/snapshots/boot.snap create mode 100644 test/snapshots/dump.actual create mode 100644 test/snapshots/dump.golden create mode 100644 test/snapshots/dump.snap create mode 100644 test/snapshots/edit.actual create mode 100644 test/snapshots/edit.golden create mode 100644 test/snapshots/edit.snap create mode 100644 test/snapshots/exec.golden create mode 100644 test/snapshots/exec.snap create mode 100644 test/snapshots/fileedit.golden create mode 100644 test/snapshots/fileedit.snap create mode 100644 test/snapshots/images.golden create mode 100644 test/snapshots/images.snap create mode 100644 test/snapshots/load.golden create mode 100644 test/snapshots/load.snap create mode 100644 test/snapshots/look-dir.actual create mode 100644 test/snapshots/look-dir.golden create mode 100644 test/snapshots/look-dir.snap create mode 100644 test/snapshots/look-file.golden create mode 100644 test/snapshots/look-file.snap create mode 100644 test/snapshots/modal.golden create mode 100644 test/snapshots/modal.snap create mode 100644 test/snapshots/scroll.golden create mode 100644 test/snapshots/scroll.snap create mode 100644 test/snapshots/syntax.actual create mode 100644 test/snapshots/syntax.golden create mode 100644 test/snapshots/syntax.snap create mode 100644 test/snapshots/tag.golden create mode 100644 test/snapshots/tag.snap create mode 100644 test/snapshots/theme.golden create mode 100644 test/snapshots/theme.snap create mode 100644 test/snapshots/tty.golden create mode 100644 test/snapshots/tty.snap create mode 100644 test/snapshots/ttyonly.golden create mode 100644 test/snapshots/ttyonly.snap create mode 100644 test/snapshots/tutor.golden create mode 100644 test/snapshots/tutor.snap create mode 100644 test/snapshots/windowops.golden create mode 100644 test/snapshots/windowops.snap delete mode 100644 tests.zig delete mode 100644 tests/browser_replay_dump.zon delete mode 100644 tests/browser_replay_e2e.mjs delete mode 100644 tests_sdl.zig delete mode 100644 tests_syntax_perf.zig delete mode 100644 tests_tty_perf.zig delete mode 100644 tutor.txt delete mode 100644 tutor_gen.zig delete mode 100644 ui_sdl.zig delete mode 100644 ui_vaxis.zig 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 Binary files /dev/null and b/assets/AdwaitaMono-Regular.ttf differ diff --git a/assets/DejaVuSansMono.ttf b/assets/DejaVuSansMono.ttf deleted file mode 100644 index bcf640e2..00000000 Binary files a/assets/DejaVuSansMono.ttf and /dev/null 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=)"); - // -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= (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 /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; - - // 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") }); - - 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"); + // static archives the emcc link needs beyond the zig object (grammars, SDL) + var wasm_libs: std.ArrayList(std.Build.LazyPath) = .empty; + + 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); + + // 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"); + + 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); + + // 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"), + }); + } + } - const run = b.step("run", "descend to the pardes"); - run.dependOn(&run_exe.step); + 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 replay_step = b.step("replay", if (is_emscripten) - "build browser SDL replay (requires -Dreplay-dump=)" - else - "replay a pardes dump: zig build replay -- "); - const replay_wasm_step = b.step("replay-wasm", "compatibility alias for browser replay; use `zig build replay -Dtarget=wasm32-emscripten -Dreplay-dump=`"); - 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= 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 /cache/sysroot or set EM_CACHE/EMSDK").step); - replay_wasm_step.dependOn(replay_step); - } else { - const emcc = emcc_for_wasm.?; + 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, &.{ - \\ - \\ - \\ - \\ - \\ - \\ pardes replay - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - }) 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=)"); - 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=").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 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); + 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 { - syntax_perf_step.dependOn(&run_syntax_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=").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); - - // 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); +// the html shell: a full-viewport canvas wired into Module (from the prototype) +const html_shell = + \\ + \\ + \\ + \\ + \\ + \\ pardes + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ +; + +// 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 -fshader-stage= -o -> .spv, returned as a LazyPath to -// @embedFile. Only used by the SDL3 GPU UI backend (`-Dui=sdl`). +// glslc -fshader-stage= -o -> .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 +<> +endobj +2 0 obj +<> +endobj +3 0 obj +<> +endobj +4 0 obj +<> +endobj +5 0 obj +<> +endobj +6 0 obj +<> +endobj +7 0 obj +<> +endobj +8 0 obj +<> +endobj +9 0 obj +<> +endobj +10 0 obj +<> +endobj +11 0 obj +<> +endobj +12 0 obj +<>/K[214 0 R]/ParentTree<>/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 +<> +endobj +18 0 obj +<> +endobj +19 0 obj +<> +endobj +20 0 obj +<> +endobj +21 0 obj +<> +endobj +22 0 obj +<> +endobj +23 0 obj +<> +endobj +24 0 obj +<> +endobj +25 0 obj +<> +endobj +26 0 obj +<> +endobj +27 0 obj +<><>]/K[26 0 R]>> +endobj +28 0 obj +<> +endobj +29 0 obj +<> +endobj +30 0 obj +<><>]/K[17 28 0 R 19 29 0 R 21]/Pg 245 0 R>> +endobj +31 0 obj +<> +endobj +32 0 obj +<> +endobj +33 0 obj +<><>]/K[32 0 R]>> +endobj +34 0 obj +<><>]/K[23 24 25]/Pg 245 0 R>> +endobj +35 0 obj +<> +endobj +36 0 obj +<>]/K[31 0 R 35 0 R]>> +endobj +37 0 obj +<> +endobj +38 0 obj +<> +endobj +39 0 obj +<> +endobj +40 0 obj +<> +endobj +41 0 obj +<> +endobj +42 0 obj +<> +endobj +43 0 obj +<> +endobj +44 0 obj +<> +endobj +45 0 obj +<> +endobj +46 0 obj +<> +endobj +47 0 obj +<> +endobj +48 0 obj +<> +endobj +49 0 obj +<> +endobj +50 0 obj +<> +endobj +51 0 obj +<> +endobj +52 0 obj +<> +endobj +53 0 obj +<> +endobj +54 0 obj +<> +endobj +55 0 obj +<> +endobj +56 0 obj +<> +endobj +57 0 obj +<> +endobj +58 0 obj +<> +endobj +59 0 obj +<> +endobj +60 0 obj +<> +endobj +61 0 obj +<> +endobj +62 0 obj +<> +endobj +63 0 obj +<> +endobj +64 0 obj +<> +endobj +65 0 obj +<> +endobj +66 0 obj +<> +endobj +67 0 obj +<> +endobj +68 0 obj +<> +endobj +69 0 obj +<> +endobj +70 0 obj +<> +endobj +71 0 obj +<>]/K[67 0 R 68 0 R 69 0 R 70 0 R]>> +endobj +72 0 obj +<> +endobj +73 0 obj +<> +endobj +74 0 obj +<> +endobj +75 0 obj +<> +endobj +76 0 obj +<> +endobj +77 0 obj +<> +endobj +78 0 obj +<> +endobj +79 0 obj +<> +endobj +80 0 obj +<> +endobj +81 0 obj +<> +endobj +82 0 obj +<> +endobj +83 0 obj +<> +endobj +84 0 obj +<> +endobj +85 0 obj +<> +endobj +86 0 obj +<> +endobj +87 0 obj +<> +endobj +88 0 obj +<> +endobj +89 0 obj +<> +endobj +90 0 obj +<> +endobj +91 0 obj +<> +endobj +92 0 obj +<> +endobj +93 0 obj +<> +endobj +94 0 obj +<> +endobj +95 0 obj +<> +endobj +96 0 obj +<> +endobj +97 0 obj +<> +endobj +98 0 obj +<> +endobj +99 0 obj +<> +endobj +100 0 obj +<> +endobj +101 0 obj +<> +endobj +102 0 obj +<>]/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 +<> +endobj +104 0 obj +<> +endobj +105 0 obj +<> +endobj +106 0 obj +<> +endobj +107 0 obj +<> +endobj +108 0 obj +<> +endobj +109 0 obj +<> +endobj +110 0 obj +<> +endobj +111 0 obj +<><>]/K[110 0 R]>> +endobj +112 0 obj +<> +endobj +113 0 obj +<><>]/K[112 0 R]>> +endobj +114 0 obj +<><>]/K[]>> +endobj +115 0 obj +<> +endobj +116 0 obj +<><>]/K[259]/Pg 246 0 R>> +endobj +117 0 obj +<><>]/K[260]/Pg 246 0 R>> +endobj +118 0 obj +<><>]/K[261]/Pg 246 0 R>> +endobj +119 0 obj +<> +endobj +120 0 obj +<><>]/K[262]/Pg 246 0 R>> +endobj +121 0 obj +<><>]/K[263]/Pg 246 0 R>> +endobj +122 0 obj +<><>]/K[264]/Pg 246 0 R>> +endobj +123 0 obj +<> +endobj +124 0 obj +<><>]/K[265]/Pg 246 0 R>> +endobj +125 0 obj +<><>]/K[266]/Pg 246 0 R>> +endobj +126 0 obj +<><>]/K[267]/Pg 246 0 R>> +endobj +127 0 obj +<> +endobj +128 0 obj +<><>]/K[268]/Pg 246 0 R>> +endobj +129 0 obj +<><>]/K[269]/Pg 246 0 R>> +endobj +130 0 obj +<><>]/K[270]/Pg 246 0 R>> +endobj +131 0 obj +<> +endobj +132 0 obj +<> +endobj +133 0 obj +<><>]/K[132 0 R]>> +endobj +134 0 obj +<> +endobj +135 0 obj +<><>]/K[134 0 R]>> +endobj +136 0 obj +<><>]/K[273 274]/Pg 246 0 R>> +endobj +137 0 obj +<> +endobj +138 0 obj +<><>]/K[275]/Pg 246 0 R>> +endobj +139 0 obj +<><>]/K[276]/Pg 246 0 R>> +endobj +140 0 obj +<><>]/K[277]/Pg 246 0 R>> +endobj +141 0 obj +<> +endobj +142 0 obj +<>]/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 +<> +endobj +144 0 obj +<> +endobj +145 0 obj +<> +endobj +146 0 obj +<> +endobj +147 0 obj +<> +endobj +148 0 obj +<> +endobj +149 0 obj +<> +endobj +150 0 obj +<> +endobj +151 0 obj +<> +endobj +152 0 obj +<> +endobj +153 0 obj +<> +endobj +154 0 obj +<> +endobj +155 0 obj +<> +endobj +156 0 obj +<> +endobj +157 0 obj +<> +endobj +158 0 obj +<> +endobj +159 0 obj +<> +endobj +160 0 obj +<> +endobj +161 0 obj +<> +endobj +162 0 obj +<> +endobj +163 0 obj +<> +endobj +164 0 obj +<> +endobj +165 0 obj +<> +endobj +166 0 obj +<> +endobj +167 0 obj +<> +endobj +168 0 obj +<> +endobj +169 0 obj +<> +endobj +170 0 obj +<> +endobj +171 0 obj +<> +endobj +172 0 obj +<> +endobj +173 0 obj +<> +endobj +174 0 obj +<> +endobj +175 0 obj +<> +endobj +176 0 obj +<> +endobj +177 0 obj +<> +endobj +178 0 obj +<> +endobj +179 0 obj +<> +endobj +180 0 obj +<> +endobj +181 0 obj +<> +endobj +182 0 obj +<> +endobj +183 0 obj +<> +endobj +184 0 obj +<><>]/Pg 247 0 R>> +endobj +185 0 obj +<> +endobj +186 0 obj +<> +endobj +187 0 obj +<> +endobj +188 0 obj +<> +endobj +189 0 obj +<> +endobj +190 0 obj +<> +endobj +191 0 obj +<> +endobj +192 0 obj +<> +endobj +193 0 obj +<> +endobj +194 0 obj +<> +endobj +195 0 obj +<> +endobj +196 0 obj +<> +endobj +197 0 obj +<> +endobj +198 0 obj +<> +endobj +199 0 obj +<> +endobj +200 0 obj +<> +endobj +201 0 obj +<> +endobj +202 0 obj +<> +endobj +203 0 obj +<> +endobj +204 0 obj +<> +endobj +205 0 obj +<> +endobj +206 0 obj +<> +endobj +207 0 obj +<> +endobj +208 0 obj +<> +endobj +209 0 obj +<> +endobj +210 0 obj +<> +endobj +211 0 obj +<> +endobj +212 0 obj +<> +endobj +213 0 obj +<> +endobj +214 0 obj +<> +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 +<>/Font<>>> +endobj +227 0 obj +<>/Font<>>> +endobj +228 0 obj +<>/Font<>>> +endobj +229 0 obj +<>/Font<>>> +endobj +230 0 obj +<> +endobj +231 0 obj +<>/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 +<> +endobj +233 0 obj +<> +endobj +234 0 obj +<>/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 +<> +endobj +236 0 obj +<> +endobj +237 0 obj +<>/FontDescriptor 238 0 R/DW 0/CIDToGIDMap/Identity/W[0 78 602.0508]>> +endobj +238 0 obj +<> +endobj +239 0 obj +<> +endobj +240 0 obj +<>/FontDescriptor 241 0 R/DW 0/W[0 0 500 1 1 527]>> +endobj +241 0 obj +<> +endobj +242 0 obj +<> +endobj +243 0 obj +<>/FontDescriptor 244 0 R/DW 0/CIDToGIDMap/Identity/W[0 8 602.0508]>> +endobj +244 0 obj +<> +endobj +245 0 obj +<> +endobj +246 0 obj +<> +endobj +247 0 obj +<> +endobj +248 0 obj +<> +endobj +249 0 obj +<> +stream +xœûÿÿÿÿÿîø +endstream +endobj +250 0 obj +<> +stream +xœm”ËŽ£0E÷|EÍ)½ÈæÕE‘¤,ú¡NkfMp%ƒ â±Èß�ð­$­Ñ,]WÙ·NÙÆýñq˜¯usäyøÓ§Oî›±+yž½­ãºÛ¦k6óf}‹ö j»¦ìy l¿Ý›jp\woÊ˨ù–ó¿” Ÿ+óH˜<(û¡©×ýª† /h†²5Ñ^³ªáJþ“㺿¸ë«Æ,(p\wgtÖÔSq½ã‰y]Sx Set'Ntœ|�@‘®ÊA”}—uÑÚɇk?p½7§†Bd鱕L""ï“ÏU?tWšÙžHó ‘÷NsW™3ÍnÅ~ ƶ½ðT$ùv”�¶_o‚+j&O€ï£BIÁcèëÚ²,àý~môM(±l4÷mQrW˜3;Kß÷ý-ó<ÏW“ã?ñ(Æ´ã©üSt6=XÑÒ÷ce•²*  B«R!AÅP1T‚Ì*µ*\C=[¥ÄáŪHÖp—57Xå*ƒ’ØóÄo÷TŽÌĪ`j€ï'/PàKr(áÛB�/N¡„îøRT€/É À— /øRñ_‚NàKÅ|±Ä„Døa¾ä1Ædc•c„Þ(0†pQ`T¨U�1Ä* +Œ!*W²‡2Œ‘ÄÀÉ*²‡è·’=D#P)0*tQ�Q¡7 +Œë­=šr§C:ÝÅû­(Ç®c3Ø«h¯ÀtÞ+Ã÷;Ý6í4Ë>ögpû¯Lê=ÿ ˆÚBÎ +endstream +endobj +251 0 obj +<> +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×…Ç„†¸ÅE†„F»Å†…ºÍü`á"7ÿ¨ÈX·¹á«C#cBݼ¼ÜÜbBCÝÂbc7LôöŽ�[;:*z­÷š¨ÈØïuOÅxÛ~çåÿîüE^sgO÷›¿Ðotlb¬Ûš¨h·�ÐØàðu1£ÆŽQ0£ ¿üO�¾H~¯WTqbß>‹¢ìsú‚ÃKRß:‹ÅlÎÊ6çå98 ÷’”ÛRœ[dÉÏÎrp4;¨”Œ‚af´­ŠýÍf[Õ^ef²­(.jf´íŒ>Ìjæ±b©âs»Ùv¨ôQŽSV(»ú¬ï#±ƒØTöïœ'—Ê;óõ¤ÙIÎÛO·—úºö=ôRÄKF:\s\ëxZ5IïÔ×)­_b¿ßœ':/wFõvõ?„7„yôN²Æüfl—艮؀^ÊîP9UƒvÃþA'Š=tÈôÜ¥ŒŽk°<„Vh…‡Ð`$e0ýxÔ"ù+Úk©—Ë x›Žé@lÇAŽ€)Óµª÷ Öõ¾[ƒ£¬ê}hÿ6:ãxä„xùØMU +¤ˆóÍ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 ÛÀj$ÒxœÎ_Ù‹ª_ 19rUmÂ~qme“VUiKZVZq�”Ÿ nÀþ˜ƒœ)?t‘øyéШù+´R¿ç›Ó%ü²Æ%¹¡CÆÑÁôåGá0í§ptOÍ^²œGEŸ”¤U‹¦U #\�£p²ºq‹TXg )bw$QùšpnN´ªk±?¦=XÞ.<’·¡½¦,2Äd0e6ż1œ‰¯|¬ÅSÜîBا…õeÇÖX—ù¡ãÇ»Sg:ð—8A‹c[—jyÔÐ4.«ÑÜRYWÝÔ´c?´‡,:ÀFTô ƒUT«n¸:ã(æJÁW…»ò8tÓ@kB}ØG!û#r'¯É›Sœ¿å@„{_BCdÎ"ófsF%�=�S¥ýš/‡‚$q;˜ $qcqXîjXt4«+6T$7…5®o!Â5«áËx¦zC †�×R[3J2` �0Äj©‚O„mebdAåNR]ʶE‡ + 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.@=Ùäĵt?ZºŒ ±I‰9¹ZÈÊË'¨@;ê C «¢S Mø/IgUw!§“°¯$\“ÕØOsB·”Ÿµ.T‰¸=$^¸H=p¡Žš3=—f$k¿¡ûx·ÛqWÅ¯Žœ¹¦U¡ÖKXØ«ZCp‚¤ì^‹û4:.ŽŠQcé< tÖŸâ~* š¦•ð Ž›¶-h„¸ q‡ Úwu$¬CÃï +[ˆ @Ï·”Ðu«�UU‚ôD$�CŽr]�œP'SÉwð�>hôGÂâ÷7.%€ŒM(¦þ¡@µ$ eQq«ý³ƒ•ñSDZõTETúÒxy¨¤�¯Ëƒ5mŠÌŒâc{åû�i¶:š2¶J¨… +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¾É¥ âBI&Þㄎ?\/=Çþ_€¬¤‹%l•RD“ú1r˜ƒý…›rÆhðŸûÔ]œ +‹×Å&hgÿ°¨¶ÇüÂþóÙÖ°Ã[›�àˆ_¿G­–öw¡ƒý–Ì·€VœÔƒÊ †ñPn6—”áfSóñÜÝ@.ŸžOÕ½-Éw{3%ȇ°ÍEòïÄ _ü)+'[à«Üÿii#ƒEˆÛë>*ú²ȘØöÇÀC´=¿«¼´lWy~Zûw�àð®§ñ‚m ‰\¨�?9¹u'Ál~_ýÑý§UÔüˆJOuVùI±yåS"ǘÖ?'2H�ªÍH¥<ͤV½Àáw{ǰq3gÀ0™Ó,ÙU%POêbö„GE&†,•V�¯Ã'ç÷�!!²y%–^~»šd§&…ÍZø¢³RŽF{MÉØ*&A¦)#eã܀ĥ@¨гí4  j´õPj,6&m2ÅÃ6òÌaÝ¢$w�9�ò”ÐWî�ÂÑø—HòD†?Q§î¶Þ¤bMë3c€Œ´%UО©×‚ÑhÈ4’!´€rXwÚ–gdƒ!8k“™èÎò•ËÀÛ{WM¢‹†`Qï"'#UX@wЫ§/›—ü óy¥ä1îxLwüûºãc6§8;ÿ¬�zIþg«úBÇI¶ë˜Ø.Ü“¯âJ  râMŸrB‡Ö°« „O¡õ£ó¿î~ �áUÔ5;LÏl¾óÄg¤Af¦~+Y±’]Ð6§ è(˜>†ÀœºÀʸöåÓQs'±Ô¸;ž×wnÞ»!`£ÿk0訊4žY9qÏ<Çœ'ž£²²òóH[{iù=ƒ ÿožG¶ó©%ìUä^±Ã ä��»Ï !à +uô�ÿ1„ ù7—.ö}Û¸÷¤(¸Þaz ïv3îg±þ +m7Èd^E+,‡±K VÚ#s�”òl¹[Ó²öÈäá” ¦¬è1U÷%íú“‡íB^¢Ž�<Ú}U°WÌ�È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#Ù–)¢Ž?�uÛœÄ +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®AíOßà@­ðˆºfi<`š‡¸64¦íÚT × Œ óI锸¦zjUA€Îr‚¤>ƒîè�¢NîÉ%­ÑqÆ„mïlIצÂ& Ô““�·©#B'rß]�àAþµ¿ÑÁÔÑkÞ˜÷ŽnÞSÛ´û˜ǃwmÎÞ¿§¡È +· 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 +<> +stream +xœûÿ +~6Ì ð +endstream +endobj +253 0 obj +<> +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ʾŽß³ÿ¼;2Íør*”¤¿ŽÞO˳ßOm}«¶æ¡ÛUÜïü�“¹RJ-h^–e¹ÿ‰9ûØWv}L× š+•»ET*ƒ²QÊ!v•AP9Ô#T•QP·ˆåPwP%Ô Ô”xY!ój�˜Ô{\ÐÜ(-™eŒYÜ¢C”²¨®ÁgQ]ƒÏ¬¡Àgq§Ÿ»_&·Ÿ†_g|…T>©¾Ôà³K(á[A ŸT|JR|™|9h ø +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 +<> +stream +xœ}zwtÇ÷ïȲ´‹,D À® ½— @0¡w½cŒ� ÂÝØcË]Ò•ä^qÁEË0˜¡) :I @€Z SgÍî;k¾ß÷~ç½sžÿŸ]ÍÎܹwæÞÏýÜQWW¢P(>šî1fÚ �ý¾õY®÷ +‘ß�—:H¡… uRnDøÐ¶à:²ôw«;ª­]ëT�QXZÉí“Ö�Alñ©üp¥EWùßÏ-º¥ Q5Ñ(„Ñ«WúLZåæÕsL`P”ÞÇ7¬—Çà�ƒ†õ>~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Îí—iø*cË6Ú+�(÷{Ú�pœÀ§§™£EM#IU•A;¿t¿újÜ9í/ÛÛ:¸Ÿ +‘�‰ž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%œ—Á"ø -Ñ¥›+³kjÖT®¦€÷üÈYkWF}«³#Ü©âõ»¤ý£µË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Å)Î[–(~2À.øÁV¼žÓ!²)Áˆ÷êRMOY•Eü“&Eɳһ%HÚºŽø´“å˜î+åï ÍMPGm[åïo«Î#>˜ü ‘5»„øxÃï" 6¶¾‰„܈Cd‰QHWŽ#¾½¯àëYí#Z B¢x¡@¢Úø yê@¢Z3�¨†tã‘´tñï¶ÕHÚ4\Fü£™Cpp9€dLGƒIë¿ à€rk�…×s+ñžÂñ¯½žˆ¯Þ;ÿœìäY+öDÖ\à'ˆÒµ^ˆ¯Ó ¾ïqÕ$k.Å__+G¢ðo@|·¥uÇkèKšUª Y®o£KßG«5ð;}ý¢Þá~ø]þ0Ø®}% kk+® ø5@ÌиDDöp%â»Îâ¿íŠÙ-Þx6~:©BR^üIñèb$^^nH>wÞ$Ñï “C…nY1’è÷-�|RºÉ@Å+��j¢ûkÇã»T ìãás�µgÍFyß  »H§ŸTäÐbe�®£Å'Y±Z#�ù}{„â˜=Ý~æ�ò^ÃP]…:u­è2àé'jX�ˆc7 ¾+9‡H&#¾ZñÏÚ³*ÄãµNDÇsOÄçÓ=“Ý ®jtòˆçÒˆñNY†xwa/Ä+¯"iÝó(’nWG €Äßc:’OÏ­C’~ù’eƒ3‘$·¸Ï¿¤=*Ä…“«ñß½s!)±þ_OÄ¿WË'ü#'¢3Æ ˆø‰'o¶p–ítÈÚ¦®OèÔ2Úö–ûá§K«hä}íÚNRéè|®2~[ÊVàéÇgg¥™ÒÄxX°v �¥Ž À'™RâEv�‹IMÎ2 Õ’–™‘S{òO¨‚ªˆÂ5žika|·xõêÐððhà™|^™3ÒLHÔw €ÄU4¯}k¯(¨·…—…Çx�ÿ�¹Ò¶ô£o(O›ù·‹0V†ðšjpÐZ;=S­ ïÒÃ/•R7zQG[¼@2û�ÉòfžÔ�i.1¥ÈÆ›  ¯ÕÉC ØXÃyÅ¿6éÕ5pÆvNÀãnà~Ü×Ñ;Eš|—;h×±t«º:×V-÷<8¸ó/:*ƒ×°–MØCA+h–nd½|4¿¹�ÄM5ñ}f$ʆɞï$š•$­.OE·õ@¢�_‡„<¿Ã;¸`Xbô_ð·†Ënï ùøöD|8ÉÇnˆwÛU#éµ®ñ̺R$=ë–#ž©[ޤ×-‚xæá—ÍRÍ› æ„DÄ{³ß#><ƒøËvÄÇ_Æ/3ÉÞœ¸¯&HH0AÒ#ó%—T‚¤£ª% qÿ¢� øª±±�!ùØó”:{÷’ÛO~DBÏ‘(šmCB\ÛÈÑŸMÛ„Äeb6â«ëW�¸-˜H>ú´�´ðx‚D=õ$­|×#Ñ@’Ö{?â/Þ~ÀZª�(a#"›´‰K·Ä·Û‡ÉŽûŸù<â¿%~ˆ÷;]FlˆÙ�H�ô\%·•Á.Øm,•£Ô·€Xߟ"qéR‰øðR3$âÍEˆ¿<|�dX† þ:Œd¨ÍñÊçÓ‘ ¹%ñמ¨�|ñƒɘÁIÿ¯ 2Ä$56ÞÁE™×˜ÖÀð±†ÉúG¢™ý1".œ‰Dóúâk×€ø4ê¡|ªãœˆ»÷µF,®<'ï9®S¨QÒQÑ!¾iN�´Ûo@âþš i>Á°_–:¦ƒ +[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“É”9cÕÒo`& ; +´/Ü,:¸³®´´,·öÃÞ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{ä»§Z˜µâ’1Á$Cl$Ã~v"ñúÜS Ó¹ÚÈÌ¥‚¶¯Ü½cS÷·ª•™±§„lµ¦?›ç }ˤvŽh»û«K´à¶öŸ­RWÝ¥Ìä%bãÎ;Æ<¹)ïûÍ–EpÍX$/d¢ˆt,W°ú¨ÿ1�‡�¬ó1 Yø¼9›æµ¨½I›!–}áùÑzÆ)¬‚… ógñpN¥ý‡u5¨hö‡ŽÆpÚ›åûöïü øÓ—ƳvˆëY›aˆ‡]ÿ™!Žß+)ż¬à— +:±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ðþ“B1äûÉÞI¹Lµn\Є) QYñYå¶‚ÍPÊW‡æDm Ž[ãµkÕ1á Ô/?ÁÓ1ÒUÑw»ËwÉÛ Á¼Ôka¹-RöDµP¤�ÑËæbžëéÜÿÿIŠlTê1ò’çXÄ«gfâ³UA|bZJZΕ”ßfúu®_TPppaÐNIëVž€„¯w¦e¦óÈ ™¬êøp{©û«ótÚÃIçµ·¶¶up²­u"½>Âq�ª8êBÝŽü�dêj'’Ïó<—Ï^Y\)@–uç~ âë²êï9ïßs"iÛÁs1ð“†zõ™’väZŽ¥Öeð�.ÜŠ ã‡#½Ü¶VÂ2c°¬í—2­ûDZÎØ”¾D`½™§šõåVdš. z.¸©3¿–Ê4X“{ù çÿ«Í/à�˜C±ÃN3ïÑL»²ÍPúuÛÝð‡í<„‹Æ­À;"9Ö,>³V”*¹[ÚÂ5í¹Þù¬?o‡ÞH!‚…ÛA•OæÖÒ>ÀoQ;S“ýÅÆJÎ'>�5"Õš]†ŠM2AÓJݩ빇w÷]üÀ 6гÒëDyõ_îlP‰±)Q1‡mލÜVPR¾Ëwû¼q_Îë,0N¦Y®qË 0Q=UÛT{à{Øc”ղɰ^=Ú”³S¤¿ÑP¶�c­Åî¼¼õòÏÂ�!s¸Ñë||íÃDxP(Ríâ`]èÔÎ<,´#M[º‰*¡ÁŽœ÷×ç´×¤L‡n%D¤­ÛÀ>¦SãósóÎØ·ëPIÓ^Œ5™õ ‡Å–5V^ŸÊ˜²â`Ä$Ç )Fÿu>kùqÓ@ +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ßÁfsø¾ÆÊÙsÀ:°^´åÈ»â%ؽsÛ.žMáüãQzµØê öí²*Æ%Øöˆ“h¶î[Ķ;Ç#ží‰¸«¯ñäO$Ëš— xå…$H¿É׆íHæg~‹äÛØ9ÂeØ1µ`&¯auMARA;ÞSÒÓ]¹^ío�j™ ³ašÙßÄ;ìÜu[ÎNh!ÐBU�1÷+!Ô¡®2�3ƒ*xn)µòúPîëÄx_X!°BÕj›á¦ ayÿžTÒAÔ¦úå*^|ŽÄ½Ò€¤CA¢[h@Ò>€”—!i9ž i±ß€øæ{‚Ä}‚!,jéjÄë� ˆWæ{"Þ¹k@<»Í9£çZÖ +˜L¨f­Nõ䑈�LòO·d!wÿôÀê²($ÍÆ»ÉCY_x#Z†øX }A|ºð?¢àA¢EâKZ"ÞħHÌøè—27Ǭy +yJI7|X„Ç?�ܺ¾"´,&/«¨,êÀêÚeާzVÿW¨µ¬ÕŒž¼~�ϲ¥«Ã¢â‹òòÊ˶To½ø|›Fí^Ý0°Òý˜=ø,=v^…öÚqºJ×™æ +)“5ÅjJ‡Th*™Y²òs‹ø-5ªïVœ +º H‚}Î’Ù_"YnQ’ PO§Üüz bc’ yáD\¿Á€¤sKÏ$+â…­IH>êƒøë‚�Húß�‡ÓpõìdpY;� Ÿ¾P>kŽD¡¹‰H{�(žwEl|?- 2 ÕjMåi$—ŸhMRñÝnÏ�ñD|?M㇜ƔÕ3L&Þ¬ö¯öÛÕmdfhÏxgOˆ Ÿˆdšó"âéß–#ùrÔ@Äg‡%Ò6�‰Hú.Ê@üwY3$=íˆç¿Û ˆŸ,DçÝŸ¯ÎÛƒXÛi(byÕ4à™ŸO -xí#s‘ +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Ý`в§.à þ�ÏŽ÷éçö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 ߸™Ó[–=o3‹m+`,þ€|þIß³C”2¸jSª§ì 3Õ‰™±t$³ðt"+i¤jGÒžÞ‚ƒ³ÃAãwð´Ùåu3/Ó‹ª ·7 ­P{!­x¡”Ú…ëèx:€¢ÞÔ› ¤Ùx6ž d™7ó¦ƒØ :^´»vgmÑ\Cu÷ÿý—ê±–ËÚôï.×/&G(î4´Ñ2½ö [ ++?Ç£W¢×FŸ ÿÈÐØdH€äŒÄ4H…ô´œþ ³âœçÏíõ^ºNÐLúßu§WçéêsÊ6é�mËáPSm戱J^ö@|ÛΉ+n—ùœ;Hz�9<ý�òÖ\ÆœÉȾ‚‰xå®P9)Ƙ” › ”7"!.—¼‘ô{7 ¼°È¾geÕRa¬Ž +__éS0 ø®]§YKX]å–Ìÿ-}ÇMÈdR¬œ{]¥¾ì:çkœ$ü.eé˜+ó§÷àÖ�SBìL.Ý´>9zCXAl^Š)ÁœØÄ'omhUá^w8|?-ß~D{‘ž¦ïu?Tœ?7ùºÙmø7ýxÆw{8¿eSBŠŠµ�’4‚ø/¯NÛ<£ŠË´¥ßmØ¿v;Vî¿ÅË·nÖͪUõÿ)"Žð'Îì»,h¯ýòó‚oD˜½löœ ~Ô,íÞO¥ý±:¬ÄüøQ_/9ôë àÜzhÿμÊB^SßàQ­ t�RúSº­cÓ7°¹R†*±Ø¸å/ ïàÖ˜g™AL´ùË{è,”˜r×ðÔðN¥²Å§'çC.¤Ùl™<�’êU¹Õ;+OȺŠ�iÆpˆ€™6?ù«C暀?^âֿ�ŽPÐæ{”OX˜Ž>~;\…Äõ§$-ÛôA¢2Ië'¿FfDf 6üTƒø¬MD6d6â‹'¿òìñûáªä˜¤¨âÄâÄô¢´<^>at¡CZ¡ R­²a¥C7Nf˜.|ŽÄeV=â«�g‘tÜñÏB>V!6n÷DÒ,ˉøj¶‰b­g$â+NNWwÄFIÎ gžéßU”Ÿ~Ã"Ÿ WÃaÛ^¡Nš*̼ƒ›^Y!’f WD’R¹…§ Fâîÿñ´±I·¼_ó¢Êõ§‰-yćú­ˆ¿uz.Sv±ˆ·o�âõêë�–ÃpŽÉ�åÑ\ W×R²‹*vQÂ[L*HN�ÍNóö„y•I×9´ó+÷ׯ&ž×þRȾÖYÍ¥±ÕÀ)9\”>çv†eþ÷–@ßhLP}>uò7ˆ5l âíHzï2 éÞÝI;‹Ië�‹‘´)4 éôô>aÊgH4§!>*w">ºjd•v”uÖ`@|êGByº ¡RWf_'hóü#C‚Ê‚«jJ¶Vˆš»áef=ZF�:Ü©ð'Ýñò‰]#M¤�tW +Ÿ…ø¿¹Çú ì¼¾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óÚë•]˜Ä³ù®ì�ÿ;Ûýþ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«ÕfI…T°™­&« ²�õÖrM)&“Éü&0Ù̹1´åpêÆZSëñ5°<`¨®mc–¯*”®§QuÞu4*Â];ð횪‹K7¤Æ¥ øaÖ�ˆzÞ[�;üÀ—'¥'¥%g¤ðZÏw)fUl´)JÔzD˜r­³!ŬÚ¹~c¤×Bý”¸ÉüuIý‰à³ÓrR³ÓyM*8è÷úýòiŽ�~jWÒ×mWY'XšÒÁ&¢%Ÿûµ@N é¨ÌŒCâF+f"!Êf¼¿\t‚C:[Yx¨¢¨vŒõ¾‡zF<øŠ,ëîéžÔ'b¡�o–ÇX¹ ñææ}HÈŽˆù«&!Q4tçç²Tõê͉ׄj�ÔJx �ÔM‡¤`7AÅÍvNù•›—rIõ«Î<’ON$ ÞŠpAÒþa♫œààJà´± +vî&ˆó‡E›6-çÙüÆ'ª”Xcr,l’ƒ]ôb$-zNEd v$-¯Öñlˆldc‘Êœ’”’q–d«)ƒgÞ UbYtÆ|àcj;5„ƒpÜZcáõrQìÌ�GˆÿÞî�X_?ñÙÃWdû!!›g 6Ö D¢˜V‚ØèÃÓ/ T”FÇÖª¢Nز=��s¨7›o˜*áülÙ*KŒxTýñ鞈/»½@üGúC“Ý[‚„ì¹…ÈNßB¢Èè‚ÈS^Ã:„—ýH•RDµ¢ŽºÒ^w•uÔE—Ï¥Û ‹·_Sª_æ/¦¨‘¸>­C|Ó5Iç£}/ÞðC2avg™y5ë0’;Æ ™œO�x®&¢äËÕÆeöꦫMÜÚm[!ðú�œW¤ÉOlôáãWy"B³4ÄK›�ˆGß ˆµ#ddbüIŸ½sÙW#�,˜¼�×ü/€;]º +endstream +endobj +255 0 obj +<> +stream +xœûÿ +þ6Ò ö +endstream +endobj +256 0 obj +<> +stream +xœm•MOã0†ïùÞC$8°Ml'„*Ñ–H=ð!ŠvÏ%q»‘håãп²Ÿ) Õ½™×3óŒ­!þñ²½º¯Úwwe~&êÕ íÔ—îjõ¸ë¢8^·åttÍøä\åªst¸U]ß–ƒÕj³Þ4õÅñ¦)?¦Ê�=ÿ³,Ý¡n¾ ¾†ZMÃØ£8~«Çw«.ø BOjS¹f¬Ç“J.£8þåú¡n›[•FqüÐT«ö蛢™ÔP³—¾-·nTûº©z©¤Þ}Ý(ÕªªËQTø]w]8¼= £;nš}« ®jêÄ©”R³Ww¨‡±?©‹ÐØ¥ªÜžÈs_¹¾nêâÜì·àvêºç›TIøêš*ü�yø§ÝÑ©™~J•~}z;uNÌ~?¶ÕY¤´X¶•º]éú]spÑ]’$ÉBÝEQ,|Åâók޽ïË?»>ØÓ…ºK’y¶J£” *·(‹JQN‰åÄ$Ë<(}�ºÆ™£np> îqÞ –8 j…Ò¨5ç$ËJœYè:õH½FÁ—¯Pðiª§ðÍïQÂW „o޾% +¾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 +<> +stream +xœÝ| xTÕõïÚ¯sÎ<2™™Ìdò~2 1äAB€„@†@€ðÊ;„ðA ¤�?&-HxµFjS*1¡!¨ˆŠ©­–ªÛ¢¹6 Âds¿½Ïä>Úïþïýþßw&çÌ9û¬½Öo­½^û( 0Áj �:sé’¨«ÕªŽàÔ¢…³KÆó>ÀͰöüåEï~ÐúÙð@añ¬é…ÝÊ~3 É =‹‹gM×ê0Hî]ŠK–<9=¢'@rÔÌ/�9}÷‘Æ$ßßK¦?¶�Ý 9)Û jÁô’YŸt%~)õÎÍ K/ùÙ#?Èï/\4kaÖ›'T€AAj(ÔÃYù©EOC=@=¬Ç«·ÿÖÃ)8 XŽ«GgÑFôœ…½à…³°¾AFò&êk¡ +h4œ…*Ø-Ÿ¬"ÿ€2r .ÀøÊÈ?P&y ΢ �>�zØØ1 i€�pŠ4@9i (•Àóè%(‡zT +«p9Ôã1PïÒóð.¬‡õ°ž‡RxWr¶= —à0T@ lÇWa†×à4�}Ô7a®Å}qTÀi¨‚�°­…&XL�Ãg¬ 'ÂópvÀ ØÍšØv�kbMì:<áJ½âPcщÛ^t uÇ#á2B9Œ#��GÉÇh�¥ËÈU¨Â@¦Á\8ÇšT©±P¥¡åtšü-òáetª…«ŠC�AnA9ކӰ[J p�a#ÙHzŠ`7ì–«ô¿ŠÞ%·a-<�9B‘¨‚r:¶Ãˆ‡R(%0J¡œmÒ¡j!‰m"Ûp¹ŽJÇ}a7.BP…o»PJB/(‚pv ֡Ê@] ‹Y€^UF FÐ-Êz»ó z,ˆzkbtR·û¾FYÕ¨ƒ0ú ßò¨ú»wGÐP6ñ ;HÜÚAêŽýìÇn~–ÔmØè‚¨zäÊè#›7m`R·ac b·ø6pbttR·¼�òž˜õ sdîüi£fG=i}26ëI묬$ÀPÌ·Ñbö<P!òPä9ê�ÆÖb +)�›»ƒõbóÅæÔ[´Ím‹.¦à]LB½ŸómªåÖ7‹”§î^¦¡t +@7O�ÁB€ ¥Ò¶ÃˆNšý Ši(V`�ÃÚÒìmNë)WÒZšm®ÌÌÔ[ºÍ˜žÖ3Ön‹Í° Ø˜¸Sõó>þä‘#Ý_|tÿ>´×µ­Þüêoñò;{öM/üL̇¡��§ãh9X! Ò=a !•G¥Ö`Ù�N‘“á6»i°‹‚‚…[[šÓÒ¼B’+W®´4[¯Ù23SݶX[º³JO³Û¬86&õˆ‹�Qœ’´ìÈ‘ä…gÿñÏ·‹v ëë×=ñä“O¬[¿©õŒb¬;�¿Á¿à_ó3Ð�?~ôqÓÅ¿|¤óT~÷2µ+ÉSª'¤�§–´‹HnÛì¦!‚#osš¢¹�!Ô¶Yíéi�HÇE²E&Õ× †®~ñö¬�ÜúuëÖm çñ€ïš7��€ú '²¡^¸é�ýåbÓÇ ~ÖݽLci9˜ x0ªeòW¡.H­´GWD¯ìÒ`ß䇂h°Å¨˜FQÅÙïkKs£`Ж™ÙR¯´x—‚O[¦=ÓfÏLõ„§F¦F¥F§ÆÔ@ ªÁ5ÆÓžÀWMPMpMˆe2jDzWF¯6ÅfôèÙe$‹{j†€>Ð�pCßç~½bþö—Б#}^^ýÛwîüë[ôÄ–)'.:ZPqºo\Ntᬅ^KÞºfoáÔ×÷=þÄòž=êããÇŒIÛ¢ÛßZª±_At÷Y˜æOêÀ†NjuFÍdÀ«Ýâ°^Ìnôf7¦I±š[²›…ˆ©È팶9û §àÒ�mÃEhŸ2¥ìÜgçjyJd¿â'«Zk~6cËÞ³xZêçÓw-'„Až' 2V6)�uH©ôC¯W4øí'8ÌjT ?Ìnõé¾Ñfº¦¢¾¾û¾Å¿}Q¸\'Pëz:mß´™Â!ôÿ†Ä·aª +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žE5°‘jKƒá¤jTÐìÖ‹�z´hi¾øŽð=©·¬Èj‹‚(eK…T”nMµyÀƒX=¶Ñ0�¶Ž¶Ù'#]ˆ�ºèÚ>+ò÷×9’||íÀ¥=ÉòäþôNë:íãe«bºøb�W=MêQ((¦ááW¼ïè~Ö+üK*ºÇ¿âUOüðêo^áFŸ m?ûùî‹'ÉwOBWðç¨;°ÖÁŒÂbÉŠ…d‹EW½^Ô�Ÿ“¸� V: Pà #.US5&8W«ƒ“¤Ži(Ê%Ï(Ö�/˜5·4wxémzkMö8{’ÞZªM Ú,2W+#+4ãä€h§�<E_&I­+ñZïQ¼¶õ:cŸ÷/Ûj‰[Æý�w/Ó!2κ Ö TÚ¡Ò,âªÁî?�Ø�ý‚¤Ó÷©îZ*jÓRF�¸x_þ!‚>.®|ê©ÊMO=µéê·7¿¸zó&ùä£?6}üqÓ?ÚÍ?àãŸñ ( ‰…™,äzžN3²yòL˜`£ÁH6šŒ”QcT˜0eš¦ +Ÿ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Ý©¯ÁfzHq€ N{²4—Ac +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/D ‡è@ÅðƒdO°VæFã.8©à:J†›�†Ã0£j±^̾ÒìÍ–.íŠÈ%RQ´¨G2¢mÑ(Ý‹}ˆJ¼ P ÿ EÖ×ÓiÞ”ª*’‹\r]â‡H‹â€.PäÉöwš·!Äê 7±¨h¦:Ô¡7 o:ë^q› FÖ%0Â�,; *¸¯ÑŸ¹;Ò›¬íäG¦®2ZÙ]¢äk¿™ÊD ï‹zÄék°/E�žù85…‹º•´x¾{q^e¿~›æ¾ø�gPåC/(�ôPeÃS[/}]½¤jѶ뗶TMØ|ë—›ƒC7ï¾U5^æƒ[øgø” ‚<&òkØ£ŠÂAˆwÚ’i'‰ @7½§wÎD)çø´J·õæzzEºCÍþZ¨ÉìOY�àºP¨‹}#ôMÿ:Û+î°à§?¢ …8cíB"�}­`¢‰4o¶,Î~ yчGªË@¯dÔŽH  ‘Dtià¦ñ“J<<~ÓÀ~ßí›·ÉãÙ4oßwý +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�Ÿô´ŽíiZÆš@ƒHô¡j+T6Ø­F S`i~½maHwX½Â“é!©Eôï�Í©;:�:4ÙbQÕ•¿Ü½‰G‡o#ÌïÞùâm–Òznëºu[ö^þøÒßZ÷és it9Ù#û„¡3­Qp hL¬ ¹dÚ{„¢ô‰vFÐè;Ÿˆ6ïǹµ‚†¾¶i<00€Û íQäßo$‘®­½½r�ûÜ \êD¨\.w?¯×·âÉ›ü¼°É¢»¹´k"ü† EŽ ¢`��Ë–ÆT‘’å ÞkÇYÓw%€õú‚ÆûdñÃ{`?Ý£0® #ëMQ#ˆbÃæ+8¼^ÏÏóLYvBçø7d™â"<þ´Ny×Á+C¤¯ á•€x%‘h±àœÑèÚwý:/PUßy«$�Iü‚Ûh�:ô*4 ¯‚©f½¨z: [´0û ‚yÁõëŠãÖ_«*i@ÅÝË´;»Á�ì ~V¿—͇¬Õ†m¢lL3Cw%5DØ‚¯ø|ó‡ÊFÝT{ö¢ÝGìy˜ÆO¡þðžCkÇŸnl<]ðb~FBÚ†¢hGB¹¾þ>—¿Çß÷ô•¼À£t9Ý&ˆ÷8à9ªaϩČC(„i!FÅ,À•qÎwâëºÉJÙ†�¡?ðûø@tŒnákÅI-:ÎP…5�IX�WÃ�V+ õ"*ô2˜EÿJï)7§µ4§µÛ ŸgH_þ&ÊôžB™üMÖT{gUm-]ãóQtŒ\G6èêq*Õ† Pí¯€¿A%iæîj„5Õn•y�Þ;‘FáŽêi³ÆEÇÚ$z ȆÃÝ·ÏÜ…i…h<È�ñZ~~ï4 »sgKá[ùjþ8Fl lçá¸A öW¼B�b½¸µzß‘™nد„ßM°¶ÐXvÂàAO„¹™Â‚‚Cˆ+Ô­(,×j{Á¯Ú±�B5«#c„+ÆJº„[½�Þæ'ô|M€ÒòN[>*smö•+mbŒ[`ÔzõCºŸÕ²ÔéPT Beø%oÙQ”Q8hËê‡ßZ8ûÍé#ÓÄÂÞMµµµ§Qr¿Õ£VnÎðN÷´«˜vbIÿ¿·ÙãPvâá¿<}eïÊák^¹###reç +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 +ÝÚ@…0�E‘œ¢,YLm‘K´/¥X[t +]Ì“ÎòDÖT{»‰%ê4> b×Käjf�Dé=«·Y¸Òœ�.ùð,>ï-dM·›jÅs¢WÒ‹]‡®0Ø“ÕNc…a¯_µYµ7¬:v›²ÃùbB`GpDœ5‚ÄD: ‘ Voc£·ù¢ЬÍíæ`½–™úºÕSÓû ›h[vó/oÌþãì¢7fì=thûÎ�»Ÿ~bbCñò?䄨Fÿæ3ï}×åLF�m›ß±wEÉâò®]_‹Šúø•r±¹)vmEÏ…5A„Á\Ooƒ¦£â&”07¥$W¡à$ÔYmpTûm0Qf 4(ÄHlf!Æà4jëî0F˜SÃuÝ yl¢Ž²é]—æ‹ÍöLñÛQ@¶µe#�+ –$#·m2„+í!ß¹`ÌÙ?Q>óõ³¨æ&Ržk}ÿÒÓ›«ŸÂ¯;J÷òb´ü¹É­YÓ_?ØzOl½öøk6Êøy™Žf×!²=ÑÁ!HBªÃî‰!¡Áfè�¦dDoØ(LØ·ùE‘Ù2"šØb3¢éèÏ.åWx#ò ð)ûG?X;õôéão ˜•ðg´³|ØT�JEPÉèynD>?ÇßãgxSd4zz/Ø @õùé(èë‰ †j£¡Ú¾Ui3Gh˜�%"�¥…u7@„=5ZO~¤µè®[Z‹ÜbÓ#�’¡@áò¤ % ôè×·ýjß×_þbíš­|(zíó[k×ny�ßäßñAøLë¥òM¿Øˆ‹x¿…+-ÜûÆ+åΚî $1޿Іۉd™W¬ÓØD—Óe @´Ç.vҀїÐïTw¥¯Z½ÙÍÂ-Š}µÔ€ äDH¦àd y½ŽOÙ˧¼Šú‘ûÁÃÙu½O�Œžòƒ½:Â\´-bGgD¨ ¡1þÑ‘]„¿Øl½ÖÖ³¾x-ÕóÇHA)X€’ÂR”5EK1¤SL9�ƒr°€)‡å(9jŽ–cÈ1æ˜FÁ(4 +�2Ž2M…©h*žjœjzžEÏâgɳôYö¬ò¬ú¬ö¬áY㳦pÀÈz€P¨´†Æ¦ãpÇÇÉqzœWŽ«Çµã†ãÆã¦A?ÆŒo*2•NeS•©êTmªALüc„ºLF“‘ÜÓÒ7»|ÞK”Ì�›óèòˆž½G�Êì=lÝ¦ŠŠM›+*6}ãÆ×_·´àk½F�îÕ{äp¼›¿ÏÏð·ùû(õD½Pj Œ¯á�óÇÐôs´ +m�>­�_ “(à)PèÉÔºv5!ˆº)VWrWtÙÙµ*ÂQ°UY¢#¬Té¢uc`RBX×( 5ÂåOÔTëEWv³+Í›Ýlõ^¹ÖRÞHk”añ�›ºs¶ù6ú�M&zñ6 r:e$áÒe“z½2Ò]Høîx›ÜôSñá?[ø×â²’‹…¨è̌³3ßzûÂñB4jϸ‡žýÞŸ®ÃùH²¤pòìóè—tá”ñ…wN $–œp¸_.FoGÖmûåQË—_²ˆ�ß%¦Pþ`äÉ�;ß3蹃Ƚ‡ÈÜ&ÎãЪñï)l0**‰`Ý (RõÌF&õÙbMf¢³EÛb7žÅ g϶~x–5µîÆ…·ñ›­™zl.»{™>ÒfßžCµ?©vnóßiæžJš=#Hfôû0mºMO³t:'×ïܱ~ýŽ�ë/|ÛÚzó[oë·ø*ÊG!üïüU^ÏÿŽ‚Q>_ÁW£Ÿ£ h=ZÍWK¹ÆÐåÒ­ótÓ0ÁªØ�A0F¹FÐ61ƒ&vÃ�ŠBSŒ +¤úé…†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¹é-þüõœ>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ÓxJý¥×ÚêŸ}"õÖÖç­?ïÕv¼³¡±- 4Àò¶|N-áᢸ³á®ƒ5èÕA§DÏC1œ‚(‡u°ÊQ-Â)ô4¡¹pJì÷¡B¨AWá¬Àš¡†C œ‚Cp m�Kð:l„r8 h ¡«èšPg`#¶Ãv¨€«pRàCØ(ß+Þ nQA¬ñFvŒ‡›ØŠƒ B¡*à5[`É9ã` +ìƒë¨Õ …‹ð¯ðÛÄ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ù°fB2DA˜ó! +Æ´Ó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ÂÁ@y>Xf£ú|#a”<Šñ#$/:§ã`T§YïåJPÛÎÙȃ10†øîö‡\È—ôÿbþAò|d;Ÿƒ|œö— Ê‚æx†ËoâêC0FÃ(+ñ”ú¸)ec|²äItMè €Q0&ʃaŒ“\ˆ™ÆùFŠïã¤-‹ó*¢sê|üÇ·ÏÛVÞÈûðm[Gmãþß¡Ój›û^ +<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<nè™�Ñ*$ʘĆ7�X˜Ý¥7í Áà‚dµlÁœ´Œy¾ã€#x5rÚ:™õCNØ9aµüPp (@þµƒ²És«üë;� ‹<÷;ôå`Öß�ü`%d7d‚4 È(éä( ,@�*Ï9†És*¯yË+È3‘ÎIëJâåä'·ÓÈwGÉ­•äÛ›•ì[N¾=AoÞ˜ÈnV’›«é�–8vc"¹á¡-qä_ߤ°Ý&ߤ�ÿÅÉuN¾N#×ä«m¤ùËÁ¬™“æú»ç=wé—ƒÉW ÙÛÈÕBòONþñ÷PöNþJ>çäÊ»M> &Ÿl#åä'ùØÉþÂÉÇNòÑ6òç?9ÙŸ9ùÓ&û““|¸’ü1‹4m2±¦,r‘“ ÙN>0’óœ¼Ïɹ'mì\y/�¼ËÉ;ÛÈÙ +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·ÒM°»�&>àf‰œ<`d¸IÎb œtå$ž“8âÌeî<ÒÅŸÄrãïÏb8‰ŽJbÑ+IT‰F"l¹,‚“pNÂBƒY'¡`e¡Á$„“`N‚8qæ2× èLb�¹Äé°2gqXIXY€ƒØm¹ÌΉ %1[.±úû3«�Xuìü-fæïOüuì,~Ff1‹Ž�ŸÙÀüŒÄ¯yS³�˜…mõ¢&NŒ†@fäÄH4+Q9QPS8aBp#· FI gV†’X ªG…ë6¡Äÿ~àš�ÿæO8üo[¬·ˆ +endstream +endobj +258 0 obj +<> +stream +xœ;ÁÁ +endstream +endobj +259 0 obj +<> +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�,ÞëÈHÎØ +ë̳`l›1s4��-Ì&³¿’U¯õ ½IHŠ’‡7ñÍê!‰ ßÑØ%dè2hŒ$_{ŧ -2ÅÑêš¡©e‹d“¦iº…MQÅÖ+þÉǪkþkØÙ6ËåëK`GÜ×ùñÜ�²Þ”.L'¸ò„Äû˜µÒ¾*œ°ŸiÕ>:?”¢ÊW +endstream +endobj +260 0 obj +<> +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 +<> +stream +xœû߀€ +endstream +endobj +262 0 obj +<> +stream +xœm’ÍnÂ0„ïyŠíÁh~Z„R �”-‚ª={¡–ˆmÙÎ�·¯ìMhUõ�Dßî83k›Ýí£'iŽ8šÜsØ£7­8Zok›1¶1¢mP‡WD‰²ïúXg„ÇëjSi2Æ*-.­Ä^óŸäÏJÿ¢¬[L“1ö®Â0 ¤LPIÔA…+ðaÆØ:¯Œ^@‘1ö¢åÚ41œÏòÎò�3â€NJK×9Á1úfŤ¡£ôMmÓâÃÕl*}20!•lm§È÷xV>¸+ R°!H> +stream +xœÍY p\ÕyþÎýïÙ]­,iwµ–ä‡ä]=,?ôòú…Œ�•-ÙÉ’,ù%bKÚ•%¬•ÖZɶ0;à€Ç%IJÃË1œŽ‰!JÌx ¸Ó2„¶P§Ž“R dFõÐÔiE¾îœsî>$ Ã$í¤ÚÁ{ï=ç|ÿ÷ÿþóß @2‚PÖ¶§Ïs¦ú•^؃íá�¡ÝKÊï4?€ììh¿÷‚Û h£€þlG°%P¼ïùÀú€eÁ–¤aö)`= ¿#Ô·Ïò,ûÀú)wWO[ ï¶ü`÷¡–}aÝJÿØ<Ý-¡àǹÖï¶2`ú_‡{"}x•¥ÙW, ÷õëŸY äÌôÏÙb–ÇN³£x šù¹Ìê°ûéËc…,�Í`y̆­ú4–‡w±Õœµ{õô:Ëc‹q­,Ë“£­Q$}Z1¨O£ÑŠÃ8AïÒrä3í˜ö®v ŽCÃ8�Ÿâ$žÀëÂV€ãx‹°Exš•²<|�•²fgåh¥wш×p'ð7 ì³ ¢ƒ�à-|ˆ÷p™�ð'pœ�ðþv3@¶eØâ¶æá2ëÁ61Æð&~­h+}�ÝŽs–víIí-}‡üìpYÛªíמćúv +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³“½dí`÷Q ‰'Ù¬¥$>Ëž•ì¸R3dkª²¯¿sËO`‡ÿ¦æÑLAsåG㣮ò²š¦-VÛÊß¶:loËKóV=iÎeþ¢4€A+ceZ™µÌfKÓt²ëKÙ +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´°G1›ãýóãçe!ðÅ4ÖÆ�‹Æa=Û8¬¿Ç4ö mþä�ýú®´•¿Å›,Ò_½oyô{<4~‘_²mƒ š–­!#[ì™ñ�ñ9¿$‘ÿ˜~8/ã^Ä!@pE.¶ã}¶’ía'´™Z™Ö W&iùXˆ ,Ðà€6€^fùà'R2�éI„ÛêšÁƒsæµþż&,À'浞pÍ‘ÅæµsÙ<óÚ†\Vg^'c ›×)ésÙ)ó:îÄjô Œô¢;Ñ�>x0m˜|(CÃV ÀƒUèD"èC/‚hAEð ÝhC <¨@ºàAc +" ˆ^ìA”ÀŽJq7Z° ýð  hA7vÊ™´H|:Ñ ÂèG+ºÐ‰6x@Bh‘c“qš$Š@X‡t£«Ðƒ.¾tž'6s“dA§”`1–O@ˆ®�®.þ;ír\©Ðg*&TéC+PŠRÌù{Ð�DЃ~ô¢ A¹¶W*R‚nч5 hQ…¢‘º>2bL¨.fµ"ˆ.ô`/æË8ýï¨/âhŸÒ²Ò°ž œ¯Ï4;Šÿˆ�°þ§È^Ï—øÜiª(b.žˆ Íz± ô ýK¹Ï$^H¢Å³Qawȱ é×NiE¨,ø œv9ŒYSVÙ$ÆûdÆ‹5b}ØÌxe¡­è3#,þÛiúÒf*Åì“,&æx Úä¼Â&zAÌVÜU&åþQœ›�%¹2rbm@~ ß{ІN´˜þ©lC?BEpí‹éÓŽNt™yTí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С ƒòÑÁûÓùÁÝŸN÷ Ò�ÝÛDûÛsø~Ýtð{|4�EûÚsø¾í5h�A}‘I¡ÞÝ%¼× Ý%nÏááÔ“EÝ¡³¼Û P×6:K¡ƒzWgïÚF]~}×.º»³€ß}–:ý× +¨£=‡whg{ß ö ƒ·çPÐAƒÚ j5¨åí0h{€¶~ÍÍ·ô57Ý飿boΠ-´.¾Ù M>ÚhІ¦d¾ÁAMÉÔ¸>ƒ7Òú_ŸA ª7¨n]€× ÒºÕTcÐÕéüŽAªN§ÛÇhí­9@U•é¼êU¦Óê,ªH¥Û|t«“nY™Êo1håÍÉ|e*ÝœL+Ê3ùŠA*¿)™—gRùAý¦dºÉ¯/_–Å—/§eÅ.¾,‹–.ÉâKkhÉb7_’E‹Ýä[”Å}ZTì⋲¨¬4ËŸÁË*©4‹JŠ]¼$‡Š‹œ¼ØEÅÚߟ¤-LãEN*ÖàOÒ.Hå Óhá0{ߤ/°Øù‚TZ0Ìú7ëókh^ÖÐÜü,>7Ÿ +ò³xA åÃÅó³(ÏM¹³Ý<×G^Ï3�f*N3li|†“fN›õ,7eÖP\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 +<> +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|e€CÔYAJV\§$Ñ-M¼†FÔ&*©ÿÓ:ð”[�ò�µ²>dIf´òåþÒô=T³ÈÉS¼0`äœ'Ñs«E.mS?a1�ZüÈ'šÂL½Õ1§…\™·†¯o:Æ·:ˆf­°Œ°À�Ķ}Bœ4ÌjsäÈùÑ](¹T°—æ¬GôMݲ -Õ/ÙϤ9§–-`6®ÎûÌÄ2�ÛûÝ$­¥ˆç;vŒøÀ*­xúV«Cœ¤CbP6hë·*‘áþaš„çùëE>_�¾*‚QNk�üÚ²© ®wA¶Nè%\dÁ=üž>«geÁRÚ¨³ZFHŠ$»Fø»IýÑ kM¡Ýžï‰gNaŠa–Ùˆ;¦þ/ºf½´þÌ,¡b­(N”yBllLÿ�Þ)ŒÁ+ÛóbÑ!hç^~Éó�»™p@Ø*Tv€SS¦þÄ]HJÆKJæüÕî0T°u€= Gct¶LpQoµ»°X|iè‘´ PÕ‰? ¥t–Õ7˾ï¸Úß°zå´ÐkHEò‡ZGA|aM¥© +ã`½�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 +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˜œ%˜Ç°ÕîµÒOq‡vÌl£*I§û<¶Ç0%”[àTûsRù#[õeØ&å(©�c],ýÃ嵊ä&ó*'{샄yÞá‘*;fmÇ£é©#FšÀ²sŒ‘Ï�nèÃ/mo"³þ_žžÿôøÓçç÷ïß}üÛ_þøî·?ýí¿ÿ÷YâòÈ—?ü8èáÇÿ>hå†ÿÜðùÎσbPÑ‚þëÕ�×õ–ƒÒÑ€ö/Ò›åÒX÷"�Pfdä—¡ÙåœÄöæ¢VÙ½§?³ã^®?ë”öÚÄmýUË>ð'™'Ét9)³|ð\YÿS@‚'Åódë2ÚL<ƘÍBK»¡´œ*0Æ#cÁ§hwD’5m&œèºf®ŽÉ £ó$BqËŽt(t’‡Ãu(§tÐÏzl¿þ–ÄhPT§5�»¨kKÚâÁ¼ãƒi«¤`aq:#œ˜J�Ltáâ8Ë/ü$.üä*uÛ.|’rÇǤOÿ®ñ2L +£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çŽkÒ¯Å1üŒKn�dšñ®A2K„!ß·ÀÑ4+)l¶âÆ(™¦¤ÊèT‘¯Û°ôY`ì +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®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‡–˳S9™Ø!@«Þ—i0®š„‰.4·&Þ£‚³ÊQM£ÜGm‘"^öì”\=ÓjŸ:ž7mÊ›µÒÖº#×ú2"¦¾/œˆ¨¹+€5&ãК]º€~>¤DD6‚o’­²ßðrÍûßT­ïúm½{ɵŽcu[ʯ¶ÔxÓó„ažCöM�*á¢yƨîiiÃ1û5 õœÙP¥KÂ7Rùž€wâ2«´F[ ºî‚sæðƒðVŽOrú©·%×&8Æì•/^yH±ÔÖ'aŒžÚœ�¨'L¾¾l¹v�«9ü ›î!ENÚH§t³@ÐìÆÞÓÆš@Ѳ¹P´Ã¦g›h¡T·mï"tÌtwÐB-‚STŽdO´¸©¯9�ü~\0[>´ÿ�9\ca¨äõ�ѧ(ÆVòÑõ +\”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”ÏZ»Š×t1Öl ‘Òžt��*a^O<�kO”Æyå“3G°y‹"¯z¼JÆjÿbl!¢UÁz`K1Q‘«¢ìäBd–#ü²9© &ÜtÅ¡$UØü9þ¹½6ª +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ÖÇŠ¸0æØl%ð M!G�*d»ÇƒQE6•S2_bWÞ_ä- V*]ì«Úk²ט4Nï�+SÏ5 "ìP®±Zéê`Às�‰‹-FÔ»UŒxÔïòœRPö’²ÞâÎk ½$F}£1J±)BÜ >¦âÊkÀ¯×RÓÊß9ïY¥­Mê|RÚgÓ±Ho—‹ µvªKVYçGàãíALOuY;=NëÀ=ž=~hR„ìhŒë´#6p `Õ¹X�§ ëÿ¯ü;„ù†«0`q¾Âé·>®7u‘Zå +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<سJy·�õ ¡[éR@“(&Ÿ—»¡ó.k“úšDwÕføËEÚ)¿òô¿ÅÓ4!ÀùÁ¿´r^ïo!3 ?^ÇvÁ;+Kà2Ô'‰ÀVGPnŒvtˆÀƒ—�qüáhwÇZêØó§£íãóáQ&½1„ ã“)Ã�gˆ0c!)£ƒ¹¹ðÞ­ „†zÚ&õ2 «h ذñ�Ï?OÇ’›N(ñ`ó|.¹9ÓP–d�4›dU´>ÜX+G¡´Cí6|\�•c‹}7cÆ8ÁbßϘâç°ýB"зÉ€þ{+F lì¬|v͈ãô$ ƒ<�7Uõþ}î$L¦'§’v~G̉!í§À +âå¦b¢Ô$?…8ŠÐ)¸Ž ñfÐ ¦ÇOéa=C¼Í3ÛLÛiè–ˆWhf›é¥FïxÓh? ;Bx%š½°ãïž1I™ï.Ì;þÍÛ +tmˆ— +2z[v$Ý/={ "Åý ßífŒdÉêÁ:euÜÓñžäì†HZ?Ãi7•G ÇÅÈØ¯` ‹ÈHÁ©à ¤½=WÖÿIHë +endstream +endobj +265 0 obj +<> +stream +xœå]Ý�7r×_1–eK[\²ø}Qw’œ � °Þ¢èÙ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'ÑÂ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³³2©#@ab7è0œæV6ƒÒ¤[㺴XR$†É¯¸õË«¿¦`GkF35§™Þ +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/‚÷pWöœ.W"A(éÒ”ÑÞ‚ÑJ{Œ @Ûä†?Ãùo{®�V‹Œ·dV,ÍÆw„Ö›®º® §ëÍ$Œ†ŸÁ +³û«-!í&fM±ã7(„E÷œ¢g$¼~¿¾L|Dg‚ÐÚJ{UVÀòÈ-æË{.æ�ÙNÆ/‚^ý&�”7€I†ÈÇVnɃ:st¿�p•]ŠÓ,iO91LÅ·â”Öè¾n…QÔ¶R�ܼ}‘ÏP î%lu�f�˜ãr¹íÃÁm³ Î¹e¦ç‚µþ|釩Æq˜º|²kÄGk +{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 £Á0”&WÔ&¨s]dUV–ÙÂïÉ!Ø¢à ¿ˆ…1è¡6͵*öLJ:·¸`ÄZ/´ÖöobãÐqÛFÖXa¢jhtÚd|ܾQ; ©^`- ëK¤^8t +èU=u$û×Åà�[2RVÚ¬ð\ b.mð€-Àû »ðñ¨Oæ‘ ç`Ròèš÷AídR»(H^z'É Å9¢TŽwÜqn ù’É´ËëójÑ¢4¯õ¦ËžP�Rڸπdá5ã,Êj!mfÃ}Ïõ�Y­…3±ïΦw·R�J…Q‘¡Ç3¹—œ5PõÄþu„9,^RÐ!M5:¥�HkWS‰�Èêi-”Ó:4ŠŠMêUOü·N«M}Zà–œŽ�«¹ U;Q«üÔÂ@V "„u:ºnˆpÄká@©Ø•ùTSjÎ� S_,"§ƒˆÒkh7 [ç›ë(ªö + Êû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¬ËèvpÒŽ™˜:V2 +㣠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ÞÒ>­ˆ>×ÙŸº/S2 ¢-xПó‚Q¹¿¥cÁ¸ž«Œâ£Õ‡k»¢Xu�XžèžcùžÜà_f¼jôÚâ] µÃ¹¹#œ�ê$N‘eW.qgô¶2éüäg° Ã?‘c€æâ×®àNçík6ŒCN4².â×®üàð=Àlp=0.–ÊÏ?—š5›úY?}yÄöÓÊ£çt×7džÈEo=A´°.¢·feI•ý¨sfîg¸Bô¹Î®ƒŽVë]½·Iž”¶JÜíˆöîÂù‹/¦?R9#Òléo°þXèˆY½blz@lÈ^~ž<T¼š6á'7ÙWéÈö¼ªôž?ݼŸóM>í4Mº.Å´ã»&r§œ8ª€ÜÌ$¨\÷9 ¨–‡¿Ÿ¯ÀOQ2c¾VËb䇭¹èê´"ú)øª@uíEpðÿ�—éÙxx”döÌ€hM_KPÂ8¥Ž2Ñ|»ÓƒÝó77 pÀ�J»þ+ÈxæÞëa³×{ëtî.ßSxöô/ -Áƒ ÷Ó?Iò‹úœU©Q—#÷ô†FX¿Ÿ7¸ç[”Š:sÆ㜷(ÕJ›S†(¿þù{Äë‹Ó(Ť%ûé~ŸP &œzÍÒ£ÆVKeËw1ñŪQ.nx€}P[·†Ç­æµÁ^FJN9²ÒÜy¾�ï3ÿ½â2BÚyaœa^�½®X“?3Á.:g ÷ +¥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ÂçՑ뎮ֿ=d"|¡Eóµéí?ã÷,rqÇ£�·"ø¸û¥ê#¨!aâŒ:>iÂ?<‰€#«L½-¢·~ U+xú-JæD%”7:aâ}Õ÷€ê82?—�6Îõø²2Ç2a†Ÿ¤’@Hë¢_ï¢à@¬>7ËÅÕw”¯—ï +¾¨Üû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Øx5ù)+¤5)sá|ùªëo¿ÿôð‡W??Ü”3ú�ÚgA¦²ùåwŠ„AݼNÙl¼ˆnÊjÛP'Yká÷Š]¾â¥dæ„Z䨩bfð{vx–;ºE•ÔðùMæ!àP€Lž� JkøÓ )ášÉmáó=¦ +š‹{¢á‹z! Äs£T¶W‹Ó#țщåùÞû·Œ¼‰Ã�g]l÷ûÜ{r¹Î…Wßq$íV.ŽþB»V¢c$βŽ�MánÅ©®Í�÷ŒP”½LØM{>©’�ã(pF-![U÷5±³IV \n¨Ô®NQ!‹GÜ8Bš<Znñå&VËjLë®B¤ó:¸òVíU3öÚ*É·ËG~¿ø¸VpâèçÙ^P®ù{´gÅmB–ˆÄJK"�ý�Æ÷ÂÿÀ+ÈZºœX2ÒNKÍѹAZ@ÇÒçàØ®¶DÐÊ*¡¢d°�¦½÷)“Œ2ÊøUæãõèRéeNnÁ¡éšì¥Ùi>.Îõ°!o~ø/Ò +ùe²Î/9ª4c +V 0¾�d¹„±JÚW»’çx€¬¶¡üm“-÷ÝÑõmËÊãüŽ +>’ ÷’©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 +<> +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Ìœ'u&|fÊYಛ¬¶s¹ãï[~0¦eŒ-yÿêÚ(t,±ë­VÞDŸ®ÀyÀ³3‘éîaÒø)ëÇ4/¬N“^„ЍÊM.?䀇\¬rÙ—kL¹ +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†‹ó–SÒ"?ßZkåéªóÇ+�Ù‚­ò7ÿÝ‚™Žó@üà¼Ö%—%&Ž Sç)ÕöÏ?¸½²„|¬ îÃøvkD„Cgå‹ÞeW¶ãûHÛ•h–ζdÈÅ›DËô€‚ ‹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å̵™¸ó±EñW2ð9ƒ€Ð*85œ/hJPÀê~òFZ­×îžÞvëæìY+‹$†Ùr »Èñ�]T÷„ä솰4qÇ•K©è>.²—ö*U²e»ñû%‚b±©Ÿî¢U:««‰p•^Ä!Žé +ø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øë¬^V´û<^ñ ÙÁg3øÑ‰l9ªl|ðW˜žÑÇ«oúò‰4ïœ-W˜”‹Â÷/e¨ø”+:n6î·¼iqGðÓ]œNøyKCO‹i—mõˆÃB�µsß9µ3¡¨+6æ M ‘µ+ôÆe� ŽÙ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,„=úœé.U^cH0  E¥”üfÓùû3GÏÑD•BJg¬÷'É´ñǾpÕë¿þ‰Øë±Á’àž�v’˜ 5QØ[ƒBÌŠæèüOTØÕܶŒÒš@C„«öž¡+ÌŸãõg�Ûð)Ž‘§pè#-Ÿ:ðË–ÅßµØ@¾ñiOf<yh"z*Ñ�Pq�κ9ÔôüÆ’{çJ¤2bw + ç�Ì‘2@­;f=’v/‡3x1Âi¬W:G[®ÂÈÅÔg�6Œ·Ÿ•ÖÁífŸí0‚ô,UÄ€ŠINÙb¼»"»Ýirï#%�J@Z°û.Nœ0ΆÆd J¨€Nß pVQ3½`Óv8k>É<ãI�€ êšiÞçÑv +µ{¼ {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‚[{é¥@5�#h£J.Vûj‡Nqùé—M.µÉŸ@‘Â\IêÆ€®ÊæìKWpŒXj¨áé’D\9­�2Ú•æ¹¥SX†=…•ˆ ¥uVÑ¥«ÌÚIŒðÈáåÄ´=ó7{ÅŸa‰,þÉÄŠÁ|âyÄž·›‚ÀÓõª»çñ[4™·<¯ALì#q7`wÑ +¨à§6 �^Œ<38�ÉŽ+ &.C©é“ì0“bޘї Q¶ÿ$½3c�W®X5?h9=[V +¸m}ÄŠ&æ÷Û¬úE9“�sîº[qnx‹B“£ÒÁš4ÀÛA‡ðbVƒã!§åž!s¡Ù?´ž�°Kh]TÑzï÷ˆ$}L¼¿Ò«ü¾�¥{ii1 pš42Fd{õÕï×>ª:(…DÁ±kÐáèÐýb]Àÿ@ÙÂP×C~™–ñüQD‰¨~±ÅÆ‹¯èÙX­\‰Ûñæíö”J.BV[# +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‰[õ€éç”5áb¯¯a"¬¼M(`+RÀ„íq9¿ +×ÝúÜ[‘ð%ñ¾ÕÎöOe ‹Æ=*è¾æÔ°}çE/Œ§6µu EèXJ~½6\ÊÐÊ9Ìnð¬s‘®2N«hôvØâWÌKЮ™…»…£ò&[3šy�{X¶©öø#ì¥;)³›�‰eMS +±Äbè½-À¤xUÊûEýÈuSø à +÷ŒcÔhgäJBˆaBç£ +Á&7 „‹N�m�žûjA5æoªù­@ �(椴™Îjí^%0 Ù±záÊ ~b…g¡Ð•C�ß«„s ›áÞ¨c"~8yé8•1QEã÷ä…òï7š¤¼7Ñ®Cž3 übtgt, ùÐD…îŒ8h‹<íib0ÒiµUA[í&F^î/*Ötú!�AÜc§g¦u!cÍ�®€…t\dœ!ÐÏr7^N‡ÒÆ`•·3LA!¥°/U£‚�Æî†ÞQžC"Ýùá¾Èe �N›ô‹¡žSž{(8åÈ›¤lšŠÙ-U¯¸™@Ú½ûš4�Ì.žÞ®*Mü�o`Éç™/%ÌmU4i;F9`É�ýíäUŽÚ›-™š±!‘€MúìUI>§ý<8oÔE9r} +€2>@‘•¹g ú†”™¶�{ù.ê ú®>™•DRØiB*/ ­%ܮѕûagý1ÕªPQa«§n?û<€‘ö�Î…©’0tÂ6mâ} ´øx•bð¹…Öe-$]å9ÝÑaÍžc«WãdV…ëKlB‚ ,*ÝÄËraj"”ÀRDP@Gz4k¬ÇÆ7¸Î�bèçž�â»dœçd!8=VXuMHžè—Íñऄ{W†� °žS¯�ÚAUGO×-„åìeÿØÑ{«mþޯΒ +endstream +endobj +267 0 obj +<> +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°“ÁKåÿOC8aŒþü%A§Eªa­é�ôÝz§5” âô$´uòü¡|9>žªPh+œ—òœ� :ˆµ·¼ÃޏuÂØ8isÝóö²ÇeQ®7Ì®–ÖÆ)9^(£ ÃâÒQ„sàW&âÕ4;ž¬n5‰Äv 6ðìÝTÏÊî>ëô€�l¥¥mCI!þÃX¨ñöõHqZO1¯ +ÅzŒp…A˜@W ^fî±Ó×: ŒºÂ»°GTS´HŸ£«<´Öòû}ôÂùpz"Žì.lo½“³>d8 žpú!À ¤ñ‚bØFݦæv°f @ÄË÷H W�(·êCé5õ›Œ§ÃH( ðA¡Qæƒç7ÑùúvŒ¿Y£ô÷@År]­) ¸÷5ïLbRÑè{8�hã—<øü¯• Y‚l¤oÈ‚#6;#Ò«óm—Xž�!T¸oÞµ¯ +éF¶ÑZç© +êôS­÷ å‡Ñ´–ÀI ä²'uª(¥++÷òÝ�OõÍÀ>º9g…S!¥žˆDcýBayº:Ý¥Z*ãâÖ›$“Lúh¶1݈ΠöŒp^¥P‰¡±ÝW™ç�|}ÇÇbk¥O ÙÌì·ÀW÷¶u[è"zãÍü!óÇËz7æ� £+6H¡'kl¬ƒrHÍ¢š@ûÃ|)§/aܼ ì: +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®+ÂÎ*f(™¦Í\È�H@֞ƀǑ8ÑMA?Éprœ(¥Ú®•Ú)éå�­Ø¶Ø›±ó¹´Ïã@d¬ðÖÅ%ª-jNKW—‰¬š4(üüDc5(q•rði RÀuGo@–½PúSƒßáóàXÒ`žYÔ$äÙK/y^�B¶²B¬l…ŠˆZX<`. mQáJ‰IFõ|‡"wøuOD’d_žþªa|­HÅ…Ù÷WÎ�j~iq³c@£ƒ‡ÜG%½RN…ň›8a»(µ¦t3ZûðÃ@º&­XîŠAĺjåº*ŠªÃÝtŠ­Øq(·Râ›fA’æ’†Ìa—Ã÷«cÏôbY‘á¯è *‰,’‘&N—QR%¼aÉåh…ÔÑ Øe7Qž|óFeq”é¤Êš`Ï_Úã +ñ©: × oÓÚÖ‚æ¢átžËrÿ‘õJÁ +ã´H`òyÉByH•¦±ÕI<‰Õ«)3Ø@QS@æGù9Ó×O ^›Lº!&w¬µöÑÉ%ƨô[‘Ý"¼pXÀ!W°´öÜP¹âpÝ+ªs…1ÆßKcœƒÅ_3C¦ðHùP¿:à„æ‹­jd3ESº€XsôTÀny3®eüÖ‰¦Ó³Íš9“:ãN ãJs°3@gudÐBÆWÌÔU£H/RïÔÛWf÷-5,½~~äBÂÁºF}QÅýQ—À¡ìd½JFÚrJ­MU,‹Ë» ä¼îö5©ÀÐbù–ÚBˆûx™? \¥K×3ÒÏø„ü±øÆŠÑB�Q"Lút +Õ’5{d�CùB±±î‹ðL(‘Æ^²¤Ü_ËædÍEª×¶t"NÑÝ„xU¤V� + &èõªÊ&p;¢öH2žë(»C5è`µ­‹Ý�wÏ,´LNyÄŽ¡ÇGaC�ZY9}E¾] âÜÃ$-koAãN¦³"Ž%0�\,^ßã�+Øøi¯W€ +¬Ö‘^,ÀZ¦còžu. @¬d‡R_À:Z.<_> +Jß°gFùð;wü#Œ\ÉÒçí4ÎÛDœt#©×çAö(kƒS_Ë„¶Ðy\å¡dÁ™ ØÂçÃ$¼M�À þiÔÚV­Q}Í,CÕÛŠ¥².e4¬Òt+«Nn.±Ú­‘pýá×L�:;{¢üøž¼È­§®®âïx…©ÇDlY¨Á¢w8êUˆ<ë.ûýÑDNÅð:püí*­ev2"N^êSíU”\„y�‡k`�†Æ/åZ�«ÊW¬Š€}[Y饻œ±Ø©Ú%ÞÎ{.lgSpjhSðV¨É4‘nÆV›¢¶·>u¿½•B[9§«*Bz€ƒR¯YàeýÀ´ +<"-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ÙH±:½¸*å!©khñ±«eàN¥Ž ÛàŽÍÉóKC+Kj�*Ji.{æ¶¶¿‘ûFö2x¡‚±~‘5ݨ‡ç³~¯¾–?/®¨œYL¨©¬]þ€}µïëEÅåÛìÚÞ±"WÌF† /@÷]œ4¥›´ƒ“m BÜ k£pRE}-§¦·¶=íΑ�kHA]Ÿëæë|Œ=GŠÃNÀÞ*á[ ç¬S”Êž\ñúÙð%›À5ñþJqÁŸÛVLcÌ$¢‘þ ˜‚º¹³Àaö@ +µŽ cÅ«Ùýe +å$«ÁëäÑÿ ž^„ +endstream +endobj +268 0 obj +<> +stream +xœu�±JÃPFOŒµUªv¬DœD@¤`]\Ú +F§ëMkŠIˆ¹‘RŸB|êê&Ø¥Žn‚ऋ¸º(¸H¹rëTųüË9ð`ù� Õ¨a”&åʺ»ãî:Ùl +Œ3Ŭ�*ÞªnÔ”h)™&C|>b™û°ä‹ÈëÞìçº�«é»“æõAçívØýCÆ«+ ô€y')ð +̵Ò8+¤/<°ŠÀâa­RkpÂàØ´óA¾mWÓQ”Ihÿ㌠œ%–Álþ½E5VW~ªüdžµþX€ì)ôÏ´þ:׺öôŽb‘ˆ�k#�&¼_¤ 3÷0±÷ ØIì +endstream +endobj +269 0 obj +<> +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” å:8ÕÁ…ü¶¼+%¿÷dбÐŒ"ÂÿG¦·™ `d_¿{Ù¹ÙÖ–gzžçm\‡Ð8rœÏSÇiœ�úµ­öþfæë ´½Ô1\íÃÈCÛóU`¨ ÕS·ô¦¥]Ù ¨ŸÃ@†oÁ½ö ãâ_± +endstream +endobj +270 0 obj +<> +endobj +271 0 obj +<> +stream +Typst 0.15.0en2026-07-06T08:35:32-03:002026-07-06T08:35:32-03:004application/pdfryCwVs21NRYeVZORaVGmEw==ryCwVs21NRYeVZORaVGmEw==proof1.7 +endstream +endobj +272 0 obj +<>/ViewerPreferences<>/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 +<> +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/docs/ideas.typ b/docs/ideas.typ new file mode 100644 index 00000000..f4faf810 --- /dev/null +++ b/docs/ideas.typ @@ -0,0 +1,13 @@ +- grep shows treesitter context around + - this could be implemented as a separate binary +- clickable file:lines show somehow (as highlights or something else) + - is line above of below middle one on the editor + - is this on the same file as open on the editor + +- clickable things for the mouse should be navigatable with the keyboard + - like if I get some search results I can just use tab to navigate them, the editor should know: + - semantic boundaries (maybe just jump to the next clickable thing?) + +- undo for everything? + - I want search results in general to be logged, that's one of the reasons why the tty is a good fit + - so it'd be cool to undo that too? diff --git a/dump.zig b/dump.zig deleted file mode 100644 index 9c1dceec..00000000 --- a/dump.zig +++ /dev/null @@ -1,218 +0,0 @@ -const std = @import("std"); -const builtin = @import("builtin"); - -pub const default_path = "/tmp/pardes-dump.zon"; -pub const magic = "pardes-dump"; -pub const version: u32 = 1; -pub const max_panes: usize = 16; -pub const max_cols: usize = 6; - -pub const Size = struct { - cols: u16, - rows: u16, -}; - -pub const Cursor = struct { - col: u16 = 0, - row: u16 = 0, -}; - -pub const PaneKind = enum { - terminal, - file, - image, -}; - -pub const Terminal = struct { - cwd: []const u8 = "", - stream: []const u8 = "", - stream_b64: []const u8 = "", - cursor: Cursor = .{}, -}; - -pub const File = struct { - path: []const u8 = "", - content: []const u8 = "", - content_b64: []const u8 = "", -}; - -pub const Image = struct { - path: []const u8 = "", - bytes_b64: []const u8 = "", -}; - -pub const Pane = struct { - kind: PaneKind, - tag: []const u8, - body: []const u8, - scroll: usize = 0, - cols: u16 = 0, - rows: u16 = 0, - vweight: f32 = 1, - terminal: ?Terminal = null, - file: ?File = null, - image: ?Image = null, -}; - -pub const Column = struct { - weight: f32 = 1, - panes: []const usize = &.{}, -}; - -pub const State = struct { - magic: []const u8 = magic, - version: u32 = version, - screen: Size, - active: usize = 0, - topbar: []const u8 = "", - theme: []const u8 = "dark", - columns: []const Column = &.{}, - panes: []const Pane = &.{}, -}; - -pub fn validate(state: State) !void { - if (!std.mem.eql(u8, state.magic, magic)) return error.BadDumpMagic; - if (state.version != version) return error.BadDumpVersion; - if (state.panes.len == 0 or state.panes.len > max_panes) return error.BadDumpPanes; - if (state.columns.len == 0 or state.columns.len > max_cols) return error.BadDumpColumns; - if (state.active >= state.panes.len) return error.BadDumpActive; - for (state.columns) |col| { - if (col.panes.len == 0 or col.panes.len > max_panes) return error.BadDumpColumns; - for (col.panes) |pane| { - if (pane >= state.panes.len) return error.BadDumpPaneRef; - } - } - for (state.panes) |pane| { - switch (pane.kind) { - .terminal => if (pane.terminal == null) return error.BadDumpPaneKind, - .file => if (pane.file == null) return error.BadDumpPaneKind, - .image => if (pane.image == null) return error.BadDumpPaneKind, - } - } -} - -pub fn writeFile(io: std.Io, gpa: std.mem.Allocator, path: []const u8, state: State) !void { - try validate(state); - var out: std.Io.Writer.Allocating = .init(gpa); - defer out.deinit(); - try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer); - - var file = try std.Io.Dir.createFileAbsolute(io, path, .{}); - defer file.close(io); - try file.writeStreamingAll(io, out.written()); -} - -pub fn readFile(io: std.Io, gpa: std.mem.Allocator, path: []const u8) !State { - const bytes = try std.Io.Dir.cwd().readFileAlloc(io, path, gpa, .limited(64 * 1024 * 1024)); - defer gpa.free(bytes); - return readZon(gpa, bytes, path); -} - -pub fn readZon(gpa: std.mem.Allocator, bytes: []const u8, label: []const u8) !State { - const source = try gpa.dupeZ(u8, bytes); - defer gpa.free(source); - return readZonZ(gpa, source, label); -} - -pub fn readZonZ(gpa: std.mem.Allocator, source: [:0]const u8, label: []const u8) !State { - if (builtin.os.tag == .emscripten) { - const state = std.zon.parse.fromSliceAlloc(State, gpa, source, null, .{}) catch |err| { - std.log.err("parse dump {s}: {s}", .{ label, @errorName(err) }); - return err; - }; - errdefer std.zon.parse.free(gpa, state); - try validate(state); - return state; - } - - var diag: std.zon.parse.Diagnostics = .{}; - defer diag.deinit(gpa); - const state = std.zon.parse.fromSliceAlloc(State, gpa, source, &diag, .{}) catch |err| { - std.log.err("parse dump {s}: {f}", .{ label, diag }); - return err; - }; - errdefer std.zon.parse.free(gpa, state); - try validate(state); - return state; -} - -pub fn free(gpa: std.mem.Allocator, state: State) void { - std.zon.parse.free(gpa, state); -} - -pub fn encodeBytes(alloc: std.mem.Allocator, bytes: []const u8) ![]const u8 { - const enc = std.base64.standard.Encoder; - const out = try alloc.alloc(u8, enc.calcSize(bytes.len)); - return enc.encode(out, bytes); -} - -pub fn decodeBytes(alloc: std.mem.Allocator, b64: []const u8) ![]u8 { - const dec = std.base64.standard.Decoder; - const n = try dec.calcSizeForSlice(b64); - const out = try alloc.alloc(u8, n); - errdefer alloc.free(out); - try dec.decode(out, b64); - return out; -} - -test "dump zon roundtrip" { - const gpa = std.testing.allocator; - const tty_b64 = try encodeBytes(gpa, "old\nhello\nworld"); - defer gpa.free(tty_b64); - const file_b64 = try encodeBytes(gpa, "alpha\nbeta\n"); - defer gpa.free(file_b64); - const panes = [_]Pane{ - .{ - .kind = .terminal, - .tag = "nm /tmp Del", - .body = "hello\nworld", - .scroll = 1, - .cols = 40, - .rows = 10, - .terminal = .{ .cwd = "/tmp", .stream = "old\nhello\nworld", .stream_b64 = tty_b64, .cursor = .{ .col = 3, .row = 2 } }, - }, - .{ - .kind = .file, - .tag = "nm /tmp/a.txt Save Del", - .body = " 1 alpha", - .cols = 20, - .rows = 5, - .file = .{ .path = "/tmp/a.txt", .content = "alpha\nbeta\n", .content_b64 = file_b64 }, - }, - }; - const col_panes = [_]usize{ 0, 1 }; - const columns = [_]Column{.{ .weight = 1, .panes = &col_panes }}; - const state: State = .{ - .screen = .{ .cols = 80, .rows = 24 }, - .topbar = "Kill Newcol Dump", - .theme = "dark", - .columns = &columns, - .panes = &panes, - }; - - var out: std.Io.Writer.Allocating = .init(gpa); - defer out.deinit(); - try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer); - const source = try out.toOwnedSliceSentinel(0); - defer gpa.free(source); - - var diag: std.zon.parse.Diagnostics = .{}; - defer diag.deinit(gpa); - const parsed = try std.zon.parse.fromSliceAlloc(State, gpa, source, &diag, .{}); - defer std.zon.parse.free(gpa, parsed); - try validate(parsed); - try std.testing.expectEqual(@as(usize, 2), parsed.panes.len); - try std.testing.expectEqualStrings("dark", parsed.theme); - try std.testing.expectEqualStrings("old\nhello\nworld", parsed.panes[0].terminal.?.stream); - try std.testing.expectEqualStrings("alpha\nbeta\n", parsed.panes[1].file.?.content); - { - const bytes = try decodeBytes(gpa, parsed.panes[0].terminal.?.stream_b64); - defer gpa.free(bytes); - try std.testing.expectEqualStrings("old\nhello\nworld", bytes); - } - { - const bytes = try decodeBytes(gpa, parsed.panes[1].file.?.content_b64); - defer gpa.free(bytes); - try std.testing.expectEqualStrings("alpha\nbeta\n", bytes); - } -} diff --git a/e2e_harness.zig b/e2e_harness.zig deleted file mode 100644 index cd4ffd63..00000000 --- a/e2e_harness.zig +++ /dev/null @@ -1,269 +0,0 @@ -const std = @import("std"); -const posix = std.posix; -const linux = std.os.linux; -const ghostty_vt = @import("ghostty-vt"); - -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; - -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); -} - -/// E2E test harness: forks the app in a pty and parses its output with a -/// ghostty terminal emulator. Tests read the 2D screen GRID (via plainString), -/// not raw byte streams — so they're decoupled from how the app renders -/// (vaxis diff-rendering, repaints, etc). The grid is always current after -/// `pump`/`waitFor`; no setWinsize-to-force-repaint hacks needed. -/// -/// Input is sent as raw bytes to the pty (the app's vaxis parses them). The -/// harness terminal only reads output. -pub const Harness = struct { - gpa: std.mem.Allocator, - master: c_int, - pid: posix.pid_t, - /// heap-allocated so its address is stable: vtStream()'s handler holds a - /// pointer back into the Terminal, so it must not move (same reason main.zig - /// heap-allocates Term). Freed in deinit. - term: *ghostty_vt.Terminal, - stream: ghostty_vt.TerminalStream, - rows: u16, - cols: u16, - /// when true, print the captured screen state after each pump/waitFor and on - /// every assertion, so live test runs can be inspected (zig build test -Dtrace). - trace: bool = false, - /// every raw byte the app has emitted (accumulated in pump). Lets tests assert - /// on control sequences the emulator consumes and never renders (e.g. OSC 52 - /// clipboard writes). gpa-owned; freed in deinit. - raw: std.ArrayList(u8) = .empty, - - pub fn init(gpa: std.mem.Allocator, exe: [*:0]const u8, rows: u16, cols: u16) !Harness { - return initArgs(gpa, exe, rows, cols, null); - } - - /// 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 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 }; - _ = execvp(exe, &argv); - _exit(127); - } - const term = try gpa.create(ghostty_vt.Terminal); - errdefer gpa.destroy(term); - term.* = try ghostty_vt.Terminal.init(gpa, .{ - .cols = cols, - .rows = rows, - .max_scrollback = 1024 * 1024, - }); - errdefer term.deinit(gpa); - // vtStream() captures a pointer to `term`; term is heap-allocated so the - // pointer stays valid for the life of the harness. - const stream = term.vtStream(); - return .{ - .gpa = gpa, - .master = master, - .pid = pid, - .term = term, - .stream = stream, - .rows = rows, - .cols = cols, - }; - } - - pub fn deinit(self: *Harness) void { - posix.kill(self.pid, posix.SIG.KILL) catch {}; - _ = linux.close(self.master); - self.stream.deinit(); - self.raw.deinit(self.gpa); - self.term.deinit(self.gpa); - self.gpa.destroy(self.term); - } - - /// Write raw bytes to the pty (input to the app: keystrokes, mouse events). - pub fn send(self: *Harness, 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); - } - } - - /// Read pty output and feed it to our ghostty terminal for `ms` ms. After - /// this, the grid reflects everything the app rendered so far. - pub fn pump(self: *Harness, 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.raw.appendSlice(self.gpa, buf[0..n]) catch {}; - self.stream.nextSlice(buf[0..n]); - } - } - if (self.trace) self.traceScreen("pump"); - } - - /// Resize both the pty (SIGWINCH to the app) and our terminal (grid reflows - /// to the new size). Harness terminal first so it's ready for the app's - /// re-render at the new size. - pub fn resize(self: *Harness, rows: u16, cols: u16) !void { - self.rows = rows; - self.cols = cols; - self.term.resize(self.gpa, cols, rows) catch {}; - const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; - _ = posix.system.ioctl(self.master, posix.T.IOCSWINSZ, @intFromPtr(&ws)); - } - - /// The full viewport text (plainString), newline-joined rows. Caller frees. - pub fn screenText(self: *Harness) ![]const u8 { - return self.term.plainString(self.gpa); - } - - /// Is `needle` present anywhere on the current screen? - pub fn contains(self: *Harness, needle: []const u8) bool { - const text = self.screenText() catch return false; - defer self.gpa.free(text); - return std.mem.indexOf(u8, text, needle) != null; - } - - /// Wait up to `ms` for `needle` to appear on screen, pumping bytes the - /// whole time. Replaces pollFor + setWinsize-repaint: the grid is checked - /// directly each iteration. - pub fn waitFor(self: *Harness, needle: []const u8, ms: i64) !bool { - { - const text = try self.screenText(); - defer self.gpa.free(text); - if (std.mem.indexOf(u8, text, needle) != null) { - if (self.trace) self.traceScreen("waitFor hit"); - return true; - } - } - 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.raw.appendSlice(self.gpa, buf[0..n]) catch {}; - self.stream.nextSlice(buf[0..n]); - } - const text = try self.screenText(); - defer self.gpa.free(text); - if (std.mem.indexOf(u8, text, needle) != null) { - if (self.trace) self.traceScreen("waitFor hit"); - return true; - } - } - if (self.trace) self.traceScreen("waitFor miss"); - return false; - } - - /// The visual cursor position (viewport coords): { x, y }. - pub fn cursor(self: *Harness) struct { x: u16, y: u16 } { - return .{ - .x = self.term.screens.active.cursor.x, - .y = self.term.screens.active.cursor.y, - }; - } - - /// True if the shell is at a prompt (OSC 133 semantic prompt). Requires - /// shell integration; false if the shell is outputting or has no integration. - pub fn atPrompt(self: *Harness) bool { - return self.term.cursorIsAtPrompt(); - } - - /// Print the screen grid (clean text, not raw bytes) for debugging on - /// failure. Also shows the cursor position and screen size. - pub fn dump(self: *Harness, msg: []const u8) void { - const text = self.screenText() catch { - std.debug.print("E2E FAIL: {s}\n(could not read screen)\n", .{msg}); - return; - }; - defer self.gpa.free(text); - const c = self.cursor(); - std.debug.print("E2E FAIL: {s}\nscreen {d}x{d} cursor=({d},{d}):\n{s}\n", .{ - msg, self.cols, self.rows, c.x, c.y, text, - }); - } - - /// trace-mode screen snapshot: print the current grid + cursor + size to - /// stderr (always, not just on failure). Bounded by a header so the live - /// stream of snapshots is easy to scan. - pub fn traceScreen(self: *Harness, tag: []const u8) void { - const text = self.screenText() catch return; - defer self.gpa.free(text); - const c = self.cursor(); - std.debug.print("\n[trace {s}] screen {d}x{d} cursor=({d},{d}):\n{s}\n[/trace]\n", .{ - tag, self.cols, self.rows, c.x, c.y, text, - }); - } - - // ---- assertions (dump on failure, return error.ExpectFailed) ---- - - pub fn expectWaitFor(self: *Harness, needle: []const u8, ms: i64, msg: []const u8) !void { - if (!try self.waitFor(needle, ms)) { - self.dump(msg); - return error.ExpectFailed; - } - } - - pub fn expectContains(self: *Harness, needle: []const u8, msg: []const u8) !void { - if (!self.contains(needle)) { - self.dump(msg); - return error.ExpectFailed; - } - } - - pub fn expectNotContains(self: *Harness, needle: []const u8, msg: []const u8) !void { - if (self.contains(needle)) { - self.dump(msg); - return error.ExpectFailed; - } - } - - /// Assert a byte sequence appears anywhere in the app's raw output so far. - /// For control sequences the emulator swallows and never renders (OSC 52 etc.). - pub fn expectRawContains(self: *Harness, needle: []const u8, msg: []const u8) !void { - if (std.mem.indexOf(u8, self.raw.items, needle) == null) { - std.debug.print("E2E FAIL: {s}\n(raw output has no {any})\n", .{ msg, needle }); - return error.ExpectFailed; - } - } -}; - -/// Write a file (helper for test setup, raw linux syscalls). -pub fn writeFile(path: [*:0]const u8, data: []const u8) !void { - const rc = linux.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644); - const sfd: isize = @bitCast(rc); - if (sfd < 0) return error.OpenFailed; - const fd: c_int = @intCast(sfd); - defer _ = linux.close(fd); - var off: usize = 0; - while (off < data.len) { - const w = linux.write(fd, data[off..].ptr, data.len - off); - const sw: isize = @bitCast(w); - if (sw <= 0) break; - off += @intCast(sw); - } -} 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/ideas.typ b/ideas.typ deleted file mode 100644 index f4faf810..00000000 --- a/ideas.typ +++ /dev/null @@ -1,13 +0,0 @@ -- grep shows treesitter context around - - this could be implemented as a separate binary -- clickable file:lines show somehow (as highlights or something else) - - is line above of below middle one on the editor - - is this on the same file as open on the editor - -- clickable things for the mouse should be navigatable with the keyboard - - like if I get some search results I can just use tab to navigate them, the editor should know: - - semantic boundaries (maybe just jump to the next clickable thing?) - -- undo for everything? - - I want search results in general to be logged, that's one of the reasons why the tty is a good fit - - so it'd be cool to undo that too? 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 use Ctrl- to enter/leave tty mode - \\ --tty-toggle= 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//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, ¤t); - 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/modal.zig b/modal.zig deleted file mode 100644 index 2a195432..00000000 --- a/modal.zig +++ /dev/null @@ -1,964 +0,0 @@ -const std = @import("std"); - -// Modal-editing text math, kept free of vaxis/ghostty so it can be unit-tested -// in isolation (see the `unit-test` build step). main.zig wires this onto the -// pane's cursor + (for file panes) its content. -// -// The cursor sits ON a character: col is a char index in [0, line.len]; col == -// line.len means "on the line terminator / after the last char". Motions are -// written to land on real characters; main.zig clamps for display. - -pub const Cursor = struct { - row: usize = 0, - col: usize = 0, - - pub fn eql(a: Cursor, b: Cursor) bool { - return a.row == b.row and a.col == b.col; - } -}; - -// word char classes (matches ad/vim/kakoune: word = alnum + _, punct = other -// non-ws, ws = space/tab/newline). -pub const Kind = enum { word, punct, ws }; - -pub fn kindOf(c: u8) Kind { - if (c == ' ' or c == '\t' or c == '\n' or c == '\r') return .ws; - if (std.ascii.isAlphanumeric(c) or c == '_') return .word; - return .punct; -} - -// "long word" (W/B/E): only whitespace separates; punct is part of a word. -fn kindOfLong(c: u8) Kind { - if (c == ' ' or c == '\t' or c == '\n' or c == '\r') return .ws; - return .word; -} - -fn kindAt(lines: []const []const u8, c: Cursor, long: bool) Kind { - if (c.row >= lines.len) return .ws; - const line = lines[c.row]; - if (c.col >= line.len) return .ws; // line terminator / EOF = whitespace - return if (long) kindOfLong(line[c.col]) else kindOf(line[c.col]); -} - -fn lineLenOf(lines: []const []const u8, row: usize) usize { - if (row >= lines.len) return 0; - return lines[row].len; -} - -// advance one position across line boundaries (line terminators are positions -// too: col == line.len is the newline). Returns false at EOF. -fn stepFwd(lines: []const []const u8, c: *Cursor) bool { - if (c.row >= lines.len) return false; - const llen = lineLenOf(lines, c.row); - if (c.col < llen) { - c.col += 1; - return true; - } - // at the newline: move to next line start - if (c.row + 1 < lines.len) { - c.row += 1; - c.col = 0; - return true; - } - return false; // EOF -} - -fn stepBwd(lines: []const []const u8, c: *Cursor) bool { - if (c.col > 0) { - c.col -= 1; - return true; - } - if (c.row == 0) return false; - c.row -= 1; - c.col = lineLenOf(lines, c.row); // the previous line's newline - return true; -} - -// at EOF? (past the last line's last char) -fn atEof(lines: []const []const u8, c: Cursor) bool { - if (c.row >= lines.len) return true; - if (c.row + 1 < lines.len) return false; - return c.col >= lines[c.row].len; -} - -pub fn firstNonWs(line: []const u8) usize { - var i: usize = 0; - while (i < line.len and (line[i] == ' ' or line[i] == '\t')) i += 1; - return i; -} - -// ---- per-line motions ---- - -pub fn lineStart(c: Cursor) Cursor { - return .{ .row = c.row, .col = 0 }; -} - -pub fn lineEnd(lines: []const []const u8, c: Cursor) Cursor { - const llen = lineLenOf(lines, c.row); - return .{ .row = c.row, .col = if (llen == 0) 0 else llen - 1 }; -} - -pub fn firstNonWsOf(lines: []const []const u8, c: Cursor) Cursor { - return .{ .row = c.row, .col = firstNonWs(lines[c.row]) }; -} - -// ---- char/line motions ---- - -pub fn charLeft(c: Cursor) Cursor { - return .{ .row = c.row, .col = if (c.col > 0) c.col - 1 else 0 }; -} - -pub fn charRight(lines: []const []const u8, c: Cursor) Cursor { - const llen = lineLenOf(lines, c.row); - const last = if (llen == 0) 0 else llen - 1; - return .{ .row = c.row, .col = if (c.col < last) c.col + 1 else last }; -} - -pub fn lineDown(lines: []const []const u8, c: Cursor) Cursor { - const nr = if (c.row + 1 < lines.len) c.row + 1 else c.row; - const llen = lineLenOf(lines, nr); - const last = if (llen == 0) 0 else llen - 1; - return .{ .row = nr, .col = if (c.col < last) c.col else last }; -} - -pub fn lineUp(lines: []const []const u8, c: Cursor) Cursor { - const nr = if (c.row > 0) c.row - 1 else c.row; - const llen = lineLenOf(lines, nr); - const last = if (llen == 0) 0 else llen - 1; - return .{ .row = nr, .col = if (c.col < last) c.col else last }; -} - -// ---- word motions ---- - -// `w`/`W`: to the start of the next word. -pub fn nextWordStart(lines: []const []const u8, c: Cursor, long: bool) Cursor { - var p = c; - const start_kind = kindAt(lines, p, long); - if (start_kind != .ws) { - // skip the rest of the current word-class run - while (!atEof(lines, p) and kindAt(lines, p, long) == start_kind) { - if (!stepFwd(lines, &p)) break; - } - } - // skip whitespace (incl. newlines) to the next word start - while (!atEof(lines, p) and kindAt(lines, p, long) == .ws) { - if (!stepFwd(lines, &p)) break; - } - // p now sits on the next word's first char (or EOF -> last valid pos) - return clampToChar(lines, p); -} - -// `b`/`B`: to the start of the previous word. -pub fn prevWordStart(lines: []const []const u8, c: Cursor, long: bool) Cursor { - var p = c; - if (!stepBwd(lines, &p)) return c; // at buffer start - // skip whitespace backward - while (kindAt(lines, p, long) == .ws) { - if (!stepBwd(lines, &p)) return .{ .row = 0, .col = 0 }; - } - // now on the end of the previous word; walk back to its start - const k = kindAt(lines, p, long); - while (true) { - var q = p; - if (!stepBwd(lines, &q)) { - p.col = 0; - break; - } - if (kindAt(lines, q, long) != k) break; // crossed into prior class - p = q; - } - return clampToChar(lines, p); -} - -// `e`/`E`: to the end of the current/next word. -pub fn nextWordEnd(lines: []const []const u8, c: Cursor, long: bool) Cursor { - var p = c; - if (!stepFwd(lines, &p)) return clampToChar(lines, c); - // skip whitespace forward - while (!atEof(lines, p) and kindAt(lines, p, long) == .ws) { - if (!stepFwd(lines, &p)) break; - } - if (atEof(lines, p)) return clampToChar(lines, p); - // now on a word's first char; advance to the last char of this run - const k = kindAt(lines, p, long); - while (!atEof(lines, p)) { - var q = p; - if (!stepFwd(lines, &q)) break; - if (kindAt(lines, q, long) != k) break; - p = q; - } - return clampToChar(lines, p); -} - -// ---- goto ---- - -pub fn gotoFirst() Cursor { - return .{ .row = 0, .col = 0 }; -} - -pub fn gotoLast(lines: []const []const u8) Cursor { - const r = if (lines.len == 0) 0 else lines.len - 1; - return .{ .row = r, .col = 0 }; -} - -// half/full page: target row, caller clamps + scrolls. -pub fn pageDown(c: Cursor, page: usize) Cursor { - return .{ .row = c.row + page, .col = c.col }; -} - -pub fn pageUp(c: Cursor, page: usize) Cursor { - return .{ .row = if (c.row > page) c.row - page else 0, .col = c.col }; -} - -// ---- helpers used by motions + main.zig ---- - -// clamp a (possibly terminator/EOF) position onto a real character. -pub fn clampToChar(lines: []const []const u8, c: Cursor) Cursor { - if (c.row >= lines.len) { - return .{ .row = if (lines.len == 0) 0 else lines.len - 1, .col = 0 }; - } - const llen = lineLenOf(lines, c.row); - if (llen == 0) return .{ .row = c.row, .col = 0 }; - return .{ .row = c.row, .col = @min(c.col, llen - 1) }; -} - -pub fn lineCount(content: []const u8) usize { - if (content.len == 0) return 0; - return std.mem.count(u8, content, "\n") + 1; -} - -// byte offset of the start of line `row` (0-based). row may == lineCount() -// (== content.len, the end). -pub fn lineStartOffset(content: []const u8, row: usize) usize { - var off: usize = 0; - var r: usize = 0; - while (r < row) : (r += 1) { - const nl = std.mem.indexOfPos(u8, content, off, "\n") orelse return content.len; - off = nl + 1; - } - return off; -} - -// the text of line `row` (no terminator), a slice into `content`. -pub fn lineSlice(content: []const u8, row: usize) []const u8 { - const start = lineStartOffset(content, row); - if (start >= content.len) return ""; - const nl = std.mem.indexOfPos(u8, content, start, "\n") orelse content.len; - return content[start..nl]; -} - -// ---- file content mutations. caller frees the returned slice + the old one. ---- - -// insert `text` at (row, col). col is clamped to the line length. -pub fn insertAt(alloc: std.mem.Allocator, content: []const u8, c: Cursor, text: []const u8) ![]u8 { - const row = if (c.row >= lineCount(content)) lineCount(content) -| 1 else c.row; - const line = lineSlice(content, row); - const col = @min(c.col, line.len); - const off = lineStartOffset(content, row) + col; - var out: std.ArrayList(u8) = .empty; - try out.appendSlice(alloc, content[0..off]); - try out.appendSlice(alloc, text); - try out.appendSlice(alloc, content[off..]); - return out.toOwnedSlice(alloc); -} - -// delete the character at (row, col). no-op if col is past the line end. -pub fn deleteChar(alloc: std.mem.Allocator, content: []const u8, c: Cursor) ![]u8 { - const line = lineSlice(content, c.row); - if (c.col >= line.len) return alloc.dupe(u8, content); - const off = lineStartOffset(content, c.row) + c.col; - var out: std.ArrayList(u8) = .empty; - try out.appendSlice(alloc, content[0..off]); - try out.appendSlice(alloc, content[off + 1 ..]); - return out.toOwnedSlice(alloc); -} - -// delete whole lines [r0, r1] inclusive (the line content + their terminators). -// returns the new content; `deleted` is the joined removed text (no terminators). -pub const Deleted = struct { content: []u8, deleted: []u8 }; - -pub fn deleteLines(alloc: std.mem.Allocator, content: []const u8, r0: usize, r1: usize) !Deleted { - const n = lineCount(content); - const lo = @min(r0, r1); - const hi = @min(@max(r0, r1), if (n == 0) 0 else n - 1); - if (n == 0 or hi < lo) return .{ .content = try alloc.dupe(u8, content), .deleted = try alloc.dupe(u8, "") }; - - const start = lineStartOffset(content, lo); - // end = start of line (hi+1), or content.len if hi is the last line. - const end = if (hi + 1 < n) lineStartOffset(content, hi + 1) else content.len; - // if we're removing the last line and there's a preceding newline, also - // drop that newline so we don't leave a trailing blank line. - var cut_lo = start; - const cut_hi = end; - if (hi + 1 == n and start > 0) cut_lo -= 1; // remove the '\n' before the last line - - var deleted: std.ArrayList(u8) = .empty; - { - var r = lo; - while (r <= hi) : (r += 1) { - if (r > lo) try deleted.append(alloc, '\n'); - try deleted.appendSlice(alloc, lineSlice(content, r)); - } - } - var out: std.ArrayList(u8) = .empty; - try out.appendSlice(alloc, content[0..cut_lo]); - try out.appendSlice(alloc, content[cut_hi..]); - return .{ .content = try out.toOwnedSlice(alloc), .deleted = try deleted.toOwnedSlice(alloc) }; -} - -// normalize two cursors into document order (lo <= hi), then byte offsets of the -// INCLUSIVE range [lo .. hi] (the char under hi is included). e == s means empty. -fn rangeBytes(content: []const u8, a: Cursor, b: Cursor) struct { s: usize, e: usize } { - var lo = a; - var hi = b; - if (hi.row < lo.row or (hi.row == lo.row and hi.col < lo.col)) { - lo = b; - hi = a; - } - const s = lineStartOffset(content, lo.row) + @min(lo.col, lineSlice(content, lo.row).len); - var e = lineStartOffset(content, hi.row) + @min(hi.col, lineSlice(content, hi.row).len); - if (e < content.len) e += 1; // include the char under the head - return .{ .s = s, .e = @max(s, e) }; -} - -// the text of the inclusive char range [a, b] (cursors in either order). Caller frees. -pub fn rangeText(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: Cursor) ![]u8 { - const r = rangeBytes(content, a, b); - return alloc.dupe(u8, content[r.s..r.e]); -} - -// delete the inclusive char range [a, b]. returns new content + the removed text. -pub fn deleteRange(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: Cursor) !Deleted { - const r = rangeBytes(content, a, b); - const deleted = try alloc.dupe(u8, content[r.s..r.e]); - errdefer alloc.free(deleted); - var out: std.ArrayList(u8) = .empty; - try out.appendSlice(alloc, content[0..r.s]); - try out.appendSlice(alloc, content[r.e..]); - return .{ .content = try out.toOwnedSlice(alloc), .deleted = deleted }; -} - -// replace line `row`'s text with "" (keep the line, empty it). For `c`hange line. -pub fn clearLine(alloc: std.mem.Allocator, content: []const u8, row: usize) ![]u8 { - const line = lineSlice(content, row); - const start = lineStartOffset(content, row); - var out: std.ArrayList(u8) = .empty; - try out.appendSlice(alloc, content[0..start]); - try out.appendSlice(alloc, content[start + line.len ..]); - return out.toOwnedSlice(alloc); -} - -// paste `text` as a new line BELOW `row`. Multiline `text` becomes several lines. -pub fn pasteLineBelow(alloc: std.mem.Allocator, content: []const u8, row: usize, text: []const u8) ![]u8 { - const n = lineCount(content); - const off = if (row + 1 < n) lineStartOffset(content, row + 1) else content.len; - var out: std.ArrayList(u8) = .empty; - try out.appendSlice(alloc, content[0..off]); - if (off < content.len) { - try out.appendSlice(alloc, text); - try out.append(alloc, '\n'); - } else { - if (off > 0) try out.append(alloc, '\n'); - try out.appendSlice(alloc, text); - } - try out.appendSlice(alloc, content[off..]); - return out.toOwnedSlice(alloc); -} - -// ---- file-pane modal step ---- -// One keypress on an editable FILE pane. This is the single source of truth for -// file-modal behavior: main.zig delegates file panes here, and the tutor-driven -// unit tests (below) exercise it directly. Pure (no vaxis/ghostty/term.zig) — -// only std + the modal primitives above. Returns the new content (caller frees -// the old), or null if the key only moved the cursor / was a no-op (caller keeps -// the old content, no realloc). - -pub const Key = union(enum) { - ch: u21, // a printable codepoint (insert inserts its UTF-8 bytes) - esc, - enter, - backspace, - left, - right, - up, - down, - home, - end, - page_down, - page_up, - half_down, // Ctrl-d - half_up, // Ctrl-u -}; - -pub const FileMode = enum { normal, insert }; - -pub const FileState = struct { - cur: Cursor = .{}, - mode: FileMode = .normal, - pending: u21 = 0, // prefix codepoint in flight: 'g' or 'z' - msel_active: bool = false, - msel_r0: i32 = 0, - msel_r1: i32 = 0, -}; - -// The view the cursor must stay inside. `scroll` is read/written so viewport -// alignment (zt/zz/zb) and page motions can adjust it; the app syncs it back to -// the file pane's scroll. The unit-test Sim passes a large rows + a dummy scroll -// so visibility is a no-op (content is what's asserted). -pub const View = struct { - rows: usize, - scroll: *usize, -}; - -fn chEq(key: Key, cp: u21) bool { - return switch (key) { - .ch => |c| c == cp, - else => false, - }; -} - -fn tagOf(key: Key) std.meta.Tag(Key) { - return std.meta.activeTag(key); -} - -fn splitLines(alloc: std.mem.Allocator, content: []const u8) ![][]const u8 { - var ls: std.ArrayList([]const u8) = .empty; - var it = std.mem.splitScalar(u8, content, '\n'); - while (it.next()) |ln| try ls.append(alloc, ln); - return ls.toOwnedSlice(alloc); -} - -fn ensureVisible(view: View, row: usize) void { - const s = view.scroll.*; - if (row < s) { - view.scroll.* = row; - return; - } - const last = s +| (view.rows -| 1); - if (row > last) view.scroll.* = row -| (view.rows -| 1); -} - -// helix `x`: start a line selection at the cursor, or extend it down by one. -fn lineSelectFile(st: *FileState, lines: []const []const u8) void { - if (!st.msel_active) { - st.msel_active = true; - st.msel_r0 = @intCast(st.cur.row); - st.msel_r1 = @intCast(st.cur.row); - } else { - const last: i32 = @intCast(if (lines.len == 0) 0 else lines.len - 1); - st.msel_r1 = @min(st.msel_r1 + 1, last); - st.cur.row = @intCast(st.msel_r1); - } - st.cur.col = 0; - st.pending = 0; -} - -// `d`: delete the selected lines (yanking them), or the char at the cursor. -fn deleteFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, lines: []const []const u8, yank: *?[]u8) !?[]u8 { - if (st.msel_active) { - const r0: i32 = @min(st.msel_r0, st.msel_r1); - const r1: i32 = @max(st.msel_r0, st.msel_r1); - const d = try deleteLines(alloc, content, @intCast(@max(0, r0)), @intCast(@max(0, r1))); - if (yank.*) |old| alloc.free(old); - yank.* = try alloc.dupe(u8, d.deleted); - alloc.free(d.deleted); - const n = lineCount(d.content); - st.cur = .{ .row = @intCast(@min(@as(usize, @intCast(@max(0, r0))), if (n == 0) 0 else n - 1)), .col = 0 }; - st.msel_active = false; - st.pending = 0; - return d.content; - } - // no selection: delete the char at the cursor (no-op if past end) - const llen = lineLenOf(lines, st.cur.row); - if (st.cur.col >= llen) { - st.pending = 0; - return null; - } - const new = try deleteChar(alloc, content, st.cur); - st.pending = 0; - return new; -} - -// `y`: yank the selected lines, or the current line. Cursor moves to the -// selection start; the selection is collapsed. -fn yankFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, yank: *?[]u8) !void { - if (st.msel_active) { - const r0: usize = @intCast(@max(0, @min(st.msel_r0, st.msel_r1))); - const r1: usize = @intCast(@max(0, @max(st.msel_r0, st.msel_r1))); - var buf: std.ArrayList(u8) = .empty; - var r: usize = r0; - while (r <= r1) : (r += 1) { - if (r > r0) try buf.append(alloc, '\n'); - try buf.appendSlice(alloc, lineSlice(content, r)); - } - if (yank.*) |old| alloc.free(old); - yank.* = try buf.toOwnedSlice(alloc); - st.cur = .{ .row = r0, .col = 0 }; - st.msel_active = false; - } else { - if (yank.*) |old| alloc.free(old); - yank.* = try alloc.dupe(u8, lineSlice(content, st.cur.row)); - } - st.pending = 0; -} - -// `p`: paste the yanked text as a new line below the cursor. -fn pasteFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, yank: *?[]u8) !?[]u8 { - const y = yank.* orelse { - st.pending = 0; - return null; - }; - const new = try pasteLineBelow(alloc, content, st.cur.row, y); - st.cur = .{ .row = st.cur.row + 1, .col = 0 }; - st.pending = 0; - return new; -} - -// `c`: change the selection (delete + enter INSERT, keeping one empty line), or -// change the char at the cursor. Yanks the removed text. -fn changeFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, lines: []const []const u8, yank: *?[]u8) !?[]u8 { - if (st.msel_active) { - const r0: usize = @intCast(@max(0, @min(st.msel_r0, st.msel_r1))); - const r1: usize = @intCast(@max(0, @max(st.msel_r0, st.msel_r1))); - // yank the original lines first (from the un-mutated content) - var buf: std.ArrayList(u8) = .empty; - var r: usize = r0; - while (r <= r1) : (r += 1) { - if (r > r0) try buf.append(alloc, '\n'); - try buf.appendSlice(alloc, lineSlice(content, r)); - } - if (yank.*) |old| alloc.free(old); - yank.* = try buf.toOwnedSlice(alloc); - // drop the extra lines (keep one), then empty the kept line - const d = if (r1 > r0) try deleteLines(alloc, content, r0 + 1, r1) else null; - defer if (d) |dd| alloc.free(dd.deleted); - const after_del: []const u8 = if (d) |dd| dd.content else content; - const cl = try clearLine(alloc, after_del, r0); - if (d) |dd| alloc.free(dd.content); // free the intermediate (after_del copy) - st.cur = .{ .row = r0, .col = 0 }; - st.mode = .insert; - st.msel_active = false; - st.pending = 0; - return cl; - } - // no selection: delete the char at the cursor (if any), then enter INSERT - const llen = lineLenOf(lines, st.cur.row); - if (st.cur.col >= llen) { - st.mode = .insert; - st.msel_active = false; - st.pending = 0; - return null; - } - const new = try deleteChar(alloc, content, st.cur); - st.mode = .insert; - st.msel_active = false; - st.pending = 0; - return new; -} - -// INSERT-mode key on a file pane. Returns new content or null (no change). -fn stepInsertFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, lines: []const []const u8, key: Key) !?[]u8 { - switch (key) { - .esc => { - st.mode = .normal; - st.msel_active = false; - st.pending = 0; - return null; - }, - .ch => |cp| { - if (cp == '\n') return try stepInsertFile(alloc, content, st, lines, .enter); - var buf: [4]u8 = undefined; - const n = std.unicode.utf8Encode(cp, &buf) catch return null; - const new = try insertAt(alloc, content, st.cur, buf[0..n]); - st.cur.col += n; // col is a byte offset; advance by the char's byte length - st.pending = 0; - return new; - }, - .enter => { - const new = try insertAt(alloc, content, st.cur, "\n"); - st.cur = .{ .row = st.cur.row + 1, .col = 0 }; - st.pending = 0; - return new; - }, - .backspace => { - if (st.cur.col > 0) { - const new = try deleteChar(alloc, content, .{ .row = st.cur.row, .col = st.cur.col - 1 }); - st.cur.col -= 1; - return new; - } else if (st.cur.row > 0) { - const prevlen = lineSlice(content, st.cur.row - 1).len; - const new = try deleteChar(alloc, content, .{ .row = st.cur.row - 1, .col = prevlen }); - st.cur = .{ .row = st.cur.row - 1, .col = prevlen }; - return new; - } - return null; - }, - .left => { - st.cur = charLeft(st.cur); - return null; - }, - .right => { - st.cur = charRight(lines, st.cur); - return null; - }, - .up => { - st.cur = lineUp(lines, st.cur); - return null; - }, - .down => { - st.cur = lineDown(lines, st.cur); - return null; - }, - .home => { - st.cur = lineStart(st.cur); - return null; - }, - .end => { - st.cur = lineEnd(lines, st.cur); - return null; - }, - else => return null, - } -} - -// The full NORMAL-mode file step (motions + insert entry + select/edit). See -// handleNormal in main.zig for the terminal counterpart (which differs in its -// edit ops: terminals can't delete shell output, only pasted runs). -pub fn stepFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, view: View, key: Key, yank: *?[]u8) !?[]u8 { - const lines = try splitLines(alloc, content); - defer alloc.free(lines); - if (st.mode == .insert) return try stepInsertFile(alloc, content, st, lines, key); - - var cur = clampToChar(lines, st.cur); - - // prefix continuations - if (st.pending != 0) { - const p = st.pending; - st.pending = 0; - if (p == 'g') { - if (chEq(key, 'g')) cur = gotoFirst() - else if (chEq(key, 'e')) cur = gotoLast(lines) - else if (chEq(key, 'h')) cur = lineStart(cur) - else if (chEq(key, 'l')) cur = lineEnd(lines, cur) - else return null; // unknown continuation: consume, no-op - st.cur = cur; - st.msel_active = false; - return null; - } - if (p == 'z') { - if (chEq(key, 't')) { - view.scroll.* = cur.row; - } else if (chEq(key, 'z')) { - view.scroll.* = @intCast(@max(0, @as(i32, @intCast(cur.row)) - @as(i32, @intCast(view.rows / 2)))); - } else if (chEq(key, 'b')) { - view.scroll.* = @intCast(@max(0, @as(i32, @intCast(cur.row)) - @as(i32, @intCast(view.rows)) + 1)); - } else return null; - st.msel_active = false; - return null; - } - } - if (chEq(key, 'g')) { - st.pending = 'g'; - return null; - } - if (chEq(key, 'z')) { - st.pending = 'z'; - return null; - } - - // motions -> move cursor, collapse selection, keep visible - var moved: ?Cursor = null; - if (chEq(key, 'h') or tagOf(key) == .left) moved = charLeft(cur) - else if (chEq(key, 'l') or tagOf(key) == .right) moved = charRight(lines, cur) - else if (chEq(key, 'j') or tagOf(key) == .down) moved = lineDown(lines, cur) - else if (chEq(key, 'k') or tagOf(key) == .up) moved = lineUp(lines, cur) - else if (chEq(key, 'w')) moved = nextWordStart(lines, cur, false) - else if (chEq(key, 'W')) moved = nextWordStart(lines, cur, true) - else if (chEq(key, 'b')) moved = prevWordStart(lines, cur, false) - else if (chEq(key, 'B')) moved = prevWordStart(lines, cur, true) - else if (chEq(key, 'e')) moved = nextWordEnd(lines, cur, false) - else if (chEq(key, 'E')) moved = nextWordEnd(lines, cur, true) - else if (chEq(key, '0') or tagOf(key) == .home) moved = lineStart(cur) - else if (chEq(key, '$') or tagOf(key) == .end) moved = lineEnd(lines, cur) - else if (chEq(key, '^')) moved = firstNonWsOf(lines, cur) - else if (chEq(key, 'G')) moved = gotoLast(lines) - else if (tagOf(key) == .half_down) moved = clampToChar(lines, pageDown(cur, @max(1, view.rows / 2))) - else if (tagOf(key) == .half_up) moved = clampToChar(lines, pageUp(cur, @max(1, view.rows / 2))) - else if (tagOf(key) == .page_down) moved = clampToChar(lines, pageDown(cur, view.rows)) - else if (tagOf(key) == .page_up) moved = clampToChar(lines, pageUp(cur, view.rows)); - if (moved) |m| { - st.cur = m; - st.msel_active = false; - st.pending = 0; - ensureVisible(view, m.row); - return null; - } - - // insert entry - if (chEq(key, 'i')) { - st.mode = .insert; - st.cur = cur; - st.msel_active = false; - st.pending = 0; - return null; - } - if (chEq(key, 'a')) { - st.mode = .insert; - st.cur = charRight(lines, cur); - st.msel_active = false; - st.pending = 0; - return null; - } - if (chEq(key, 'I')) { - st.mode = .insert; - st.cur = firstNonWsOf(lines, cur); - st.msel_active = false; - st.pending = 0; - return null; - } - if (chEq(key, 'A')) { - st.mode = .insert; - st.cur = .{ .row = cur.row, .col = lineLenOf(lines, cur.row) }; - st.msel_active = false; - st.pending = 0; - return null; - } - if (chEq(key, 'o')) { - const new = try insertAt(alloc, content, .{ .row = cur.row, .col = lineLenOf(lines, cur.row) }, "\n"); - st.cur = .{ .row = cur.row + 1, .col = 0 }; - st.mode = .insert; - st.msel_active = false; - st.pending = 0; - ensureVisible(view, st.cur.row); - return new; - } - if (chEq(key, 'O')) { - const new = try insertAt(alloc, content, .{ .row = cur.row, .col = 0 }, "\n"); - st.cur = .{ .row = cur.row, .col = 0 }; - st.mode = .insert; - st.msel_active = false; - st.pending = 0; - ensureVisible(view, st.cur.row); - return new; - } - - // select / edit - if (chEq(key, 'x')) { - lineSelectFile(st, lines); - return null; - } - if (chEq(key, 'd')) return try deleteFile(alloc, content, st, lines, yank); - if (chEq(key, 'c')) return try changeFile(alloc, content, st, lines, yank); - if (chEq(key, 'y')) { - try yankFile(alloc, content, st, yank); - return null; - } - if (chEq(key, 'p')) return try pasteFile(alloc, content, st, yank); - - // Enter / other keys: no-op on a file (no tty mode) - return null; -} - -// ---- tests ---- - -const testEq = struct { - fn cur(lines: []const []const u8, s: []const u8) Cursor { - var row: usize = 0; - for (lines) |ln| { - if (std.mem.eql(u8, ln, s)) return .{ .row = row, .col = 0 }; - row += 1; - } - unreachable; - } -}; - -test "kindOf" { - try std.testing.expectEqual(Kind.word, kindOf('a')); - try std.testing.expectEqual(Kind.word, kindOf('_')); - try std.testing.expectEqual(Kind.word, kindOf('9')); - try std.testing.expectEqual(Kind.punct, kindOf('.')); - try std.testing.expectEqual(Kind.punct, kindOf('(')); - try std.testing.expectEqual(Kind.ws, kindOf(' ')); - try std.testing.expectEqual(Kind.ws, kindOf('\n')); -} - -test "char/line motions" { - const lines = [_][]const u8{ "alpha beta", " two words", "x" }; - const c = Cursor{ .row = 0, .col = 5 }; - try std.testing.expectEqual(Cursor{ .row = 0, .col = 4 }, charLeft(c)); - try std.testing.expectEqual(Cursor{ .row = 0, .col = 6 }, charRight(&lines, c)); - try std.testing.expectEqual(Cursor{ .row = 1, .col = 5 }, lineDown(&lines, c)); - try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, lineUp(&lines, Cursor{ .row = 1, .col = 5 })); - // line ends - try std.testing.expectEqual(Cursor{ .row = 0, .col = 9 }, lineEnd(&lines, c)); - try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, lineEnd(&lines, Cursor{ .row = 2, .col = 0 })); - // first non-ws - try std.testing.expectEqual(Cursor{ .row = 1, .col = 2 }, firstNonWsOf(&lines, Cursor{ .row = 1, .col = 0 })); -} - -test "word motions w/b/e" { - const lines = [_][]const u8{"this is a test"}; - const w = &lines; - // "this is a test", indices 0..13 - try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, nextWordStart(w, Cursor{ .row = 0, .col = 0 }, false)); // t->next word "is" - try std.testing.expectEqual(Cursor{ .row = 0, .col = 8 }, nextWordStart(w, Cursor{ .row = 0, .col = 5 }, false)); // -> "a" - try std.testing.expectEqual(Cursor{ .row = 0, .col = 10 }, nextWordStart(w, Cursor{ .row = 0, .col = 8 }, false)); // -> "test" - try std.testing.expectEqual(Cursor{ .row = 0, .col = 10 }, nextWordStart(w, Cursor{ .row = 0, .col = 9 }, false)); // from ws - // b - try std.testing.expectEqual(Cursor{ .row = 0, .col = 8 }, prevWordStart(w, Cursor{ .row = 0, .col = 10 }, false)); // test -> "a" - try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, prevWordStart(w, Cursor{ .row = 0, .col = 8 }, false)); // -> "is" - try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, prevWordStart(w, Cursor{ .row = 0, .col = 5 }, false)); // -> "this" - // e - try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, nextWordEnd(w, Cursor{ .row = 0, .col = 0 }, false)); // this[3] - try std.testing.expectEqual(Cursor{ .row = 0, .col = 6 }, nextWordEnd(w, Cursor{ .row = 0, .col = 3 }, false)); // -> "is"[6] - try std.testing.expectEqual(Cursor{ .row = 0, .col = 13 }, nextWordEnd(w, Cursor{ .row = 0, .col = 10 }, false)); // -> "test"[13] -} - -test "word motions cross line" { - const lines = [_][]const u8{ "foo bar", "", "baz" }; - const w = &lines; - // from end of "foo bar" (row0 col6) w crosses the blank line to "baz" - try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, nextWordStart(w, Cursor{ .row = 0, .col = 6 }, false)); - // b from "baz" crosses back to "bar" - try std.testing.expectEqual(Cursor{ .row = 0, .col = 4 }, prevWordStart(w, Cursor{ .row = 2, .col = 0 }, false)); - // e from row0 col0 -> "foo" end (col2) - try std.testing.expectEqual(Cursor{ .row = 0, .col = 2 }, nextWordEnd(w, Cursor{ .row = 0, .col = 0 }, false)); -} - -test "long word W treats punct as word" { - // "foo.bar baz" : W from 0 -> "baz" at 8 (foo.bar is one long word) - const lines = [_][]const u8{"foo.bar baz"}; - const w = &lines; - try std.testing.expectEqual(Cursor{ .row = 0, .col = 8 }, nextWordStart(w, Cursor{ .row = 0, .col = 0 }, true)); - // w (non-long) from 0 -> '.' at 3 (punct is its own word, like vim/helix) - try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, nextWordStart(w, Cursor{ .row = 0, .col = 0 }, false)); -} - -test "goto" { - const lines = [_][]const u8{ "a", "b", "c" }; - try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, gotoFirst()); - try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, gotoLast(&lines)); -} - -test "lineStartOffset + lineSlice" { - const content = "alpha\nbeta\n\ngamma"; - try std.testing.expectEqual(@as(usize, 0), lineStartOffset(content, 0)); - try std.testing.expectEqual(@as(usize, 6), lineStartOffset(content, 1)); - try std.testing.expectEqual(@as(usize, 11), lineStartOffset(content, 2)); - try std.testing.expectEqual(@as(usize, 12), lineStartOffset(content, 3)); - try std.testing.expectEqual(@as(usize, 17), lineStartOffset(content, 4)); // past end - try std.testing.expectEqualStrings("alpha", lineSlice(content, 0)); - try std.testing.expectEqualStrings("beta", lineSlice(content, 1)); - try std.testing.expectEqualStrings("", lineSlice(content, 2)); - try std.testing.expectEqualStrings("gamma", lineSlice(content, 3)); - try std.testing.expectEqual(@as(usize, 4), lineCount(content)); -} - -test "insertAt mid-line and at end" { - const content = "hello world"; - const a = std.testing.allocator; - const r1 = try insertAt(a, content, .{ .row = 0, .col = 5 }, "!"); - defer a.free(r1); - try std.testing.expectEqualStrings("hello! world", r1); - const r2 = try insertAt(a, content, .{ .row = 0, .col = 99 }, "!"); - defer a.free(r2); - try std.testing.expectEqualStrings("hello world!", r2); -} - -test "insertAt multiline creates lines" { - const content = "a\nb"; - const a = std.testing.allocator; - const r = try insertAt(a, content, .{ .row = 0, .col = 1 }, "X\nY"); - defer a.free(r); - try std.testing.expectEqualStrings("aX\nY\nb", r); - try std.testing.expectEqual(@as(usize, 3), lineCount(r)); -} - -test "deleteChar" { - const content = "abc"; - const a = std.testing.allocator; - const r = try deleteChar(a, content, .{ .row = 0, .col = 1 }); - defer a.free(r); - try std.testing.expectEqualStrings("ac", r); - // past end: no-op - const r2 = try deleteChar(a, content, .{ .row = 0, .col = 5 }); - defer a.free(r2); - try std.testing.expectEqualStrings("abc", r2); -} - -test "deleteLines middle" { - const content = "one\ntwo\nthree\nfour"; - const a = std.testing.allocator; - const d = try deleteLines(a, content, 1, 2); - defer a.free(d.content); - defer a.free(d.deleted); - try std.testing.expectEqualStrings("one\nfour", d.content); - try std.testing.expectEqualStrings("two\nthree", d.deleted); -} - -test "deleteLines last line drops preceding newline" { - const content = "one\ntwo\nthree"; - const a = std.testing.allocator; - const d = try deleteLines(a, content, 2, 2); - defer a.free(d.content); - defer a.free(d.deleted); - try std.testing.expectEqualStrings("one\ntwo", d.content); - try std.testing.expectEqualStrings("three", d.deleted); -} - -test "deleteLines only line" { - const content = "only"; - const a = std.testing.allocator; - const d = try deleteLines(a, content, 0, 0); - defer a.free(d.content); - defer a.free(d.deleted); - try std.testing.expectEqualStrings("", d.content); - try std.testing.expectEqualStrings("only", d.deleted); -} - -test "rangeText + deleteRange (char-wise select)" { - const a = std.testing.allocator; - const content = "hello\nworld\nfoo"; - // same-line inclusive range: "hello"[1..3] -> "ell" - const t1 = try rangeText(a, content, .{ .row = 0, .col = 1 }, .{ .row = 0, .col = 3 }); - defer a.free(t1); - try std.testing.expectEqualStrings("ell", t1); - // reversed cursors give the same range - const t2 = try rangeText(a, content, .{ .row = 0, .col = 3 }, .{ .row = 0, .col = 1 }); - defer a.free(t2); - try std.testing.expectEqualStrings("ell", t2); - // cross-line range includes the newline: row0 col3 .. row1 col1 -> "lo\nwo" - const t3 = try rangeText(a, content, .{ .row = 0, .col = 3 }, .{ .row = 1, .col = 1 }); - defer a.free(t3); - try std.testing.expectEqualStrings("lo\nwo", t3); - // delete the same cross-line range - const d = try deleteRange(a, content, .{ .row = 0, .col = 3 }, .{ .row = 1, .col = 1 }); - defer a.free(d.content); - defer a.free(d.deleted); - try std.testing.expectEqualStrings("helrld\nfoo", d.content); - try std.testing.expectEqualStrings("lo\nwo", d.deleted); -} - -test "clearLine" { - const content = "keep\nzap me\nkeep2"; - const a = std.testing.allocator; - const r = try clearLine(a, content, 1); - defer a.free(r); - try std.testing.expectEqualStrings("keep\n\nkeep2", r); -} - -test "pasteLineBelow" { - const content = "one\ntwo"; - const a = std.testing.allocator; - const r = try pasteLineBelow(a, content, 0, "INSERTED"); - defer a.free(r); - try std.testing.expectEqualStrings("one\nINSERTED\ntwo", r); - // paste below last line - const r2 = try pasteLineBelow(a, content, 1, "END"); - defer a.free(r2); - try std.testing.expectEqualStrings("one\ntwo\nEND", r2); - // multiline yanked text - const r3 = try pasteLineBelow(a, content, 0, "a\nb"); - defer a.free(r3); - try std.testing.expectEqualStrings("one\na\nb\ntwo", r3); -} 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/petscii.zig b/petscii.zig deleted file mode 100644 index 136c4ead..00000000 --- a/petscii.zig +++ /dev/null @@ -1,446 +0,0 @@ -// PETSCII image rendering — a fallback when the terminal has no kitty graphics, -// and a per-pane toggle. Ported in spirit from caioluders/petsciinator: split the -// image into character cells and match each cell to the Unicode block/sextant glyph -// + a C64 foreground/background color pair that best reproduces the cell's pixels -// (minimum per-pixel color error). Pure: only std + the raw RGBA bytes (no vaxis), -// so the matcher is unit-testable on its own. -const std = @import("std"); - -// The match palette is supplied by the caller (mode-dependent): the Commodore 64 -// colors below by default, or the terminal's own ANSI palette. Cells store fg/bg as -// palette indices; image.draw paints them as C64 RGB or as indexed colors per mode. -// This is Pepto's canonical C64 palette. -pub const commodore = [16][3]u8{ - .{ 0x00, 0x00, 0x00 }, // 0 black - .{ 0xff, 0xff, 0xff }, // 1 white - .{ 0x68, 0x37, 0x2b }, // 2 red - .{ 0x70, 0xa4, 0xb2 }, // 3 cyan - .{ 0x6f, 0x3d, 0x86 }, // 4 purple - .{ 0x58, 0x8d, 0x43 }, // 5 green - .{ 0x35, 0x28, 0x79 }, // 6 blue - .{ 0xb8, 0xc7, 0x6f }, // 7 yellow - .{ 0x6f, 0x4f, 0x25 }, // 8 orange - .{ 0x43, 0x39, 0x00 }, // 9 brown - .{ 0x9a, 0x67, 0x59 }, // 10 light red - .{ 0x44, 0x44, 0x44 }, // 11 dark grey - .{ 0x6c, 0x6c, 0x6c }, // 12 grey - .{ 0x9a, 0xd2, 0x84 }, // 13 light green - .{ 0x6c, 0x5e, 0xb5 }, // 14 light blue - .{ 0x95, 0x95, 0x95 }, // 15 light grey -}; - -const Palette = [16][3]u8; - -// One rendered character cell: the UTF-8 bytes of the chosen glyph (stored inline -// so the slice handed to the retained vaxis screen outlives the frame) + palette -// fg/bg indices. -pub const Cell = struct { - glyph: [4]u8 = .{ ' ', 0, 0, 0 }, - glen: u3 = 1, - fg: u4 = 1, - bg: u4 = 0, -}; - -pub const Grid = struct { cells: []Cell, gw: usize, gh: usize }; - -fn dist2(a: [3]u8, b: [3]u8) u32 { - const dr = @as(i32, a[0]) - b[0]; - const dg = @as(i32, a[1]) - b[1]; - const db = @as(i32, a[2]) - b[2]; - return @intCast(dr * dr + dg * dg + db * db); -} - -fn nearest(px: [3]u8, pal: Palette) u4 { - var best: u4 = 0; - var bestd: u32 = std.math.maxInt(u32); - for (pal, 0..) |c, i| { - const d = dist2(px, c); - if (d < bestd) { - bestd = d; - best = @intCast(i); - } - } - return best; -} - -// ---- glyph set: each is a codepoint + an 8x8 ink bitmap (bit y*8+x set = fg) ---- -const Glyph = struct { cp: u21, bits: u64 }; - -// The Unicode codepoint for a 2x3 sextant pattern. Bits: 1=upper-left, 2=upper-right, -// 4=mid-left, 8=mid-right, 16=lower-left, 32=lower-right. The four patterns that -// coincide with existing block characters are mapped to those instead. -fn sextantCp(p: u6) u21 { - return switch (p) { - 0 => ' ', - 21 => 0x258C, // left half â–Œ - 42 => 0x2590, // right half â–� - 63 => 0x2588, // full block â–ˆ - else => blk: { - var off: u21 = @as(u21, p) - 1; - if (p > 21) off -= 1; - if (p > 42) off -= 1; - break :blk 0x1FB00 + off; // Symbols for Legacy Computing sextants - }, - }; -} - -// the 8x8 ink bitmap for a sextant pattern (2 cols x 3 rows of subcells). -fn sextantBits(p: u6) u64 { - var bits: u64 = 0; - var y: usize = 0; - while (y < 8) : (y += 1) { - const band = (y * 3) / 8; // 0,0,0,1,1,1,2,2 - var x: usize = 0; - while (x < 8) : (x += 1) { - const col: usize = if (x < 4) 0 else 1; - const sub: u6 = @intCast(band * 2 + col); - if ((p >> sub) & 1 != 0) bits |= @as(u64, 1) << @intCast(y * 8 + x); - } - } - return bits; -} - -// pack 8 row-bytes (bit x set, x=0 leftmost) into the 8x8 bitmap. -fn rows(r: [8]u8) u64 { - var b: u64 = 0; - for (r, 0..) |row, y| b |= @as(u64, row) << @intCast(y * 8); - return b; -} - -// the horizontal half blocks and the ten 2x2 quadrants — sextants are 2x3, so they -// can't express an exact 4-row half or a quarter; these fill that gap. -const block_glyphs = [_]Glyph{ - .{ .cp = 0x2580, .bits = rows(.{ 0xff, 0xff, 0xff, 0xff, 0, 0, 0, 0 }) }, // â–€ top half - .{ .cp = 0x2584, .bits = rows(.{ 0, 0, 0, 0, 0xff, 0xff, 0xff, 0xff }) }, // â–„ bottom half - .{ .cp = 0x2598, .bits = rows(.{ 0x0f, 0x0f, 0x0f, 0x0f, 0, 0, 0, 0 }) }, // â–˜ TL - .{ .cp = 0x259d, .bits = rows(.{ 0xf0, 0xf0, 0xf0, 0xf0, 0, 0, 0, 0 }) }, // â–� TR - .{ .cp = 0x2596, .bits = rows(.{ 0, 0, 0, 0, 0x0f, 0x0f, 0x0f, 0x0f }) }, // â–– BL - .{ .cp = 0x2597, .bits = rows(.{ 0, 0, 0, 0, 0xf0, 0xf0, 0xf0, 0xf0 }) }, // â–— BR - .{ .cp = 0x259a, .bits = rows(.{ 0x0f, 0x0f, 0x0f, 0x0f, 0xf0, 0xf0, 0xf0, 0xf0 }) }, // â–š TL+BR - .{ .cp = 0x259e, .bits = rows(.{ 0xf0, 0xf0, 0xf0, 0xf0, 0x0f, 0x0f, 0x0f, 0x0f }) }, // â–ž TR+BL - .{ .cp = 0x259b, .bits = rows(.{ 0xff, 0xff, 0xff, 0xff, 0x0f, 0x0f, 0x0f, 0x0f }) }, // â–› ¬BR - .{ .cp = 0x259c, .bits = rows(.{ 0xff, 0xff, 0xff, 0xff, 0xf0, 0xf0, 0xf0, 0xf0 }) }, // â–œ ¬BL - .{ .cp = 0x2599, .bits = rows(.{ 0x0f, 0x0f, 0x0f, 0x0f, 0xff, 0xff, 0xff, 0xff }) }, // â–™ ¬TR - .{ .cp = 0x259f, .bits = rows(.{ 0xf0, 0xf0, 0xf0, 0xf0, 0xff, 0xff, 0xff, 0xff }) }, // â–Ÿ ¬TL -}; - -// line/diagonal glyphs add the characteristic PETSCII "drawn" look on edges. -const line_glyphs = [_]Glyph{ - .{ .cp = 0x2500, .bits = rows(.{ 0, 0, 0, 0xff, 0xff, 0, 0, 0 }) }, // ─ - .{ .cp = 0x2502, .bits = rows(.{ 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18 }) }, // │ - .{ .cp = 0x253c, .bits = rows(.{ 0x18, 0x18, 0x18, 0xff, 0xff, 0x18, 0x18, 0x18 }) }, // ┼ - .{ .cp = 0x2572, .bits = rows(.{ 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80 }) }, // ╲ - .{ .cp = 0x2571, .bits = rows(.{ 0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01 }) }, // ╱ - .{ .cp = 0x2573, .bits = rows(.{ 0x81, 0x42, 0x24, 0x18, 0x18, 0x24, 0x42, 0x81 }) }, // ╳ -}; - -// Real 8x8 font bitmaps for the printable ASCII range (extracted from the cp850-8x8 -// console font). This is how petsciinator's charset works: the FULL character set is -// matched, each glyph scored against the cell by its actual bitmap (ink = fg), then -// rendered by its codepoint — the terminal draws the letter in its own font. (bit -// y*8+x set = ink; x=0 leftmost. Generated, not hand-drawn.) -const ascii_glyphs = [_]Glyph{ - .{ .cp = 0x21, .bits = 0x00180018183c3c18 }, // ! - .{ .cp = 0x22, .bits = 0x0000000000246666 }, // " - .{ .cp = 0x23, .bits = 0x0036367f367f3636 }, // # - .{ .cp = 0x24, .bits = 0x00183e603c067c18 }, // $ - .{ .cp = 0x25, .bits = 0x0063660c18336300 }, // % - .{ .cp = 0x26, .bits = 0x006e333b6e1c361c }, // & - .{ .cp = 0x27, .bits = 0x00000000000c1818 }, // ' - .{ .cp = 0x28, .bits = 0x0030180c0c0c1830 }, // ( - .{ .cp = 0x29, .bits = 0x000c18303030180c }, // ) - .{ .cp = 0x2a, .bits = 0x0000663cff3c6600 }, // * - .{ .cp = 0x2b, .bits = 0x000018187e181800 }, // + - .{ .cp = 0x2c, .bits = 0x0c18180000000000 }, // , - .{ .cp = 0x2d, .bits = 0x000000007e000000 }, // - - .{ .cp = 0x2e, .bits = 0x0018180000000000 }, // . - .{ .cp = 0x2f, .bits = 0x000103060c183060 }, // / - .{ .cp = 0x30, .bits = 0x001c36636b63361c }, // 0 - .{ .cp = 0x31, .bits = 0x007e181818181c18 }, // 1 - .{ .cp = 0x32, .bits = 0x007f660c3860633e }, // 2 - .{ .cp = 0x33, .bits = 0x003e63603c60633e }, // 3 - .{ .cp = 0x34, .bits = 0x0078307f33363c38 }, // 4 - .{ .cp = 0x35, .bits = 0x003e63603f03037f }, // 5 - .{ .cp = 0x36, .bits = 0x003e63633f03061c }, // 6 - .{ .cp = 0x37, .bits = 0x000c0c0c1830637f }, // 7 - .{ .cp = 0x38, .bits = 0x003e63633e63633e }, // 8 - .{ .cp = 0x39, .bits = 0x001e30607e63633e }, // 9 - .{ .cp = 0x3a, .bits = 0x0018180000181800 }, // : - .{ .cp = 0x3b, .bits = 0x0c18180000181800 }, // ; - .{ .cp = 0x3c, .bits = 0x006030180c183060 }, // < - .{ .cp = 0x3d, .bits = 0x00007e00007e0000 }, // = - .{ .cp = 0x3e, .bits = 0x00060c1830180c06 }, // > - .{ .cp = 0x3f, .bits = 0x001800181830633e }, // ? - .{ .cp = 0x40, .bits = 0x001e037b7b7b633e }, // @ - .{ .cp = 0x41, .bits = 0x006363637f63361c }, // A - .{ .cp = 0x42, .bits = 0x003f66663e66663f }, // B - .{ .cp = 0x43, .bits = 0x003c66030303663c }, // C - .{ .cp = 0x44, .bits = 0x001f36666666361f }, // D - .{ .cp = 0x45, .bits = 0x007f46161e16467f }, // E - .{ .cp = 0x46, .bits = 0x000f06161e16467f }, // F - .{ .cp = 0x47, .bits = 0x005c66730303663c }, // G - .{ .cp = 0x48, .bits = 0x006363637f636363 }, // H - .{ .cp = 0x49, .bits = 0x003c18181818183c }, // I - .{ .cp = 0x4a, .bits = 0x001e333330303078 }, // J - .{ .cp = 0x4b, .bits = 0x006766361e366667 }, // K - .{ .cp = 0x4c, .bits = 0x007f66460606060f }, // L - .{ .cp = 0x4d, .bits = 0x0063636b7f7f7763 }, // M - .{ .cp = 0x4e, .bits = 0x006363737b6f6763 }, // N - .{ .cp = 0x4f, .bits = 0x003e63636363633e }, // O - .{ .cp = 0x50, .bits = 0x000f06063e66663f }, // P - .{ .cp = 0x51, .bits = 0x703e73636363633e }, // Q - .{ .cp = 0x52, .bits = 0x006766363e66663f }, // R - .{ .cp = 0x53, .bits = 0x003c6630180c663c }, // S - .{ .cp = 0x54, .bits = 0x003c1818185a7e7e }, // T - .{ .cp = 0x55, .bits = 0x003e636363636363 }, // U - .{ .cp = 0x56, .bits = 0x001c366363636363 }, // V - .{ .cp = 0x57, .bits = 0x00367f6b6b636363 }, // W - .{ .cp = 0x58, .bits = 0x006363361c366363 }, // X - .{ .cp = 0x59, .bits = 0x003c18183c666666 }, // Y - .{ .cp = 0x5a, .bits = 0x007f664c1831637f }, // Z - .{ .cp = 0x5b, .bits = 0x003c0c0c0c0c0c3c }, // [ - .{ .cp = 0x5c, .bits = 0x00406030180c0603 }, // \\ - .{ .cp = 0x5d, .bits = 0x003c30303030303c }, // ] - .{ .cp = 0x5e, .bits = 0x0000000063361c08 }, // ^ - .{ .cp = 0x5f, .bits = 0xff00000000000000 }, // _ - .{ .cp = 0x60, .bits = 0x000000000030180c }, // ` - .{ .cp = 0x61, .bits = 0x006e333e301e0000 }, // a - .{ .cp = 0x62, .bits = 0x003b6666663e0607 }, // b - .{ .cp = 0x63, .bits = 0x003e6303633e0000 }, // c - .{ .cp = 0x64, .bits = 0x006e3333333e3038 }, // d - .{ .cp = 0x65, .bits = 0x003e037f633e0000 }, // e - .{ .cp = 0x66, .bits = 0x000f06061f06663c }, // f - .{ .cp = 0x67, .bits = 0x1f303e33336e0000 }, // g - .{ .cp = 0x68, .bits = 0x006766666e360607 }, // h - .{ .cp = 0x69, .bits = 0x003c1818181c0018 }, // i - .{ .cp = 0x6a, .bits = 0x3c66666060600060 }, // j - .{ .cp = 0x6b, .bits = 0x0067361e36660607 }, // k - .{ .cp = 0x6c, .bits = 0x003c18181818181c }, // l - .{ .cp = 0x6d, .bits = 0x006b6b6b7f370000 }, // m - .{ .cp = 0x6e, .bits = 0x00666666663b0000 }, // n - .{ .cp = 0x6f, .bits = 0x003e6363633e0000 }, // o - .{ .cp = 0x70, .bits = 0x0f063e66663b0000 }, // p - .{ .cp = 0x71, .bits = 0x78303e33336e0000 }, // q - .{ .cp = 0x72, .bits = 0x000f06066e3b0000 }, // r - .{ .cp = 0x73, .bits = 0x003f603e037e0000 }, // s - .{ .cp = 0x74, .bits = 0x00386c0c0c3f0c0c }, // t - .{ .cp = 0x75, .bits = 0x006e333333330000 }, // u - .{ .cp = 0x76, .bits = 0x001c366363630000 }, // v - .{ .cp = 0x77, .bits = 0x00367f6b6b630000 }, // w - .{ .cp = 0x78, .bits = 0x0063361c36630000 }, // x - .{ .cp = 0x79, .bits = 0x3f607e6363630000 }, // y - .{ .cp = 0x7a, .bits = 0x007e4c18327e0000 }, // z - .{ .cp = 0x7b, .bits = 0x007018180e181870 }, // { - .{ .cp = 0x7c, .bits = 0x0018181818181818 }, // | - .{ .cp = 0x7d, .bits = 0x000e18187018180e }, // } - .{ .cp = 0x7e, .bits = 0x0000000000003b6e }, // ~ -}; - -// the block glyph table (sextants + half/quadrant blocks + line glyphs), built at -// comptime, and the same set extended with the ASCII glyphs. `render(ascii=…)` picks. -const glyphs = blk: { - @setEvalBranchQuota(100000); - var list: [64 + block_glyphs.len + line_glyphs.len]Glyph = undefined; - var p: usize = 0; - while (p < 64) : (p += 1) list[p] = .{ .cp = sextantCp(@intCast(p)), .bits = sextantBits(@intCast(p)) }; - for (block_glyphs, 0..) |bg, i| list[64 + i] = bg; - for (line_glyphs, 0..) |lg, i| list[64 + block_glyphs.len + i] = lg; - break :blk list; -}; -const glyphs_ascii = glyphs ++ ascii_glyphs; - -// match one 8x8 RGB cell to the best (glyph, fg, bg). Candidate colors are the -// most-common palette colors among the 64 pixels; for each ordered pair the glyph -// cost is base(all-bg) + sum over the glyph's ink bits of (dist_fg - dist_bg). -fn matchCell(cell: *const [64][3]u8, pal: Palette, gset: []const Glyph) Cell { - var counts = [_]u16{0} ** 16; - for (cell) |px| counts[nearest(px, pal)] += 1; - var cand: [4]u4 = undefined; - var ncand: usize = 0; - var used = [_]bool{false} ** 16; - while (ncand < 4) : (ncand += 1) { - var best: ?usize = null; - for (counts, 0..) |c, i| { - if (used[i] or c == 0) continue; - if (best == null or c > counts[best.?]) best = i; - } - if (best) |bi| { - cand[ncand] = @intCast(bi); - used[bi] = true; - } else break; - } - if (ncand == 0) return .{}; // can't happen (64 pixels), but keep it total - if (ncand == 1) return encode(' ', cand[0], cand[0]); // solid color - - var best_cost: i64 = std.math.maxInt(i64); - var best = encode(' ', cand[0], cand[0]); - var fi: usize = 0; - while (fi < ncand) : (fi += 1) { - var bi: usize = 0; - while (bi < ncand) : (bi += 1) { - if (fi == bi) continue; - const fg = cand[fi]; - const bg = cand[bi]; - var dfg: [64]u32 = undefined; - var dbg: [64]u32 = undefined; - var base: i64 = 0; - for (cell, 0..) |px, p| { - dfg[p] = dist2(px, pal[fg]); - dbg[p] = dist2(px, pal[bg]); - base += dbg[p]; - } - for (gset) |g| { - var delta: i64 = 0; - var bits = g.bits; - while (bits != 0) : (bits &= bits - 1) { - const p: usize = @ctz(bits); - delta += @as(i64, dfg[p]) - @as(i64, dbg[p]); - } - const cost = base + delta; - if (cost < best_cost) { - best_cost = cost; - best = encode(g.cp, fg, bg); - } - } - } - } - return best; -} - -fn encode(cp: u21, fg: u4, bg: u4) Cell { - var c = Cell{ .fg = fg, .bg = bg }; - const n = std.unicode.utf8Encode(cp, &c.glyph) catch 1; - c.glen = @intCast(n); - return c; -} - -// Render `rgba` (iw x ih, 4 bytes/px) into a grid of at most cols x rows cells, -// preserving the image aspect with the terminal cell aspect (~1:2) corrected. -// Caller owns Grid.cells (gpa). -pub fn render(gpa: std.mem.Allocator, rgba: []const u8, iw: usize, ih: usize, cols: usize, rows_: usize, pal: Palette, ascii: bool) !Grid { - if (iw == 0 or ih == 0 or cols == 0 or rows_ == 0) return .{ .cells = try gpa.alloc(Cell, 0), .gw = 0, .gh = 0 }; - const gset: []const Glyph = if (ascii) &glyphs_ascii else &glyphs; - // contain-fit; cells are ~twice as tall as wide, so a row spans 2 width-units. - var gw = cols; - var gh = (cols * ih) / (2 * iw); - if (gh > rows_) { - gh = rows_; - gw = (rows_ * 2 * iw) / ih; - } - gw = std.math.clamp(gw, 1, cols); - gh = std.math.clamp(gh, 1, rows_); - - const cells = try gpa.alloc(Cell, gw * gh); - var cy: usize = 0; - while (cy < gh) : (cy += 1) { - const ry0 = cy * ih / gh; - const ry1 = @max(ry0 + 1, (cy + 1) * ih / gh); - var cx: usize = 0; - while (cx < gw) : (cx += 1) { - const rx0 = cx * iw / gw; - const rx1 = @max(rx0 + 1, (cx + 1) * iw / gw); - var cell: [64][3]u8 = undefined; - var sy: usize = 0; - while (sy < 8) : (sy += 1) { - const py0 = ry0 + sy * (ry1 - ry0) / 8; - const py1 = @max(py0 + 1, ry0 + (sy + 1) * (ry1 - ry0) / 8); - var sx: usize = 0; - while (sx < 8) : (sx += 1) { - const px0 = rx0 + sx * (rx1 - rx0) / 8; - const px1 = @max(px0 + 1, rx0 + (sx + 1) * (rx1 - rx0) / 8); - var rs: usize = 0; - var gs: usize = 0; - var bs: usize = 0; - var n: usize = 0; - var yy = py0; - while (yy < py1 and yy < ih) : (yy += 1) { - var xx = px0; - while (xx < px1 and xx < iw) : (xx += 1) { - const i = (yy * iw + xx) * 4; - rs += rgba[i]; - gs += rgba[i + 1]; - bs += rgba[i + 2]; - n += 1; - } - } - if (n == 0) n = 1; - cell[sy * 8 + sx] = .{ @intCast(rs / n), @intCast(gs / n), @intCast(bs / n) }; - } - } - cells[cy * gw + cx] = matchCell(&cell, pal, gset); - } - } - return .{ .cells = cells, .gw = gw, .gh = gh }; -} - -test "sextant codepoints: blocks + endpoints" { - try std.testing.expectEqual(@as(u21, ' '), sextantCp(0)); - try std.testing.expectEqual(@as(u21, 0x2588), sextantCp(63)); - try std.testing.expectEqual(@as(u21, 0x258C), sextantCp(21)); - try std.testing.expectEqual(@as(u21, 0x2590), sextantCp(42)); - try std.testing.expectEqual(@as(u21, 0x1FB00), sextantCp(1)); // first sextant - try std.testing.expectEqual(@as(u21, 0x1FB3B), sextantCp(62)); // last sextant -} - -test "matchCell: solid color -> space on that bg" { - var cell: [64][3]u8 = undefined; - for (&cell) |*p| p.* = commodore[5]; // all green - const m = matchCell(&cell, commodore, &glyphs); - try std.testing.expectEqual(@as(u4, 5), m.bg); - try std.testing.expectEqual(@as(u8, ' '), m.glyph[0]); -} - -test "matchCell: clean top/bottom split picks the two colors" { - var cell: [64][3]u8 = undefined; - for (0..64) |p| cell[p] = if (p < 32) commodore[1] else commodore[6]; // white over blue - const m = matchCell(&cell, commodore, &glyphs); - // both palette colors must be chosen (in some fg/bg order) - const a = @as(u4, @min(m.fg, m.bg)); - const b = @as(u4, @max(m.fg, m.bg)); - try std.testing.expectEqual(@as(u4, 1), a); - try std.testing.expectEqual(@as(u4, 6), b); - // a clean top/bottom split must resolve to a real block glyph (the top-4-rows - // half block â–€), never a blank cell. - const cp = std.unicode.utf8Decode(m.glyph[0..m.glen]) catch 0; - try std.testing.expectEqual(@as(u21, 0x2580), cp); -} - -test "ascii option only enables the ascii glyphs" { - // the ascii glyph codepoints must be reachable exactly when ascii is on. - var seen_block = false; - var seen_ascii = false; - for (glyphs) |g| if (g.cp == '#') { - seen_block = true; - }; - for (glyphs_ascii) |g| if (g.cp == '#') { - seen_ascii = true; - }; - try std.testing.expect(!seen_block); // '#' is an ascii-only glyph - try std.testing.expect(seen_ascii); - try std.testing.expectEqual(glyphs.len + ascii_glyphs.len, glyphs_ascii.len); -} - -test "ascii glyph is chosen when a cell has its exact shape" { - // a cell shaped exactly like the font 'S' (ink=white on black) must match 'S' - // with ascii on (cost 0), and fall back to some block glyph with ascii off. - const s_bits: u64 = 0x003c6630180c663c; - var cell: [64][3]u8 = undefined; - for (0..64) |p| cell[p] = if ((s_bits >> @intCast(p)) & 1 != 0) commodore[1] else commodore[0]; - const on = matchCell(&cell, commodore, &glyphs_ascii); - try std.testing.expectEqual(@as(u21, 'S'), std.unicode.utf8Decode(on.glyph[0..on.glen]) catch 0); - const off = matchCell(&cell, commodore, &glyphs); - try std.testing.expect((std.unicode.utf8Decode(off.glyph[0..off.glen]) catch 0) != 'S'); -} - -test "render: tiny image produces a grid within bounds" { - const a = std.testing.allocator; - // 2x2 checker, RGBA - var img = [_]u8{0} ** (2 * 2 * 4); - img[0] = 255; img[1] = 255; img[2] = 255; img[3] = 255; // (0,0) white - img[(3) * 4 + 0] = 255; img[(3) * 4 + 1] = 255; img[(3) * 4 + 2] = 255; img[(3) * 4 + 3] = 255; // (1,1) white - const g = try render(a, &img, 2, 2, 10, 10, commodore, true); - defer a.free(g.cells); - try std.testing.expect(g.gw >= 1 and g.gw <= 10); - try std.testing.expect(g.gh >= 1 and g.gh <= 10); - try std.testing.expectEqual(g.gw * g.gh, g.cells.len); -} 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 \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/src/dump.zig b/src/dump.zig new file mode 100644 index 00000000..9c1dceec --- /dev/null +++ b/src/dump.zig @@ -0,0 +1,218 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +pub const default_path = "/tmp/pardes-dump.zon"; +pub const magic = "pardes-dump"; +pub const version: u32 = 1; +pub const max_panes: usize = 16; +pub const max_cols: usize = 6; + +pub const Size = struct { + cols: u16, + rows: u16, +}; + +pub const Cursor = struct { + col: u16 = 0, + row: u16 = 0, +}; + +pub const PaneKind = enum { + terminal, + file, + image, +}; + +pub const Terminal = struct { + cwd: []const u8 = "", + stream: []const u8 = "", + stream_b64: []const u8 = "", + cursor: Cursor = .{}, +}; + +pub const File = struct { + path: []const u8 = "", + content: []const u8 = "", + content_b64: []const u8 = "", +}; + +pub const Image = struct { + path: []const u8 = "", + bytes_b64: []const u8 = "", +}; + +pub const Pane = struct { + kind: PaneKind, + tag: []const u8, + body: []const u8, + scroll: usize = 0, + cols: u16 = 0, + rows: u16 = 0, + vweight: f32 = 1, + terminal: ?Terminal = null, + file: ?File = null, + image: ?Image = null, +}; + +pub const Column = struct { + weight: f32 = 1, + panes: []const usize = &.{}, +}; + +pub const State = struct { + magic: []const u8 = magic, + version: u32 = version, + screen: Size, + active: usize = 0, + topbar: []const u8 = "", + theme: []const u8 = "dark", + columns: []const Column = &.{}, + panes: []const Pane = &.{}, +}; + +pub fn validate(state: State) !void { + if (!std.mem.eql(u8, state.magic, magic)) return error.BadDumpMagic; + if (state.version != version) return error.BadDumpVersion; + if (state.panes.len == 0 or state.panes.len > max_panes) return error.BadDumpPanes; + if (state.columns.len == 0 or state.columns.len > max_cols) return error.BadDumpColumns; + if (state.active >= state.panes.len) return error.BadDumpActive; + for (state.columns) |col| { + if (col.panes.len == 0 or col.panes.len > max_panes) return error.BadDumpColumns; + for (col.panes) |pane| { + if (pane >= state.panes.len) return error.BadDumpPaneRef; + } + } + for (state.panes) |pane| { + switch (pane.kind) { + .terminal => if (pane.terminal == null) return error.BadDumpPaneKind, + .file => if (pane.file == null) return error.BadDumpPaneKind, + .image => if (pane.image == null) return error.BadDumpPaneKind, + } + } +} + +pub fn writeFile(io: std.Io, gpa: std.mem.Allocator, path: []const u8, state: State) !void { + try validate(state); + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer); + + var file = try std.Io.Dir.createFileAbsolute(io, path, .{}); + defer file.close(io); + try file.writeStreamingAll(io, out.written()); +} + +pub fn readFile(io: std.Io, gpa: std.mem.Allocator, path: []const u8) !State { + const bytes = try std.Io.Dir.cwd().readFileAlloc(io, path, gpa, .limited(64 * 1024 * 1024)); + defer gpa.free(bytes); + return readZon(gpa, bytes, path); +} + +pub fn readZon(gpa: std.mem.Allocator, bytes: []const u8, label: []const u8) !State { + const source = try gpa.dupeZ(u8, bytes); + defer gpa.free(source); + return readZonZ(gpa, source, label); +} + +pub fn readZonZ(gpa: std.mem.Allocator, source: [:0]const u8, label: []const u8) !State { + if (builtin.os.tag == .emscripten) { + const state = std.zon.parse.fromSliceAlloc(State, gpa, source, null, .{}) catch |err| { + std.log.err("parse dump {s}: {s}", .{ label, @errorName(err) }); + return err; + }; + errdefer std.zon.parse.free(gpa, state); + try validate(state); + return state; + } + + var diag: std.zon.parse.Diagnostics = .{}; + defer diag.deinit(gpa); + const state = std.zon.parse.fromSliceAlloc(State, gpa, source, &diag, .{}) catch |err| { + std.log.err("parse dump {s}: {f}", .{ label, diag }); + return err; + }; + errdefer std.zon.parse.free(gpa, state); + try validate(state); + return state; +} + +pub fn free(gpa: std.mem.Allocator, state: State) void { + std.zon.parse.free(gpa, state); +} + +pub fn encodeBytes(alloc: std.mem.Allocator, bytes: []const u8) ![]const u8 { + const enc = std.base64.standard.Encoder; + const out = try alloc.alloc(u8, enc.calcSize(bytes.len)); + return enc.encode(out, bytes); +} + +pub fn decodeBytes(alloc: std.mem.Allocator, b64: []const u8) ![]u8 { + const dec = std.base64.standard.Decoder; + const n = try dec.calcSizeForSlice(b64); + const out = try alloc.alloc(u8, n); + errdefer alloc.free(out); + try dec.decode(out, b64); + return out; +} + +test "dump zon roundtrip" { + const gpa = std.testing.allocator; + const tty_b64 = try encodeBytes(gpa, "old\nhello\nworld"); + defer gpa.free(tty_b64); + const file_b64 = try encodeBytes(gpa, "alpha\nbeta\n"); + defer gpa.free(file_b64); + const panes = [_]Pane{ + .{ + .kind = .terminal, + .tag = "nm /tmp Del", + .body = "hello\nworld", + .scroll = 1, + .cols = 40, + .rows = 10, + .terminal = .{ .cwd = "/tmp", .stream = "old\nhello\nworld", .stream_b64 = tty_b64, .cursor = .{ .col = 3, .row = 2 } }, + }, + .{ + .kind = .file, + .tag = "nm /tmp/a.txt Save Del", + .body = " 1 alpha", + .cols = 20, + .rows = 5, + .file = .{ .path = "/tmp/a.txt", .content = "alpha\nbeta\n", .content_b64 = file_b64 }, + }, + }; + const col_panes = [_]usize{ 0, 1 }; + const columns = [_]Column{.{ .weight = 1, .panes = &col_panes }}; + const state: State = .{ + .screen = .{ .cols = 80, .rows = 24 }, + .topbar = "Kill Newcol Dump", + .theme = "dark", + .columns = &columns, + .panes = &panes, + }; + + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer); + const source = try out.toOwnedSliceSentinel(0); + defer gpa.free(source); + + var diag: std.zon.parse.Diagnostics = .{}; + defer diag.deinit(gpa); + const parsed = try std.zon.parse.fromSliceAlloc(State, gpa, source, &diag, .{}); + defer std.zon.parse.free(gpa, parsed); + try validate(parsed); + try std.testing.expectEqual(@as(usize, 2), parsed.panes.len); + try std.testing.expectEqualStrings("dark", parsed.theme); + try std.testing.expectEqualStrings("old\nhello\nworld", parsed.panes[0].terminal.?.stream); + try std.testing.expectEqualStrings("alpha\nbeta\n", parsed.panes[1].file.?.content); + { + const bytes = try decodeBytes(gpa, parsed.panes[0].terminal.?.stream_b64); + defer gpa.free(bytes); + try std.testing.expectEqualStrings("old\nhello\nworld", bytes); + } + { + const bytes = try decodeBytes(gpa, parsed.panes[1].file.?.content_b64); + defer gpa.free(bytes); + try std.testing.expectEqualStrings("alpha\nbeta\n", bytes); + } +} 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;;;;; 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 use Ctrl- to enter/leave tty mode + \\ -l 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/src/modal.zig b/src/modal.zig new file mode 100644 index 00000000..2a195432 --- /dev/null +++ b/src/modal.zig @@ -0,0 +1,964 @@ +const std = @import("std"); + +// Modal-editing text math, kept free of vaxis/ghostty so it can be unit-tested +// in isolation (see the `unit-test` build step). main.zig wires this onto the +// pane's cursor + (for file panes) its content. +// +// The cursor sits ON a character: col is a char index in [0, line.len]; col == +// line.len means "on the line terminator / after the last char". Motions are +// written to land on real characters; main.zig clamps for display. + +pub const Cursor = struct { + row: usize = 0, + col: usize = 0, + + pub fn eql(a: Cursor, b: Cursor) bool { + return a.row == b.row and a.col == b.col; + } +}; + +// word char classes (matches ad/vim/kakoune: word = alnum + _, punct = other +// non-ws, ws = space/tab/newline). +pub const Kind = enum { word, punct, ws }; + +pub fn kindOf(c: u8) Kind { + if (c == ' ' or c == '\t' or c == '\n' or c == '\r') return .ws; + if (std.ascii.isAlphanumeric(c) or c == '_') return .word; + return .punct; +} + +// "long word" (W/B/E): only whitespace separates; punct is part of a word. +fn kindOfLong(c: u8) Kind { + if (c == ' ' or c == '\t' or c == '\n' or c == '\r') return .ws; + return .word; +} + +fn kindAt(lines: []const []const u8, c: Cursor, long: bool) Kind { + if (c.row >= lines.len) return .ws; + const line = lines[c.row]; + if (c.col >= line.len) return .ws; // line terminator / EOF = whitespace + return if (long) kindOfLong(line[c.col]) else kindOf(line[c.col]); +} + +fn lineLenOf(lines: []const []const u8, row: usize) usize { + if (row >= lines.len) return 0; + return lines[row].len; +} + +// advance one position across line boundaries (line terminators are positions +// too: col == line.len is the newline). Returns false at EOF. +fn stepFwd(lines: []const []const u8, c: *Cursor) bool { + if (c.row >= lines.len) return false; + const llen = lineLenOf(lines, c.row); + if (c.col < llen) { + c.col += 1; + return true; + } + // at the newline: move to next line start + if (c.row + 1 < lines.len) { + c.row += 1; + c.col = 0; + return true; + } + return false; // EOF +} + +fn stepBwd(lines: []const []const u8, c: *Cursor) bool { + if (c.col > 0) { + c.col -= 1; + return true; + } + if (c.row == 0) return false; + c.row -= 1; + c.col = lineLenOf(lines, c.row); // the previous line's newline + return true; +} + +// at EOF? (past the last line's last char) +fn atEof(lines: []const []const u8, c: Cursor) bool { + if (c.row >= lines.len) return true; + if (c.row + 1 < lines.len) return false; + return c.col >= lines[c.row].len; +} + +pub fn firstNonWs(line: []const u8) usize { + var i: usize = 0; + while (i < line.len and (line[i] == ' ' or line[i] == '\t')) i += 1; + return i; +} + +// ---- per-line motions ---- + +pub fn lineStart(c: Cursor) Cursor { + return .{ .row = c.row, .col = 0 }; +} + +pub fn lineEnd(lines: []const []const u8, c: Cursor) Cursor { + const llen = lineLenOf(lines, c.row); + return .{ .row = c.row, .col = if (llen == 0) 0 else llen - 1 }; +} + +pub fn firstNonWsOf(lines: []const []const u8, c: Cursor) Cursor { + return .{ .row = c.row, .col = firstNonWs(lines[c.row]) }; +} + +// ---- char/line motions ---- + +pub fn charLeft(c: Cursor) Cursor { + return .{ .row = c.row, .col = if (c.col > 0) c.col - 1 else 0 }; +} + +pub fn charRight(lines: []const []const u8, c: Cursor) Cursor { + const llen = lineLenOf(lines, c.row); + const last = if (llen == 0) 0 else llen - 1; + return .{ .row = c.row, .col = if (c.col < last) c.col + 1 else last }; +} + +pub fn lineDown(lines: []const []const u8, c: Cursor) Cursor { + const nr = if (c.row + 1 < lines.len) c.row + 1 else c.row; + const llen = lineLenOf(lines, nr); + const last = if (llen == 0) 0 else llen - 1; + return .{ .row = nr, .col = if (c.col < last) c.col else last }; +} + +pub fn lineUp(lines: []const []const u8, c: Cursor) Cursor { + const nr = if (c.row > 0) c.row - 1 else c.row; + const llen = lineLenOf(lines, nr); + const last = if (llen == 0) 0 else llen - 1; + return .{ .row = nr, .col = if (c.col < last) c.col else last }; +} + +// ---- word motions ---- + +// `w`/`W`: to the start of the next word. +pub fn nextWordStart(lines: []const []const u8, c: Cursor, long: bool) Cursor { + var p = c; + const start_kind = kindAt(lines, p, long); + if (start_kind != .ws) { + // skip the rest of the current word-class run + while (!atEof(lines, p) and kindAt(lines, p, long) == start_kind) { + if (!stepFwd(lines, &p)) break; + } + } + // skip whitespace (incl. newlines) to the next word start + while (!atEof(lines, p) and kindAt(lines, p, long) == .ws) { + if (!stepFwd(lines, &p)) break; + } + // p now sits on the next word's first char (or EOF -> last valid pos) + return clampToChar(lines, p); +} + +// `b`/`B`: to the start of the previous word. +pub fn prevWordStart(lines: []const []const u8, c: Cursor, long: bool) Cursor { + var p = c; + if (!stepBwd(lines, &p)) return c; // at buffer start + // skip whitespace backward + while (kindAt(lines, p, long) == .ws) { + if (!stepBwd(lines, &p)) return .{ .row = 0, .col = 0 }; + } + // now on the end of the previous word; walk back to its start + const k = kindAt(lines, p, long); + while (true) { + var q = p; + if (!stepBwd(lines, &q)) { + p.col = 0; + break; + } + if (kindAt(lines, q, long) != k) break; // crossed into prior class + p = q; + } + return clampToChar(lines, p); +} + +// `e`/`E`: to the end of the current/next word. +pub fn nextWordEnd(lines: []const []const u8, c: Cursor, long: bool) Cursor { + var p = c; + if (!stepFwd(lines, &p)) return clampToChar(lines, c); + // skip whitespace forward + while (!atEof(lines, p) and kindAt(lines, p, long) == .ws) { + if (!stepFwd(lines, &p)) break; + } + if (atEof(lines, p)) return clampToChar(lines, p); + // now on a word's first char; advance to the last char of this run + const k = kindAt(lines, p, long); + while (!atEof(lines, p)) { + var q = p; + if (!stepFwd(lines, &q)) break; + if (kindAt(lines, q, long) != k) break; + p = q; + } + return clampToChar(lines, p); +} + +// ---- goto ---- + +pub fn gotoFirst() Cursor { + return .{ .row = 0, .col = 0 }; +} + +pub fn gotoLast(lines: []const []const u8) Cursor { + const r = if (lines.len == 0) 0 else lines.len - 1; + return .{ .row = r, .col = 0 }; +} + +// half/full page: target row, caller clamps + scrolls. +pub fn pageDown(c: Cursor, page: usize) Cursor { + return .{ .row = c.row + page, .col = c.col }; +} + +pub fn pageUp(c: Cursor, page: usize) Cursor { + return .{ .row = if (c.row > page) c.row - page else 0, .col = c.col }; +} + +// ---- helpers used by motions + main.zig ---- + +// clamp a (possibly terminator/EOF) position onto a real character. +pub fn clampToChar(lines: []const []const u8, c: Cursor) Cursor { + if (c.row >= lines.len) { + return .{ .row = if (lines.len == 0) 0 else lines.len - 1, .col = 0 }; + } + const llen = lineLenOf(lines, c.row); + if (llen == 0) return .{ .row = c.row, .col = 0 }; + return .{ .row = c.row, .col = @min(c.col, llen - 1) }; +} + +pub fn lineCount(content: []const u8) usize { + if (content.len == 0) return 0; + return std.mem.count(u8, content, "\n") + 1; +} + +// byte offset of the start of line `row` (0-based). row may == lineCount() +// (== content.len, the end). +pub fn lineStartOffset(content: []const u8, row: usize) usize { + var off: usize = 0; + var r: usize = 0; + while (r < row) : (r += 1) { + const nl = std.mem.indexOfPos(u8, content, off, "\n") orelse return content.len; + off = nl + 1; + } + return off; +} + +// the text of line `row` (no terminator), a slice into `content`. +pub fn lineSlice(content: []const u8, row: usize) []const u8 { + const start = lineStartOffset(content, row); + if (start >= content.len) return ""; + const nl = std.mem.indexOfPos(u8, content, start, "\n") orelse content.len; + return content[start..nl]; +} + +// ---- file content mutations. caller frees the returned slice + the old one. ---- + +// insert `text` at (row, col). col is clamped to the line length. +pub fn insertAt(alloc: std.mem.Allocator, content: []const u8, c: Cursor, text: []const u8) ![]u8 { + const row = if (c.row >= lineCount(content)) lineCount(content) -| 1 else c.row; + const line = lineSlice(content, row); + const col = @min(c.col, line.len); + const off = lineStartOffset(content, row) + col; + var out: std.ArrayList(u8) = .empty; + try out.appendSlice(alloc, content[0..off]); + try out.appendSlice(alloc, text); + try out.appendSlice(alloc, content[off..]); + return out.toOwnedSlice(alloc); +} + +// delete the character at (row, col). no-op if col is past the line end. +pub fn deleteChar(alloc: std.mem.Allocator, content: []const u8, c: Cursor) ![]u8 { + const line = lineSlice(content, c.row); + if (c.col >= line.len) return alloc.dupe(u8, content); + const off = lineStartOffset(content, c.row) + c.col; + var out: std.ArrayList(u8) = .empty; + try out.appendSlice(alloc, content[0..off]); + try out.appendSlice(alloc, content[off + 1 ..]); + return out.toOwnedSlice(alloc); +} + +// delete whole lines [r0, r1] inclusive (the line content + their terminators). +// returns the new content; `deleted` is the joined removed text (no terminators). +pub const Deleted = struct { content: []u8, deleted: []u8 }; + +pub fn deleteLines(alloc: std.mem.Allocator, content: []const u8, r0: usize, r1: usize) !Deleted { + const n = lineCount(content); + const lo = @min(r0, r1); + const hi = @min(@max(r0, r1), if (n == 0) 0 else n - 1); + if (n == 0 or hi < lo) return .{ .content = try alloc.dupe(u8, content), .deleted = try alloc.dupe(u8, "") }; + + const start = lineStartOffset(content, lo); + // end = start of line (hi+1), or content.len if hi is the last line. + const end = if (hi + 1 < n) lineStartOffset(content, hi + 1) else content.len; + // if we're removing the last line and there's a preceding newline, also + // drop that newline so we don't leave a trailing blank line. + var cut_lo = start; + const cut_hi = end; + if (hi + 1 == n and start > 0) cut_lo -= 1; // remove the '\n' before the last line + + var deleted: std.ArrayList(u8) = .empty; + { + var r = lo; + while (r <= hi) : (r += 1) { + if (r > lo) try deleted.append(alloc, '\n'); + try deleted.appendSlice(alloc, lineSlice(content, r)); + } + } + var out: std.ArrayList(u8) = .empty; + try out.appendSlice(alloc, content[0..cut_lo]); + try out.appendSlice(alloc, content[cut_hi..]); + return .{ .content = try out.toOwnedSlice(alloc), .deleted = try deleted.toOwnedSlice(alloc) }; +} + +// normalize two cursors into document order (lo <= hi), then byte offsets of the +// INCLUSIVE range [lo .. hi] (the char under hi is included). e == s means empty. +fn rangeBytes(content: []const u8, a: Cursor, b: Cursor) struct { s: usize, e: usize } { + var lo = a; + var hi = b; + if (hi.row < lo.row or (hi.row == lo.row and hi.col < lo.col)) { + lo = b; + hi = a; + } + const s = lineStartOffset(content, lo.row) + @min(lo.col, lineSlice(content, lo.row).len); + var e = lineStartOffset(content, hi.row) + @min(hi.col, lineSlice(content, hi.row).len); + if (e < content.len) e += 1; // include the char under the head + return .{ .s = s, .e = @max(s, e) }; +} + +// the text of the inclusive char range [a, b] (cursors in either order). Caller frees. +pub fn rangeText(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: Cursor) ![]u8 { + const r = rangeBytes(content, a, b); + return alloc.dupe(u8, content[r.s..r.e]); +} + +// delete the inclusive char range [a, b]. returns new content + the removed text. +pub fn deleteRange(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: Cursor) !Deleted { + const r = rangeBytes(content, a, b); + const deleted = try alloc.dupe(u8, content[r.s..r.e]); + errdefer alloc.free(deleted); + var out: std.ArrayList(u8) = .empty; + try out.appendSlice(alloc, content[0..r.s]); + try out.appendSlice(alloc, content[r.e..]); + return .{ .content = try out.toOwnedSlice(alloc), .deleted = deleted }; +} + +// replace line `row`'s text with "" (keep the line, empty it). For `c`hange line. +pub fn clearLine(alloc: std.mem.Allocator, content: []const u8, row: usize) ![]u8 { + const line = lineSlice(content, row); + const start = lineStartOffset(content, row); + var out: std.ArrayList(u8) = .empty; + try out.appendSlice(alloc, content[0..start]); + try out.appendSlice(alloc, content[start + line.len ..]); + return out.toOwnedSlice(alloc); +} + +// paste `text` as a new line BELOW `row`. Multiline `text` becomes several lines. +pub fn pasteLineBelow(alloc: std.mem.Allocator, content: []const u8, row: usize, text: []const u8) ![]u8 { + const n = lineCount(content); + const off = if (row + 1 < n) lineStartOffset(content, row + 1) else content.len; + var out: std.ArrayList(u8) = .empty; + try out.appendSlice(alloc, content[0..off]); + if (off < content.len) { + try out.appendSlice(alloc, text); + try out.append(alloc, '\n'); + } else { + if (off > 0) try out.append(alloc, '\n'); + try out.appendSlice(alloc, text); + } + try out.appendSlice(alloc, content[off..]); + return out.toOwnedSlice(alloc); +} + +// ---- file-pane modal step ---- +// One keypress on an editable FILE pane. This is the single source of truth for +// file-modal behavior: main.zig delegates file panes here, and the tutor-driven +// unit tests (below) exercise it directly. Pure (no vaxis/ghostty/term.zig) — +// only std + the modal primitives above. Returns the new content (caller frees +// the old), or null if the key only moved the cursor / was a no-op (caller keeps +// the old content, no realloc). + +pub const Key = union(enum) { + ch: u21, // a printable codepoint (insert inserts its UTF-8 bytes) + esc, + enter, + backspace, + left, + right, + up, + down, + home, + end, + page_down, + page_up, + half_down, // Ctrl-d + half_up, // Ctrl-u +}; + +pub const FileMode = enum { normal, insert }; + +pub const FileState = struct { + cur: Cursor = .{}, + mode: FileMode = .normal, + pending: u21 = 0, // prefix codepoint in flight: 'g' or 'z' + msel_active: bool = false, + msel_r0: i32 = 0, + msel_r1: i32 = 0, +}; + +// The view the cursor must stay inside. `scroll` is read/written so viewport +// alignment (zt/zz/zb) and page motions can adjust it; the app syncs it back to +// the file pane's scroll. The unit-test Sim passes a large rows + a dummy scroll +// so visibility is a no-op (content is what's asserted). +pub const View = struct { + rows: usize, + scroll: *usize, +}; + +fn chEq(key: Key, cp: u21) bool { + return switch (key) { + .ch => |c| c == cp, + else => false, + }; +} + +fn tagOf(key: Key) std.meta.Tag(Key) { + return std.meta.activeTag(key); +} + +fn splitLines(alloc: std.mem.Allocator, content: []const u8) ![][]const u8 { + var ls: std.ArrayList([]const u8) = .empty; + var it = std.mem.splitScalar(u8, content, '\n'); + while (it.next()) |ln| try ls.append(alloc, ln); + return ls.toOwnedSlice(alloc); +} + +fn ensureVisible(view: View, row: usize) void { + const s = view.scroll.*; + if (row < s) { + view.scroll.* = row; + return; + } + const last = s +| (view.rows -| 1); + if (row > last) view.scroll.* = row -| (view.rows -| 1); +} + +// helix `x`: start a line selection at the cursor, or extend it down by one. +fn lineSelectFile(st: *FileState, lines: []const []const u8) void { + if (!st.msel_active) { + st.msel_active = true; + st.msel_r0 = @intCast(st.cur.row); + st.msel_r1 = @intCast(st.cur.row); + } else { + const last: i32 = @intCast(if (lines.len == 0) 0 else lines.len - 1); + st.msel_r1 = @min(st.msel_r1 + 1, last); + st.cur.row = @intCast(st.msel_r1); + } + st.cur.col = 0; + st.pending = 0; +} + +// `d`: delete the selected lines (yanking them), or the char at the cursor. +fn deleteFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, lines: []const []const u8, yank: *?[]u8) !?[]u8 { + if (st.msel_active) { + const r0: i32 = @min(st.msel_r0, st.msel_r1); + const r1: i32 = @max(st.msel_r0, st.msel_r1); + const d = try deleteLines(alloc, content, @intCast(@max(0, r0)), @intCast(@max(0, r1))); + if (yank.*) |old| alloc.free(old); + yank.* = try alloc.dupe(u8, d.deleted); + alloc.free(d.deleted); + const n = lineCount(d.content); + st.cur = .{ .row = @intCast(@min(@as(usize, @intCast(@max(0, r0))), if (n == 0) 0 else n - 1)), .col = 0 }; + st.msel_active = false; + st.pending = 0; + return d.content; + } + // no selection: delete the char at the cursor (no-op if past end) + const llen = lineLenOf(lines, st.cur.row); + if (st.cur.col >= llen) { + st.pending = 0; + return null; + } + const new = try deleteChar(alloc, content, st.cur); + st.pending = 0; + return new; +} + +// `y`: yank the selected lines, or the current line. Cursor moves to the +// selection start; the selection is collapsed. +fn yankFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, yank: *?[]u8) !void { + if (st.msel_active) { + const r0: usize = @intCast(@max(0, @min(st.msel_r0, st.msel_r1))); + const r1: usize = @intCast(@max(0, @max(st.msel_r0, st.msel_r1))); + var buf: std.ArrayList(u8) = .empty; + var r: usize = r0; + while (r <= r1) : (r += 1) { + if (r > r0) try buf.append(alloc, '\n'); + try buf.appendSlice(alloc, lineSlice(content, r)); + } + if (yank.*) |old| alloc.free(old); + yank.* = try buf.toOwnedSlice(alloc); + st.cur = .{ .row = r0, .col = 0 }; + st.msel_active = false; + } else { + if (yank.*) |old| alloc.free(old); + yank.* = try alloc.dupe(u8, lineSlice(content, st.cur.row)); + } + st.pending = 0; +} + +// `p`: paste the yanked text as a new line below the cursor. +fn pasteFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, yank: *?[]u8) !?[]u8 { + const y = yank.* orelse { + st.pending = 0; + return null; + }; + const new = try pasteLineBelow(alloc, content, st.cur.row, y); + st.cur = .{ .row = st.cur.row + 1, .col = 0 }; + st.pending = 0; + return new; +} + +// `c`: change the selection (delete + enter INSERT, keeping one empty line), or +// change the char at the cursor. Yanks the removed text. +fn changeFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, lines: []const []const u8, yank: *?[]u8) !?[]u8 { + if (st.msel_active) { + const r0: usize = @intCast(@max(0, @min(st.msel_r0, st.msel_r1))); + const r1: usize = @intCast(@max(0, @max(st.msel_r0, st.msel_r1))); + // yank the original lines first (from the un-mutated content) + var buf: std.ArrayList(u8) = .empty; + var r: usize = r0; + while (r <= r1) : (r += 1) { + if (r > r0) try buf.append(alloc, '\n'); + try buf.appendSlice(alloc, lineSlice(content, r)); + } + if (yank.*) |old| alloc.free(old); + yank.* = try buf.toOwnedSlice(alloc); + // drop the extra lines (keep one), then empty the kept line + const d = if (r1 > r0) try deleteLines(alloc, content, r0 + 1, r1) else null; + defer if (d) |dd| alloc.free(dd.deleted); + const after_del: []const u8 = if (d) |dd| dd.content else content; + const cl = try clearLine(alloc, after_del, r0); + if (d) |dd| alloc.free(dd.content); // free the intermediate (after_del copy) + st.cur = .{ .row = r0, .col = 0 }; + st.mode = .insert; + st.msel_active = false; + st.pending = 0; + return cl; + } + // no selection: delete the char at the cursor (if any), then enter INSERT + const llen = lineLenOf(lines, st.cur.row); + if (st.cur.col >= llen) { + st.mode = .insert; + st.msel_active = false; + st.pending = 0; + return null; + } + const new = try deleteChar(alloc, content, st.cur); + st.mode = .insert; + st.msel_active = false; + st.pending = 0; + return new; +} + +// INSERT-mode key on a file pane. Returns new content or null (no change). +fn stepInsertFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, lines: []const []const u8, key: Key) !?[]u8 { + switch (key) { + .esc => { + st.mode = .normal; + st.msel_active = false; + st.pending = 0; + return null; + }, + .ch => |cp| { + if (cp == '\n') return try stepInsertFile(alloc, content, st, lines, .enter); + var buf: [4]u8 = undefined; + const n = std.unicode.utf8Encode(cp, &buf) catch return null; + const new = try insertAt(alloc, content, st.cur, buf[0..n]); + st.cur.col += n; // col is a byte offset; advance by the char's byte length + st.pending = 0; + return new; + }, + .enter => { + const new = try insertAt(alloc, content, st.cur, "\n"); + st.cur = .{ .row = st.cur.row + 1, .col = 0 }; + st.pending = 0; + return new; + }, + .backspace => { + if (st.cur.col > 0) { + const new = try deleteChar(alloc, content, .{ .row = st.cur.row, .col = st.cur.col - 1 }); + st.cur.col -= 1; + return new; + } else if (st.cur.row > 0) { + const prevlen = lineSlice(content, st.cur.row - 1).len; + const new = try deleteChar(alloc, content, .{ .row = st.cur.row - 1, .col = prevlen }); + st.cur = .{ .row = st.cur.row - 1, .col = prevlen }; + return new; + } + return null; + }, + .left => { + st.cur = charLeft(st.cur); + return null; + }, + .right => { + st.cur = charRight(lines, st.cur); + return null; + }, + .up => { + st.cur = lineUp(lines, st.cur); + return null; + }, + .down => { + st.cur = lineDown(lines, st.cur); + return null; + }, + .home => { + st.cur = lineStart(st.cur); + return null; + }, + .end => { + st.cur = lineEnd(lines, st.cur); + return null; + }, + else => return null, + } +} + +// The full NORMAL-mode file step (motions + insert entry + select/edit). See +// handleNormal in main.zig for the terminal counterpart (which differs in its +// edit ops: terminals can't delete shell output, only pasted runs). +pub fn stepFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, view: View, key: Key, yank: *?[]u8) !?[]u8 { + const lines = try splitLines(alloc, content); + defer alloc.free(lines); + if (st.mode == .insert) return try stepInsertFile(alloc, content, st, lines, key); + + var cur = clampToChar(lines, st.cur); + + // prefix continuations + if (st.pending != 0) { + const p = st.pending; + st.pending = 0; + if (p == 'g') { + if (chEq(key, 'g')) cur = gotoFirst() + else if (chEq(key, 'e')) cur = gotoLast(lines) + else if (chEq(key, 'h')) cur = lineStart(cur) + else if (chEq(key, 'l')) cur = lineEnd(lines, cur) + else return null; // unknown continuation: consume, no-op + st.cur = cur; + st.msel_active = false; + return null; + } + if (p == 'z') { + if (chEq(key, 't')) { + view.scroll.* = cur.row; + } else if (chEq(key, 'z')) { + view.scroll.* = @intCast(@max(0, @as(i32, @intCast(cur.row)) - @as(i32, @intCast(view.rows / 2)))); + } else if (chEq(key, 'b')) { + view.scroll.* = @intCast(@max(0, @as(i32, @intCast(cur.row)) - @as(i32, @intCast(view.rows)) + 1)); + } else return null; + st.msel_active = false; + return null; + } + } + if (chEq(key, 'g')) { + st.pending = 'g'; + return null; + } + if (chEq(key, 'z')) { + st.pending = 'z'; + return null; + } + + // motions -> move cursor, collapse selection, keep visible + var moved: ?Cursor = null; + if (chEq(key, 'h') or tagOf(key) == .left) moved = charLeft(cur) + else if (chEq(key, 'l') or tagOf(key) == .right) moved = charRight(lines, cur) + else if (chEq(key, 'j') or tagOf(key) == .down) moved = lineDown(lines, cur) + else if (chEq(key, 'k') or tagOf(key) == .up) moved = lineUp(lines, cur) + else if (chEq(key, 'w')) moved = nextWordStart(lines, cur, false) + else if (chEq(key, 'W')) moved = nextWordStart(lines, cur, true) + else if (chEq(key, 'b')) moved = prevWordStart(lines, cur, false) + else if (chEq(key, 'B')) moved = prevWordStart(lines, cur, true) + else if (chEq(key, 'e')) moved = nextWordEnd(lines, cur, false) + else if (chEq(key, 'E')) moved = nextWordEnd(lines, cur, true) + else if (chEq(key, '0') or tagOf(key) == .home) moved = lineStart(cur) + else if (chEq(key, '$') or tagOf(key) == .end) moved = lineEnd(lines, cur) + else if (chEq(key, '^')) moved = firstNonWsOf(lines, cur) + else if (chEq(key, 'G')) moved = gotoLast(lines) + else if (tagOf(key) == .half_down) moved = clampToChar(lines, pageDown(cur, @max(1, view.rows / 2))) + else if (tagOf(key) == .half_up) moved = clampToChar(lines, pageUp(cur, @max(1, view.rows / 2))) + else if (tagOf(key) == .page_down) moved = clampToChar(lines, pageDown(cur, view.rows)) + else if (tagOf(key) == .page_up) moved = clampToChar(lines, pageUp(cur, view.rows)); + if (moved) |m| { + st.cur = m; + st.msel_active = false; + st.pending = 0; + ensureVisible(view, m.row); + return null; + } + + // insert entry + if (chEq(key, 'i')) { + st.mode = .insert; + st.cur = cur; + st.msel_active = false; + st.pending = 0; + return null; + } + if (chEq(key, 'a')) { + st.mode = .insert; + st.cur = charRight(lines, cur); + st.msel_active = false; + st.pending = 0; + return null; + } + if (chEq(key, 'I')) { + st.mode = .insert; + st.cur = firstNonWsOf(lines, cur); + st.msel_active = false; + st.pending = 0; + return null; + } + if (chEq(key, 'A')) { + st.mode = .insert; + st.cur = .{ .row = cur.row, .col = lineLenOf(lines, cur.row) }; + st.msel_active = false; + st.pending = 0; + return null; + } + if (chEq(key, 'o')) { + const new = try insertAt(alloc, content, .{ .row = cur.row, .col = lineLenOf(lines, cur.row) }, "\n"); + st.cur = .{ .row = cur.row + 1, .col = 0 }; + st.mode = .insert; + st.msel_active = false; + st.pending = 0; + ensureVisible(view, st.cur.row); + return new; + } + if (chEq(key, 'O')) { + const new = try insertAt(alloc, content, .{ .row = cur.row, .col = 0 }, "\n"); + st.cur = .{ .row = cur.row, .col = 0 }; + st.mode = .insert; + st.msel_active = false; + st.pending = 0; + ensureVisible(view, st.cur.row); + return new; + } + + // select / edit + if (chEq(key, 'x')) { + lineSelectFile(st, lines); + return null; + } + if (chEq(key, 'd')) return try deleteFile(alloc, content, st, lines, yank); + if (chEq(key, 'c')) return try changeFile(alloc, content, st, lines, yank); + if (chEq(key, 'y')) { + try yankFile(alloc, content, st, yank); + return null; + } + if (chEq(key, 'p')) return try pasteFile(alloc, content, st, yank); + + // Enter / other keys: no-op on a file (no tty mode) + return null; +} + +// ---- tests ---- + +const testEq = struct { + fn cur(lines: []const []const u8, s: []const u8) Cursor { + var row: usize = 0; + for (lines) |ln| { + if (std.mem.eql(u8, ln, s)) return .{ .row = row, .col = 0 }; + row += 1; + } + unreachable; + } +}; + +test "kindOf" { + try std.testing.expectEqual(Kind.word, kindOf('a')); + try std.testing.expectEqual(Kind.word, kindOf('_')); + try std.testing.expectEqual(Kind.word, kindOf('9')); + try std.testing.expectEqual(Kind.punct, kindOf('.')); + try std.testing.expectEqual(Kind.punct, kindOf('(')); + try std.testing.expectEqual(Kind.ws, kindOf(' ')); + try std.testing.expectEqual(Kind.ws, kindOf('\n')); +} + +test "char/line motions" { + const lines = [_][]const u8{ "alpha beta", " two words", "x" }; + const c = Cursor{ .row = 0, .col = 5 }; + try std.testing.expectEqual(Cursor{ .row = 0, .col = 4 }, charLeft(c)); + try std.testing.expectEqual(Cursor{ .row = 0, .col = 6 }, charRight(&lines, c)); + try std.testing.expectEqual(Cursor{ .row = 1, .col = 5 }, lineDown(&lines, c)); + try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, lineUp(&lines, Cursor{ .row = 1, .col = 5 })); + // line ends + try std.testing.expectEqual(Cursor{ .row = 0, .col = 9 }, lineEnd(&lines, c)); + try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, lineEnd(&lines, Cursor{ .row = 2, .col = 0 })); + // first non-ws + try std.testing.expectEqual(Cursor{ .row = 1, .col = 2 }, firstNonWsOf(&lines, Cursor{ .row = 1, .col = 0 })); +} + +test "word motions w/b/e" { + const lines = [_][]const u8{"this is a test"}; + const w = &lines; + // "this is a test", indices 0..13 + try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, nextWordStart(w, Cursor{ .row = 0, .col = 0 }, false)); // t->next word "is" + try std.testing.expectEqual(Cursor{ .row = 0, .col = 8 }, nextWordStart(w, Cursor{ .row = 0, .col = 5 }, false)); // -> "a" + try std.testing.expectEqual(Cursor{ .row = 0, .col = 10 }, nextWordStart(w, Cursor{ .row = 0, .col = 8 }, false)); // -> "test" + try std.testing.expectEqual(Cursor{ .row = 0, .col = 10 }, nextWordStart(w, Cursor{ .row = 0, .col = 9 }, false)); // from ws + // b + try std.testing.expectEqual(Cursor{ .row = 0, .col = 8 }, prevWordStart(w, Cursor{ .row = 0, .col = 10 }, false)); // test -> "a" + try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, prevWordStart(w, Cursor{ .row = 0, .col = 8 }, false)); // -> "is" + try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, prevWordStart(w, Cursor{ .row = 0, .col = 5 }, false)); // -> "this" + // e + try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, nextWordEnd(w, Cursor{ .row = 0, .col = 0 }, false)); // this[3] + try std.testing.expectEqual(Cursor{ .row = 0, .col = 6 }, nextWordEnd(w, Cursor{ .row = 0, .col = 3 }, false)); // -> "is"[6] + try std.testing.expectEqual(Cursor{ .row = 0, .col = 13 }, nextWordEnd(w, Cursor{ .row = 0, .col = 10 }, false)); // -> "test"[13] +} + +test "word motions cross line" { + const lines = [_][]const u8{ "foo bar", "", "baz" }; + const w = &lines; + // from end of "foo bar" (row0 col6) w crosses the blank line to "baz" + try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, nextWordStart(w, Cursor{ .row = 0, .col = 6 }, false)); + // b from "baz" crosses back to "bar" + try std.testing.expectEqual(Cursor{ .row = 0, .col = 4 }, prevWordStart(w, Cursor{ .row = 2, .col = 0 }, false)); + // e from row0 col0 -> "foo" end (col2) + try std.testing.expectEqual(Cursor{ .row = 0, .col = 2 }, nextWordEnd(w, Cursor{ .row = 0, .col = 0 }, false)); +} + +test "long word W treats punct as word" { + // "foo.bar baz" : W from 0 -> "baz" at 8 (foo.bar is one long word) + const lines = [_][]const u8{"foo.bar baz"}; + const w = &lines; + try std.testing.expectEqual(Cursor{ .row = 0, .col = 8 }, nextWordStart(w, Cursor{ .row = 0, .col = 0 }, true)); + // w (non-long) from 0 -> '.' at 3 (punct is its own word, like vim/helix) + try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, nextWordStart(w, Cursor{ .row = 0, .col = 0 }, false)); +} + +test "goto" { + const lines = [_][]const u8{ "a", "b", "c" }; + try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, gotoFirst()); + try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, gotoLast(&lines)); +} + +test "lineStartOffset + lineSlice" { + const content = "alpha\nbeta\n\ngamma"; + try std.testing.expectEqual(@as(usize, 0), lineStartOffset(content, 0)); + try std.testing.expectEqual(@as(usize, 6), lineStartOffset(content, 1)); + try std.testing.expectEqual(@as(usize, 11), lineStartOffset(content, 2)); + try std.testing.expectEqual(@as(usize, 12), lineStartOffset(content, 3)); + try std.testing.expectEqual(@as(usize, 17), lineStartOffset(content, 4)); // past end + try std.testing.expectEqualStrings("alpha", lineSlice(content, 0)); + try std.testing.expectEqualStrings("beta", lineSlice(content, 1)); + try std.testing.expectEqualStrings("", lineSlice(content, 2)); + try std.testing.expectEqualStrings("gamma", lineSlice(content, 3)); + try std.testing.expectEqual(@as(usize, 4), lineCount(content)); +} + +test "insertAt mid-line and at end" { + const content = "hello world"; + const a = std.testing.allocator; + const r1 = try insertAt(a, content, .{ .row = 0, .col = 5 }, "!"); + defer a.free(r1); + try std.testing.expectEqualStrings("hello! world", r1); + const r2 = try insertAt(a, content, .{ .row = 0, .col = 99 }, "!"); + defer a.free(r2); + try std.testing.expectEqualStrings("hello world!", r2); +} + +test "insertAt multiline creates lines" { + const content = "a\nb"; + const a = std.testing.allocator; + const r = try insertAt(a, content, .{ .row = 0, .col = 1 }, "X\nY"); + defer a.free(r); + try std.testing.expectEqualStrings("aX\nY\nb", r); + try std.testing.expectEqual(@as(usize, 3), lineCount(r)); +} + +test "deleteChar" { + const content = "abc"; + const a = std.testing.allocator; + const r = try deleteChar(a, content, .{ .row = 0, .col = 1 }); + defer a.free(r); + try std.testing.expectEqualStrings("ac", r); + // past end: no-op + const r2 = try deleteChar(a, content, .{ .row = 0, .col = 5 }); + defer a.free(r2); + try std.testing.expectEqualStrings("abc", r2); +} + +test "deleteLines middle" { + const content = "one\ntwo\nthree\nfour"; + const a = std.testing.allocator; + const d = try deleteLines(a, content, 1, 2); + defer a.free(d.content); + defer a.free(d.deleted); + try std.testing.expectEqualStrings("one\nfour", d.content); + try std.testing.expectEqualStrings("two\nthree", d.deleted); +} + +test "deleteLines last line drops preceding newline" { + const content = "one\ntwo\nthree"; + const a = std.testing.allocator; + const d = try deleteLines(a, content, 2, 2); + defer a.free(d.content); + defer a.free(d.deleted); + try std.testing.expectEqualStrings("one\ntwo", d.content); + try std.testing.expectEqualStrings("three", d.deleted); +} + +test "deleteLines only line" { + const content = "only"; + const a = std.testing.allocator; + const d = try deleteLines(a, content, 0, 0); + defer a.free(d.content); + defer a.free(d.deleted); + try std.testing.expectEqualStrings("", d.content); + try std.testing.expectEqualStrings("only", d.deleted); +} + +test "rangeText + deleteRange (char-wise select)" { + const a = std.testing.allocator; + const content = "hello\nworld\nfoo"; + // same-line inclusive range: "hello"[1..3] -> "ell" + const t1 = try rangeText(a, content, .{ .row = 0, .col = 1 }, .{ .row = 0, .col = 3 }); + defer a.free(t1); + try std.testing.expectEqualStrings("ell", t1); + // reversed cursors give the same range + const t2 = try rangeText(a, content, .{ .row = 0, .col = 3 }, .{ .row = 0, .col = 1 }); + defer a.free(t2); + try std.testing.expectEqualStrings("ell", t2); + // cross-line range includes the newline: row0 col3 .. row1 col1 -> "lo\nwo" + const t3 = try rangeText(a, content, .{ .row = 0, .col = 3 }, .{ .row = 1, .col = 1 }); + defer a.free(t3); + try std.testing.expectEqualStrings("lo\nwo", t3); + // delete the same cross-line range + const d = try deleteRange(a, content, .{ .row = 0, .col = 3 }, .{ .row = 1, .col = 1 }); + defer a.free(d.content); + defer a.free(d.deleted); + try std.testing.expectEqualStrings("helrld\nfoo", d.content); + try std.testing.expectEqualStrings("lo\nwo", d.deleted); +} + +test "clearLine" { + const content = "keep\nzap me\nkeep2"; + const a = std.testing.allocator; + const r = try clearLine(a, content, 1); + defer a.free(r); + try std.testing.expectEqualStrings("keep\n\nkeep2", r); +} + +test "pasteLineBelow" { + const content = "one\ntwo"; + const a = std.testing.allocator; + const r = try pasteLineBelow(a, content, 0, "INSERTED"); + defer a.free(r); + try std.testing.expectEqualStrings("one\nINSERTED\ntwo", r); + // paste below last line + const r2 = try pasteLineBelow(a, content, 1, "END"); + defer a.free(r2); + try std.testing.expectEqualStrings("one\ntwo\nEND", r2); + // multiline yanked text + const r3 = try pasteLineBelow(a, content, 0, "a\nb"); + defer a.free(r3); + try std.testing.expectEqualStrings("one\na\nb\ntwo", r3); +} 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//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/src/petscii.zig b/src/petscii.zig new file mode 100644 index 00000000..136c4ead --- /dev/null +++ b/src/petscii.zig @@ -0,0 +1,446 @@ +// PETSCII image rendering — a fallback when the terminal has no kitty graphics, +// and a per-pane toggle. Ported in spirit from caioluders/petsciinator: split the +// image into character cells and match each cell to the Unicode block/sextant glyph +// + a C64 foreground/background color pair that best reproduces the cell's pixels +// (minimum per-pixel color error). Pure: only std + the raw RGBA bytes (no vaxis), +// so the matcher is unit-testable on its own. +const std = @import("std"); + +// The match palette is supplied by the caller (mode-dependent): the Commodore 64 +// colors below by default, or the terminal's own ANSI palette. Cells store fg/bg as +// palette indices; image.draw paints them as C64 RGB or as indexed colors per mode. +// This is Pepto's canonical C64 palette. +pub const commodore = [16][3]u8{ + .{ 0x00, 0x00, 0x00 }, // 0 black + .{ 0xff, 0xff, 0xff }, // 1 white + .{ 0x68, 0x37, 0x2b }, // 2 red + .{ 0x70, 0xa4, 0xb2 }, // 3 cyan + .{ 0x6f, 0x3d, 0x86 }, // 4 purple + .{ 0x58, 0x8d, 0x43 }, // 5 green + .{ 0x35, 0x28, 0x79 }, // 6 blue + .{ 0xb8, 0xc7, 0x6f }, // 7 yellow + .{ 0x6f, 0x4f, 0x25 }, // 8 orange + .{ 0x43, 0x39, 0x00 }, // 9 brown + .{ 0x9a, 0x67, 0x59 }, // 10 light red + .{ 0x44, 0x44, 0x44 }, // 11 dark grey + .{ 0x6c, 0x6c, 0x6c }, // 12 grey + .{ 0x9a, 0xd2, 0x84 }, // 13 light green + .{ 0x6c, 0x5e, 0xb5 }, // 14 light blue + .{ 0x95, 0x95, 0x95 }, // 15 light grey +}; + +const Palette = [16][3]u8; + +// One rendered character cell: the UTF-8 bytes of the chosen glyph (stored inline +// so the slice handed to the retained vaxis screen outlives the frame) + palette +// fg/bg indices. +pub const Cell = struct { + glyph: [4]u8 = .{ ' ', 0, 0, 0 }, + glen: u3 = 1, + fg: u4 = 1, + bg: u4 = 0, +}; + +pub const Grid = struct { cells: []Cell, gw: usize, gh: usize }; + +fn dist2(a: [3]u8, b: [3]u8) u32 { + const dr = @as(i32, a[0]) - b[0]; + const dg = @as(i32, a[1]) - b[1]; + const db = @as(i32, a[2]) - b[2]; + return @intCast(dr * dr + dg * dg + db * db); +} + +fn nearest(px: [3]u8, pal: Palette) u4 { + var best: u4 = 0; + var bestd: u32 = std.math.maxInt(u32); + for (pal, 0..) |c, i| { + const d = dist2(px, c); + if (d < bestd) { + bestd = d; + best = @intCast(i); + } + } + return best; +} + +// ---- glyph set: each is a codepoint + an 8x8 ink bitmap (bit y*8+x set = fg) ---- +const Glyph = struct { cp: u21, bits: u64 }; + +// The Unicode codepoint for a 2x3 sextant pattern. Bits: 1=upper-left, 2=upper-right, +// 4=mid-left, 8=mid-right, 16=lower-left, 32=lower-right. The four patterns that +// coincide with existing block characters are mapped to those instead. +fn sextantCp(p: u6) u21 { + return switch (p) { + 0 => ' ', + 21 => 0x258C, // left half â–Œ + 42 => 0x2590, // right half â–� + 63 => 0x2588, // full block â–ˆ + else => blk: { + var off: u21 = @as(u21, p) - 1; + if (p > 21) off -= 1; + if (p > 42) off -= 1; + break :blk 0x1FB00 + off; // Symbols for Legacy Computing sextants + }, + }; +} + +// the 8x8 ink bitmap for a sextant pattern (2 cols x 3 rows of subcells). +fn sextantBits(p: u6) u64 { + var bits: u64 = 0; + var y: usize = 0; + while (y < 8) : (y += 1) { + const band = (y * 3) / 8; // 0,0,0,1,1,1,2,2 + var x: usize = 0; + while (x < 8) : (x += 1) { + const col: usize = if (x < 4) 0 else 1; + const sub: u6 = @intCast(band * 2 + col); + if ((p >> sub) & 1 != 0) bits |= @as(u64, 1) << @intCast(y * 8 + x); + } + } + return bits; +} + +// pack 8 row-bytes (bit x set, x=0 leftmost) into the 8x8 bitmap. +fn rows(r: [8]u8) u64 { + var b: u64 = 0; + for (r, 0..) |row, y| b |= @as(u64, row) << @intCast(y * 8); + return b; +} + +// the horizontal half blocks and the ten 2x2 quadrants — sextants are 2x3, so they +// can't express an exact 4-row half or a quarter; these fill that gap. +const block_glyphs = [_]Glyph{ + .{ .cp = 0x2580, .bits = rows(.{ 0xff, 0xff, 0xff, 0xff, 0, 0, 0, 0 }) }, // â–€ top half + .{ .cp = 0x2584, .bits = rows(.{ 0, 0, 0, 0, 0xff, 0xff, 0xff, 0xff }) }, // â–„ bottom half + .{ .cp = 0x2598, .bits = rows(.{ 0x0f, 0x0f, 0x0f, 0x0f, 0, 0, 0, 0 }) }, // â–˜ TL + .{ .cp = 0x259d, .bits = rows(.{ 0xf0, 0xf0, 0xf0, 0xf0, 0, 0, 0, 0 }) }, // â–� TR + .{ .cp = 0x2596, .bits = rows(.{ 0, 0, 0, 0, 0x0f, 0x0f, 0x0f, 0x0f }) }, // â–– BL + .{ .cp = 0x2597, .bits = rows(.{ 0, 0, 0, 0, 0xf0, 0xf0, 0xf0, 0xf0 }) }, // â–— BR + .{ .cp = 0x259a, .bits = rows(.{ 0x0f, 0x0f, 0x0f, 0x0f, 0xf0, 0xf0, 0xf0, 0xf0 }) }, // â–š TL+BR + .{ .cp = 0x259e, .bits = rows(.{ 0xf0, 0xf0, 0xf0, 0xf0, 0x0f, 0x0f, 0x0f, 0x0f }) }, // â–ž TR+BL + .{ .cp = 0x259b, .bits = rows(.{ 0xff, 0xff, 0xff, 0xff, 0x0f, 0x0f, 0x0f, 0x0f }) }, // â–› ¬BR + .{ .cp = 0x259c, .bits = rows(.{ 0xff, 0xff, 0xff, 0xff, 0xf0, 0xf0, 0xf0, 0xf0 }) }, // â–œ ¬BL + .{ .cp = 0x2599, .bits = rows(.{ 0x0f, 0x0f, 0x0f, 0x0f, 0xff, 0xff, 0xff, 0xff }) }, // â–™ ¬TR + .{ .cp = 0x259f, .bits = rows(.{ 0xf0, 0xf0, 0xf0, 0xf0, 0xff, 0xff, 0xff, 0xff }) }, // â–Ÿ ¬TL +}; + +// line/diagonal glyphs add the characteristic PETSCII "drawn" look on edges. +const line_glyphs = [_]Glyph{ + .{ .cp = 0x2500, .bits = rows(.{ 0, 0, 0, 0xff, 0xff, 0, 0, 0 }) }, // ─ + .{ .cp = 0x2502, .bits = rows(.{ 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18 }) }, // │ + .{ .cp = 0x253c, .bits = rows(.{ 0x18, 0x18, 0x18, 0xff, 0xff, 0x18, 0x18, 0x18 }) }, // ┼ + .{ .cp = 0x2572, .bits = rows(.{ 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80 }) }, // ╲ + .{ .cp = 0x2571, .bits = rows(.{ 0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01 }) }, // ╱ + .{ .cp = 0x2573, .bits = rows(.{ 0x81, 0x42, 0x24, 0x18, 0x18, 0x24, 0x42, 0x81 }) }, // ╳ +}; + +// Real 8x8 font bitmaps for the printable ASCII range (extracted from the cp850-8x8 +// console font). This is how petsciinator's charset works: the FULL character set is +// matched, each glyph scored against the cell by its actual bitmap (ink = fg), then +// rendered by its codepoint — the terminal draws the letter in its own font. (bit +// y*8+x set = ink; x=0 leftmost. Generated, not hand-drawn.) +const ascii_glyphs = [_]Glyph{ + .{ .cp = 0x21, .bits = 0x00180018183c3c18 }, // ! + .{ .cp = 0x22, .bits = 0x0000000000246666 }, // " + .{ .cp = 0x23, .bits = 0x0036367f367f3636 }, // # + .{ .cp = 0x24, .bits = 0x00183e603c067c18 }, // $ + .{ .cp = 0x25, .bits = 0x0063660c18336300 }, // % + .{ .cp = 0x26, .bits = 0x006e333b6e1c361c }, // & + .{ .cp = 0x27, .bits = 0x00000000000c1818 }, // ' + .{ .cp = 0x28, .bits = 0x0030180c0c0c1830 }, // ( + .{ .cp = 0x29, .bits = 0x000c18303030180c }, // ) + .{ .cp = 0x2a, .bits = 0x0000663cff3c6600 }, // * + .{ .cp = 0x2b, .bits = 0x000018187e181800 }, // + + .{ .cp = 0x2c, .bits = 0x0c18180000000000 }, // , + .{ .cp = 0x2d, .bits = 0x000000007e000000 }, // - + .{ .cp = 0x2e, .bits = 0x0018180000000000 }, // . + .{ .cp = 0x2f, .bits = 0x000103060c183060 }, // / + .{ .cp = 0x30, .bits = 0x001c36636b63361c }, // 0 + .{ .cp = 0x31, .bits = 0x007e181818181c18 }, // 1 + .{ .cp = 0x32, .bits = 0x007f660c3860633e }, // 2 + .{ .cp = 0x33, .bits = 0x003e63603c60633e }, // 3 + .{ .cp = 0x34, .bits = 0x0078307f33363c38 }, // 4 + .{ .cp = 0x35, .bits = 0x003e63603f03037f }, // 5 + .{ .cp = 0x36, .bits = 0x003e63633f03061c }, // 6 + .{ .cp = 0x37, .bits = 0x000c0c0c1830637f }, // 7 + .{ .cp = 0x38, .bits = 0x003e63633e63633e }, // 8 + .{ .cp = 0x39, .bits = 0x001e30607e63633e }, // 9 + .{ .cp = 0x3a, .bits = 0x0018180000181800 }, // : + .{ .cp = 0x3b, .bits = 0x0c18180000181800 }, // ; + .{ .cp = 0x3c, .bits = 0x006030180c183060 }, // < + .{ .cp = 0x3d, .bits = 0x00007e00007e0000 }, // = + .{ .cp = 0x3e, .bits = 0x00060c1830180c06 }, // > + .{ .cp = 0x3f, .bits = 0x001800181830633e }, // ? + .{ .cp = 0x40, .bits = 0x001e037b7b7b633e }, // @ + .{ .cp = 0x41, .bits = 0x006363637f63361c }, // A + .{ .cp = 0x42, .bits = 0x003f66663e66663f }, // B + .{ .cp = 0x43, .bits = 0x003c66030303663c }, // C + .{ .cp = 0x44, .bits = 0x001f36666666361f }, // D + .{ .cp = 0x45, .bits = 0x007f46161e16467f }, // E + .{ .cp = 0x46, .bits = 0x000f06161e16467f }, // F + .{ .cp = 0x47, .bits = 0x005c66730303663c }, // G + .{ .cp = 0x48, .bits = 0x006363637f636363 }, // H + .{ .cp = 0x49, .bits = 0x003c18181818183c }, // I + .{ .cp = 0x4a, .bits = 0x001e333330303078 }, // J + .{ .cp = 0x4b, .bits = 0x006766361e366667 }, // K + .{ .cp = 0x4c, .bits = 0x007f66460606060f }, // L + .{ .cp = 0x4d, .bits = 0x0063636b7f7f7763 }, // M + .{ .cp = 0x4e, .bits = 0x006363737b6f6763 }, // N + .{ .cp = 0x4f, .bits = 0x003e63636363633e }, // O + .{ .cp = 0x50, .bits = 0x000f06063e66663f }, // P + .{ .cp = 0x51, .bits = 0x703e73636363633e }, // Q + .{ .cp = 0x52, .bits = 0x006766363e66663f }, // R + .{ .cp = 0x53, .bits = 0x003c6630180c663c }, // S + .{ .cp = 0x54, .bits = 0x003c1818185a7e7e }, // T + .{ .cp = 0x55, .bits = 0x003e636363636363 }, // U + .{ .cp = 0x56, .bits = 0x001c366363636363 }, // V + .{ .cp = 0x57, .bits = 0x00367f6b6b636363 }, // W + .{ .cp = 0x58, .bits = 0x006363361c366363 }, // X + .{ .cp = 0x59, .bits = 0x003c18183c666666 }, // Y + .{ .cp = 0x5a, .bits = 0x007f664c1831637f }, // Z + .{ .cp = 0x5b, .bits = 0x003c0c0c0c0c0c3c }, // [ + .{ .cp = 0x5c, .bits = 0x00406030180c0603 }, // \\ + .{ .cp = 0x5d, .bits = 0x003c30303030303c }, // ] + .{ .cp = 0x5e, .bits = 0x0000000063361c08 }, // ^ + .{ .cp = 0x5f, .bits = 0xff00000000000000 }, // _ + .{ .cp = 0x60, .bits = 0x000000000030180c }, // ` + .{ .cp = 0x61, .bits = 0x006e333e301e0000 }, // a + .{ .cp = 0x62, .bits = 0x003b6666663e0607 }, // b + .{ .cp = 0x63, .bits = 0x003e6303633e0000 }, // c + .{ .cp = 0x64, .bits = 0x006e3333333e3038 }, // d + .{ .cp = 0x65, .bits = 0x003e037f633e0000 }, // e + .{ .cp = 0x66, .bits = 0x000f06061f06663c }, // f + .{ .cp = 0x67, .bits = 0x1f303e33336e0000 }, // g + .{ .cp = 0x68, .bits = 0x006766666e360607 }, // h + .{ .cp = 0x69, .bits = 0x003c1818181c0018 }, // i + .{ .cp = 0x6a, .bits = 0x3c66666060600060 }, // j + .{ .cp = 0x6b, .bits = 0x0067361e36660607 }, // k + .{ .cp = 0x6c, .bits = 0x003c18181818181c }, // l + .{ .cp = 0x6d, .bits = 0x006b6b6b7f370000 }, // m + .{ .cp = 0x6e, .bits = 0x00666666663b0000 }, // n + .{ .cp = 0x6f, .bits = 0x003e6363633e0000 }, // o + .{ .cp = 0x70, .bits = 0x0f063e66663b0000 }, // p + .{ .cp = 0x71, .bits = 0x78303e33336e0000 }, // q + .{ .cp = 0x72, .bits = 0x000f06066e3b0000 }, // r + .{ .cp = 0x73, .bits = 0x003f603e037e0000 }, // s + .{ .cp = 0x74, .bits = 0x00386c0c0c3f0c0c }, // t + .{ .cp = 0x75, .bits = 0x006e333333330000 }, // u + .{ .cp = 0x76, .bits = 0x001c366363630000 }, // v + .{ .cp = 0x77, .bits = 0x00367f6b6b630000 }, // w + .{ .cp = 0x78, .bits = 0x0063361c36630000 }, // x + .{ .cp = 0x79, .bits = 0x3f607e6363630000 }, // y + .{ .cp = 0x7a, .bits = 0x007e4c18327e0000 }, // z + .{ .cp = 0x7b, .bits = 0x007018180e181870 }, // { + .{ .cp = 0x7c, .bits = 0x0018181818181818 }, // | + .{ .cp = 0x7d, .bits = 0x000e18187018180e }, // } + .{ .cp = 0x7e, .bits = 0x0000000000003b6e }, // ~ +}; + +// the block glyph table (sextants + half/quadrant blocks + line glyphs), built at +// comptime, and the same set extended with the ASCII glyphs. `render(ascii=…)` picks. +const glyphs = blk: { + @setEvalBranchQuota(100000); + var list: [64 + block_glyphs.len + line_glyphs.len]Glyph = undefined; + var p: usize = 0; + while (p < 64) : (p += 1) list[p] = .{ .cp = sextantCp(@intCast(p)), .bits = sextantBits(@intCast(p)) }; + for (block_glyphs, 0..) |bg, i| list[64 + i] = bg; + for (line_glyphs, 0..) |lg, i| list[64 + block_glyphs.len + i] = lg; + break :blk list; +}; +const glyphs_ascii = glyphs ++ ascii_glyphs; + +// match one 8x8 RGB cell to the best (glyph, fg, bg). Candidate colors are the +// most-common palette colors among the 64 pixels; for each ordered pair the glyph +// cost is base(all-bg) + sum over the glyph's ink bits of (dist_fg - dist_bg). +fn matchCell(cell: *const [64][3]u8, pal: Palette, gset: []const Glyph) Cell { + var counts = [_]u16{0} ** 16; + for (cell) |px| counts[nearest(px, pal)] += 1; + var cand: [4]u4 = undefined; + var ncand: usize = 0; + var used = [_]bool{false} ** 16; + while (ncand < 4) : (ncand += 1) { + var best: ?usize = null; + for (counts, 0..) |c, i| { + if (used[i] or c == 0) continue; + if (best == null or c > counts[best.?]) best = i; + } + if (best) |bi| { + cand[ncand] = @intCast(bi); + used[bi] = true; + } else break; + } + if (ncand == 0) return .{}; // can't happen (64 pixels), but keep it total + if (ncand == 1) return encode(' ', cand[0], cand[0]); // solid color + + var best_cost: i64 = std.math.maxInt(i64); + var best = encode(' ', cand[0], cand[0]); + var fi: usize = 0; + while (fi < ncand) : (fi += 1) { + var bi: usize = 0; + while (bi < ncand) : (bi += 1) { + if (fi == bi) continue; + const fg = cand[fi]; + const bg = cand[bi]; + var dfg: [64]u32 = undefined; + var dbg: [64]u32 = undefined; + var base: i64 = 0; + for (cell, 0..) |px, p| { + dfg[p] = dist2(px, pal[fg]); + dbg[p] = dist2(px, pal[bg]); + base += dbg[p]; + } + for (gset) |g| { + var delta: i64 = 0; + var bits = g.bits; + while (bits != 0) : (bits &= bits - 1) { + const p: usize = @ctz(bits); + delta += @as(i64, dfg[p]) - @as(i64, dbg[p]); + } + const cost = base + delta; + if (cost < best_cost) { + best_cost = cost; + best = encode(g.cp, fg, bg); + } + } + } + } + return best; +} + +fn encode(cp: u21, fg: u4, bg: u4) Cell { + var c = Cell{ .fg = fg, .bg = bg }; + const n = std.unicode.utf8Encode(cp, &c.glyph) catch 1; + c.glen = @intCast(n); + return c; +} + +// Render `rgba` (iw x ih, 4 bytes/px) into a grid of at most cols x rows cells, +// preserving the image aspect with the terminal cell aspect (~1:2) corrected. +// Caller owns Grid.cells (gpa). +pub fn render(gpa: std.mem.Allocator, rgba: []const u8, iw: usize, ih: usize, cols: usize, rows_: usize, pal: Palette, ascii: bool) !Grid { + if (iw == 0 or ih == 0 or cols == 0 or rows_ == 0) return .{ .cells = try gpa.alloc(Cell, 0), .gw = 0, .gh = 0 }; + const gset: []const Glyph = if (ascii) &glyphs_ascii else &glyphs; + // contain-fit; cells are ~twice as tall as wide, so a row spans 2 width-units. + var gw = cols; + var gh = (cols * ih) / (2 * iw); + if (gh > rows_) { + gh = rows_; + gw = (rows_ * 2 * iw) / ih; + } + gw = std.math.clamp(gw, 1, cols); + gh = std.math.clamp(gh, 1, rows_); + + const cells = try gpa.alloc(Cell, gw * gh); + var cy: usize = 0; + while (cy < gh) : (cy += 1) { + const ry0 = cy * ih / gh; + const ry1 = @max(ry0 + 1, (cy + 1) * ih / gh); + var cx: usize = 0; + while (cx < gw) : (cx += 1) { + const rx0 = cx * iw / gw; + const rx1 = @max(rx0 + 1, (cx + 1) * iw / gw); + var cell: [64][3]u8 = undefined; + var sy: usize = 0; + while (sy < 8) : (sy += 1) { + const py0 = ry0 + sy * (ry1 - ry0) / 8; + const py1 = @max(py0 + 1, ry0 + (sy + 1) * (ry1 - ry0) / 8); + var sx: usize = 0; + while (sx < 8) : (sx += 1) { + const px0 = rx0 + sx * (rx1 - rx0) / 8; + const px1 = @max(px0 + 1, rx0 + (sx + 1) * (rx1 - rx0) / 8); + var rs: usize = 0; + var gs: usize = 0; + var bs: usize = 0; + var n: usize = 0; + var yy = py0; + while (yy < py1 and yy < ih) : (yy += 1) { + var xx = px0; + while (xx < px1 and xx < iw) : (xx += 1) { + const i = (yy * iw + xx) * 4; + rs += rgba[i]; + gs += rgba[i + 1]; + bs += rgba[i + 2]; + n += 1; + } + } + if (n == 0) n = 1; + cell[sy * 8 + sx] = .{ @intCast(rs / n), @intCast(gs / n), @intCast(bs / n) }; + } + } + cells[cy * gw + cx] = matchCell(&cell, pal, gset); + } + } + return .{ .cells = cells, .gw = gw, .gh = gh }; +} + +test "sextant codepoints: blocks + endpoints" { + try std.testing.expectEqual(@as(u21, ' '), sextantCp(0)); + try std.testing.expectEqual(@as(u21, 0x2588), sextantCp(63)); + try std.testing.expectEqual(@as(u21, 0x258C), sextantCp(21)); + try std.testing.expectEqual(@as(u21, 0x2590), sextantCp(42)); + try std.testing.expectEqual(@as(u21, 0x1FB00), sextantCp(1)); // first sextant + try std.testing.expectEqual(@as(u21, 0x1FB3B), sextantCp(62)); // last sextant +} + +test "matchCell: solid color -> space on that bg" { + var cell: [64][3]u8 = undefined; + for (&cell) |*p| p.* = commodore[5]; // all green + const m = matchCell(&cell, commodore, &glyphs); + try std.testing.expectEqual(@as(u4, 5), m.bg); + try std.testing.expectEqual(@as(u8, ' '), m.glyph[0]); +} + +test "matchCell: clean top/bottom split picks the two colors" { + var cell: [64][3]u8 = undefined; + for (0..64) |p| cell[p] = if (p < 32) commodore[1] else commodore[6]; // white over blue + const m = matchCell(&cell, commodore, &glyphs); + // both palette colors must be chosen (in some fg/bg order) + const a = @as(u4, @min(m.fg, m.bg)); + const b = @as(u4, @max(m.fg, m.bg)); + try std.testing.expectEqual(@as(u4, 1), a); + try std.testing.expectEqual(@as(u4, 6), b); + // a clean top/bottom split must resolve to a real block glyph (the top-4-rows + // half block â–€), never a blank cell. + const cp = std.unicode.utf8Decode(m.glyph[0..m.glen]) catch 0; + try std.testing.expectEqual(@as(u21, 0x2580), cp); +} + +test "ascii option only enables the ascii glyphs" { + // the ascii glyph codepoints must be reachable exactly when ascii is on. + var seen_block = false; + var seen_ascii = false; + for (glyphs) |g| if (g.cp == '#') { + seen_block = true; + }; + for (glyphs_ascii) |g| if (g.cp == '#') { + seen_ascii = true; + }; + try std.testing.expect(!seen_block); // '#' is an ascii-only glyph + try std.testing.expect(seen_ascii); + try std.testing.expectEqual(glyphs.len + ascii_glyphs.len, glyphs_ascii.len); +} + +test "ascii glyph is chosen when a cell has its exact shape" { + // a cell shaped exactly like the font 'S' (ink=white on black) must match 'S' + // with ascii on (cost 0), and fall back to some block glyph with ascii off. + const s_bits: u64 = 0x003c6630180c663c; + var cell: [64][3]u8 = undefined; + for (0..64) |p| cell[p] = if ((s_bits >> @intCast(p)) & 1 != 0) commodore[1] else commodore[0]; + const on = matchCell(&cell, commodore, &glyphs_ascii); + try std.testing.expectEqual(@as(u21, 'S'), std.unicode.utf8Decode(on.glyph[0..on.glen]) catch 0); + const off = matchCell(&cell, commodore, &glyphs); + try std.testing.expect((std.unicode.utf8Decode(off.glyph[0..off.glen]) catch 0) != 'S'); +} + +test "render: tiny image produces a grid within bounds" { + const a = std.testing.allocator; + // 2x2 checker, RGBA + var img = [_]u8{0} ** (2 * 2 * 4); + img[0] = 255; img[1] = 255; img[2] = 255; img[3] = 255; // (0,0) white + img[(3) * 4 + 0] = 255; img[(3) * 4 + 1] = 255; img[(3) * 4 + 2] = 255; img[(3) * 4 + 3] = 255; // (1,1) white + const g = try render(a, &img, 2, 2, 10, 10, commodore, true); + defer a.free(g.cells); + try std.testing.expect(g.gw >= 1 and g.gw <= 10); + try std.testing.expect(g.gh >= 1 and g.gh <= 10); + try std.testing.expectEqual(g.gw * g.gh, g.cells.len); +} 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/src/tutor.txt b/src/tutor.txt new file mode 100644 index 00000000..5a93d388 --- /dev/null +++ b/src/tutor.txt @@ -0,0 +1,412 @@ + + PARDES TUTOR + tmux + vi + acme, from scratch. + + The model is acme's: EVERYTHING is text, editable and executable + the same way. Modal editing (helix-style) lives on top of that. + Press j until you reach the introduction. + + +================================================================= += INTRODUCTION = +================================================================= + + Pardes is tmux + vi + acme, made from scratch. The foundation is + the acme model: a pane is just text on screen. There is no separate + "buffer" type — what you see is what you edit. Some panes happen to + be live terminals (tty mode); most are text you can move a cursor + over. Modal editing (helix-style) is layered on top of that. + + Three modes (the tag shows which): + nm NORMAL block cursor, keys move/edit. DEFAULT on startup. + in INSERT keys type text at the cursor. + sy TTY keys go straight to the shell. (terminals only) + + This tutor is in THREE parts, ordered by what's most different from + editors you may know: + PART 1 — the MOUSE. Acme's three buttons; nothing like Vim/Helix. + PART 2 — the TTY. A terminal is just a pane; Ctrl-b drops into it + by default. + PART 3 — the KEYS. Helix-style modal (with the Pardes differences). + + PRACTICE BLOCKS (part 3): the "# keys:" line lists keystrokes + (space-separated; esc/enter/bs are special, the rest type each char). + The clean lines under "# before" are what you practice on; the lines + under "# after" are what you should end up with. "# at: row,col" + (right after # keys:) sets where the cursor starts. These blocks are also run as unit + tests (generated from this file by tutor_gen). + + Hold j to reach part 1. + + +================================================================= += PART 1 — THE MOUSE (acme chording; the most different part) = +================================================================= + + Acme's central idea: the THREE mouse buttons each ACT on text, and the + keyboard mirrors them. Pardes inherits this. (Vim and Helix have almost + none of it — their mouse is selection/scroll only.) + + ┌─────────┬─────────┬─────────â”� + │ L │ M │ R │ + │ (1) │ (2) │ (3) │ + ├─────────┴─────────┴─────────┤ + │ │ + │ ( o ) │ the wheel scrolls the pane + │ │ + └─────────────────────────────┘ + + L select a plain click also FOCUSES the pane and PINS the modal + cursor where you click, so you can edit there next. + M execute run the selected text: a builtin name runs it, anything + else is SENT to the shell the pane runs. This is how you + run a command you typed in a text mode. + R look open the word under the cursor as a path: a directory + opens/focuses a terminal there and ls's it; a file opens + a file pane (scrolled to a ":line" suffix if present). + + SELECT-THEN-ACT: there is no held-button chord. Instead: + - MIDDLE-drag over text selects AND runs it on release (one gesture); + a no-drag middle click auto-expands to the word under the cursor. + - or select with the keyboard (`v` chars / `x` lines), then press + Tab to execute or Enter to look — the acme chords on the keyboard. + + THE TAG: each pane has a one-line tag: its MODE (nm/in/sy) + directory + or file path + builtins. File panes show "Save Del" by default; Save + writes the current file to disk, Del closes the window. Terminal and + image panes show "Del". "Delcol" still exists as a command: type it in + a tag or body and execute it to close the whole column. A SEPARATE bar + across the top of the screen holds the window-agnostic builtins: + Kill Newcol Tutor Debug Colors NextColor + Middle-click "Newcol" for a new column, "Tutor" to spawn this tutor, + "Kill" to quit; Debug/Colors/NextColor toggle the stats overlay, the + syntax/ansi recolor, and the theme. + Right-click a directory in any body to open a terminal there. + + LAYOUT: columns split the screen; windows stack within a column. Panes + abut with no wasted gap — a pane's own trailing edge (its last column, or + last row above the next tag) IS the resize handle: hover it and a dashed + line overlays that edge (keeping the underlying colors) to mark the drag. + - drag a column's right edge to resize it against its neighbour + - drag a window's bottom edge to resize it against the one below + - drag the accent box (top-left of a pane; indigo-purple, brighter + when the pane is focused) to MOVE a window between columns / reorder + - the scrollbar is the gutter below the box: left-click scrolls UP + to that point, right-click scrolls DOWN + + (Mouse actions are hard to unit-test from text; they're covered by + the e2e suite: middle-click execute, right-click dir/file open, tag + builtins, drag-rearrange. See tests.zig steps 6-8.) + + +================================================================= += PART 2 — THE TTY (a terminal is just a pane) = +================================================================= + + This is the headline difference from Vim/Helix: there is no "open a + terminal in a split" that's separate from "edit a file in a split". + A TERMINAL IS A PANE. The same modal keys that edit a file navigate + the terminal's screen, and dropping into tty REUSES the shell's own + prompt model rather than layering an editor cursor on top. + + On a terminal pane: + NORMAL (nm) prompt rows are HIDDEN — a clean acme-style page. You + navigate it with the same h/j/k/l/w/b/e as a file. + INSERT (in) same clean page; keys type an insertion overlay (the + command you're composing). Prompts still hidden. + TTY (sy) the REAL shell — prompts + typed input shown, and keys + go straight to the pty as terminal input. + + ESC: insert -> normal. (Esc never reaches tty.) + Ctrl-b: toggles TTY on a terminal by default — the ONLY way in or out. + Use `pardes --tty-toggle=g` (or another letter) to make Ctrl-g + the toggle instead. Entering is tty-native: if the shell is at + a prompt and your modal cursor sits on the input line, it first + moves the shell's REAL cursor to that spot — + - empty input -> shell cursor at the prompt START + - typed text -> shell cursor at/after the char you were on + via the shell's OSC 133 semantic prompt (ghostty promptClickMove; + our rc opts in with cl=line). The shell moves its own cursor with + arrow keys it understands — Pardes doesn't fake one on top. + + ENTER / TAB (in normal) are NOT a way into tty — they're the acme mouse + chords on the keyboard: Enter = "look" (open the path under the cursor), + Tab = "execute". Both act on the selection if one is active. + + So the flow is: navigate the hidden-prompt page in normal, hit the tty + toggle where you want to keep typing, and you're IN the live shell at + that spot. No separate "terminal mode cursor" to reconcile. + + (The cursor positioning is enterTty's promptClickMove; it needs a live + shell at a prompt. Mouse/tty behavior is exercised by the e2e suite.) + + +================================================================= += PART 3 — THE KEYS (helix-style modal, Pardes differences) = +================================================================= + + Same core as Helix and Vim: a block cursor you move with h/j/k/l, + motions w/b/e, and you enter insert with i/a/o. The differences: + + - Selection is LINE-first: `x` grows a line selection downward. + There's also `v` for a CHARACTER range. No multi-cursor. d/c/y act + on whichever selection is active (else the current line). + - NO verb+noun (Vim's dw, cw). Motions only MOVE. To delete a word, + select it (`v` then motions, or `x` for whole lines) then `d`. + - ESC never reaches tty; it only does insert -> normal. Dropping a + terminal into the live shell is the tty toggle (Ctrl-b by default; + see Part 2). + - Enter / Tab in normal mirror the mouse: Enter = look (open the path + under the cursor), Tab = execute. `u` undo, `U` redo. + - Editing a file pane MUTATES real content; a terminal pane yanks + rendered text and pastes it as an insertion run (shell output + can't be deleted, only pasted text can). + + PRACTICE: run the keys on the "# before" lines; you should get the + "# after" lines. (These blocks are unit tests too.) + + +----------------------------------------------------------------- += 3.1 MOVING THE CURSOR (h j k l) = +----------------------------------------------------------------- + + k * h = left, l = right + h l * j = down, k = up (also: arrow keys) + j + + The cursor sits ON a character (block). Motions only move. + + # keys: l l l + # before + abcdef + # after + abcdef + + +----------------------------------------------------------------- += 3.2 ENTERING INSERT: i a A I o O = +----------------------------------------------------------------- + + `i` insert at cursor. `a` insert AFTER the cursor. + `A` insert at end of line. `I` insert at first non-blank. + `o` open line BELOW + insert. `O` open line ABOVE + insert. + (All match Vim and Helix.) + + # keys: i X esc + # before + bcdef + # after + Xbcdef + + Cursor on 'b' (col 0). `i` inserts before 'b'. + + # keys: a Z esc + # before + abc + # after + aZbc + + Cursor on 'a' (col 0). `a` inserts after 'a'. + + # keys: A Z esc + # before + abc + # after + abcZ + + `A` jumps to end then inserts — appends 'Z'. + + # keys: I Z esc + # before + abc + # after + Zabc + + `I` goes to the first non-blank ('a'), inserts before it. + + # keys: o line2 esc + # before + line1 + # after + line1 + line2 + + `o` makes a blank line below, enters insert, "line2" typed, Esc. + + # keys: O top esc + # before + bot + # after + top + bot + + +----------------------------------------------------------------- += 3.3 WORD MOTIONS: w b e (W B E long) = +----------------------------------------------------------------- + + `w` next word start, `b` prev word start, `e` next word END. + W/B/E treat punctuation as part of the word. Same as Vim & Helix. + + # keys: w i Z esc + # before + foo bar + # after + foo Zbar + + `w` from 'f'(col0) lands on 'b'(col4). Insert before 'b'. + + # keys: e i Z esc + # before + foo bar + # after + foZo bar + + `e` from 'f' lands on 'o'(col2, end of "foo"). Insert before it. + + # keys: b i Z esc + # at: 0,4 + # before + foo bar + # after + Zfoo bar + + Cursor on 'b'(col4, "bar"). `b` -> start of previous word = 'f'(col0). + + +----------------------------------------------------------------- += 3.4 LINE BOUNDS: 0 $ ^ g g G = +----------------------------------------------------------------- + + `0` start of line, `$` end, `^` first non-blank. + `gg` first line, `G` last line. Same as Vim/Helix. + + # keys: $ i Z esc + # before + abcde + # after + abcdZe + + `$` to last char 'e', insert before it. (Cursor ON a char, so "end" = + last char, not past it.) + + # keys: 0 i Z esc + # before + abcde + # after + Zabcde + + # keys: ^ i Z esc + # before + abcde + # after + Zabcde + + +----------------------------------------------------------------- += 3.5 LINE SELECTION + EDIT: x d c y p = +----------------------------------------------------------------- + + The Pardes difference is LINE-first selection: `x` grows a line + selection downward; d/c/y act on it. `v` is still available for a + character range when you need one. + + `x` start/extend a line selection + `d` delete the selected lines (yanked) + `c` clear the line + insert (change) + `y` yank the selection (or current line) + `p` paste the yank as a new line below + + # keys: x d + # at: 1,0 + # before + keep + gone + # after + keep + + Cursor on "gone" (row 1). `x` selects it, `d` deletes it. + + # keys: x x d + # at: 1,0 + # before + a + b + c + # after + a + + `x` selects "b", `x` extends to "c", `d` removes both. + + # keys: x y j p + # before + orig + dupe + # after + orig + dupe + orig + + `x` selects "orig", `y` yanks it, `j` to "dupe", `p` pastes below. + + # keys: x c typed esc + # before + old + # after + typed + + `x` selects "old", `c` clears it to an empty line + insert, type. + + # keys: c Q esc + # at: 0,1 + # before + ab + # after + aQ + + No selection: cursor on 'b'(col1). `c` deletes 'b' + insert; type 'Q'. + + +----------------------------------------------------------------- += 3.6 VIEWPORT + PANES = +----------------------------------------------------------------- + + zt / zz / zb scroll so the cursor is at top/center/bottom + Ctrl-d / Ctrl-u half-page down / up + Ctrl-f full page down. (Ctrl-b pages up on a file pane; + on a terminal it is the default tty toggle. If you + start with --tty-toggle=g, Ctrl-g takes that role.) + Ctrl-w h/j/k/l focus the pane left/down/up/right + Alt-n new terminal below the active one + Alt-c move the active terminal into a fresh column + + (No practice blocks: these are viewport/layout, not text edits.) + + +================================================================= += SUMMARY = +================================================================= + + MOUSE (acme): L select/focus+pin M execute R look(open) + select-then-act: middle-drag, or v/x then Tab + file tag = mode + path + "Save Del"; other tags show Del + top bar = Kill Newcol Tutor Debug Colors NextColor + TTY: a terminal IS a pane; Ctrl-b toggles the live shell by default, + landing its cursor where you navigated (prompt start if + the input is empty, mid-text otherwise) + KEYS (helix): h j k l w b e 0 $ ^ gg G v x d c y p i a I A o O + u undo U redo Enter=look Tab=execute + zt zz zb Ctrl-d/u Ctrl-f Ctrl-w hjkl Alt-n/c + + vs Helix: no multi-cursor; selection is LINE-first (x), plus v chars. + vs Vim: no verb+noun (dw); motions only move; Esc never reaches tty. + vs both: a terminal is just a pane; the same keys edit text and + navigate the shell screen, and the tty toggle reuses the + shell's own prompt to position you precisely. + + To spawn THIS tutor again from anywhere: middle-click "Tutor" in the + top bar (next to Kill / Newcol). + + Quit the tutor: this is a file pane — `:q` isn't wired; close the + window (middle-click "Del" in its tag), "Kill" (top bar) to quit + everything, or Ctrl-c the app. 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//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/test/e2e_harness.zig b/test/e2e_harness.zig new file mode 100644 index 00000000..6354aa4b --- /dev/null +++ b/test/e2e_harness.zig @@ -0,0 +1,278 @@ +const std = @import("std"); +const posix = std.posix; +const linux = std.os.linux; +const ghostty_vt = @import("ghostty-vt"); + +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; + +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); +} + +/// E2E test harness: forks the app in a pty and parses its output with a +/// ghostty terminal emulator. Tests read the 2D screen GRID (via plainString), +/// not raw byte streams — so they're decoupled from how the app renders +/// (vaxis diff-rendering, repaints, etc). The grid is always current after +/// `pump`/`waitFor`; no setWinsize-to-force-repaint hacks needed. +/// +/// Input is sent as raw bytes to the pty (the app's vaxis parses them). The +/// harness terminal only reads output. +pub const Harness = struct { + gpa: std.mem.Allocator, + master: c_int, + pid: posix.pid_t, + /// heap-allocated so its address is stable: vtStream()'s handler holds a + /// pointer back into the Terminal, so it must not move (same reason main.zig + /// heap-allocates Term). Freed in deinit. + term: *ghostty_vt.Terminal, + stream: ghostty_vt.TerminalStream, + rows: u16, + cols: u16, + /// when true, print the captured screen state after each pump/waitFor and on + /// every assertion, so live test runs can be inspected (zig build test -Dtrace). + trace: bool = false, + /// every raw byte the app has emitted (accumulated in pump). Lets tests assert + /// on control sequences the emulator consumes and never renders (e.g. OSC 52 + /// clipboard writes). gpa-owned; freed in deinit. + raw: std.ArrayList(u8) = .empty, + + pub fn init(gpa: std.mem.Allocator, exe: [*:0]const u8, rows: u16, cols: u16) !Harness { + return initArgs(gpa, exe, rows, cols, null); + } + + /// 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) { + 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); + } + const term = try gpa.create(ghostty_vt.Terminal); + errdefer gpa.destroy(term); + term.* = try ghostty_vt.Terminal.init(gpa, .{ + .cols = cols, + .rows = rows, + .max_scrollback = 1024 * 1024, + }); + errdefer term.deinit(gpa); + // vtStream() captures a pointer to `term`; term is heap-allocated so the + // pointer stays valid for the life of the harness. + const stream = term.vtStream(); + return .{ + .gpa = gpa, + .master = master, + .pid = pid, + .term = term, + .stream = stream, + .rows = rows, + .cols = cols, + }; + } + + pub fn deinit(self: *Harness) void { + posix.kill(self.pid, posix.SIG.KILL) catch {}; + _ = linux.close(self.master); + self.stream.deinit(); + self.raw.deinit(self.gpa); + self.term.deinit(self.gpa); + self.gpa.destroy(self.term); + } + + /// Write raw bytes to the pty (input to the app: keystrokes, mouse events). + pub fn send(self: *Harness, 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); + } + } + + /// Read pty output and feed it to our ghostty terminal for `ms` ms. After + /// this, the grid reflects everything the app rendered so far. + pub fn pump(self: *Harness, 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.raw.appendSlice(self.gpa, buf[0..n]) catch {}; + self.stream.nextSlice(buf[0..n]); + } + } + if (self.trace) self.traceScreen("pump"); + } + + /// Resize both the pty (SIGWINCH to the app) and our terminal (grid reflows + /// to the new size). Harness terminal first so it's ready for the app's + /// re-render at the new size. + pub fn resize(self: *Harness, rows: u16, cols: u16) !void { + self.rows = rows; + self.cols = cols; + self.term.resize(self.gpa, cols, rows) catch {}; + const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; + _ = posix.system.ioctl(self.master, posix.T.IOCSWINSZ, @intFromPtr(&ws)); + } + + /// The full viewport text (plainString), newline-joined rows. Caller frees. + pub fn screenText(self: *Harness) ![]const u8 { + return self.term.plainString(self.gpa); + } + + /// Is `needle` present anywhere on the current screen? + pub fn contains(self: *Harness, needle: []const u8) bool { + const text = self.screenText() catch return false; + defer self.gpa.free(text); + return std.mem.indexOf(u8, text, needle) != null; + } + + /// Wait up to `ms` for `needle` to appear on screen, pumping bytes the + /// whole time. Replaces pollFor + setWinsize-repaint: the grid is checked + /// directly each iteration. + pub fn waitFor(self: *Harness, needle: []const u8, ms: i64) !bool { + { + const text = try self.screenText(); + defer self.gpa.free(text); + if (std.mem.indexOf(u8, text, needle) != null) { + if (self.trace) self.traceScreen("waitFor hit"); + return true; + } + } + 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.raw.appendSlice(self.gpa, buf[0..n]) catch {}; + self.stream.nextSlice(buf[0..n]); + } + const text = try self.screenText(); + defer self.gpa.free(text); + if (std.mem.indexOf(u8, text, needle) != null) { + if (self.trace) self.traceScreen("waitFor hit"); + return true; + } + } + if (self.trace) self.traceScreen("waitFor miss"); + return false; + } + + /// The visual cursor position (viewport coords): { x, y }. + pub fn cursor(self: *Harness) struct { x: u16, y: u16 } { + return .{ + .x = self.term.screens.active.cursor.x, + .y = self.term.screens.active.cursor.y, + }; + } + + /// True if the shell is at a prompt (OSC 133 semantic prompt). Requires + /// shell integration; false if the shell is outputting or has no integration. + pub fn atPrompt(self: *Harness) bool { + return self.term.cursorIsAtPrompt(); + } + + /// Print the screen grid (clean text, not raw bytes) for debugging on + /// failure. Also shows the cursor position and screen size. + pub fn dump(self: *Harness, msg: []const u8) void { + const text = self.screenText() catch { + std.debug.print("E2E FAIL: {s}\n(could not read screen)\n", .{msg}); + return; + }; + defer self.gpa.free(text); + const c = self.cursor(); + std.debug.print("E2E FAIL: {s}\nscreen {d}x{d} cursor=({d},{d}):\n{s}\n", .{ + msg, self.cols, self.rows, c.x, c.y, text, + }); + } + + /// trace-mode screen snapshot: print the current grid + cursor + size to + /// stderr (always, not just on failure). Bounded by a header so the live + /// stream of snapshots is easy to scan. + pub fn traceScreen(self: *Harness, tag: []const u8) void { + const text = self.screenText() catch return; + defer self.gpa.free(text); + const c = self.cursor(); + std.debug.print("\n[trace {s}] screen {d}x{d} cursor=({d},{d}):\n{s}\n[/trace]\n", .{ + tag, self.cols, self.rows, c.x, c.y, text, + }); + } + + // ---- assertions (dump on failure, return error.ExpectFailed) ---- + + pub fn expectWaitFor(self: *Harness, needle: []const u8, ms: i64, msg: []const u8) !void { + if (!try self.waitFor(needle, ms)) { + self.dump(msg); + return error.ExpectFailed; + } + } + + pub fn expectContains(self: *Harness, needle: []const u8, msg: []const u8) !void { + if (!self.contains(needle)) { + self.dump(msg); + return error.ExpectFailed; + } + } + + pub fn expectNotContains(self: *Harness, needle: []const u8, msg: []const u8) !void { + if (self.contains(needle)) { + self.dump(msg); + return error.ExpectFailed; + } + } + + /// Assert a byte sequence appears anywhere in the app's raw output so far. + /// For control sequences the emulator swallows and never renders (OSC 52 etc.). + pub fn expectRawContains(self: *Harness, needle: []const u8, msg: []const u8) !void { + if (std.mem.indexOf(u8, self.raw.items, needle) == null) { + std.debug.print("E2E FAIL: {s}\n(raw output has no {any})\n", .{ msg, needle }); + return error.ExpectFailed; + } + } +}; + +/// Write a file (helper for test setup, raw linux syscalls). +pub fn writeFile(path: [*:0]const u8, data: []const u8) !void { + const rc = linux.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644); + const sfd: isize = @bitCast(rc); + if (sfd < 0) return error.OpenFailed; + const fd: c_int = @intCast(sfd); + defer _ = linux.close(fd); + var off: usize = 0; + while (off < data.len) { + const w = linux.write(fd, data[off..].ptr, data.len - off); + const sw: isize = @bitCast(w); + if (sw <= 0) break; + off += @intCast(sw); + } +} 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 create file in the script's cwd (before start) +// lines create file with n numbered lines +// dirmk create a subdirectory +// start [arg] fork the app in a pty (optional extra CLI arg) +// wait pump until needle appears on the grid (fails hard) +// settle pump for a fixed duration +// stable pump until the grid stops changing +// text send literal bytes +// send send with \e \r \n \t \\ \xNN escapes +// key enter esc tab bs space up down left right home end +// pgup pgdn c- a- (after esc: settle >=600ms, +// or vaxis parses the next byte as alt-) +// press|release SGR mouse, 1-based +// drag motion with button held +// motion button-less motion (hover) +// wheel +// resize +// snap