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

@ -30,7 +30,7 @@ namespace AcDream.UI.Abstractions.Input;
/// action stream.
/// </para>
/// </summary>
public sealed class InputDispatcher
public sealed class InputDispatcher : IDisposable
{
private readonly IKeyboardSource _keyboard;
private readonly IMouseSource _mouse;
@ -39,6 +39,10 @@ public sealed class InputDispatcher
private InputScope? _combatScope;
private readonly HashSet<KeyChord> _heldHoldChords = new();
private readonly HashSet<InputAction> _automationHeldActions = new();
private readonly bool[] _sourceAttached = new bool[5];
private bool _attachStarted;
private int _disposeRequested;
private int _active;
// Double-click detection. _lastMouseDownButton == null means no recent press.
// _lastMouseDownTickMs is Environment.TickCount64 at the time of that press.
@ -57,7 +61,10 @@ public sealed class InputDispatcher
/// Multicast — every subscriber gets every event in subscription order.</summary>
public event Action<InputAction, ActivationType>? Fired;
public InputDispatcher(IKeyboardSource keyboard, IMouseSource mouse, KeyBindings bindings)
private InputDispatcher(
IKeyboardSource keyboard,
IMouseSource mouse,
KeyBindings bindings)
{
_keyboard = keyboard ?? throw new ArgumentNullException(nameof(keyboard));
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
@ -66,11 +73,84 @@ public sealed class InputDispatcher
_scopes.Push(InputScope.Always); // bottom of the stack
_scopes.Push(InputScope.Game); // default top for normal play
_keyboard.KeyDown += OnKeyDown;
_keyboard.KeyUp += OnKeyUp;
_mouse.MouseDown += OnMouseDown;
_mouse.MouseUp += OnMouseUp;
_mouse.Scroll += OnScroll;
}
/// <summary>
/// Production two-phase construction seam. The host publishes the owner
/// before <see cref="Attach"/> so failed custom event accessors cannot
/// orphan a source subscription.
/// </summary>
public static InputDispatcher CreateDetached(
IKeyboardSource keyboard,
IMouseSource mouse,
KeyBindings bindings) =>
new(keyboard, mouse, bindings);
public bool IsDisposalComplete =>
_sourceAttached.All(static attached => !attached);
public void Attach()
{
ObjectDisposedException.ThrowIf(
Volatile.Read(ref _disposeRequested) != 0,
this);
if (_attachStarted)
throw new InvalidOperationException(
"Input dispatcher attachment has already started.");
_attachStarted = true;
try
{
_sourceAttached[0] = true;
_keyboard.KeyDown += OnKeyDown;
_sourceAttached[1] = true;
_keyboard.KeyUp += OnKeyUp;
_sourceAttached[2] = true;
_mouse.MouseDown += OnMouseDown;
_sourceAttached[3] = true;
_mouse.MouseUp += OnMouseUp;
_sourceAttached[4] = true;
_mouse.Scroll += OnScroll;
Volatile.Write(ref _active, 1);
}
catch (Exception attachError)
{
Deactivate();
List<Exception> rollbackErrors = DetachSources();
if (rollbackErrors.Count != 0)
{
rollbackErrors.Insert(0, new InvalidOperationException(
"Input dispatcher source registration failed.",
attachError));
throw new AggregateException(
"Input dispatcher registration and rollback both failed.",
rollbackErrors);
}
throw new InvalidOperationException(
"Input dispatcher registration failed and was rolled back.",
attachError);
}
}
/// <summary>Immediate logical cutoff; physical detach remains retriable.</summary>
public void Deactivate()
{
Interlocked.Exchange(ref _active, 0);
_captureCallback = null;
_heldHoldChords.Clear();
_automationHeldActions.Clear();
}
public void Dispose()
{
Interlocked.Exchange(ref _disposeRequested, 1);
Deactivate();
List<Exception> failures = DetachSources();
if (failures.Count != 0)
throw new AggregateException(
"One or more input dispatcher source callbacks could not be detached.",
failures);
}
/// <summary>
@ -85,7 +165,9 @@ public sealed class InputDispatcher
{
if (action == InputAction.None || !Enum.IsDefined(action))
throw new ArgumentOutOfRangeException(nameof(action));
if (_captureCallback is not null || _mouse.WantCaptureKeyboard)
if (Volatile.Read(ref _active) == 0
|| _captureCallback is not null
|| _mouse.WantCaptureKeyboard)
return false;
Fired?.Invoke(action, ActivationType.Press);
@ -105,6 +187,9 @@ public sealed class InputDispatcher
if (action == InputAction.None || !Enum.IsDefined(action))
throw new ArgumentOutOfRangeException(nameof(action));
if (Volatile.Read(ref _active) == 0)
return false;
if (held)
{
if (_captureCallback is not null || _mouse.WantCaptureKeyboard)
@ -217,7 +302,7 @@ public sealed class InputDispatcher
/// </summary>
public bool IsActionHeld(InputAction action)
{
if (action == InputAction.None) return false;
if (Volatile.Read(ref _active) == 0 || action == InputAction.None) return false;
// While a text field owns the keyboard ("write mode"), held game actions read as
// released: typing "swd" must not move the character. This is the polling-path twin
// of the WantCaptureKeyboard gate on Fired actions. This suppresses physical keys;
@ -341,7 +426,7 @@ public sealed class InputDispatcher
/// </summary>
public void Tick()
{
if (_mouse.WantCaptureKeyboard) return;
if (Volatile.Read(ref _active) == 0 || _mouse.WantCaptureKeyboard) return;
// Snapshot to avoid issues if a subscriber mutates _heldHoldChords.
if (_heldHoldChords.Count == 0) return;
@ -363,6 +448,7 @@ public sealed class InputDispatcher
private void OnKeyDown(Key key, ModifierMask mods)
{
if (Volatile.Read(ref _active) == 0) return;
// K.3 modal capture (used by Settings panel's "Rebind" UX) takes
// precedence over both WantCaptureKeyboard gating AND normal
// binding lookup. Esc cancels capture; modifier-only keys don't
@ -417,6 +503,7 @@ public sealed class InputDispatcher
private void OnKeyUp(Key key, ModifierMask mods)
{
if (Volatile.Read(ref _active) == 0) return;
// Release fires regardless of WantCaptureKeyboard so we don't
// strand a Hold subscriber in the "held" state if the UI captured
// mid-press.
@ -444,6 +531,7 @@ public sealed class InputDispatcher
private void OnMouseDown(MouseButton button, ModifierMask mods)
{
if (Volatile.Read(ref _active) == 0) return;
if (_mouse.WantCaptureMouse) return;
var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1);
@ -478,6 +566,7 @@ public sealed class InputDispatcher
private void OnMouseUp(MouseButton button, ModifierMask mods)
{
if (Volatile.Read(ref _active) == 0) return;
var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1);
var release = FindActive(chord, ActivationType.Release);
@ -500,6 +589,7 @@ public sealed class InputDispatcher
private void OnScroll(float delta)
{
if (Volatile.Read(ref _active) == 0) return;
if (_mouse.WantCaptureMouse) return;
// K.1b: wheel ticks emit ScrollUp / ScrollDown depending on the
// sign of the delta. Magnitude is dropped — the action is a
@ -526,4 +616,34 @@ public sealed class InputDispatcher
MouseButton.Button5 => (Key)(-1005),
_ => (Key)(-1000 - (int)button),
};
private List<Exception> DetachSources()
{
var failures = new List<Exception>();
for (int index = _sourceAttached.Length - 1; index >= 0; index--)
{
if (!_sourceAttached[index])
continue;
try
{
switch (index)
{
case 0: _keyboard.KeyDown -= OnKeyDown; break;
case 1: _keyboard.KeyUp -= OnKeyUp; break;
case 2: _mouse.MouseDown -= OnMouseDown; break;
case 3: _mouse.MouseUp -= OnMouseUp; break;
case 4: _mouse.Scroll -= OnScroll; break;
default: throw new ArgumentOutOfRangeException(nameof(index));
}
_sourceAttached[index] = false;
}
catch (Exception error)
{
failures.Add(new InvalidOperationException(
$"Input dispatcher source callback {index} could not be detached.",
error));
}
}
return failures;
}
}