summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/gui/gui.zig35
-rw-r--r--src/look.zig33
-rw-r--r--src/pardes.zig3
-rw-r--r--test/gui_golden.py61
4 files changed, 107 insertions, 25 deletions
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 6bde8b60..8d9c29ab 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -4273,7 +4273,13 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void {
if (core.continuous()) {
const display = virtual.* + std.time.ns_per_s / s.test_hz;
virtual.* = if (core.nextWake()) |due| @max(virtual.*, @min(due, display)) else display;
- } else if (!got and timeout_ms != 0) virtual.* = @max(virtual.*, core.nextWake() orelse virtual.*);
+ } else if (timeout_ms != 0 and (!got or !core.needs_frame)) {
+ // A wait that ran out, or one a stray event cut short while
+ // nothing owes a frame: time moves to the core's wake all
+ // the same, so a steady trickle of events (a pty's, the
+ // test feed's) cannot hold a virtual clock still forever.
+ virtual.* = @max(virtual.*, core.nextWake() orelse virtual.*);
+ }
}
if (got) {
// A run of queued motions is one move to its last position (the
@@ -4348,6 +4354,7 @@ fn pollFrame(ctx: ?*anyopaque) void {
if (updateCoreResize(core, geom.cols, geom.rows, g.cell_w, g.cell_h, g.tagline_width, g.tagline_height)) resetScroll(g);
stepScroll(g, core, s.gpa);
g.post.sync(s.gpa, s.io, g.device, g.swapchain_format, core);
+ if (s.test_mode) writeTestStatus(g, core, s.presented);
// Between the core's frames, a chain that moves on its own redraws
// alone, once a refresh of the display the window is on (a millisecond
// early is on time: the wait that paces it is in whole milliseconds).
@@ -8235,6 +8242,32 @@ fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, t
g.captured +%= 1;
}
+/// A test window's state for a harness (test/gui_golden.py), in the
+/// capture directory's `status`: "<loop step> <frames captured> <idle>".
+/// Idle is 1 once the frame last captured is the one that stays: nothing
+/// owes a frame, nothing moves, nothing waits on a clock. A harness that
+/// sees a step past the one it acted on, idle, has its frame, however slow
+/// the machine is.
+var test_status_step: u64 = 0;
+fn writeTestStatus(g: *const Gui, core: *const pardes.Pardes, presented: bool) void {
+ test_status_step +%= 1;
+ if (g.capture_dir.len == 0) return;
+ const idle = presented and !core.needs_frame and !core.continuous() and core.nextWake() == null and
+ !g.glide.moving() and g.scroll_lag == 0 and g.scroll_delta == 0 and g.scroll_notch == 0;
+ var line_buf: [64]u8 = undefined;
+ // What keeps it busy, after: owes a frame, moves, waits, not shown.
+ const line = std.fmt.bufPrint(&line_buf, "{d} {d} {d} {d}{d}{d}{d}\n", .{ test_status_step, g.captured, @intFromBool(idle), @intFromBool(core.needs_frame), @intFromBool(core.continuous()), @intFromBool(core.nextWake() != null), @intFromBool(!presented) }) catch return;
+ var tmp_buf: [4096]u8 = undefined;
+ var final_buf: [4096]u8 = undefined;
+ const tmp_path = std.fmt.bufPrintSentinel(&tmp_buf, "{s}/status.tmp", .{g.capture_dir}, 0) catch return;
+ const final_path = std.fmt.bufPrintSentinel(&final_buf, "{s}/status", .{g.capture_dir}, 0) catch return;
+ const fd = libc.open(tmp_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
+ if (fd < 0) return;
+ _ = libc.write(fd, line.ptr, line.len);
+ _ = libc.close(fd);
+ _ = libc.rename(tmp_path, final_path);
+}
+
fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u32, height: u32, name: []const u8) !void {
// A latency trace times frames, and an 8 MB file write per frame would
// stall the loop it is timing.
diff --git a/src/look.zig b/src/look.zig
index 4ee5a638..39f7420e 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -934,6 +934,12 @@ fn noteLookHover(p: *Pardes, col: u16, row: u16, body_hit: ?Mouse.BodyHit, tag_h
if (p.look_hover_wait) |waiting|
if (waiting.col == col and waiting.row == row and waiting.pane == id and waiting.serial == pane.serial and mouse.sameBodyCell(waiting.body_hit, body_hit) and tag_layer.sameCell(waiting.tag_hit, tag_hit)) return;
+ // Found nothing to open here a moment ago: the pointer resting on it
+ // does not start another wait (and another frame) every frame.
+ if (p.look_hover_refused) |refused|
+ if (refused.col == col and refused.row == row and refused.pane == id and refused.serial == pane.serial and mouse.sameBodyCell(refused.body_hit, body_hit) and tag_layer.sameCell(refused.tag_hit, tag_hit)) return;
+ p.look_hover_refused = null;
+
cancelLookHover(p);
p.look_hover_wait = .{ .col = col, .row = row, .pane = id, .serial = pane.serial, .body_hit = body_hit, .tag_hit = tag_hit };
}
@@ -958,8 +964,10 @@ pub fn advanceLookHover(p: *Pardes) void {
if (waiting.frames < delay) return;
const pane = p.panes[waiting.pane] orelse return cancelLookHover(p);
if (pane.serial != waiting.serial) return cancelLookHover(p);
- const pointed = edit.pointerTextSelection(p, waiting.pane, waiting.col, waiting.row, waiting.body_hit, waiting.tag_hit) orelse
+ const pointed = edit.pointerTextSelection(p, waiting.pane, waiting.col, waiting.row, waiting.body_hit, waiting.tag_hit) orelse {
+ p.look_hover_refused = waiting.*;
return cancelLookHover(p);
+ };
if (comptime pdf_enabled) if (!pointed.on_tag and panes.Pdf.paneNativeReady(p, pane)) {
const probe = panes.Pdf.probeAt(p, pane, waiting.col, waiting.row) orelse
return cancelLookHover(p);
@@ -976,7 +984,10 @@ pub fn advanceLookHover(p: *Pardes) void {
return;
};
const operand = exec.pointerOperand(p, pane, pointed.sel);
- if (operand.text == null or operand.text.?.len == 0) return cancelLookHover(p);
+ if (operand.text == null or operand.text.?.len == 0) {
+ p.look_hover_refused = waiting.*;
+ return cancelLookHover(p);
+ }
var preview = operand.preview;
if (preview) |*sel| sel.state = .done;
p.look_hover_preview = .{
@@ -1727,3 +1738,21 @@ test "Look ignores missing and out of bounds pane addresses" {
try std.testing.expect(pane.body.vsel.active); // a miss changes nothing, the selection included
}
}
+
+test "a pointer resting where a look-hover found nothing does not wake the core every frame" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer p.deinit();
+ _ = try p.setTestFile(" \n" ** 30);
+ p.sync();
+ const r = p.rects[p.active];
+ // Resting on blanks in the body: a wait, then nothing to open.
+ p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = r.x + 20, .row = r.y + 5 } });
+ var frames: usize = 0;
+ while (p.nextWake() != null and frames < 20) : (frames += 1) {
+ p.update(.tick);
+ // What a shell's present does after every frame.
+ p.acknowledgePanelPresentation(&.{});
+ }
+ try std.testing.expect(frames < 20);
+ try std.testing.expect(p.look_hover_wait == null and p.look_hover_preview == null);
+}
diff --git a/src/pardes.zig b/src/pardes.zig
index 72a90244..d6385e6f 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -4377,6 +4377,9 @@ pub const Pardes = struct {
/// The look just run found nothing (look.zig missed).
look_missed: bool = false,
look_hover_wait: ?look.LookHoverWait = null,
+ /// The place a look-hover last found nothing to open: the pointer
+ /// resting there does not ask again every frame (look.noteLookHover).
+ look_hover_refused: ?look.LookHoverWait = null,
look_hover_preview: ?look.LookHoverPreview = null,
pdf_hover_preview: if (pdf_enabled) ?look.PdfWordPreview else void = if (pdf_enabled) null else {},
wheel_guard: u8 = 0,
diff --git a/test/gui_golden.py b/test/gui_golden.py
index 99e8c636..24fc5986 100644
--- a/test/gui_golden.py
+++ b/test/gui_golden.py
@@ -9,7 +9,8 @@ The window is hidden (PARDES_TEST renders into an offscreen capture texture),
and the capture carries PARDES_TEST_PAD leftover pixels so edge bugs show.
GPU rasterisation differs between machines and drivers: the goldens in
test/gui-goldens.txt are a LOCAL gate for one machine, never a CI one. Each
-scene waits until two captures 400 ms apart agree before it is hashed.
+scene waits until the window says it is idle (its status file) before it is
+hashed, so a loaded machine gives the same frames.
"""
import argparse
import hashlib
@@ -60,24 +61,34 @@ def panes(client):
return {serial: client.read(f'/pane/{serial}/tag').decode(errors='replace') for serial in serials}
+def status(root):
+ """The window's (step, frames captured, idle), or None before the first."""
+ try:
+ step, frames, idle = (root / 'status').read_text().split()[:3]
+ return int(step), int(frames), idle == '1'
+ except (FileNotFoundError, ValueError):
+ return None
+
+
def settled(root):
- """The capture once it stops changing: two reads 400 ms apart agree."""
- latest = root / 'latest.ppm'
- previous = None
- deadline = time.monotonic() + 20
+ """The capture once the window says it is idle: a loop step past the one
+ current when this was asked, with nothing owing a frame (the window's
+ status file, gui.zig writeTestStatus). Every command before this call has
+ been served by then (a 9P write returns once the core has it), so the
+ frame is the scene's however loaded the machine is; the digest of it."""
+ deadline = time.monotonic() + 60
+ start = None
while time.monotonic() < deadline:
- time.sleep(.4)
- try:
- data = latest.read_bytes()
- except FileNotFoundError:
- continue
- if not data.startswith(b'P6'):
- continue
- digest = hashlib.sha256(data).hexdigest()
- if digest == previous:
- return digest
- previous = digest
- raise RuntimeError('capture never settled')
+ now = status(root)
+ if now is not None:
+ if start is None:
+ start = now[0]
+ elif now[0] >= start + 2 and now[2] and now[1] > 0:
+ data = (root / 'latest.ppm').read_bytes()
+ if data.startswith(b'P6'):
+ return hashlib.sha256(data).hexdigest()
+ time.sleep(.02)
+ raise RuntimeError('the window never went idle')
def run(args):
@@ -86,6 +97,12 @@ def run(args):
root = Path(tempfile.gettempdir()) / 'pardes-gui-golden'
shutil.rmtree(root, ignore_errors=True)
root.mkdir(mode=0o700)
+ # Captures go outside the session's own directory: a file written there
+ # every frame is a change in the directory its panes look at, which
+ # would ask for another frame, and so on for ever.
+ captures = Path(tempfile.gettempdir()) / 'pardes-gui-golden-captures'
+ shutil.rmtree(captures, ignore_errors=True)
+ captures.mkdir(mode=0o700)
name = 'gui-golden'
config = root / 'config/pardes'
config.mkdir(parents=True)
@@ -109,7 +126,7 @@ def run(args):
# The virtual clock: a transition's frames are the same frames on any
# machine, so one partway through can be hashed too.
inherited = {'PARDES_TEST': '1', 'PARDES_TEST_CLOCK': '1', 'PARDES_TEST_COLS': '120', 'PARDES_TEST_ROWS': '40',
- 'PARDES_TEST_PAD': '7', 'PARDES_TEST_CAPTURE_DIR': str(root),
+ 'PARDES_TEST_PAD': '7', 'PARDES_TEST_CAPTURE_DIR': str(captures),
'SDL_VIDEODRIVER': 'wayland', 'WAYLAND_DISPLAY': str(display)}
launch = ['-c', 'stty cols 120 rows 40; exec "$@"', name, str(args.binary.resolve()),
'--9p=' + name, str(source)]
@@ -120,11 +137,11 @@ def run(args):
if 'shapes.zig' in tag), None)), 'source pane missing')
def scene(label, frame='latest.ppm'):
- settled(root)
- results.append((label, hashlib.sha256((root / frame).read_bytes()).hexdigest()))
+ settled(captures)
+ results.append((label, hashlib.sha256((captures / frame).read_bytes()).hexdigest()))
if args.keep:
args.keep.mkdir(parents=True, exist_ok=True)
- shutil.copyfile(root / frame, args.keep / (label + '.ppm'))
+ shutil.copyfile(captures / frame, args.keep / (label + '.ppm'))
print(label, results[-1][1][:16], flush=True)
def mouse(kind, button, x, y):
@@ -201,7 +218,7 @@ def run(args):
# chrome slides with each pane.
client.write('/ctl', b'PanelSlide\n')
client.write(f'/pane/{first}/exec', b'Newcol\n')
- require(wait_for(lambda: (root / 'transition-6.ppm').exists()), 'transition frame 6 never drawn')
+ require(wait_for(lambda: (captures / 'transition-6.ppm').exists()), 'transition frame 6 never drawn')
scene('18-mid-transition', 'transition-6.ppm')
lines = ''.join(f'{label} {digest}\n' for label, digest in results)
if args.update: