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

View file

@ -0,0 +1,76 @@
using AcDream.App.Input;
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
public sealed class FramebufferResizeControllerTests
{
[Fact]
public void ResizePreservesViewportAspectCameraDevToolsOrder()
{
var calls = new List<string>();
var aspect = new ViewportAspectState();
var owner = new FramebufferResizeController(aspect);
owner.BindViewport(new Viewport(calls));
owner.BindCamera(new Camera(calls));
owner.BindDevTools(new DevTools(calls));
owner.Resize(1600, 900);
Assert.Equal(["viewport:1600x900", "camera:1.778", "devtools:1600x900"], calls);
Assert.Equal(1600f / 900f, aspect.Aspect, 5);
}
[Theory]
[InlineData(0, 720)]
[InlineData(1280, 0)]
[InlineData(-1, 720)]
[InlineData(1280, -1)]
public void NonPositiveFramebufferIsRejectedWithoutMutatingTargets(int width, int height)
{
var calls = new List<string>();
var aspect = new ViewportAspectState();
var owner = new FramebufferResizeController(aspect);
owner.BindViewport(new Viewport(calls));
owner.BindCamera(new Camera(calls));
owner.BindDevTools(new DevTools(calls));
owner.Resize(width, height);
Assert.Empty(calls);
Assert.Equal(16f / 9f, aspect.Aspect);
}
[Fact]
public void LateBindingDoesNotReplayEarlierResize()
{
var calls = new List<string>();
var owner = new FramebufferResizeController(new ViewportAspectState());
owner.Resize(1024, 768);
owner.BindViewport(new Viewport(calls));
owner.BindCamera(new Camera(calls));
owner.BindDevTools(new DevTools(calls));
Assert.Empty(calls);
owner.Resize(800, 600);
Assert.Equal(["viewport:800x600", "camera:1.333", "devtools:800x600"], calls);
}
private sealed class Viewport(List<string> calls) : IFramebufferViewportTarget
{
public void ResizeViewport(int width, int height) =>
calls.Add($"viewport:{width}x{height}");
}
private sealed class Camera(List<string> calls) : IFramebufferCameraTarget
{
public void SetAspect(float aspect) => calls.Add($"camera:{aspect:F3}");
}
private sealed class DevTools(List<string> calls) : IFramebufferDevToolsTarget
{
public void ResetLayout(int width, int height) =>
calls.Add($"devtools:{width}x{height}");
}
}

View file

@ -99,7 +99,13 @@ public sealed class GameWindowRenderLeafCompositionTests
Assert.Contains("new AcDream.App.Rendering.PrivatePresentationRenderer(", source);
Assert.Contains("new AcDream.App.Rendering.RenderFrameOrchestrator(", source);
Assert.Contains("new DisplayFramePacingController(", source);
Assert.Contains("_inputCapture.WantCaptureMouse", source);
string pointerSource = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Input",
"CameraPointerInputController.cs"));
Assert.Contains("_capture.WantCaptureMouse", pointerSource);
}
[Fact]

View file

