refactor(input): own pointer and callback lifetime
Move camera pointer, framebuffer resize, and retained/devtools input edges behind focused reversible owners. Preserve input priority while making shutdown deactivate callbacks before live-session retirement and retry physical detach without stranding transport teardown.
This commit is contained in:
parent
d09e246d3a
commit
8b8afeefa3
42 changed files with 4029 additions and 461 deletions
|
|
@ -0,0 +1,287 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.Tests.Input;
|
||||
|
||||
public sealed class CameraPointerInputControllerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public void AttachFailureRollsBackRawPrefixAndLeavesCameraUnsubscribed(int failureIndex)
|
||||
{
|
||||
var surfaces = new[] { new RawSurface(), new RawSurface(), new RawSurface() };
|
||||
surfaces[failureIndex].FailAdd = true;
|
||||
CameraPointerInputController owner = Create(surfaces).Owner;
|
||||
|
||||
Assert.ThrowsAny<Exception>(owner.AttachRaw);
|
||||
|
||||
Assert.True(owner.IsDisposalComplete);
|
||||
Assert.All(surfaces, surface => Assert.Equal(0, surface.LiveEdges));
|
||||
Assert.Equal(failureIndex + 1, surfaces.Sum(surface => surface.AddCalls));
|
||||
Assert.Equal(failureIndex + 1, surfaces.Sum(surface => surface.RemoveCalls));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlyPointerUsesAbsoluteDeltaAndCaptureAdvancesPointerWithoutLooking()
|
||||
{
|
||||
var surface = new RawSurface();
|
||||
var fixture = Create([surface]);
|
||||
fixture.Camera.ToggleFly();
|
||||
fixture.Owner.AttachRaw();
|
||||
float originalYaw = fixture.Camera.Fly.Yaw;
|
||||
float originalPitch = fixture.Camera.Fly.Pitch;
|
||||
|
||||
surface.Raise(new Vector2(10f, 5f));
|
||||
Assert.Equal(originalYaw - 10f * 0.003f, fixture.Camera.Fly.Yaw, 5);
|
||||
Assert.Equal(originalPitch - 5f * 0.003f, fixture.Camera.Fly.Pitch, 5);
|
||||
|
||||
fixture.Capture.Mouse = true;
|
||||
surface.Raise(new Vector2(100f, 100f));
|
||||
float capturedYaw = fixture.Camera.Fly.Yaw;
|
||||
float capturedPitch = fixture.Camera.Fly.Pitch;
|
||||
fixture.Capture.Mouse = false;
|
||||
surface.Raise(new Vector2(102f, 97f));
|
||||
|
||||
Assert.Equal(capturedYaw - 2f * 0.003f, fixture.Camera.Fly.Yaw, 5);
|
||||
Assert.Equal(capturedPitch + 3f * 0.003f, fixture.Camera.Fly.Pitch, 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RawMoveRaisedReentrantlyDuringAttachCannotEnterPartialOwner()
|
||||
{
|
||||
var surface = new RawSurface { RaiseDuringAdd = true };
|
||||
var fixture = Create([surface]);
|
||||
fixture.Camera.ToggleFly();
|
||||
float yaw = fixture.Camera.Fly.Yaw;
|
||||
|
||||
fixture.Owner.AttachRaw();
|
||||
Assert.Equal(yaw, fixture.Camera.Fly.Yaw);
|
||||
surface.Raise(new Vector2(3, 0));
|
||||
|
||||
Assert.NotEqual(yaw, fixture.Camera.Fly.Yaw);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LateGameplayBindDoesNotResubscribeAndFocusLossEndsMouseLook()
|
||||
{
|
||||
var surface = new RawSurface();
|
||||
var fixture = Create([surface]);
|
||||
fixture.Owner.AttachRaw();
|
||||
int adds = surface.AddCalls;
|
||||
var mouseLook = new MouseLook(active: true);
|
||||
var frame = new GameplayInputFrameController(
|
||||
dispatcher: null,
|
||||
new DispatcherMovementInputSource(),
|
||||
mouseLook,
|
||||
new Combat());
|
||||
|
||||
fixture.Owner.BindGameplayFrame(frame);
|
||||
fixture.Owner.HandleFocusChanged(focused: false);
|
||||
|
||||
Assert.Equal(adds, surface.AddCalls);
|
||||
Assert.Equal(1, mouseLook.LifecycleEndCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeactivateSilencesCopiedRawAndCameraDelegatesBeforeDetach()
|
||||
{
|
||||
var surface = new RawSurface();
|
||||
var fixture = Create([surface]);
|
||||
fixture.Camera.ToggleFly();
|
||||
fixture.Owner.AttachRaw();
|
||||
Action<Vector2> copied = surface.Callback!;
|
||||
float yaw = fixture.Camera.Fly.Yaw;
|
||||
|
||||
fixture.Owner.Deactivate();
|
||||
copied(new Vector2(100, 0));
|
||||
fixture.Camera.ToggleFly();
|
||||
fixture.Owner.Dispose();
|
||||
|
||||
Assert.Equal(yaw, fixture.Camera.Fly.Yaw);
|
||||
Assert.True(fixture.Owner.IsDisposalComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessCloseCutoffDoesNotEndMouseLookUntilSessionRetirement()
|
||||
{
|
||||
var fixture = Create([new RawSurface()]);
|
||||
var mouseLook = new MouseLook(active: true);
|
||||
var frame = new GameplayInputFrameController(
|
||||
dispatcher: null,
|
||||
new DispatcherMovementInputSource(),
|
||||
mouseLook,
|
||||
new Combat());
|
||||
fixture.Owner.BindGameplayFrame(frame);
|
||||
fixture.Owner.AttachRaw();
|
||||
|
||||
fixture.Owner.Deactivate();
|
||||
fixture.Owner.Dispose();
|
||||
Assert.Equal(0, mouseLook.LifecycleEndCalls);
|
||||
|
||||
fixture.Owner.ReleaseMouseLookAfterSessionRetirement();
|
||||
Assert.Equal(1, mouseLook.LifecycleEndCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedRawDetachRetriesOnlyPendingEdge()
|
||||
{
|
||||
var surface = new RawSurface();
|
||||
var fixture = Create([surface]);
|
||||
fixture.Owner.AttachRaw();
|
||||
surface.FailRemoveOnce = true;
|
||||
|
||||
fixture.Owner.Dispose();
|
||||
int removes = surface.RemoveCalls;
|
||||
fixture.Owner.Dispose();
|
||||
|
||||
Assert.Equal(2, surface.RemoveCalls);
|
||||
Assert.Equal(removes, surface.RemoveCalls);
|
||||
Assert.True(fixture.Owner.IsDisposalComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisposeBeforeAttachMakesPointerOwnerTerminal()
|
||||
{
|
||||
var surface = new RawSurface();
|
||||
var fixture = Create([surface]);
|
||||
|
||||
fixture.Owner.Dispose();
|
||||
|
||||
Assert.Throws<ObjectDisposedException>(fixture.Owner.AttachRaw);
|
||||
fixture.Owner.Dispose();
|
||||
Assert.Equal(0, surface.LiveEdges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScrollAndSensitivityPreserveModeSpecificPolicy()
|
||||
{
|
||||
var fixture = Create([new RawSurface()]);
|
||||
float orbitDistance = fixture.Camera.Orbit.Distance;
|
||||
|
||||
fixture.Owner.HandleScroll(InputAction.ScrollUp);
|
||||
string orbit = fixture.Owner.AdjustSensitivity(1.2f);
|
||||
fixture.Camera.ToggleFly();
|
||||
float flyBefore = fixture.Owner.ActiveSensitivity;
|
||||
string fly = fixture.Owner.AdjustSensitivity(1.2f);
|
||||
|
||||
Assert.Equal(Math.Clamp(orbitDistance - 20f, 50f, 2000f), fixture.Camera.Orbit.Distance);
|
||||
Assert.Equal("Orbit sens 1.200x", orbit);
|
||||
Assert.Equal("Fly sens 1.200x", fly);
|
||||
Assert.Equal(flyBefore * 1.2f, fixture.Owner.ActiveSensitivity, 5);
|
||||
}
|
||||
|
||||
private static Fixture Create(IReadOnlyList<RawSurface> surfaces)
|
||||
{
|
||||
var camera = new CameraController(new OrbitCamera(), new FlyCamera());
|
||||
var capture = new Capture();
|
||||
var mouse = new Mouse();
|
||||
var cursor = new Cursor();
|
||||
var owner = new CameraPointerInputController(
|
||||
surfaces,
|
||||
cursor,
|
||||
new HostQuiescenceGate(),
|
||||
capture,
|
||||
new LocalPlayerModeState(),
|
||||
camera,
|
||||
new ChaseCameraInputState(),
|
||||
mouse,
|
||||
new PointerPositionState(),
|
||||
new Clock());
|
||||
return new Fixture(owner, camera, capture, cursor);
|
||||
}
|
||||
|
||||
private sealed record Fixture(
|
||||
CameraPointerInputController Owner,
|
||||
CameraController Camera,
|
||||
Capture Capture,
|
||||
Cursor Cursor);
|
||||
|
||||
private sealed class RawSurface : IRawPointerSurface
|
||||
{
|
||||
public bool FailAdd { get; set; }
|
||||
public bool FailRemoveOnce { get; set; }
|
||||
public bool RaiseDuringAdd { get; set; }
|
||||
public int AddCalls { get; private set; }
|
||||
public int RemoveCalls { get; private set; }
|
||||
public int LiveEdges { get; private set; }
|
||||
public Action<Vector2>? Callback { get; private set; }
|
||||
public void AddMouseMove(Action<Vector2> callback)
|
||||
{
|
||||
AddCalls++;
|
||||
Callback = callback;
|
||||
LiveEdges++;
|
||||
if (RaiseDuringAdd) callback(new Vector2(50, 25));
|
||||
if (FailAdd) throw new InvalidOperationException("add");
|
||||
}
|
||||
public void RemoveMouseMove(Action<Vector2> callback)
|
||||
{
|
||||
RemoveCalls++;
|
||||
if (FailRemoveOnce)
|
||||
{
|
||||
FailRemoveOnce = false;
|
||||
throw new InvalidOperationException("remove");
|
||||
}
|
||||
if (ReferenceEquals(Callback, callback))
|
||||
{
|
||||
Callback = null;
|
||||
LiveEdges--;
|
||||
}
|
||||
}
|
||||
public void Raise(Vector2 position) => Callback?.Invoke(position);
|
||||
}
|
||||
|
||||
private sealed class Cursor : IPointerCursorModeTarget
|
||||
{
|
||||
public CursorMode CursorMode { get; set; }
|
||||
}
|
||||
|
||||
private sealed class Capture : IInputCaptureSource
|
||||
{
|
||||
public bool Mouse { get; set; }
|
||||
public bool WantCaptureMouse => Mouse;
|
||||
public bool WantCaptureKeyboard => false;
|
||||
public bool DevToolsWantCaptureKeyboard => false;
|
||||
}
|
||||
|
||||
private sealed class Mouse : IMouseSource
|
||||
{
|
||||
#pragma warning disable CS0067
|
||||
public event Action<MouseButton, ModifierMask>? MouseDown;
|
||||
public event Action<MouseButton, ModifierMask>? MouseUp;
|
||||
public event Action<float, float>? MouseMove;
|
||||
public event Action<float>? Scroll;
|
||||
#pragma warning restore CS0067
|
||||
public bool IsHeld(MouseButton button) => false;
|
||||
public bool WantCaptureMouse => false;
|
||||
public bool WantCaptureKeyboard => false;
|
||||
}
|
||||
|
||||
private sealed class Clock : IInputMonotonicClock
|
||||
{
|
||||
public float NowSeconds => 1f;
|
||||
}
|
||||
|
||||
private sealed class MouseLook(bool active) : IMouseLookInputFrameController
|
||||
{
|
||||
public bool Active { get; } = active;
|
||||
public int LifecycleEndCalls { get; private set; }
|
||||
public bool HandlePointerAction(InputAction action, ActivationType activation) => false;
|
||||
public void QueueRawDelta(float dx, float dy) { }
|
||||
public void Tick() { }
|
||||
public void EndAndRestoreCursor() { }
|
||||
public void EndForLifecycle() => LifecycleEndCalls++;
|
||||
public void ResetSession() { }
|
||||
}
|
||||
|
||||
private sealed class Combat : ICombatInputFrameController
|
||||
{
|
||||
public void Tick() { }
|
||||
public void HandleMovementInput(InputAction action, ActivationType activation) { }
|
||||
public bool HandleInputAction(InputAction action, ActivationType activation) => false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
using AcDream.App.Input;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.Tests.Input;
|
||||
|
||||
public sealed class DispatcherCameraInputSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public void ExactUnbindRestoresNeutralCameraSampling()
|
||||
{
|
||||
var keyboard = new Keyboard();
|
||||
var mouse = new Mouse();
|
||||
var dispatcher = InputDispatcher.CreateDetached(keyboard, mouse, new KeyBindings());
|
||||
dispatcher.Attach();
|
||||
var other = InputDispatcher.CreateDetached(new Keyboard(), new Mouse(), new KeyBindings());
|
||||
other.Attach();
|
||||
var source = new DispatcherCameraInputSource();
|
||||
source.Bind(dispatcher);
|
||||
dispatcher.TrySetAutomationActionHeld(InputAction.MovementForward, held: true);
|
||||
|
||||
source.Unbind(other);
|
||||
Assert.True(source.CaptureFly().Forward);
|
||||
source.Unbind(dispatcher);
|
||||
|
||||
Assert.False(source.IsAvailable);
|
||||
Assert.Equal(default, source.CaptureFly());
|
||||
Assert.Equal(default, source.CaptureChaseAdjustment());
|
||||
}
|
||||
|
||||
private sealed class Keyboard : IKeyboardSource
|
||||
{
|
||||
#pragma warning disable CS0067
|
||||
public event Action<Key, ModifierMask>? KeyDown;
|
||||
public event Action<Key, ModifierMask>? KeyUp;
|
||||
#pragma warning restore CS0067
|
||||
public bool IsHeld(Key key) => false;
|
||||
public ModifierMask CurrentModifiers => ModifierMask.None;
|
||||
}
|
||||
|
||||
private sealed class Mouse : IMouseSource
|
||||
{
|
||||
#pragma warning disable CS0067
|
||||
public event Action<MouseButton, ModifierMask>? MouseDown;
|
||||
public event Action<MouseButton, ModifierMask>? MouseUp;
|
||||
public event Action<float, float>? MouseMove;
|
||||
public event Action<float>? Scroll;
|
||||
#pragma warning restore CS0067
|
||||
public bool IsHeld(MouseButton button) => false;
|
||||
public bool WantCaptureMouse => false;
|
||||
public bool WantCaptureKeyboard => false;
|
||||
}
|
||||
}
|
||||
|
|
@ -112,12 +112,34 @@ public sealed class DispatcherMovementInputSourceTests
|
|||
Assert.Throws<InvalidOperationException>(() => source.Bind(second));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnbindRequiresExactDispatcherAndRestoresNeutralCapture()
|
||||
{
|
||||
var source = new DispatcherMovementInputSource();
|
||||
var (first, _, _) = CreateDispatcher();
|
||||
var (other, _, _) = CreateDispatcher();
|
||||
source.Bind(first);
|
||||
first.TrySetAutomationActionHeld(InputAction.MovementForward, held: true);
|
||||
|
||||
source.Unbind(other);
|
||||
Assert.True(source.Capture().Forward);
|
||||
source.Unbind(first);
|
||||
|
||||
Assert.False(source.IsAvailable);
|
||||
Assert.Equal(default, source.Capture());
|
||||
}
|
||||
|
||||
private static (InputDispatcher Dispatcher, FakeKeyboard Keyboard, FakeMouse Mouse)
|
||||
CreateDispatcher()
|
||||
{
|
||||
var keyboard = new FakeKeyboard();
|
||||
var mouse = new FakeMouse();
|
||||
return (new InputDispatcher(keyboard, mouse, new KeyBindings()), keyboard, mouse);
|
||||
var dispatcher = InputDispatcher.CreateDetached(
|
||||
keyboard,
|
||||
mouse,
|
||||
new KeyBindings());
|
||||
dispatcher.Attach();
|
||||
return (dispatcher, keyboard, mouse);
|
||||
}
|
||||
|
||||
private sealed class FakeKeyboard : IKeyboardSource
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ public sealed class GameplayInputFrameControllerTests
|
|||
new KeyChord(Key.W, ModifierMask.None),
|
||||
InputAction.MovementForward,
|
||||
ActivationType.Hold));
|
||||
var dispatcher = new InputDispatcher(keyboard, mouseSource, bindings);
|
||||
var dispatcher = InputDispatcher.CreateDetached(keyboard, mouseSource, bindings);
|
||||
dispatcher.Attach();
|
||||
keyboard.Press(Key.W);
|
||||
dispatcher.Fired += (_, activation) =>
|
||||
{
|
||||
|
|
|
|||
344
tests/AcDream.App.Tests/Input/QuiescentInputContextTests.cs
Normal file
344
tests/AcDream.App.Tests/Input/QuiescentInputContextTests.cs
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
using System.Reflection;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.Tests.Input;
|
||||
|
||||
public sealed class QuiescentInputContextTests
|
||||
{
|
||||
[Fact]
|
||||
public void EveryFrontendAddFailureRetainsTheCompletePossiblyAttachedPrefix()
|
||||
{
|
||||
for (int failAdd = 1; failAdd <= 10; failAdd++)
|
||||
{
|
||||
var fixture = new Fixture { FailAdd = failAdd };
|
||||
var context = fixture.CreateContext();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => SubscribeAll(context, () => { }));
|
||||
context.Dispose();
|
||||
|
||||
Assert.Equal(failAdd, fixture.AddCalls);
|
||||
Assert.Equal(failAdd, fixture.RemoveCalls);
|
||||
Assert.Equal(0, fixture.LiveEdges);
|
||||
Assert.True(context.IsDisposalComplete);
|
||||
Assert.False(fixture.InnerDisposed);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryFrontendRemoveFailureRetriesOnlyThePendingEdge()
|
||||
{
|
||||
for (int failRemoveEdge = 1; failRemoveEdge <= 10; failRemoveEdge++)
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
var context = fixture.CreateContext();
|
||||
SubscribeAll(context, () => { });
|
||||
context.Activate();
|
||||
fixture.FailRemoveEdgeOnce = failRemoveEdge;
|
||||
|
||||
context.Dispose();
|
||||
int[] completedCounts = fixture.RemoveCounts.ToArray();
|
||||
context.Dispose();
|
||||
|
||||
for (int edge = 1; edge <= 10; edge++)
|
||||
{
|
||||
Assert.Equal(
|
||||
edge == failRemoveEdge ? 2 : 1,
|
||||
completedCounts[edge]);
|
||||
Assert.Equal(completedCounts[edge], fixture.RemoveCounts[edge]);
|
||||
}
|
||||
Assert.Equal(0, fixture.LiveEdges);
|
||||
Assert.True(context.IsDisposalComplete);
|
||||
Assert.False(fixture.InnerDisposed);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeactivateSilencesKeyboardMouseAndConnectionRelaysBeforePhysicalDetach()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
var context = fixture.CreateContext();
|
||||
int calls = 0;
|
||||
SubscribeAll(context, () => calls++);
|
||||
context.Activate();
|
||||
|
||||
fixture.RaiseAll();
|
||||
Assert.Equal(10, calls);
|
||||
|
||||
context.Deactivate();
|
||||
fixture.RaiseAll();
|
||||
context.Dispose();
|
||||
|
||||
Assert.Equal(10, calls);
|
||||
Assert.Equal(0, fixture.LiveEdges);
|
||||
Assert.True(context.IsDisposalComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventRaisedFromAddAccessorCannotEnterPartialFrontend()
|
||||
{
|
||||
var fixture = new Fixture { RaiseKeyboardDownDuringAdd = true };
|
||||
var context = fixture.CreateContext();
|
||||
int calls = 0;
|
||||
|
||||
context.Keyboards[0].KeyDown += (_, _, _) => calls++;
|
||||
Assert.Equal(0, calls);
|
||||
context.Activate();
|
||||
fixture.Keyboard.RaiseDown();
|
||||
|
||||
Assert.Equal(1, calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisposeBeforeFrontendConstructionMakesContextAndDeviceViewsTerminal()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
var context = fixture.CreateContext();
|
||||
IKeyboard keyboard = context.Keyboards[0];
|
||||
IMouse mouse = context.Mice[0];
|
||||
context.Dispose();
|
||||
|
||||
Assert.Throws<ObjectDisposedException>(context.Activate);
|
||||
Assert.Throws<ObjectDisposedException>(
|
||||
() => keyboard.KeyDown += static (_, _, _) => { });
|
||||
Assert.Throws<ObjectDisposedException>(
|
||||
() => mouse.MouseDown += static (_, _) => { });
|
||||
Assert.Throws<ObjectDisposedException>(
|
||||
() => context.ConnectionChanged += static (_, _) => { });
|
||||
|
||||
context.Dispose();
|
||||
Assert.Equal(0, fixture.LiveEdges);
|
||||
Assert.True(context.IsDisposalComplete);
|
||||
}
|
||||
|
||||
private static void SubscribeAll(QuiescentInputContext context, Action called)
|
||||
{
|
||||
IKeyboard keyboard = context.Keyboards[0];
|
||||
IMouse mouse = context.Mice[0];
|
||||
keyboard.KeyDown += (_, _, _) => called();
|
||||
keyboard.KeyUp += (_, _, _) => called();
|
||||
keyboard.KeyChar += (_, _) => called();
|
||||
mouse.MouseDown += (_, _) => called();
|
||||
mouse.MouseUp += (_, _) => called();
|
||||
mouse.Click += (_, _, _) => called();
|
||||
mouse.DoubleClick += (_, _, _) => called();
|
||||
mouse.MouseMove += (_, _) => called();
|
||||
mouse.Scroll += (_, _) => called();
|
||||
context.ConnectionChanged += (_, _) => called();
|
||||
}
|
||||
|
||||
private sealed class Fixture
|
||||
{
|
||||
private readonly FaultPlan _faults = new();
|
||||
private Context? _inner;
|
||||
private IMouse? _mouse;
|
||||
private MouseProxy? _mouseProxy;
|
||||
|
||||
public Keyboard Keyboard { get; private set; } = null!;
|
||||
public int FailAdd { set => _faults.FailAdd = value; }
|
||||
public int FailRemoveEdgeOnce { set => _faults.FailRemoveEdgeOnce = value; }
|
||||
public bool RaiseKeyboardDownDuringAdd { get; set; }
|
||||
public int AddCalls => _faults.AddCalls;
|
||||
public int RemoveCalls => _faults.RemoveCalls;
|
||||
public int[] RemoveCounts => _faults.RemoveCounts;
|
||||
public int LiveEdges => _faults.LiveEdges;
|
||||
public bool InnerDisposed => _inner?.Disposed == true;
|
||||
|
||||
public QuiescentInputContext CreateContext()
|
||||
{
|
||||
Keyboard = new Keyboard(_faults)
|
||||
{
|
||||
RaiseDuringAdd = RaiseKeyboardDownDuringAdd,
|
||||
};
|
||||
_mouse = DispatchProxy.Create<IMouse, MouseProxy>();
|
||||
_mouseProxy = (MouseProxy)(object)_mouse;
|
||||
_mouseProxy.Initialize(_faults, _mouse);
|
||||
_inner = new Context(_faults, Keyboard, _mouse);
|
||||
return new QuiescentInputContext(_inner, new HostQuiescenceGate());
|
||||
}
|
||||
|
||||
public void RaiseAll()
|
||||
{
|
||||
Keyboard.RaiseAll();
|
||||
_mouseProxy!.RaiseAll();
|
||||
_inner!.RaiseConnection(Keyboard);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FaultPlan
|
||||
{
|
||||
public int FailAdd { get; set; }
|
||||
public int FailRemoveEdgeOnce { get; set; }
|
||||
public int AddCalls { get; private set; }
|
||||
public int RemoveCalls { get; private set; }
|
||||
public int LiveEdges { get; private set; }
|
||||
public int[] RemoveCounts { get; } = new int[11];
|
||||
|
||||
public void Add(int edge, Action sideEffect)
|
||||
{
|
||||
AddCalls++;
|
||||
sideEffect();
|
||||
LiveEdges++;
|
||||
if (AddCalls == FailAdd)
|
||||
throw new InvalidOperationException($"add {edge}");
|
||||
}
|
||||
|
||||
public void Remove(int edge, Action sideEffect)
|
||||
{
|
||||
RemoveCalls++;
|
||||
RemoveCounts[edge]++;
|
||||
if (FailRemoveEdgeOnce == edge)
|
||||
{
|
||||
FailRemoveEdgeOnce = 0;
|
||||
throw new InvalidOperationException($"remove {edge}");
|
||||
}
|
||||
|
||||
sideEffect();
|
||||
LiveEdges--;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Context(
|
||||
FaultPlan faults,
|
||||
IKeyboard keyboard,
|
||||
IMouse mouse) : IInputContext
|
||||
{
|
||||
private Action<IInputDevice, bool>? _connectionChanged;
|
||||
public bool Disposed { get; private set; }
|
||||
public nint Handle => 1;
|
||||
public IReadOnlyList<IGamepad> Gamepads { get; } = [];
|
||||
public IReadOnlyList<IJoystick> Joysticks { get; } = [];
|
||||
public IReadOnlyList<IKeyboard> Keyboards { get; } = [keyboard];
|
||||
public IReadOnlyList<IMouse> Mice { get; } = [mouse];
|
||||
public IReadOnlyList<IInputDevice> OtherDevices { get; } = [];
|
||||
|
||||
public event Action<IInputDevice, bool>? ConnectionChanged
|
||||
{
|
||||
add => faults.Add(10, () => _connectionChanged += value);
|
||||
remove => faults.Remove(10, () => _connectionChanged -= value);
|
||||
}
|
||||
|
||||
public void Dispose() => Disposed = true;
|
||||
public void RaiseConnection(IInputDevice device) =>
|
||||
_connectionChanged?.Invoke(device, true);
|
||||
}
|
||||
|
||||
private sealed class Keyboard(FaultPlan faults) : IKeyboard
|
||||
{
|
||||
private Action<IKeyboard, Key, int>? _down;
|
||||
private Action<IKeyboard, Key, int>? _up;
|
||||
private Action<IKeyboard, char>? _char;
|
||||
public bool RaiseDuringAdd { get; init; }
|
||||
public string Name => "keyboard";
|
||||
public int Index => 0;
|
||||
public bool IsConnected => true;
|
||||
public IReadOnlyList<Key> SupportedKeys { get; } = [];
|
||||
public string ClipboardText { get; set; } = string.Empty;
|
||||
|
||||
public event Action<IKeyboard, Key, int>? KeyDown
|
||||
{
|
||||
add => faults.Add(1, () =>
|
||||
{
|
||||
_down += value;
|
||||
if (RaiseDuringAdd)
|
||||
_down?.Invoke(this, Key.A, 0);
|
||||
});
|
||||
remove => faults.Remove(1, () => _down -= value);
|
||||
}
|
||||
public event Action<IKeyboard, Key, int>? KeyUp
|
||||
{
|
||||
add => faults.Add(2, () => _up += value);
|
||||
remove => faults.Remove(2, () => _up -= value);
|
||||
}
|
||||
public event Action<IKeyboard, char>? KeyChar
|
||||
{
|
||||
add => faults.Add(3, () => _char += value);
|
||||
remove => faults.Remove(3, () => _char -= value);
|
||||
}
|
||||
|
||||
public bool IsKeyPressed(Key key) => false;
|
||||
public bool IsScancodePressed(int scancode) => false;
|
||||
public void BeginInput() { }
|
||||
public void EndInput() { }
|
||||
public void RaiseDown() => _down?.Invoke(this, Key.A, 0);
|
||||
public void RaiseAll()
|
||||
{
|
||||
_down?.Invoke(this, Key.A, 0);
|
||||
_up?.Invoke(this, Key.A, 0);
|
||||
_char?.Invoke(this, 'a');
|
||||
}
|
||||
}
|
||||
|
||||
public class MouseProxy : DispatchProxy
|
||||
{
|
||||
private readonly Dictionary<string, Delegate?> _events = new();
|
||||
private FaultPlan _faults = null!;
|
||||
private IMouse _self = null!;
|
||||
|
||||
public void Initialize(FaultPlan faults, IMouse self)
|
||||
{
|
||||
_faults = faults;
|
||||
_self = self;
|
||||
}
|
||||
|
||||
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(targetMethod);
|
||||
args ??= [];
|
||||
if (targetMethod.Name.StartsWith("add_", StringComparison.Ordinal))
|
||||
{
|
||||
string name = targetMethod.Name[4..];
|
||||
int edge = Edge(name);
|
||||
_faults.Add(edge, () =>
|
||||
_events[name] = Delegate.Combine(_events.GetValueOrDefault(name), (Delegate)args[0]!));
|
||||
return null;
|
||||
}
|
||||
if (targetMethod.Name.StartsWith("remove_", StringComparison.Ordinal))
|
||||
{
|
||||
string name = targetMethod.Name[7..];
|
||||
int edge = Edge(name);
|
||||
_faults.Remove(edge, () =>
|
||||
_events[name] = Delegate.Remove(_events.GetValueOrDefault(name), (Delegate)args[0]!));
|
||||
return null;
|
||||
}
|
||||
|
||||
if (targetMethod.Name == nameof(IMouse.IsButtonPressed))
|
||||
return false;
|
||||
Type returnType = targetMethod.ReturnType;
|
||||
if (returnType == typeof(void))
|
||||
return null;
|
||||
if (returnType == typeof(string))
|
||||
return string.Empty;
|
||||
return returnType.IsValueType ? Activator.CreateInstance(returnType) : null;
|
||||
}
|
||||
|
||||
public void RaiseAll()
|
||||
{
|
||||
Get<Action<IMouse, MouseButton>>(nameof(IMouse.MouseDown))?.Invoke(_self, MouseButton.Left);
|
||||
Get<Action<IMouse, MouseButton>>(nameof(IMouse.MouseUp))?.Invoke(_self, MouseButton.Left);
|
||||
Get<Action<IMouse, MouseButton, System.Numerics.Vector2>>(nameof(IMouse.Click))
|
||||
?.Invoke(_self, MouseButton.Left, System.Numerics.Vector2.One);
|
||||
Get<Action<IMouse, MouseButton, System.Numerics.Vector2>>(nameof(IMouse.DoubleClick))
|
||||
?.Invoke(_self, MouseButton.Left, System.Numerics.Vector2.One);
|
||||
Get<Action<IMouse, System.Numerics.Vector2>>(nameof(IMouse.MouseMove))
|
||||
?.Invoke(_self, System.Numerics.Vector2.One);
|
||||
Get<Action<IMouse, ScrollWheel>>(nameof(IMouse.Scroll))
|
||||
?.Invoke(_self, new ScrollWheel(1, 1));
|
||||
}
|
||||
|
||||
private T? Get<T>(string name) where T : Delegate =>
|
||||
_events.GetValueOrDefault(name) as T;
|
||||
|
||||
private static int Edge(string name) => name switch
|
||||
{
|
||||
nameof(IMouse.MouseDown) => 4,
|
||||
nameof(IMouse.MouseUp) => 5,
|
||||
nameof(IMouse.Click) => 6,
|
||||
nameof(IMouse.DoubleClick) => 7,
|
||||
nameof(IMouse.MouseMove) => 8,
|
||||
nameof(IMouse.Scroll) => 9,
|
||||
_ => throw new InvalidOperationException($"Unexpected mouse event {name}."),
|
||||
};
|
||||
}
|
||||
}
|
||||
284
tests/AcDream.App.Tests/Input/SilkInputSourceLifetimeTests.cs
Normal file
284
tests/AcDream.App.Tests/Input/SilkInputSourceLifetimeTests.cs
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.Tests.Input;
|
||||
|
||||
public sealed class SilkInputSourceLifetimeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public void KeyboardAttachFailureRollsBackEveryPossiblyAcquiredPrefix(int failAdd)
|
||||
{
|
||||
var surface = new KeyboardSurface { FailAdd = failAdd };
|
||||
var source = SilkKeyboardSource.CreateDetached(surface, new HostQuiescenceGate());
|
||||
|
||||
Assert.ThrowsAny<Exception>(source.Attach);
|
||||
|
||||
Assert.True(source.IsDisposalComplete);
|
||||
Assert.Equal(failAdd, surface.AddCalls);
|
||||
Assert.Equal(failAdd, surface.RemoveCalls);
|
||||
Assert.Equal(0, surface.LiveEdges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyboardDeactivateSilencesCopiedDelegateBeforePhysicalDetach()
|
||||
{
|
||||
var surface = new KeyboardSurface();
|
||||
var source = SilkKeyboardSource.CreateDetached(surface, new HostQuiescenceGate());
|
||||
int calls = 0;
|
||||
source.KeyDown += (_, _) => calls++;
|
||||
source.Attach();
|
||||
Action<Key> copied = surface.Down!;
|
||||
|
||||
surface.RaiseDown(Key.A);
|
||||
source.Deactivate();
|
||||
copied(Key.B);
|
||||
source.Dispose();
|
||||
|
||||
Assert.Equal(1, calls);
|
||||
Assert.True(source.IsDisposalComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyboardEventRaisedReentrantlyDuringAttachCannotEnterPartialSource()
|
||||
{
|
||||
var surface = new KeyboardSurface { RaiseDuringAdd = true };
|
||||
var source = SilkKeyboardSource.CreateDetached(surface, new HostQuiescenceGate());
|
||||
int calls = 0;
|
||||
source.KeyDown += (_, _) => calls++;
|
||||
|
||||
source.Attach();
|
||||
Assert.Equal(0, calls);
|
||||
surface.RaiseDown(Key.A);
|
||||
|
||||
Assert.Equal(1, calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyboardDetachRetriesOnlyPendingEdge()
|
||||
{
|
||||
var surface = new KeyboardSurface();
|
||||
var source = SilkKeyboardSource.CreateDetached(surface, new HostQuiescenceGate());
|
||||
source.Attach();
|
||||
surface.FailRemoveDown = true;
|
||||
|
||||
Assert.Throws<AggregateException>(source.Dispose);
|
||||
int upRemoves = surface.UpRemoveCalls;
|
||||
surface.FailRemoveDown = false;
|
||||
source.Dispose();
|
||||
|
||||
Assert.Equal(upRemoves, surface.UpRemoveCalls);
|
||||
Assert.True(source.IsDisposalComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyboardDisposeBeforeAttachMakesSourceTerminal()
|
||||
{
|
||||
var surface = new KeyboardSurface();
|
||||
var source = SilkKeyboardSource.CreateDetached(surface, new HostQuiescenceGate());
|
||||
|
||||
source.Dispose();
|
||||
|
||||
Assert.Throws<ObjectDisposedException>(source.Attach);
|
||||
source.Dispose();
|
||||
Assert.Equal(0, surface.LiveEdges);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
public void MouseAttachFailureRollsBackEveryPossiblyAcquiredPrefix(int failAdd)
|
||||
{
|
||||
var surface = new MouseSurface { FailAdd = failAdd };
|
||||
var source = SilkMouseSource.CreateDetached(
|
||||
surface,
|
||||
new Capture(),
|
||||
modifierSource: null,
|
||||
new HostQuiescenceGate());
|
||||
|
||||
Assert.ThrowsAny<Exception>(source.Attach);
|
||||
|
||||
Assert.True(source.IsDisposalComplete);
|
||||
Assert.Equal(failAdd, surface.AddCalls);
|
||||
Assert.Equal(failAdd, surface.RemoveCalls);
|
||||
Assert.Equal(0, surface.LiveEdges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MouseDeactivateSilencesAllCopiedDelegatesBeforePhysicalDetach()
|
||||
{
|
||||
var surface = new MouseSurface();
|
||||
var source = SilkMouseSource.CreateDetached(
|
||||
surface,
|
||||
new Capture(),
|
||||
modifierSource: null,
|
||||
new HostQuiescenceGate());
|
||||
int calls = 0;
|
||||
source.MouseDown += (_, _) => calls++;
|
||||
source.MouseUp += (_, _) => calls++;
|
||||
source.MouseMove += (_, _) => calls++;
|
||||
source.Scroll += _ => calls++;
|
||||
source.Attach();
|
||||
var copied = surface.CopyCallbacks();
|
||||
|
||||
surface.RaiseAll();
|
||||
source.Deactivate();
|
||||
copied.Down(MouseButton.Left);
|
||||
copied.Up(MouseButton.Left);
|
||||
copied.Move(new Vector2(5, 6));
|
||||
copied.Scroll(1);
|
||||
source.Dispose();
|
||||
|
||||
Assert.Equal(4, calls);
|
||||
Assert.True(source.IsDisposalComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MouseFailedDetachRetriesOnlyPendingEdge()
|
||||
{
|
||||
var surface = new MouseSurface();
|
||||
var source = SilkMouseSource.CreateDetached(
|
||||
surface,
|
||||
new Capture(),
|
||||
modifierSource: null,
|
||||
new HostQuiescenceGate());
|
||||
source.Attach();
|
||||
surface.FailRemoveMoveOnce = true;
|
||||
|
||||
source.Dispose();
|
||||
int removes = surface.RemoveCalls;
|
||||
source.Dispose();
|
||||
|
||||
Assert.Equal(removes, surface.RemoveCalls);
|
||||
Assert.Equal(2, surface.MoveRemoveCalls);
|
||||
Assert.True(source.IsDisposalComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MouseDisposeBeforeAttachMakesSourceTerminal()
|
||||
{
|
||||
var surface = new MouseSurface();
|
||||
var source = SilkMouseSource.CreateDetached(
|
||||
surface,
|
||||
new Capture(),
|
||||
modifierSource: null,
|
||||
new HostQuiescenceGate());
|
||||
|
||||
source.Dispose();
|
||||
|
||||
Assert.Throws<ObjectDisposedException>(source.Attach);
|
||||
source.Dispose();
|
||||
Assert.Equal(0, surface.LiveEdges);
|
||||
}
|
||||
|
||||
private sealed class Capture : IInputCaptureSource
|
||||
{
|
||||
public bool WantCaptureMouse => false;
|
||||
public bool WantCaptureKeyboard => false;
|
||||
public bool DevToolsWantCaptureKeyboard => false;
|
||||
}
|
||||
|
||||
private sealed class KeyboardSurface : IKeyboardEventSurface
|
||||
{
|
||||
public int FailAdd { get; init; }
|
||||
public bool FailRemoveDown { get; set; }
|
||||
public bool RaiseDuringAdd { get; init; }
|
||||
public int AddCalls { get; private set; }
|
||||
public int RemoveCalls { get; private set; }
|
||||
public int UpRemoveCalls { get; private set; }
|
||||
public int LiveEdges { get; private set; }
|
||||
private Action<Key>? _down;
|
||||
private Action<Key>? _up;
|
||||
public Action<Key>? Down => _down;
|
||||
|
||||
public void AddKeyDown(Action<Key> callback) => Add(ref _down, callback);
|
||||
public void AddKeyUp(Action<Key> callback) => Add(ref _up, callback);
|
||||
public void RemoveKeyDown(Action<Key> callback)
|
||||
{
|
||||
RemoveCalls++;
|
||||
if (FailRemoveDown) throw new InvalidOperationException("remove down");
|
||||
if (ReferenceEquals(_down, callback)) { _down = null; LiveEdges--; }
|
||||
}
|
||||
public void RemoveKeyUp(Action<Key> callback)
|
||||
{
|
||||
RemoveCalls++;
|
||||
UpRemoveCalls++;
|
||||
if (ReferenceEquals(_up, callback)) { _up = null; LiveEdges--; }
|
||||
}
|
||||
public bool IsKeyPressed(Key key) => false;
|
||||
public void RaiseDown(Key key) => _down?.Invoke(key);
|
||||
|
||||
private void Add(ref Action<Key>? slot, Action<Key> callback)
|
||||
{
|
||||
AddCalls++;
|
||||
slot = callback;
|
||||
LiveEdges++;
|
||||
if (RaiseDuringAdd) callback(Key.A);
|
||||
if (AddCalls == FailAdd) throw new InvalidOperationException("add");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MouseSurface : IMouseEventSurface
|
||||
{
|
||||
public int FailAdd { get; init; }
|
||||
public bool FailRemoveMoveOnce { get; set; }
|
||||
public int AddCalls { get; private set; }
|
||||
public int RemoveCalls { get; private set; }
|
||||
public int MoveRemoveCalls { get; private set; }
|
||||
public int LiveEdges { get; private set; }
|
||||
private Action<MouseButton>? _down;
|
||||
private Action<MouseButton>? _up;
|
||||
private Action<Vector2>? _move;
|
||||
private Action<float>? _scroll;
|
||||
|
||||
public void AddMouseDown(Action<MouseButton> callback) => Add(ref _down, callback);
|
||||
public void AddMouseUp(Action<MouseButton> callback) => Add(ref _up, callback);
|
||||
public void AddMouseMove(Action<Vector2> callback) => Add(ref _move, callback);
|
||||
public void AddScroll(Action<float> callback) => Add(ref _scroll, callback);
|
||||
public void RemoveMouseDown(Action<MouseButton> callback) => Remove(ref _down, callback);
|
||||
public void RemoveMouseUp(Action<MouseButton> callback) => Remove(ref _up, callback);
|
||||
public void RemoveMouseMove(Action<Vector2> callback)
|
||||
{
|
||||
MoveRemoveCalls++;
|
||||
if (FailRemoveMoveOnce)
|
||||
{
|
||||
FailRemoveMoveOnce = false;
|
||||
throw new InvalidOperationException("remove move");
|
||||
}
|
||||
Remove(ref _move, callback);
|
||||
}
|
||||
public void RemoveScroll(Action<float> callback) => Remove(ref _scroll, callback);
|
||||
public bool IsButtonPressed(MouseButton button) => false;
|
||||
|
||||
public (Action<MouseButton> Down, Action<MouseButton> Up, Action<Vector2> Move, Action<float> Scroll)
|
||||
CopyCallbacks() => (_down!, _up!, _move!, _scroll!);
|
||||
|
||||
public void RaiseAll()
|
||||
{
|
||||
_down?.Invoke(MouseButton.Left);
|
||||
_up?.Invoke(MouseButton.Left);
|
||||
_move?.Invoke(Vector2.One);
|
||||
_scroll?.Invoke(1);
|
||||
}
|
||||
|
||||
private void Add<T>(ref Action<T>? slot, Action<T> callback)
|
||||
{
|
||||
AddCalls++;
|
||||
slot = callback;
|
||||
LiveEdges++;
|
||||
if (AddCalls == FailAdd) throw new InvalidOperationException("add");
|
||||
}
|
||||
|
||||
private void Remove<T>(ref Action<T>? slot, Action<T> callback)
|
||||
{
|
||||
RemoveCalls++;
|
||||
if (ReferenceEquals(slot, callback)) { slot = null; LiveEdges--; }
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue