diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-02 21:56:01 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | 589269c0b3ca74a29f5889fa6e781e59cef7efdb (patch) | |
| tree | ecd2ce3060d06353f3b6302e70abf784295ff40f | |
| parent | 81992cc92743a98066ee1a9025bc74f91cf0e967 (diff) | |
| download | pardes-589269c0b3ca74a29f5889fa6e781e59cef7efdb.tar.gz pardes-589269c0b3ca74a29f5889fa6e781e59cef7efdb.zip | |
wasm replay ui
| -rw-r--r-- | build.zig | 327 | ||||
| -rw-r--r-- | dump.zig | 20 | ||||
| -rw-r--r-- | paint.zig | 7 | ||||
| -rw-r--r-- | replay.zig | 345 | ||||
| -rw-r--r-- | shaders/ui.frag.es.glsl | 16 | ||||
| -rw-r--r-- | shaders/ui.vert.es.glsl | 41 | ||||
| -rw-r--r-- | tests/browser_replay_dump.zon | 53 | ||||
| -rw-r--r-- | tests/browser_replay_e2e.mjs | 240 | ||||
| -rw-r--r-- | ui_sdl.zig | 291 |
9 files changed, 1136 insertions, 204 deletions
@@ -1,10 +1,14 @@ const std = @import("std"); const AppKind = enum { main, replay }; +const GhosttyArtifact = enum { ghostty, lib }; pub fn build(b: *std.Build) void { - const optimize = b.standardOptimizeOption(.{}); + const requested_optimize = b.standardOptimizeOption(.{}); const target = b.standardTargetOptions(.{}); + const is_emscripten = target.result.os.tag == .emscripten; + const optimize = if (is_emscripten) .ReleaseSmall else requested_optimize; + const emscripten_sysroot = if (is_emscripten) detectEmscriptenSysroot(b) else null; // build-time IO, only used to slurp the grammars' highlights.scm queries. var threaded: std.Io.Threaded = .init(b.allocator, .{}); @@ -24,6 +28,25 @@ pub fn build(b: *std.Build) void { // 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 syntax_highlighting = !is_emscripten; + 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 files = b.addWriteFiles(); + break :blk files.add("emscripten-libc.txt", b.fmt( + \\include_dir={s}/include + \\sys_include_dir={s}/include + \\crt_dir={s}/lib/wasm32-emscripten + \\msvc_lib_dir= + \\kernel32_lib_dir= + \\gcc_dir= + \\ + , .{ sysroot, sysroot, sysroot })); + } else null; const exe = b.addExecutable(.{ .name = "pardes", @@ -45,6 +68,10 @@ pub fn build(b: *std.Build) void { .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, @@ -58,11 +85,21 @@ pub fn build(b: *std.Build) void { const main_app_config = b.addOptions(); main_app_config.addOption(AppKind, "app_kind", .main); + main_app_config.addOption(bool, "syntax_highlighting", syntax_highlighting); 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.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 @@ -100,7 +137,7 @@ pub fn build(b: *std.Build) void { // 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 = false, + .simd = !is_emscripten, })) |dep| { // Accessing ghostty's modules runs its build(), which (because vaxis is // a shared dependency, deduplicated to the same module) wires ghostty's @@ -108,6 +145,23 @@ pub fn build(b: *std.Build) void { // with ours — order matters, so this must happen after this point. const ghostty_vt = dep.module("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 @@ -124,7 +178,7 @@ pub fn build(b: *std.Build) void { // `"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) b.path("ui_sdl.zig") else b.path("ui_vaxis.zig"); + const ui_root = if (sdl_backend) b.path("ui_sdl.zig") else b.path("ui_vaxis.zig"); const ui_mod = b.createModule(.{ .target = target, .optimize = optimize, @@ -134,19 +188,30 @@ pub fn build(b: *std.Build) void { exe.root_module.addImport("ui", ui_mod); replay.root_module.addImport("ui", ui_mod); - // SDL3 GPU backend wiring (`-Dsdl`). The sdl package builds SDL3 from source + // 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 DejaVuSansMono font. All gated on the flag so a plain `zig build` // (vaxis) never fetches or compiles SDL. - if (sdl) { - const sdl_dep = b.lazyDependency("sdl", .{ - .target = target, - .optimize = optimize, - }) orelse return; // not fetched (offline?) — skip SDL wiring + 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 @@ -156,13 +221,18 @@ pub fn build(b: *std.Build) void { ui_mod.addCSourceFile(.{ .file = b.path("vendor/stb/stb_truetype_impl.c") }); ui_mod.addCSourceFile(.{ .file = b.path("vendor/stb/font.c") }); - // 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"), - }); + 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") }); + } 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"), + }); + } } // zstbi: C (stb_image)-backed image decoder for the image-viewer panes. We use @@ -170,73 +240,77 @@ pub fn build(b: *std.Build) void { // 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_mod = b.dependency("zstbi", .{ .target = target, .optimize = optimize }).module("root"); + 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); - // 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 }); - exe.root_module.addImport("tree-sitter", tree_sitter.module("tree_sitter")); - replay.root_module.addImport("tree-sitter", tree_sitter.module("tree_sitter")); + 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"); + exe.root_module.addImport("tree-sitter", tree_sitter_mod); + replay.root_module.addImport("tree-sitter", tree_sitter_mod); - const Grammar = struct { - name: []const u8, - dep: []const u8, - src: []const u8 = "src", - scanner: bool = false, - query: []const u8 = "queries/highlights.scm", - local_query: ?[]const u8 = null, - }; - const grammars = [_]Grammar{ - .{ .name = "ada", .dep = "ts_ada" }, - .{ .name = "bash", .dep = "ts_bash", .scanner = true }, - .{ .name = "c", .dep = "ts_c" }, - .{ .name = "c_sharp", .dep = "ts_c_sharp", .scanner = true }, - .{ .name = "clojure", .dep = "ts_clojure" }, - .{ .name = "cpp", .dep = "ts_cpp", .scanner = true }, - .{ .name = "css", .dep = "ts_css", .scanner = true }, - .{ .name = "elixir", .dep = "ts_elixir", .scanner = true }, - .{ .name = "erlang", .dep = "ts_erlang", .scanner = true }, - .{ .name = "fortran", .dep = "ts_fortran", .scanner = true }, - .{ .name = "go", .dep = "ts_go" }, - .{ .name = "haskell", .dep = "ts_haskell", .scanner = true }, - .{ .name = "html", .dep = "ts_html", .scanner = true }, - .{ .name = "java", .dep = "ts_java" }, - .{ .name = "javascript", .dep = "ts_javascript", .scanner = true }, - .{ .name = "json", .dep = "ts_json" }, - .{ .name = "kotlin", .dep = "ts_kotlin", .scanner = true }, - .{ .name = "ocaml", .dep = "ts_ocaml", .src = "grammars/ocaml/src", .scanner = true }, - .{ .name = "pascal", .dep = "ts_pascal" }, - .{ .name = "php", .dep = "ts_php", .src = "php/src", .scanner = true }, - .{ .name = "powershell", .dep = "ts_powershell", .scanner = true }, - .{ .name = "python", .dep = "ts_python", .scanner = true }, - .{ .name = "ruby", .dep = "ts_ruby", .scanner = true }, - .{ .name = "rust", .dep = "ts_rust", .scanner = true }, - .{ .name = "scala", .dep = "ts_scala", .scanner = true }, - .{ .name = "zig", .dep = "ts_zig" }, - }; - const ts_queries = b.addOptions(); - inline for (grammars) |g| { - const dep = b.dependency(g.dep, .{}); - const query_path = if (g.local_query) |p| b.path(p) else 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); + const Grammar = struct { + name: []const u8, + dep: []const u8, + src: []const u8 = "src", + scanner: bool = false, + query: []const u8 = "queries/highlights.scm", + local_query: ?[]const u8 = null, + }; + const grammars = [_]Grammar{ + .{ .name = "ada", .dep = "ts_ada" }, + .{ .name = "bash", .dep = "ts_bash", .scanner = true }, + .{ .name = "c", .dep = "ts_c" }, + .{ .name = "c_sharp", .dep = "ts_c_sharp", .scanner = true }, + .{ .name = "clojure", .dep = "ts_clojure" }, + .{ .name = "cpp", .dep = "ts_cpp", .scanner = true }, + .{ .name = "css", .dep = "ts_css", .scanner = true }, + .{ .name = "elixir", .dep = "ts_elixir", .scanner = true }, + .{ .name = "erlang", .dep = "ts_erlang", .scanner = true }, + .{ .name = "fortran", .dep = "ts_fortran", .scanner = true }, + .{ .name = "go", .dep = "ts_go" }, + .{ .name = "haskell", .dep = "ts_haskell", .scanner = true }, + .{ .name = "html", .dep = "ts_html", .scanner = true }, + .{ .name = "java", .dep = "ts_java" }, + .{ .name = "javascript", .dep = "ts_javascript", .scanner = true }, + .{ .name = "json", .dep = "ts_json" }, + .{ .name = "kotlin", .dep = "ts_kotlin", .scanner = true }, + .{ .name = "ocaml", .dep = "ts_ocaml", .src = "grammars/ocaml/src", .scanner = true }, + .{ .name = "pascal", .dep = "ts_pascal" }, + .{ .name = "php", .dep = "ts_php", .src = "php/src", .scanner = true }, + .{ .name = "powershell", .dep = "ts_powershell", .scanner = true }, + .{ .name = "python", .dep = "ts_python", .scanner = true }, + .{ .name = "ruby", .dep = "ts_ruby", .scanner = true }, + .{ .name = "rust", .dep = "ts_rust", .scanner = true }, + .{ .name = "scala", .dep = "ts_scala", .scanner = true }, + .{ .name = "zig", .dep = "ts_zig" }, + }; + const ts_queries = b.addOptions(); + inline for (grammars) |g| { + const dep = b.dependency(g.dep, .{}); + const query_path = if (g.local_query) |p| b.path(p) else 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); - const lib = b.addLibrary(.{ - .name = "tree-sitter-" ++ g.name, - .root_module = b.createModule(.{ .target = target, .optimize = optimize, .link_libc = true }), - .linkage = .static, - }); - 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); + const lib = b.addLibrary(.{ + .name = "tree-sitter-" ++ g.name, + .root_module = b.createModule(.{ .target = target, .optimize = optimize, .link_libc = true }), + .linkage = .static, + }); + 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); + } + exe.root_module.addOptions("ts_queries", ts_queries); + replay.root_module.addOptions("ts_queries", ts_queries); } - exe.root_module.addOptions("ts_queries", ts_queries); - replay.root_module.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`. @@ -253,6 +327,85 @@ pub fn build(b: *std.Build) void { const replay_step = b.step("replay", "replay a pardes dump: zig build replay -- <dump.zon>"); replay_step.dependOn(&run_replay.step); + const replay_wasm_step = b.step("replay-wasm", "build browser SDL replay (requires -Dtarget=wasm32-emscripten -Dreplay-dump=<dump.zon>)"); + if (!is_emscripten) { + replay_wasm_step.dependOn(&b.addFail("replay-wasm requires -Dtarget=wasm32-emscripten").step); + } else if (replay_dump == null) { + replay_wasm_step.dependOn(&b.addFail("replay-wasm requires -Dreplay-dump=<dump.zon>").step); + } else if (emscripten_sysroot == null) { + replay_wasm_step.dependOn(&b.addFail("replay-wasm requires an Emscripten sysroot; pass --sysroot <emscripten>/cache/sysroot or set EM_CACHE/EMSDK").step); + } else { + 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 replay-wasm 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()); + 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", + "-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_wasm_step.dependOn(&b.addInstallFileWithDir(replay_js, .{ .custom = "web" }, "pardes-replay.js").step); + replay_wasm_step.dependOn(&b.addInstallFileWithDir(replay_js.dirname().path(b, "pardes-replay.wasm"), .{ .custom = "web" }, "pardes-replay.wasm").step); + + const web = b.addWriteFiles(); + const html = web.add("replay.html", + \\<!doctype html> + \\<html> + \\<head> + \\ <meta charset="utf-8"> + \\ <meta name="viewport" content="width=device-width, initial-scale=1"> + \\ <title>pardes replay</title> + \\ <style> + \\ html, body { margin: 0; min-width: 100%; min-height: 100%; background: #121212; } + \\ body { overflow: auto; } + \\ canvas { display: block; } + \\ </style> + \\</head> + \\<body> + \\ <canvas id="canvas" oncontextmenu="event.preventDefault()" tabindex="-1"></canvas> + \\ <script> + \\ window.Module = { canvas: document.getElementById('canvas') }; + \\ </script> + \\ <script src="pardes-replay.js"></script> + \\</body> + \\</html> + ); + replay_wasm_step.dependOn(&b.addInstallFileWithDir(html, .{ .custom = "web" }, "replay.html").step); + } + + const test_browser_step = b.step("test-browser", "run browser SDL replay screenshot e2e (requires -Dtarget=wasm32-emscripten -Dreplay-dump=<dump.zon>)"); + if (!is_emscripten) { + test_browser_step.dependOn(&b.addFail("test-browser requires -Dtarget=wasm32-emscripten").step); + } else if (replay_dump == null) { + test_browser_step.dependOn(&b.addFail("test-browser requires -Dreplay-dump=<dump.zon>").step); + } 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.step.dependOn(replay_wasm_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. @@ -380,3 +533,23 @@ fn compileGlsl(b: *std.Build, src: []const u8, stage: []const u8, out: []const u cmd.addArg("-o"); return cmd.addOutputFileArg(out); } + +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" }); + } + return null; +} + +fn envPath(b: *std.Build, name: []const u8) ?[]const u8 { + return b.graph.environ_map.get(name); +} @@ -1,4 +1,5 @@ const std = @import("std"); +const builtin = @import("builtin"); pub const default_path = "/tmp/pardes-dump.zon"; pub const magic = "pardes-dump"; @@ -104,13 +105,30 @@ pub fn writeFile(io: std.Io, gpa: std.mem.Allocator, path: []const u8, state: St 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}", .{ path, diag }); + std.log.err("parse dump {s}: {f}", .{ label, diag }); return err; }; errdefer std.zon.parse.free(gpa, state); @@ -2,8 +2,10 @@ const std = @import("std"); const ui = @import("ui"); const app_config = @import("app_config"); const ghostty_vt = @import("ghostty-vt"); -const ts = @import("tree-sitter"); -const ts_queries = @import("ts_queries"); +const ts = if (app_config.syntax_highlighting) @import("tree-sitter") else struct { + pub const Language = opaque {}; +}; +const ts_queries = if (app_config.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; @@ -166,6 +168,7 @@ fn extIs(ext: []const u8, comptime choices: anytype) bool { } pub fn highlightFile(gpa: std.mem.Allocator, path: []const u8, content: []const u8) ![]u8 { + if (!app_config.syntax_highlighting) return &.{}; const ext = std.fs.path.extension(path); var lang: *const ts.Language = undefined; var query_src: []const u8 = undefined; @@ -1,10 +1,30 @@ 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 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 = 1; const GUTTER: u16 = 2; const BOX_H: u16 = 1; @@ -455,6 +475,15 @@ fn deinitPanes(gpa: std.mem.Allocator, panes: []Pane) void { } } +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, @@ -490,7 +519,7 @@ fn buildPane(gpa: std.mem.Allocator, src: dump.Pane, out: *Pane) !void { .term = try ghostty_vt.Terminal.init(gpa, .{ .cols = @max(1, src.cols), .rows = @max(1, src.rows), - .max_scrollback = 16 * 1024 * 1024, + .max_scrollback = terminalScrollbackLimit(src), }), .stream = undefined, }; @@ -518,97 +547,148 @@ fn buildPane(gpa: std.mem.Allocator, src: dump.Pane, out: *Pane) !void { clampScroll(out); } -pub fn main(init: std.process.Init) !void { - const io = init.io; - const gpa = init.gpa; - const args = try init.minimal.args.toSlice(init.arena.allocator()); - if (args.len != 2) { - try printUsage(io); - return error.BadArgs; - } +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, - const state = try dump.readFile(io, gpa, args[1]); - defer dump.free(gpa, state); - _ = paint.setThemeByName(state.theme); - image.start(io, gpa); - defer image.stop(); + 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; + } - var panes_buf: [dump.max_panes]Pane = undefined; - for (state.panes, 0..) |src, i| try buildPane(gpa, src, &panes_buf[i]); - var panes = panes_buf[0..state.panes.len]; - defer deinitPanes(gpa, panes); + 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); - var layout: Layout = .{ - .ncol = state.columns.len, - .col_weight = @splat(1), - .col_terms = undefined, - .col_n = @splat(0), - }; - for (state.columns, 0..) |col, c| { - layout.col_weight[c] = col.weight; - layout.col_n[c] = col.panes.len; - for (col.panes, 0..) |pane, k| layout.col_terms[c][k] = pane; - } + 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.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, + }); + } - var screen_w: u16 = if (state.screen.cols > 0) state.screen.cols else 80; - var screen_h: u16 = if (state.screen.rows > 0) state.screen.rows else 24; - var active = state.active; + self.loop = .init(io, &self.tty, &self.vx); + try self.loop.start(); + try self.loop.installResizeHandler(); - var tty_buf: [0x10000]u8 = undefined; - var tty = try ui.Tty.init(io, &tty_buf); - var vx = try ui.init(io, gpa, init.environ_map, .{}); - defer vx.deinit(gpa, tty.writer()); - try vx.setMouseMode(tty.writer(), true); - try vx.enterAltScreen(tty.writer()); - defer vx.exitAltScreen(tty.writer()) catch {}; + computeGeom(&self.layout, self.panes, self.screen_w, self.screen_h, &self.rects, &self.col_x, &self.col_w); + } + + 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); + } - var loop: Loop = .init(io, &tty, &vx); - try loop.start(); - defer loop.stop(); - try loop.installResizeHandler(); + fn run(self: *App) !void { + while (try self.step(try self.loop.nextEvent())) {} + } - var rects: [dump.max_panes]Rect = undefined; - var col_x: [dump.max_cols]u16 = undefined; - var col_w: [dump.max_cols]u16 = undefined; - computeGeom(&layout, panes, screen_w, screen_h, &rects, &col_x, &col_w); + 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; + } - var drag: Drag = .none; - while (true) { - const event = try loop.nextEvent(); + fn step(self: *App, event: @TypeOf(Command.value)) !bool { switch (event) { .winsize => |ws| { - screen_w = ws.cols; - screen_h = ws.rows; - try vx.resize(gpa, tty.writer(), ws); + if (!is_emscripten) { + self.screen_w = ws.cols; + self.screen_h = ws.rows; + try self.vx.resize(self.gpa, self.tty.writer(), ws); + } }, .mouse => |mouse| { - const mcol: u16 = if (mouse.col < 0) 0 else @min(@as(u16, @intCast(mouse.col)), screen_w -| 1); - const mrow: u16 = if (mouse.row < 0) 0 else @min(@as(u16, @intCast(mouse.row)), screen_h -| 1); - const hovered = paneAt(&rects, panes.len, mcol, mrow); + const mcol: u16 = if (mouse.col < 0) 0 else @min(@as(u16, @intCast(mouse.col)), self.screen_w -| 1); + const mrow: u16 = if (mouse.row < 0) 0 else @min(@as(u16, @intCast(mouse.row)), self.screen_h -| 1); + const hovered = paneAt(&self.rects, self.panes.len, mcol, mrow); if (mouse.button == .wheel_down and mouse.type == .press) { - if (hovered) |id| scrollBy(&panes[id], 1); + if (hovered) |id| scrollBy(&self.panes[id], 1); } else if (mouse.button == .wheel_up and mouse.type == .press) { - if (hovered) |id| scrollBy(&panes[id], -1); + if (hovered) |id| scrollBy(&self.panes[id], -1); } else if (mouse.type == .press and mouse.button == .left) { var handled = false; 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 } }; + 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 < layout.ncol) : (cc += 1) { - if (mcol < col_x[cc] or mcol >= col_x[cc] + col_w[cc]) continue; + 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 < layout.col_n[cc]) : (k += 1) { - const id = layout.col_terms[cc][k]; - const r = rects[id]; + 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) { - drag = .{ .border_h = .{ .col = cc, .top_idx = k, .cur_y = mrow } }; + self.drag = .{ .border_h = .{ .col = cc, .top_idx = k, .cur_y = mrow } }; handled = true; break :outer; } @@ -616,35 +696,35 @@ pub fn main(init: std.process.Init) !void { } } if (!handled) if (hovered) |id| { - active = id; - const r = rects[id]; + self.active = id; + const r = self.rects[id]; if (mcol < r.x + GUTTER and mrow < r.y + BOX_H) { - drag = .{ .move = .{ .id = id, .cur_x = mcol, .cur_y = mrow } }; + self.drag = .{ .move = .{ .id = id, .cur_x = mcol, .cur_y = mrow } }; } else if (mcol < r.x + GUTTER) { const local: i32 = @as(i32, mrow) - @as(i32, r.y + BOX_H); - scrollBy(&panes[id], -local); + scrollBy(&self.panes[id], -local); } }; } else if (mouse.type == .press) { - if (hovered) |id| active = id; + if (hovered) |id| self.active = id; } else if (mouse.type == .drag) { - switch (drag) { + switch (self.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; + 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 < layout.col_n[c]) { - const a = layout.col_terms[c][k]; - const b = layout.col_terms[c][k + 1]; - const lo = rects[a].y + MINH; - const hi = rects[a].y +| rects[a].h +| rects[b].h -| MINH; + 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; } }, @@ -655,58 +735,111 @@ pub fn main(init: std.process.Init) !void { .none => {}, } } else if (mouse.type == .release) { - switch (drag) { + switch (self.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]); - var nl: f32 = @floatFromInt((d.cur_x + 1) -| col_x[c]); + 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 = 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]; + 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 < layout.col_n[c]) { - const a = layout.col_terms[c][k]; - const b = layout.col_terms[c][k + 1]; - const combined: f32 = @floatFromInt(rects[a].h + rects[b].h); - var nt: f32 = @floatFromInt((d.cur_y + 1) -| rects[a].y); + 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 = panes[a].vweight + panes[b].vweight; - panes[a].vweight = pair * (nt / combined); - panes[b].vweight = pair - panes[a].vweight; + 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(&layout, &rects, &col_x, &col_w, d.id, d.cur_x, d.cur_y), + .move => |d| moveTerm(&self.layout, &self.rects, &self.col_x, &self.col_w, d.id, d.cur_x, d.cur_y), .none => {}, } - drag = .none; + self.drag = .none; } }, .key_press => |key| { - if (key.matches('q', .{}) or key.matches(ui.Key.escape, .{}) or key.matches('c', .{ .ctrl = true })) break; + if (key.matches('q', .{}) or key.matches(ui.Key.escape, .{}) or key.matches('c', .{ .ctrl = true })) return false; }, - .paste => |text| gpa.free(text), - .quit, .close => break, + .paste => |text| self.gpa.free(text), + .quit, .close => return false, else => {}, } - computeGeom(&layout, panes, screen_w, screen_h, &rects, &col_x, &col_w); - for (panes, 0..) |*p, id| { - const r = rects[id]; + computeGeom(&self.layout, self.panes, self.screen_w, self.screen_h, &self.rects, &self.col_x, &self.col_w); + 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(gpa, cols, rows) catch {}; + 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(&vx, gpa, tty.writer(), state, panes, &rects, screen_w, active); + try render(&self.vx, self.gpa, self.tty.writer(), self.state, self.panes, &self.rects, self.screen_w, self.active); + return true; } +}; + +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/shaders/ui.frag.es.glsl b/shaders/ui.frag.es.glsl new file mode 100644 index 00000000..736e48f5 --- /dev/null +++ b/shaders/ui.frag.es.glsl @@ -0,0 +1,16 @@ +#version 300 es + +precision mediump float; + +uniform sampler2D u_atlas; + +in vec2 v_uv; +in vec3 v_fg; +in vec3 v_bg; + +out vec4 o_col; + +void main() { + float a = texture(u_atlas, v_uv).r; + o_col = vec4(mix(v_bg, v_fg, a), 1.0); +} diff --git a/shaders/ui.vert.es.glsl b/shaders/ui.vert.es.glsl new file mode 100644 index 00000000..271524be --- /dev/null +++ b/shaders/ui.vert.es.glsl @@ -0,0 +1,41 @@ +#version 300 es + +precision mediump float; + +layout(location = 0) in vec4 a_rect; +layout(location = 1) in vec4 a_uv_rect; +layout(location = 2) in vec3 a_fg; +layout(location = 3) in vec3 a_bg; + +out vec2 v_uv; +out vec3 v_fg; +out vec3 v_bg; + +void main() { + vec2 pos; + vec2 uv; + int corner = gl_VertexID % 6; + if (corner == 0) { + pos = vec2(a_rect.x, a_rect.y); + uv = vec2(a_uv_rect.x, a_uv_rect.y); + } else if (corner == 1) { + pos = vec2(a_rect.z, a_rect.y); + uv = vec2(a_uv_rect.z, a_uv_rect.y); + } else if (corner == 2) { + pos = vec2(a_rect.z, a_rect.w); + uv = vec2(a_uv_rect.z, a_uv_rect.w); + } else if (corner == 3) { + pos = vec2(a_rect.x, a_rect.y); + uv = vec2(a_uv_rect.x, a_uv_rect.y); + } else if (corner == 4) { + pos = vec2(a_rect.z, a_rect.w); + uv = vec2(a_uv_rect.z, a_uv_rect.w); + } else { + pos = vec2(a_rect.x, a_rect.w); + uv = vec2(a_uv_rect.x, a_uv_rect.w); + } + v_uv = uv; + v_fg = a_fg; + v_bg = a_bg; + gl_Position = vec4(pos, 0.0, 1.0); +} diff --git a/tests/browser_replay_dump.zon b/tests/browser_replay_dump.zon new file mode 100644 index 00000000..fbac9067 --- /dev/null +++ b/tests/browser_replay_dump.zon @@ -0,0 +1,53 @@ +.{ + .screen = .{ .cols = 80, .rows = 24 }, + .active = 0, + .topbar = "pardes replay browser e2e", + .theme = "dark", + .columns = .{ + .{ .weight = 1, .panes = .{ 0, 1 } }, + }, + .panes = .{ + .{ + .kind = .file, + .tag = "nm browser_replay.zig Del", + .body = + \\const message = "browser replay shader path"; + \\pub fn main() void { + \\ // This fixture should render in WebGL. + \\} + , + .scroll = 0, + .cols = 80, + .rows = 12, + .vweight = 1, + .file = .{ + .path = "browser_replay.zig", + .content = + \\const message = "browser replay shader path"; + \\pub fn main() void { + \\ // This fixture should render in WebGL. + \\} + , + }, + }, + .{ + .kind = .terminal, + .tag = "sh /tmp Del", + .body = + \\$ zig build replay-wasm + \\browser e2e marker 4200 + , + .scroll = 0, + .cols = 80, + .rows = 10, + .vweight = 1, + .terminal = .{ + .cwd = "/tmp", + .stream = + \\$ zig build replay-wasm + \\browser e2e marker 4200 + , + }, + }, + }, +} diff --git a/tests/browser_replay_e2e.mjs b/tests/browser_replay_e2e.mjs new file mode 100644 index 00000000..33267bbb --- /dev/null +++ b/tests/browser_replay_e2e.mjs @@ -0,0 +1,240 @@ +import { createServer } from "node:http"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { resolve, join, extname } from "node:path"; +import { inflateSync } from "node:zlib"; + +const webDir = resolve(process.argv[2] ?? "zig-out/web"); +const screenshotPath = resolve(process.argv[3] ?? "zig-out/browser-replay.png"); + +const mime = new Map([ + [".html", "text/html; charset=utf-8"], + [".js", "text/javascript; charset=utf-8"], + [".wasm", "application/wasm"], + [".png", "image/png"], +]); + +function run(cmd, args) { + return new Promise((resolvePromise, reject) => { + const child = spawn(cmd, args, { stdio: "inherit" }); + child.on("error", reject); + child.on("exit", (code) => { + if (code === 0) resolvePromise(); + else reject(new Error(`${cmd} ${args.join(" ")} exited with ${code}`)); + }); + }); +} + +async function loadPlaywright() { + try { + return { api: await import("playwright"), cleanup: async () => {} }; + } catch (err) { + if (process.env.PARDES_BROWSER_SKIP_INSTALL === "1") throw err; + } + + const dir = await mkdtemp(join(tmpdir(), "pardes-playwright-")); + try { + await run("npm", ["--prefix", dir, "install", "--silent", "playwright"]); + await run("npm", ["--prefix", dir, "exec", "--", "playwright", "install", "chromium"]); + const require = createRequire(join(dir, "package.json")); + return { + api: require("playwright"), + cleanup: async () => { + await rm(dir, { recursive: true, force: true }); + }, + }; + } catch (err) { + await rm(dir, { recursive: true, force: true }); + throw err; + } +} + +async function serve(root) { + await stat(join(root, "replay.html")); + await stat(join(root, "pardes-replay.js")); + + const server = createServer(async (req, res) => { + try { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + const path = url.pathname === "/" ? "/replay.html" : url.pathname; + if (path.includes("..")) throw new Error("bad path"); + const file = join(root, path.slice(1)); + const body = await readFile(file); + res.writeHead(200, { + "content-type": mime.get(extname(file)) ?? "application/octet-stream", + "cache-control": "no-store", + }); + res.end(body); + } catch { + res.writeHead(404); + res.end("not found"); + } + }); + + await new Promise((resolvePromise) => server.listen(0, "127.0.0.1", resolvePromise)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("failed to bind test server"); + return { server, url: `http://127.0.0.1:${address.port}/replay.html` }; +} + +function parsePng(buf) { + const sig = "89504e470d0a1a0a"; + if (buf.subarray(0, 8).toString("hex") !== sig) throw new Error("not a png"); + + let offset = 8; + let width = 0; + let height = 0; + let bitDepth = 0; + let colorType = 0; + const idat = []; + while (offset < buf.length) { + const len = buf.readUInt32BE(offset); + const type = buf.subarray(offset + 4, offset + 8).toString("ascii"); + const data = buf.subarray(offset + 8, offset + 8 + len); + offset += 12 + len; + if (type === "IHDR") { + width = data.readUInt32BE(0); + height = data.readUInt32BE(4); + bitDepth = data[8]; + colorType = data[9]; + } else if (type === "IDAT") { + idat.push(data); + } else if (type === "IEND") { + break; + } + } + if (bitDepth !== 8) throw new Error(`unsupported png bit depth ${bitDepth}`); + const channels = colorType === 6 ? 4 : colorType === 2 ? 3 : colorType === 0 ? 1 : 0; + if (channels === 0) throw new Error(`unsupported png color type ${colorType}`); + + const raw = inflateSync(Buffer.concat(idat)); + const stride = width * channels; + const rows = Buffer.alloc(width * height * channels); + let rawOffset = 0; + for (let y = 0; y < height; y += 1) { + const filter = raw[rawOffset]; + rawOffset += 1; + const rowStart = y * stride; + for (let x = 0; x < stride; x += 1) { + const left = x >= channels ? rows[rowStart + x - channels] : 0; + const up = y > 0 ? rows[rowStart + x - stride] : 0; + const upLeft = y > 0 && x >= channels ? rows[rowStart + x - stride - channels] : 0; + const value = raw[rawOffset + x]; + let recon; + if (filter === 0) recon = value; + else if (filter === 1) recon = value + left; + else if (filter === 2) recon = value + up; + else if (filter === 3) recon = value + Math.floor((left + up) / 2); + else if (filter === 4) { + const p = left + up - upLeft; + const pa = Math.abs(p - left); + const pb = Math.abs(p - up); + const pc = Math.abs(p - upLeft); + recon = value + (pa <= pb && pa <= pc ? left : pb <= pc ? up : upLeft); + } else { + throw new Error(`unsupported png filter ${filter}`); + } + rows[rowStart + x] = recon & 0xff; + } + rawOffset += stride; + } + return { width, height, channels, rows }; +} + +function validateScreenshot(png) { + let bright = 0; + let colored = 0; + let nonDark = 0; + const pixels = png.width * png.height; + for (let i = 0; i < pixels; i += 1) { + const off = i * png.channels; + const r = png.rows[off]; + const g = png.channels === 1 ? r : png.rows[off + 1]; + const b = png.channels === 1 ? r : png.rows[off + 2]; + if (r > 90 || g > 90 || b > 90) bright += 1; + if (Math.max(r, g, b) - Math.min(r, g, b) > 20) colored += 1; + if (r > 35 || g > 35 || b > 35) nonDark += 1; + } + if (png.width < 640 || png.height < 360) { + throw new Error(`screenshot too small: ${png.width}x${png.height}`); + } + if (bright < 500 || nonDark < 2000) { + throw new Error(`browser replay appears blank: bright=${bright} nonDark=${nonDark} colored=${colored}`); + } + console.log(`browser replay screenshot ok: ${png.width}x${png.height}, bright=${bright}, nonDark=${nonDark}, colored=${colored}`); +} + +const { server, url } = await serve(webDir); +let cleanupPlaywright = async () => {}; +let browser; +try { + const { api, cleanup } = await loadPlaywright(); + cleanupPlaywright = cleanup; + const { chromium } = api; + + browser = await chromium.launch({ + headless: true, + args: ["--use-angle=swiftshader", "--enable-unsafe-swiftshader"], + }); + const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); + const consoleMessages = []; + const pageErrors = []; + page.on("console", (msg) => consoleMessages.push(`${msg.type()}: ${msg.text()}`)); + page.on("pageerror", (err) => pageErrors.push(err.stack ?? err.message)); + + await page.addInitScript(() => { + window.__pardesReplayE2E = { draws: 0, contextLost: 0 }; + window.addEventListener("webglcontextlost", () => { + window.__pardesReplayE2E.contextLost += 1; + }, true); + + const wrapDraws = (target) => { + if (!target) return; + for (const name of ["drawArraysInstanced", "drawArrays", "drawElements", "drawElementsInstanced"]) { + if (typeof target[name] !== "function" || target[name].__pardesReplayE2E) continue; + const oldDraw = target[name]; + target[name] = function(...args) { + window.__pardesReplayE2E.draws += 1; + return oldDraw.apply(this, args); + }; + target[name].__pardesReplayE2E = true; + } + }; + + wrapDraws(globalThis.WebGLRenderingContext?.prototype); + wrapDraws(globalThis.WebGL2RenderingContext?.prototype); + + const oldGetContext = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = function(type, attrs) { + const ctx = oldGetContext.call(this, type, attrs); + if (ctx && !ctx.__pardesReplayE2E && (type === "webgl2" || type === "webgl" || type === "experimental-webgl")) { + ctx.__pardesReplayE2E = true; + wrapDraws(ctx); + } + return ctx; + }; + }); + + await page.goto(url, { waitUntil: "load" }); + await page.waitForSelector("canvas", { timeout: 5000 }); + await page.waitForFunction(() => window.__pardesReplayE2E?.draws >= 2, null, { timeout: 5000 }); + await page.waitForTimeout(250); + const trace = await page.evaluate(() => window.__pardesReplayE2E); + if (trace.contextLost !== 0) { + throw new Error(`browser WebGL context was lost during replay: ${JSON.stringify(trace)}`); + } + if (pageErrors.length !== 0) { + throw new Error(`browser page error: ${pageErrors[0]}`); + } + const lostMessage = consoleMessages.find((msg) => msg.includes("CONTEXT_LOST_WEBGL")); + if (lostMessage) throw new Error(`browser WebGL context lost: ${lostMessage}`); + + await page.screenshot({ path: screenshotPath, fullPage: true }); + validateScreenshot(parsePng(await readFile(screenshotPath))); +} finally { + if (browser) await browser.close(); + await cleanupPlaywright(); + server.close(); +} @@ -24,10 +24,12 @@ const builtin = @import("builtin"); const vaxis = @import("vaxis"); const posix = std.posix; const linux = std.os.linux; +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"); }); @@ -61,8 +63,10 @@ pub fn Loop(comptime EventType: type) type { } const font_ttf = @embedFile("assets/DejaVuSansMono.ttf"); -const vert_spv_bytes = @embedFile("ui.vert.spv"); -const frag_spv_bytes = @embedFile("ui.frag.spv"); +const vert_spv_bytes = if (is_emscripten) "" else @embedFile("ui.vert.spv"); +const frag_spv_bytes = if (is_emscripten) "" else @embedFile("ui.frag.spv"); +const 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 log = std.log.scoped(.ui_sdl); @@ -104,6 +108,15 @@ cell_cache_valid: bool = false, last_render_w: u32 = 0, last_render_h: u32 = 0, +// Emscripten/browser path: SDL window + WebGL2 shader renderer. Handles are GL +// GLuint/GLint values, kept as plain C integers so native builds need no GL API. +gl_context: ?c.SDL_GLContext = null, +gl_program: c_uint = 0, +gl_atlas_tex: c_uint = 0, +gl_vbo: c_uint = 0, +gl_u_atlas: c_int = -1, +gl_instances: []CellInstance = &.{}, + // font + cell metrics font: ?*c.UIFont, scale: f32, @@ -189,6 +202,7 @@ fn envFlag(env_map: *std.process.Environ.Map, name: []const u8) bool { } fn nowNs() i128 { + if (is_emscripten) return @intCast(c.SDL_GetTicksNS()); var ts: linux.timespec = undefined; _ = linux.clock_gettime(linux.CLOCK.MONOTONIC, &ts); return @as(i128, ts.sec) * 1_000_000_000 + @as(i128, ts.nsec); @@ -259,7 +273,7 @@ pub fn init( const trace_enabled = envFlag(env_map, "PARDES_SDL_TRACE"); const trace_every = envU32(env_map, "PARDES_SDL_TRACE_EVERY") orelse 1; var trace_fd: c_int = -1; - if (trace_enabled) { + if (!is_emscripten and trace_enabled) { if (env_map.get("PARDES_SDL_TRACE_FILE")) |path| { trace_fd = openTraceFile(path); } else if (capture_dir) |dir| { @@ -278,10 +292,9 @@ pub fn init( const win_w: c_int = 1120; const win_h: c_int = 720; - const win_flags: c.SDL_WindowFlags = if (test_mode) - c.SDL_WINDOW_RESIZABLE - else - c.SDL_WINDOW_HIGH_PIXEL_DENSITY | c.SDL_WINDOW_RESIZABLE; + var win_flags: c.SDL_WindowFlags = c.SDL_WINDOW_RESIZABLE; + if (!test_mode) win_flags |= c.SDL_WINDOW_HIGH_PIXEL_DENSITY; + if (is_emscripten) win_flags |= c.SDL_WINDOW_OPENGL; const sdl_win = c.SDL_CreateWindow( "pardes", win_w, @@ -291,6 +304,9 @@ pub fn init( log.err("SDL_CreateWindow: {s}", .{c.SDL_GetError()}); return error.SdlInit; }; + if (is_emscripten) { + return initGl(io, alloc, sdl_win, test_mode, capture_dir, trace_enabled, trace_fd, trace_every); + } const device = c.SDL_CreateGPUDevice( c.SDL_GPU_SHADERFORMAT_SPIRV, @@ -410,6 +426,98 @@ pub fn init( return self; } +fn initGl( + io: std.Io, + alloc: std.mem.Allocator, + sdl_win: *c.SDL_Window, + test_mode: bool, + capture_dir: ?[]const u8, + trace_enabled: bool, + trace_fd: c_int, + trace_every: u32, +) !SdlBackend { + _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_PROFILE_MASK, c.SDL_GL_CONTEXT_PROFILE_ES); + _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MAJOR_VERSION, 3); + _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MINOR_VERSION, 0); + _ = c.SDL_GL_SetAttribute(c.SDL_GL_DOUBLEBUFFER, 1); + const gl_context = c.SDL_GL_CreateContext(sdl_win) orelse { + log.err("SDL_GL_CreateContext: {s}", .{c.SDL_GetError()}); + return error.SdlInit; + }; + if (!c.SDL_GL_MakeCurrent(sdl_win, gl_context)) { + log.err("SDL_GL_MakeCurrent: {s}", .{c.SDL_GetError()}); + return error.SdlInit; + } + _ = c.SDL_GL_SetSwapInterval(1); + + const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)) orelse { + log.err("ui_font_new failed", .{}); + return error.FontInit; + }; + const px: f32 = 18.0; + const 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 program = try makeGlProgram(); + 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); + + const atlas_stage = try alloc.alloc(u8, atlas_w * atlas_h); + @memset(atlas_stage, 0); + + var self: SdlBackend = .{ + .io = io, + .alloc = alloc, + .sdl_window = sdl_win, + .device = undefined, + .swapchain_format = undefined, + .present_mode = c.SDL_GPU_PRESENTMODE_VSYNC, + .pipeline = undefined, + .atlas_tex = undefined, + .atlas_sampler = undefined, + .atlas_xfer = undefined, + .vbuf = undefined, + .vxfer = undefined, + .gl_context = gl_context, + .gl_program = program, + .gl_atlas_tex = atlas_tex, + .gl_vbo = vbo, + .gl_u_atlas = c.glGetUniformLocation(program, "u_atlas"), + .font = font, + .scale = scale, + .cell_w = @intCast(@max(cw, 1)), + .cell_h = @intCast(@max(chh, 1)), + .ascent = asc, + .screen = .{}, + .atlas_stage = atlas_stage, + .glyphs = std.AutoHashMap(u32, Slot).init(alloc), + .test_mode = test_mode, + .capture_images = false, + .capture_dir = capture_dir, + .trace_enabled = trace_enabled, + .trace_fd = trace_fd, + .trace_every = @max(trace_every, 1), + }; + _ = c.ui_font_raster(font, scale, ' ', atlas_stage.ptr, @intCast(atlas_w), @intCast(self.cell_w), @intCast(self.cell_h), asc); + self.space_slot = .{ .u = 0, .v = 0 }; + self.pen_x = self.cell_w; + self.tracef("sdl.init browser=webgl2 trace_every={d}", .{self.trace_every}); + return self; +} + // Headless debug init for PARDES_TEST_GRID=1: no SDL/GPU/window, just the cell // grid. Input comes from stdin (the pty), output goes to stdout (grid text // snapshots). Cell metrics are dummies: the pty gives us rows/cols directly. @@ -447,6 +555,20 @@ pub fn deinit(self: *SdlBackend, alloc: ?std.mem.Allocator, writer: *std.Io.Writ self.glyphs.deinit(); return; } + if (is_emscripten) { + if (self.gl_vbo != 0) c.glDeleteBuffers(1, &self.gl_vbo); + if (self.gl_atlas_tex != 0) c.glDeleteTextures(1, &self.gl_atlas_tex); + if (self.gl_program != 0) c.glDeleteProgram(self.gl_program); + self.alloc.free(self.gl_instances); + if (self.font) |f| c.ui_font_free(f); + self.screen.deinit(self.alloc); + self.glyphs.deinit(); + if (alloc) |a| a.free(self.atlas_stage); + if (self.gl_context) |ctx| _ = c.SDL_GL_DestroyContext(ctx); + c.SDL_DestroyWindow(self.sdl_window); + c.SDL_Quit(); + return; + } if (self.capture_tex) |t| c.SDL_ReleaseGPUTexture(self.device, t); if (self.capture_xfer) |x| c.SDL_ReleaseGPUTransferBuffer(self.device, x); if (self.trace_fd >= 0 and self.trace_fd != 2) _ = linux.close(self.trace_fd); @@ -476,7 +598,7 @@ pub fn resize(self: *SdlBackend, alloc: std.mem.Allocator, writer: *std.Io.Write self.screen.deinit(alloc); self.screen = try vaxis.Screen.init(alloc, ws); self.screen.width_method = .wcwidth; - if (self.capture_images) { + if (self.capture_images or is_emscripten) { _ = c.SDL_SetWindowSize( self.sdl_window, @intCast(@as(u32, ws.cols) * self.cell_w), @@ -484,6 +606,7 @@ pub fn resize(self: *SdlBackend, alloc: std.mem.Allocator, writer: *std.Io.Write ); _ = c.SDL_SyncWindow(self.sdl_window); } + if (is_emscripten) return; if (!self.grid_test_mode) try self.ensureVbuf(ws.cols * ws.rows); } @@ -501,7 +624,8 @@ pub fn window(self: *SdlBackend) vaxis.Window { pub fn render(self: *SdlBackend, writer: *std.Io.Writer) !void { _ = writer; - if (self.grid_test_mode) return self.dumpGrid(); + if (!is_emscripten and self.grid_test_mode) return self.dumpGrid(); + if (is_emscripten) return self.renderGl(); const trace_this = self.trace_enabled and (self.trace_frame % self.trace_every == 0); const t0 = nowNs(); @@ -665,6 +789,69 @@ pub fn render(self: *SdlBackend, writer: *std.Io.Writer) !void { self.trace_frame += 1; } +fn renderGl(self: *SdlBackend) !void { + var pw: c_int = 0; + var ph: c_int = 0; + _ = c.SDL_GetWindowSizeInPixels(self.sdl_window, &pw, &ph); + const sw: u32 = @max(1, @as(u32, @intCast(pw))); + const sh: u32 = @max(1, @as(u32, @intCast(ph))); + c.glViewport(0, 0, @intCast(sw), @intCast(sh)); + c.glClearColor( + @as(f32, @floatFromInt(self.bg_default[0])) / 255.0, + @as(f32, @floatFromInt(self.bg_default[1])) / 255.0, + @as(f32, @floatFromInt(self.bg_default[2])) / 255.0, + 1.0, + ); + c.glClear(c.GL_COLOR_BUFFER_BIT); + + const cols = self.screen.width; + const rows = self.screen.height; + const cells: u32 = @as(u32, cols) * @as(u32, rows); + if (cells == 0) { + _ = c.SDL_GL_SwapWindow(self.sdl_window); + return; + } + + try self.ensureGlInstances(cells); + const cw_f: f32 = @floatFromInt(self.cell_w); + const ch_f: f32 = @floatFromInt(self.cell_h); + const win_w: f32 = @floatFromInt(sw); + const win_h: f32 = @floatFromInt(sh); + var r: u16 = 0; + while (r < rows) : (r += 1) { + var col: u16 = 0; + while (col < cols) : (col += 1) { + const cell_index: u32 = @as(u32, r) * @as(u32, cols) + @as(u32, col); + self.emitInstance(self.gl_instances.ptr, cell_index, col, r, cw_f, ch_f, win_w, win_h, self.cellKey(self.screen.buf[cell_index])); + } + } + if (self.atlas_dirty) self.uploadAtlasGl(); + + c.glUseProgram(self.gl_program); + c.glActiveTexture(c.GL_TEXTURE0); + c.glBindTexture(c.GL_TEXTURE_2D, self.gl_atlas_tex); + c.glUniform1i(self.gl_u_atlas, 0); + + c.glBindBuffer(c.GL_ARRAY_BUFFER, self.gl_vbo); + c.glBufferData(c.GL_ARRAY_BUFFER, @intCast(cells * @sizeOf(CellInstance)), self.gl_instances.ptr, c.GL_STREAM_DRAW); + const stride: c_int = @intCast(@sizeOf(CellInstance)); + c.glEnableVertexAttribArray(0); + c.glVertexAttribPointer(0, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "x0"))); + c.glVertexAttribDivisor(0, 1); + c.glEnableVertexAttribArray(1); + c.glVertexAttribPointer(1, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "u0"))); + c.glVertexAttribDivisor(1, 1); + c.glEnableVertexAttribArray(2); + c.glVertexAttribPointer(2, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "fr"))); + c.glVertexAttribDivisor(2, 1); + c.glEnableVertexAttribArray(3); + c.glVertexAttribPointer(3, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "br"))); + c.glVertexAttribDivisor(3, 1); + + c.glDrawArraysInstanced(c.GL_TRIANGLES, 0, 6, @intCast(cells)); + _ = c.SDL_GL_SwapWindow(self.sdl_window); +} + // PARDES_TEST_GRID debug mode: dump the retained cell grid as text to stdout // (fd 1). One frame per render, delimited by ---FRAME---/---ENDFRAME--- markers. fn dumpGrid(self: *SdlBackend) !void { @@ -876,6 +1063,7 @@ fn writeAllFdIgnore(fd: c_int, bytes: []const u8) void { } fn tracef(self: *SdlBackend, comptime fmt: []const u8, args: anytype) void { + if (is_emscripten) return; if (!self.trace_enabled or self.trace_fd < 0) return; var buf: [1024]u8 = undefined; const line = std.fmt.bufPrint(&buf, fmt ++ "\n", args) catch return; @@ -1000,6 +1188,12 @@ fn ensureVbuf(self: *SdlBackend, cells: u32) !void { self.vbuf_cells = cells; } +fn ensureGlInstances(self: *SdlBackend, cells: u32) !void { + if (self.gl_instances.len >= cells) return; + self.alloc.free(self.gl_instances); + self.gl_instances = try self.alloc.alloc(CellInstance, cells); +} + fn resolveColor(self: *const SdlBackend, col: vaxis.Color, is_bg: bool) [3]u8 { return switch (col) { .default => if (is_bg) self.bg_default else self.fg_default, @@ -1201,6 +1395,13 @@ fn uploadAtlas(self: *SdlBackend, cmd: *c.SDL_GPUCommandBuffer) void { self.atlas_dirty = false; } +fn uploadAtlasGl(self: *SdlBackend) void { + c.glBindTexture(c.GL_TEXTURE_2D, self.gl_atlas_tex); + c.glPixelStorei(c.GL_UNPACK_ALIGNMENT, 1); + c.glTexSubImage2D(c.GL_TEXTURE_2D, 0, 0, 0, atlas_w, atlas_h, c.GL_RED, c.GL_UNSIGNED_BYTE, self.atlas_stage.ptr); + self.atlas_dirty = false; +} + fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline { const vs = try makeShader(device, vert_spv_bytes, c.SDL_GPU_SHADERSTAGE_VERTEX, 0); defer c.SDL_ReleaseGPUShader(device, vs); @@ -1257,6 +1458,44 @@ fn makeShader( return c.SDL_CreateGPUShader(device, &info) orelse error.GpuCreate; } +fn makeGlProgram() !c_uint { + const vs = try makeGlShader(c.GL_VERTEX_SHADER, vert_glsl_es); + defer c.glDeleteShader(vs); + const fs = try makeGlShader(c.GL_FRAGMENT_SHADER, frag_glsl_es); + defer c.glDeleteShader(fs); + + const program = c.glCreateProgram(); + if (program == 0) return error.GpuCreate; + c.glAttachShader(program, vs); + c.glAttachShader(program, fs); + c.glLinkProgram(program); + var ok: c_int = 0; + c.glGetProgramiv(program, c.GL_LINK_STATUS, &ok); + if (ok == 0) { + log.err("WebGL program link failed", .{}); + c.glDeleteProgram(program); + return error.GpuCreate; + } + return program; +} + +fn makeGlShader(kind: c_uint, source: []const u8) !c_uint { + const shader = c.glCreateShader(kind); + if (shader == 0) return error.GpuCreate; + var src_ptr = [_][*c]const u8{@ptrCast(source.ptr)}; + var src_len = [_]c_int{@intCast(source.len)}; + c.glShaderSource(shader, 1, &src_ptr, &src_len); + c.glCompileShader(shader); + var ok: c_int = 0; + c.glGetShaderiv(shader, c.GL_COMPILE_STATUS, &ok); + if (ok == 0) { + log.err("WebGL shader compile failed", .{}); + c.glDeleteShader(shader); + return error.GpuCreate; + } + return shader; +} + // ===================================================================== // Tty stub — the app threads a `*std.Io.Writer` through every backend call; SDL // needs none of it, so we back it with /dev/null. The pointer just has to stay @@ -1264,17 +1503,26 @@ fn makeShader( // ===================================================================== const SdlTty = struct { - file: std.Io.File, - tty_writer: std.Io.File.Writer, + const File = if (is_emscripten) void else std.Io.File; + const Writer = if (is_emscripten) std.Io.Writer.Discarding else std.Io.File.Writer; + + file: File, + tty_writer: Writer, pub fn init(io: std.Io, buffer: []u8) !SdlTty { + if (is_emscripten) return .{ + .file = {}, + .tty_writer = .init(buffer), + }; const f = try std.Io.Dir.openFileAbsolute(io, "/dev/null", .{ .mode = .read_write }); return .{ .file = f, .tty_writer = f.writerStreaming(io, buffer) }; } pub fn deinit(self: SdlTty, io: std.Io) void { + if (is_emscripten) return; self.file.close(io); } pub fn writer(self: *SdlTty) *std.Io.Writer { + if (is_emscripten) return &self.tty_writer.writer; return &self.tty_writer.interface; } }; @@ -1288,6 +1536,7 @@ const SdlTty = struct { fn SdlLoop(comptime EventType: type) type { return struct { const Self = @This(); + const Reader = if (is_emscripten) void else ?std.Io.Future(void); io: std.Io, vx: *SdlBackend, @@ -1298,7 +1547,7 @@ fn SdlLoop(comptime EventType: type) type { last_cols: u16 = 0, last_rows: u16 = 0, // test-mode stdin reader task + quit flag - reader: ?std.Io.Future(void) = null, + reader: Reader = if (is_emscripten) {} else null, should_quit: bool = false, pub fn init(io: std.Io, tty: *SdlTty, vx: *SdlBackend) Self { @@ -1307,7 +1556,7 @@ fn SdlLoop(comptime EventType: type) type { } pub fn start(self: *Self) !void { - if (self.vx.test_mode) { + if (!is_emscripten and self.vx.test_mode) { setStdinRaw() catch {}; // e2e: spawn a stdin reader that parses terminal escape sequences // (vaxis.Parser) into Key/Mouse/Winsize events and posts them. @@ -1320,7 +1569,7 @@ fn SdlLoop(comptime EventType: type) type { self.postWinsize(); } pub fn stop(self: *Self) void { - if (self.vx.test_mode) { + if (!is_emscripten and self.vx.test_mode) { self.should_quit = true; if (self.reader) |*r| r.await(self.io); self.reader = null; @@ -1328,7 +1577,7 @@ fn SdlLoop(comptime EventType: type) type { } } pub fn installResizeHandler(self: *Self) !void { - if (self.vx.test_mode) { + if (!is_emscripten and self.vx.test_mode) { var act = posix.Sigaction{ .handler = .{ .handler = winchHandler }, .mask = switch (builtin.os.tag) { @@ -1355,7 +1604,7 @@ fn SdlLoop(comptime EventType: type) type { } pub fn nextEvent(self: *Self) !EventType { - if (self.vx.test_mode) return try self.queue.pop(); + if (!is_emscripten and self.vx.test_mode) return try self.queue.pop(); while (true) { // pty/queued events first (they're already decoded) if (try self.queue.tryPop()) |ev| { @@ -1379,11 +1628,17 @@ fn SdlLoop(comptime EventType: type) type { } pub fn tryEvent(self: *Self) !?EventType { - return try self.queue.tryPop(); + if (try self.queue.tryPop()) |ev| return ev; + if (!is_emscripten and self.vx.test_mode) return null; + var sev = std.mem.zeroes(c.SDL_Event); + while (c.SDL_PollEvent(&sev)) { + if (try self.dispatch(&sev)) |ev| return ev; + } + return null; } fn postWinsize(self: *Self) void { - if (self.vx.test_mode) { + if (!is_emscripten and self.vx.test_mode) { // read winsize from stdin (fd 0 = the pty slave) var ws: posix.winsize = std.mem.zeroes(posix.winsize); const iorc = posix.system.ioctl(0, posix.T.IOCGWINSZ, @intFromPtr(&ws)); |