@ -54,14 +54,18 @@ public sealed class GameWindowSlice8BoundaryTests
body,
"_gl = GL.GetApi(_window!);",
"_input = _window!.CreateInput();",
"_kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb);",
"_mouseSource = new AcDream.App.Input.SilkMouseSource(",
"_inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher(",
"_kbSource = AcDream.App.Input.SilkKeyboardSource.CreateDetached(",
"_kbSource.Attach();",
"_mouseSource = AcDream.App.Input.SilkMouseSource.CreateDetached(",
"_mouseSource.Attach();",
"_inputDispatcher = AcDream.UI.Abstractions.Input.InputDispatcher.CreateDetached(",
"_inputDispatcher.Attach();",
"_movementInput.Bind(_inputDispatcher);",
"_cameraInput.Bind(_inputDispatcher);",
"_inputDispatcher.Fired += OnInputAction;",
"mouse.MouseMove +=",
"_cameraController = new CameraController(orbit, fly);",
"_cameraPointerInput = AcDream.App.Input.CameraPointerInputController.Create(",
"_cameraPointerInput.AttachRaw();",
"_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);",
"LoadAndApplyPersistedSettings();",
"_uiHost = new AcDream.App.UI.UiHost(",
@ -74,6 +78,16 @@ public sealed class GameWindowSlice8BoundaryTests
"_liveSessionHost = CreateLiveSessionHost();",
"_liveSessionHost.Start(_options)");
Assert.Equal(1, CountOccurrences(body, "_liveSessionHost.Start(_options)"));
Assert.Contains("if (firstKb is not null)", body, StringComparison.Ordinal);
Assert.Contains("if (firstMouse is not null)", body, StringComparison.Ordinal);
Assert.Contains(
"if (_kbSource is not null && _mouseSource is not null)",
body,
StringComparison.Ordinal);
Assert.DoesNotContain(
"if (firstKb is not null && firstMouse is not null)",
body,
StringComparison.Ordinal);
int start = body.IndexOf("_liveSessionHost.Start(_options)", StringComparison.Ordinal);
string postStart = body[start..];
Assert.Contains("switch (liveStart.Status)", postStart, StringComparison.Ordinal);
@ -144,20 +158,28 @@ public sealed class GameWindowSlice8BoundaryTests
}
[Fact]
public void FramebufferResize_PreservesViewportCameraAndDevtoolsOrder()
public void FramebufferResize_IsOneTypedOwnerHandoff()
{
string load = MethodBody(
"private void OnLoad()",
"private AcDream.App.Net.LiveSessionHost");
string body = MethodBody(
"private void OnFramebufferResize(",
"private void ApplyDisplayWindowState(");
Assert.Contains("=> _framebufferResize.Resize(newSize);", body, StringComparison.Ordinal);
Assert.DoesNotContain("Viewport(", body, StringComparison.Ordinal);
Assert.DoesNotContain("SetAspect(", body, StringComparison.Ordinal);
Assert.DoesNotContain("ResetLayout(", body, StringComparison.Ordinal);
AssertAppearsInOrder(
body,
"if (newSize.X <= 0 || newSize.Y <= 0) return;",
"_gl?.Viewport(",
"_viewportAspect.Update(",
"_cameraController?.SetAspect(",
"_devToolsFramePresenter?.ResetLayout(");
Assert.DoesNotContain("_uiHost", body, StringComparison.Ordinal);
load,
"_framebufferResize.BindViewport(",
"_framebufferResize.BindCamera(",
"_framebufferResize.Resize(_window!.FramebufferSize);");
Assert.DoesNotContain(
"_viewportAspect.Update(_window",
load,
StringComparison.Ordinal);
}
[Fact]
@ -167,7 +189,7 @@ public sealed class GameWindowSlice8BoundaryTests
string update = Slice(
source,
"private void OnUpdate(double dt)",
"private void OnCameraModeChanged(");
"private void OnRender(double deltaSeconds)");
string render = Slice(
source,
"private void OnRender(double deltaSeconds)",
@ -189,7 +211,10 @@ public sealed class GameWindowSlice8BoundaryTests
"_renderFrameOrchestrator!.Render(",
"new AcDream.App.Rendering.RenderFrameInput(");
Assert.DoesNotContain("FramebufferSize", render, StringComparison.Ordinal);
AssertAppearsInOrder(focus, "if (!focused)", "_gameplayInputFrame?.EndMouseLook();");
Assert.Contains(
"=> _cameraPointerInput?.HandleFocusChanged(focused);",
focus,
StringComparison.Ordinal);
AssertAppearsInOrder(
close,
"_hostQuiescence.StopAccepting();",
@ -212,7 +237,9 @@ public sealed class GameWindowSlice8BoundaryTests
string[] stages =
[
"new ResourceShutdownStage(\"input callback deactivation\"",
"new ResourceShutdownStage(\"session lifetime\"",
"new ResourceShutdownStage(\"input callback detach\"",
"new ResourceShutdownStage(\"frame borrowers\"",
"new ResourceShutdownStage(\"session dependents\"",
"new ResourceShutdownStage(\"live entities\"",
@ -236,6 +263,17 @@ public sealed class GameWindowSlice8BoundaryTests
"binding.Dispose();",
"if (!binding.IsDisposalComplete)",
"_windowCallbacks = null;");
AssertAppearsInOrder(
manifest,
"new(\"camera pointer\", () => _cameraPointerInput?.Deactivate())",
"new ResourceShutdownStage(\"session lifetime\"",
"controller.Dispose();",
"new ResourceShutdownStage(\"input callback detach\"",
"pointer.Dispose();",
"new ResourceShutdownStage(\"session dependents\"",
"pointer.ReleaseMouseLookAfterSessionRetirement();",
"pointer.UnbindGameplayFrame(gameplayFrame);",
"_cameraPointerInput = null;");
AssertAppearsInOrder(
dispose,
"CompleteShutdown();",

View file

@ -0,0 +1,262 @@
using AcDream.App.Rendering;
using AcDream.App.UI;
using Silk.NET.Input;
namespace AcDream.App.Tests.UI;
public sealed class RetainedUiInputBindingTests
{
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void MouseAttachFailureRollsBackEveryPossiblyAcquiredPrefix(int failAdd)
{
var surface = new MouseSurface { FailAdd = failAdd };
var binding = new RetainedMouseInputBinding(
surface,
new UiRoot(),
new HostQuiescenceGate());
Assert.ThrowsAny<Exception>(binding.Attach);
Assert.True(binding.IsDisposalComplete);
Assert.Equal(failAdd, surface.AddCalls);
Assert.Equal(failAdd, surface.RemoveCalls);
Assert.Equal(0, surface.LiveEdges);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
public void KeyboardAttachFailureRollsBackEveryPossiblyAcquiredPrefix(int failAdd)
{
var surface = new KeyboardSurface { FailAdd = failAdd };
var binding = new RetainedKeyboardInputBinding(
surface,
new UiRoot(),
new HostQuiescenceGate());
Assert.ThrowsAny<Exception>(binding.Attach);
Assert.True(binding.IsDisposalComplete);
Assert.Equal(failAdd, surface.AddCalls);
Assert.Equal(failAdd, surface.RemoveCalls);
Assert.Equal(0, surface.LiveEdges);
}
[Fact]
public void MouseDeactivateSilencesCopiedDelegateBeforePhysicalDetach()
{
var surface = new MouseSurface();
var root = new UiRoot();
int worldClicks = 0;
root.WorldMouseFallThrough += (_, _, _, _) => worldClicks++;
var binding = new RetainedMouseInputBinding(
surface,
root,
new HostQuiescenceGate());
binding.Attach();
Action<MouseButton, int, int> copied = surface.Down!;
copied(MouseButton.Left, 10, 20);
binding.Deactivate();
copied(MouseButton.Left, 10, 20);
binding.Dispose();
Assert.Equal(1, worldClicks);
}
[Fact]
public void MouseEventRaisedReentrantlyDuringAttachCannotEnterPartialUiBinding()
{
var surface = new MouseSurface { RaiseDuringAdd = true };
var root = new UiRoot();
int worldClicks = 0;
root.WorldMouseFallThrough += (_, _, _, _) => worldClicks++;
var binding = new RetainedMouseInputBinding(
surface,
root,
new HostQuiescenceGate());
binding.Attach();
Assert.Equal(0, worldClicks);
surface.Down!(MouseButton.Left, 1, 2);
Assert.Equal(1, worldClicks);
}
[Fact]
public void KeyboardDeactivateSilencesCopiedDelegateBeforePhysicalDetach()
{
var surface = new KeyboardSurface();
var root = new UiRoot();
int worldKeys = 0;
root.WorldKeyFallThrough += (_, _) => worldKeys++;
var binding = new RetainedKeyboardInputBinding(
surface,
root,
new HostQuiescenceGate());
binding.Attach();
Action<Key> copied = surface.Down!;
copied(Key.A);
binding.Deactivate();
copied(Key.B);
binding.Dispose();
Assert.Equal(1, worldKeys);
}
[Fact]
public void RetainedBindingsAreTerminalAfterDisposeBeforeAttach()
{
var mouseSurface = new MouseSurface();
var mouse = new RetainedMouseInputBinding(
mouseSurface,
new UiRoot(),
new HostQuiescenceGate());
var keyboardSurface = new KeyboardSurface();
var keyboard = new RetainedKeyboardInputBinding(
keyboardSurface,
new UiRoot(),
new HostQuiescenceGate());
mouse.Dispose();
keyboard.Dispose();
Assert.Throws<ObjectDisposedException>(mouse.Attach);
Assert.Throws<ObjectDisposedException>(keyboard.Attach);
mouse.Dispose();
keyboard.Dispose();
Assert.Equal(0, mouseSurface.LiveEdges);
Assert.Equal(0, keyboardSurface.LiveEdges);
}
[Fact]
public void RetainedBindingsRetryOnlyFailedPhysicalEdges()
{
var mouseSurface = new MouseSurface();
var mouse = new RetainedMouseInputBinding(
mouseSurface,
new UiRoot(),
new HostQuiescenceGate());
var keyboardSurface = new KeyboardSurface();
var keyboard = new RetainedKeyboardInputBinding(
keyboardSurface,
new UiRoot(),
new HostQuiescenceGate());
mouse.Attach();
keyboard.Attach();
mouseSurface.FailRemoveMoveOnce = true;
keyboardSurface.FailRemoveUpOnce = true;
mouse.Dispose();
keyboard.Dispose();
int mouseRemoves = mouseSurface.RemoveCalls;
int keyboardRemoves = keyboardSurface.RemoveCalls;
mouse.Dispose();
keyboard.Dispose();
Assert.Equal(mouseRemoves, mouseSurface.RemoveCalls);
Assert.Equal(keyboardRemoves, keyboardSurface.RemoveCalls);
Assert.Equal(2, mouseSurface.MoveRemoveCalls);
Assert.Equal(2, keyboardSurface.UpRemoveCalls);
Assert.True(mouse.IsDisposalComplete);
Assert.True(keyboard.IsDisposalComplete);
}
private sealed class MouseSurface : IRetainedMouseSurface
{
public int FailAdd { get; init; }
public bool RaiseDuringAdd { 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; }
public Action<MouseButton, int, int>? Down { get; private set; }
private Action<MouseButton, int, int>? _up;
private Action<int, int>? _move;
private Action<int>? _scroll;
public void AddMouseDown(Action<MouseButton, int, int> callback) => Add(() => Down = callback);
public void AddMouseUp(Action<MouseButton, int, int> callback) => Add(() => _up = callback);
public void AddMouseMove(Action<int, int> callback) => Add(() => _move = callback);
public void AddScroll(Action<int> callback) => Add(() => _scroll = callback);
public void RemoveMouseDown(Action<MouseButton, int, int> callback) => Remove(() => Down = null);
public void RemoveMouseUp(Action<MouseButton, int, int> callback) => Remove(() => _up = null);
public void RemoveMouseMove(Action<int, int> callback)
{
MoveRemoveCalls++;
if (FailRemoveMoveOnce)
{
FailRemoveMoveOnce = false;
throw new InvalidOperationException("remove move");
}
Remove(() => _move = null);
}
public void RemoveScroll(Action<int> callback) => Remove(() => _scroll = null);
private void Add(Action publish)
{
AddCalls++;
publish();
LiveEdges++;
if (RaiseDuringAdd)
Down?.Invoke(MouseButton.Left, 1, 2);
if (AddCalls == FailAdd) throw new InvalidOperationException("add");
}
private void Remove(Action clear)
{
RemoveCalls++;
clear();
LiveEdges--;
}
}
private sealed class KeyboardSurface : IRetainedKeyboardSurface
{
public int FailAdd { get; init; }
public bool FailRemoveUpOnce { get; set; }
public int AddCalls { get; private set; }
public int RemoveCalls { get; private set; }
public int UpRemoveCalls { get; private set; }
public int LiveEdges { get; private set; }
public Action<Key>? Down { get; private set; }
private Action<Key>? _up;
private Action<char>? _char;
public void AddKeyDown(Action<Key> callback) => Add(() => Down = callback);
public void AddKeyUp(Action<Key> callback) => Add(() => _up = callback);
public void AddKeyChar(Action<char> callback) => Add(() => _char = callback);
public void RemoveKeyDown(Action<Key> callback) => Remove(() => Down = null);
public void RemoveKeyUp(Action<Key> callback)
{
UpRemoveCalls++;
if (FailRemoveUpOnce)
{
FailRemoveUpOnce = false;
throw new InvalidOperationException("remove up");
}
Remove(() => _up = null);
}
public void RemoveKeyChar(Action<char> callback) => Remove(() => _char = null);
private void Add(Action publish)
{
AddCalls++;
publish();
LiveEdges++;
if (AddCalls == FailAdd) throw new InvalidOperationException("add");
}
private void Remove(Action clear)
{
RemoveCalls++;
clear();
LiveEdges--;
}
}
}

View file

@ -39,10 +39,11 @@ public sealed class LiveObjectFrameControllerTests
private static SettingsVM CreateSettings()
{
var dispatcher = new InputDispatcher(
var dispatcher = InputDispatcher.CreateDetached(
new NullKeyboardSource(),
new NullMouseSource(),
new KeyBindings());
dispatcher.Attach();
return new SettingsVM(
new KeyBindings(),
dispatcher,

View file

@ -409,13 +409,18 @@ public sealed class UpdateFrameOrchestratorTests
StringComparison.Ordinal);
Assert.DoesNotContain("wantCaptureMouse: ()", source,
StringComparison.Ordinal);
Assert.Equal(2, CountOccurrences(
source,
"_gameplayInputFrame?.EndMouseLook();"));
Assert.Contains(
"new(\"mouse capture\", () => _gameplayInputFrame?.EndMouseLook())",
Assert.DoesNotContain(
"_gameplayInputFrame?.EndMouseLook",
source,
StringComparison.Ordinal);
string pointerSource = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Input",
"CameraPointerInputController.cs"));
Assert.Contains("_gameplayFrame?.EndMouseLook();", pointerSource,
StringComparison.Ordinal);
string teleportSource = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
@ -438,8 +443,7 @@ public sealed class UpdateFrameOrchestratorTests
AssertAppearsInOrder(
source,
"private void OnFocusChanged(bool focused)",
"if (!focused)",
"_gameplayInputFrame?.EndMouseLook();");
"_cameraPointerInput?.HandleFocusChanged(focused);");
}
[Fact]
@ -696,7 +700,7 @@ public sealed class UpdateFrameOrchestratorTests
"private void OnUpdate(double dt)",
"_frameProfiler.BeginStage(",
"_updateFrameOrchestrator.Tick(",
"private void OnCameraModeChanged");
"private void OnRender(double deltaSeconds)");
}
[Fact]

