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:
Erik 2026-07-22 11:59:33 +02:00
parent d09e246d3a
commit 8b8afeefa3
42 changed files with 4029 additions and 461 deletions

View file

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

View file

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

View file

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

View file

@ -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) =>
{

View 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}."),
};
}
}

View 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--; }
}
}
}