P6.1: lock input for the full in-flight animation window

A swipe that began while a move animation was playing could still commit:
mouse_down latched the drag unconditionally and the animation-active check
sat at mouse_up, so a press made mid-animation committed once the timeline
finished before release — against a board mid-transition.

Gate input at gesture START instead. Add a pure `accepts_input(anim)`
predicate (false while a timeline is active) and check it at mouse_down: a
press begun mid-animation is dropped and never latches a drag, so it cannot
commit when the animation later settles. The now-dead mouse_up gate is
removed. Animation visuals and the logical model are unchanged.

Extend tests/anim_plan.sx to assert accepts_input rejects for the whole
window (idle accept / busy reject / settled accept) and that press-gating
drops the exact failure gesture a release-gate would let through.
This commit is contained in:
swipelab
2026-06-05 01:23:12 +03:00
parent 0b858f7724
commit 5ec7247001
5 changed files with 59 additions and 3 deletions

View File

@@ -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
}

View File

@@ -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); }

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

View File

@@ -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;

View File

@@ -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