View file

@ -92,7 +92,8 @@ public class DispatcherToMovementIntegrationTests
var kb = new FakeKb();
var mouse = new FakeMouse();
var bindings = KeyBindings.AcdreamCurrentDefaults();
var dispatcher = new InputDispatcher(kb, mouse, bindings);
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
dispatcher.Attach();
kb.Press(Key.W);
@ -122,7 +123,8 @@ public class DispatcherToMovementIntegrationTests
var kb = new FakeKb();
var mouse = new FakeMouse();
var bindings = KeyBindings.AcdreamCurrentDefaults();
var dispatcher = new InputDispatcher(kb, mouse, bindings);
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
dispatcher.Attach();
kb.Press(Key.W);
// Shift pressed alongside W — real keyboard delivers KeyDown(Shift,
@ -140,7 +142,8 @@ public class DispatcherToMovementIntegrationTests
var kb = new FakeKb();
var mouse = new FakeMouse();
var bindings = KeyBindings.AcdreamCurrentDefaults();
var dispatcher = new InputDispatcher(kb, mouse, bindings);
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
dispatcher.Attach();
kb.Press(Key.W);
Assert.True(BuildInputFromDispatcher(dispatcher).Forward);
@ -188,7 +191,8 @@ public class DispatcherToMovementIntegrationTests
var kb = new FakeKb();
var mouse = new FakeMouse();
var bindings = KeyBindings.AcdreamCurrentDefaults();
var dispatcher = new InputDispatcher(kb, mouse, bindings);
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
dispatcher.Attach();
var input = BuildInputFromDispatcher(dispatcher);
Assert.False(input.Forward);

