a1685301
scenarios: first fixture blink-bar + Makefile targets
a73x 2026-04-19 13:46
Commit message
.gitignore
| Old | New | ||
|---|---|---|---|
| @@ -8,6 +8,8 @@ perf.data | |||
| 8 | flamegraph.svg | 8 | flamegraph.svg |
| 9 | tests/golden/output/ | 9 | tests/golden/output/ |
| 10 | 10 | ||
| 11 | tests/scenarios/out/ | ||
| 12 | |||
| 11 | # Scratch test binaries (ad-hoc compilations) | 13 | # Scratch test binaries (ad-hoc compilations) |
| 12 | /test_io | 14 | /test_io |
| 13 | /test_io2 | 15 | /test_io2 |
Makefile
| Old | New | ||
|---|---|---|---|
| @@ -43,5 +43,21 @@ bench-baseline: | |||
| 43 | bench-check: | 43 | bench-check: |
| 44 | $(ZIG) build bench-check -Doptimize=$(OPT) | 44 | $(ZIG) build bench-check -Doptimize=$(OPT) |
| 45 | 45 | ||
| 46 | .PHONY: scenario scenario-update | ||
| 47 | |||
| 48 | scenario: | ||
| 49 | $(ZIG) build | ||
| 50 | @for f in tests/scenarios/*.scenario; do \ | ||
| 51 | echo "=== $$f ==="; \ | ||
| 52 | ./zig-out/bin/waystty --scenario "$$f" || exit $$?; \ | ||
| 53 | done | ||
| 54 | |||
| 55 | scenario-update: | ||
| 56 | $(ZIG) build | ||
| 57 | @for f in tests/scenarios/*.scenario; do \ | ||
| 58 | echo "=== $$f (update goldens) ==="; \ | ||
| 59 | WAYSTTY_SCENARIO_UPDATE=1 ./zig-out/bin/waystty --scenario "$$f" || exit $$?; \ | ||
| 60 | done | ||
| 61 | |||
| 46 | clean: | 62 | clean: |
| 47 | rm -rf zig-out .zig-cache perf.data bench.log flamegraph.svg tests/golden/output | 63 | rm -rf zig-out .zig-cache perf.data bench.log flamegraph.svg tests/golden/output |
src/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -237,15 +237,9 @@ pub fn runScenarios(alloc: std.mem.Allocator, argv: []const [:0]const u8) !u8 { | |||
| 237 | }; | 237 | }; |
| 238 | defer rc.deinit(); | 238 | defer rc.deinit(); |
| 239 | 239 | ||
| 240 | runTerminal(alloc, &rc) catch |err| switch (err) { | 240 | runTerminal(alloc, &rc) catch |err| { |
| 241 | error.ScenarioScaleChangeNotSupported => { | 241 | std.debug.print("scenario {s}: runtime error: {s}\n", .{ scenario_name, @errorName(err) }); |
| 242 | // already printed; return exit 6 (other render error) | 242 | return @intFromEnum(scenario_runtime.ExitCode.other_error); |
| 243 | return @intFromEnum(scenario_runtime.ExitCode.other_error); | ||
| 244 | }, | ||
| 245 | else => { | ||
| 246 | std.debug.print("scenario {s}: runtime error: {s}\n", .{ scenario_name, @errorName(err) }); | ||
| 247 | return @intFromEnum(scenario_runtime.ExitCode.other_error); | ||
| 248 | }, | ||
| 249 | }; | 243 | }; |
| 250 | 244 | ||
| 251 | if (rc.tick_fatal) |err| { | 245 | if (rc.tick_fatal) |err| { |
| @@ -592,6 +586,9 @@ fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext | |||
| 592 | if (tick_ctx) |rc| { | 586 | if (tick_ctx) |rc| { |
| 593 | if (rc.state) |s| { | 587 | if (rc.state) |s| { |
| 594 | if (tick.flipped) rc.blink_flipped_this_iter = true; | 588 | if (tick.flipped) rc.blink_flipped_this_iter = true; |
| 589 | // Keep the capture callback's cursor rendering in sync with | ||
| 590 | // the current blink phase. | ||
| 591 | rc.blink_on = blink_state.blink_on; | ||
| 595 | 592 | ||
| 596 | const outcome = s.tick(std.time.nanoTimestamp(), scenario_runtime.tickIO(rc)) catch |err| { | 593 | const outcome = s.tick(std.time.nanoTimestamp(), scenario_runtime.tickIO(rc)) catch |err| { |
| 597 | rc.tick_fatal = err; | 594 | rc.tick_fatal = err; |
| @@ -614,10 +611,16 @@ fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext | |||
| 614 | if (!render_pending) continue; | 611 | if (!render_pending) continue; |
| 615 | 612 | ||
| 616 | if (scale_pending) { | 613 | if (scale_pending) { |
| 617 | if (tick_ctx) |_| { | 614 | if (tick_ctx != null) { |
| 618 | 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", .{}); | 615 | // In scenario mode the swapchain is never presented — all |
| 619 | return error.ScenarioScaleChangeNotSupported; | 616 | // captures go through the fixed-size offscreen target. |
| 620 | } | 617 | // Silently absorb compositor scale changes so mixed-DPI |
| 618 | // setups (e.g. 2x primary + 1x secondary) do not abort the | ||
| 619 | // scenario. Update last_scale so we don't re-enter this path | ||
| 620 | // every iteration. | ||
| 621 | last_scale = current_scale; | ||
| 622 | scale_pending = false; | ||
| 623 | } else { | ||
| 621 | vk_sync.waitIdleForShutdown(ctx.vkd, ctx.device); | 624 | vk_sync.waitIdleForShutdown(ctx.vkd, ctx.device); |
| 622 | 625 | ||
| 623 | geom = try rebuildFaceForScale( | 626 | geom = try rebuildFaceForScale( |
| @@ -645,6 +648,16 @@ fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext | |||
| 645 | 648 | ||
| 646 | last_scale = geom.buffer_scale; | 649 | last_scale = geom.buffer_scale; |
| 647 | scale_pending = false; | 650 | scale_pending = false; |
| 651 | } | ||
| 652 | } | ||
| 653 | |||
| 654 | if (resize_pending and tick_ctx != null) { | ||
| 655 | // Scenario mode: compositor window size changes are irrelevant — | ||
| 656 | // the terminal grid and offscreen target dimensions are fixed at | ||
| 657 | // parse time. Absorb without touching the grid or swapchain. | ||
| 658 | last_window_w = window.width; | ||
| 659 | last_window_h = window.height; | ||
| 660 | resize_pending = false; | ||
| 648 | } | 661 | } |
| 649 | 662 | ||
| 650 | if (resize_pending) { | 663 | if (resize_pending) { |
| @@ -694,17 +707,21 @@ fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext | |||
| 694 | // state we see right now, so no transition can be "missed". | 707 | // state we see right now, so no transition can be "missed". |
| 695 | const current_cursor = term.render_state.cursor; | 708 | const current_cursor = term.render_state.cursor; |
| 696 | const viewport_present = current_cursor.viewport != null; | 709 | const viewport_present = current_cursor.viewport != null; |
| 710 | // In scenario mode there is no Wayland keyboard-focus event, so | ||
| 711 | // treat has_focus as true unconditionally. This lets DECSCUSR 5/6 | ||
| 712 | // arm the blink timer so blink-phase fixtures work correctly. | ||
| 713 | const has_focus_eff = keyboard.has_focus or (tick_ctx != null); | ||
| 697 | const want_blink = current_cursor.visible | 714 | const want_blink = current_cursor.visible |
| 698 | and viewport_present | 715 | and viewport_present |
| 699 | and current_cursor.blinking | 716 | and current_cursor.blinking |
| 700 | and keyboard.has_focus; | 717 | and has_focus_eff; |
| 701 | 718 | ||
| 702 | const cursor_identity_changed = | 719 | const cursor_identity_changed = |
| 703 | !viewportEql(current_cursor.viewport, previous_cursor.viewport) | 720 | !viewportEql(current_cursor.viewport, previous_cursor.viewport) |
| 704 | or current_cursor.visual_style != previous_cursor.visual_style | 721 | or current_cursor.visual_style != previous_cursor.visual_style |
| 705 | or current_cursor.visible != previous_cursor.visible | 722 | or current_cursor.visible != previous_cursor.visible |
| 706 | or current_cursor.blinking != previous_cursor.blinking; | 723 | or current_cursor.blinking != previous_cursor.blinking; |
| 707 | const focus_regained = keyboard.has_focus and !previous_has_focus; | 724 | const focus_regained = has_focus_eff and !previous_has_focus; |
| 708 | 725 | ||
| 709 | const reconf = reconfigureBlink( | 726 | const reconf = reconfigureBlink( |
| 710 | blink_state, | 727 | blink_state, |
| @@ -779,7 +796,7 @@ fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext | |||
| 779 | const draw_cursor = shouldDrawCursor( | 796 | const draw_cursor = shouldDrawCursor( |
| 780 | current_cursor.visible, | 797 | current_cursor.visible, |
| 781 | current_cursor.viewport != null, | 798 | current_cursor.viewport != null, |
| 782 | keyboard.has_focus, | 799 | has_focus_eff, |
| 783 | current_cursor.blinking, | 800 | current_cursor.blinking, |
| 784 | blink_state.blink_on, | 801 | blink_state.blink_on, |
| 785 | ); | 802 | ); |
| @@ -945,7 +962,7 @@ fn runTerminal(alloc: std.mem.Allocator, tick_ctx: ?*scenario_runtime.RunContext | |||
| 945 | frame_timing.present_us = submit_timing.present_us; | 962 | frame_timing.present_us = submit_timing.present_us; |
| 946 | 963 | ||
| 947 | frame_ring.push(frame_timing); | 964 | frame_ring.push(frame_timing); |
| 948 | previous_has_focus = keyboard.has_focus; | 965 | previous_has_focus = has_focus_eff; |
| 949 | 966 | ||
| 950 | clearConsumedDirtyFlags(&term.render_state.dirty, dirty_rows, refresh_plan); | 967 | clearConsumedDirtyFlags(&term.render_state.dirty, dirty_rows, refresh_plan); |
| 951 | if (!bench_unthrottled) frame_loop.commitRender() catch {}; | 968 | if (!bench_unthrottled) frame_loop.commitRender() catch {}; |
src/scenario_runtime.zig
| Old | New | ||
|---|---|---|---|
| @@ -30,6 +30,9 @@ pub const RunContext = struct { | |||
| 30 | 30 | ||
| 31 | failures: std.ArrayListUnmanaged(Failure) = .{}, | 31 | failures: std.ArrayListUnmanaged(Failure) = .{}, |
| 32 | blink_flipped_this_iter: bool = false, | 32 | blink_flipped_this_iter: bool = false, |
| 33 | /// Current blink phase — true = cursor on, false = cursor off. | ||
| 34 | /// Updated by the main loop each tick before scenario.tick() fires. | ||
| 35 | blink_on: bool = true, | ||
| 33 | 36 | ||
| 34 | // Populated by runTerminal once it has built the ScenarioState on top of | 37 | // Populated by runTerminal once it has built the ScenarioState on top of |
| 35 | // its own terminal/renderer handles. The main loop tick hook reads this. | 38 | // 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 { | |||
| 138 | rc.baseline, | 141 | rc.baseline, |
| 139 | ); | 142 | ); |
| 140 | 143 | ||
| 144 | // --- cursor overlay --- | ||
| 145 | // Mirror shouldDrawCursor logic from main.zig: in scenario mode we | ||
| 146 | // always treat has_focus as true. Draw if visible, in-viewport, and | ||
| 147 | // (not blinking OR currently blink-on). | ||
| 148 | const cur = term.render_state.cursor; | ||
| 149 | const draw_cursor = cur.visible | ||
| 150 | and cur.viewport != null | ||
| 151 | and (!cur.blinking or rc.blink_on); | ||
| 152 | if (draw_cursor) { | ||
| 153 | const vp = cur.viewport.?; | ||
| 154 | const shape: renderer.CursorShape = switch (cur.visual_style) { | ||
| 155 | .block, .block_hollow => .block, | ||
| 156 | .underline => .underline, | ||
| 157 | .bar => .bar, | ||
| 158 | }; | ||
| 159 | var inst = renderer.cursorInstance( | ||
| 160 | shape, | ||
| 161 | rc.cell_w, | ||
| 162 | rc.cell_h, | ||
| 163 | 1, // scenario mode is always scale-1 in offscreen | ||
| 164 | atlas.cursorUV(), | ||
| 165 | ); | ||
| 166 | inst.cell_pos = .{ | ||
| 167 | @floatFromInt(vp.x), | ||
| 168 | @floatFromInt(vp.y), | ||
| 169 | }; | ||
| 170 | try instances.append(rc.alloc, inst); | ||
| 171 | } | ||
| 172 | |||
| 141 | // If scenario bytes pulled in new glyphs, the atlas pixels are newer | 173 | // If scenario bytes pulled in new glyphs, the atlas pixels are newer |
| 142 | // than the GPU copy. Re-upload before rendering. | 174 | // than the GPU copy. Re-upload before rendering. |
| 143 | if (atlas.dirty) { | 175 | if (atlas.dirty) { |
tests/scenarios/blink-bar.scenario
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,14 @@ | |||
| 1 | # Blinking bar cursor: DECSCUSR 5 then observe two phases. | ||
| 2 | size 80 24 | ||
| 3 | timeout 5000ms | ||
| 4 | |||
| 5 | # Inject DECSCUSR 5 directly (no shell) - blinking bar. | ||
| 6 | bytes "\e[5 q" | ||
| 7 | |||
| 8 | # Give blink state machine time to arm. | ||
| 9 | sleep 100ms | ||
| 10 | capture before-flip | ||
| 11 | |||
| 12 | # Advance past the first phase flip (500ms period). | ||
| 13 | sleep 500ms | ||
| 14 | capture after-flip | ||
tests/scenarios/golden/blink-bar/after-flip.png
| Old | New | ||
|---|---|---|---|
| Binary file | |||
| Binary files differ | |||
tests/scenarios/golden/blink-bar/before-flip.png
| Old | New | ||
|---|---|---|---|
| Binary file | |||
| Binary files differ | |||