summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-03 14:43:30 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commitd1ed5bd8f55935e6470c4a7559216119571a084f (patch)
tree434478ce8a5dcd9aed367334816f54da9953e56d
parent153f0a88862507cf1f5dda71f71db3d6649dfeaa (diff)
downloadpardes-d1ed5bd8f55935e6470c4a7559216119571a084f.tar.gz
pardes-d1ed5bd8f55935e6470c4a7559216119571a084f.zip
fixed scaling and fitting on web ui
-rw-r--r--build.zig36
-rw-r--r--main.zig292
-rw-r--r--pane_ui.zig273
-rw-r--r--replay.zig246
-rw-r--r--tests.zig54
-rw-r--r--tests/browser_replay_e2e.mjs32
-rw-r--r--ui_sdl.zig185
7 files changed, 627 insertions, 491 deletions
diff --git a/build.zig b/build.zig
index 2794b4c5..b55d1c1a 100644
--- a/build.zig
+++ b/build.zig
@@ -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);
}
diff --git a/main.zig b/main.zig
index 019d2a44..184b1058 100644
--- a/main.zig
+++ b/main.zig
@@ -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);
+ }
+ }
+}
diff --git a/replay.zig b/replay.zig
index 18deab13..6cec683e 100644
--- a/replay.zig
+++ b/replay.zig
@@ -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;
}
};
diff --git a/tests.zig b/tests.zig
index 0604fb98..7e44ddb7 100644
--- a/tests.zig
+++ b/tests.zig
@@ -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 {
diff --git a/ui_sdl.zig b/ui_sdl.zig
index 8bc68db5..8fbc78f5 100644
--- a/ui_sdl.zig
+++ b/ui_sdl.zig
@@ -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,