diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-10 17:32:43 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | 685b8c8e74c9edbdf701594f50ff1b70ac288ec9 (patch) | |
| tree | f303bd5e1f422030c2c645ea2d6c3a393990fb5e | |
| parent | ddc2eefb1e7cdd402593efb1297c25fc7ae4b110 (diff) | |
| download | pardes-685b8c8e74c9edbdf701594f50ff1b70ac288ec9.tar.gz pardes-685b8c8e74c9edbdf701594f50ff1b70ac288ec9.zip | |
web snapshots: add switch for saving/loading exports, update web version, web snapshot tooling
| -rw-r--r-- | build.zig | 65 | ||||
| -rw-r--r-- | docs/design.typ | 35 | ||||
| -rw-r--r-- | src/gui.zig | 401 | ||||
| -rw-r--r-- | src/look.zig | 79 | ||||
| -rw-r--r-- | src/syntax.zig | 18 | ||||
| -rw-r--r-- | src/tutor.txt | 24 | ||||
| -rw-r--r-- | test/web-snapshots/README.md | 18 | ||||
| -rw-r--r-- | test/web-snapshots/source-list.dump.zon | 26 | ||||
| -rw-r--r-- | test/web-snapshots/source-list.golden | 190 | ||||
| -rw-r--r-- | test/web-snapshots/source-list.snap | 29 | ||||
| -rw-r--r-- | test/web_snapshot.mjs | 471 | ||||
| -rw-r--r-- | tools/embed_zig_sources.zig | 128 |
12 files changed, 1410 insertions, 74 deletions
@@ -1,7 +1,7 @@ const std = @import("std"); pub const Platform = enum { tty, gui, web }; -pub const TreeSitterGrammars = enum { disabled, minimal, full }; +pub const TreeSitterGrammars = enum { disabled, zig, minimal, full }; const GhosttyArtifact = enum { ghostty, lib }; pub fn build(b: *std.Build) void { @@ -24,13 +24,62 @@ pub fn build(b: *std.Build) void { const is_emscripten = target.result.os.tag == .emscripten; // wasm: size is the budget const optimize = if (is_emscripten) .ReleaseSmall else requested_optimize; - const default_grammars: TreeSitterGrammars = if (is_emscripten) .minimal else .full; - const tree_sitter_grammars = b.option(TreeSitterGrammars, "tree-sitter", "tree-sitter grammar set: disabled, minimal, full") orelse default_grammars; + const default_grammars: TreeSitterGrammars = if (is_emscripten) .zig else .full; + const tree_sitter_grammars = b.option(TreeSitterGrammars, "tree-sitter", "tree-sitter grammar set: disabled, zig, minimal (c/c++/zig), full") orelse default_grammars; const tracy = b.option([]const u8, "tracy", "enable Tracy profiling; supply the path to a Tracy source checkout"); // the browser shell: zig object + emcc link + html, installed to zig-out/web const web_step = b.step("web", "build the browser shell into zig-out/web (-Dplatform=web -Dtarget=wasm32-emscripten -Ddump=<dump.zon>)"); + // Web LOOK has a tiny read-only source filesystem. Generate one Zig module + // on the host from every tracked .zig path + its current working-tree + // contents. The generator prefers Git as the tracked-file filter and has a + // conservative filesystem fallback for source archives without `.git`. + const source_gen = b.addExecutable(.{ + .name = "pardes-embed-zig-sources", + .root_module = b.createModule(.{ + .target = b.graph.host, + .optimize = .ReleaseSafe, + .root_source_file = b.path("tools/embed_zig_sources.zig"), + }), + }); + const run_source_gen = b.addRunArtifact(source_gen); + run_source_gen.addDirectoryArg(b.path(".")); + const embedded_sources = run_source_gen.addOutputFileArg("embedded_sources.zig"); + // The tracked set can change without any already-known input changing, so + // discovery intentionally runs whenever a consumer builds it. + run_source_gen.has_side_effects = true; + const inspect_sources = b.addInstallFileWithDir(embedded_sources, .{ .custom = "web" }, "embedded_sources.zig"); + b.step("embed-web-sources", "generate zig-out/web/embedded_sources.zig from tracked .zig files").dependOn(&inspect_sources.step); + + // Browser regression suite: make an isolated web build from the real TTY + // dump captured after running `git ls-files '*.zig'`, with the Zig-only + // tree-sitter tier. The dependency-free CDP harness then drives + // its .snap script through real Chromium touch events. This recursive build + // selects `web`, not `web-snap`, so it bottoms out after one level. + const build_web_snap = b.addSystemCommand(&.{ + b.graph.zig_exe, + "build", + "web", + "-Dplatform=web", + "-Dtarget=wasm32-emscripten", + "-Dtree-sitter=zig", + "-Ddump=test/web-snapshots/source-list.dump.zon", + "-p", + "zig-out/web-snap-test", + }); + build_web_snap.setCwd(b.path(".")); + const run_web_snap = b.addSystemCommand(&.{ + "node", + "test/web_snapshot.mjs", + "zig-out/web-snap-test/web", + "test/web-snapshots/source-list.snap", + }); + run_web_snap.setCwd(b.path(".")); + run_web_snap.step.dependOn(&build_web_snap.step); + if (b.args) |args| run_web_snap.addArgs(args); + b.step("web-snap", "run browser touch/LOOK snapshots (-- --update regenerates goldens)").dependOn(&run_web_snap.step); + const emscripten_sysroot = if (is_emscripten) detectEmscriptenSysroot(b) else null; if (platform == .web and !is_emscripten) return failBuild(b, web_step, "-Dplatform=web requires -Dtarget=wasm32-emscripten"); if (platform != .web and is_emscripten) return failBuild(b, web_step, "-Dtarget=wasm32-emscripten requires -Dplatform=web"); @@ -88,7 +137,8 @@ pub fn build(b: *std.Build) void { const opts = b.addOptions(); opts.addOption(Platform, "platform", platform); opts.addOption(bool, "syntax_highlighting", tree_sitter_grammars != .disabled); - opts.addOption(bool, "syntax_minimal_grammars", tree_sitter_grammars != .disabled); + opts.addOption(bool, "syntax_zig_grammar", tree_sitter_grammars != .disabled); + opts.addOption(bool, "syntax_minimal_grammars", tree_sitter_grammars == .minimal or tree_sitter_grammars == .full); opts.addOption(bool, "syntax_full_grammars", tree_sitter_grammars == .full); opts.addOption(bool, "enable_tracy", tracy != null); root_mod.addOptions("pardes_config", opts); @@ -176,7 +226,10 @@ pub fn build(b: *std.Build) void { }; const ts_queries = b.addOptions(); inline for (grammars) |g| { - if (tree_sitter_grammars == .full or g.minimal) { + if (tree_sitter_grammars == .full or + (tree_sitter_grammars == .minimal and g.minimal) or + (tree_sitter_grammars == .zig and std.mem.eql(u8, g.name, "zig"))) + { const dep = b.dependency(g.dep, .{}); const query_path = dep.path(g.query); const query = std.Io.Dir.cwd().readFileAlloc(io, query_path.getPath(b), b.allocator, .limited(0x100000)) catch @panic("read " ++ g.name ++ " highlights"); @@ -329,6 +382,7 @@ pub fn build(b: *std.Build) void { root_mod.addAnonymousImport("embedded.dump.zon", .{ .root_source_file = if (std.fs.path.isAbsolute(dp)) .{ .cwd_relative = dp } else b.path(dp), }); + root_mod.addAnonymousImport("embedded_sources", .{ .root_source_file = embedded_sources }); } else { root_mod.addAnonymousImport("ui.vert.spv", .{ .root_source_file = compileGlsl(b, "shaders/ui.vert.glsl", "vertex", "ui.vert.spv"), @@ -376,6 +430,7 @@ pub fn build(b: *std.Build) void { "-sMIN_WEBGL_VERSION=2", "-sMAX_WEBGL_VERSION=2", "-sINCOMING_MODULE_JS_API=canvas", + "-sEXPORTED_FUNCTIONS=['_main','_pardes_web_test_cols','_pardes_web_test_rows','_pardes_web_test_cursor_x','_pardes_web_test_cursor_y','_pardes_web_test_cell','_pardes_web_test_cell_fg','_pardes_web_test_cell_bg','_pardes_web_test_cell_attrs','_pardes_web_test_cell_x','_pardes_web_test_cell_y']", "-o", }); const js = emcc.addOutputFileArg("pardes.js"); diff --git a/docs/design.typ b/docs/design.typ index dc94219a..e2fce5de 100644 --- a/docs/design.typ +++ b/docs/design.typ @@ -51,9 +51,11 @@ The boundary is two data types, both plain values: *Event* (in): `key`, `text`, `mouse` (press/release/motion; button left, middle, right; cell position), `wheel`, `pinch`, `resize`, and `output` (bytes a pty -produced, tagged with the pane id). A touch tap is a left press; a joystick -cursor is a motion plus buttons; the shells do this translation and nothing else -with input. One event vocabulary, three dialects translated at the door. +produced, tagged with the pane id). Touch policy belongs to the shell: web maps +a one-finger tap to right-button LOOK and a drag past its tap slop to natural +scrolling; native SDL keeps its two-finger gestures. A joystick cursor is a +motion plus buttons. The shells do this translation and nothing else with +input: one event vocabulary, three dialects translated at the door. *Surface* (out): a grid of cells — grapheme, fg, bg, attrs — plus a cursor and per-pane rectangles. This is the *canonical interface*: it is literally what the @@ -237,10 +239,23 @@ palettes, ascii glyph set toggle). Tutor: embedded text as file pane. toggles all recolor passes; Debug stats overlay; Dump writes state ZON. *Web (replay viewer parity).* Embedded dump; focus, border/move drags, wheel, -scrollbar, Colors/NextColor, pinch + touch (two-finger = middle), render -scale, link-look opens a new tab (new — the prototype has no web link -handling). SDL shells: stb_truetype atlas over the SDL GPU API (SPIR-V on -native, GLES3 on web), letterboxed web scaling, two-finger drag = wheel, tap -= middle click, pinch, touch debug overlay, `PARDES_TEST` PPM capture and -`PARDES_TEST_GRID` text-grid capture. Joystick cursor for the steamdeck is -*new* scope — the prototype ships no gamepad input. +scrollbar, Colors/NextColor, pinch, render scale, and link-LOOK opening a new +tab (new — the prototype has no web link handling). One-finger touch is +deliberately complete by itself: tap = LOOK; drag past a small slop = natural +scroll, with no LOOK on release. A build-generated read-only archive lets LOOK +open the current contents of Git-tracked Pardes `.zig` files despite the web +shell having no host filesystem. The published launcher dump is captured from +a running Pardes TTY after `git ls-files '*.zig'`, so its complete terminal +listing is the set the user can open; the published web build carries only the +Zig tree-sitter grammar and colors opened source panes without shipping unused +C/C++ parsers. A body gesture retains tap-LOOK/drag-scroll, but finger-down on +a tagline or a one-cell-tolerant pane separator latches to a left-mouse gesture +for its lifetime, keeping layout drags out of the scroll heuristic. Touch +circles, trails, and click flashes render only while Debug is enabled. SDL +shells use an +stb_truetype atlas over the SDL GPU API (SPIR-V on native, GLES3 on web), +letterboxed web scaling, pinch, and a touch debug overlay. `PARDES_TEST` +provides PPM and text-grid capture; the browser `.snap` harness drives real +Chromium touch input and reads both text and per-cell styles from the rendered +surface. Joystick cursor for the steamdeck is *new* scope — the prototype ships +no gamepad input. diff --git a/src/gui.zig b/src/gui.zig index a53dfa24..f170e1a6 100644 --- a/src/gui.zig +++ b/src/gui.zig @@ -100,10 +100,13 @@ const Slot = struct { u: u32, v: u32 }; const max_touch_points: usize = 16; const max_touch_trail_points: usize = 8; const overlay_circle_vertices: usize = 16 * 3; -const touch_middle_flash_max_frames: u8 = 14; -const touch_middle_flash_vertices: usize = 1400; +const touch_click_flash_max_frames: u8 = 14; +const touch_click_flash_vertices: usize = 1400; const touch_scroll_tick: f32 = 0.02; -const max_overlay_vertices: usize = touch_middle_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices); +/// Web tap-vs-scroll heuristic, measured after normalized coordinates have +/// been mapped into backing pixels (12px = 8 CSS px at web_render_scale). +const touch_tap_slop_px: f32 = 12.0; +const max_overlay_vertices: usize = touch_click_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices); // One instance per cell; the vertex shader expands it to a 2-triangle quad // with gl_VertexIndex. Coords are NDC (y up), uv into the atlas, colors 0..1. @@ -127,7 +130,7 @@ const CellInstance = extern struct { const OverlayVertex = extern struct { x: f32, y: f32, r: f32, g: f32, b: f32, a: f32 }; const OverlayColor = struct { r: f32, g: f32, b: f32, a: f32 }; -// ---- touch: per-finger tracking + the two-finger scroll/tap machine ---- +// ---- touch: per-finger tracking + shared scroll/tap machines ---- const TouchSample = struct { x: f32 = 0, y: f32 = 0, pressure: f32 = 1 }; @@ -157,8 +160,22 @@ const Touch = struct { scrolled: bool = false, last_center: TouchNormPoint = .{}, } = .{}, - middle_clicks: u32 = 0, - middle_flash_frames: u8 = 0, + // Web policy: one primary finger is either a body tap/scroll or a latched + // left-mouse gesture when it begins on layout chrome. A second simultaneous + // finger cancels a body tap rather than producing a click. + single: struct { + active: bool = false, + id: u64 = 0, + start: TouchNormPoint = .{}, + last: TouchNormPoint = .{}, + accum_y: f32 = 0, + scrolling: bool = false, + mouse_drag: bool = false, + multitouch: bool = false, + } = .{}, + click_count: u32 = 0, + click_button: pardes.Mouse.Button = .middle, + click_flash_frames: u8 = 0, fn findSlot(t: *const Touch, id: u64) ?usize { for (&t.points, 0..) |*tp, i| if (tp.active and tp.id == id) return i; @@ -187,6 +204,12 @@ const Touch = struct { } } + fn activeCount(t: *const Touch) usize { + var count: usize = 0; + for (&t.points) |*tp| count += @intFromBool(tp.active); + return count; + } + fn pairCenter(t: *const Touch, ids: [2]u64) ?TouchNormPoint { var sum: TouchNormPoint = .{}; for (ids) |id| { @@ -250,20 +273,174 @@ const Touch = struct { const delta_y = center.y - t.scroll.last_center.y; t.scroll.last_center = center; t.scroll.accum_y += delta_y; - var ticks: i32 = 0; - while (t.scroll.accum_y >= touch_scroll_tick) : (ticks += 1) t.scroll.accum_y -= touch_scroll_tick; - while (t.scroll.accum_y <= -touch_scroll_tick) : (ticks -= 1) t.scroll.accum_y += touch_scroll_tick; + const ticks = takeScrollTicks(&t.scroll.accum_y); if (ticks == 0) return null; t.scroll.scrolled = true; return .{ .center = center, .ticks = ticks, .delta_y = @as(f32, @floatFromInt(ticks)) * touch_scroll_tick }; } + + fn beginSingle(t: *Touch, f: Finger, mouse_drag: bool) void { + if (t.single.active) { + if (t.single.id != f.id) t.single.multitouch = true; + return; + } + if (t.activeCount() != 1) return; + const point = TouchNormPoint{ .x = f.x, .y = f.y }; + t.single = .{ .active = true, .id = f.id, .start = point, .last = point, .mouse_drag = mouse_drag }; + } + + fn noteSingleMotion(t: *Touch, f: Finger, win_w: f32, win_h: f32) ?struct { center: TouchNormPoint, ticks: i32, delta_y: f32 } { + if (!t.single.active or t.single.id != f.id or t.single.multitouch) return null; + const center = TouchNormPoint{ .x = f.x, .y = f.y }; + var delta_y = center.y - t.single.last.y; + t.single.last = center; + if (!t.single.scrolling) { + const dx = (center.x - t.single.start.x) * win_w; + const dy = (center.y - t.single.start.y) * win_h; + if (dx * dx + dy * dy < touch_tap_slop_px * touch_tap_slop_px) return null; + // Once the pointer leaves the tap slop it can never click. Include + // all travel since finger-down so the first scroll tick is prompt. + t.single.scrolling = true; + delta_y = center.y - t.single.start.y; + } + t.single.accum_y += delta_y; + const ticks = takeScrollTicks(&t.single.accum_y); + if (ticks == 0) return null; + return .{ .center = center, .ticks = ticks, .delta_y = @as(f32, @floatFromInt(ticks)) * touch_scroll_tick }; + } + + fn finishSingle(t: *Touch, f: Finger, win_w: f32, win_h: f32) ?TouchNormPoint { + if (!t.single.active or t.single.id != f.id) return null; + const point = TouchNormPoint{ .x = f.x, .y = f.y }; + const dx = (point.x - t.single.start.x) * win_w; + const dy = (point.y - t.single.start.y) * win_h; + const left_slop = dx * dx + dy * dy >= touch_tap_slop_px * touch_tap_slop_px; + const tap = f.kind == .up and !left_slop and !t.single.scrolling and !t.single.mouse_drag and !t.single.multitouch; + t.single = .{}; + return if (tap) point else null; + } }; -/// Two-finger drag → synthesized wheel presses + a touch_scroll event; a -/// two-finger tap → synthesized middle press+release. win/cell dims map -/// normalized coords to cells (grid test mode passes cols/rows with cell=1). +fn takeScrollTicks(accum: *f32) i32 { + var ticks: i32 = 0; + while (accum.* >= touch_scroll_tick) : (ticks += 1) accum.* -= touch_scroll_tick; + while (accum.* <= -touch_scroll_tick) : (ticks -= 1) accum.* += touch_scroll_tick; + return ticks; +} + +/// The SDL boundary owns touch policy: a web body uses one-finger natural +/// scrolling and tap-as-LOOK, while layout chrome latches a left-mouse drag; +/// native keeps two-finger scroll/tap-as-execute. Both feed the same core mouse +/// events and coordinate mapping. fn handleFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void { std.debug.assert(cell_w > 0 and cell_h > 0); + if (is_emscripten) + return handleSingleFinger(t, core, f, win_w, win_h, cell_w, cell_h); + return handlePairFinger(t, core, f, win_w, win_h, cell_w, cell_h); +} + +fn handleSingleFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void { + switch (f.kind) { + .down => { + t.updatePoint(f); + const point = TouchNormPoint{ .x = f.x, .y = f.y }; + const col = normCell(point.x, win_w, cell_w); + const row = normCell(point.y, win_h, cell_h); + const chrome = webChromeTarget(core, col, row); + t.beginSingle(f, chrome != null); + if (chrome) |target| + core.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = target.col, .row = target.row } }); + }, + .motion => { + t.updatePoint(f); + if (t.single.active and t.single.id == f.id and t.single.mouse_drag) { + core.update(.{ .mouse = .{ + .button = .left, + .kind = .drag, + .col = normCell(f.x, win_w, cell_w), + .row = normCell(f.y, win_h, cell_h), + } }); + return; + } + const res = t.noteSingleMotion(f, win_w, win_h) orelse return; + emitTouchScroll(core, res.center, res.ticks, res.delta_y, win_w, win_h, cell_w, cell_h); + }, + .up, .cancel => { + const mouse_drag = t.single.active and t.single.id == f.id and t.single.mouse_drag; + const tap = t.finishSingle(f, win_w, win_h); + t.updatePoint(f); + if (mouse_drag) { + core.update(.{ .mouse = .{ + .button = .left, + .kind = .release, + .col = normCell(f.x, win_w, cell_w), + .row = normCell(f.y, win_h, cell_h), + } }); + } else if (tap) |point| emitTouchClick(t, core, point, .right, win_w, win_h, cell_w, cell_h); + }, + } +} + +const WebChromeTarget = struct { col: u16, row: u16 }; + +/// Fat-finger hit test for web layout chrome. A gesture is classified once at +/// finger-down, then never changes into scrolling. Handle coordinates are +/// snapped onto the core's one-cell resize edge; body rows remain untouched. +fn webChromeTarget(core: *const pardes.Pardes, col: u16, row: u16) ?WebChromeTarget { + if (row < pardes.TOPBAR_H) return .{ .col = col, .row = row }; + + // The move box wins over a horizontal handle when a pane is tag-only, + // matching the core's own mouse hit-test priority. + for (core.panes, 0..) |slot, id| { + if (slot == null) continue; + const rect = core.rects[id]; + if (row == rect.y and col >= rect.x and col < rect.x + rect.w and col < rect.x + pardes.GUTTER) + return .{ .col = col, .row = rect.y }; + } + + for (0..core.ncol -| 1) |column| { + const handle = core.col_x[column] + core.col_w[column] -| 1; + if (col == handle) return .{ .col = handle, .row = row }; + } + for (0..core.ncol) |column| { + if (col < core.col_x[column] or col >= core.col_x[column] + core.col_w[column]) continue; + for (0..core.col_n[column] -| 1) |index| { + const rect = core.rects[core.col_terms[column][index]]; + const handle = rect.y + rect.h -| 1; + if (row == handle) return .{ .col = col, .row = handle }; + } + } + + // The remainder of each tag row is tag editing, not a scrollable body. + for (core.panes, 0..) |slot, id| { + if (slot == null) continue; + const rect = core.rects[id]; + if (row == rect.y and col >= rect.x and col < rect.x + rect.w) + return .{ .col = col, .row = rect.y }; + } + + // One-cell tolerance around thin separators, after exact tag rows have had + // first refusal so a fat-finger tag edit never turns into a resize. + for (0..core.ncol -| 1) |column| { + const handle = core.col_x[column] + core.col_w[column] -| 1; + if (cellDistance(col, handle) == 1) return .{ .col = handle, .row = row }; + } + for (0..core.ncol) |column| { + if (col < core.col_x[column] or col >= core.col_x[column] + core.col_w[column]) continue; + for (0..core.col_n[column] -| 1) |index| { + const rect = core.rects[core.col_terms[column][index]]; + const handle = rect.y + rect.h -| 1; + if (cellDistance(row, handle) == 1) return .{ .col = col, .row = handle }; + } + } + return null; +} + +fn cellDistance(a: u16, b: u16) u16 { + return if (a > b) a - b else b - a; +} + +fn handlePairFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void { switch (f.kind) { .down => { t.updatePoint(f); @@ -275,30 +452,37 @@ fn handleFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f t.syncPair(); if (!had_pair) return; const res = t.noteMotion(f) orelse return; - const col = normCell(res.center.x, win_w, cell_w); - const row = normCell(res.center.y, win_h, cell_h); - const button: pardes.Mouse.Button = if (res.ticks > 0) .wheel_up else .wheel_down; - var remaining: u32 = @abs(res.ticks); - while (remaining > 0) : (remaining -= 1) - core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = col, .row = row } }); - core.update(.{ .touch_scroll = res.delta_y }); + emitTouchScroll(core, res.center, res.ticks, res.delta_y, win_w, win_h, cell_w, cell_h); }, .up, .cancel => { const tap_center = t.finishPair(f); t.updatePoint(f); t.syncPair(); - if (tap_center) |center| { - t.middle_clicks +%= 1; - t.middle_flash_frames = touch_middle_flash_max_frames; - const col = normCell(center.x, win_w, cell_w); - const row = normCell(center.y, win_h, cell_h); - core.update(.{ .mouse = .{ .button = .middle, .kind = .press, .col = col, .row = row } }); - core.update(.{ .mouse = .{ .button = .middle, .kind = .release, .col = col, .row = row } }); - } + if (tap_center) |center| emitTouchClick(t, core, center, .middle, win_w, win_h, cell_w, cell_h); }, } } +fn emitTouchScroll(core: *pardes.Pardes, center: TouchNormPoint, ticks: i32, delta_y: f32, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void { + const col = normCell(center.x, win_w, cell_w); + const row = normCell(center.y, win_h, cell_h); + const button: pardes.Mouse.Button = if (ticks > 0) .wheel_up else .wheel_down; + var remaining: u32 = @abs(ticks); + while (remaining > 0) : (remaining -= 1) + core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = col, .row = row } }); + core.update(.{ .touch_scroll = delta_y }); +} + +fn emitTouchClick(t: *Touch, core: *pardes.Pardes, point: TouchNormPoint, button: pardes.Mouse.Button, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void { + t.click_count +%= 1; + t.click_button = button; + t.click_flash_frames = touch_click_flash_max_frames; + const col = normCell(point.x, win_w, cell_w); + const row = normCell(point.y, win_h, cell_h); + core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = col, .row = row } }); + core.update(.{ .mouse = .{ .button = button, .kind = .release, .col = col, .row = row } }); +} + fn normCell(norm: f32, win: f32, cell: f32) u16 { const px = std.math.clamp(norm, 0.0, 1.0) * win; return @intFromFloat(@max(0, @floor(px / cell))); @@ -768,7 +952,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { // 7. render _ = frame_arena.reset(.retain_capacity); const surface = try core.render(frame_arena.allocator()); - renderFrame(&g, gpa, surface, core.crt_on) catch |err| log.err("render: {t}", .{err}); + renderFrame(&g, gpa, surface, core.crt_on, core.show_debug) catch |err| log.err("render: {t}", .{err}); } } @@ -788,11 +972,130 @@ const WebState = struct { gpa: std.mem.Allocator, frame_arena: std.heap.ArenaAllocator, }; +var live_web_state: ?*WebState = null; + +// Browser snapshot tests drive real DOM touch input, then read the exact same +// rendered Surface that WebGL consumed. These tiny exports keep the test +// contract textual (the existing .snap convention) without adding test state +// or browser concepts to the core. +comptime { + if (is_emscripten) { + @export(&webTestCols, .{ .name = "pardes_web_test_cols" }); + @export(&webTestRows, .{ .name = "pardes_web_test_rows" }); + @export(&webTestCursorX, .{ .name = "pardes_web_test_cursor_x" }); + @export(&webTestCursorY, .{ .name = "pardes_web_test_cursor_y" }); + @export(&webTestCell, .{ .name = "pardes_web_test_cell" }); + @export(&webTestCellFg, .{ .name = "pardes_web_test_cell_fg" }); + @export(&webTestCellBg, .{ .name = "pardes_web_test_cell_bg" }); + @export(&webTestCellAttrs, .{ .name = "pardes_web_test_cell_attrs" }); + @export(&webTestCellX, .{ .name = "pardes_web_test_cell_x" }); + @export(&webTestCellY, .{ .name = "pardes_web_test_cell_y" }); + } +} + +fn webTestCols() callconv(.c) c_uint { + const st = live_web_state orelse return 0; + return st.core.surface.cols; +} + +fn webTestRows() callconv(.c) c_uint { + const st = live_web_state orelse return 0; + return st.core.surface.rows; +} + +fn webTestCursorX() callconv(.c) c_int { + const st = live_web_state orelse return -1; + return if (st.core.surface.cursor) |cursor| cursor.x else -1; +} + +fn webTestCursorY() callconv(.c) c_int { + const st = live_web_state orelse return -1; + return if (st.core.surface.cursor) |cursor| cursor.y else -1; +} + +fn webTestCell(col: c_uint, row: c_uint) callconv(.c) c_uint { + const cell = webTestCellAt(col, row) orelse return ' '; + const glyph = cell.grapheme(); + if (glyph.len == 0) return ' '; + return std.unicode.utf8Decode(glyph) catch 0xfffd; +} + +// RGB occupies 0x000000-0xffffff; the high byte distinguishes the two other +// Color tags. The browser .snap harness renders these into d/pN/#rrggbb runs. +fn webTestCellFg(col: c_uint, row: c_uint) callconv(.c) c_uint { + const cell = webTestCellAt(col, row) orelse return 0x01000000; + return webTestColor(cell.style.fg); +} + +fn webTestCellBg(col: c_uint, row: c_uint) callconv(.c) c_uint { + const cell = webTestCellAt(col, row) orelse return 0x01000000; + return webTestColor(cell.style.bg); +} + +fn webTestColor(color: pardes.Color) c_uint { + return switch (color) { + .default => 0x01000000, + .index => |idx| 0x02000000 | @as(c_uint, idx), + .rgb => |rgb| (@as(c_uint, rgb[0]) << 16) | (@as(c_uint, rgb[1]) << 8) | rgb[2], + }; +} + +fn webTestCellAttrs(col: c_uint, row: c_uint) callconv(.c) c_uint { + const cell = webTestCellAt(col, row) orelse return 0; + const style = cell.style; + var attrs: c_uint = 0; + if (style.bold) attrs |= 1 << 0; + if (style.dim) attrs |= 1 << 1; + if (style.italic) attrs |= 1 << 2; + if (style.blink) attrs |= 1 << 3; + if (style.reverse) attrs |= 1 << 4; + if (style.invisible) attrs |= 1 << 5; + if (style.strikethrough) attrs |= 1 << 6; + attrs |= @as(c_uint, @intFromEnum(style.ul)) << 8; + return attrs; +} + +fn webTestCellAt(col: c_uint, row: c_uint) ?*const pardes.Cell { + const st = live_web_state orelse return null; + const surface = &st.core.surface; + if (col >= surface.cols or row >= surface.rows) return null; + return surface.at(@intCast(col), @intCast(row)); +} + +fn webTestCellX(col: f32) callconv(.c) f32 { + return webTestCellCenter(col, true); +} + +fn webTestCellY(row: f32) callconv(.c) f32 { + return webTestCellCenter(row, false); +} + +fn webTestCellCenter(cell: f32, horizontal: bool) f32 { + const st = live_web_state orelse return 0; + var pixel_w: c_int = 0; + var pixel_h: c_int = 0; + _ = c.SDL_GetWindowSizeInPixels(st.g.window, &pixel_w, &pixel_h); + const pw: u32 = @intCast(@max(pixel_w, 1)); + const ph: u32 = @intCast(@max(pixel_h, 1)); + var css_w: f64 = 0; + var css_h: f64 = 0; + if (emscripten_get_element_css_size("#canvas", &css_w, &css_h) != EMSCRIPTEN_RESULT_SUCCESS) return 0; + const layout = cellLayout(&st.g, st.core.surface.cols, st.core.surface.rows, pw, ph); + if (horizontal) { + const backing = layout.x_off + (cell + 0.5) * layout.w; + return backing * @as(f32, @floatCast(css_w)) / @as(f32, @floatFromInt(pw)); + } + const backing = layout.y_off + (cell + 0.5) * layout.h; + return backing * @as(f32, @floatCast(css_h)) / @as(f32, @floatFromInt(ph)); +} fn runWeb(opts_in: pardes.Options) !void { web_fba = .init(&web_heap); const gpa = web_fba.allocator(); + // Our finger machine deliberately maps touch to scroll/LOOK. Suppress + // SDL's default synthetic left mouse event for the same browser touch. + _ = c.SDL_SetHint(c.SDL_HINT_TOUCH_MOUSE_EVENTS, "0"); if (!c.SDL_Init(c.SDL_INIT_VIDEO)) { // no gamepad in the browser log.err("SDL_Init: {s}", .{c.SDL_GetError()}); return error.SdlInit; @@ -900,6 +1203,7 @@ fn runWeb(opts_in: pardes.Options) !void { opts.cols = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), cell_w))); opts.rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), cell_h))); st.core = try pardes.Pardes.initFromDump(gpa, opts, embedded_dump); + live_web_state = st; _ = c.SDL_StartTextInput(window); std.os.emscripten.emscripten_set_main_loop_arg(webFrame, st, 0, 0); @@ -927,7 +1231,7 @@ fn webFrame(arg: ?*anyopaque) callconv(.c) void { // 4. render _ = st.frame_arena.reset(.retain_capacity); const surface = core.render(st.frame_arena.allocator()) catch return; - renderFrameGl(g, st.gpa, surface) catch return; + renderFrameGl(g, st.gpa, surface, core.show_debug) catch return; } /// the canvas follows the page (CSS); the SDL window follows the canvas, @@ -1382,7 +1686,7 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void { } if (is_emscripten) { // remap normalized coords into the letterboxed grid so the - // shared two-finger machine's cell math stays exact + // shared touch machine's cell math stays exact const layout = cellLayout(g, core.screen_w, core.screen_h, @intCast(@max(pw, 1)), @intCast(@max(ph, 1))); const grid_w = @max(1.0, @as(f32, @floatFromInt(core.screen_w)) * layout.w); const grid_h = @max(1.0, @as(f32, @floatFromInt(core.screen_h)) * layout.h); @@ -1772,7 +2076,7 @@ fn openLinkWeb(gpa: std.mem.Allocator, url: []const u8) void { // render: Surface → instanced quads → SDL GPU // ===================================================================== -fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, crt_on: bool) !void { +fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, crt_on: bool, debug_on: bool) !void { const cmd = c.SDL_AcquireGPUCommandBuffer(g.device) orelse return; var sw: u32 = 0; var sh: u32 = 0; @@ -1806,7 +2110,7 @@ fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, crt_on if (crt_on) try ensureSceneTexture(g, sw, sh); const scene: *c.SDL_GPUTexture = if (crt_on) g.scene_tex.? else target; - const overlay_count = buildTouchOverlay(g, sw, sh); + const overlay_count = buildTouchOverlay(g, sw, sh, debug_on); if (overlay_count != 0) uploadOverlayGpu(g, cmd, overlay_count); const cells: u32 = @as(u32, surface.cols) * surface.rows; @@ -2135,7 +2439,7 @@ fn makeShader(device: *c.SDL_GPUDevice, code: []const u8, stage: c.SDL_GPUShader // re-upload each frame; attribs re-specified per draw (no VAO to manage). // ===================================================================== -fn renderFrameGl(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface) !void { +fn renderFrameGl(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, debug_on: bool) !void { var pw: c_int = 0; var ph: c_int = 0; _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); @@ -2150,7 +2454,7 @@ fn renderFrameGl(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface) !voi ); c.glClear(c.GL_COLOR_BUFFER_BIT); - const overlay_count = buildTouchOverlay(g, sw, sh); + const overlay_count = buildTouchOverlay(g, sw, sh, debug_on); const cells: u32 = @as(u32, surface.cols) * surface.rows; if (cells == 0) { drawOverlayGl(g, overlay_count); @@ -2376,20 +2680,24 @@ fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u } // ===================================================================== -// touch debug overlay: per-finger colored circles + trails + a middle-click -// flash HUD, alpha-blended over the grid. Always active on touch input. +// touch debug overlay: per-finger colored circles + trails + a click-action +// flash HUD, alpha-blended over the grid only while the Debug builtin is on. // ===================================================================== -fn buildTouchOverlay(g: *Gui, sw: u32, sh: u32) u32 { +fn buildTouchOverlay(g: *Gui, sw: u32, sh: u32, debug_on: bool) u32 { + if (!debug_on) { + g.touch.click_flash_frames = 0; + return 0; + } if (g.overlay_vertices.len == 0 or sw == 0 or sh == 0) return 0; const win_w: f32 = @floatFromInt(sw); const win_h: f32 = @floatFromInt(sh); var builder = OverlayBuilder{ .vertices = g.overlay_vertices, .win_w = win_w, .win_h = win_h }; const t = &g.touch; - if (t.middle_flash_frames != 0) { - const flash = @as(f32, @floatFromInt(t.middle_flash_frames)) / @as(f32, @floatFromInt(touch_middle_flash_max_frames)); - const label = touchMiddleClickLabel(t.middle_clicks); + if (t.click_flash_frames != 0) { + const flash = @as(f32, @floatFromInt(t.click_flash_frames)) / @as(f32, @floatFromInt(touch_click_flash_max_frames)); + const label = touchClickLabel(t.click_count, t.click_button); const glyph_scale = 3.0; const char_step = 4.0 * glyph_scale; const pad = 5.0; @@ -2400,7 +2708,7 @@ fn buildTouchOverlay(g: *Gui, sw: u32, sh: u32) u32 { builder.addRect(8.0, 8.0, 8.0 + label_w, 8.0 + label_h, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.54 + 0.22 * flash }); builder.addRect(8.0 + pad, 8.0 + pad, 8.0 + pad + stripe_w, 8.0 + label_h - pad, .{ .r = 1.0, .g = 0.78, .b = 0.18, .a = 0.96 }); builder.addMiniText(label[0..], 8.0 + pad + stripe_w + 3.0, 8.0 + pad, glyph_scale, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.96 }); - t.middle_flash_frames -= 1; + t.click_flash_frames -= 1; } for (&t.points) |*tp| { @@ -2573,12 +2881,12 @@ fn touchIdLabel(id: u64) [9]u8 { return out; } -fn touchMiddleClickLabel(count: u32) [13]u8 { - var out: [13]u8 = undefined; - @memcpy(out[0..5], "MID #"); +fn touchClickLabel(count: u32, button: pardes.Mouse.Button) [14]u8 { + var out: [14]u8 = undefined; + @memcpy(out[0..6], if (button == .right) "LOOK #" else "MID #"); for (0..8) |i| { const shift: u5 = @intCast((7 - i) * 4); - out[i + 5] = hexDigit((count >> shift) & 0xf); + out[i + 6] = hexDigit((count >> shift) & 0xf); } return out; } @@ -2607,7 +2915,10 @@ fn miniGlyph(ch: u8) [5]u8 { 'E' => .{ 0b111, 0b100, 0b111, 0b100, 0b111 }, 'F' => .{ 0b111, 0b100, 0b111, 0b100, 0b100 }, 'I' => .{ 0b111, 0b010, 0b010, 0b010, 0b111 }, + 'K' => .{ 0b101, 0b101, 0b110, 0b101, 0b101 }, + 'L' => .{ 0b100, 0b100, 0b100, 0b100, 0b111 }, 'M' => .{ 0b101, 0b111, 0b111, 0b101, 0b101 }, + 'O' => .{ 0b111, 0b101, 0b101, 0b101, 0b111 }, else => .{ 0, 0, 0, 0, 0 }, }; } diff --git a/src/look.zig b/src/look.zig index 8744da24..fb74353f 100644 --- a/src/look.zig +++ b/src/look.zig @@ -5,12 +5,16 @@ //! os.tag, so every platform's behavior sits in the same screenful: //! tty/gui — the word resolves through the real filesystem (realpath, //! open(O_DIRECTORY)); dirs open shells, files open file panes. -//! web — there is no filesystem; only URLs mean anything, and a URL -//! opens in a new tab (the open_link effect). +//! web — tracked Pardes .zig sources form a build-generated read-only +//! filesystem; URLs still open in a new tab. const std = @import("std"); const builtin = @import("builtin"); const libc = std.c; const pardes = @import("pardes.zig"); +const embedded_sources = if (pardes.platform == .web) @import("embedded_sources") else struct { + pub const Source = struct { path: []const u8, contents: []const u8 }; + pub const all = [_]Source{}; +}; extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8; @@ -55,7 +59,8 @@ pub fn isImagePath(path: []const u8) bool { } /// Resolve a looked-at word against the pane's directory. `realbuf` must -/// outlive the returned Target (dir/file/image paths point into it). +/// outlive the returned Target (native paths point into it; web paths are +/// process-lifetime slices in the embedded source archive). pub fn resolve(word_raw: []const u8, cwd: []const u8, realbuf: *[4096]u8) Target { const trimmed = std.mem.trim(u8, word_raw, " \t\r\n"); const pl = parsePathLine(trimmed); @@ -75,13 +80,68 @@ pub fn resolve(word_raw: []const u8, cwd: []const u8, realbuf: *[4096]u8) Target if (isImagePath(resolved)) return .{ .image = .{ .path = resolved } }; return .{ .file = .{ .path = resolved, .line = pl.line } }; } else { - // web: no filesystem. A URL opens in a new tab; everything else is inert. + // web: URLs leave the app; tracked Zig sources resolve inside the + // build-generated, read-only source filesystem. if (std.mem.startsWith(u8, trimmed, "http://") or std.mem.startsWith(u8, trimmed, "https://")) return .{ .url = trimmed }; + if (resolveEmbedded(word, cwd, realbuf)) |source| + return .{ .file = .{ .path = source.path, .line = pl.line } }; return .none; } } +/// Resolve a source path without teaching the core about a browser filesystem. +/// Cwd-relative and absolute dump paths are normalized, with printed archive +/// paths also accepted root-relative. The suffix match lets a dump made in +/// `/host/repo` address names that deliberately remain relative to the root. +fn resolveEmbedded(word: []const u8, cwd: []const u8, scratch: *[4096]u8) ?embedded_sources.Source { + var wordbuf: [4096]u8 = undefined; + const normalized_word = normalizeVirtualPath(word, &wordbuf) orelse return null; + if (word.len > 0 and word[0] == '/') return findEmbeddedSource(normalized_word, true); + + var joined: [4096]u8 = undefined; + if (std.fmt.bufPrint(&joined, "{s}/{s}", .{ cwd, word }) catch null) |candidate| + if (normalizeVirtualPath(candidate, scratch)) |normalized| + if (findEmbeddedSource(normalized, true)) |source| return source; + // A printed archive path is root-relative even when its surrounding dump + // pane came from some unrelated cwd. + return findEmbeddedSource(normalized_word, false); +} + +fn normalizeVirtualPath(path: []const u8, out: *[4096]u8) ?[]const u8 { + var len: usize = 0; + var parts = std.mem.tokenizeAny(u8, path, "/\\"); + while (parts.next()) |part| { + if (std.mem.eql(u8, part, ".")) continue; + if (std.mem.eql(u8, part, "..")) { + while (len > 0 and out[len - 1] != '/') len -= 1; + if (len > 0) len -= 1; + continue; + } + const extra = part.len + @intFromBool(len != 0); + if (len + extra > out.len) return null; + if (len != 0) { + out[len] = '/'; + len += 1; + } + @memcpy(out[len..][0..part.len], part); + len += part.len; + } + if (len == 0) return null; + return out[0..len]; +} + +fn findEmbeddedSource(path: []const u8, allow_root_suffix: bool) ?embedded_sources.Source { + for (embedded_sources.all) |source| + if (std.mem.eql(u8, source.path, path)) return source; + if (!allow_root_suffix) return null; + for (embedded_sources.all) |source| { + if (path.len <= source.path.len or path[path.len - source.path.len - 1] != '/') continue; + if (std.mem.endsWith(u8, path, source.path)) return source; + } + return null; +} + const platform_has_fs = switch (pardes.platform) { .tty, .gui => true, .web => false, @@ -95,10 +155,15 @@ fn isDir(path: [*:0]const u8) bool { return true; } -/// read a whole file (gpa-owned) — the look side of opening a file pane. -/// Compiled out on web (no fs); dumps provide file content there instead. +/// Read a whole file (gpa-owned) — the look side of opening a file pane. Web +/// reads from the generated source archive; native shells read the real fs. pub fn readFile(gpa: std.mem.Allocator, path: []const u8) ![]u8 { - if (!platform_has_fs) return error.NoFilesystem; + if (!platform_has_fs) { + var normalized_buf: [4096]u8 = undefined; + const normalized = normalizeVirtualPath(path, &normalized_buf) orelse return error.OpenFailed; + const source = findEmbeddedSource(normalized, true) orelse return error.OpenFailed; + return gpa.dupe(u8, source.contents); + } var pathbuf: [4096]u8 = undefined; const path_z = std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0) catch return error.PathTooLong; const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY }); diff --git a/src/syntax.zig b/src/syntax.zig index 0d2fa291..bbab17fa 100644 --- a/src/syntax.zig +++ b/src/syntax.zig @@ -1,12 +1,13 @@ //! Tree-sitter syntax highlighting: one style byte per content byte, filled by //! running each grammar's highlights.scm query (slurped at build time into the -//! ts_queries options module). Grammar set is tiered: `minimal` (c, cpp, zig) -//! compiles queries at startup; `full` adds ~23 languages lazily on first use. +//! ts_queries options module). Grammar set is tiered: `zig`; `minimal` (c, cpp, +//! zig); and `full`, which adds ~23 languages lazily on first use. const std = @import("std"); const config = @import("pardes_config"); const tracy = @import("tracy.zig"); pub const enabled = config.syntax_highlighting; +const zig_grammar = config.syntax_zig_grammar; const minimal_grammars = config.syntax_minimal_grammars; const full_grammars = config.syntax_full_grammars; @@ -52,10 +53,13 @@ const Spec = struct { compiled_query: ?*ts.Query = null, }; +var zig_specs = if (zig_grammar) [_]Spec{ + .{ .exts = &.{ ".zig", ".zon" }, .language = tree_sitter_zig, .query_src = ts_queries.zig_highlights }, +} else [_]Spec{}; + var minimal_specs = if (minimal_grammars) [_]Spec{ .{ .exts = &.{ ".c", ".h" }, .language = tree_sitter_c, .query_src = ts_queries.c_highlights }, .{ .exts = &.{ ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx" }, .language = tree_sitter_cpp, .query_src = ts_queries.cpp_highlights }, - .{ .exts = &.{ ".zig", ".zon" }, .language = tree_sitter_zig, .query_src = ts_queries.zig_highlights }, } else [_]Spec{}; var full_specs = if (full_grammars) [_]Spec{ @@ -98,6 +102,11 @@ fn ensure(spec: *Spec) !Selected { } fn forExt(ext: []const u8) !?Selected { + for (&zig_specs) |*spec| { + for (spec.exts) |choice| { + if (std.ascii.eqlIgnoreCase(ext, choice)) return try ensure(spec); + } + } for (&minimal_specs) |*spec| { for (spec.exts) |choice| { if (std.ascii.eqlIgnoreCase(ext, choice)) return try ensure(spec); @@ -111,9 +120,10 @@ fn forExt(ext: []const u8) !?Selected { return null; } -/// compile the minimal-tier queries eagerly so the first .zig/.c open is fast +/// Compile the compact-tier queries eagerly so the first source open is fast. pub fn compileStartupQueries() void { if (!enabled) return; + for (&zig_specs) |*spec| _ = ensure(spec) catch {}; for (&minimal_specs) |*spec| _ = ensure(spec) catch {}; } diff --git a/src/tutor.txt b/src/tutor.txt index 7a8c7026..e2dfb123 100644 --- a/src/tutor.txt +++ b/src/tutor.txt @@ -66,6 +66,20 @@ opens/focuses a terminal there and ls's it; a file opens a file pane (scrolled to a ":line" suffix if present). + WEB TOUCH (one finger): a tap is LOOK, the same action as R; a drag + past a small movement threshold is natural scrolling. + Once a gesture becomes a scroll it cannot also LOOK on + release. The browser has no host filesystem or ptys: + LOOK opens URLs or read-only Pardes .zig sources that + were Git-tracked and embedded when the web build was made. + The published launcher is a real Pardes terminal dump of + `git ls-files '*.zig'`: every listed path opens, and the + opened source uses tree-sitter syntax colors. + A touch beginning on a tagline or pane separator instead + latches a left-mouse gesture, so layout drags never scroll. + Touch circles/trails and the LOOK flash appear only while + the Debug builtin is on. + SELECT-THEN-ACT: - MIDDLE-drag over text selects AND runs it on release (one gesture); a no-drag middle click auto-expands to the word under the cursor. @@ -119,9 +133,9 @@ - the scrollbar is the gutter below the box: left-click scrolls UP to that point, right-click scrolls DOWN - (Mouse actions are hard to unit-test from text; they're covered by - the e2e suite: middle-click execute, right-click dir/file open, tag - builtins, drag-rearrange. See tests.zig steps 6-8.) + (Mouse actions are hard to unit-test from text; native gestures are + covered by the e2e suite. Browser touch and embedded-source LOOK are + driven through real Chromium input by `zig build web-snap`.) ================================================================= @@ -418,6 +432,10 @@ 2-1 = middle-exec with the left selection as argument file tag = mode + path + "Save Del"; other tags show Del top bar = Kill Newcol Tutor Debug Colors NextColor + WEB TOUCH: one-finger tap = LOOK; drag = natural scroll + LOOK opens URLs or embedded tracked .zig source (read-only) + published TTY dump lists every source; tree-sitter colors it + tag/separator start = left drag; touch HUD requires Debug TTY: a terminal IS a pane; Ctrl-b toggles the live shell by default, landing its cursor where you navigated (prompt start if the input is empty, mid-text otherwise) diff --git a/test/web-snapshots/README.md b/test/web-snapshots/README.md new file mode 100644 index 00000000..d2ea84f8 --- /dev/null +++ b/test/web-snapshots/README.md @@ -0,0 +1,18 @@ +# Web browser snapshots + +`source-list.dump.zon` is a byte-for-byte dump from a running Pardes instance. +It is not assembled or normalized by a fixture generator. + +To recapture it: + +1. Build and launch the native TTY in this checkout with `PARDES_DUMP` pointing + at a temporary file. +2. Enter TTY mode, run `clear`, then run `git ls-files '*.zig'`. +3. Leave the pane in TTY mode and middle-click Pardes's `Dump` builtin. +4. Copy the resulting dump into `source-list.dump.zon` without editing it. + +The visible `.body` must exactly match the command plus every path reported by +Git (and the final prompt). The base64 PTY stream must contain the command +itself. `source-list.snap` then drives that dump through Chromium: it checks the +full listing, taps `build.zig`, snapshots tree-sitter style runs, resizes the +pane separator, and finally verifies body scrolling. diff --git a/test/web-snapshots/source-list.dump.zon b/test/web-snapshots/source-list.dump.zon new file mode 100644 index 00000000..02e1eb43 --- /dev/null +++ b/test/web-snapshots/source-list.dump.zon @@ -0,0 +1,26 @@ +.{ + .magic = "pardes-dump", + .version = 1, + .screen = .{ .cols = 100, .rows = 30 }, + .active = 0, + .topbar = "Kill Newcol Tutor Debug Colors NextColor Crt Dump", + .theme = "dark", + .columns = .{.{ .weight = 1, .panes = .{0} }}, + .panes = .{.{ + .kind = .terminal, + .tag = "TTY /home/goblin/00-projects/0x4200.cafe/02-pardes-code Del", + .body = "$ git ls-files '*.zig'\nbuild.zig\nsrc/crt.zig\nsrc/deck.zig\nsrc/dump.zig\nsrc/gui.zig\nsrc/image.zig\nsrc/look.zig\nsrc/main.zig\nsrc/modal.zig\nsrc/pardes.zig\nsrc/petscii.zig\nsrc/syntax.zig\nsrc/tracy.zig\nsrc/tty.zig\ntest/deck/replay.zig\ntest/deck/schema.zig\ntest/e2e_harness.zig\ntest/hxdiff.zig\ntest/snapshot.zig\nuucode_config.zig\n$ \n\n\n\n\n\n", + .scroll = 0, + .cols = 98, + .rows = 28, + .vweight = 1, + .terminal = .{ + .cwd = "/home/goblin/00-projects/0x4200.cafe/02-pardes-code", + .stream = "$ git ls-files '*.zig'\nbuild.zig\nsrc/crt.zig\nsrc/deck.zig\nsrc/dump.zig\nsrc/gui.zig\nsrc/image.zig\nsrc/look.zig\nsrc/main.zig\nsrc/modal.zig\nsrc/pardes.zig\nsrc/petscii.zig\nsrc/syntax.zig\nsrc/tracy.zig\nsrc/tty.zig\ntest/deck/replay.zig\ntest/deck/schema.zig\ntest/e2e_harness.zig\ntest/hxdiff.zig\ntest/snapshot.zig\nuucode_config.zig\n$ ", + .stream_b64 = "G10xMzM7QwcbXTEzMztEBxtdMTMzO0MHG10wO2dvYmxpbkBwYXJkZXM6L2hvbWUvZ29ibGluLzAwLXByb2plY3RzLzB4NDIwMC5jYWZlLzAyLXBhcmRlcy1jb2RlBxtbPzIwMDRoG10xMzM7QTtjbD1saW5lByQgG10xMzM7QgcNG1tLDRtdMTMzO0E7Y2w9bGluZQckIBtdMTMzO0IHbHMNChtbPzIwMDRsDRtdMTMzO0MHYXNzZXRzCSAgICAgICBkb2NzCQkgICAgbmV4dC1zdGVwcy50eHQgIHRlc3QJICAgICAgdXVjb2RlX2NvbmZpZy56aWcgIHppZy1wa2cNCmJ1aWxkLnppZyAgICAgIG1pc2UudG9tbAkgICAgc2hhZGVycwkgICAgdG9vbHMJICAgICAgdmVuZG9yDQpidWlsZC56aWcuem9uICBuZXh0LW5leHQtc3RlcHMudHh0ICBzcmMJCSAgICB0cmFuc2FjdGlvbnMudHh0ICB6aWctb3V0DQobXTEzMztDBxtdMTMzO0QHG10xMzM7QwcbXTA7Z29ibGluQHBhcmRlczovaG9tZS9nb2JsaW4vMDAtcHJvamVjdHMvMHg0MjAwLmNhZmUvMDItcGFyZGVzLWNvZGUHG1s/MjAwNGgbXTEzMztBO2NsPWxpbmUHJCAbXTEzMztCB2NsZWFyDQobWz8yMDA0bA0bXTEzMztDBxtbSBtbMkobWzNKG10xMzM7QwcbXTEzMztEBxtdMTMzO0MHG10wO2dvYmxpbkBwYXJkZXM6L2hvbWUvZ29ibGluLzAwLXByb2plY3RzLzB4NDIwMC5jYWZlLzAyLXBhcmRlcy1jb2RlBxtbPzIwMDRoG10xMzM7QTtjbD1saW5lByQgG10xMzM7QgdnaXQgbHMtZmlsZXMgJyouemlnJw0KG1s/MjAwNGwNG10xMzM7QwdidWlsZC56aWcNCnNyYy9jcnQuemlnDQpzcmMvZGVjay56aWcNCnNyYy9kdW1wLnppZw0Kc3JjL2d1aS56aWcNCnNyYy9pbWFnZS56aWcNCnNyYy9sb29rLnppZw0Kc3JjL21haW4uemlnDQpzcmMvbW9kYWwuemlnDQpzcmMvcGFyZGVzLnppZw0Kc3JjL3BldHNjaWkuemlnDQpzcmMvc3ludGF4LnppZw0Kc3JjL3RyYWN5LnppZw0Kc3JjL3R0eS56aWcNCnRlc3QvZGVjay9yZXBsYXkuemlnDQp0ZXN0L2RlY2svc2NoZW1hLnppZw0KdGVzdC9lMmVfaGFybmVzcy56aWcNCnRlc3QvaHhkaWZmLnppZw0KdGVzdC9zbmFwc2hvdC56aWcNCnV1Y29kZV9jb25maWcuemlnDQobXTEzMztDBxtdMTMzO0QHG10xMzM7QwcbXTA7Z29ibGluQHBhcmRlczovaG9tZS9nb2JsaW4vMDAtcHJvamVjdHMvMHg0MjAwLmNhZmUvMDItcGFyZGVzLWNvZGUHG1s/MjAwNGgbXTEzMztBO2NsPWxpbmUHJCAbXTEzMztCBw==", + .cursor = .{ .col = 2, .row = 21 }, + }, + .file = null, + .image = null, + }}, +}
\ No newline at end of file diff --git a/test/web-snapshots/source-list.golden b/test/web-snapshots/source-list.golden new file mode 100644 index 00000000..d4321fbb --- /dev/null +++ b/test/web-snapshots/source-list.golden @@ -0,0 +1,190 @@ +== snap launcher grid=120x37 cursor=4,23 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| TTY /home/goblin/00-projects/0x4200.cafe/02-pardes-code Del +| $ git ls-files '*.zig' +| build.zig +| src/crt.zig +| src/deck.zig +| src/dump.zig +| src/gui.zig +| src/image.zig +| src/look.zig +| src/main.zig +| src/modal.zig +| src/pardes.zig +| src/petscii.zig +| src/syntax.zig +| src/tracy.zig +| src/tty.zig +| test/deck/replay.zig +| test/deck/schema.zig +| test/e2e_harness.zig +| test/hxdiff.zig +| test/snapshot.zig +| uucode_config.zig +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap source-open grid=120x37 cursor=7,25 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| TTY /home/goblin/00-projects/0x4200.cafe/02-pardes-code Del +| $ git ls-files '*.zig' +| build.zig +| src/crt.zig +| src/deck.zig +| src/dump.zig +| src/gui.zig +| src/image.zig +| src/look.zig +| src/main.zig +| src/modal.zig +| src/pardes.zig +| src/petscii.zig +| src/syntax.zig +| src/tracy.zig +| src/tty.zig +| test/deck/replay.zig +| test/deck/schema.zig +| test/e2e_harness.zig +| test/hxdiff.zig +| test/snapshot.zig +| uucode_config.zig +| +| NOR build.zig Save Del +| 1 const std = @import("std"); +| 2 +| 3 pub const Platform = enum { tty, gui, web }; +| 4 pub const TreeSitterGrammars = enum { disabled, zig, minimal, full }; +| 5 const GhosttyArtifact = enum { ghostty, lib }; +| 6 +| 7 pub fn build(b: *std.Build) void { +| 8 // Default target is the Steam Deck (deckcap's trick): x86_64 linux-gnu +| 9 // with the glibc version pinned low, so a binary built on a rolling- +| 10 // release host runs on SteamOS — a native build references the host's +| 11 // newer versioned libm/libc symbols and dies with "GLIBC_2.4x not found" +| 12 // on the deck. Override with -Dtarget= as usual. +== style source-open +|0: 0-48 #969696,#2c2a3e, 49-119 d,#2c2a3e, +|1: 0-1 d,#383c54, 2-60 #969696,#2c2a3e, 61-119 d,#2c2a3e, +|2: 0-1 d,#525252, 2-119 d,d, +|3: 0-1 d,#525252, 2-119 d,d, +|4: 0-1 d,#525252, 2-119 d,d, +|5: 0-1 d,#525252, 2-119 d,d, +|6: 0-1 d,#525252, 2-119 d,d, +|7: 0-1 d,#525252, 2-119 d,d, +|8: 0-1 d,#525252, 2-119 d,d, +|9: 0-1 d,#525252, 2-119 d,d, +|10: 0-1 d,#525252, 2-119 d,d, +|11: 0-1 d,#525252, 2-119 d,d, +|12: 0-1 d,#525252, 2-119 d,d, +|13: 0-1 d,#525252, 2-119 d,d, +|14: 0-1 d,#525252, 2-119 d,d, +|15: 0-1 d,#525252, 2-119 d,d, +|16: 0-1 d,#525252, 2-119 d,d, +|17: 0-1 d,#525252, 2-119 d,d, +|18: 0-1 d,#525252, 2-119 d,d, +|19: 0-1 d,#525252, 2-119 d,d, +|20: 0-1 d,#525252, 2-119 d,d, +|21: 0-1 d,#525252, 2-119 d,d, +|22: 0-1 d,#525252, 2-119 d,d, +|23: 0-1 d,#525252, 2-119 d,d, +|24: 0-1 d,#6e4fd0, 2-23 #969696,#2c2a3e, 24-119 d,#2c2a3e, +|25: 0-1 d,#525252, 2-6 #969696,#2c2a3e, 7-18 d,d, 19-25 #cc70d8,d,b 26-26 d,d, 27-31 #8ab87a,d, 32-119 d,d, +|26: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-119 d,d, +|27: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-27 d,d, 28-31 #cc70d8,d,b 32-119 d,d, +|28: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-37 d,d, 38-41 #cc70d8,d,b 42-119 d,d, +|29: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-30 d,d, 31-34 #cc70d8,d,b 35-119 d,d, +|30: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-119 d,d, +|31: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-10 d,d, 11-12 #cc70d8,d,b 13-119 d,d, +|32: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-10 d,d, 11-81 #f0f0f0,d,b 82-119 d,d, +|33: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-10 d,d, 11-79 #f0f0f0,d,b 80-119 d,d, +|34: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-10 d,d, 11-80 #f0f0f0,d,b 81-119 d,d, +|35: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-10 d,d, 11-83 #f0f0f0,d,b 84-119 d,d, +|36: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-10 d,d, 11-59 #f0f0f0,d,b 60-119 d,d, +== snap separator-resized grid=120x37 cursor=7,21 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| TTY /home/goblin/00-projects/0x4200.cafe/02-pardes-code Del +| src/dump.zig +| src/gui.zig +| src/image.zig +| src/look.zig +| src/main.zig +| src/modal.zig +| src/pardes.zig +| src/petscii.zig +| src/syntax.zig +| src/tracy.zig +| src/tty.zig +| test/deck/replay.zig +| test/deck/schema.zig +| test/e2e_harness.zig +| test/hxdiff.zig +| test/snapshot.zig +| uucode_config.zig +|╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ +| NOR build.zig Save Del +| 1 const std = @import("std"); +| 2 +| 3 pub const Platform = enum { tty, gui, web }; +| 4 pub const TreeSitterGrammars = enum { disabled, zig, minimal, full }; +| 5 const GhosttyArtifact = enum { ghostty, lib }; +| 6 +| 7 pub fn build(b: *std.Build) void { +| 8 // Default target is the Steam Deck (deckcap's trick): x86_64 linux-gnu +| 9 // with the glibc version pinned low, so a binary built on a rolling- +| 10 // release host runs on SteamOS — a native build references the host's +| 11 // newer versioned libm/libc symbols and dies with "GLIBC_2.4x not found" +| 12 // on the deck. Override with -Dtarget= as usual. +| 13 const target = b.standardTargetOptions(.{ .default_target = .{ +| 14 .cpu_arch = .x86_64, +| 15 .os_tag = .linux, +| 16 .abi = .gnu, +== snap source-scrolled grid=120x37 cursor=0,0 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| TTY /home/goblin/00-projects/0x4200.cafe/02-pardes-code Del +| src/dump.zig +| src/gui.zig +| src/image.zig +| src/look.zig +| src/main.zig +| src/modal.zig +| src/pardes.zig +| src/petscii.zig +| src/syntax.zig +| src/tracy.zig +| src/tty.zig +| test/deck/replay.zig +| test/deck/schema.zig +| test/e2e_harness.zig +| test/hxdiff.zig +| test/snapshot.zig +| uucode_config.zig +| +| NOR build.zig Save Del +| 10 // release host runs on SteamOS — a native build references the host's +| 11 // newer versioned libm/libc symbols and dies with "GLIBC_2.4x not found" +| 12 // on the deck. Override with -Dtarget= as usual. +| 13 const target = b.standardTargetOptions(.{ .default_target = .{ +| 14 .cpu_arch = .x86_64, +| 15 .os_tag = .linux, +| 16 .abi = .gnu, +| 17 .glibc_version = .{ .major = 2, .minor = 38, .patch = 0 }, +| 18 } }); +| 19 const requested_optimize = b.standardOptimizeOption(.{}); +| 20 const platform = b.option(Platform, "platform", "which shell to build (tty, gui, web)") orelse .tty; +| 21 const static = b.option(bool, "static", "statically link") orelse false; +| 22 const dump_path = b.option([]const u8, "dump", "dump .zon embedded into the web shell (-Dplatform=web)"); +| 23 const emcc_path = b.option([]const u8, "emcc", "emcc for the web shell's final link") orelse "/usr/lib/emscri +| 24 const is_emscripten = target.result.os.tag == .emscripten; +| 25 // wasm: size is the budget diff --git a/test/web-snapshots/source-list.snap b/test/web-snapshots/source-list.snap new file mode 100644 index 00000000..b1a0a79a --- /dev/null +++ b/test/web-snapshots/source-list.snap @@ -0,0 +1,29 @@ +# Browser-only .snap commands. The page is built from a dump made by a real +# Pardes TTY after running `git ls-files '*.zig'`; touch events below are +# injected through Chrome's real browser input path. +wait 10000 uucode_config.zig +stable 100 5000 +snap launcher + +# A little sub-slop movement is still a tap. On web one-finger taps are LOOK, +# so this opens build.zig from the archive generated from Git-tracked sources. +touch down 1 5 3 +touch move 1 5.2 3.1 +touch up 1 5.2 3.1 +wait 10000 build.zig Save Del +stable 100 5000 +snap source-open +snapstyle source-open + +# A touch that begins on the horizontal pane separator is a latched left-mouse +# resize gesture. Crossing the body-scroll threshold must never turn it into a +# wheel gesture. +swipe 2 60 23 60 19 8 +stable 100 5000 +snap separator-resized + +# Travel well outside the tap slop becomes natural one-finger scrolling and +# must not also click/LOOK at the release point. +swipe 3 60 34 60 27 12 +stable 100 5000 +snap source-scrolled diff --git a/test/web_snapshot.mjs b/test/web_snapshot.mjs new file mode 100644 index 00000000..7c0c7462 --- /dev/null +++ b/test/web_snapshot.mjs @@ -0,0 +1,471 @@ +#!/usr/bin/env node +// Drive the Emscripten build in a real headless Chromium using .snap scripts. +// Shared commands retain test/snapshot.zig's spelling and output format: +// wait <ms> <text> | stable <quiet-ms> <timeout-ms> +// snap <label> | snapstyle <label> +// Browser touch additions: +// touch <down|move|up> <id> <col> <row> +// swipe <id> <from-col> <from-row> <to-col> <to-row> <steps> +// +// No npm package is needed: Node's WebSocket connects directly to Chrome's +// DevTools protocol. Set PARDES_CHROME when Chromium is not on PATH or in the +// normal Playwright cache. +import childProcess from "node:child_process"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import http from "node:http"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; + +const VIEWPORT = { width: 1120, height: 720 }; +const args = process.argv.slice(2); +const update = args.includes("--update"); +const positional = args.filter((arg) => arg !== "--update"); +if (positional.length !== 2) { + console.error("usage: node test/web_snapshot.mjs <zig-out/web> <script.snap> [--update]"); + process.exit(2); +} + +const webDir = path.resolve(positional[0]); +const scriptPath = path.resolve(positional[1]); +const goldenPath = scriptPath.replace(/\.snap$/, ".golden"); +if (goldenPath === scriptPath) throw new Error(`not a .snap path: ${scriptPath}`); + +let server; +let chrome; +let client; +let userDataDir; +let chromeStderr = ""; +const browserLog = []; + +async function main() { + try { + const served = await serveDirectory(webDir); + server = served.server; + const debugPort = await unusedPort(); + const chromePath = await findChrome(); + userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "pardes-web-snap-")); + chrome = childProcess.spawn(chromePath, [ + "--headless=new", + "--no-sandbox", + "--disable-dev-shm-usage", + "--enable-unsafe-swiftshader", + "--use-angle=swiftshader", + "--no-first-run", + "--no-default-browser-check", + `--remote-debugging-address=127.0.0.1`, + `--remote-debugging-port=${debugPort}`, + `--user-data-dir=${userDataDir}`, + "about:blank", + ], { stdio: ["ignore", "ignore", "pipe"] }); + chrome.stderr.setEncoding("utf8"); + chrome.stderr.on("data", (chunk) => { + chromeStderr = (chromeStderr + chunk).slice(-16_384); + }); + + await waitForDevTools(debugPort, chrome); + const targetResponse = await fetch( + `http://127.0.0.1:${debugPort}/json/new?${encodeURIComponent(`http://127.0.0.1:${served.port}/index.html`)}`, + { method: "PUT" }, + ); + if (!targetResponse.ok) throw new Error(`create Chrome target: HTTP ${targetResponse.status}`); + const target = await targetResponse.json(); + client = await Cdp.connect(target.webSocketDebuggerUrl, browserLog); + await client.send("Runtime.enable"); + await client.send("Page.enable"); + await client.send("Log.enable"); + await client.send("Emulation.setDeviceMetricsOverride", { + ...VIEWPORT, + screenWidth: VIEWPORT.width, + screenHeight: VIEWPORT.height, + deviceScaleFactor: 1, + mobile: false, + }); + await client.send("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 }); + await waitForRuntime(20_000); + + const output = await runScript(await fsp.readFile(scriptPath, "utf8")); + if (update) { + await fsp.writeFile(goldenPath, output); + console.error(`updated ${path.relative(process.cwd(), goldenPath)}`); + } else { + const golden = await fsp.readFile(goldenPath, "utf8").catch(() => null); + if (golden === null) throw new Error(`missing ${goldenPath}; run with --update`); + if (golden !== output) { + const actualPath = goldenPath.replace(/\.golden$/, ".actual"); + await fsp.writeFile(actualPath, output); + throw new Error(`snapshot mismatch; actual written to ${actualPath}`); + } + console.error(`web snapshot ok: ${path.relative(process.cwd(), scriptPath)}`); + } + } catch (error) { + console.error(error?.stack ?? error); + if (browserLog.length) console.error(`browser log:\n${browserLog.join("\n")}`); + if (chromeStderr.trim()) console.error(`chromium stderr (tail):\n${chromeStderr.trim()}`); + process.exitCode = 1; + } finally { + client?.close(); + if (chrome && chrome.exitCode === null) { + chrome.kill("SIGTERM"); + await Promise.race([ + new Promise((resolve) => chrome.once("exit", resolve)), + delay(2_000), + ]); + } + if (server) await new Promise((resolve) => server.close(resolve)); + if (userDataDir) await fsp.rm(userDataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +} + +async function runScript(source) { + let output = ""; + let activeTouch = null; + const lines = source.split("\n"); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index].trim(); + if (!line || line.startsWith("#")) continue; + const tokens = line.split(/\s+/); + const command = tokens.shift(); + try { + if (command === "wait") { + const timeout = integer(tokens.shift(), "wait timeout"); + const needle = tokens.join(" "); + if (!needle) throw new Error("wait needs text"); + await waitUntil(async () => (await grid()).text.includes(needle), timeout, `screen containing ${JSON.stringify(needle)}`); + } else if (command === "stable") { + const quiet = integer(tokens.shift(), "stable quiet time"); + const timeout = integer(tokens.shift(), "stable timeout"); + await waitStable(quiet, timeout); + } else if (command === "snap") { + const label = tokens.join(" "); + if (!label) throw new Error("snap needs a label"); + output += formatSnapshot(label, await grid()); + } else if (command === "snapstyle") { + const label = tokens.join(" "); + if (!label) throw new Error("snapstyle needs a label"); + output += formatStyleSnapshot(label, await grid()); + } else if (command === "touch") { + const phase = tokens.shift(); + const id = integer(tokens.shift(), "touch id"); + const col = number(tokens.shift(), "touch col"); + const row = number(tokens.shift(), "touch row"); + if (tokens.length || !["down", "move", "up"].includes(phase)) throw new Error("bad touch command"); + activeTouch = await dispatchTouch(phase, id, col, row, activeTouch); + await delay(40); + } else if (command === "swipe") { + const id = integer(tokens.shift(), "swipe id"); + const x0 = number(tokens.shift(), "swipe start col"); + const y0 = number(tokens.shift(), "swipe start row"); + const x1 = number(tokens.shift(), "swipe end col"); + const y1 = number(tokens.shift(), "swipe end row"); + const steps = integer(tokens.shift(), "swipe steps"); + if (tokens.length || steps < 1) throw new Error("bad swipe command"); + activeTouch = await dispatchTouch("down", id, x0, y0, activeTouch); + for (let step = 1; step <= steps; step += 1) { + const t = step / steps; + activeTouch = await dispatchTouch("move", id, x0 + (x1 - x0) * t, y0 + (y1 - y0) * t, activeTouch); + await delay(20); + } + activeTouch = await dispatchTouch("up", id, x1, y1, activeTouch); + await delay(80); + } else { + throw new Error(`unknown command ${command}`); + } + } catch (error) { + const screen = await grid().catch(() => null); + const detail = screen ? `\ncurrent screen:\n${screen.lines.map((row) => `|${row}`).join("\n")}` : ""; + throw new Error(`${path.basename(scriptPath)}:${index + 1}: ${line}\n${error.message}${detail}`, { cause: error }); + } + } + if (activeTouch) throw new Error("script ended with a finger still down"); + return output; +} + +async function dispatchTouch(phase, id, col, row, active) { + const point = await evaluate(`({ + x: Module._pardes_web_test_cell_x(${JSON.stringify(col)}), + y: Module._pardes_web_test_cell_y(${JSON.stringify(row)}) + })`); + const touch = { x: point.x, y: point.y, id, radiusX: 1, radiusY: 1, force: 1 }; + if (phase === "down") { + if (active) throw new Error("only one finger is supported by this web test"); + await client.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [touch] }); + return touch; + } + if (!active || active.id !== id) throw new Error(`finger ${id} is not down`); + if (phase === "move") { + await client.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [touch] }); + return touch; + } + // Chrome's touchEnd carries no points; a final move gives SDL the scripted + // release coordinate before the lift. + await client.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [touch] }); + await client.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); + return null; +} + +async function grid() { + return evaluate(`(() => { + const cols = Module._pardes_web_test_cols(); + const rows = Module._pardes_web_test_rows(); + const lines = []; + const styleRows = []; + for (let y = 0; y < rows; y++) { + let line = ""; + const styles = []; + for (let x = 0; x < cols; x++) { + const cp = Module._pardes_web_test_cell(x, y) || 32; + line += String.fromCodePoint(cp); + styles.push([ + Module._pardes_web_test_cell_fg(x, y), + Module._pardes_web_test_cell_bg(x, y), + Module._pardes_web_test_cell_attrs(x, y), + ]); + } + lines.push(line.replace(/ +$/, "")); + styleRows.push(styles); + } + return { + cols, rows, lines, styleRows, + cursorX: Module._pardes_web_test_cursor_x(), + cursorY: Module._pardes_web_test_cursor_y(), + text: lines.join("\\n"), + }; + })()`); +} + +function formatSnapshot(label, state) { + const cursorX = state.cursorX < 0 ? 0 : state.cursorX; + const cursorY = state.cursorY < 0 ? 0 : state.cursorY; + let out = `== snap ${label} grid=${state.cols}x${state.rows} cursor=${cursorX},${cursorY}\n`; + for (const line of state.lines) out += `|${line}\n`; + return out; +} + +function formatStyleSnapshot(label, state) { + let out = `== style ${label}\n`; + for (let y = 0; y < state.rows; y++) { + const styles = state.styleRows[y]; + out += `|${y}:`; + let runStart = 0; + let runKey = styleKey(styles[0]); + for (let x = 1; x <= state.cols; x++) { + const key = x === state.cols ? null : styleKey(styles[x]); + if (key === runKey) continue; + out += ` ${runStart}-${x - 1} ${runKey}`; + runStart = x; + runKey = key; + } + out += "\n"; + } + return out; +} + +function styleKey([fg, bg, attrs]) { + return `${colorKey(fg)},${colorKey(bg)},${attrsKey(attrs)}`; +} + +function colorKey(color) { + if (color === 0x01000000) return "d"; + if ((color & 0xff000000) === 0x02000000) return `p${color & 0xff}`; + return `#${(color & 0xffffff).toString(16).padStart(6, "0")}`; +} + +function attrsKey(attrs) { + let out = ""; + if (attrs & (1 << 0)) out += "b"; + if (attrs & (1 << 1)) out += "f"; + if (attrs & (1 << 2)) out += "i"; + if (attrs & (1 << 3)) out += "k"; + if (attrs & (1 << 4)) out += "r"; + if (attrs & (1 << 5)) out += "v"; + if (attrs & (1 << 6)) out += "x"; + out += ["", "u", "U", "~", ".", "-"][(attrs >> 8) & 0x7] ?? "?"; + return out; +} + +async function waitStable(quiet, timeout) { + let elapsed = 0; + let previous = JSON.stringify(await grid()); + while (elapsed < timeout) { + await delay(quiet); + elapsed += quiet; + const current = JSON.stringify(await grid()); + if (current === previous) return; + previous = current; + } + throw new Error(`screen did not stabilize within ${timeout}ms`); +} + +async function waitForRuntime(timeout) { + await waitUntil(async () => { + try { + return await evaluate(`typeof Module?._pardes_web_test_cols === "function" && Module._pardes_web_test_cols() > 0`); + } catch { + return false; + } + }, timeout, "Pardes web runtime"); +} + +async function waitUntil(predicate, timeout, description) { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + if (await predicate()) return; + if (chrome?.exitCode !== null) throw new Error(`Chromium exited with ${chrome.exitCode}`); + await delay(50); + } + throw new Error(`timed out waiting for ${description}`); +} + +async function evaluate(expression) { + const response = await client.send("Runtime.evaluate", { + expression, + returnByValue: true, + awaitPromise: true, + }); + if (response.exceptionDetails) throw new Error(response.exceptionDetails.text); + return response.result.value; +} + +class Cdp { + static async connect(url, log) { + const socket = new WebSocket(url); + await new Promise((resolve, reject) => { + socket.addEventListener("open", resolve, { once: true }); + socket.addEventListener("error", () => reject(new Error(`connect DevTools websocket ${url}`)), { once: true }); + }); + return new Cdp(socket, log); + } + + constructor(socket, log) { + this.socket = socket; + this.log = log; + this.nextId = 1; + this.pending = new Map(); + socket.addEventListener("message", (event) => { + const message = JSON.parse(event.data); + if (message.id) { + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + if (message.error) pending.reject(new Error(`${pending.method}: ${message.error.message}`)); + else pending.resolve(message.result ?? {}); + return; + } + if (message.method === "Runtime.exceptionThrown") + log.push(`exception: ${message.params.exceptionDetails.text}`); + else if (message.method === "Runtime.consoleAPICalled") + log.push(`console ${message.params.type}: ${message.params.args.map((arg) => arg.value ?? arg.description).join(" ")}`); + else if (message.method === "Log.entryAdded") + log.push(`${message.params.entry.level}: ${message.params.entry.text}`); + }); + } + + send(method, params = {}) { + const id = this.nextId++; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject, method }); + this.socket.send(JSON.stringify({ id, method, params })); + }); + } + + close() { + this.socket.close(); + } +} + +async function serveDirectory(root) { + const canonicalRoot = await fsp.realpath(root); + const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url, "http://localhost"); + const relative = decodeURIComponent(url.pathname === "/" ? "/index.html" : url.pathname); + const filename = path.resolve(canonicalRoot, `.${relative}`); + if (filename !== canonicalRoot && !filename.startsWith(`${canonicalRoot}${path.sep}`)) { + response.writeHead(403).end(); + return; + } + const contents = await fsp.readFile(filename); + const contentType = filename.endsWith(".wasm") ? "application/wasm" + : filename.endsWith(".js") ? "text/javascript; charset=utf-8" + : "text/html; charset=utf-8"; + response.writeHead(200, { "content-type": contentType, "cache-control": "no-store" }); + response.end(contents); + } catch { + response.writeHead(404).end(); + } + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + return { server, port: server.address().port }; +} + +async function unusedPort() { + const probe = net.createServer(); + await new Promise((resolve, reject) => { + probe.once("error", reject); + probe.listen(0, "127.0.0.1", resolve); + }); + const port = probe.address().port; + await new Promise((resolve) => probe.close(resolve)); + return port; +} + +async function waitForDevTools(port, child) { + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + if (child.exitCode !== null) throw new Error(`Chromium exited with ${child.exitCode}`); + try { + const response = await fetch(`http://127.0.0.1:${port}/json/version`); + if (response.ok) return; + } catch {} + await delay(50); + } + throw new Error("Chromium DevTools endpoint did not start"); +} + +async function findChrome() { + const candidates = []; + if (process.env.PARDES_CHROME) candidates.push(process.env.PARDES_CHROME); + for (const command of ["chromium", "chromium-browser", "google-chrome", "google-chrome-stable"]) { + const found = childProcess.spawnSync("which", [command], { encoding: "utf8" }); + if (found.status === 0 && found.stdout.trim()) candidates.push(found.stdout.trim()); + } + const cache = process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(os.homedir(), ".cache", "ms-playwright"); + for (const entry of await fsp.readdir(cache).catch(() => [])) { + if (entry.startsWith("chromium-")) { + candidates.push(path.join(cache, entry, "chrome-linux64", "chrome")); + candidates.push(path.join(cache, entry, "chrome-linux", "chrome")); + } else if (entry.startsWith("chromium_headless_shell-")) { + candidates.push(path.join(cache, entry, "chrome-headless-shell-linux64", "chrome-headless-shell")); + } + } + for (const candidate of candidates) { + try { + await fsp.access(candidate, fs.constants.X_OK); + return candidate; + } catch {} + } + throw new Error("no Chromium found; set PARDES_CHROME=/path/to/chrome"); +} + +function integer(value, label) { + const parsed = Number.parseInt(value, 10); + if (!Number.isSafeInteger(parsed) || String(parsed) !== value) throw new Error(`bad ${label}: ${value}`); + return parsed; +} + +function number(value, label) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) throw new Error(`bad ${label}: ${value}`); + return parsed; +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +await main(); diff --git a/tools/embed_zig_sources.zig b/tools/embed_zig_sources.zig new file mode 100644 index 00000000..a832798c --- /dev/null +++ b/tools/embed_zig_sources.zig @@ -0,0 +1,128 @@ +//! Build-time generator for the web LOOK filesystem. +//! +//! Prefer `git ls-files` so ignored build/cache/vendor files never leak into +//! the browser bundle. A source archive should still be buildable without Git, +//! so a conservative directory walk is the fallback. +const std = @import("std"); + +pub fn main(init: std.process.Init) !void { + const gpa = init.gpa; + const io = init.io; + const args = try init.minimal.args.toSlice(init.arena.allocator()); + if (args.len != 3) return error.BadArgs; + + const root = args[1]; + const output_path = args[2]; + var paths: std.ArrayList([]const u8) = .empty; + defer { + for (paths.items) |path| gpa.free(path); + paths.deinit(gpa); + } + + if (!try gitTrackedZigFiles(gpa, io, root, &paths)) + try walkedZigFiles(gpa, io, root, &paths); + std.mem.sort([]const u8, paths.items, {}, lessThanPath); + + var generated: std.Io.Writer.Allocating = .init(gpa); + defer generated.deinit(); + try generated.writer.writeAll( + \\// Generated by tools/embed_zig_sources.zig. Do not edit. + \\pub const Source = struct { path: []const u8, contents: []const u8 }; + \\pub const all = [_]Source{ + \\ + ); + + for (paths.items) |path| { + const full_path = try std.Io.Dir.path.join(gpa, &.{ root, path }); + defer gpa.free(full_path); + const contents = try std.Io.Dir.cwd().readFileAlloc(io, full_path, gpa, .limited(64 * 1024 * 1024)); + defer gpa.free(contents); + try generated.writer.print(" .{{ .path = \"{f}\", .contents = \"{f}\" }},\n", .{ + std.zig.fmtString(path), + std.zig.fmtString(contents), + }); + } + try generated.writer.writeAll("};\n"); + + const output = if (std.Io.Dir.path.isAbsolute(output_path)) + try std.Io.Dir.createFileAbsolute(io, output_path, .{}) + else + try std.Io.Dir.cwd().createFile(io, output_path, .{}); + defer output.close(io); + try output.writeStreamingAll(io, generated.written()); +} + +/// Returns true only when Git ran successfully. The working-tree contents are +/// read later; Git supplies the tracked path filter, not historical blobs. +fn gitTrackedZigFiles( + gpa: std.mem.Allocator, + io: std.Io, + root: []const u8, + paths: *std.ArrayList([]const u8), +) !bool { + const result = std.process.run(gpa, io, .{ + .argv = &.{ "git", "-C", root, "ls-files", "-z", "--", "*.zig" }, + .stdout_limit = .limited(16 * 1024 * 1024), + .stderr_limit = .limited(1024 * 1024), + }) catch return false; + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + switch (result.term) { + .exited => |code| if (code != 0) return false, + else => return false, + } + + var it = std.mem.splitScalar(u8, result.stdout, 0); + while (it.next()) |raw_path| { + if (raw_path.len == 0 or !std.mem.endsWith(u8, raw_path, ".zig")) continue; + try paths.append(gpa, try normalizedPath(gpa, raw_path)); + } + return true; +} + +fn walkedZigFiles( + gpa: std.mem.Allocator, + io: std.Io, + root: []const u8, + paths: *std.ArrayList([]const u8), +) !void { + var dir = if (std.Io.Dir.path.isAbsolute(root)) + try std.Io.Dir.openDirAbsolute(io, root, .{ .iterate = true }) + else + try std.Io.Dir.cwd().openDir(io, root, .{ .iterate = true }); + defer dir.close(io); + var walker = try dir.walk(gpa); + defer walker.deinit(); + + while (try walker.next(io)) |entry| { + if (entry.kind == .directory and ignoredFallbackDir(entry.path)) { + walker.leave(io); + continue; + } + if ((entry.kind != .file and entry.kind != .sym_link) or !std.mem.endsWith(u8, entry.path, ".zig")) continue; + try paths.append(gpa, try normalizedPath(gpa, entry.path)); + } +} + +fn ignoredFallbackDir(path: []const u8) bool { + var parts = std.mem.tokenizeAny(u8, path, "/\\"); + while (parts.next()) |part| { + if (std.mem.eql(u8, part, ".git") or + std.mem.eql(u8, part, ".zig-cache") or + std.mem.eql(u8, part, "zig-cache") or + std.mem.eql(u8, part, "zig-out") or + std.mem.eql(u8, part, "zig-pkg")) return true; + } + return false; +} + +fn normalizedPath(gpa: std.mem.Allocator, path: []const u8) ![]u8 { + const copy = try gpa.dupe(u8, path); + if (std.Io.Dir.path.sep != '/') + std.mem.replaceScalar(u8, copy, std.Io.Dir.path.sep, '/'); + return copy; +} + +fn lessThanPath(_: void, a: []const u8, b: []const u8) bool { + return std.mem.order(u8, a, b) == .lt; +} |
