summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-02 21:56:01 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commit589269c0b3ca74a29f5889fa6e781e59cef7efdb (patch)
treeecd2ce3060d06353f3b6302e70abf784295ff40f
parent81992cc92743a98066ee1a9025bc74f91cf0e967 (diff)
downloadpardes-589269c0b3ca74a29f5889fa6e781e59cef7efdb.tar.gz
pardes-589269c0b3ca74a29f5889fa6e781e59cef7efdb.zip
wasm replay ui
-rw-r--r--build.zig327
-rw-r--r--dump.zig20
-rw-r--r--paint.zig7
-rw-r--r--replay.zig345
-rw-r--r--shaders/ui.frag.es.glsl16
-rw-r--r--shaders/ui.vert.es.glsl41
-rw-r--r--tests/browser_replay_dump.zon53
-rw-r--r--tests/browser_replay_e2e.mjs240
-rw-r--r--ui_sdl.zig291
9 files changed, 1136 insertions, 204 deletions
diff --git a/build.zig b/build.zig
index 14fefaf5..1b9a4351 100644
--- a/build.zig
+++ b/build.zig
@@ -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);
+}
diff --git a/dump.zig b/dump.zig
index 5a55f04f..9c1dceec 100644
--- a/dump.zig
+++ b/dump.zig
@@ -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);
diff --git a/paint.zig b/paint.zig
index d8526cb4..2f953a5a 100644
--- a/paint.zig
+++ b/paint.zig
@@ -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;
diff --git a/replay.zig b/replay.zig
index 75227c42..18deab13 100644
--- a/replay.zig
+++ b/replay.zig
@@ -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();
+}
diff --git a/ui_sdl.zig b/ui_sdl.zig
index 4e0a5bb2..2e3f8afa 100644
--- a/ui_sdl.zig
+++ b/ui_sdl.zig
@@ -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));