View file

@ -21,7 +21,8 @@ public class InputDispatcherCaptureTests
var kb = new FakeKeyboardSource();
var mouse = new FakeMouseSource();
var bindings = new KeyBindings();
var dispatcher = new InputDispatcher(kb, mouse, bindings);
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
dispatcher.Attach();
var fired = new List<(InputAction, ActivationType)>();
dispatcher.Fired += (a, t) => fired.Add((a, t));
return (dispatcher, kb, mouse, bindings, fired);

View file

@ -33,7 +33,8 @@ public class InputDispatcherDoubleClickTests
bindings.Add(new Binding(lmbChord, InputAction.SelectDblLeft, ActivationType.DoubleClick));
bindings.Add(new Binding(rmbChord, InputAction.SelectRight));
var dispatcher = new InputDispatcher(kb, mouse, bindings);
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
dispatcher.Attach();
var fired = new List<(InputAction, ActivationType)>();
dispatcher.Fired += (a, t) => fired.Add((a, t));
return (dispatcher, mouse, fired);

View file

@ -19,7 +19,8 @@ public class InputDispatcherIsActionHeldTests
var kb = new FakeKeyboardSource();
var mouse = new FakeMouseSource();
var bindings = new KeyBindings();
var dispatcher = new InputDispatcher(kb, mouse, bindings);
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
dispatcher.Attach();
return (dispatcher, kb, mouse, bindings);
}

