feat(B.4b): InputDispatcher detects double-clicks
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) <noreply@anthropic.com>
This commit is contained in:
parent
89d82e1b76
commit
242ce706ef
2 changed files with 142 additions and 0 deletions
|
|
@ -38,6 +38,14 @@ public sealed class InputDispatcher
|
||||||
private readonly Stack<InputScope> _scopes = new();
|
private readonly Stack<InputScope> _scopes = new();
|
||||||
private readonly HashSet<KeyChord> _heldHoldChords = new();
|
private readonly HashSet<KeyChord> _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;
|
||||||
|
|
||||||
/// <summary>K.3 modal-rebind hook: when non-null, the next non-modifier
|
/// <summary>K.3 modal-rebind hook: when non-null, the next non-modifier
|
||||||
/// chord is reported via this callback INSTEAD of firing actions. Esc
|
/// chord is reported via this callback INSTEAD of firing actions. Esc
|
||||||
/// cancels (callback receives <c>default(KeyChord)</c>).</summary>
|
/// cancels (callback receives <c>default(KeyChord)</c>).</summary>
|
||||||
|
|
@ -325,6 +333,24 @@ public sealed class InputDispatcher
|
||||||
Fired?.Invoke(hold.Value.Action, ActivationType.Press);
|
Fired?.Invoke(hold.Value.Action, ActivationType.Press);
|
||||||
_heldHoldChords.Add(chord);
|
_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)
|
private void OnMouseUp(MouseButton button, ModifierMask mods)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for double-click detection added to <see cref="InputDispatcher.OnMouseDown"/>
|
||||||
|
/// in Phase B.4b. The dispatcher tracks the most-recent mouse-down button +
|
||||||
|
/// timestamp; a same-button press within DoubleClickThresholdMs (500 ms)
|
||||||
|
/// additionally fires <see cref="ActivationType.DoubleClick"/> for the matching
|
||||||
|
/// binding on top of the normal <see cref="ActivationType.Press"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class InputDispatcherDoubleClickTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Build a dispatcher wired with LMB Press → SelectLeft,
|
||||||
|
/// LMB DoubleClick → SelectDblLeft, and RMB Press → SelectRight.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Two LMB clicks in rapid succession (~10 ms) → Press fires twice AND
|
||||||
|
/// DoubleClick fires once (on the second click).
|
||||||
|
/// </summary>
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Two LMB clicks 600 ms apart → Press fires twice but NO DoubleClick
|
||||||
|
/// (interval exceeds the 500 ms threshold).
|
||||||
|
/// </summary>
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// LMB then RMB in rapid succession → no DoubleClick (different buttons).
|
||||||
|
/// </summary>
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue