summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-03 15:17:46 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commit6b4b1f76bb6bf969fb5106f95fb3f9ad077fac08 (patch)
tree423fa58bc169d44d7581ab50970f154e962646ab
parentd1ed5bd8f55935e6470c4a7559216119571a084f (diff)
downloadpardes-6b4b1f76bb6bf969fb5106f95fb3f9ad077fac08.tar.gz
pardes-6b4b1f76bb6bf969fb5106f95fb3f9ad077fac08.zip
fixing mouse drag on web
-rw-r--r--assets/AdwaitaMono-LICENSE.txt96
-rw-r--r--build.zig55
-rw-r--r--main.zig23
-rw-r--r--pane_ui.zig29
-rw-r--r--replay.zig22
-rw-r--r--tests.zig51
-rw-r--r--tests/browser_replay_dump.zon7
-rw-r--r--tests/browser_replay_e2e.mjs84
-rw-r--r--ui_sdl.zig21
9 files changed, 331 insertions, 57 deletions
diff --git a/assets/AdwaitaMono-LICENSE.txt b/assets/AdwaitaMono-LICENSE.txt
new file mode 100644
index 00000000..6dc188d1
--- /dev/null
+++ b/assets/AdwaitaMono-LICENSE.txt
@@ -0,0 +1,96 @@
+Copyright (c) 2016, The Inter Project Authors (https://github.com/rsms/inter)
+Copyright (c) 2015-2025, Renzhi Li (aka. Belleve Invis, [email protected])
+Copyright (c) 2025, Jamie Gravendeel (https://jamie.garden)
+Copyright (c) 2025, Florian Müllner
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://openfontlicense.org
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/build.zig b/build.zig
index b55d1c1a..f2a691c3 100644
--- a/build.zig
+++ b/build.zig
@@ -208,7 +208,7 @@ pub fn build(b: *std.Build) void {
// 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 Maple Mono NF font. All gated on the flag so a plain `zig build`
+ // the embedded Adwaita Mono font. All gated on the flag so a plain `zig build`
// (vaxis) never fetches or compiles SDL.
var sdl_lib_for_wasm: ?*std.Build.Step.Compile = null;
if (sdl_backend) {
@@ -270,7 +270,7 @@ pub fn build(b: *std.Build) void {
});
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_path = b.option([]const u8, "emcc", "path to emcc for browser replay final link") orelse "/usr/lib/emscripten/emcc";
const emcc = b.addSystemCommand(&.{emcc_path});
emcc.addFileArg(replay_obj.getEmittedBinDirectory().path(b, "pardes-replay_zcu.o"));
emcc.addFileArg(replay_obj.getEmittedBin());
@@ -372,18 +372,22 @@ pub fn build(b: *std.Build) void {
const run = b.step("run", "descend to the pardes");
run.dependOn(&run_exe.step);
- const run_replay = b.addRunArtifact(replay);
- if (b.args) |args| run_replay.addArgs(args);
- 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>)");
+ const replay_step = b.step("replay", if (is_emscripten)
+ "build browser SDL replay (requires -Dreplay-dump=<dump.zon>)"
+ else
+ "replay a pardes dump: zig build replay -- <dump.zon>");
+ const replay_wasm_step = b.step("replay-wasm", "compatibility alias for browser replay; use `zig build replay -Dtarget=wasm32-emscripten -Dreplay-dump=<dump.zon>`");
if (!is_emscripten) {
- replay_wasm_step.dependOn(&b.addFail("replay-wasm requires -Dtarget=wasm32-emscripten").step);
+ const run_replay = b.addRunArtifact(replay);
+ if (b.args) |args| run_replay.addArgs(args);
+ replay_step.dependOn(&run_replay.step);
+ replay_wasm_step.dependOn(&b.addFail("replay-wasm requires -Dtarget=wasm32-emscripten; use `zig build replay` for native replay").step);
} else if (replay_dump == null) {
- replay_wasm_step.dependOn(&b.addFail("replay-wasm requires -Dreplay-dump=<dump.zon>").step);
+ replay_step.dependOn(&b.addFail("replay requires -Dreplay-dump=<dump.zon> when targeting wasm32-emscripten").step);
+ replay_wasm_step.dependOn(replay_step);
} else if (emscripten_sysroot == null) {
- replay_wasm_step.dependOn(&b.addFail("replay-wasm requires an Emscripten sysroot; pass --sysroot <emscripten>/cache/sysroot or set EM_CACHE/EMSDK").step);
+ replay_step.dependOn(&b.addFail("replay requires an Emscripten sysroot; pass --sysroot <emscripten>/cache/sysroot or set EM_CACHE/EMSDK").step);
+ replay_wasm_step.dependOn(replay_step);
} else {
const emcc = emcc_for_wasm.?;
emcc.addPrefixedDirectoryArg("-I", b.path("vendor/stb"));
@@ -401,13 +405,9 @@ pub fn build(b: *std.Build) void {
"-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);
+ replay_step.dependOn(&b.addInstallFileWithDir(replay_js, .{ .custom = "web" }, "pardes-replay.js").step);
+ replay_step.dependOn(&b.addInstallFileWithDir(replay_js.dirname().path(b, "pardes-replay.wasm"), .{ .custom = "web" }, "pardes-replay.wasm").step);
- const canvas_rendering_css = switch (web_filter) {
- .linear => " image-rendering: auto;",
- .nearest => " image-rendering: crisp-edges; image-rendering: pixelated;",
- };
const replay_html = std.mem.concat(b.allocator, u8, &.{
\\<!doctype html>
\\<html>
@@ -419,9 +419,9 @@ pub fn build(b: *std.Build) void {
\\ html, body { margin: 0; width: 100%; height: 100%; background: #121212; }
\\ body { overflow: hidden; }
\\ canvas { display: block; width: 100vw; height: 100vh; outline: none;
- ,
- canvas_rendering_css,
- \\ }
+ \\ image-rendering: crisp-edges; image-rendering: pixelated;
+ \\ -ms-interpolation-mode: nearest-neighbor;
+ \\ transform: translateZ(0); backface-visibility: hidden; }
\\ </style>
\\</head>
\\<body>
@@ -429,7 +429,15 @@ pub fn build(b: *std.Build) void {
\\ <script>
\\ const canvas = document.getElementById('canvas');
\\ canvas.addEventListener('contextmenu', (event) => event.preventDefault());
- \\ canvas.addEventListener('pointerdown', () => canvas.focus({ preventScroll: true }));
+ \\ canvas.addEventListener('pointerdown', (event) => {
+ \\ canvas.focus({ preventScroll: true });
+ \\ canvas.setPointerCapture?.(event.pointerId);
+ \\ });
+ \\ const releasePointer = (event) => {
+ \\ if (canvas.hasPointerCapture?.(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
+ \\ };
+ \\ canvas.addEventListener('pointerup', releasePointer);
+ \\ canvas.addEventListener('pointercancel', releasePointer);
\\ window.Module = { canvas };
\\ </script>
\\ <script src="pardes-replay.js"></script>
@@ -438,7 +446,8 @@ pub fn build(b: *std.Build) void {
}) catch @panic("failed to build replay.html");
const web = b.addWriteFiles();
const html = web.add("replay.html", replay_html);
- replay_wasm_step.dependOn(&b.addInstallFileWithDir(html, .{ .custom = "web" }, "replay.html").step);
+ replay_step.dependOn(&b.addInstallFileWithDir(html, .{ .custom = "web" }, "replay.html").step);
+ replay_wasm_step.dependOn(replay_step);
}
const test_browser_step = b.step("test-browser", "run browser SDL replay screenshot e2e (requires -Dtarget=wasm32-emscripten -Dreplay-dump=<dump.zon>)");
@@ -451,7 +460,7 @@ pub fn build(b: *std.Build) void {
run_browser_e2e.addArg("zig-out/web");
run_browser_e2e.addArg("zig-out/browser-replay.png");
run_browser_e2e.addArg(b.fmt("{d}", .{web_render_scale}));
- run_browser_e2e.step.dependOn(replay_wasm_step);
+ run_browser_e2e.step.dependOn(replay_step);
test_browser_step.dependOn(&run_browser_e2e.step);
}
diff --git a/main.zig b/main.zig
index 184b1058..93c3324e 100644
--- a/main.zig
+++ b/main.zig
@@ -2629,8 +2629,9 @@ pub fn main(init: std.process.Init) !void {
var drain_pty_after = false;
switch (event) {
.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 mpos = pane_ui.clampMousePos(screen_w, screen_h, mouse);
+ const mcol = mpos.col;
+ const mrow = mpos.row;
hover_col = mcol;
hover_row = mrow;
@@ -3155,23 +3156,7 @@ pub fn main(init: std.process.Init) !void {
// resize-handle hint: overlay a dash on the boundary cells, keeping their
// colors (only the glyph changes). Shown while dragging a border and, in the
// else branch, on hover.
- switch (drag) {
- .border_v => |d| {
- pane_ui.drawBorderV(win, d.cur_x, screen_h);
- },
- .border_h => |d| {
- pane_ui.drawBorderH(win, col_x[d.col], col_w[d.col], d.cur_y);
- },
- .move => |d| {
- pane_ui.drawMovePreview(win, &layout, &col_x, &col_w, d.cur_x, d.cur_y, gray.mid);
- },
- else => {
- // not dragging: hint the resize handle under the cursor with the same
- // dash overlay. Vertical = the left pane's last column; horizontal =
- // the upper pane's last row (a's own last line, just above b's tag).
- pane_ui.drawHoverResizeHints(win, &layout, &rects, &col_x, &col_w, screen_h, hover_col, hover_row);
- },
- }
+ pane_ui.drawInteractionPreview(win, &layout, &rects, &col_x, &col_w, screen_h, drag, hover_col, hover_row, gray.mid);
// debug overlay: a small virtual-text box drawn last, on top of everything.
// ov_buf lives across vx.render() — vaxis keeps slices into it (retained).
diff --git a/pane_ui.zig b/pane_ui.zig
index f53fa269..6091cb5f 100644
--- a/pane_ui.zig
+++ b/pane_ui.zig
@@ -7,6 +7,8 @@ pub const PREFIX_W: u16 = 5;
pub const Rect = struct { x: u16, y: u16, w: u16, h: u16 };
+pub const MousePos = struct { col: u16, row: u16 };
+
pub const ScrollbarButton = enum { left, middle, right };
pub const Scrollbar = struct {
@@ -165,6 +167,13 @@ pub fn paneAtSlots(rects: anytype, slots: anytype, col: u16, row: u16) ?usize {
return null;
}
+pub fn clampMousePos(screen_w: u16, screen_h: u16, mouse: anytype) MousePos {
+ return .{
+ .col = if (mouse.col < 0) 0 else @min(@as(u16, @intCast(mouse.col)), screen_w -| 1),
+ .row = if (mouse.row < 0) 0 else @min(@as(u16, @intCast(mouse.row)), screen_h -| 1),
+ };
+}
+
pub fn inMoveBox(r: Rect, col: u16, row: u16) bool {
return col < r.x + GUTTER and row < r.y + BOX_H;
}
@@ -271,3 +280,23 @@ pub fn drawHoverResizeHints(
}
}
}
+
+pub fn drawInteractionPreview(
+ win: anytype,
+ l: anytype,
+ rects: anytype,
+ col_x: anytype,
+ col_w: anytype,
+ screen_h: u16,
+ drag: anytype,
+ hover_col: u16,
+ hover_row: u16,
+ move_color: [3]u8,
+) void {
+ switch (drag) {
+ .border_v => |d| drawBorderV(win, d.cur_x, screen_h),
+ .border_h => |d| drawBorderH(win, col_x[d.col], col_w[d.col], d.cur_y),
+ .move => |d| drawMovePreview(win, l, col_x, col_w, d.cur_x, d.cur_y, move_color),
+ else => drawHoverResizeHints(win, l, rects, col_x, col_w, screen_h, hover_col, hover_row),
+ }
+}
diff --git a/replay.zig b/replay.zig
index 6cec683e..e08a461c 100644
--- a/replay.zig
+++ b/replay.zig
@@ -287,6 +287,8 @@ fn render(
screen_h: u16,
active: usize,
drag: Drag,
+ hover_col: u16,
+ hover_row: u16,
) !void {
var arena: std.heap.ArenaAllocator = .init(gpa);
defer arena.deinit();
@@ -309,12 +311,7 @@ fn render(
const topbar = if (state.topbar.len > 0) state.topbar else "pardes replay";
_ = bar.printSegment(.{ .text = topbar, .style = .{ .fg = rgbColor(T().tag_fg), .bg = rgbColor(T().tag_bg) } }, .{ .wrap = .none });
- switch (drag) {
- .border_v => |d| pane_ui.drawBorderV(win, d.cur_x, screen_h),
- .border_h => |d| pane_ui.drawBorderH(win, col_x[d.col], col_w[d.col], d.cur_y),
- .move => |d| pane_ui.drawMovePreview(win, layout, col_x, col_w, d.cur_x, d.cur_y, T().scroll_thumb),
- .none => {},
- }
+ pane_ui.drawInteractionPreview(win, layout, rects, col_x, col_w, screen_h, drag, hover_col, hover_row, T().scroll_thumb);
try vx.render(writer);
}
@@ -421,6 +418,8 @@ const App = struct {
col_x: [dump.max_cols]u16,
col_w: [dump.max_cols]u16,
drag: Drag = .none,
+ hover_col: u16 = 0,
+ hover_row: u16 = 0,
fn init(
self: *App,
@@ -464,6 +463,8 @@ const App = struct {
self.screen_h = if (self.state.screen.rows > 0) self.state.screen.rows else 24;
self.active = self.state.active;
self.drag = .none;
+ self.hover_col = 0;
+ self.hover_row = 0;
self.tty = try ui.Tty.init(io, &self.tty_buf);
self.vx = try ui.init(io, gpa, env_map, .{});
@@ -516,8 +517,11 @@ const App = struct {
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)), self.screen_w -| 1);
- const mrow: u16 = if (mouse.row < 0) 0 else @min(@as(u16, @intCast(mouse.row)), self.screen_h -| 1);
+ const mpos = pane_ui.clampMousePos(self.screen_w, self.screen_h, mouse);
+ const mcol = mpos.col;
+ const mrow = mpos.row;
+ self.hover_col = mcol;
+ self.hover_row = mrow;
const hovered = pane_ui.paneAt(&self.rects, self.panes.len, mcol, mrow);
if (mouse.button == .wheel_down and mouse.type == .press) {
@@ -651,7 +655,7 @@ const App = struct {
p.rows = rows;
clampScroll(p);
}
- try render(&self.vx, self.gpa, self.tty.writer(), self.state, self.panes, &self.rects, &self.layout, &self.col_x, &self.col_w, self.screen_w, self.screen_h, self.active, self.drag);
+ try render(&self.vx, self.gpa, self.tty.writer(), self.state, self.panes, &self.rects, &self.layout, &self.col_x, &self.col_w, self.screen_w, self.screen_h, self.active, self.drag, self.hover_col, self.hover_row);
return true;
}
};
diff --git a/tests.zig b/tests.zig
index 7e44ddb7..ebcc12df 100644
--- a/tests.zig
+++ b/tests.zig
@@ -101,6 +101,46 @@ fn writeReplayScrollFixture(io: std.Io, gpa: std.mem.Allocator, path: []const u8
});
}
+fn writeReplayHoverFixture(io: std.Io, gpa: std.mem.Allocator, path: []const u8) !void {
+ const panes = [_]dump.Pane{
+ .{
+ .kind = .file,
+ .tag = "nm /tmp/replay_left.txt Save Del",
+ .body = "",
+ .cols = 48,
+ .rows = 22,
+ .file = .{
+ .path = "/tmp/replay_left.txt",
+ .content = "left\n",
+ },
+ },
+ .{
+ .kind = .file,
+ .tag = "nm /tmp/replay_right.txt Save Del",
+ .body = "",
+ .cols = 48,
+ .rows = 22,
+ .file = .{
+ .path = "/tmp/replay_right.txt",
+ .content = "right\n",
+ },
+ },
+ };
+ const left_col = [_]usize{0};
+ const right_col = [_]usize{1};
+ const columns = [_]dump.Column{
+ .{ .weight = 1, .panes = &left_col },
+ .{ .weight = 1, .panes = &right_col },
+ };
+ try dump.writeFile(io, gpa, path, .{
+ .screen = .{ .cols = 100, .rows = 24 },
+ .topbar = "Kill Newcol Tutor Debug Colors NextColor Dump",
+ .theme = "dark",
+ .columns = &columns,
+ .panes = &panes,
+ });
+}
+
fn writeReplayImageFixture(io: std.Io, gpa: std.mem.Allocator, path: []const u8) !void {
const png_b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
const panes = [_]dump.Pane{.{
@@ -212,6 +252,17 @@ pub fn main(init: std.process.Init) !void {
try replay_file.expectNotContains("\xc2\xaa", "replay file line numbers used invalid stack bytes");
}
{
+ const fixture_path = "/tmp/pardes-replay-hover.zon";
+ try writeReplayHoverFixture(io, gpa, fixture_path);
+ var replay_hover = try Harness.initArgs(gpa, replay_exe.ptr, 24, 100, fixture_path);
+ replay_hover.trace = config.trace;
+ defer replay_hover.deinit();
+ try replay_hover.pump(1200);
+ try replay_hover.send("\x1b[<35;50;10M");
+ try replay_hover.pump(400);
+ try replay_hover.expectContains("╎", "replay hover did not overlay the dashed resize handle");
+ }
+ {
const fixture_path = "/tmp/pardes-replay-scroll-fixture.zon";
try writeReplayScrollFixture(io, gpa, fixture_path);
var replay_scroll = try Harness.initArgs(gpa, replay_exe.ptr, 24, 80, fixture_path);
diff --git a/tests/browser_replay_dump.zon b/tests/browser_replay_dump.zon
index fbac9067..bee296d7 100644
--- a/tests/browser_replay_dump.zon
+++ b/tests/browser_replay_dump.zon
@@ -4,7 +4,8 @@
.topbar = "pardes replay browser e2e",
.theme = "dark",
.columns = .{
- .{ .weight = 1, .panes = .{ 0, 1 } },
+ .{ .weight = 1, .panes = .{ 0 } },
+ .{ .weight = 1, .panes = .{ 1 } },
},
.panes = .{
.{
@@ -34,7 +35,7 @@
.kind = .terminal,
.tag = "sh /tmp Del",
.body =
- \\$ zig build replay-wasm
+ \\$ zig build replay -Dtarget=wasm32-emscripten
\\browser e2e marker 4200
,
.scroll = 0,
@@ -44,7 +45,7 @@
.terminal = .{
.cwd = "/tmp",
.stream =
- \\$ zig build replay-wasm
+ \\$ zig build replay -Dtarget=wasm32-emscripten
\\browser e2e marker 4200
,
},
diff --git a/tests/browser_replay_e2e.mjs b/tests/browser_replay_e2e.mjs
index f0d85bf8..2d8e202c 100644
--- a/tests/browser_replay_e2e.mjs
+++ b/tests/browser_replay_e2e.mjs
@@ -170,6 +170,75 @@ function validateScreenshot(png) {
console.log(`browser replay screenshot ok: ${png.width}x${png.height}, bright=${bright}, nonDark=${nonDark}, colored=${colored}`);
}
+function pixelRgb(png, x, y) {
+ const off = (y * png.width + x) * png.channels;
+ const r = png.rows[off];
+ return {
+ r,
+ g: png.channels === 1 ? r : png.rows[off + 1],
+ b: png.channels === 1 ? r : png.rows[off + 2],
+ };
+}
+
+function countChangedPixels(a, b, region = {}) {
+ if (a.width !== b.width || a.height !== b.height) {
+ throw new Error(`png sizes differ: ${a.width}x${a.height} vs ${b.width}x${b.height}`);
+ }
+ const x0 = Math.max(0, Math.floor(region.x0 ?? 0));
+ const y0 = Math.max(0, Math.floor(region.y0 ?? 0));
+ const x1 = Math.min(a.width, Math.ceil(region.x1 ?? a.width));
+ const y1 = Math.min(a.height, Math.ceil(region.y1 ?? a.height));
+ let changed = 0;
+ for (let y = y0; y < y1; y += 1) {
+ for (let x = x0; x < x1; x += 1) {
+ const ap = pixelRgb(a, x, y);
+ const bp = pixelRgb(b, x, y);
+ const dr = Math.abs(ap.r - bp.r);
+ const dg = Math.abs(ap.g - bp.g);
+ const db = Math.abs(ap.b - bp.b);
+ if (dr + dg + db > 45 && Math.max(dr, dg, db) > 12) changed += 1;
+ }
+ }
+ return changed;
+}
+
+async function screenshotPng(page) {
+ return parsePng(await page.screenshot({ fullPage: true }));
+}
+
+async function waitForNextDraw(page, action) {
+ const before = await page.evaluate(() => window.__pardesReplayE2E?.draws ?? 0);
+ await action();
+ await page.waitForFunction((draws) => (window.__pardesReplayE2E?.draws ?? 0) > draws, before, { timeout: 5000 });
+ await page.waitForTimeout(50);
+}
+
+async function findHoverBoundary(page, baseline) {
+ const canvasSize = await page.evaluate(() => {
+ const canvas = document.querySelector("canvas");
+ return { width: canvas.clientWidth, height: canvas.clientHeight };
+ });
+ const y = Math.floor(canvasSize.height * 0.55);
+ const center = canvasSize.width / 2;
+ const offsets = [];
+ for (let dx = -48; dx <= 48; dx += 3) offsets.push(dx);
+
+ for (const dx of offsets) {
+ const x = Math.round(center + dx);
+ await waitForNextDraw(page, () => page.mouse.move(x, y));
+ const hover = await screenshotPng(page);
+ const changed = countChangedPixels(baseline, hover, {
+ x0: x - 20,
+ x1: x + 20,
+ y0: 20,
+ y1: canvasSize.height - 20,
+ });
+ if (changed >= 80) return { x, y, changed, canvasSize };
+ }
+
+ throw new Error("browser replay hover did not visibly draw the dashed resize handle");
+}
+
const { server, url } = await serve(webDir);
let cleanupPlaywright = async () => {};
let browser;
@@ -263,6 +332,21 @@ try {
}, backingScale, { timeout: 5000 });
await page.waitForFunction(() => window.__pardesReplayE2E?.draws >= 3, null, { timeout: 5000 });
+ const baseline = await screenshotPng(page);
+ const hover = await findHoverBoundary(page, baseline);
+ console.log(`browser replay hover ok: changed=${hover.changed} x=${hover.x} y=${hover.y}`);
+
+ const dragToX = Math.min(hover.canvasSize.width - 24, hover.x + 120);
+ await waitForNextDraw(page, () => page.mouse.down());
+ await waitForNextDraw(page, () => page.mouse.move(dragToX, hover.y, { steps: 8 }));
+ await waitForNextDraw(page, () => page.mouse.up());
+ const dragged = await screenshotPng(page);
+ const dragChanged = countChangedPixels(baseline, dragged);
+ if (dragChanged < 2500) {
+ throw new Error(`browser replay drag did not visibly resize panes: changed=${dragChanged}`);
+ }
+ console.log(`browser replay drag ok: changed=${dragChanged}`);
+
await page.screenshot({ path: screenshotPath, fullPage: true });
validateScreenshot(parsePng(await readFile(screenshotPath)));
} finally {
diff --git a/ui_sdl.zig b/ui_sdl.zig
index 8fbc78f5..a86d02f4 100644
--- a/ui_sdl.zig
+++ b/ui_sdl.zig
@@ -66,7 +66,7 @@ pub fn Loop(comptime EventType: type) type {
return SdlLoop(EventType);
}
-const font_ttf = @embedFile("assets/MapleMono-NF-Regular.ttf");
+const font_ttf = @embedFile("assets/AdwaitaMono-Regular.ttf");
const vert_spv_bytes = if (is_emscripten) "" else @embedFile("ui.vert.spv");
const frag_spv_bytes = if (is_emscripten) "" else @embedFile("ui.frag.spv");
const vert_glsl_es = if (is_emscripten) @embedFile("ui.vert.es.glsl") else "";
@@ -932,6 +932,20 @@ fn floorToI16(value: f32) i16 {
return @intFromFloat(floored);
}
+fn mousePixel(self: *const SdlBackend, x: f32, y: f32, pixel_w: u32, pixel_h: u32) struct { x: f32, y: f32 } {
+ if (!is_emscripten) return .{ .x = x, .y = y };
+
+ var ww: c_int = 0;
+ var wh: c_int = 0;
+ _ = c.SDL_GetWindowSize(self.sdl_window, &ww, &wh);
+ if (ww <= 0 or wh <= 0) return .{ .x = x, .y = y };
+
+ return .{
+ .x = x * (@as(f32, @floatFromInt(pixel_w)) / @as(f32, @floatFromInt(ww))),
+ .y = y * (@as(f32, @floatFromInt(pixel_h)) / @as(f32, @floatFromInt(wh))),
+ };
+}
+
fn mouseCell(self: *const SdlBackend, x: f32, y: f32) MouseCell {
if (!is_emscripten) return self.fixedMouseCell(x, y);
@@ -941,9 +955,10 @@ fn mouseCell(self: *const SdlBackend, x: f32, y: f32) MouseCell {
const sw: u32 = @max(1, @as(u32, @intCast(pw)));
const sh: u32 = @max(1, @as(u32, @intCast(ph)));
const layout = self.cellLayout(sw, sh);
+ const pixel = self.mousePixel(x, y, sw, sh);
return .{
- .col = floorToI16((x - layout.x_off) / layout.w),
- .row = floorToI16((y - layout.y_off) / layout.h),
+ .col = floorToI16((pixel.x - layout.x_off) / layout.w),
+ .row = floorToI16((pixel.y - layout.y_off) / layout.h),
};
}