From 242ce706ef1882c9b73f7d5ebc08cf5a08a140bd Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 13 May 2026 18:10:25 +0200 Subject: [PATCH] feat(B.4b): InputDispatcher detects double-clicks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visual test of the B.4b handler revealed the dispatcher never fired SelectDblLeft. OnMouseDown was only looking up Press and Hold activations — DoubleClick bindings in KeyBindings.cs were effectively dead code. Adds 500ms-threshold double-click detection: tracks last-mouse-down button + Environment.TickCount64 timestamp; a same-button press within the threshold additionally fires ActivationType.DoubleClick for the matching binding (Press still fires normally for the second click). Clears the pair-state after firing so a triple-click doesn't produce a second DoubleClick. Tests cover same-button within threshold, beyond threshold (no fire), different-button (no fire), and triple-click (fresh pair required). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Input/InputDispatcher.cs | 26 ++++ .../Input/InputDispatcherDoubleClickTests.cs | 116 ++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherDoubleClickTests.cs diff --git a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs index 590b9a9..84bafce 100644 --- a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs +++ b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs @@ -38,6 +38,14 @@ public sealed class InputDispatcher private readonly Stack _scopes = new(); private readonly HashSet _heldHoldChords = new(); + // Double-click detection. _lastMouseDownButton == null means no recent press. + // _lastMouseDownTickMs is Environment.TickCount64 at the time of that press. + // A subsequent mouse-down on the same button within DoubleClickThresholdMs + // additionally fires ActivationType.DoubleClick for the matching binding. + private MouseButton? _lastMouseDownButton; + private long _lastMouseDownTickMs; + private const long DoubleClickThresholdMs = 500; + /// K.3 modal-rebind hook: when non-null, the next non-modifier /// chord is reported via this callback INSTEAD of firing actions. Esc /// cancels (callback receives default(KeyChord)). @@ -325,6 +333,24 @@ public sealed class InputDispatcher Fired?.Invoke(hold.Value.Action, ActivationType.Press); _heldHoldChords.Add(chord); } + + // Double-click recognition. Same button within DoubleClickThresholdMs + // -> additionally fire ActivationType.DoubleClick for any matching + // binding. Press has already fired for the second click (same as a + // single click); DoubleClick is the *additional* signal. + long nowMs = Environment.TickCount64; + if (_lastMouseDownButton == button + && nowMs - _lastMouseDownTickMs <= DoubleClickThresholdMs) + { + var dbl = _bindings.Find(chord, ActivationType.DoubleClick); + if (dbl is not null) Fired?.Invoke(dbl.Value.Action, ActivationType.DoubleClick); + _lastMouseDownButton = null; // consumed; require fresh pair for next + } + else + { + _lastMouseDownButton = button; + _lastMouseDownTickMs = nowMs; + } } private void OnMouseUp(MouseButton button, ModifierMask mods) diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherDoubleClickTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherDoubleClickTests.cs new file mode 100644 index 0000000..f6de5da --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherDoubleClickTests.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.UI.Abstractions.Tests.Input; + +/// +/// Tests for double-click detection added to +/// in Phase B.4b. The dispatcher tracks the most-recent mouse-down button + +/// timestamp; a same-button press within DoubleClickThresholdMs (500 ms) +/// additionally fires for the matching +/// binding on top of the normal . +/// +public class InputDispatcherDoubleClickTests +{ + /// + /// Build a dispatcher wired with LMB Press → SelectLeft, + /// LMB DoubleClick → SelectDblLeft, and RMB Press → SelectRight. + /// + private static (InputDispatcher dispatcher, FakeMouseSource mouse, List<(InputAction, ActivationType)> fired) + Build() + { + var kb = new FakeKeyboardSource(); + var mouse = new FakeMouseSource(); + var bindings = new KeyBindings(); + + var lmbChord = new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Left), ModifierMask.None, Device: 1); + var rmbChord = new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Right), ModifierMask.None, Device: 1); + + bindings.Add(new Binding(lmbChord, InputAction.SelectLeft)); + bindings.Add(new Binding(lmbChord, InputAction.SelectDblLeft, ActivationType.DoubleClick)); + bindings.Add(new Binding(rmbChord, InputAction.SelectRight)); + + var dispatcher = new InputDispatcher(kb, mouse, bindings); + var fired = new List<(InputAction, ActivationType)>(); + dispatcher.Fired += (a, t) => fired.Add((a, t)); + return (dispatcher, mouse, fired); + } + + /// + /// Two LMB clicks in rapid succession (~10 ms) → Press fires twice AND + /// DoubleClick fires once (on the second click). + /// + [Fact] + public void SecondClick_WithinThreshold_FiresDoubleClick() + { + var (_, mouse, fired) = Build(); + + mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None); + Thread.Sleep(10); + mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None); + + // Two SelectLeft Press events (one per click). + Assert.Equal(2, fired.FindAll(e => e == (InputAction.SelectLeft, ActivationType.Press)).Count); + + // One SelectDblLeft DoubleClick event on the second click. + Assert.Single(fired, e => e == (InputAction.SelectDblLeft, ActivationType.DoubleClick)); + } + + /// + /// Two LMB clicks 600 ms apart → Press fires twice but NO DoubleClick + /// (interval exceeds the 500 ms threshold). + /// + [Fact] + public void SecondClick_BeyondThreshold_DoesNotFireDoubleClick() + { + var (_, mouse, fired) = Build(); + + mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None); + Thread.Sleep(600); + mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None); + + Assert.Equal(2, fired.FindAll(e => e == (InputAction.SelectLeft, ActivationType.Press)).Count); + Assert.Empty(fired.FindAll(e => e.Item2 == ActivationType.DoubleClick)); + } + + /// + /// LMB then RMB in rapid succession → no DoubleClick (different buttons). + /// + [Fact] + public void DifferentButtons_DoNotFireDoubleClick() + { + var (_, mouse, fired) = Build(); + + mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None); + Thread.Sleep(10); + mouse.EmitMouseDown(MouseButton.Right, ModifierMask.None); + + Assert.Empty(fired.FindAll(e => e.Item2 == ActivationType.DoubleClick)); + } + + /// + /// Three rapid LMB clicks → exactly one DoubleClick (between clicks 1 and 2). + /// The third click resets the pair-state, so it acts as the "first click" of + /// a new potential double-click rather than firing a second DoubleClick. + /// + [Fact] + public void ThirdClick_AfterDoubleClick_RequiresFreshPair() + { + var (_, mouse, fired) = Build(); + + mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None); // click 1 + Thread.Sleep(10); + mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None); // click 2 → DoubleClick fires, state reset + Thread.Sleep(10); + mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None); // click 3 → no DoubleClick (fresh pair started) + + // Three Press events total. + Assert.Equal(3, fired.FindAll(e => e == (InputAction.SelectLeft, ActivationType.Press)).Count); + + // Exactly one DoubleClick (between clicks 1 and 2). + Assert.Single(fired.FindAll(e => e == (InputAction.SelectDblLeft, ActivationType.DoubleClick))); + } +}