diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-03 14:43:30 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | d1ed5bd8f55935e6470c4a7559216119571a084f (patch) | |
| tree | 434478ce8a5dcd9aed367334816f54da9953e56d | |
| parent | 153f0a88862507cf1f5dda71f71db3d6649dfeaa (diff) | |
| download | pardes-d1ed5bd8f55935e6470c4a7559216119571a084f.tar.gz pardes-d1ed5bd8f55935e6470c4a7559216119571a084f.zip | |
fixed scaling and fitting on web ui
| -rw-r--r-- | build.zig | 36 | ||||
| -rw-r--r-- | main.zig | 292 | ||||
| -rw-r--r-- | pane_ui.zig | 273 | ||||
| -rw-r--r-- | replay.zig | 246 | ||||
| -rw-r--r-- | tests.zig | 54 | ||||
| -rw-r--r-- | tests/browser_replay_e2e.mjs | 32 | ||||
| -rw-r--r-- | ui_sdl.zig | 185 |
7 files changed, 627 insertions, 491 deletions
@@ -3,6 +3,7 @@ const std = @import("std"); const AppKind = enum { main, replay }; const GhosttyArtifact = enum { ghostty, lib }; const TreeSitterGrammars = enum { disabled, minimal, full }; +const WebFilter = enum { linear, nearest }; pub fn build(b: *std.Build) void { const requested_optimize = b.standardOptimizeOption(.{}); @@ -31,6 +32,9 @@ pub fn build(b: *std.Build) void { const sdl = b.option(bool, "sdl", "build the SDL3 GPU UI frontend instead of the terminal backend") orelse false; const sdl_backend = sdl or is_emscripten; const replay_dump = b.option([]const u8, "replay-dump", "embed this replay dump .zon into pardes-replay"); + const web_render_scale = b.option(f32, "web-render-scale", "browser replay backing/glyph scale") orelse 1.5; + if (web_render_scale <= 0) @panic("web-render-scale must be greater than zero"); + const web_filter = b.option(WebFilter, "web-filter", "browser replay filtering: linear, nearest") orelse .linear; const default_tree_sitter_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_tree_sitter_grammars; const syntax_highlighting = tree_sitter_grammars != .disabled; @@ -193,6 +197,10 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .root_source_file = ui_root, }); + const ui_sdl_config = b.addOptions(); + ui_sdl_config.addOption(f32, "web_render_scale", web_render_scale); + ui_sdl_config.addOption(WebFilter, "web_filter", web_filter); + ui_mod.addOptions("ui_sdl_config", ui_sdl_config); ui_mod.addImport("vaxis", vaxis_mod); exe.root_module.addImport("ui", ui_mod); replay.root_module.addImport("ui", ui_mod); @@ -396,8 +404,11 @@ pub fn build(b: *std.Build) void { replay_wasm_step.dependOn(&b.addInstallFileWithDir(replay_js, .{ .custom = "web" }, "pardes-replay.js").step); replay_wasm_step.dependOn(&b.addInstallFileWithDir(replay_js.dirname().path(b, "pardes-replay.wasm"), .{ .custom = "web" }, "pardes-replay.wasm").step); - const web = b.addWriteFiles(); - const html = web.add("replay.html", + 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> \\<head> @@ -405,20 +416,28 @@ pub fn build(b: *std.Build) void { \\ <meta name="viewport" content="width=device-width, initial-scale=1"> \\ <title>pardes replay</title> \\ <style> - \\ html, body { margin: 0; min-width: 100%; min-height: 100%; background: #121212; } - \\ body { overflow: auto; } - \\ canvas { display: block; } + \\ 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, + \\ } \\ </style> \\</head> \\<body> - \\ <canvas id="canvas" oncontextmenu="event.preventDefault()" tabindex="-1"></canvas> + \\ <canvas id="canvas" tabindex="0"></canvas> \\ <script> - \\ window.Module = { canvas: document.getElementById('canvas') }; + \\ const canvas = document.getElementById('canvas'); + \\ canvas.addEventListener('contextmenu', (event) => event.preventDefault()); + \\ canvas.addEventListener('pointerdown', () => canvas.focus({ preventScroll: true })); + \\ window.Module = { canvas }; \\ </script> \\ <script src="pardes-replay.js"></script> \\</body> \\</html> - ); + }) 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); } @@ -431,6 +450,7 @@ pub fn build(b: *std.Build) void { const run_browser_e2e = b.addSystemCommand(&.{ "node", "tests/browser_replay_e2e.mjs" }); run_browser_e2e.addArg("zig-out/web"); run_browser_e2e.addArg("zig-out/browser-replay.png"); + run_browser_e2e.addArg(b.fmt("{d}", .{web_render_scale})); run_browser_e2e.step.dependOn(replay_wasm_step); test_browser_step.dependOn(&run_browser_e2e.step); } @@ -6,6 +6,7 @@ const modal = @import("modal.zig"); const image = @import("image.zig"); const dump = @import("dump.zig"); const paint = @import("paint.zig"); +const pane_ui = @import("pane_ui.zig"); // A TUI can't let std.log hit stderr — it's the same tty as our alt-screen, so the // message lands on top of the UI (this is the stray text you saw when opening yazi: @@ -155,26 +156,26 @@ fn parseCli(args: []const [:0]const u8) !Cli { const Loop = vaxis.Loop(@TypeOf(Command.value)); // layout constants -const TOPBAR_H: u16 = 1; // global tagbar row at the very top (full width) -const GUTTER: u16 = 2; // left gutter: box (top) + scrollbar (below) per pane -const BOX_H: u16 = 1; // rows of the drag/rearrange box; scrollbar starts below it -const MINW: u16 = 10; // min pane width (cells) -const MINH: u16 = 3; // min pane height (cells) +const TOPBAR_H: u16 = pane_ui.TOPBAR_H; // global tagbar row at the very top (full width) +const GUTTER: u16 = pane_ui.GUTTER; // left gutter: box (top) + scrollbar (below) per pane +const BOX_H: u16 = pane_ui.BOX_H; // rows of the drag/rearrange box; scrollbar starts below it +const MINW: u16 = pane_ui.MINW; // min pane width (cells) +const MINH: u16 = pane_ui.MINH; // min pane height (cells) const MAX_TERMS: usize = 16; const MAX_COLS: usize = 6; const MAX_PTY_DRAIN_EVENTS: usize = 128; -const PREFIX_W: u16 = 5; // file-pane line-number prefix width: "%4d " +const PREFIX_W: u16 = pane_ui.PREFIX_W; // file-pane line-number prefix width: "%4d " -const Rect = struct { x: u16, y: u16, w: u16, h: u16 }; +const Rect = pane_ui.Rect; // acme model: columns own X (col_weight), windows own Y (Term.vweight). The // per-column ordered list `col_terms[c][0..col_n[c]]` is the source of truth. -const Layout = struct { - ncol: usize, - col_weight: [MAX_COLS]f32, - col_terms: [MAX_COLS][MAX_TERMS]usize, - col_n: [MAX_COLS]usize, -}; +const Layout = pane_ui.Layout(MAX_COLS, MAX_TERMS); +const layoutFindTerm = pane_ui.layoutFindTerm; +const layoutRemove = pane_ui.layoutRemove; +const layoutInsert = pane_ui.layoutInsert; +const layoutAppendColumn = pane_ui.layoutAppendColumn; +const moveTerm = pane_ui.moveTerm; const Drag = union(enum) { none, @@ -878,49 +879,6 @@ fn startReader(io: std.Io, gpa: std.mem.Allocator, loop: *Loop, t: *Term, id: us t.reader = try io.concurrent(readPty, .{ io, gpa, t.pty, id, loop }); } -// ---- layout surgery over the per-column ordered lists ---- - -fn layoutFindTerm(l: *const Layout, id: usize) ?struct { col: usize, idx: usize } { - var c: usize = 0; - while (c < l.ncol) : (c += 1) { - var k: usize = 0; - while (k < l.col_n[c]) : (k += 1) { - if (l.col_terms[c][k] == id) return .{ .col = c, .idx = k }; - } - } - return null; -} - -fn layoutRemove(l: *Layout, id: usize) void { - const f = layoutFindTerm(l, id) orelse return; - const c = f.col; - var k = f.idx; - while (k + 1 < l.col_n[c]) : (k += 1) l.col_terms[c][k] = l.col_terms[c][k + 1]; - l.col_n[c] -= 1; - if (l.col_n[c] == 0) { - // The column just emptied: hand its width to a neighbor (the column to its - // left, or right if it was the first) so the OTHER columns keep their widths - // and only that neighbor grows to fill the gap. Horizontal mirror of - // absorbVWeight; without it, wsum drops and every remaining column reflows - // sideways — an extra movement on delete. - if (l.ncol > 1) l.col_weight[if (c > 0) c - 1 else c + 1] += l.col_weight[c]; - var j = c; - while (j + 1 < l.ncol) : (j += 1) { - l.col_terms[j] = l.col_terms[j + 1]; - l.col_n[j] = l.col_n[j + 1]; - l.col_weight[j] = l.col_weight[j + 1]; - } - l.ncol -= 1; - } -} - -fn layoutInsert(l: *Layout, c: usize, idx: usize, id: usize) void { - var k = l.col_n[c]; - while (k > idx) : (k -= 1) l.col_terms[c][k] = l.col_terms[c][k - 1]; - l.col_terms[c][idx] = id; - l.col_n[c] += 1; -} - // Before deleting `id`, hand its vertical weight to a sibling (the pane above, or // below if it's the first) so the freed space goes to ONE neighbor and the rest of // the column keeps its sizes — the deletion mirror of splitBelow. Call BEFORE @@ -934,15 +892,6 @@ fn absorbVWeight(l: *const Layout, terms: *const [MAX_TERMS]?*Term, id: usize) v if (terms[sib]) |s| s.vweight += gone.vweight; } -fn layoutAppendColumn(l: *Layout, id: usize) void { - if (l.ncol >= MAX_COLS) return; - const c = l.ncol; - l.col_weight[c] = 1; - l.col_terms[c][0] = id; - l.col_n[c] = 1; - l.ncol += 1; -} - // Open a window BELOW `src` (acme-style) without rebalancing the whole column: // shrink ONLY `src` to the height of its content so far — and hand the freed // rows to the new window `nw`. `src`+`nw` together fill `src`'s old slot, so the @@ -1032,63 +981,8 @@ fn ttyForDir( return nt; } -// Overlay a resize-handle dash on a cell WITHOUT touching its colors: read the cell, -// swap only the grapheme, write it back. So the boundary hint reads as a dashed line -// over whatever content is already there — the only UI change is the character. -fn overlayDash(win: vaxis.Window, col: u16, row: u16, glyph: []const u8) void { - var cell = win.readCell(col, row) orelse return; - cell.char.grapheme = glyph; // static literal — safe under vaxis's retained render - cell.default = false; // make it draw with the kept style, not as a blank cell - win.writeCell(col, row, cell); -} - -// Tile the screen: columns by col_weight, then each column's windows by vweight. -// Last column/window absorbs rounding so the tiling always fills the screen. -// `out_*` written for every live term and live column. -fn computeGeom( - l: *const Layout, - terms: *const [MAX_TERMS]?*Term, - sw: u16, - sh: u16, - rects: *[MAX_TERMS]Rect, - col_x: *[MAX_COLS]u16, - col_w: *[MAX_COLS]u16, -) void { - if (l.ncol == 0) return; - var wsum: f32 = 0; - for (0..l.ncol) |c| wsum += l.col_weight[c]; - if (wsum <= 0) wsum = 1; - - // Panes tile edge to edge — NO reserved inter-pane gap. The cells that used to be - // an empty drag strip are now usable pane content; the resize handle is the shared - // boundary itself (a pane's right/bottom edge), surfaced as a dashed line on hover. - // Only the top row is held back, for the global tagbar. - var x: u16 = 0; - for (0..l.ncol) |c| { - const last = (c + 1 == l.ncol); - const cw: u16 = if (last) (sw -| x) else @max(1, @as(u16, @intFromFloat(@round(@as(f32, @floatFromInt(sw)) * l.col_weight[c] / wsum)))); - col_x[c] = x; - col_w[c] = cw; - - const n = l.col_n[c]; - var vsum: f32 = 0; - for (0..n) |k| { - if (terms[l.col_terms[c][k]]) |t| vsum += t.vweight; - } - if (vsum <= 0) vsum = 1; - const avail_h: u16 = sh -| TOPBAR_H; - - var y: u16 = TOPBAR_H; - for (0..n) |k| { - const id = l.col_terms[c][k]; - const t = terms[id] orelse continue; - const lastk = (k + 1 == n); - const ch: u16 = if (lastk) (sh -| y) else @max(1, @as(u16, @intFromFloat(@round(@as(f32, @floatFromInt(avail_h)) * t.vweight / vsum)))); - rects[id] = .{ .x = x, .y = y, .w = cw, .h = ch }; - y +|= ch; - } - x +|= cw; - } +fn termVWeight(terms: *const [MAX_TERMS]?*Term, id: usize) ?f32 { + return if (terms[id]) |t| t.vweight else null; } // `alloc` must be a per-frame arena, NOT freed before vx.render(): vaxis is a @@ -1137,15 +1031,7 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a // frame, so this runs every render. Nothing else (text/cursor/selection) applies. if (t.image) |*iv| { if (r.h > BOX_H) image.draw(iv, text.child(.{ .y_off = BOX_H, .height = r.h - BOX_H }), r.w - GUTTER, r.h - BOX_H); - const gut = root.child(.{ .x_off = r.x, .y_off = r.y, .width = GUTTER, .height = r.h }); - const box_bg = if (active) accent else accent_dim; - var gy: u16 = 0; - while (gy < r.h) : (gy += 1) { - const bg = if (gy < BOX_H) box_bg else T().scroll_track; - var gx: u16 = 0; - while (gx < GUTTER) : (gx += 1) - gut.writeCell(gx, gy, .{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = bg } } }); - } + pane_ui.drawGutter(root, r, active, accent, accent_dim, T().scroll_track, T().scroll_thumb, null); return; } @@ -1353,43 +1239,15 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a } } - // gutter: box (the drag/rearrange handle) on top, scrollbar below - const gut = root.child(.{ .x_off = r.x, .y_off = r.y, .width = GUTTER, .height = r.h }); - const box_bg = if (active) accent else accent_dim; - var bx: u16 = 0; - while (bx < GUTTER) : (bx += 1) { - var by: u16 = 0; - while (by < BOX_H) : (by += 1) - gut.writeCell(bx, by, .{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = box_bg } } }); - } - const track_h: usize = if (r.h > BOX_H) r.h - BOX_H else 0; - if (track_h > 0) { - // scrollbar thumb: from file lines for a file pane, else the emulator's. - var sb_total: usize = undefined; - var sb_off: usize = undefined; - var sb_len: usize = undefined; - if (t.file) |f| { - sb_total = fileLineCount(f.content); - sb_off = f.scroll; - sb_len = t.rows; - } else { - const sb = t.term.screens.active.pages.scrollbar(); - sb_total = sb.total; - sb_off = sb.offset; - sb_len = sb.len; - } - const total: usize = if (sb_total == 0) 1 else sb_total; - const pos = (track_h * sb_off) / total; - const len = @max(1, (track_h * sb_len) / total); - var gy: usize = 0; - while (gy < track_h) : (gy += 1) { - const thumb = gy >= pos and gy < pos + len; - const st_bg = if (thumb) T().scroll_thumb else T().scroll_track; - var cx: u16 = 0; - while (cx < GUTTER) : (cx += 1) - gut.writeCell(cx, @intCast(BOX_H + gy), .{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = st_bg } } }); - } - } + const scrollbar: pane_ui.Scrollbar = if (t.file) |f| .{ + .total = fileLineCount(f.content), + .offset = f.scroll, + .len = t.rows, + } else blk: { + const sb = t.term.screens.active.pages.scrollbar(); + break :blk .{ .total = sb.total, .offset = sb.offset, .len = sb.len }; + }; + pane_ui.drawGutter(root, r, active, accent, accent_dim, T().scroll_track, T().scroll_thumb, scrollbar); } // ---- modal editing helpers (wired onto Term by the key handler) ---- @@ -2750,7 +2608,7 @@ pub fn main(init: std.process.Init) !void { var rects: [MAX_TERMS]Rect = undefined; var col_x: [MAX_COLS]u16 = undefined; var col_w: [MAX_COLS]u16 = undefined; - computeGeom(&layout, &terms, screen_w, screen_h, &rects, &col_x, &col_w); + pane_ui.computeGeom(&layout, &terms, screen_w, screen_h, &rects, &col_x, &col_w, termVWeight); var drag: Drag = .none; var hover_col: u16 = 0; // last mouse position; used to tint the resize border under it @@ -2863,14 +2721,12 @@ pub fn main(init: std.process.Init) !void { if (!handled) { if (hovered) |id| { const r = rects[id]; - const in_gutter = mcol < r.x + GUTTER; - if (in_gutter and mrow < r.y + BOX_H) { + if (pane_ui.inMoveBox(r, mcol, mrow)) { drag = .{ .move = .{ .id = id, .cur_x = mcol, .cur_y = mrow } }; - } else if (in_gutter) { + } else if (pane_ui.scrollbarClickDelta(r, .left, mcol, mrow)) |delta| { active = id; if (terms[id]) |t| { - const local: i32 = @as(i32, mrow) - @as(i32, r.y + BOX_H); - paneScrollBy(t, -local); // left: scroll up to here + paneScrollBy(t, delta); // left: scroll up to here } } else if (mrow < r.y + BOX_H and terms[id] != null and terms[id].?.image == null) { // left-click on the tag row (non-image): edit the tail. @@ -2895,12 +2751,12 @@ pub fn main(init: std.process.Init) !void { const b: Button = if (mouse.button == .middle) .middle else .right; if (hovered) |id| { const r = rects[id]; - if (mcol < r.x + GUTTER and mrow >= r.y + BOX_H) { + const sb_button: pane_ui.ScrollbarButton = if (b == .right) .right else .middle; + if (pane_ui.scrollbarClickDelta(r, sb_button, mcol, mrow)) |delta| { // gutter (scrollbar): right scrolls down, mirroring left's up if (b == .right) active = id; if (terms[id]) |t| { - const local: i32 = @as(i32, mrow) - @as(i32, r.y + BOX_H); - paneScrollBy(t, if (b == .right) local else -local); + paneScrollBy(t, delta); } } else if (mcol >= r.x + GUTTER) { if (b == .right) active = id; @@ -3236,7 +3092,7 @@ pub fn main(init: std.process.Init) !void { // recompute geometry from the (possibly mutated) layout, then push any // grid-size change to each emulator + pty. Weights only change on a drag // *release*, so mid-drag the rects are unchanged and nothing resizes. - computeGeom(&layout, &terms, screen_w, screen_h, &rects, &col_x, &col_w); + pane_ui.computeGeom(&layout, &terms, screen_w, screen_h, &rects, &col_x, &col_w, termVWeight); for (&terms, 0..) |*slot, id| { if (slot.*) |t| { const r = rects[id]; @@ -3301,49 +3157,19 @@ pub fn main(init: std.process.Init) !void { // else branch, on hover. switch (drag) { .border_v => |d| { - var ry: u16 = TOPBAR_H; - while (ry < screen_h) : (ry += 1) overlayDash(win, d.cur_x, ry, "╎"); + pane_ui.drawBorderV(win, d.cur_x, screen_h); }, .border_h => |d| { - const c = d.col; - var rx: u16 = col_x[c]; - while (rx < col_x[c] + col_w[c]) : (rx += 1) overlayDash(win, rx, d.cur_y, "╌"); + pane_ui.drawBorderH(win, col_x[d.col], col_w[d.col], d.cur_y); }, .move => |d| { - // insertion indicator: dashed line at cur_y across the target column - var tc: usize = if (layout.ncol > 0) layout.ncol - 1 else 0; - for (0..layout.ncol) |c| { - if (d.cur_x >= col_x[c] and d.cur_x < col_x[c] + col_w[c]) { - tc = c; - break; - } - } - var rx: u16 = col_x[tc]; - while (rx < col_x[tc] + col_w[tc]) : (rx += 1) - win.writeCell(rx, d.cur_y, .{ .char = .{ .grapheme = "╌" }, .style = .{ .fg = .{ .rgb = gray.mid } } }); - win.writeCell(d.cur_x, d.cur_y, .{ .char = .{ .grapheme = "▌" }, .style = .{ .fg = .{ .rgb = gray.mid } } }); + 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). - var c: usize = 0; - while (c + 1 < layout.ncol) : (c += 1) { - if (hover_col != col_x[c] + col_w[c] -| 1) continue; - var ry: u16 = TOPBAR_H; - while (ry < screen_h) : (ry += 1) overlayDash(win, hover_col, ry, "╎"); - } - var cc: usize = 0; - while (cc < layout.ncol) : (cc += 1) { - if (hover_col < col_x[cc] or hover_col >= col_x[cc] + col_w[cc]) continue; - var k: usize = 0; - while (k + 1 < layout.col_n[cc]) : (k += 1) { - const r = rects[layout.col_terms[cc][k]]; - if (hover_row != r.y + r.h -| 1) continue; - var rx: u16 = col_x[cc]; - while (rx < col_x[cc] + col_w[cc]) : (rx += 1) overlayDash(win, rx, hover_row, "╌"); - } - } + pane_ui.drawHoverResizeHints(win, &layout, &rects, &col_x, &col_w, screen_h, hover_col, hover_row); }, } @@ -3383,48 +3209,6 @@ pub fn main(init: std.process.Init) !void { } } -// On box-drag release: drop the window into the column under cur_x at the -// vertical slot picked by cur_y (acme's coldragwin, minus the live retile). -fn moveTerm( - l: *Layout, - rects: *const [MAX_TERMS]Rect, - col_x: *const [MAX_COLS]u16, - col_w: *const [MAX_COLS]u16, - id: usize, - cur_x: u16, - cur_y: u16, -) void { - const src = layoutFindTerm(l, id) orelse return; - // target column under cur_x (last column absorbs anything past the right edge) - var tc: usize = if (l.ncol > 0) l.ncol - 1 else 0; - for (0..l.ncol) |c| { - if (cur_x >= col_x[c] and cur_x < col_x[c] + col_w[c]) { - tc = c; - break; - } - } - - // insertion index in the target column, ignoring the moving window itself - var ti: usize = 0; - var k: usize = 0; - while (k < l.col_n[tc]) : (k += 1) { - const cid = l.col_terms[tc][k]; - if (cid == id) continue; - const rr = rects[cid]; - if (cur_y < rr.y + rr.h / 2) break; - ti += 1; - } - - const src_solo = l.col_n[src.col] == 1; - if (tc == src.col and src_solo) return; // dragging the only window of its column: nothing to do - - layoutRemove(l, id); - if (src_solo and src.col < tc) tc -= 1; // removing src dropped a column to our left - if (tc >= l.ncol) tc = l.ncol - 1; - if (ti > l.col_n[tc]) ti = l.col_n[tc]; - layoutInsert(l, tc, ti, id); -} - // index of the run anchored exactly at (row, col), if any. fn runAt(t: *Term, row: i32, col: i32) ?usize { for (t.edits[0..t.nedits], 0..) |e, i| if (e.row == row and e.col == col) return i; diff --git a/pane_ui.zig b/pane_ui.zig new file mode 100644 index 00000000..f53fa269 --- /dev/null +++ b/pane_ui.zig @@ -0,0 +1,273 @@ +pub const TOPBAR_H: u16 = 1; +pub const GUTTER: u16 = 2; +pub const BOX_H: u16 = 1; +pub const MINW: u16 = 10; +pub const MINH: u16 = 3; +pub const PREFIX_W: u16 = 5; + +pub const Rect = struct { x: u16, y: u16, w: u16, h: u16 }; + +pub const ScrollbarButton = enum { left, middle, right }; + +pub const Scrollbar = struct { + total: usize, + offset: usize, + len: usize, +}; + +pub fn Layout(comptime max_cols: usize, comptime max_panes: usize) type { + return struct { + ncol: usize, + col_weight: [max_cols]f32, + col_terms: [max_cols][max_panes]usize, + col_n: [max_cols]usize, + }; +} + +pub fn layoutFindTerm(l: anytype, id: usize) ?struct { col: usize, idx: usize } { + var c: usize = 0; + while (c < l.ncol) : (c += 1) { + var k: usize = 0; + while (k < l.col_n[c]) : (k += 1) { + if (l.col_terms[c][k] == id) return .{ .col = c, .idx = k }; + } + } + return null; +} + +pub fn layoutRemove(l: anytype, id: usize) void { + const f = layoutFindTerm(l, id) orelse return; + const c = f.col; + var k = f.idx; + while (k + 1 < l.col_n[c]) : (k += 1) l.col_terms[c][k] = l.col_terms[c][k + 1]; + l.col_n[c] -= 1; + if (l.col_n[c] == 0) { + if (l.ncol > 1) l.col_weight[if (c > 0) c - 1 else c + 1] += l.col_weight[c]; + var j = c; + while (j + 1 < l.ncol) : (j += 1) { + l.col_terms[j] = l.col_terms[j + 1]; + l.col_n[j] = l.col_n[j + 1]; + l.col_weight[j] = l.col_weight[j + 1]; + } + l.ncol -= 1; + } +} + +pub fn layoutInsert(l: anytype, c: usize, idx: usize, id: usize) void { + var k = l.col_n[c]; + while (k > idx) : (k -= 1) l.col_terms[c][k] = l.col_terms[c][k - 1]; + l.col_terms[c][idx] = id; + l.col_n[c] += 1; +} + +pub fn layoutAppendColumn(l: anytype, id: usize) void { + if (l.ncol >= l.col_weight.len) return; + const c = l.ncol; + l.col_weight[c] = 1; + l.col_terms[c][0] = id; + l.col_n[c] = 1; + l.ncol += 1; +} + +pub fn computeGeom( + l: anytype, + items: anytype, + sw: u16, + sh: u16, + rects: anytype, + col_x: anytype, + col_w: anytype, + comptime vweightFn: anytype, +) void { + if (l.ncol == 0) return; + var wsum: f32 = 0; + for (0..l.ncol) |c| wsum += l.col_weight[c]; + if (wsum <= 0) wsum = 1; + + var x: u16 = 0; + for (0..l.ncol) |c| { + const last = c + 1 == l.ncol; + const cw: u16 = if (last) (sw -| x) else @max(1, @as(u16, @intFromFloat(@round(@as(f32, @floatFromInt(sw)) * l.col_weight[c] / wsum)))); + col_x[c] = x; + col_w[c] = cw; + + var vsum: f32 = 0; + for (0..l.col_n[c]) |k| { + if (vweightFn(items, l.col_terms[c][k])) |vweight| vsum += vweight; + } + if (vsum <= 0) vsum = 1; + + var y: u16 = TOPBAR_H; + const avail_h = sh -| TOPBAR_H; + for (0..l.col_n[c]) |k| { + const id = l.col_terms[c][k]; + const vweight = vweightFn(items, id) orelse continue; + const lastk = k + 1 == l.col_n[c]; + const ch: u16 = if (lastk) (sh -| y) else @max(1, @as(u16, @intFromFloat(@round(@as(f32, @floatFromInt(avail_h)) * vweight / vsum)))); + rects[id] = .{ .x = x, .y = y, .w = cw, .h = ch }; + y +|= ch; + } + x +|= cw; + } +} + +pub fn moveTerm( + l: anytype, + rects: anytype, + col_x: anytype, + col_w: anytype, + id: usize, + cur_x: u16, + cur_y: u16, +) void { + const src = layoutFindTerm(l, id) orelse return; + var tc: usize = if (l.ncol > 0) l.ncol - 1 else 0; + for (0..l.ncol) |c| { + if (cur_x >= col_x[c] and cur_x < col_x[c] + col_w[c]) { + tc = c; + break; + } + } + + var ti: usize = 0; + var k: usize = 0; + while (k < l.col_n[tc]) : (k += 1) { + const cid = l.col_terms[tc][k]; + if (cid == id) continue; + const r = rects[cid]; + if (cur_y < r.y + r.h / 2) break; + ti += 1; + } + + const src_solo = l.col_n[src.col] == 1; + if (tc == src.col and src_solo) return; + layoutRemove(l, id); + if (src_solo and src.col < tc) tc -= 1; + if (tc >= l.ncol) tc = l.ncol - 1; + if (ti > l.col_n[tc]) ti = l.col_n[tc]; + layoutInsert(l, tc, ti, id); +} + +pub fn paneAt(rects: anytype, pane_count: usize, col: u16, row: u16) ?usize { + for (0..pane_count) |i| { + const r = rects[i]; + if (col >= r.x and col < r.x + r.w and row >= r.y and row < r.y + r.h) return i; + } + return null; +} + +pub fn paneAtSlots(rects: anytype, slots: anytype, col: u16, row: u16) ?usize { + for (slots.*, 0..) |slot, i| { + if (slot == null) continue; + const r = rects[i]; + if (col >= r.x and col < r.x + r.w and row >= r.y and row < r.y + r.h) return i; + } + return null; +} + +pub fn inMoveBox(r: Rect, col: u16, row: u16) bool { + return col < r.x + GUTTER and row < r.y + BOX_H; +} + +pub fn scrollbarClickDelta(r: Rect, button: ScrollbarButton, col: u16, row: u16) ?i32 { + if (col >= r.x + GUTTER or row < r.y + BOX_H) return null; + const local: i32 = @as(i32, row) - @as(i32, r.y + BOX_H); + return switch (button) { + .left, .middle => -local, + .right => local, + }; +} + +pub fn drawGutter( + root: anytype, + r: Rect, + active: bool, + active_box: [3]u8, + inactive_box: [3]u8, + track: [3]u8, + thumb: [3]u8, + scrollbar: ?Scrollbar, +) void { + const gut = root.child(.{ .x_off = r.x, .y_off = r.y, .width = GUTTER, .height = r.h }); + const box_bg = if (active) active_box else inactive_box; + var gy: u16 = 0; + while (gy < r.h) : (gy += 1) { + const bg = if (gy < BOX_H) box_bg else track; + var gx: u16 = 0; + while (gx < GUTTER) : (gx += 1) { + gut.writeCell(gx, gy, .{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = bg } } }); + } + } + + const sb = scrollbar orelse return; + const track_h: usize = if (r.h > BOX_H) r.h - BOX_H else 0; + if (track_h == 0) return; + const total = if (sb.total == 0) 1 else sb.total; + const len = @max(1, (track_h * @max(1, sb.len)) / total); + const pos = (track_h * sb.offset) / total; + var sy: usize = 0; + while (sy < track_h) : (sy += 1) { + if (sy < pos or sy >= pos + len) continue; + var gx: u16 = 0; + while (gx < GUTTER) : (gx += 1) { + gut.writeCell(gx, @intCast(BOX_H + sy), .{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = thumb } } }); + } + } +} + +pub fn overlayDash(win: anytype, col: u16, row: u16, glyph: []const u8) void { + var cell = win.readCell(col, row) orelse return; + cell.char.grapheme = glyph; + cell.default = false; + win.writeCell(col, row, cell); +} + +pub fn drawBorderV(win: anytype, col: u16, screen_h: u16) void { + var row: u16 = TOPBAR_H; + while (row < screen_h) : (row += 1) overlayDash(win, col, row, "╎"); +} + +pub fn drawBorderH(win: anytype, x: u16, width: u16, row: u16) void { + var col = x; + while (col < x + width) : (col += 1) overlayDash(win, col, row, "╌"); +} + +pub fn drawMovePreview(win: anytype, l: anytype, col_x: anytype, col_w: anytype, cur_x: u16, cur_y: u16, color: [3]u8) void { + var tc: usize = if (l.ncol > 0) l.ncol - 1 else 0; + for (0..l.ncol) |c| { + if (cur_x >= col_x[c] and cur_x < col_x[c] + col_w[c]) { + tc = c; + break; + } + } + var col: u16 = col_x[tc]; + while (col < col_x[tc] + col_w[tc]) : (col += 1) { + win.writeCell(col, cur_y, .{ .char = .{ .grapheme = "╌" }, .style = .{ .fg = .{ .rgb = color } } }); + } + win.writeCell(cur_x, cur_y, .{ .char = .{ .grapheme = "▌" }, .style = .{ .fg = .{ .rgb = color } } }); +} + +pub fn drawHoverResizeHints( + win: anytype, + l: anytype, + rects: anytype, + col_x: anytype, + col_w: anytype, + screen_h: u16, + hover_col: u16, + hover_row: u16, +) void { + var c: usize = 0; + while (c + 1 < l.ncol) : (c += 1) { + if (hover_col == col_x[c] + col_w[c] -| 1) drawBorderV(win, hover_col, screen_h); + } + var cc: usize = 0; + while (cc < l.ncol) : (cc += 1) { + if (hover_col < col_x[cc] or hover_col >= col_x[cc] + col_w[cc]) continue; + var k: usize = 0; + while (k + 1 < l.col_n[cc]) : (k += 1) { + const r = rects[l.col_terms[cc][k]]; + if (hover_row == r.y + r.h -| 1) drawBorderH(win, col_x[cc], col_w[cc], hover_row); + } + } +} @@ -6,6 +6,7 @@ const replay_config = @import("replay_config"); const dump = @import("dump.zig"); const paint = @import("paint.zig"); const image = @import("image.zig"); +const pane_ui = @import("pane_ui.zig"); const is_emscripten = builtin.os.tag == .emscripten; const embedded_dump = if (replay_config.embedded_dump) @embedFile("replay.dump.zon") else ""; @@ -25,14 +26,14 @@ pub const std_options_debug_io: std.Io = if (is_emscripten) else std_options_debug_threaded_io.?.io(); -const TOPBAR_H: u16 = 1; -const GUTTER: u16 = 2; -const BOX_H: u16 = 1; -const MINW: u16 = 10; -const MINH: u16 = 3; -const PREFIX_W: u16 = 5; +const TOPBAR_H: u16 = pane_ui.TOPBAR_H; +const GUTTER: u16 = pane_ui.GUTTER; +const BOX_H: u16 = pane_ui.BOX_H; +const MINW: u16 = pane_ui.MINW; +const MINH: u16 = pane_ui.MINH; +const PREFIX_W: u16 = pane_ui.PREFIX_W; -const Rect = struct { x: u16, y: u16, w: u16, h: u16 }; +const Rect = pane_ui.Rect; const ReplayTerm = struct { term: ghostty_vt.Terminal, @@ -60,12 +61,8 @@ const Pane = struct { } }; -const Layout = struct { - ncol: usize, - col_weight: [dump.max_cols]f32, - col_terms: [dump.max_cols][dump.max_panes]usize, - col_n: [dump.max_cols]usize, -}; +const Layout = pane_ui.Layout(dump.max_cols, dump.max_panes); +const moveTerm = pane_ui.moveTerm; const Drag = union(enum) { none, @@ -164,115 +161,8 @@ fn terminalBodyText(alloc: std.mem.Allocator, p: *Pane) ![]const u8 { return out.toOwnedSlice(alloc); } -fn layoutFindTerm(l: *const Layout, id: usize) ?struct { col: usize, idx: usize } { - var c: usize = 0; - while (c < l.ncol) : (c += 1) { - var k: usize = 0; - while (k < l.col_n[c]) : (k += 1) { - if (l.col_terms[c][k] == id) return .{ .col = c, .idx = k }; - } - } - return null; -} - -fn layoutRemove(l: *Layout, id: usize) void { - const f = layoutFindTerm(l, id) orelse return; - const c = f.col; - var k = f.idx; - while (k + 1 < l.col_n[c]) : (k += 1) l.col_terms[c][k] = l.col_terms[c][k + 1]; - l.col_n[c] -= 1; - if (l.col_n[c] == 0 and l.ncol > 1) { - l.col_weight[if (c > 0) c - 1 else c + 1] += l.col_weight[c]; - var j = c; - while (j + 1 < l.ncol) : (j += 1) { - l.col_terms[j] = l.col_terms[j + 1]; - l.col_n[j] = l.col_n[j + 1]; - l.col_weight[j] = l.col_weight[j + 1]; - } - l.ncol -= 1; - } -} - -fn layoutInsert(l: *Layout, c: usize, idx: usize, id: usize) void { - var k = l.col_n[c]; - while (k > idx) : (k -= 1) l.col_terms[c][k] = l.col_terms[c][k - 1]; - l.col_terms[c][idx] = id; - l.col_n[c] += 1; -} - -fn computeGeom( - l: *const Layout, - panes: []const Pane, - sw: u16, - sh: u16, - rects: *[dump.max_panes]Rect, - col_x: *[dump.max_cols]u16, - col_w: *[dump.max_cols]u16, -) void { - if (l.ncol == 0) return; - var wsum: f32 = 0; - for (0..l.ncol) |c| wsum += l.col_weight[c]; - if (wsum <= 0) wsum = 1; - - var x: u16 = 0; - for (0..l.ncol) |c| { - const last = c + 1 == l.ncol; - const cw: u16 = if (last) (sw -| x) else @max(1, @as(u16, @intFromFloat(@round(@as(f32, @floatFromInt(sw)) * l.col_weight[c] / wsum)))); - col_x[c] = x; - col_w[c] = cw; - - var vsum: f32 = 0; - for (0..l.col_n[c]) |k| vsum += panes[l.col_terms[c][k]].vweight; - if (vsum <= 0) vsum = 1; - - var y: u16 = TOPBAR_H; - const avail_h = sh -| TOPBAR_H; - for (0..l.col_n[c]) |k| { - const id = l.col_terms[c][k]; - const lastk = k + 1 == l.col_n[c]; - const ch: u16 = if (lastk) (sh -| y) else @max(1, @as(u16, @intFromFloat(@round(@as(f32, @floatFromInt(avail_h)) * panes[id].vweight / vsum)))); - rects[id] = .{ .x = x, .y = y, .w = cw, .h = ch }; - y +|= ch; - } - x +|= cw; - } -} - -fn moveTerm( - l: *Layout, - rects: *const [dump.max_panes]Rect, - col_x: *const [dump.max_cols]u16, - col_w: *const [dump.max_cols]u16, - id: usize, - cur_x: u16, - cur_y: u16, -) void { - const src = layoutFindTerm(l, id) orelse return; - var tc: usize = if (l.ncol > 0) l.ncol - 1 else 0; - for (0..l.ncol) |c| { - if (cur_x >= col_x[c] and cur_x < col_x[c] + col_w[c]) { - tc = c; - break; - } - } - - var ti: usize = 0; - var k: usize = 0; - while (k < l.col_n[tc]) : (k += 1) { - const cid = l.col_terms[tc][k]; - if (cid == id) continue; - const r = rects[cid]; - if (cur_y < r.y + r.h / 2) break; - ti += 1; - } - - const src_solo = l.col_n[src.col] == 1; - if (tc == src.col and src_solo) return; - layoutRemove(l, id); - if (src_solo and src.col < tc) tc -= 1; - if (tc >= l.ncol) tc = l.ncol - 1; - if (ti > l.col_n[tc]) ti = l.col_n[tc]; - layoutInsert(l, tc, ti, id); +fn paneVWeight(panes: []const Pane, id: usize) ?f32 { + return panes[id].vweight; } fn renderPane(alloc: std.mem.Allocator, root: ui.Window, p: *Pane, r: Rect, active: bool) !void { @@ -291,16 +181,7 @@ fn renderPane(alloc: std.mem.Allocator, root: ui.Window, p: *Pane, r: Rect, acti if (p.image_view) |*iv| { if (body_h > 0) image.draw(iv, text.child(.{ .y_off = BOX_H, .height = body_h }), body_w, body_h); - const gut = root.child(.{ .x_off = r.x, .y_off = r.y, .width = GUTTER, .height = r.h }); - const box_bg = if (active) accent else accent_dim; - var gy: u16 = 0; - while (gy < r.h) : (gy += 1) { - const bg = if (gy < BOX_H) box_bg else T().scroll_track; - var gx: u16 = 0; - while (gx < GUTTER) : (gx += 1) { - gut.writeCell(gx, gy, .{ .char = .{ .grapheme = " " }, .style = .{ .bg = rgbColor(bg) } }); - } - } + pane_ui.drawGutter(root, r, active, accent, accent_dim, T().scroll_track, T().scroll_thumb, null); return; } @@ -381,44 +262,15 @@ fn renderPane(alloc: std.mem.Allocator, root: ui.Window, p: *Pane, r: Rect, acti } } - const gut = root.child(.{ .x_off = r.x, .y_off = r.y, .width = GUTTER, .height = r.h }); - const box_bg = if (active) accent else accent_dim; - var gy: u16 = 0; - while (gy < r.h) : (gy += 1) { - const bg = if (gy < BOX_H) box_bg else T().scroll_track; - var gx: u16 = 0; - while (gx < GUTTER) : (gx += 1) { - gut.writeCell(gx, gy, .{ .char = .{ .grapheme = " " }, .style = .{ .bg = rgbColor(bg) } }); - } - } - - const track_h: usize = if (r.h > BOX_H) r.h - BOX_H else 0; - if (track_h > 0) { - var sb_total: usize = undefined; - var sb_off: usize = undefined; - var sb_len: usize = undefined; - if (p.terminal) |*rt| { - const sb = rt.term.screens.active.pages.scrollbar(); - sb_total = sb.total; - sb_off = sb.offset; - sb_len = sb.len; - } else { - sb_total = lineCount(p.content); - sb_off = p.scroll; - sb_len = p.rows; - } - const total = if (sb_total == 0) 1 else sb_total; - const len = @max(1, (track_h * @max(1, sb_len)) / total); - const pos = (track_h * sb_off) / total; - var sy: usize = 0; - while (sy < track_h) : (sy += 1) { - if (sy < pos or sy >= pos + len) continue; - var gx: u16 = 0; - while (gx < GUTTER) : (gx += 1) { - gut.writeCell(gx, @intCast(BOX_H + sy), .{ .char = .{ .grapheme = " " }, .style = .{ .bg = rgbColor(T().scroll_thumb) } }); - } - } - } + const scrollbar: pane_ui.Scrollbar = if (p.terminal) |*rt| blk: { + const sb = rt.term.screens.active.pages.scrollbar(); + break :blk .{ .total = sb.total, .offset = sb.offset, .len = sb.len }; + } else .{ + .total = lineCount(p.content), + .offset = p.scroll, + .len = p.rows, + }; + pane_ui.drawGutter(root, r, active, accent, accent_dim, T().scroll_track, T().scroll_thumb, scrollbar); } fn render( @@ -428,8 +280,13 @@ fn render( state: dump.State, panes: []Pane, rects: *[dump.max_panes]Rect, + layout: *const Layout, + col_x: *const [dump.max_cols]u16, + col_w: *const [dump.max_cols]u16, screen_w: u16, + screen_h: u16, active: usize, + drag: Drag, ) !void { var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); @@ -452,15 +309,14 @@ 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 }); - try vx.render(writer); -} - -fn paneAt(rects: *const [dump.max_panes]Rect, pane_count: usize, col: u16, row: u16) ?usize { - for (0..pane_count) |i| { - const r = rects[i]; - if (col >= r.x and col < r.x + r.w and row >= r.y and row < r.y + r.h) return i; + 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 => {}, } - return null; + + try vx.render(writer); } fn deinitPanes(gpa: std.mem.Allocator, panes: []Pane) void { @@ -626,7 +482,7 @@ const App = struct { try self.loop.start(); try self.loop.installResizeHandler(); - computeGeom(&self.layout, self.panes, self.screen_w, self.screen_h, &self.rects, &self.col_x, &self.col_w); + pane_ui.computeGeom(&self.layout, self.panes, self.screen_w, self.screen_h, &self.rects, &self.col_x, &self.col_w, paneVWeight); } fn deinit(self: *App) void { @@ -655,16 +511,14 @@ const App = struct { fn step(self: *App, event: @TypeOf(Command.value)) !bool { switch (event) { .winsize => |ws| { - if (!is_emscripten) { - self.screen_w = ws.cols; - self.screen_h = ws.rows; - try self.vx.resize(self.gpa, self.tty.writer(), ws); - } + self.screen_w = ws.cols; + self.screen_h = ws.rows; + try self.vx.resize(self.gpa, self.tty.writer(), ws); }, .mouse => |mouse| { const mcol: u16 = if (mouse.col < 0) 0 else @min(@as(u16, @intCast(mouse.col)), self.screen_w -| 1); const mrow: u16 = if (mouse.row < 0) 0 else @min(@as(u16, @intCast(mouse.row)), self.screen_h -| 1); - const hovered = paneAt(&self.rects, self.panes.len, mcol, mrow); + const hovered = pane_ui.paneAt(&self.rects, self.panes.len, mcol, mrow); if (mouse.button == .wheel_down and mouse.type == .press) { if (hovered) |id| scrollBy(&self.panes[id], 1); @@ -698,13 +552,23 @@ const App = struct { if (!handled) if (hovered) |id| { self.active = id; const r = self.rects[id]; - if (mcol < r.x + GUTTER and mrow < r.y + BOX_H) { + if (pane_ui.inMoveBox(r, mcol, mrow)) { self.drag = .{ .move = .{ .id = id, .cur_x = mcol, .cur_y = mrow } }; - } else if (mcol < r.x + GUTTER) { - const local: i32 = @as(i32, mrow) - @as(i32, r.y + BOX_H); - scrollBy(&self.panes[id], -local); + } else if (pane_ui.scrollbarClickDelta(r, .left, mcol, mrow)) |delta| { + scrollBy(&self.panes[id], delta); } }; + } else if (mouse.type == .press and (mouse.button == .middle or mouse.button == .right)) { + if (hovered) |id| { + const r = self.rects[id]; + const sb_button: pane_ui.ScrollbarButton = if (mouse.button == .right) .right else .middle; + if (pane_ui.scrollbarClickDelta(r, sb_button, mcol, mrow)) |delta| { + if (mouse.button == .right) self.active = id; + scrollBy(&self.panes[id], delta); + } else { + self.active = id; + } + } } else if (mouse.type == .press) { if (hovered) |id| self.active = id; } else if (mouse.type == .drag) { @@ -775,7 +639,7 @@ const App = struct { else => {}, } - computeGeom(&self.layout, self.panes, self.screen_w, self.screen_h, &self.rects, &self.col_x, &self.col_w); + pane_ui.computeGeom(&self.layout, self.panes, self.screen_w, self.screen_h, &self.rects, &self.col_x, &self.col_w, paneVWeight); for (self.panes, 0..) |*p, id| { const r = self.rects[id]; const cols = @max(1, r.w -| GUTTER); @@ -787,7 +651,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.screen_w, self.active); + 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); return true; } }; @@ -67,6 +67,40 @@ fn writeReplayFixture(io: std.Io, gpa: std.mem.Allocator, path: []const u8) !voi }); } +fn writeReplayScrollFixture(io: std.Io, gpa: std.mem.Allocator, path: []const u8) !void { + var file_content: std.ArrayList(u8) = .empty; + defer file_content.deinit(gpa); + var line_no: usize = 1; + while (line_no <= 80) : (line_no += 1) { + const line = try std.fmt.allocPrint(gpa, "replay scroll line {d}\n", .{line_no}); + defer gpa.free(line); + try file_content.appendSlice(gpa, line); + } + const content_b64 = try dump.encodeBytes(gpa, file_content.items); + defer gpa.free(content_b64); + const panes = [_]dump.Pane{.{ + .kind = .file, + .tag = "nm /tmp/replay_scroll_fixture.zig Save Del", + .body = "", + .cols = 78, + .rows = 22, + .file = .{ + .path = "/tmp/replay_scroll_fixture.zig", + .content = file_content.items, + .content_b64 = content_b64, + }, + }}; + const col_panes = [_]usize{0}; + const columns = [_]dump.Column{.{ .weight = 1, .panes = &col_panes }}; + try dump.writeFile(io, gpa, path, .{ + .screen = .{ .cols = 80, .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{.{ @@ -178,6 +212,22 @@ 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-scroll-fixture.zon"; + try writeReplayScrollFixture(io, gpa, fixture_path); + var replay_scroll = try Harness.initArgs(gpa, replay_exe.ptr, 24, 80, fixture_path); + replay_scroll.trace = config.trace; + defer replay_scroll.deinit(); + try replay_scroll.pump(1200); + try replay_scroll.expectContains(" 1 replay scroll line 1", "replay scrollbar fixture did not start at the top"); + try replay_scroll.send("\x1b[<2;1;23M\x1b[<2;1;23m"); + try replay_scroll.pump(300); + try replay_scroll.expectContains(" 21 replay scroll line 21", "replay right-click scrollbar did not scroll down"); + try replay_scroll.expectNotContains(" 1 replay scroll line 1", "replay right-click scrollbar left the top line visible"); + try replay_scroll.send("\x1b[<0;1;23M\x1b[<0;1;23m"); + try replay_scroll.pump(300); + try replay_scroll.expectContains(" 1 replay scroll line 1", "replay left-click scrollbar did not scroll back up"); + } + { const fixture_path = "/tmp/pardes-replay-image.zon"; try writeReplayImageFixture(io, gpa, fixture_path); var replay_image = try Harness.initArgs(gpa, replay_exe.ptr, 24, 80, fixture_path); @@ -417,13 +467,13 @@ pub fn main(init: std.process.Init) !void { } try hs.resize(30, 100); // clean geometry (right column starts ~col 51) try hs.pump(700); // settle the reflow - try hs.send("\x1b[<0;60;19M\x1b[<0;60;19m"); // left-click a blank row well below the ls output, right column -> normal + pin (+1 row: global tagbar) + try hs.send("\x1b[<0;60;26M\x1b[<0;60;26m"); // left-click a blank row well below the ls output, right column -> normal + pin (+1 row: global tagbar) try hs.pump(400); try hs.send("i"); // normal -> insert so typing composes the path:line try hs.pump(400); try hs.send("/tmp/pardesE2Efile.txt:150"); try hs.pump(800); // build the run - try hs.send("\x1b[<2;70;19M\x1b[<2;70;19m"); // right-click on the typed path (+1 row: global tagbar) + try hs.send("\x1b[<2;70;26M\x1b[<2;70;26m"); // right-click on the typed path (+1 row: global tagbar) try hs.expectWaitFor("ZZDEEPMARK", 20000, "right-click 'path:150' did not open the file scrolled to line 150"); try hs.expectContains("Save Del", "file pane tag did not include the default Save command"); try hs.expectNotContains("Delcol", "Delcol is still shown in the default pane tag"); diff --git a/tests/browser_replay_e2e.mjs b/tests/browser_replay_e2e.mjs index 33267bbb..f0d85bf8 100644 --- a/tests/browser_replay_e2e.mjs +++ b/tests/browser_replay_e2e.mjs @@ -8,6 +8,10 @@ import { inflateSync } from "node:zlib"; const webDir = resolve(process.argv[2] ?? "zig-out/web"); const screenshotPath = resolve(process.argv[3] ?? "zig-out/browser-replay.png"); +const backingScale = Number(process.argv[4] ?? "1.5"); +if (!Number.isFinite(backingScale) || backingScale <= 0) { + throw new Error(`invalid browser backing scale: ${process.argv[4]}`); +} const mime = new Map([ [".html", "text/html; charset=utf-8"], @@ -231,6 +235,34 @@ try { const lostMessage = consoleMessages.find((msg) => msg.includes("CONTEXT_LOST_WEBGL")); if (lostMessage) throw new Error(`browser WebGL context lost: ${lostMessage}`); + const initialCanvasSize = await page.evaluate(() => { + const canvas = document.querySelector("canvas"); + return { + clientWidth: canvas.clientWidth, + clientHeight: canvas.clientHeight, + width: canvas.width, + height: canvas.height, + }; + }); + if ( + initialCanvasSize.clientWidth !== 1280 || + initialCanvasSize.clientHeight !== 720 || + initialCanvasSize.width !== Math.round(1280 * backingScale) || + initialCanvasSize.height !== Math.round(720 * backingScale) + ) { + throw new Error(`browser replay canvas did not fill initial viewport: ${JSON.stringify(initialCanvasSize)}`); + } + + await page.setViewportSize({ width: 913, height: 617 }); + await page.waitForFunction((scale) => { + const canvas = document.querySelector("canvas"); + return canvas?.clientWidth === 913 && + canvas?.clientHeight === 617 && + canvas?.width === Math.round(913 * scale) && + canvas?.height === Math.round(617 * scale); + }, backingScale, { timeout: 5000 }); + await page.waitForFunction(() => window.__pardesReplayE2E?.draws >= 3, null, { timeout: 5000 }); + await page.screenshot({ path: screenshotPath, fullPage: true }); validateScreenshot(parsePng(await readFile(screenshotPath))); } finally { @@ -22,6 +22,7 @@ const std = @import("std"); const builtin = @import("builtin"); const vaxis = @import("vaxis"); +const ui_sdl_config = @import("ui_sdl_config"); const posix = std.posix; const linux = std.os.linux; const is_emscripten = builtin.os.tag == .emscripten; @@ -33,6 +34,9 @@ pub const c = @cImport({ @cInclude("font.h"); }); +extern "c" fn emscripten_get_element_css_size(target: [*:0]const u8, width: *f64, height: *f64) c_int; +const EMSCRIPTEN_RESULT_SUCCESS: c_int = 0; + // ---- shared (backend-agnostic) types: re-exported straight from libvaxis ---- pub const Key = vaxis.Key; pub const Mouse = vaxis.Mouse; @@ -69,6 +73,8 @@ const vert_glsl_es = if (is_emscripten) @embedFile("ui.vert.es.glsl") else ""; const frag_glsl_es = if (is_emscripten) @embedFile("ui.frag.es.glsl") else ""; const log = std.log.scoped(.ui_sdl); +const web_render_scale: f32 = ui_sdl_config.web_render_scale; +const web_render_scale_f64: f64 = @floatCast(web_render_scale); // A standard 16-color ANSI palette (xterm), used to resolve vaxis.Color.index // cells — the dark theme passes palette indices through (palColor), and there's @@ -161,8 +167,8 @@ reported_cols: u16 = 0, reported_rows: u16 = 0, // ---- constants + helper types (must come after all fields in Zig 0.16) ---- -pub const atlas_w: u32 = 1024; -pub const atlas_h: u32 = 1024; +pub const atlas_w: u32 = if (is_emscripten) 2048 else 1024; +pub const atlas_h: u32 = if (is_emscripten) 2048 else 1024; pub const Slot = struct { u: u32, v: u32 }; pub const CellKey = packed struct { cp: u32, fg: u32, bg: u32 }; pub const DirtyRange = struct { start: u32, count: u32 }; @@ -186,6 +192,25 @@ const CellInstance = extern struct { bb: f32, }; +const CellLayout = struct { + w: f32, + h: f32, + x_off: f32, + y_off: f32, +}; + +const MouseCell = struct { + col: i16, + row: i16, +}; + +fn webGlFilter() c_int { + return switch (ui_sdl_config.web_filter) { + .linear => c.GL_LINEAR, + .nearest => c.GL_NEAREST, + }; +} + fn envU16(env_map: *std.process.Environ.Map, name: []const u8) ?u16 { const raw = env_map.get(name) orelse return null; return std.fmt.parseInt(u16, raw, 10) catch null; @@ -455,18 +480,23 @@ fn initGl( return error.FontInit; }; const px: f32 = 18.0; - const scale = c.ui_font_scale_for_height(font, px); + const base_scale = c.ui_font_scale_for_height(font, px); var cw: c_int = 10; var chh: c_int = 20; var asc: c_int = 16; - c.ui_font_cell_metrics(font, scale, &cw, &chh, &asc); + c.ui_font_cell_metrics(font, base_scale, &cw, &chh, &asc); + const scale = c.ui_font_scale_for_height(font, px * web_render_scale); + const cell_w: u32 = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(cw, 1))) * web_render_scale)); + const cell_h: u32 = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(chh, 1))) * web_render_scale)); + const ascent: c_int = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(asc, 1))) * web_render_scale)); const program = try makeGlProgram(); var atlas_tex: c_uint = 0; c.glGenTextures(1, &atlas_tex); c.glBindTexture(c.GL_TEXTURE_2D, atlas_tex); - c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_MIN_FILTER, c.GL_LINEAR); - c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_MAG_FILTER, c.GL_LINEAR); + const filter = webGlFilter(); + c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_MIN_FILTER, filter); + c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_MAG_FILTER, filter); c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_S, c.GL_CLAMP_TO_EDGE); c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_T, c.GL_CLAMP_TO_EDGE); c.glPixelStorei(c.GL_UNPACK_ALIGNMENT, 1); @@ -498,9 +528,9 @@ fn initGl( .gl_u_atlas = c.glGetUniformLocation(program, "u_atlas"), .font = font, .scale = scale, - .cell_w = @intCast(@max(cw, 1)), - .cell_h = @intCast(@max(chh, 1)), - .ascent = asc, + .cell_w = cell_w, + .cell_h = cell_h, + .ascent = ascent, .screen = .{}, .atlas_stage = atlas_stage, .glyphs = std.AutoHashMap(u32, Slot).init(alloc), @@ -511,7 +541,7 @@ fn initGl( .trace_fd = trace_fd, .trace_every = @max(trace_every, 1), }; - _ = c.ui_font_raster(font, scale, ' ', atlas_stage.ptr, @intCast(atlas_w), @intCast(self.cell_w), @intCast(self.cell_h), asc); + _ = c.ui_font_raster(font, scale, ' ', atlas_stage.ptr, @intCast(atlas_w), @intCast(self.cell_w), @intCast(self.cell_h), self.ascent); self.space_slot = .{ .u = 0, .v = 0 }; self.pen_x = self.cell_w; self.tracef("sdl.init browser=webgl2 trace_every={d}", .{self.trace_every}); @@ -598,7 +628,7 @@ pub fn resize(self: *SdlBackend, alloc: std.mem.Allocator, writer: *std.Io.Write self.screen.deinit(alloc); self.screen = try vaxis.Screen.init(alloc, ws); self.screen.width_method = .wcwidth; - if (self.capture_images or is_emscripten) { + if (self.capture_images) { _ = c.SDL_SetWindowSize( self.sdl_window, @intCast(@as(u32, ws.cols) * self.cell_w), @@ -690,8 +720,7 @@ pub fn render(self: *SdlBackend, writer: *std.Io.Writer) !void { }); const instances: [*]CellInstance = @ptrCast(@alignCast(vptr)); t_mapped = nowNs(); - const cw_f: f32 = @floatFromInt(self.cell_w); - const ch_f: f32 = @floatFromInt(self.cell_h); + const layout = self.fixedCellLayout(); var r: u16 = 0; while (r < rows) : (r += 1) { var col: u16 = 0; @@ -702,7 +731,7 @@ pub fn render(self: *SdlBackend, writer: *std.Io.Writer) !void { self.cell_keys[cell_index] = key; self.addDirtyRange(&dirty_ranges, cell_index); dirty_cells += 1; - self.emitInstance(instances, cell_index, col, r, cw_f, ch_f, win_w, win_h, key); + self.emitInstance(instances, cell_index, col, r, layout, win_w, win_h, key); } } c.SDL_UnmapGPUTransferBuffer(self.device, self.vxfer); @@ -813,8 +842,7 @@ fn renderGl(self: *SdlBackend) !void { } try self.ensureGlInstances(cells); - const cw_f: f32 = @floatFromInt(self.cell_w); - const ch_f: f32 = @floatFromInt(self.cell_h); + const layout = self.cellLayout(sw, sh); const win_w: f32 = @floatFromInt(sw); const win_h: f32 = @floatFromInt(sh); var r: u16 = 0; @@ -822,7 +850,7 @@ fn renderGl(self: *SdlBackend) !void { var col: u16 = 0; while (col < cols) : (col += 1) { const cell_index: u32 = @as(u32, r) * @as(u32, cols) + @as(u32, col); - self.emitInstance(self.gl_instances.ptr, cell_index, col, r, cw_f, ch_f, win_w, win_h, self.cellKey(self.screen.buf[cell_index])); + self.emitInstance(self.gl_instances.ptr, cell_index, col, r, layout, win_w, win_h, self.cellKey(self.screen.buf[cell_index])); } } if (self.atlas_dirty) self.uploadAtlasGl(); @@ -852,6 +880,73 @@ fn renderGl(self: *SdlBackend) !void { _ = c.SDL_GL_SwapWindow(self.sdl_window); } +fn fixedCellLayout(self: *const SdlBackend) CellLayout { + return .{ + .w = @floatFromInt(self.cell_w), + .h = @floatFromInt(self.cell_h), + .x_off = 0, + .y_off = 0, + }; +} + +fn cellLayout(self: *const SdlBackend, sw: u32, sh: u32) CellLayout { + const fixed = self.fixedCellLayout(); + if (!is_emscripten) return fixed; + + const cols = self.screen.width; + const rows = self.screen.height; + if (cols == 0 or rows == 0) return fixed; + + const cols_f: f32 = @floatFromInt(cols); + const rows_f: f32 = @floatFromInt(rows); + const grid_w = cols_f * fixed.w; + const grid_h = rows_f * fixed.h; + if (grid_w <= 0 or grid_h <= 0) return fixed; + + const win_w: f32 = @floatFromInt(sw); + const win_h: f32 = @floatFromInt(sh); + const scale = @min(win_w / grid_w, win_h / grid_h); + const scaled_grid_w = grid_w * scale; + const scaled_grid_h = grid_h * scale; + return .{ + .w = fixed.w * scale, + .h = fixed.h * scale, + .x_off = if (win_w > scaled_grid_w) (win_w - scaled_grid_w) * 0.5 else 0, + .y_off = if (win_h > scaled_grid_h) (win_h - scaled_grid_h) * 0.5 else 0, + }; +} + +fn fixedMouseCell(self: *const SdlBackend, x: f32, y: f32) MouseCell { + return .{ + .col = @as(i16, @intCast(@divTrunc(@as(i32, @intFromFloat(x)), @as(i32, @intCast(self.cell_w))))), + .row = @as(i16, @intCast(@divTrunc(@as(i32, @intFromFloat(y)), @as(i32, @intCast(self.cell_h))))), + }; +} + +fn floorToI16(value: f32) i16 { + const floored = @floor(value); + const min: f32 = @floatFromInt(std.math.minInt(i16)); + const max: f32 = @floatFromInt(std.math.maxInt(i16)); + if (floored <= min) return std.math.minInt(i16); + if (floored >= max) return std.math.maxInt(i16); + return @intFromFloat(floored); +} + +fn mouseCell(self: *const SdlBackend, x: f32, y: f32) MouseCell { + if (!is_emscripten) return self.fixedMouseCell(x, y); + + var pw: c_int = 0; + var ph: c_int = 0; + _ = c.SDL_GetWindowSizeInPixels(self.sdl_window, &pw, &ph); + const sw: u32 = @max(1, @as(u32, @intCast(pw))); + const sh: u32 = @max(1, @as(u32, @intCast(ph))); + const layout = self.cellLayout(sw, sh); + return .{ + .col = floorToI16((x - layout.x_off) / layout.w), + .row = floorToI16((y - layout.y_off) / layout.h), + }; +} + // PARDES_TEST_GRID debug mode: dump the retained cell grid as text to stdout // (fd 1). One frame per render, delimited by ---FRAME---/---ENDFRAME--- markers. fn dumpGrid(self: *SdlBackend) !void { @@ -1309,17 +1404,16 @@ fn emitInstance( cell_index: u32, col: u16, row: u16, - cw_f: f32, - ch_f: f32, + layout: CellLayout, win_w: f32, win_h: f32, key: CellKey, ) void { // cell pixel rect (top-left origin) - const px0 = @as(f32, @floatFromInt(col)) * cw_f; - const py0 = @as(f32, @floatFromInt(row)) * ch_f; - const px1 = px0 + cw_f; - const py1 = py0 + ch_f; + const px0 = layout.x_off + @as(f32, @floatFromInt(col)) * layout.w; + const py0 = layout.y_off + @as(f32, @floatFromInt(row)) * layout.h; + const px1 = px0 + layout.w; + const py1 = py0 + layout.h; // -> NDC (SDL GPU clip space: x right, y up; framebuffer row 0 = top) const x0 = (px0 / win_w) * 2.0 - 1.0; const x1 = (px1 / win_w) * 2.0 - 1.0; @@ -1634,6 +1728,10 @@ fn SdlLoop(comptime EventType: type) type { while (c.SDL_PollEvent(&sev)) { if (try self.dispatch(&sev)) |ev| return ev; } + if (is_emscripten) { + self.postWinsize(); + if (try self.queue.tryPop()) |ev| return ev; + } return null; } @@ -1659,7 +1757,25 @@ fn SdlLoop(comptime EventType: type) type { } var pw: c_int = 0; var ph: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(self.vx.sdl_window, &pw, &ph); + if (is_emscripten) { + var css_w: f64 = 0; + var css_h: f64 = 0; + if (emscripten_get_element_css_size("#canvas", &css_w, &css_h) == EMSCRIPTEN_RESULT_SUCCESS and css_w > 0 and css_h > 0) { + pw = @intFromFloat(@round(css_w * web_render_scale_f64)); + ph = @intFromFloat(@round(css_h * web_render_scale_f64)); + var cur_w: c_int = 0; + var cur_h: c_int = 0; + _ = c.SDL_GetWindowSizeInPixels(self.vx.sdl_window, &cur_w, &cur_h); + if (cur_w != pw or cur_h != ph) { + _ = c.SDL_SetWindowSize(self.vx.sdl_window, pw, ph); + _ = c.SDL_SyncWindow(self.vx.sdl_window); + } + } else { + _ = c.SDL_GetWindowSizeInPixels(self.vx.sdl_window, &pw, &ph); + } + } else { + _ = c.SDL_GetWindowSizeInPixels(self.vx.sdl_window, &pw, &ph); + } const cw: u32 = self.vx.cell_w; const ch: u32 = self.vx.cell_h; const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(pw)), cw))); @@ -1712,11 +1828,10 @@ fn SdlLoop(comptime EventType: type) type { const b = sev.button; const t: vaxis.Mouse.Type = if (b.down) .press else .release; const button = mouseButton(b.button) orelse return null; - const col = @as(i16, @intCast(@divTrunc(@as(i32, @intFromFloat(b.x)), @as(i32, @intCast(self.vx.cell_w))))); - const row = @as(i16, @intCast(@divTrunc(@as(i32, @intFromFloat(b.y)), @as(i32, @intCast(self.vx.cell_h))))); + const cell = self.vx.mouseCell(b.x, b.y); return EventType{ .mouse = .{ - .col = col, - .row = row, + .col = cell.col, + .row = cell.row, .button = button, .mods = sdlMouseMods(self.live_mods), .type = t, @@ -1729,11 +1844,10 @@ fn SdlLoop(comptime EventType: type) type { const held = heldButton(m.state); const t: vaxis.Mouse.Type = if (held != null) .drag else .motion; const button = held orelse .none; - const col = @as(i16, @intCast(@divTrunc(@as(i32, @intFromFloat(m.x)), @as(i32, @intCast(self.vx.cell_w))))); - const row = @as(i16, @intCast(@divTrunc(@as(i32, @intFromFloat(m.y)), @as(i32, @intCast(self.vx.cell_h))))); + const cell = self.vx.mouseCell(m.x, m.y); return EventType{ .mouse = .{ - .col = col, - .row = row, + .col = cell.col, + .row = cell.row, .button = button, .mods = sdlMouseMods(self.live_mods), .type = t, @@ -1743,11 +1857,10 @@ fn SdlLoop(comptime EventType: type) type { const w = sev.wheel; const dy = w.y; const button: vaxis.Mouse.Button = if (dy > 0) .wheel_up else if (dy < 0) .wheel_down else return null; - const col = @as(i16, @intCast(@divTrunc(@as(i32, @intFromFloat(w.mouse_x)), @as(i32, @intCast(self.vx.cell_w))))); - const row = @as(i16, @intCast(@divTrunc(@as(i32, @intFromFloat(w.mouse_y)), @as(i32, @intCast(self.vx.cell_h))))); + const cell = self.vx.mouseCell(w.mouse_x, w.mouse_y); return EventType{ .mouse = .{ - .col = col, - .row = row, + .col = cell.col, + .row = cell.row, .button = button, .mods = sdlMouseMods(self.live_mods), .type = .press, |