View file

@ -0,0 +1,187 @@
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
namespace AcDream.UI.Abstractions.Tests.Input;
public sealed class InputDispatcherLifetimeTests
{
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
[InlineData(5)]
public void AttachFailureRollsBackEveryPossiblyAcquiredPrefix(int failAdd)
{
var source = new ThrowingSources { FailAdd = failAdd };
var dispatcher = InputDispatcher.CreateDetached(
source,
source,
new KeyBindings());
Assert.ThrowsAny<Exception>(dispatcher.Attach);
Assert.True(dispatcher.IsDisposalComplete);
Assert.Equal(failAdd, source.AddCalls);
Assert.Equal(failAdd, source.RemoveCalls);
Assert.Equal(0, source.LiveEdges);
}
[Fact]
public void DeactivateSilencesCopiedSourceDelegateBeforePhysicalDetach()
{
var source = new ThrowingSources();
var bindings = new KeyBindings();
bindings.Add(new Binding(
new KeyChord(Key.W, ModifierMask.None),
InputAction.MovementForward));
var dispatcher = InputDispatcher.CreateDetached(source, source, bindings);
int fired = 0;
dispatcher.Fired += (_, _) => fired++;
dispatcher.Attach();
Action<Key, ModifierMask> copied = source.KeyDownDelegate!;
copied(Key.W, ModifierMask.None);
dispatcher.Deactivate();
copied(Key.W, ModifierMask.None);
dispatcher.Dispose();
Assert.Equal(1, fired);
Assert.True(dispatcher.IsDisposalComplete);
}
[Fact]
public void SourceEventRaisedReentrantlyDuringAttachCannotEnterPartialDispatcher()
{
var source = new ThrowingSources { RaiseDuringKeyDownAdd = true };
var bindings = new KeyBindings();
bindings.Add(new Binding(
new KeyChord(Key.W, ModifierMask.None),
InputAction.MovementForward));
var dispatcher = InputDispatcher.CreateDetached(source, source, bindings);
int fired = 0;
dispatcher.Fired += (_, _) => fired++;
dispatcher.Attach();
Assert.Equal(0, fired);
source.KeyDownDelegate!(Key.W, ModifierMask.None);
Assert.Equal(1, fired);
}
[Fact]
public void FailedDetachRetainsOnlyPendingSourceForRetry()
{
var source = new ThrowingSources();
var dispatcher = InputDispatcher.CreateDetached(
source,
source,
new KeyBindings());
dispatcher.Attach();
source.FailRemoveKeyDown = true;
Assert.Throws<AggregateException>(dispatcher.Dispose);
int completedRemoves = source.RemoveCalls - source.KeyDownRemoveCalls;
source.FailRemoveKeyDown = false;
dispatcher.Dispose();
Assert.Equal(completedRemoves, source.RemoveCalls - source.KeyDownRemoveCalls);
Assert.True(dispatcher.IsDisposalComplete);
}
[Fact]
public void DisposeBeforeAttachMakesDispatcherTerminal()
{
var source = new ThrowingSources();
var dispatcher = InputDispatcher.CreateDetached(
source,
source,
new KeyBindings());
dispatcher.Dispose();
Assert.Throws<ObjectDisposedException>(dispatcher.Attach);
dispatcher.Dispose();
Assert.Equal(0, source.AddCalls);
Assert.Equal(0, source.LiveEdges);
}
private sealed class ThrowingSources : IKeyboardSource, IMouseSource
{
private Action<Key, ModifierMask>? _keyDown;
private Action<Key, ModifierMask>? _keyUp;
private Action<MouseButton, ModifierMask>? _mouseDown;
private Action<MouseButton, ModifierMask>? _mouseUp;
private Action<float>? _scroll;
public int FailAdd { get; init; }
public bool FailRemoveKeyDown { get; set; }
public bool RaiseDuringKeyDownAdd { get; init; }
public int AddCalls { get; private set; }
public int RemoveCalls { get; private set; }
public int KeyDownRemoveCalls { get; private set; }
public int LiveEdges { get; private set; }
public Action<Key, ModifierMask>? KeyDownDelegate => _keyDown;
public event Action<Key, ModifierMask>? KeyDown
{
add
{
Add(ref _keyDown, value!);
if (RaiseDuringKeyDownAdd)
value?.Invoke(Key.W, ModifierMask.None);
}
remove
{
KeyDownRemoveCalls++;
if (FailRemoveKeyDown) throw new InvalidOperationException("remove key down");
Remove(ref _keyDown, value!);
}
}
public event Action<Key, ModifierMask>? KeyUp
{
add => Add(ref _keyUp, value!);
remove => Remove(ref _keyUp, value!);
}
public event Action<MouseButton, ModifierMask>? MouseDown
{
add => Add(ref _mouseDown, value!);
remove => Remove(ref _mouseDown, value!);
}
public event Action<MouseButton, ModifierMask>? MouseUp
{
add => Add(ref _mouseUp, value!);
remove => Remove(ref _mouseUp, value!);
}
#pragma warning disable CS0067
public event Action<float, float>? MouseMove;
#pragma warning restore CS0067
public event Action<float>? Scroll
{
add => Add(ref _scroll, value!);
remove => Remove(ref _scroll, value!);
}
public ModifierMask CurrentModifiers => ModifierMask.None;
public bool WantCaptureMouse => false;
public bool WantCaptureKeyboard => false;
public bool IsHeld(Key key) => false;
public bool IsHeld(MouseButton button) => false;
private void Add<T>(ref T? slot, T value) where T : Delegate
{
AddCalls++;
slot = (T?)Delegate.Combine(slot, value);
LiveEdges++;
if (AddCalls == FailAdd) throw new InvalidOperationException("add");
}
private void Remove<T>(ref T? slot, T value) where T : Delegate
{
RemoveCalls++;
if (slot is null) return;
slot = (T?)Delegate.Remove(slot, value);
LiveEdges--;
}
}
}

