diff options
| -rw-r--r-- | assets/AdwaitaMono-LICENSE.txt | 96 | ||||
| -rw-r--r-- | build.zig | 55 | ||||
| -rw-r--r-- | main.zig | 23 | ||||
| -rw-r--r-- | pane_ui.zig | 29 | ||||
| -rw-r--r-- | replay.zig | 22 | ||||
| -rw-r--r-- | tests.zig | 51 | ||||
| -rw-r--r-- | tests/browser_replay_dump.zon | 7 | ||||
| -rw-r--r-- | tests/browser_replay_e2e.mjs | 84 | ||||
| -rw-r--r-- | ui_sdl.zig | 21 |
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. @@ -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); } @@ -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), + } +} @@ -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; } }; @@ -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 { @@ -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), }; } |
