diff --git a/board_anim.sx b/board_anim.sx index 0b10e7a..86505b1 100644 --- a/board_anim.sx +++ b/board_anim.sx @@ -184,3 +184,13 @@ BoardAnim :: struct { AnimPhase.{ kind = .done, round = 0, t = 1.0 } } } + +// Input gate: the board accepts a new swipe/tap gesture only when no move +// animation is in flight. The view checks this at gesture START (mouse_down), +// not at commit (mouse_up), so a gesture begun while a timeline is playing never +// latches a drag and so cannot commit when the animation later settles. Input +// resumes once `tick` clears `active` at the end of the timeline. A null anim +// (no animation layer wired) always accepts. +accepts_input :: (anim: *BoardAnim) -> bool { + anim == null or !anim.active +} diff --git a/board_view.sx b/board_view.sx index 6300a83..c1edeb3 100644 --- a/board_view.sx +++ b/board_view.sx @@ -400,6 +400,12 @@ impl View for BoardView { self.compute_layout(frame); if event.* == { case .mouse_down: (d) { + // Gate input at gesture START: while a move animation is in + // flight the board ignores new gestures for the WHOLE in-flight + // window, so a press begun mid-animation never latches a drag and + // so can't commit when the animation later ends. The press is + // still consumed; input resumes once the timeline settles. + if !accepts_input(self.anim) { return true; } self.drag.begin(d.position); return true; } @@ -407,9 +413,6 @@ impl View for BoardView { if !self.drag.active { return false; } start := self.drag.start; self.drag.clear(); - // Ignore swipes while a move is still animating so two timelines - // never overlap; the model is already settled by then either way. - if self.anim != null and self.anim.active { return true; } if intent := swipe_intent(@self.layout, start, d.position) { mv := plan_and_commit(self.board, intent.a, intent.b); if self.anim != null { self.anim.begin(mv); } diff --git a/goldens/p6_inputlock_board.png b/goldens/p6_inputlock_board.png new file mode 100644 index 0000000..5624d42 Binary files /dev/null and b/goldens/p6_inputlock_board.png differ diff --git a/tests/anim_plan.sx b/tests/anim_plan.sx index 05c4da0..7f622b3 100644 --- a/tests/anim_plan.sx +++ b/tests/anim_plan.sx @@ -10,6 +10,10 @@ // each later round starts on the prior round's settled board), and the last // round's `after` equals `final`; // - an illegal swap records no rounds and leaves the board untouched. +// It also guards the P6.1 input-lock fix: `accepts_input` rejects a gesture for +// the FULL in-flight animation window (mouse_down → settle), so a swipe begun +// while a move animates never latches a drag and never commits — even if it is +// released after the timeline ends. // No rendering — it calls exactly what BoardView.handle_event calls. Links headless // like tests/swipe_commit.sx; avoids tests/test.sx (its trace.sx pulls in a second // `Frame` that collides with the UI one). Failure is a non-zero exit code. @@ -108,6 +112,42 @@ main :: () -> s32 { if bi2.score != 0 { fails += 1; } if bi2.moves_made != 0 { fails += 1; } + // ── Input gate: locked for the FULL in-flight animation ───────────────── + // The view begins a drag on mouse_down only when `accepts_input` is true, + // and commits on mouse_up only if a drag latched. So a gesture that BEGINS + // while a move animates must NEVER commit — even if it is released after the + // animation has fully settled. This guards the P6.1 input-lock fix. + print("== input gate: locked while animating ==\n"); + gate : BoardAnim = ---; + gate.init(); + idle_ok := accepts_input(@gate); // no move in flight → accept + gate.begin(move); // a legal move's timeline starts + busy_ok := accepts_input(@gate); // mouse_down DURING animation → reject + while gate.active { gate.tick(0.05); } // player holds; timeline plays out + settled_ok := accepts_input(@gate); // animation fully settled → accept + print("accepts idle {} busy {} settled {}\n", idle_ok, busy_ok, settled_ok); + if !idle_ok { fails += 1; } + if busy_ok { fails += 1; } // MUST be locked while animating + if !settled_ok { fails += 1; } + + // The board MUST decide a gesture at PRESS, not at RELEASE. Over the exact + // failure scenario — a gesture PRESSED while animating and RELEASED after the + // timeline has settled — the two policies diverge: + // release-gate: commit unless animating AT RELEASE → COMMITS (the timeline + // finished first), letting input slip through mid-transition. + // press-gate: latch only if input accepted AT PRESS → DROPS, because + // input was locked for the whole window the timeline ran. + gate.init(); + gate.begin(move); + accept_at_press := accepts_input(@gate); // mouse_down while animating + while gate.active { gate.tick(0.05); } + accept_at_release := accepts_input(@gate); // mouse_up after settle + release_gate_commits := accept_at_release; + press_gate_commits := accept_at_press; + print("release_gate_commits {} press_gate_commits {}\n", release_gate_commits, press_gate_commits); + if !release_gate_commits { fails += 1; } // the scenario release-gating lets through + if press_gate_commits { fails += 1; } // the board press-gates: MUST NOT commit + if fails == 0 { print("ok: animation layer leaves the model result unchanged\n"); return 0; diff --git a/tests/expected/anim_plan.stdout b/tests/expected/anim_plan.stdout index ba20f94..e769413 100644 --- a/tests/expected/anim_plan.stdout +++ b/tests/expected/anim_plan.stdout @@ -14,4 +14,7 @@ YOYYROBB OROBPPRB == illegal swap: untouched == legal false rounds 0 score 0 moves 0 +== input gate: locked while animating == +accepts idle true busy false settled true +release_gate_commits true press_gate_commits false ok: animation layer leaves the model result unchanged