a1685301
scenarios: first fixture blink-bar + Makefile targets
a73x 2026-04-19 13:46
blink-bar.scenario drives DECSCUSR 5 and captures two frames separated by 500ms to observe the blink-bar cursor on/off phase boundary. Proves the scenario runner end-to-end: - --scenario flag parses and dispatches - bytes directive injects into the VT parser - real main loop fires the blink timer - capture directive offscreens + PNG-writes + diffs goldens - exit code 0 on pass, 4 on golden mismatch Three fixes required to make this work: 1. has_focus_eff: scenario mode has no keyboard focus event. Treat has_focus as true unconditionally in scenario mode so DECSCUSR 5/6 arms the blink timer and shouldDrawCursor draws it correctly during captures. 2. Scale/resize suppression: compositor promotes the waystty window to the display's native scale on mixed-DPI setups (DP-4 @2x). Scenario mode absorbs scale and resize events without touching the offscreen target or grid — only the fixed-size offscreen matters for captures. 3. Cursor rendering in captureCb: buildInstancesForSnapshot only builds cell instances. Added a cursor overlay in captureCb using renderer.cursorInstance, driven by rc.blink_on (kept in sync each tick). Without this, before-flip and after-flip were always identical (no cursor visible in either). make scenario — run all *.scenario fixtures make scenario-update — regenerate goldens (WAYSTTY_SCENARIO_UPDATE=1) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
diff --git a/.gitignore b/.gitignore index df57e1a..7ade714 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ perf.data flamegraph.svg tests/golden/output/ tests/scenarios/out/ # Scratch test binaries (ad-hoc compilations) /test_io /test_io2 diff --git a/Makefile b/Makefile index f00a753..188b64e 100644 --- a/Makefile +++ b/Makefile @@ -43,5 +43,21 @@ bench-baseline: bench-check: $(ZIG) build bench-check -Doptimize=$(OPT) .PHONY: scenario scenario-update scenario: $(ZIG) build @for f in tests/scenarios/*.scenario; do \ echo "=== $$f ==="; \ ./zig-out/bin/waystty --scenario "$$f" || exit $$?; \ done scenario-update: $(ZIG) build @for f in tests/scenarios/*.scenario; do \ echo "=== $$f (update goldens) ==="; \ WAYSTTY_SCENARIO_UPDATE=1 ./zig-out/bin/waystty --scenario "$$f" || exit $$?; \ done clean: rm -rf zig-out .zig-cache perf.data bench.log flamegraph.svg tests/golden/output diff --git a/src/main.zig b/src/main.zig index cc6cdfc..629453e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -237,15 +237,9 @@ pub fn runScenarios(alloc: std.mem.Allocator, argv: []const [:0]const u8) !u8 { }; defer rc.deinit(); runTerminal(alloc, &rc) catch |err| switch (err) { error.ScenarioScaleChangeNotSupported => { // already printed; return exit 6 (other render error) return @intFromEnum(scenario_runtime.ExitCode.other_error); }, else => { std.debug.print("scenario {s}: runtime error: {s}\n", .{ scenario_name, @errorName(err) }); return @intFromEnum(scenario_runtime.ExitCode.other_error); }, runTerminal(alloc, &rc) catch |err| { std.debug.print("scenario {s}: runtime error: {s}\n", .{ scenario_name, @errorName(err) }); return @intFromEnum(scenario_runtime.ExitCode.other_error); }; if (rc.tick_fatal) |err| { @@ -592,6 +586,9 @@ fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext if (tick_ctx) |rc| { if (rc.state) |s| { if (tick.flipped) rc.blink_flipped_this_iter = true; // Keep the capture callback's cursor rendering in sync with // the current blink phase. rc.blink_on = blink_state.blink_on; const outcome = s.tick(std.time.nanoTimestamp(), scenario_runtime.tickIO(rc)) catch |err| { rc.tick_fatal = err; @@ -614,10 +611,16 @@ fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext if (!render_pending) continue; if (scale_pending) { if (tick_ctx) |_| { std.debug.print("scenario: runtime scale change not supported; please pin the compositor output to scale 1 (or re-run on a scale-1 monitor).\n", .{}); return error.ScenarioScaleChangeNotSupported; } if (tick_ctx != null) { // In scenario mode the swapchain is never presented — all // captures go through the fixed-size offscreen target. // Silently absorb compositor scale changes so mixed-DPI // setups (e.g. 2x primary + 1x secondary) do not abort the // scenario. Update last_scale so we don't re-enter this path // every iteration. last_scale = current_scale; scale_pending = false; } else { vk_sync.waitIdleForShutdown(ctx.vkd, ctx.device); geom = try rebuildFaceForScale( @@ -645,6 +648,16 @@ fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext last_scale = geom.buffer_scale; scale_pending = false; } } if (resize_pending and tick_ctx != null) { // Scenario mode: compositor window size changes are irrelevant — // the terminal grid and offscreen target dimensions are fixed at // parse time. Absorb without touching the grid or swapchain. last_window_w = window.width; last_window_h = window.height; resize_pending = false; } if (resize_pending) { @@ -694,17 +707,21 @@ fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext // state we see right now, so no transition can be "missed". const current_cursor = term.render_state.cursor; const viewport_present = current_cursor.viewport != null; // In scenario mode there is no Wayland keyboard-focus event, so // treat has_focus as true unconditionally. This lets DECSCUSR 5/6 // arm the blink timer so blink-phase fixtures work correctly. const has_focus_eff = keyboard.has_focus or (tick_ctx != null); const want_blink = current_cursor.visible and viewport_present and current_cursor.blinking and keyboard.has_focus; and has_focus_eff; const cursor_identity_changed = !viewportEql(current_cursor.viewport, previous_cursor.viewport) or current_cursor.visual_style != previous_cursor.visual_style or current_cursor.visible != previous_cursor.visible or current_cursor.blinking != previous_cursor.blinking; const focus_regained = keyboard.has_focus and !previous_has_focus; const focus_regained = has_focus_eff and !previous_has_focus; const reconf = reconfigureBlink( blink_state, @@ -779,7 +796,7 @@ fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext const draw_cursor = shouldDrawCursor( current_cursor.visible, current_cursor.viewport != null, keyboard.has_focus, has_focus_eff, current_cursor.blinking, blink_state.blink_on, ); @@ -945,7 +962,7 @@ fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext frame_timing.present_us = submit_timing.present_us; frame_ring.push(frame_timing); previous_has_focus = keyboard.has_focus; previous_has_focus = has_focus_eff; clearConsumedDirtyFlags(&term.render_state.dirty, dirty_rows, refresh_plan); if (!bench_unthrottled) frame_loop.commitRender() catch {}; diff --git a/src/scenario_runtime.zig b/src/scenario_runtime.zig index a66c361..8d73f4e 100644 --- a/src/scenario_runtime.zig +++ b/src/scenario_runtime.zig @@ -30,6 +30,9 @@ pub const RunContext = struct { failures: std.ArrayListUnmanaged(Failure) = .{}, blink_flipped_this_iter: bool = false, /// Current blink phase — true = cursor on, false = cursor off. /// Updated by the main loop each tick before scenario.tick() fires. blink_on: bool = true, // Populated by runTerminal once it has built the ScenarioState on top of // its own terminal/renderer handles. The main loop tick hook reads this. @@ -138,6 +141,35 @@ pub fn captureCb(ctx: *anyopaque, label: []const u8) anyerror!png.Image { rc.baseline, ); // --- cursor overlay --- // Mirror shouldDrawCursor logic from main.zig: in scenario mode we // always treat has_focus as true. Draw if visible, in-viewport, and // (not blinking OR currently blink-on). const cur = term.render_state.cursor; const draw_cursor = cur.visible and cur.viewport != null and (!cur.blinking or rc.blink_on); if (draw_cursor) { const vp = cur.viewport.?; const shape: renderer.CursorShape = switch (cur.visual_style) { .block, .block_hollow => .block, .underline => .underline, .bar => .bar, }; var inst = renderer.cursorInstance( shape, rc.cell_w, rc.cell_h, 1, // scenario mode is always scale-1 in offscreen atlas.cursorUV(), ); inst.cell_pos = .{ @floatFromInt(vp.x), @floatFromInt(vp.y), }; try instances.append(rc.alloc, inst); } // If scenario bytes pulled in new glyphs, the atlas pixels are newer // than the GPU copy. Re-upload before rendering. if (atlas.dirty) { diff --git a/tests/scenarios/blink-bar.scenario b/tests/scenarios/blink-bar.scenario new file mode 100644 index 0000000..74ee438 --- /dev/null +++ b/tests/scenarios/blink-bar.scenario @@ -0,0 +1,14 @@ # Blinking bar cursor: DECSCUSR 5 then observe two phases. size 80 24 timeout 5000ms # Inject DECSCUSR 5 directly (no shell) - blinking bar. bytes "\e[5 q" # Give blink state machine time to arm. sleep 100ms capture before-flip # Advance past the first phase flip (500ms period). sleep 500ms capture after-flip diff --git a/tests/scenarios/golden/blink-bar/after-flip.png b/tests/scenarios/golden/blink-bar/after-flip.png new file mode 100644 index 0000000..22ac3b2 Binary files /dev/null and b/tests/scenarios/golden/blink-bar/after-flip.png differ diff --git a/tests/scenarios/golden/blink-bar/before-flip.png b/tests/scenarios/golden/blink-bar/before-flip.png new file mode 100644 index 0000000..3ed3d6e Binary files /dev/null and b/tests/scenarios/golden/blink-bar/before-flip.png differ