From 6bb1a4cd73826c6c0d1bdd05e85cb091272669d3 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Fri, 5 Jun 2026 18:33:56 -0700 Subject: [PATCH 1/2] synth: frame-pace the mouse timeline so drags survive a slow/stalled frame advance_mouse_timeline fired every event with t_ms <= now_ms (wall-clock), so on a coarse frame (the windows CI runner, where each test also eats ~10s of host spawn) the press and the destination move collapse into ONE frame: imgui sees the button go down with the cursor already at the end, so the splitter/slider activates with a zero per-frame delta and never moves. test_layout_helpers' real-drag subtest fails exactly this way on windows-latest (value/geometry unchanged) while passing on mac/ubuntu and on a fast local box. Keys already solved this: imgui_key_chord / imgui_key_play are frame-paced (t_ms read as a frame index, one event-group per frame). Mirror it for the mouse: - imgui_live_core: mouse_frame_paced / mouse_frame_no; advance_mouse_timeline steps the threshold by frame index when paced (the existing lerp then interpolates one cursor delta per frame); MousePlayArgs.frame_paced opts in. Wall-clock callers (move_to / click_at / plain play) are byte-identical. - imgui_playwright: drag() emits a frame-indexed timeline with frame_paced=true, so press -> sweep -> release each land on their own frame regardless of dt. drag_to() inherits via drag(). Reproduced locally by throttling the headless harness frame loop to 300ms/frame (test_layout_helpers drag FAIL -> PASS after this change); drag_drop / io_synth_drag stay green at the same throttle. Clicks/recordings (wall-clock) are untouched. Also clears 4 pre-existing LINT012 surfaced by touching the file (_input on the status/stop live_command handlers, matching imgui_snapshot's pattern). Co-Authored-By: Claude Opus 4.8 (1M context) --- widgets/imgui_live_core.das | 34 +++++++++++++++++++++++++++++----- widgets/imgui_playwright.das | 28 ++++++++++++++-------------- 2 files changed, 43 insertions(+), 19 deletions(-) diff --git a/widgets/imgui_live_core.das b/widgets/imgui_live_core.das index e338e07..69db427 100644 --- a/widgets/imgui_live_core.das +++ b/widgets/imgui_live_core.das @@ -47,11 +47,20 @@ var private mouse_playing : bool = false var private last_move_x : float = 0.0 var private last_move_y : float = 0.0 var private last_move_t : int = -1 +// Frame-paced playback: when set, advance steps one t_ms-group per FRAME (t_ms read as +// a frame index) instead of by wall-clock — so a drag delivers a per-frame cursor delta +// regardless of frame rate. A wall-clock drag collapses press+move into a single frame +// on a slow/stalled frame (the CI runner), landing a zero-delta gesture so a splitter +// never moves. Mirrors key_frame_paced. +var private mouse_frame_paced : bool = false +var private mouse_frame_no : int = 0 def private reset_mouse_timeline() { mouse_timeline |> clear() mouse_play_idx = 0 mouse_playing = false + mouse_frame_paced = false + mouse_frame_no = 0 let cur = get_synth_cursor() last_move_x = cur._1 last_move_y = cur._2 @@ -111,6 +120,11 @@ struct MouseEventWire { struct MousePlayArgs { events : array + // When true, t_ms is read as a FRAME index (one event-group per frame) instead of + // wall-clock ms — mirrors imgui_key_play's frame_paced. Keeps a drag robust on slow or + // stalled frames: each frame advances the gesture one step, so press and the following + // moves never collapse into one frame (which lands a zero-delta drag on a splitter). + @optional frame_paced : bool = false } def public sort_mouse_play_events(var events : array) { @@ -126,6 +140,7 @@ def imgui_mouse_play(input : JsonValue?) : JsonValue? { return JV((error = "no events")) if (empty(args.events)) sort_mouse_play_events(args.events) reset_mouse_timeline() + mouse_frame_paced = args.frame_paced // after reset (which clears it); frame-index pacing for drags mouse_timeline |> reserve(length(args.events)) for (w in args.events) { var ev = MouseEvent(t_ms = w.t_ms) @@ -158,7 +173,7 @@ def imgui_mouse_play(input : JsonValue?) : JsonValue? { } [live_command(description="Stop synthetic mouse playback (ImGui bypass) and release any held buttons.")] -def imgui_mouse_stop(input : JsonValue?) : JsonValue? { +def imgui_mouse_stop(_input : JsonValue?) : JsonValue? { let was_playing = mouse_playing var released = 0 for (b in range(0, 5)) { @@ -183,7 +198,7 @@ struct MouseStatusResult { } [live_command(description="ImGui-bypass mouse playback status.")] -def imgui_mouse_status(input : JsonValue?) : JsonValue? { +def imgui_mouse_status(_input : JsonValue?) : JsonValue? { let elapsed = mouse_playing ? int((get_uptime() - mouse_play_start) * 1000.0) : 0 let cur = get_synth_cursor() var held = 0 @@ -206,7 +221,16 @@ def imgui_mouse_status(input : JsonValue?) : JsonValue? { def private advance_mouse_timeline() { return if (!mouse_playing) - let now_ms = int((get_uptime() - mouse_play_start) * 1000.0) + // Frame-paced gestures step one t_ms-group per frame; others use wall-clock. The lerp + // below interpolates by (now_ms - last_move_t)/span, so a frame-index `now_ms` makes it + // advance the cursor exactly one delta per frame — robust to any frame duration. + var now_ms = 0 + if (mouse_frame_paced) { + now_ms = mouse_frame_no + mouse_frame_no++ + } else { + now_ms = int((get_uptime() - mouse_play_start) * 1000.0) + } while (mouse_play_idx < length(mouse_timeline)) { let ev = mouse_timeline[mouse_play_idx] break if (ev.t_ms > now_ms) @@ -506,7 +530,7 @@ def imgui_key_play(input : JsonValue?) : JsonValue? { } [live_command(description="Stop synthetic key playback (ImGui bypass) and release any held keys.")] -def imgui_key_stop(input : JsonValue?) : JsonValue? { +def imgui_key_stop(_input : JsonValue?) : JsonValue? { let was_playing = key_playing let released = length(synth_held_keys) release_held_keys() @@ -525,7 +549,7 @@ struct KeyStatusResult { } [live_command(description="ImGui-bypass keyboard playback status.")] -def imgui_key_status(input : JsonValue?) : JsonValue? { +def imgui_key_status(_input : JsonValue?) : JsonValue? { let elapsed = key_playing ? int((get_uptime() - key_play_start) * 1000.0) : 0 return JV(KeyStatusResult( playing = key_playing, diff --git a/widgets/imgui_playwright.das b/widgets/imgui_playwright.das index 5de9cf1..21de8ce 100644 --- a/widgets/imgui_playwright.das +++ b/widgets/imgui_playwright.das @@ -501,22 +501,22 @@ def public drag(app : ImguiApp; target : string; let cy = ((bb?["y"] ?? 0.0f) + (bb?["w"] ?? 0.0f)) * 0.5f let ex = cx + dx let ey = cy + dy - let drag_ms = (steps > 0 ? steps : 1) * 32 - // Dwell at center BEFORE pressing: a move waypoint pins (cx,cy) at t=80, then - // the press fires 1ms later. Timestamps are STRICTLY increasing (the host sorts - // the timeline by t_ms with no tie-break, so a press sharing the dwell's t_ms - // could sort first and defeat it). The 1ms-into-a-drag_ms-long segment leaves the - // press essentially dead-center (sub-pixel drift), so it still lands on an 8px - // handle — without the dwell the press fires mid-travel and misses entirely. + // Frame-paced drag: t_ms is a FRAME index, so the host advances one cursor delta per + // frame and the press never collapses into the same frame as the sweep — a wall-clock + // drag does exactly that on a slow/stalled host (a coarse dt fires press + the full move + // together), landing a zero-delta gesture that never moves a splitter. ``steps`` is the + // number of sweep frames. A 2-frame dwell pins (cx,cy) on the handle before the press so + // it lands dead-center on a thin (8px) handle; timestamps stay strictly increasing. + let dframes = steps > 0 ? steps : 8 var events <- [ - JV((t_ms = 0, kind = "move", x = cx, y = cy)), - JV((t_ms = 80, kind = "move", x = cx, y = cy)), - JV((t_ms = 81, kind = "button", button = button, action = "press")), - JV((t_ms = 81 + drag_ms, kind = "move", x = ex, y = ey)), - JV((t_ms = 181 + drag_ms, kind = "button", button = button, action = "release")), - JV((t_ms = 281 + drag_ms, kind = "move", x = ex, y = ey)) + JV((t_ms = 0, kind = "move", x = cx, y = cy)), + JV((t_ms = 1, kind = "move", x = cx, y = cy)), + JV((t_ms = 2, kind = "button", button = button, action = "press")), + JV((t_ms = 2 + dframes, kind = "move", x = ex, y = ey)), + JV((t_ms = 3 + dframes, kind = "button", button = button, action = "release")), + JV((t_ms = 4 + dframes, kind = "move", x = ex, y = ey)) ] - let resp = post_command(app, "imgui_mouse_play", JV((events = events))) + let resp = post_command(app, "imgui_mouse_play", JV((events = events, frame_paced = true))) unsafe { delete events; } if (resp == null) return JV((ok = false, error = "drag post failed: {target}")) return JV((ok = true, value = target)) From 87a1b1db23a7a4afe37d17c58acaa9a9a2d3ca94 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Fri, 5 Jun 2026 18:38:25 -0700 Subject: [PATCH 2/2] [PROBE] ctrl_shortcuts: enrich the :126 assert with actual state for CI diagnosis Throwaway. On the windows-CI failure the enriched message prints the actual NAME_INPUT value, io.active_widget, focus, and key-timeline drain (playing + queue idx/total), which disambiguates activation-race vs keys-lost vs insert-not-replace vs timeline-stall. Revert after the failure state is captured. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test_imgui_synth_ctrl_shortcuts.das | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/integration/test_imgui_synth_ctrl_shortcuts.das b/tests/integration/test_imgui_synth_ctrl_shortcuts.das index 1a46ff0..4d53931 100644 --- a/tests/integration/test_imgui_synth_ctrl_shortcuts.das +++ b/tests/integration/test_imgui_synth_ctrl_shortcuts.das @@ -123,7 +123,19 @@ def test_imgui_synth_ctrl_shortcuts(t : T?) { let v = snap?["globals"]?["MAIN_WIN/NAME_INPUT"]?["payload"]?["value"] ?? "" return v == "abc" } - t |> success(typed != null, "NAME_INPUT.value == \"abc\" before chord") + // [PROBE — remove after diagnosis] capture the actual state when this fails on CI: + // disambiguates activation-race (active_widget != NAME_INPUT) vs keys-lost (val="", + // timeline drained q=N/N) vs insert-not-replace (val="Name...") vs stall (key_playing, + // q stuck at 0). + let dbg_snap = snapshot(d) + let dbg_val = dbg_snap?["globals"]?["MAIN_WIN/NAME_INPUT"]?["payload"]?["value"] ?? "?" + let dbg_aw = dbg_snap?["io"]?["active_widget"] ?? "none" + let dbg_focus = dbg_snap?["globals"]?["MAIN_WIN/NAME_INPUT"]?["focus"] ?? false + let dbg_ks = post_command(d, "imgui_key_status", null) + let dbg_kp = dbg_ks?["playing"] ?? true + let dbg_qi = dbg_ks?["queue_idx"] ?? -1 + let dbg_qt = dbg_ks?["queue_total"] ?? -1 + t |> success(typed != null, "NAME_INPUT.value == \"abc\" before chord [val='{dbg_val}' active_widget={dbg_aw} focus={dbg_focus} key_playing={dbg_kp} q={dbg_qi}/{dbg_qt}]") // The value showing "abc" proves the chars landed, but the key_type // timeline's final per-char delay may still be ticking; let it reach