View file

@ -13,7 +13,8 @@ public class InputDispatcherTests
var kb = new FakeKeyboardSource();
var mouse = new FakeMouseSource();
var bindings = new KeyBindings();
var dispatcher = new InputDispatcher(kb, mouse, bindings);
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
dispatcher.Attach();
var fired = new List<(InputAction, ActivationType)>();
dispatcher.Fired += (a, t) => fired.Add((a, t));
return (dispatcher, kb, mouse, bindings, fired);

View file

@ -28,7 +28,8 @@ public sealed class SettingsPanelTests
var persisted = new KeyBindings();
persisted.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
persisted.Add(new Binding(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft));
var dispatcher = new InputDispatcher(kb, mouse, persisted);
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, persisted);
dispatcher.Attach();
var vm = new SettingsVM(
persisted, dispatcher, _ => { },
DisplaySettings.Default, _ => { },

View file

@ -22,7 +22,8 @@ public sealed class SettingsVMTests
persisted ??= MakeMinimalBindings();
var kb = new FakeKeyboardSource();
var mouse = new FakeMouseSource();
var dispatcher = new InputDispatcher(kb, mouse, persisted);
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, persisted);
dispatcher.Attach();
var savedHistory = new System.Collections.Generic.List<KeyBindings>();
var savedDisplayHistory = new System.Collections.Generic.List<DisplaySettings>();
var savedAudioHistory = new System.Collections.Generic.List<AudioSettings>();