refactor(input): own pointer and callback lifetime
Move camera pointer, framebuffer resize, and retained/devtools input edges behind focused reversible owners. Preserve input priority while making shutdown deactivate callbacks before live-session retirement and retry physical detach without stranding transport teardown.
This commit is contained in:
parent
d09e246d3a
commit
8b8afeefa3
42 changed files with 4029 additions and 461 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue