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
449
src/AcDream.App/Input/CameraPointerInputController.cs
Normal file
449
src/AcDream.App/Input/CameraPointerInputController.cs
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.Rendering;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Narrow raw-pointer surface. The production adapter keeps the Silk event
|
||||
/// accessor out of the camera policy and lets failure tests exercise an event
|
||||
/// add/remove without implementing Silk's complete mouse contract.
|
||||
/// </summary>
|
||||
internal interface IRawPointerSurface
|
||||
{
|
||||
void AddMouseMove(Action<Vector2> callback);
|
||||
void RemoveMouseMove(Action<Vector2> callback);
|
||||
}
|
||||
|
||||
internal sealed class SilkRawPointerSurface : IRawPointerSurface
|
||||
{
|
||||
private readonly IMouse _mouse;
|
||||
private Action<Vector2>? _callback;
|
||||
private readonly Action<IMouse, Vector2> _mouseMove;
|
||||
|
||||
public SilkRawPointerSurface(IMouse mouse)
|
||||
{
|
||||
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
|
||||
_mouseMove = OnMouseMove;
|
||||
}
|
||||
|
||||
public void AddMouseMove(Action<Vector2> callback)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(callback);
|
||||
if (_callback is not null)
|
||||
throw new InvalidOperationException("The raw mouse surface is already attached.");
|
||||
|
||||
// Publish before invoking the custom event accessor: an accessor may
|
||||
// perform its side effect and then throw, in which case rollback still
|
||||
// has the exact delegate needed to remove it.
|
||||
_callback = callback;
|
||||
_mouse.MouseMove += _mouseMove;
|
||||
}
|
||||
|
||||
public void RemoveMouseMove(Action<Vector2> callback)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(callback);
|
||||
if (!ReferenceEquals(_callback, callback))
|
||||
return;
|
||||
|
||||
_mouse.MouseMove -= _mouseMove;
|
||||
_callback = null;
|
||||
}
|
||||
|
||||
private void OnMouseMove(IMouse _, Vector2 position) =>
|
||||
_callback?.Invoke(position);
|
||||
}
|
||||
|
||||
internal interface IPointerCursorModeTarget
|
||||
{
|
||||
CursorMode CursorMode { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class SilkPointerCursorModeTarget(IMouse mouse)
|
||||
: IPointerCursorModeTarget
|
||||
{
|
||||
private readonly IMouse _mouse = mouse
|
||||
?? throw new ArgumentNullException(nameof(mouse));
|
||||
|
||||
public CursorMode CursorMode
|
||||
{
|
||||
get => _mouse.Cursor.CursorMode;
|
||||
set => _mouse.Cursor.CursorMode = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns raw pointer subscriptions and the camera-facing pointer policy that
|
||||
/// previously lived in GameWindow. Physical event removal is retriable; logical
|
||||
/// deactivation is immediate, so a copied or stubborn Silk delegate cannot
|
||||
/// re-enter gameplay while shutdown is closing the live session.
|
||||
/// </summary>
|
||||
internal sealed class CameraPointerInputController : IDisposable
|
||||
{
|
||||
private readonly IReadOnlyList<IRawPointerSurface> _surfaces;
|
||||
private readonly IPointerCursorModeTarget _cursor;
|
||||
private readonly HostQuiescenceGate _quiescence;
|
||||
private readonly IInputCaptureSource _capture;
|
||||
private readonly LocalPlayerModeState _playerMode;
|
||||
private readonly CameraController _camera;
|
||||
private readonly ChaseCameraInputState _chase;
|
||||
private readonly IMouseSource _mouse;
|
||||
private readonly PointerPositionState _pointer;
|
||||
private readonly IInputMonotonicClock _clock;
|
||||
private readonly Action<Vector2> _mouseMoved;
|
||||
private readonly Action<bool> _cameraModeChanged;
|
||||
private readonly bool[] _surfaceAttached;
|
||||
private ResourceShutdownTransaction? _detach;
|
||||
private GameplayInputFrameController? _gameplayFrame;
|
||||
private bool _cameraAttached;
|
||||
private bool _attachStarted;
|
||||
private int _disposeRequested;
|
||||
private int _active;
|
||||
private float _flySensitivity = 1f;
|
||||
private float _orbitSensitivity = 1f;
|
||||
|
||||
public CameraPointerInputController(
|
||||
IReadOnlyList<IRawPointerSurface> surfaces,
|
||||
IPointerCursorModeTarget cursor,
|
||||
HostQuiescenceGate quiescence,
|
||||
IInputCaptureSource capture,
|
||||
LocalPlayerModeState playerMode,
|
||||
CameraController camera,
|
||||
ChaseCameraInputState chase,
|
||||
IMouseSource mouse,
|
||||
PointerPositionState pointer,
|
||||
IInputMonotonicClock clock)
|
||||
{
|
||||
_surfaces = surfaces ?? throw new ArgumentNullException(nameof(surfaces));
|
||||
if (_surfaces.Any(static surface => surface is null))
|
||||
throw new ArgumentException("Raw pointer surfaces cannot contain null.", nameof(surfaces));
|
||||
_cursor = cursor ?? throw new ArgumentNullException(nameof(cursor));
|
||||
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
|
||||
_capture = capture ?? throw new ArgumentNullException(nameof(capture));
|
||||
_playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode));
|
||||
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
|
||||
_chase = chase ?? throw new ArgumentNullException(nameof(chase));
|
||||
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
|
||||
_pointer = pointer ?? throw new ArgumentNullException(nameof(pointer));
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_surfaceAttached = new bool[_surfaces.Count];
|
||||
_mouseMoved = OnMouseMoved;
|
||||
_cameraModeChanged = OnCameraModeChanged;
|
||||
}
|
||||
|
||||
public static CameraPointerInputController Create(
|
||||
IReadOnlyList<IMouse> mice,
|
||||
HostQuiescenceGate quiescence,
|
||||
IInputCaptureSource capture,
|
||||
LocalPlayerModeState playerMode,
|
||||
CameraController camera,
|
||||
ChaseCameraInputState chase,
|
||||
IMouseSource mouse,
|
||||
PointerPositionState pointer,
|
||||
IInputMonotonicClock clock)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(mice);
|
||||
if (mice.Count == 0)
|
||||
throw new ArgumentException("At least one mouse is required.", nameof(mice));
|
||||
|
||||
return new CameraPointerInputController(
|
||||
mice.Select(static candidate =>
|
||||
(IRawPointerSurface)new SilkRawPointerSurface(candidate)).ToArray(),
|
||||
new SilkPointerCursorModeTarget(mice[0]),
|
||||
quiescence,
|
||||
capture,
|
||||
playerMode,
|
||||
camera,
|
||||
chase,
|
||||
mouse,
|
||||
pointer,
|
||||
clock);
|
||||
}
|
||||
|
||||
public bool IsDisposalComplete =>
|
||||
!_cameraAttached && _surfaceAttached.All(static attached => !attached);
|
||||
|
||||
public float ActiveSensitivity
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_playerMode.IsPlayerMode && _camera.IsChaseMode)
|
||||
return _chase.Sensitivity;
|
||||
if (_camera.IsFlyMode)
|
||||
return _flySensitivity;
|
||||
return _orbitSensitivity;
|
||||
}
|
||||
}
|
||||
|
||||
public bool RmbOrbitHeld => _chase.RmbOrbitHeld;
|
||||
|
||||
public void AttachRaw()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(
|
||||
Volatile.Read(ref _disposeRequested) != 0,
|
||||
this);
|
||||
if (_attachStarted)
|
||||
throw new InvalidOperationException("Raw pointer attachment has already started.");
|
||||
_attachStarted = true;
|
||||
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < _surfaces.Count; i++)
|
||||
{
|
||||
_surfaceAttached[i] = true;
|
||||
_surfaces[i].AddMouseMove(_mouseMoved);
|
||||
}
|
||||
|
||||
_cameraAttached = true;
|
||||
_camera.ModeChanged += _cameraModeChanged;
|
||||
Volatile.Write(ref _active, 1);
|
||||
}
|
||||
catch (Exception attachError)
|
||||
{
|
||||
Deactivate();
|
||||
try
|
||||
{
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
catch (Exception rollbackError)
|
||||
{
|
||||
throw new AggregateException(
|
||||
"Raw pointer registration and rollback both failed.",
|
||||
new InvalidOperationException(
|
||||
"Raw pointer registration failed.", attachError),
|
||||
rollbackError);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Raw pointer registration failed and was rolled back.",
|
||||
attachError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes the deliberate two-phase composition edge. Binding the frame
|
||||
/// owner never resubscribes or changes event priority.
|
||||
/// </summary>
|
||||
public void BindGameplayFrame(GameplayInputFrameController gameplayFrame)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(gameplayFrame);
|
||||
if (_gameplayFrame is not null
|
||||
&& !ReferenceEquals(_gameplayFrame, gameplayFrame))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The raw pointer owner is already bound to another gameplay frame.");
|
||||
}
|
||||
|
||||
_gameplayFrame = gameplayFrame;
|
||||
}
|
||||
|
||||
public void UnbindGameplayFrame(GameplayInputFrameController gameplayFrame)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(gameplayFrame);
|
||||
if (ReferenceEquals(_gameplayFrame, gameplayFrame))
|
||||
_gameplayFrame = null;
|
||||
}
|
||||
|
||||
public void HandleFocusChanged(bool focused)
|
||||
{
|
||||
if (!focused)
|
||||
_gameplayFrame?.EndMouseLook();
|
||||
}
|
||||
|
||||
public void HandleScroll(InputAction action)
|
||||
{
|
||||
float direction = action switch
|
||||
{
|
||||
InputAction.ScrollUp => 1f,
|
||||
InputAction.ScrollDown => -1f,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(action)),
|
||||
};
|
||||
|
||||
if (_playerMode.IsPlayerMode && _camera.IsChaseMode)
|
||||
{
|
||||
if (CameraDiagnostics.UseRetailChaseCamera && _chase.Retail is not null)
|
||||
_chase.Retail.AdjustDistance(-direction * 0.8f);
|
||||
else
|
||||
_chase.Legacy?.AdjustDistance(-direction * 0.8f);
|
||||
}
|
||||
else if (!_camera.IsFlyMode)
|
||||
{
|
||||
_camera.Orbit.Distance = Math.Clamp(
|
||||
_camera.Orbit.Distance - direction * 20f,
|
||||
50f,
|
||||
2000f);
|
||||
}
|
||||
}
|
||||
|
||||
public string AdjustSensitivity(float factor)
|
||||
{
|
||||
string mode;
|
||||
float current;
|
||||
if (_playerMode.IsPlayerMode && _camera.IsChaseMode)
|
||||
{
|
||||
mode = "Chase";
|
||||
current = _chase.Sensitivity;
|
||||
}
|
||||
else if (_camera.IsFlyMode)
|
||||
{
|
||||
mode = "Fly";
|
||||
current = _flySensitivity;
|
||||
}
|
||||
else
|
||||
{
|
||||
mode = "Orbit";
|
||||
current = _orbitSensitivity;
|
||||
}
|
||||
|
||||
float next = MathF.Min(3f, MathF.Max(0.005f, current * factor));
|
||||
if (mode == "Chase")
|
||||
_chase.Sensitivity = next;
|
||||
else if (mode == "Fly")
|
||||
_flySensitivity = next;
|
||||
else
|
||||
_orbitSensitivity = next;
|
||||
|
||||
return $"{mode} sens {next:F3}x";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immediate callback cutoff used before the live-session close. The
|
||||
/// gameplay mouse-look transition is retained until the session has
|
||||
/// retired so it cannot publish one last movement packet.
|
||||
/// </summary>
|
||||
public void Deactivate()
|
||||
{
|
||||
Interlocked.Exchange(ref _active, 0);
|
||||
_chase.RmbOrbitHeld = false;
|
||||
}
|
||||
|
||||
/// <summary>Release presentation state only after live-session retirement.</summary>
|
||||
public void ReleaseMouseLookAfterSessionRetirement() =>
|
||||
_gameplayFrame?.EndMouseLook();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Interlocked.Exchange(ref _disposeRequested, 1);
|
||||
Deactivate();
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
|
||||
private void OnMouseMoved(Vector2 position) =>
|
||||
_quiescence.Invoke(() => ProcessMouseMove(position));
|
||||
|
||||
private void ProcessMouseMove(Vector2 position)
|
||||
{
|
||||
if (Volatile.Read(ref _active) == 0)
|
||||
return;
|
||||
|
||||
if (_capture.WantCaptureMouse)
|
||||
{
|
||||
_pointer.X = position.X;
|
||||
_pointer.Y = position.Y;
|
||||
return;
|
||||
}
|
||||
|
||||
float dx = position.X - _pointer.X;
|
||||
float dy = position.Y - _pointer.Y;
|
||||
|
||||
if (_playerMode.IsPlayerMode && _camera.IsChaseMode
|
||||
&& _chase.Legacy is not null)
|
||||
{
|
||||
float sensitivity = _chase.Sensitivity;
|
||||
if (_gameplayFrame?.MouseLookActive == true)
|
||||
{
|
||||
_gameplayFrame.QueueRawMouseDelta(dx, dy);
|
||||
}
|
||||
else if (_chase.RmbOrbitHeld)
|
||||
{
|
||||
if (CameraDiagnostics.UseRetailChaseCamera
|
||||
&& _chase.Retail is not null)
|
||||
{
|
||||
var (filteredDx, filteredDy) = _chase.Retail.FilterMouseDelta(
|
||||
rawX: dx,
|
||||
rawY: dy,
|
||||
weight: 0.5f,
|
||||
nowSec: _clock.NowSeconds);
|
||||
_chase.Retail.YawOffset -= filteredDx * 0.004f * sensitivity;
|
||||
_chase.Retail.AdjustPitch(filteredDy * 0.003f * sensitivity);
|
||||
}
|
||||
else
|
||||
{
|
||||
_chase.Legacy.YawOffset -= dx * 0.004f * sensitivity;
|
||||
_chase.Legacy.AdjustPitch(dy * 0.003f * sensitivity);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (_camera.IsFlyMode)
|
||||
{
|
||||
_camera.Fly.Look(dx * _flySensitivity, dy * _flySensitivity);
|
||||
}
|
||||
else if (_mouse.IsHeld(MouseButton.Left))
|
||||
{
|
||||
_camera.Orbit.Yaw -= dx * 0.005f * _orbitSensitivity;
|
||||
_camera.Orbit.Pitch = Math.Clamp(
|
||||
_camera.Orbit.Pitch + dy * 0.005f * _orbitSensitivity,
|
||||
0.1f,
|
||||
1.5f);
|
||||
}
|
||||
|
||||
_pointer.X = position.X;
|
||||
_pointer.Y = position.Y;
|
||||
}
|
||||
|
||||
private void OnCameraModeChanged(bool _) =>
|
||||
_quiescence.Invoke(ApplyCursorForCameraMode);
|
||||
|
||||
private void ApplyCursorForCameraMode()
|
||||
{
|
||||
if (Volatile.Read(ref _active) == 0)
|
||||
return;
|
||||
|
||||
if (_gameplayFrame?.MouseLookActive == true && !_camera.IsChaseMode)
|
||||
_gameplayFrame.EndMouseLook();
|
||||
|
||||
_cursor.CursorMode = _camera.IsFlyMode
|
||||
? CursorMode.Raw
|
||||
: CursorMode.Normal;
|
||||
}
|
||||
|
||||
private ResourceShutdownTransaction EnsureDetachTransaction()
|
||||
{
|
||||
if (_detach is not null)
|
||||
return _detach;
|
||||
|
||||
var operations = new List<ResourceShutdownOperation>
|
||||
{
|
||||
new("camera mode", RemoveCameraMode),
|
||||
};
|
||||
for (int i = _surfaces.Count - 1; i >= 0; i--)
|
||||
{
|
||||
int index = i;
|
||||
operations.Add(new ResourceShutdownOperation(
|
||||
$"raw mouse {index}",
|
||||
() => RemoveSurface(index)));
|
||||
}
|
||||
|
||||
_detach = new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage("raw pointer callbacks", operations.ToArray()));
|
||||
return _detach;
|
||||
}
|
||||
|
||||
private void RemoveCameraMode()
|
||||
{
|
||||
if (!_cameraAttached)
|
||||
return;
|
||||
_camera.ModeChanged -= _cameraModeChanged;
|
||||
_cameraAttached = false;
|
||||
}
|
||||
|
||||
private void RemoveSurface(int index)
|
||||
{
|
||||
if (!_surfaceAttached[index])
|
||||
return;
|
||||
_surfaces[index].RemoveMouseMove(_mouseMoved);
|
||||
_surfaceAttached[index] = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +43,13 @@ internal sealed class DispatcherCameraInputSource : ICameraFrameInputSource
|
|||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
public void Unbind(InputDispatcher dispatcher)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dispatcher);
|
||||
if (ReferenceEquals(_dispatcher, dispatcher))
|
||||
_dispatcher = null;
|
||||
}
|
||||
|
||||
public FlyCameraInput CaptureFly()
|
||||
{
|
||||
if (_dispatcher is not { } dispatcher)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,13 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource
|
|||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
public void Unbind(InputDispatcher dispatcher)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dispatcher);
|
||||
if (ReferenceEquals(_dispatcher, dispatcher))
|
||||
_dispatcher = null;
|
||||
}
|
||||
|
||||
public MovementInput Capture()
|
||||
{
|
||||
// Devtools owns the whole gameplay keyboard while active, including
|
||||
|
|
|
|||
|
|
@ -52,23 +52,3 @@ internal sealed class CompositeInputCaptureSource : IInputCaptureSource
|
|||
public bool DevToolsWantCaptureKeyboard =>
|
||||
_devTools.WantCaptureKeyboard;
|
||||
}
|
||||
|
||||
internal sealed class DelegateInputCaptureSource : IInputCaptureSource
|
||||
{
|
||||
private readonly Func<bool> _wantCaptureMouse;
|
||||
private readonly Func<bool> _wantCaptureKeyboard;
|
||||
|
||||
public DelegateInputCaptureSource(
|
||||
Func<bool> wantCaptureMouse,
|
||||
Func<bool> wantCaptureKeyboard)
|
||||
{
|
||||
_wantCaptureMouse = wantCaptureMouse
|
||||
?? throw new ArgumentNullException(nameof(wantCaptureMouse));
|
||||
_wantCaptureKeyboard = wantCaptureKeyboard
|
||||
?? throw new ArgumentNullException(nameof(wantCaptureKeyboard));
|
||||
}
|
||||
|
||||
public bool WantCaptureMouse => _wantCaptureMouse();
|
||||
public bool WantCaptureKeyboard => _wantCaptureKeyboard();
|
||||
public bool DevToolsWantCaptureKeyboard => false;
|
||||
}
|
||||
|
|
|
|||
486
src/AcDream.App/Input/QuiescentInputContext.cs
Normal file
486
src/AcDream.App/Input/QuiescentInputContext.cs
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Non-owning input-context view for third-party frontends such as Silk's
|
||||
/// ImGuiController. It interposes named device relays so frontend callbacks can
|
||||
/// be silenced before a long live-session close and physically detached later,
|
||||
/// without disposing the canonical Silk input context.
|
||||
/// </summary>
|
||||
internal sealed class QuiescentInputContext : IInputContext
|
||||
{
|
||||
private readonly IInputContext _inner;
|
||||
private readonly HostQuiescenceGate _quiescence;
|
||||
private readonly QuiescentKeyboard[] _keyboards;
|
||||
private readonly QuiescentMouse[] _mice;
|
||||
private readonly Action<IInputDevice, bool> _connectionChanged;
|
||||
private Action<IInputDevice, bool>? _connectionSubscribers;
|
||||
private ResourceShutdownTransaction? _detach;
|
||||
private bool _connectionAttached;
|
||||
private int _active;
|
||||
private bool _activationStarted;
|
||||
private int _disposeRequested;
|
||||
|
||||
public QuiescentInputContext(
|
||||
IInputContext inner,
|
||||
HostQuiescenceGate quiescence)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
|
||||
_keyboards = inner.Keyboards
|
||||
.Select(keyboard => new QuiescentKeyboard(keyboard, quiescence))
|
||||
.ToArray();
|
||||
_mice = inner.Mice
|
||||
.Select(mouse => new QuiescentMouse(mouse, quiescence))
|
||||
.ToArray();
|
||||
_connectionChanged = OnConnectionChanged;
|
||||
}
|
||||
|
||||
public nint Handle => _inner.Handle;
|
||||
public IReadOnlyList<IGamepad> Gamepads => _inner.Gamepads;
|
||||
public IReadOnlyList<IJoystick> Joysticks => _inner.Joysticks;
|
||||
public IReadOnlyList<IKeyboard> Keyboards => _keyboards;
|
||||
public IReadOnlyList<IMouse> Mice => _mice;
|
||||
public IReadOnlyList<IInputDevice> OtherDevices => _inner.OtherDevices;
|
||||
|
||||
public event Action<IInputDevice, bool>? ConnectionChanged
|
||||
{
|
||||
add
|
||||
{
|
||||
if (value is null) return;
|
||||
ThrowIfDisposed();
|
||||
_connectionSubscribers += value;
|
||||
if (_connectionAttached) return;
|
||||
_connectionAttached = true;
|
||||
_inner.ConnectionChanged += _connectionChanged;
|
||||
}
|
||||
remove
|
||||
{
|
||||
if (value is null) return;
|
||||
_connectionSubscribers -= value;
|
||||
if (_connectionSubscribers is not null || !_connectionAttached) return;
|
||||
_inner.ConnectionChanged -= _connectionChanged;
|
||||
_connectionAttached = false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsDisposalComplete =>
|
||||
!_connectionAttached
|
||||
&& _keyboards.All(static keyboard => keyboard.IsDisposalComplete)
|
||||
&& _mice.All(static mouse => mouse.IsDisposalComplete);
|
||||
|
||||
/// <summary>
|
||||
/// Opens delivery only after the third-party frontend constructor has
|
||||
/// finished installing every callback. An event raised reentrantly from an
|
||||
/// add accessor therefore cannot enter a partially constructed frontend.
|
||||
/// </summary>
|
||||
public void Activate()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
if (_activationStarted)
|
||||
throw new InvalidOperationException(
|
||||
"Frontend input activation has already been attempted.");
|
||||
_activationStarted = true;
|
||||
foreach (QuiescentKeyboard keyboard in _keyboards)
|
||||
keyboard.Activate();
|
||||
foreach (QuiescentMouse mouse in _mice)
|
||||
mouse.Activate();
|
||||
Volatile.Write(ref _active, 1);
|
||||
}
|
||||
|
||||
public void Deactivate()
|
||||
{
|
||||
Interlocked.Exchange(ref _active, 0);
|
||||
foreach (QuiescentKeyboard keyboard in _keyboards)
|
||||
keyboard.Deactivate();
|
||||
foreach (QuiescentMouse mouse in _mice)
|
||||
mouse.Deactivate();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Interlocked.Exchange(ref _disposeRequested, 1);
|
||||
foreach (QuiescentKeyboard keyboard in _keyboards)
|
||||
keyboard.RequestDisposal();
|
||||
foreach (QuiescentMouse mouse in _mice)
|
||||
mouse.RequestDisposal();
|
||||
Deactivate();
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed() =>
|
||||
ObjectDisposedException.ThrowIf(
|
||||
Volatile.Read(ref _disposeRequested) != 0,
|
||||
this);
|
||||
|
||||
private void OnConnectionChanged(IInputDevice device, bool connected) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
_connectionSubscribers?.Invoke(device, connected);
|
||||
});
|
||||
|
||||
private ResourceShutdownTransaction EnsureDetachTransaction()
|
||||
{
|
||||
if (_detach is not null)
|
||||
return _detach;
|
||||
|
||||
var operations = new List<ResourceShutdownOperation>();
|
||||
for (int i = _mice.Length - 1; i >= 0; i--)
|
||||
{
|
||||
int index = i;
|
||||
operations.Add(new ResourceShutdownOperation(
|
||||
$"frontend mouse {index}",
|
||||
_mice[index].Dispose));
|
||||
}
|
||||
for (int i = _keyboards.Length - 1; i >= 0; i--)
|
||||
{
|
||||
int index = i;
|
||||
operations.Add(new ResourceShutdownOperation(
|
||||
$"frontend keyboard {index}",
|
||||
_keyboards[index].Dispose));
|
||||
}
|
||||
operations.Add(new ResourceShutdownOperation(
|
||||
"frontend connection event",
|
||||
RemoveConnectionChanged));
|
||||
_detach = new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage("frontend input callbacks", operations.ToArray()));
|
||||
return _detach;
|
||||
}
|
||||
|
||||
private void RemoveConnectionChanged()
|
||||
{
|
||||
if (!_connectionAttached) return;
|
||||
_inner.ConnectionChanged -= _connectionChanged;
|
||||
_connectionAttached = false;
|
||||
_connectionSubscribers = null;
|
||||
}
|
||||
|
||||
private sealed class QuiescentKeyboard : IKeyboard, IDisposable
|
||||
{
|
||||
private readonly IKeyboard _inner;
|
||||
private readonly HostQuiescenceGate _quiescence;
|
||||
private readonly Action<IKeyboard, Key, int> _downRelay;
|
||||
private readonly Action<IKeyboard, Key, int> _upRelay;
|
||||
private readonly Action<IKeyboard, char> _charRelay;
|
||||
private Action<IKeyboard, Key, int>? _downSubscribers;
|
||||
private Action<IKeyboard, Key, int>? _upSubscribers;
|
||||
private Action<IKeyboard, char>? _charSubscribers;
|
||||
private readonly bool[] _attached = new bool[3];
|
||||
private ResourceShutdownTransaction? _detach;
|
||||
private int _disposeRequested;
|
||||
private int _active;
|
||||
|
||||
public QuiescentKeyboard(IKeyboard inner, HostQuiescenceGate quiescence)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_quiescence = quiescence;
|
||||
_downRelay = OnDown;
|
||||
_upRelay = OnUp;
|
||||
_charRelay = OnChar;
|
||||
}
|
||||
|
||||
public string Name => _inner.Name;
|
||||
public int Index => _inner.Index;
|
||||
public bool IsConnected => _inner.IsConnected;
|
||||
public IReadOnlyList<Key> SupportedKeys => _inner.SupportedKeys;
|
||||
public string ClipboardText
|
||||
{
|
||||
get => _inner.ClipboardText;
|
||||
set => _inner.ClipboardText = value;
|
||||
}
|
||||
|
||||
public event Action<IKeyboard, Key, int>? KeyDown
|
||||
{
|
||||
add
|
||||
{
|
||||
if (value is null) return;
|
||||
ThrowIfDisposed();
|
||||
_downSubscribers += value;
|
||||
if (_attached[0]) return;
|
||||
_attached[0] = true;
|
||||
_inner.KeyDown += _downRelay;
|
||||
}
|
||||
remove
|
||||
{
|
||||
if (value is null) return;
|
||||
_downSubscribers -= value;
|
||||
if (_downSubscribers is not null || !_attached[0]) return;
|
||||
_inner.KeyDown -= _downRelay;
|
||||
_attached[0] = false;
|
||||
}
|
||||
}
|
||||
|
||||
public event Action<IKeyboard, Key, int>? KeyUp
|
||||
{
|
||||
add
|
||||
{
|
||||
if (value is null) return;
|
||||
ThrowIfDisposed();
|
||||
_upSubscribers += value;
|
||||
if (_attached[1]) return;
|
||||
_attached[1] = true;
|
||||
_inner.KeyUp += _upRelay;
|
||||
}
|
||||
remove
|
||||
{
|
||||
if (value is null) return;
|
||||
_upSubscribers -= value;
|
||||
if (_upSubscribers is not null || !_attached[1]) return;
|
||||
_inner.KeyUp -= _upRelay;
|
||||
_attached[1] = false;
|
||||
}
|
||||
}
|
||||
|
||||
public event Action<IKeyboard, char>? KeyChar
|
||||
{
|
||||
add
|
||||
{
|
||||
if (value is null) return;
|
||||
ThrowIfDisposed();
|
||||
_charSubscribers += value;
|
||||
if (_attached[2]) return;
|
||||
_attached[2] = true;
|
||||
_inner.KeyChar += _charRelay;
|
||||
}
|
||||
remove
|
||||
{
|
||||
if (value is null) return;
|
||||
_charSubscribers -= value;
|
||||
if (_charSubscribers is not null || !_attached[2]) return;
|
||||
_inner.KeyChar -= _charRelay;
|
||||
_attached[2] = false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsDisposalComplete => _attached.All(static value => !value);
|
||||
public bool IsKeyPressed(Key key) => _inner.IsKeyPressed(key);
|
||||
public bool IsScancodePressed(int scancode) => _inner.IsScancodePressed(scancode);
|
||||
public void BeginInput() => _inner.BeginInput();
|
||||
public void EndInput() => _inner.EndInput();
|
||||
public void Activate()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
Volatile.Write(ref _active, 1);
|
||||
}
|
||||
public void Deactivate() => Interlocked.Exchange(ref _active, 0);
|
||||
|
||||
public void RequestDisposal() =>
|
||||
Interlocked.Exchange(ref _disposeRequested, 1);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
RequestDisposal();
|
||||
Deactivate();
|
||||
_detach ??= new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage("frontend keyboard callbacks",
|
||||
[
|
||||
new("character", () => Remove(2)),
|
||||
new("up", () => Remove(1)),
|
||||
new("down", () => Remove(0)),
|
||||
]));
|
||||
_detach.CompleteOrThrow();
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed() =>
|
||||
ObjectDisposedException.ThrowIf(
|
||||
Volatile.Read(ref _disposeRequested) != 0,
|
||||
this);
|
||||
|
||||
private void OnDown(IKeyboard _, Key key, int scanCode) =>
|
||||
Invoke(() => _downSubscribers?.Invoke(this, key, scanCode));
|
||||
private void OnUp(IKeyboard _, Key key, int scanCode) =>
|
||||
Invoke(() => _upSubscribers?.Invoke(this, key, scanCode));
|
||||
private void OnChar(IKeyboard _, char value) =>
|
||||
Invoke(() => _charSubscribers?.Invoke(this, value));
|
||||
private void Invoke(Action callback) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
callback();
|
||||
});
|
||||
|
||||
private void Remove(int index)
|
||||
{
|
||||
if (!_attached[index]) return;
|
||||
switch (index)
|
||||
{
|
||||
case 0: _inner.KeyDown -= _downRelay; _downSubscribers = null; break;
|
||||
case 1: _inner.KeyUp -= _upRelay; _upSubscribers = null; break;
|
||||
case 2: _inner.KeyChar -= _charRelay; _charSubscribers = null; break;
|
||||
default: throw new ArgumentOutOfRangeException(nameof(index));
|
||||
}
|
||||
_attached[index] = false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class QuiescentMouse : IMouse, IDisposable
|
||||
{
|
||||
private readonly IMouse _inner;
|
||||
private readonly HostQuiescenceGate _quiescence;
|
||||
private readonly Action<IMouse, MouseButton> _downRelay;
|
||||
private readonly Action<IMouse, MouseButton> _upRelay;
|
||||
private readonly Action<IMouse, MouseButton, Vector2> _clickRelay;
|
||||
private readonly Action<IMouse, MouseButton, Vector2> _doubleClickRelay;
|
||||
private readonly Action<IMouse, Vector2> _moveRelay;
|
||||
private readonly Action<IMouse, ScrollWheel> _scrollRelay;
|
||||
private Action<IMouse, MouseButton>? _downSubscribers;
|
||||
private Action<IMouse, MouseButton>? _upSubscribers;
|
||||
private Action<IMouse, MouseButton, Vector2>? _clickSubscribers;
|
||||
private Action<IMouse, MouseButton, Vector2>? _doubleClickSubscribers;
|
||||
private Action<IMouse, Vector2>? _moveSubscribers;
|
||||
private Action<IMouse, ScrollWheel>? _scrollSubscribers;
|
||||
private readonly bool[] _attached = new bool[6];
|
||||
private ResourceShutdownTransaction? _detach;
|
||||
private int _disposeRequested;
|
||||
private int _active;
|
||||
|
||||
public QuiescentMouse(IMouse inner, HostQuiescenceGate quiescence)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_quiescence = quiescence;
|
||||
_downRelay = OnDown;
|
||||
_upRelay = OnUp;
|
||||
_clickRelay = OnClick;
|
||||
_doubleClickRelay = OnDoubleClick;
|
||||
_moveRelay = OnMove;
|
||||
_scrollRelay = OnScroll;
|
||||
}
|
||||
|
||||
public string Name => _inner.Name;
|
||||
public int Index => _inner.Index;
|
||||
public bool IsConnected => _inner.IsConnected;
|
||||
public IReadOnlyList<MouseButton> SupportedButtons => _inner.SupportedButtons;
|
||||
public IReadOnlyList<ScrollWheel> ScrollWheels => _inner.ScrollWheels;
|
||||
public Vector2 Position { get => _inner.Position; set => _inner.Position = value; }
|
||||
public ICursor Cursor => _inner.Cursor;
|
||||
public int DoubleClickTime { get => _inner.DoubleClickTime; set => _inner.DoubleClickTime = value; }
|
||||
public int DoubleClickRange { get => _inner.DoubleClickRange; set => _inner.DoubleClickRange = value; }
|
||||
|
||||
public event Action<IMouse, MouseButton> MouseDown
|
||||
{
|
||||
add => Add(ref _downSubscribers, value, 0, () => _inner.MouseDown += _downRelay);
|
||||
remove => RemoveSubscriber(ref _downSubscribers, value, 0, () => _inner.MouseDown -= _downRelay);
|
||||
}
|
||||
public event Action<IMouse, MouseButton> MouseUp
|
||||
{
|
||||
add => Add(ref _upSubscribers, value, 1, () => _inner.MouseUp += _upRelay);
|
||||
remove => RemoveSubscriber(ref _upSubscribers, value, 1, () => _inner.MouseUp -= _upRelay);
|
||||
}
|
||||
public event Action<IMouse, MouseButton, Vector2> Click
|
||||
{
|
||||
add => Add(ref _clickSubscribers, value, 2, () => _inner.Click += _clickRelay);
|
||||
remove => RemoveSubscriber(ref _clickSubscribers, value, 2, () => _inner.Click -= _clickRelay);
|
||||
}
|
||||
public event Action<IMouse, MouseButton, Vector2> DoubleClick
|
||||
{
|
||||
add => Add(ref _doubleClickSubscribers, value, 3, () => _inner.DoubleClick += _doubleClickRelay);
|
||||
remove => RemoveSubscriber(ref _doubleClickSubscribers, value, 3, () => _inner.DoubleClick -= _doubleClickRelay);
|
||||
}
|
||||
public event Action<IMouse, Vector2> MouseMove
|
||||
{
|
||||
add => Add(ref _moveSubscribers, value, 4, () => _inner.MouseMove += _moveRelay);
|
||||
remove => RemoveSubscriber(ref _moveSubscribers, value, 4, () => _inner.MouseMove -= _moveRelay);
|
||||
}
|
||||
public event Action<IMouse, ScrollWheel> Scroll
|
||||
{
|
||||
add => Add(ref _scrollSubscribers, value, 5, () => _inner.Scroll += _scrollRelay);
|
||||
remove => RemoveSubscriber(ref _scrollSubscribers, value, 5, () => _inner.Scroll -= _scrollRelay);
|
||||
}
|
||||
|
||||
public bool IsDisposalComplete => _attached.All(static value => !value);
|
||||
public bool IsButtonPressed(MouseButton button) => _inner.IsButtonPressed(button);
|
||||
public void Activate()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
Volatile.Write(ref _active, 1);
|
||||
}
|
||||
public void Deactivate() => Interlocked.Exchange(ref _active, 0);
|
||||
|
||||
public void RequestDisposal() =>
|
||||
Interlocked.Exchange(ref _disposeRequested, 1);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
RequestDisposal();
|
||||
Deactivate();
|
||||
_detach ??= new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage("frontend mouse callbacks",
|
||||
[
|
||||
new("scroll", () => RemovePhysical(5)),
|
||||
new("move", () => RemovePhysical(4)),
|
||||
new("double click", () => RemovePhysical(3)),
|
||||
new("click", () => RemovePhysical(2)),
|
||||
new("up", () => RemovePhysical(1)),
|
||||
new("down", () => RemovePhysical(0)),
|
||||
]));
|
||||
_detach.CompleteOrThrow();
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed() =>
|
||||
ObjectDisposedException.ThrowIf(
|
||||
Volatile.Read(ref _disposeRequested) != 0,
|
||||
this);
|
||||
|
||||
private void OnDown(IMouse _, MouseButton button) =>
|
||||
Invoke(() => _downSubscribers?.Invoke(this, button));
|
||||
private void OnUp(IMouse _, MouseButton button) =>
|
||||
Invoke(() => _upSubscribers?.Invoke(this, button));
|
||||
private void OnClick(IMouse _, MouseButton button, Vector2 position) =>
|
||||
Invoke(() => _clickSubscribers?.Invoke(this, button, position));
|
||||
private void OnDoubleClick(IMouse _, MouseButton button, Vector2 position) =>
|
||||
Invoke(() => _doubleClickSubscribers?.Invoke(this, button, position));
|
||||
private void OnMove(IMouse _, Vector2 position) =>
|
||||
Invoke(() => _moveSubscribers?.Invoke(this, position));
|
||||
private void OnScroll(IMouse _, ScrollWheel scroll) =>
|
||||
Invoke(() => _scrollSubscribers?.Invoke(this, scroll));
|
||||
private void Invoke(Action callback) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
callback();
|
||||
});
|
||||
|
||||
private void Add<T>(ref T? subscribers, T value, int index, Action attach)
|
||||
where T : Delegate
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
subscribers = (T?)Delegate.Combine(subscribers, value);
|
||||
if (_attached[index]) return;
|
||||
_attached[index] = true;
|
||||
attach();
|
||||
}
|
||||
|
||||
private void RemoveSubscriber<T>(
|
||||
ref T? subscribers,
|
||||
T value,
|
||||
int index,
|
||||
Action detach)
|
||||
where T : Delegate
|
||||
{
|
||||
subscribers = (T?)Delegate.Remove(subscribers, value);
|
||||
if (subscribers is not null || !_attached[index]) return;
|
||||
detach();
|
||||
_attached[index] = false;
|
||||
}
|
||||
|
||||
private void RemovePhysical(int index)
|
||||
{
|
||||
if (!_attached[index]) return;
|
||||
switch (index)
|
||||
{
|
||||
case 0: _inner.MouseDown -= _downRelay; _downSubscribers = null; break;
|
||||
case 1: _inner.MouseUp -= _upRelay; _upSubscribers = null; break;
|
||||
case 2: _inner.Click -= _clickRelay; _clickSubscribers = null; break;
|
||||
case 3: _inner.DoubleClick -= _doubleClickRelay; _doubleClickSubscribers = null; break;
|
||||
case 4: _inner.MouseMove -= _moveRelay; _moveSubscribers = null; break;
|
||||
case 5: _inner.Scroll -= _scrollRelay; _scrollSubscribers = null; break;
|
||||
default: throw new ArgumentOutOfRangeException(nameof(index));
|
||||
}
|
||||
_attached[index] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +1,218 @@
|
|||
using System;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Bridges a Silk.NET <see cref="IKeyboard"/> to the test-fakeable
|
||||
/// <see cref="IKeyboardSource"/> interface used by
|
||||
/// <see cref="InputDispatcher"/>. K.1a wiring: GameWindow constructs
|
||||
/// one of these wrapping the first keyboard from
|
||||
/// <c>IInputContext.Keyboards</c>; the dispatcher fans events out to
|
||||
/// subscribers without ever touching Silk types directly.
|
||||
/// </summary>
|
||||
public sealed class SilkKeyboardSource : IKeyboardSource
|
||||
internal interface IKeyboardEventSurface
|
||||
{
|
||||
void AddKeyDown(Action<Key> callback);
|
||||
void RemoveKeyDown(Action<Key> callback);
|
||||
void AddKeyUp(Action<Key> callback);
|
||||
void RemoveKeyUp(Action<Key> callback);
|
||||
bool IsKeyPressed(Key key);
|
||||
}
|
||||
|
||||
internal sealed class SilkKeyboardEventSurface : IKeyboardEventSurface
|
||||
{
|
||||
private readonly IKeyboard _keyboard;
|
||||
private Action<Key>? _keyDownCallback;
|
||||
private Action<Key>? _keyUpCallback;
|
||||
private readonly Action<IKeyboard, Key, int> _keyDown;
|
||||
private readonly Action<IKeyboard, Key, int> _keyUp;
|
||||
|
||||
public SilkKeyboardEventSurface(IKeyboard keyboard)
|
||||
{
|
||||
_keyboard = keyboard ?? throw new ArgumentNullException(nameof(keyboard));
|
||||
_keyDown = OnKeyDown;
|
||||
_keyUp = OnKeyUp;
|
||||
}
|
||||
|
||||
public void AddKeyDown(Action<Key> callback)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(callback);
|
||||
_keyDownCallback = callback;
|
||||
_keyboard.KeyDown += _keyDown;
|
||||
}
|
||||
|
||||
public void RemoveKeyDown(Action<Key> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_keyDownCallback, callback))
|
||||
return;
|
||||
_keyboard.KeyDown -= _keyDown;
|
||||
_keyDownCallback = null;
|
||||
}
|
||||
|
||||
public void AddKeyUp(Action<Key> callback)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(callback);
|
||||
_keyUpCallback = callback;
|
||||
_keyboard.KeyUp += _keyUp;
|
||||
}
|
||||
|
||||
public void RemoveKeyUp(Action<Key> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_keyUpCallback, callback))
|
||||
return;
|
||||
_keyboard.KeyUp -= _keyUp;
|
||||
_keyUpCallback = null;
|
||||
}
|
||||
|
||||
public bool IsKeyPressed(Key key) => _keyboard.IsKeyPressed(key);
|
||||
|
||||
private void OnKeyDown(IKeyboard _, Key key, int __) =>
|
||||
_keyDownCallback?.Invoke(key);
|
||||
|
||||
private void OnKeyUp(IKeyboard _, Key key, int __) =>
|
||||
_keyUpCallback?.Invoke(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reversible Silk keyboard bridge. Production publishes the detached owner
|
||||
/// before Attach so a side-effecting event accessor can never orphan a
|
||||
/// subscription. Logical deactivation makes copied delegates inert while
|
||||
/// physical removal is retried.
|
||||
/// </summary>
|
||||
public sealed class SilkKeyboardSource : IKeyboardSource, IDisposable
|
||||
{
|
||||
private readonly IKeyboardEventSurface _surface;
|
||||
private readonly HostQuiescenceGate _quiescence;
|
||||
private readonly Action<Key> _keyDown;
|
||||
private readonly Action<Key> _keyUp;
|
||||
private readonly bool[] _attached = new bool[2];
|
||||
private ResourceShutdownTransaction? _detach;
|
||||
private bool _attachStarted;
|
||||
private int _disposeRequested;
|
||||
private int _active;
|
||||
|
||||
public event Action<Key, ModifierMask>? KeyDown;
|
||||
public event Action<Key, ModifierMask>? KeyUp;
|
||||
|
||||
public SilkKeyboardSource(IKeyboard keyboard)
|
||||
private SilkKeyboardSource(
|
||||
IKeyboardEventSurface surface,
|
||||
HostQuiescenceGate quiescence)
|
||||
{
|
||||
_keyboard = keyboard ?? throw new ArgumentNullException(nameof(keyboard));
|
||||
_keyboard.KeyDown += (_, key, _) => KeyDown?.Invoke(key, ReadModifiers());
|
||||
_keyboard.KeyUp += (_, key, _) => KeyUp?.Invoke(key, ReadModifiers());
|
||||
_surface = surface ?? throw new ArgumentNullException(nameof(surface));
|
||||
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
|
||||
_keyDown = OnKeyDown;
|
||||
_keyUp = OnKeyUp;
|
||||
}
|
||||
|
||||
public bool IsHeld(Key key) => _keyboard.IsKeyPressed(key);
|
||||
internal static SilkKeyboardSource CreateDetached(
|
||||
IKeyboard keyboard,
|
||||
HostQuiescenceGate quiescence) =>
|
||||
new(
|
||||
new SilkKeyboardEventSurface(keyboard),
|
||||
quiescence);
|
||||
|
||||
internal static SilkKeyboardSource CreateDetached(
|
||||
IKeyboardEventSurface surface,
|
||||
HostQuiescenceGate quiescence) =>
|
||||
new(surface, quiescence);
|
||||
|
||||
public bool IsDisposalComplete => _attached.All(static value => !value);
|
||||
|
||||
public void Attach()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(
|
||||
Volatile.Read(ref _disposeRequested) != 0,
|
||||
this);
|
||||
if (_attachStarted)
|
||||
throw new InvalidOperationException("Keyboard source attachment has already started.");
|
||||
_attachStarted = true;
|
||||
|
||||
try
|
||||
{
|
||||
_attached[0] = true;
|
||||
_surface.AddKeyDown(_keyDown);
|
||||
_attached[1] = true;
|
||||
_surface.AddKeyUp(_keyUp);
|
||||
Volatile.Write(ref _active, 1);
|
||||
}
|
||||
catch (Exception attachError)
|
||||
{
|
||||
Deactivate();
|
||||
RollBackOrThrow(attachError);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsHeld(Key key) => _surface.IsKeyPressed(key);
|
||||
|
||||
public ModifierMask CurrentModifiers => ReadModifiers();
|
||||
|
||||
public void Deactivate() => Interlocked.Exchange(ref _active, 0);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Interlocked.Exchange(ref _disposeRequested, 1);
|
||||
Deactivate();
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
|
||||
private void OnKeyDown(Key key) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
KeyDown?.Invoke(key, ReadModifiers());
|
||||
});
|
||||
|
||||
private void OnKeyUp(Key key) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
KeyUp?.Invoke(key, ReadModifiers());
|
||||
});
|
||||
|
||||
private ModifierMask ReadModifiers()
|
||||
{
|
||||
ModifierMask m = ModifierMask.None;
|
||||
if (_keyboard.IsKeyPressed(Key.ShiftLeft) || _keyboard.IsKeyPressed(Key.ShiftRight))
|
||||
m |= ModifierMask.Shift;
|
||||
if (_keyboard.IsKeyPressed(Key.ControlLeft) || _keyboard.IsKeyPressed(Key.ControlRight))
|
||||
m |= ModifierMask.Ctrl;
|
||||
if (_keyboard.IsKeyPressed(Key.AltLeft) || _keyboard.IsKeyPressed(Key.AltRight))
|
||||
m |= ModifierMask.Alt;
|
||||
if (_keyboard.IsKeyPressed(Key.SuperLeft) || _keyboard.IsKeyPressed(Key.SuperRight))
|
||||
m |= ModifierMask.Win;
|
||||
return m;
|
||||
ModifierMask modifiers = ModifierMask.None;
|
||||
if (_surface.IsKeyPressed(Key.ShiftLeft) || _surface.IsKeyPressed(Key.ShiftRight))
|
||||
modifiers |= ModifierMask.Shift;
|
||||
if (_surface.IsKeyPressed(Key.ControlLeft) || _surface.IsKeyPressed(Key.ControlRight))
|
||||
modifiers |= ModifierMask.Ctrl;
|
||||
if (_surface.IsKeyPressed(Key.AltLeft) || _surface.IsKeyPressed(Key.AltRight))
|
||||
modifiers |= ModifierMask.Alt;
|
||||
if (_surface.IsKeyPressed(Key.SuperLeft) || _surface.IsKeyPressed(Key.SuperRight))
|
||||
modifiers |= ModifierMask.Win;
|
||||
return modifiers;
|
||||
}
|
||||
|
||||
private ResourceShutdownTransaction EnsureDetachTransaction() =>
|
||||
_detach ??= new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage("keyboard source callbacks",
|
||||
[
|
||||
new("key up", () => Remove(1)),
|
||||
new("key down", () => Remove(0)),
|
||||
]));
|
||||
|
||||
private void Remove(int index)
|
||||
{
|
||||
if (!_attached[index])
|
||||
return;
|
||||
if (index == 1)
|
||||
_surface.RemoveKeyUp(_keyUp);
|
||||
else
|
||||
_surface.RemoveKeyDown(_keyDown);
|
||||
_attached[index] = false;
|
||||
}
|
||||
|
||||
private void RollBackOrThrow(Exception attachError)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
catch (Exception rollbackError)
|
||||
{
|
||||
throw new AggregateException(
|
||||
"Keyboard source registration and rollback both failed.",
|
||||
new InvalidOperationException(
|
||||
"Keyboard source registration failed.", attachError),
|
||||
rollbackError);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Keyboard source registration failed and was rolled back.",
|
||||
attachError);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,92 +1,295 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Bridges a Silk.NET <see cref="IMouse"/> + the active ImGui IO
|
||||
/// (for <see cref="WantCaptureMouse"/> / <see cref="WantCaptureKeyboard"/>)
|
||||
/// to the test-fakeable <see cref="IMouseSource"/> used by
|
||||
/// <see cref="InputDispatcher"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// Production receives a typed capture source, keeping retained-UI and
|
||||
/// developer-UI ownership outside the Silk adapter. The public delegate
|
||||
/// overload remains as a compatibility seam for standalone consumers.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SilkMouseSource : IMouseSource
|
||||
internal interface IMouseEventSurface
|
||||
{
|
||||
void AddMouseDown(Action<MouseButton> callback);
|
||||
void RemoveMouseDown(Action<MouseButton> callback);
|
||||
void AddMouseUp(Action<MouseButton> callback);
|
||||
void RemoveMouseUp(Action<MouseButton> callback);
|
||||
void AddMouseMove(Action<Vector2> callback);
|
||||
void RemoveMouseMove(Action<Vector2> callback);
|
||||
void AddScroll(Action<float> callback);
|
||||
void RemoveScroll(Action<float> callback);
|
||||
bool IsButtonPressed(MouseButton button);
|
||||
}
|
||||
|
||||
internal sealed class SilkMouseEventSurface : IMouseEventSurface
|
||||
{
|
||||
private readonly IMouse _mouse;
|
||||
private Action<MouseButton>? _downCallback;
|
||||
private Action<MouseButton>? _upCallback;
|
||||
private Action<Vector2>? _moveCallback;
|
||||
private Action<float>? _scrollCallback;
|
||||
private readonly Action<IMouse, MouseButton> _down;
|
||||
private readonly Action<IMouse, MouseButton> _up;
|
||||
private readonly Action<IMouse, Vector2> _move;
|
||||
private readonly Action<IMouse, ScrollWheel> _scroll;
|
||||
|
||||
public SilkMouseEventSurface(IMouse mouse)
|
||||
{
|
||||
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
|
||||
_down = OnDown;
|
||||
_up = OnUp;
|
||||
_move = OnMove;
|
||||
_scroll = OnScroll;
|
||||
}
|
||||
|
||||
public void AddMouseDown(Action<MouseButton> callback)
|
||||
{
|
||||
_downCallback = callback ?? throw new ArgumentNullException(nameof(callback));
|
||||
_mouse.MouseDown += _down;
|
||||
}
|
||||
|
||||
public void RemoveMouseDown(Action<MouseButton> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_downCallback, callback)) return;
|
||||
_mouse.MouseDown -= _down;
|
||||
_downCallback = null;
|
||||
}
|
||||
|
||||
public void AddMouseUp(Action<MouseButton> callback)
|
||||
{
|
||||
_upCallback = callback ?? throw new ArgumentNullException(nameof(callback));
|
||||
_mouse.MouseUp += _up;
|
||||
}
|
||||
|
||||
public void RemoveMouseUp(Action<MouseButton> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_upCallback, callback)) return;
|
||||
_mouse.MouseUp -= _up;
|
||||
_upCallback = null;
|
||||
}
|
||||
|
||||
public void AddMouseMove(Action<Vector2> callback)
|
||||
{
|
||||
_moveCallback = callback ?? throw new ArgumentNullException(nameof(callback));
|
||||
_mouse.MouseMove += _move;
|
||||
}
|
||||
|
||||
public void RemoveMouseMove(Action<Vector2> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_moveCallback, callback)) return;
|
||||
_mouse.MouseMove -= _move;
|
||||
_moveCallback = null;
|
||||
}
|
||||
|
||||
public void AddScroll(Action<float> callback)
|
||||
{
|
||||
_scrollCallback = callback ?? throw new ArgumentNullException(nameof(callback));
|
||||
_mouse.Scroll += _scroll;
|
||||
}
|
||||
|
||||
public void RemoveScroll(Action<float> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_scrollCallback, callback)) return;
|
||||
_mouse.Scroll -= _scroll;
|
||||
_scrollCallback = null;
|
||||
}
|
||||
|
||||
public bool IsButtonPressed(MouseButton button) => _mouse.IsButtonPressed(button);
|
||||
|
||||
private void OnDown(IMouse _, MouseButton button) => _downCallback?.Invoke(button);
|
||||
private void OnUp(IMouse _, MouseButton button) => _upCallback?.Invoke(button);
|
||||
private void OnMove(IMouse _, Vector2 position) => _moveCallback?.Invoke(position);
|
||||
private void OnScroll(IMouse _, ScrollWheel scroll) => _scrollCallback?.Invoke(scroll.Y);
|
||||
}
|
||||
|
||||
/// <summary>Reversible Silk mouse bridge with immediate logical cutoff.</summary>
|
||||
public sealed class SilkMouseSource : IMouseSource, IDisposable
|
||||
{
|
||||
private readonly IMouseEventSurface _surface;
|
||||
private readonly IInputCaptureSource _capture;
|
||||
private readonly HostQuiescenceGate _quiescence;
|
||||
private readonly Action<MouseButton> _mouseDown;
|
||||
private readonly Action<MouseButton> _mouseUp;
|
||||
private readonly Action<Vector2> _mouseMove;
|
||||
private readonly Action<float> _scroll;
|
||||
private readonly bool[] _attached = new bool[4];
|
||||
private ResourceShutdownTransaction? _detach;
|
||||
private bool _attachStarted;
|
||||
private int _disposeRequested;
|
||||
private int _active;
|
||||
private float _lastX;
|
||||
private float _lastY;
|
||||
private bool _haveLastPos;
|
||||
private bool _haveLastPosition;
|
||||
|
||||
public event Action<MouseButton, ModifierMask>? MouseDown;
|
||||
public event Action<MouseButton, ModifierMask>? MouseUp;
|
||||
public event Action<float, float>? MouseMove;
|
||||
public event Action<float>? Scroll;
|
||||
|
||||
/// <summary>The keyboard source that owns the current modifier mask, so
|
||||
/// mouse events and keyboard events resolve identical chords.</summary>
|
||||
public IKeyboardSource? ModifierSource { get; set; }
|
||||
|
||||
internal SilkMouseSource(
|
||||
private SilkMouseSource(
|
||||
IMouseEventSurface surface,
|
||||
IInputCaptureSource capture,
|
||||
IKeyboardSource? modifierSource,
|
||||
HostQuiescenceGate quiescence)
|
||||
{
|
||||
_surface = surface ?? throw new ArgumentNullException(nameof(surface));
|
||||
_capture = capture ?? throw new ArgumentNullException(nameof(capture));
|
||||
ModifierSource = modifierSource;
|
||||
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
|
||||
_mouseDown = OnMouseDown;
|
||||
_mouseUp = OnMouseUp;
|
||||
_mouseMove = OnMouseMove;
|
||||
_scroll = OnScroll;
|
||||
}
|
||||
|
||||
internal static SilkMouseSource CreateDetached(
|
||||
IMouse mouse,
|
||||
IInputCaptureSource capture,
|
||||
IKeyboardSource modifierSource)
|
||||
{
|
||||
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
|
||||
_capture = capture ?? throw new ArgumentNullException(nameof(capture));
|
||||
ModifierSource = modifierSource
|
||||
?? throw new ArgumentNullException(nameof(modifierSource));
|
||||
Subscribe();
|
||||
}
|
||||
IKeyboardSource? modifierSource,
|
||||
HostQuiescenceGate quiescence) =>
|
||||
new(
|
||||
new SilkMouseEventSurface(mouse),
|
||||
capture,
|
||||
modifierSource,
|
||||
quiescence);
|
||||
|
||||
public SilkMouseSource(
|
||||
IMouse mouse,
|
||||
Func<bool> wantCaptureMouse,
|
||||
Func<bool> wantCaptureKeyboard)
|
||||
{
|
||||
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
|
||||
_capture = new DelegateInputCaptureSource(
|
||||
wantCaptureMouse,
|
||||
wantCaptureKeyboard);
|
||||
Subscribe();
|
||||
}
|
||||
internal static SilkMouseSource CreateDetached(
|
||||
IMouseEventSurface surface,
|
||||
IInputCaptureSource capture,
|
||||
IKeyboardSource? modifierSource,
|
||||
HostQuiescenceGate quiescence) =>
|
||||
new(surface, capture, modifierSource, quiescence);
|
||||
|
||||
private void Subscribe()
|
||||
public bool IsDisposalComplete => _attached.All(static value => !value);
|
||||
|
||||
public void Attach()
|
||||
{
|
||||
_mouse.MouseDown += (_, btn) => MouseDown?.Invoke(btn, ReadModifiers());
|
||||
_mouse.MouseUp += (_, btn) => MouseUp?.Invoke(btn, ReadModifiers());
|
||||
_mouse.MouseMove += (_, pos) =>
|
||||
ObjectDisposedException.ThrowIf(
|
||||
Volatile.Read(ref _disposeRequested) != 0,
|
||||
this);
|
||||
if (_attachStarted)
|
||||
throw new InvalidOperationException("Mouse source attachment has already started.");
|
||||
_attachStarted = true;
|
||||
try
|
||||
{
|
||||
float dx, dy;
|
||||
if (_haveLastPos)
|
||||
{
|
||||
dx = pos.X - _lastX;
|
||||
dy = pos.Y - _lastY;
|
||||
}
|
||||
else
|
||||
{
|
||||
dx = 0f;
|
||||
dy = 0f;
|
||||
_haveLastPos = true;
|
||||
}
|
||||
_lastX = pos.X;
|
||||
_lastY = pos.Y;
|
||||
MouseMove?.Invoke(dx, dy);
|
||||
};
|
||||
_mouse.Scroll += (_, scroll) => Scroll?.Invoke(scroll.Y);
|
||||
_attached[0] = true;
|
||||
_surface.AddMouseDown(_mouseDown);
|
||||
_attached[1] = true;
|
||||
_surface.AddMouseUp(_mouseUp);
|
||||
_attached[2] = true;
|
||||
_surface.AddMouseMove(_mouseMove);
|
||||
_attached[3] = true;
|
||||
_surface.AddScroll(_scroll);
|
||||
Volatile.Write(ref _active, 1);
|
||||
}
|
||||
catch (Exception attachError)
|
||||
{
|
||||
Deactivate();
|
||||
RollBackOrThrow(attachError);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsHeld(MouseButton button) => _surface.IsButtonPressed(button);
|
||||
public bool WantCaptureMouse => _capture.WantCaptureMouse;
|
||||
public bool WantCaptureKeyboard => _capture.WantCaptureKeyboard;
|
||||
|
||||
public void Deactivate() => Interlocked.Exchange(ref _active, 0);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Interlocked.Exchange(ref _disposeRequested, 1);
|
||||
Deactivate();
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
|
||||
private ModifierMask ReadModifiers() =>
|
||||
ModifierSource?.CurrentModifiers ?? ModifierMask.None;
|
||||
|
||||
public bool IsHeld(MouseButton button) => _mouse.IsButtonPressed(button);
|
||||
private void OnMouseDown(MouseButton button) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
MouseDown?.Invoke(button, ReadModifiers());
|
||||
});
|
||||
|
||||
public bool WantCaptureMouse => _capture.WantCaptureMouse;
|
||||
public bool WantCaptureKeyboard => _capture.WantCaptureKeyboard;
|
||||
private void OnMouseUp(MouseButton button) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
MouseUp?.Invoke(button, ReadModifiers());
|
||||
});
|
||||
|
||||
private void OnMouseMove(Vector2 position) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) == 0)
|
||||
return;
|
||||
|
||||
float dx = 0f;
|
||||
float dy = 0f;
|
||||
if (_haveLastPosition)
|
||||
{
|
||||
dx = position.X - _lastX;
|
||||
dy = position.Y - _lastY;
|
||||
}
|
||||
else
|
||||
{
|
||||
_haveLastPosition = true;
|
||||
}
|
||||
|
||||
_lastX = position.X;
|
||||
_lastY = position.Y;
|
||||
MouseMove?.Invoke(dx, dy);
|
||||
});
|
||||
|
||||
private void OnScroll(float delta) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
Scroll?.Invoke(delta);
|
||||
});
|
||||
|
||||
private ResourceShutdownTransaction EnsureDetachTransaction() =>
|
||||
_detach ??= new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage("mouse source callbacks",
|
||||
[
|
||||
new("scroll", () => Remove(3)),
|
||||
new("move", () => Remove(2)),
|
||||
new("up", () => Remove(1)),
|
||||
new("down", () => Remove(0)),
|
||||
]));
|
||||
|
||||
private void Remove(int index)
|
||||
{
|
||||
if (!_attached[index])
|
||||
return;
|
||||
switch (index)
|
||||
{
|
||||
case 0: _surface.RemoveMouseDown(_mouseDown); break;
|
||||
case 1: _surface.RemoveMouseUp(_mouseUp); break;
|
||||
case 2: _surface.RemoveMouseMove(_mouseMove); break;
|
||||
case 3: _surface.RemoveScroll(_scroll); break;
|
||||
default: throw new ArgumentOutOfRangeException(nameof(index));
|
||||
}
|
||||
_attached[index] = false;
|
||||
}
|
||||
|
||||
private void RollBackOrThrow(Exception attachError)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
catch (Exception rollbackError)
|
||||
{
|
||||
throw new AggregateException(
|
||||
"Mouse source registration and rollback both failed.",
|
||||
new InvalidOperationException(
|
||||
"Mouse source registration failed.", attachError),
|
||||
rollbackError);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Mouse source registration failed and was rolled back.",
|
||||
attachError);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
118
src/AcDream.App/Rendering/FramebufferResizeController.cs
Normal file
118
src/AcDream.App/Rendering/FramebufferResizeController.cs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
using AcDream.App.Input;
|
||||
using Silk.NET.Maths;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
internal interface IFramebufferViewportTarget
|
||||
{
|
||||
void ResizeViewport(int width, int height);
|
||||
}
|
||||
|
||||
internal sealed class SilkFramebufferViewportTarget(GL gl)
|
||||
: IFramebufferViewportTarget
|
||||
{
|
||||
private readonly GL _gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
||||
|
||||
public void ResizeViewport(int width, int height) =>
|
||||
_gl.Viewport(0, 0, (uint)width, (uint)height);
|
||||
}
|
||||
|
||||
internal interface IFramebufferCameraTarget
|
||||
{
|
||||
void SetAspect(float aspect);
|
||||
}
|
||||
|
||||
internal sealed class CameraFramebufferTarget(CameraController camera)
|
||||
: IFramebufferCameraTarget
|
||||
{
|
||||
private readonly CameraController _camera = camera
|
||||
?? throw new ArgumentNullException(nameof(camera));
|
||||
|
||||
public void SetAspect(float aspect) => _camera.SetAspect(aspect);
|
||||
}
|
||||
|
||||
internal interface IFramebufferDevToolsTarget
|
||||
{
|
||||
void ResetLayout(int width, int height);
|
||||
}
|
||||
|
||||
internal sealed class DevToolsFramebufferTarget(DevToolsFramePresenter presenter)
|
||||
: IFramebufferDevToolsTarget
|
||||
{
|
||||
private readonly DevToolsFramePresenter _presenter = presenter
|
||||
?? throw new ArgumentNullException(nameof(presenter));
|
||||
|
||||
public void ResetLayout(int width, int height) =>
|
||||
_presenter.ResetLayout(
|
||||
width,
|
||||
height,
|
||||
DevToolsPanelLayoutCondition.Always);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one framebuffer-size target. Logical gameplay/UI dimensions continue
|
||||
/// to come from Window.Size; this owner only applies physical framebuffer
|
||||
/// viewport/aspect changes in the frozen acdream host order.
|
||||
/// </summary>
|
||||
internal sealed class FramebufferResizeController
|
||||
{
|
||||
private readonly ViewportAspectState _viewportAspect;
|
||||
private IFramebufferViewportTarget? _viewport;
|
||||
private IFramebufferCameraTarget? _camera;
|
||||
private IFramebufferDevToolsTarget? _devTools;
|
||||
|
||||
public FramebufferResizeController(ViewportAspectState viewportAspect) =>
|
||||
_viewportAspect = viewportAspect
|
||||
?? throw new ArgumentNullException(nameof(viewportAspect));
|
||||
|
||||
public void BindViewport(IFramebufferViewportTarget viewport)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(viewport);
|
||||
BindOnce(ref _viewport, viewport, "viewport");
|
||||
}
|
||||
|
||||
public void BindCamera(IFramebufferCameraTarget camera)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(camera);
|
||||
BindOnce(ref _camera, camera, "camera");
|
||||
}
|
||||
|
||||
public void BindDevTools(IFramebufferDevToolsTarget devTools)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(devTools);
|
||||
BindOnce(ref _devTools, devTools, "developer tools");
|
||||
}
|
||||
|
||||
public void UnbindDevTools(IFramebufferDevToolsTarget devTools)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(devTools);
|
||||
if (ReferenceEquals(_devTools, devTools))
|
||||
_devTools = null;
|
||||
}
|
||||
|
||||
public void Resize(Vector2D<int> newSize) => Resize(newSize.X, newSize.Y);
|
||||
|
||||
public void Resize(int width, int height)
|
||||
{
|
||||
if (width <= 0 || height <= 0)
|
||||
return;
|
||||
|
||||
// Frozen order: GL viewport, shared aspect publication, camera, then
|
||||
// optional ImGui layout reset. Late binding never replays an earlier
|
||||
// resize transition.
|
||||
_viewport?.ResizeViewport(width, height);
|
||||
_viewportAspect.Update(width, height);
|
||||
_camera?.SetAspect(width / (float)height);
|
||||
_devTools?.ResetLayout(width, height);
|
||||
}
|
||||
|
||||
private static void BindOnce<T>(ref T? slot, T value, string name)
|
||||
where T : class
|
||||
{
|
||||
if (slot is not null && !ReferenceEquals(slot, value))
|
||||
throw new InvalidOperationException(
|
||||
$"The framebuffer {name} target is already bound.");
|
||||
slot = value;
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ public sealed class GameWindow : IDisposable
|
|||
private SilkWindowCallbackBinding? _windowCallbacks;
|
||||
private GL? _gl;
|
||||
private IInputContext? _input;
|
||||
private AcDream.App.Input.QuiescentInputContext? _devToolsInputContext;
|
||||
private TerrainModernRenderer? _terrain;
|
||||
/// <summary>Phase N.5b: terrain_modern.vert/.frag program. Owned by
|
||||
/// <see cref="_terrain"/> at draw time but allocated + disposed here.</summary>
|
||||
|
|
@ -34,16 +35,7 @@ public sealed class GameWindow : IDisposable
|
|||
private CameraController? _cameraController;
|
||||
private IDatReaderWriter? _dats;
|
||||
private readonly AcDream.App.Input.PointerPositionState _pointerPosition = new();
|
||||
private float _lastMouseX
|
||||
{
|
||||
get => _pointerPosition.X;
|
||||
set => _pointerPosition.X = value;
|
||||
}
|
||||
private float _lastMouseY
|
||||
{
|
||||
get => _pointerPosition.Y;
|
||||
set => _pointerPosition.Y = value;
|
||||
}
|
||||
private AcDream.App.Input.CameraPointerInputController? _cameraPointerInput;
|
||||
private Shader? _meshShader;
|
||||
private TextureCache? _textureCache;
|
||||
/// <summary>Phase N.4+: WB-backed rendering pipeline adapter. Always non-null
|
||||
|
|
@ -434,6 +426,8 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
private readonly AcDream.App.Input.LocalPlayerSkillState _localPlayerSkills = new();
|
||||
private readonly AcDream.App.Input.ViewportAspectState _viewportAspect = new();
|
||||
private readonly FramebufferResizeController _framebufferResize;
|
||||
private IFramebufferDevToolsTarget? _framebufferDevToolsTarget;
|
||||
private AcDream.App.Input.PlayerModeController? _playerModeController;
|
||||
private readonly AcDream.App.Interaction.PlayerApproachCompletionState
|
||||
_playerApproachCompletions = new();
|
||||
|
|
@ -444,26 +438,6 @@ public sealed class GameWindow : IDisposable
|
|||
// Phase D.2b-C — live character-sheet assembly + raise flow (extracted
|
||||
// feature class; GameWindow only wires it). Null unless ACDREAM_RETAIL_UI=1.
|
||||
private AcDream.App.UI.Layout.CharacterSheetProvider? _characterSheetProvider;
|
||||
// Mouse sensitivity multipliers — one per camera mode because the visual
|
||||
// feel is very different. Adjust via F8 / F9 for whichever mode is
|
||||
// currently active. Chase default is low because the character + camera
|
||||
// rotating together is overwhelming at fly speeds.
|
||||
private float _sensChase
|
||||
{
|
||||
get => _chaseCameraInput.Sensitivity;
|
||||
set => _chaseCameraInput.Sensitivity = value;
|
||||
}
|
||||
private float _sensFly = 1.0f;
|
||||
private float _sensOrbit = 1.0f;
|
||||
|
||||
// Right-mouse-button held → free-orbit the chase camera around the
|
||||
// player without turning the character. Release leaves the camera at
|
||||
// the orbited position (no snap back).
|
||||
private bool _rmbHeld
|
||||
{
|
||||
get => _chaseCameraInput.RmbOrbitHeld;
|
||||
}
|
||||
|
||||
// Phase K.2 — auto-enter player mode after a successful login. Armed
|
||||
// by LiveSessionHost's entered-world transition; ticked from
|
||||
// OnUpdate; disarmed if the user manually enters fly mode (or any
|
||||
|
|
@ -579,6 +553,7 @@ public sealed class GameWindow : IDisposable
|
|||
_retainedInputCapture);
|
||||
_movementInput = new AcDream.App.Input.DispatcherMovementInputSource(
|
||||
_inputCapture);
|
||||
_framebufferResize = new FramebufferResizeController(_viewportAspect);
|
||||
_datDir = options.DatDir;
|
||||
_worldGameState = worldGameState;
|
||||
_worldEvents = worldEvents;
|
||||
|
|
@ -676,6 +651,8 @@ public sealed class GameWindow : IDisposable
|
|||
_physicsEngine.DataCache = _physicsDataCache;
|
||||
|
||||
_gl = GL.GetApi(_window!);
|
||||
_framebufferResize.BindViewport(
|
||||
new SilkFramebufferViewportTarget(_gl));
|
||||
_gpuFrameFlights = new AcDream.App.Rendering.GpuFrameFlightController(_gl);
|
||||
var worldRenderDiagnostics = new AcDream.App.Rendering.WorldRenderDiagnostics(
|
||||
new AcDream.App.Rendering.SilkRenderGlStateReader(_gl),
|
||||
|
|
@ -692,113 +669,36 @@ public sealed class GameWindow : IDisposable
|
|||
// per K.1b plan §D).
|
||||
var firstKb = _input.Keyboards.FirstOrDefault();
|
||||
var firstMouse = _input.Mice.FirstOrDefault();
|
||||
if (firstKb is not null && firstMouse is not null)
|
||||
if (firstKb is not null)
|
||||
{
|
||||
_kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb);
|
||||
_mouseSource = new AcDream.App.Input.SilkMouseSource(
|
||||
_kbSource = AcDream.App.Input.SilkKeyboardSource.CreateDetached(
|
||||
firstKb,
|
||||
_hostQuiescence);
|
||||
_kbSource.Attach();
|
||||
}
|
||||
if (firstMouse is not null)
|
||||
{
|
||||
_mouseSource = AcDream.App.Input.SilkMouseSource.CreateDetached(
|
||||
firstMouse,
|
||||
_inputCapture,
|
||||
_kbSource);
|
||||
_inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher(
|
||||
_kbSource,
|
||||
_hostQuiescence);
|
||||
_mouseSource.Attach();
|
||||
_mouseLookCursor = new AcDream.App.Input.SilkMouseLookCursor(firstMouse);
|
||||
}
|
||||
if (_kbSource is not null && _mouseSource is not null)
|
||||
{
|
||||
_inputDispatcher = AcDream.UI.Abstractions.Input.InputDispatcher.CreateDetached(
|
||||
_kbSource, _mouseSource, _keyBindings);
|
||||
_inputDispatcher.Attach();
|
||||
_movementInput.Bind(_inputDispatcher);
|
||||
_cameraInput.Bind(_inputDispatcher);
|
||||
_mouseLookCursor = new AcDream.App.Input.SilkMouseLookCursor(firstMouse);
|
||||
_inputDispatcher.Fired += OnInputAction;
|
||||
Combat.CombatModeChanged += SetInputCombatScope;
|
||||
SetInputCombatScope(Combat.CurrentMode);
|
||||
|
||||
}
|
||||
|
||||
// Mouse delta handler — kept direct because Silk.NET delivers mouse
|
||||
// moves as continuous (x, y) positions, not chord events. We gate
|
||||
// on WantCaptureMouse so panel hover never drives camera. The
|
||||
// previous "_playerMouseDeltaX += dx * sens" branch is GONE — mouse
|
||||
// never drives character yaw in K.1b. RMB-held orbit is wired via
|
||||
// the dispatcher's AcdreamRmbOrbitHold action (subscriber sets
|
||||
// _rmbHeld below).
|
||||
foreach (var mouse in _input.Mice)
|
||||
{
|
||||
mouse.MouseMove += (m, pos) =>
|
||||
{
|
||||
// K.1b §E: explicit WantCaptureMouse defense-in-depth on the
|
||||
// surviving direct-mouse handler. Suppresses RMB orbit /
|
||||
// FlyCamera look while ImGui has the mouse focus.
|
||||
if (_inputCapture.WantCaptureMouse)
|
||||
{
|
||||
_lastMouseX = pos.X;
|
||||
_lastMouseY = pos.Y;
|
||||
return;
|
||||
}
|
||||
if (_cameraController is null) return;
|
||||
|
||||
float dx = pos.X - _lastMouseX;
|
||||
float dy = pos.Y - _lastMouseY;
|
||||
|
||||
if (_playerMode && _cameraController.IsChaseMode && _chaseCamera is not null)
|
||||
{
|
||||
float sens = _sensChase;
|
||||
if (_gameplayInputFrame?.MouseLookActive == true)
|
||||
{
|
||||
// DirectInput may deliver several device records before
|
||||
// one retail GetInput pass. Accumulate raw motion here;
|
||||
// the update loop filters the aggregate exactly once.
|
||||
_gameplayInputFrame.QueueRawMouseDelta(dx, dy);
|
||||
}
|
||||
else if (_rmbHeld)
|
||||
{
|
||||
// Hold-RMB orbit: player stays the central point, camera
|
||||
// free-orbits around. X rotates around, Y pitches. On release
|
||||
// the camera STAYS at the new angle (no snap back).
|
||||
// K.1b: this is the ONLY mouse-delta path that affects
|
||||
// ANYTHING when in player mode — character yaw is
|
||||
// dispatcher-only (A/D keys). K.2: MMB mouse-look path
|
||||
// above takes precedence when active.
|
||||
if (AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null)
|
||||
{
|
||||
float nowSec = (float)(Environment.TickCount64 / 1000.0);
|
||||
var (filteredDx, filteredDy) = _retailChaseCamera.FilterMouseDelta(
|
||||
rawX: dx, rawY: dy, weight: 0.5f, nowSec: nowSec);
|
||||
_retailChaseCamera.YawOffset -= filteredDx * 0.004f * sens;
|
||||
_retailChaseCamera.AdjustPitch(filteredDy * 0.003f * sens);
|
||||
}
|
||||
else
|
||||
{
|
||||
_chaseCamera.YawOffset -= dx * 0.004f * sens;
|
||||
_chaseCamera.AdjustPitch(dy * 0.003f * sens);
|
||||
}
|
||||
}
|
||||
// K-fix1 (2026-04-26): no default-pitch path. With
|
||||
// neither MMB nor RMB held, mouse moves the cursor
|
||||
// freely so the user can interact with panels +
|
||||
// (eventually) click selectables in the world. Pitch
|
||||
// is gated on a deliberate hold input.
|
||||
}
|
||||
else if (_cameraController.IsFlyMode)
|
||||
{
|
||||
float sens = _sensFly;
|
||||
_cameraController.Fly.Look(dx * sens, dy * sens);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Orbit-camera mode (offline / pre-login): hold LMB to
|
||||
// drag-rotate. K.1b reads the mouse-button state via
|
||||
// IMouseSource so all "is button held" queries go
|
||||
// through the same abstraction the dispatcher uses.
|
||||
float sens = _sensOrbit;
|
||||
if (_mouseSource is not null && _mouseSource.IsHeld(MouseButton.Left))
|
||||
{
|
||||
_cameraController.Orbit.Yaw -= dx * 0.005f * sens;
|
||||
_cameraController.Orbit.Pitch = Math.Clamp(
|
||||
_cameraController.Orbit.Pitch + dy * 0.005f * sens,
|
||||
0.1f, 1.5f);
|
||||
}
|
||||
}
|
||||
_lastMouseX = pos.X;
|
||||
_lastMouseY = pos.Y;
|
||||
};
|
||||
}
|
||||
|
||||
_gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f);
|
||||
_gl.Enable(EnableCap.DepthTest);
|
||||
|
||||
|
|
@ -849,10 +749,26 @@ public sealed class GameWindow : IDisposable
|
|||
Console.WriteLine("world-hud font: no system monospace font found");
|
||||
}
|
||||
|
||||
var orbit = new OrbitCamera { Aspect = _window!.Size.X / (float)_window.Size.Y };
|
||||
var fly = new FlyCamera { Aspect = _window.Size.X / (float)_window.Size.Y };
|
||||
var orbit = new OrbitCamera();
|
||||
var fly = new FlyCamera();
|
||||
_cameraController = new CameraController(orbit, fly);
|
||||
_cameraController.ModeChanged += OnCameraModeChanged;
|
||||
_framebufferResize.BindCamera(
|
||||
new CameraFramebufferTarget(_cameraController));
|
||||
_framebufferResize.Resize(_window!.FramebufferSize);
|
||||
if (_mouseSource is not null && firstMouse is not null)
|
||||
{
|
||||
_cameraPointerInput = AcDream.App.Input.CameraPointerInputController.Create(
|
||||
_input.Mice,
|
||||
_hostQuiescence,
|
||||
_inputCapture,
|
||||
_localPlayerMode,
|
||||
_cameraController,
|
||||
_chaseCameraInput,
|
||||
_mouseSource,
|
||||
_pointerPosition,
|
||||
new AcDream.App.Input.EnvironmentInputMonotonicClock());
|
||||
_cameraPointerInput.AttachRaw();
|
||||
}
|
||||
|
||||
_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);
|
||||
_magicCatalog = AcDream.App.Spells.MagicCatalog.Load(_dats);
|
||||
|
|
@ -959,8 +875,15 @@ public sealed class GameWindow : IDisposable
|
|||
AcDream.UI.ImGui.ImGuiBootstrapper? imguiBootstrap = null;
|
||||
try
|
||||
{
|
||||
_devToolsInputContext = new AcDream.App.Input.QuiescentInputContext(
|
||||
_input!,
|
||||
_hostQuiescence);
|
||||
imguiBootstrap =
|
||||
new AcDream.UI.ImGui.ImGuiBootstrapper(_gl!, _window!, _input!);
|
||||
new AcDream.UI.ImGui.ImGuiBootstrapper(
|
||||
_gl!,
|
||||
_window!,
|
||||
_devToolsInputContext);
|
||||
_devToolsInputContext.Activate();
|
||||
var panelHost = new AcDream.UI.ImGui.ImGuiPanelHost();
|
||||
|
||||
// VitalsVM: GUID=0 at construction; set later at EnterWorld
|
||||
|
|
@ -1021,9 +944,11 @@ public sealed class GameWindow : IDisposable
|
|||
_worldSceneDebugState.CollisionWireframesVisible,
|
||||
getStreamingRadius: () => _nearRadius, // A.5 T16 follow-up: was _streamingRadius (legacy single-tier); show near tier
|
||||
|
||||
getMouseSensitivity: () => GetActiveSensitivity(),
|
||||
getMouseSensitivity: () =>
|
||||
_cameraPointerInput?.ActiveSensitivity ?? 1f,
|
||||
getChaseDistance: () => _chaseCamera?.Distance ?? 0f,
|
||||
getRmbOrbit: () => _rmbHeld,
|
||||
getRmbOrbit: () =>
|
||||
_cameraPointerInput?.RmbOrbitHeld ?? false,
|
||||
getHourName: () => WorldTime.CurrentCalendar.Hour.ToString(),
|
||||
getDayFraction: () => (float)WorldTime.DayFraction,
|
||||
getWeather: () => Weather.Kind.ToString(),
|
||||
|
|
@ -1229,6 +1154,9 @@ public sealed class GameWindow : IDisposable
|
|||
debugPanel,
|
||||
_debugVm,
|
||||
settingsPanel));
|
||||
_framebufferDevToolsTarget = new DevToolsFramebufferTarget(
|
||||
_devToolsFramePresenter);
|
||||
_framebufferResize.BindDevTools(_framebufferDevToolsTarget);
|
||||
_devToolsFramePresenter.ResetLayout(
|
||||
_window!.Size.X,
|
||||
_window.Size.Y,
|
||||
|
|
@ -1241,6 +1169,13 @@ public sealed class GameWindow : IDisposable
|
|||
// operation. Construction failure still owns this local and
|
||||
// must release it before abandoning the optional frontend.
|
||||
imguiBootstrap?.Dispose();
|
||||
if (_framebufferDevToolsTarget is { } framebufferTarget)
|
||||
{
|
||||
_framebufferResize.UnbindDevTools(framebufferTarget);
|
||||
_framebufferDevToolsTarget = null;
|
||||
}
|
||||
_devToolsInputContext?.Dispose();
|
||||
_devToolsInputContext = null;
|
||||
_devToolsBackend = null;
|
||||
_devToolsFramePresenter = null;
|
||||
_devToolsCameraMenu = null;
|
||||
|
|
@ -1429,7 +1364,11 @@ public sealed class GameWindow : IDisposable
|
|||
if (_options.RetailUi)
|
||||
{
|
||||
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
|
||||
_uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont);
|
||||
_uiHost = new AcDream.App.UI.UiHost(
|
||||
_gl,
|
||||
shadersDir,
|
||||
_debugFont,
|
||||
_hostQuiescence);
|
||||
_retainedInputCapture.Root = _uiHost.Root;
|
||||
_uiHost.Root.UiLocked = _persistedGameplay.LockUI;
|
||||
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
|
||||
|
|
@ -2031,7 +1970,9 @@ public sealed class GameWindow : IDisposable
|
|||
camera.Projection,
|
||||
camera.Viewport);
|
||||
},
|
||||
() => new System.Numerics.Vector2(_lastMouseX, _lastMouseY),
|
||||
() => new System.Numerics.Vector2(
|
||||
_pointerPosition.X,
|
||||
_pointerPosition.Y),
|
||||
() => _playerController is { } player
|
||||
? new AcDream.App.Interaction.PlayerInteractionPose(
|
||||
player.CellId,
|
||||
|
|
@ -2472,6 +2413,7 @@ public sealed class GameWindow : IDisposable
|
|||
mouseLookController,
|
||||
new AcDream.App.Input.CombatAttackInputFrameAdapter(
|
||||
_combatAttackController!));
|
||||
_cameraPointerInput?.BindGameplayFrame(_gameplayInputFrame);
|
||||
var streamingFrame = new AcDream.App.Streaming.StreamingFrameController(
|
||||
_options.LiveMode,
|
||||
_localPlayerMode,
|
||||
|
|
@ -2559,7 +2501,6 @@ public sealed class GameWindow : IDisposable
|
|||
_animatedEntities,
|
||||
_equippedChildRenderer!,
|
||||
liveEffectFrame);
|
||||
_viewportAspect.Update(_window!.Size.X, _window.Size.Y);
|
||||
_playerModeController = new AcDream.App.Input.PlayerModeController(
|
||||
_localPlayerMode,
|
||||
_playerControllerSlot,
|
||||
|
|
@ -3169,31 +3110,6 @@ public sealed class GameWindow : IDisposable
|
|||
new AcDream.App.Update.UpdateFrameInput(dt));
|
||||
}
|
||||
|
||||
private void OnCameraModeChanged(bool _modeBool)
|
||||
{
|
||||
if (_gameplayInputFrame?.MouseLookActive == true
|
||||
&& _cameraController?.IsChaseMode != true)
|
||||
{
|
||||
_gameplayInputFrame?.EndMouseLook();
|
||||
}
|
||||
|
||||
if (_input is null) return;
|
||||
var mouse = _input.Mice.FirstOrDefault();
|
||||
if (mouse is null) return;
|
||||
|
||||
// K-fix2 (2026-04-26): the bool passed to ModeChanged is NOT
|
||||
// reliably "isFlyMode" — CameraController.EnterChaseMode invokes
|
||||
// it with IsChaseMode (true), CameraController.ToggleFly invokes
|
||||
// it with IsFlyMode, and CameraController.ExitChaseMode invokes
|
||||
// it with IsFlyMode. Reading the controller state directly is
|
||||
// the only correct gate. Cursor visible by default in chase /
|
||||
// orbit modes; Raw cursor only in fly mode (continuous
|
||||
// look-and-fly affordance). Mouse-look (raw mode) when MMB is
|
||||
// held is handled separately by the gameplay input owner.
|
||||
bool needsRawCursor = _cameraController?.IsFlyMode == true;
|
||||
mouse.Cursor.CursorMode = needsRawCursor ? CursorMode.Raw : CursorMode.Normal;
|
||||
}
|
||||
|
||||
// Performance overlay state — updated every ~0.5s and written to the
|
||||
// window title so there's zero rendering cost (no font/overlay needed).
|
||||
private void OnRender(double deltaSeconds)
|
||||
|
|
@ -3325,13 +3241,6 @@ public sealed class GameWindow : IDisposable
|
|||
private bool GetDebugPlayerOnGround() =>
|
||||
_playerMode && _playerController is not null && !_playerController.IsAirborne;
|
||||
|
||||
private float GetActiveSensitivity()
|
||||
{
|
||||
if (_playerMode && _cameraController?.IsChaseMode == true) return _sensChase;
|
||||
if (_cameraController?.IsFlyMode == true) return _sensFly;
|
||||
return _sensOrbit;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cycle the time-of-day debug override. Same body as the old F7
|
||||
/// keybind handler; called by both the keybind AND the DebugPanel
|
||||
|
|
@ -3611,21 +3520,7 @@ public sealed class GameWindow : IDisposable
|
|||
/// previously off the new viewport snap back to default positions.
|
||||
/// </summary>
|
||||
private void OnFramebufferResize(Silk.NET.Maths.Vector2D<int> newSize)
|
||||
{
|
||||
if (newSize.X <= 0 || newSize.Y <= 0) return;
|
||||
_gl?.Viewport(0, 0, (uint)newSize.X, (uint)newSize.Y);
|
||||
_viewportAspect.Update(newSize.X, newSize.Y);
|
||||
_cameraController?.SetAspect(newSize.X / (float)newSize.Y);
|
||||
// Resize is always a force-reset — the alternative ("clamp
|
||||
// existing positions") would require tracking each panel's
|
||||
// current pos+size, which ImGuiNET doesn't expose by name.
|
||||
// Force-reset is acceptable UX because resizing happens rarely
|
||||
// and the user can always drag panels back where they want.
|
||||
_devToolsFramePresenter?.ResetLayout(
|
||||
newSize.X,
|
||||
newSize.Y,
|
||||
AcDream.App.Rendering.DevToolsPanelLayoutCondition.Always);
|
||||
}
|
||||
=> _framebufferResize.Resize(newSize);
|
||||
|
||||
/// <summary>
|
||||
/// L.0 Display tab: apply the window-state-dependent settings
|
||||
|
|
@ -3701,11 +3596,8 @@ public sealed class GameWindow : IDisposable
|
|||
// Diagnostic — kept from K.1a; helpful for K.1c verification.
|
||||
Console.WriteLine($"[input] {action} {activation}");
|
||||
|
||||
// RMB-orbit hold: track press/release transitions explicitly so
|
||||
// _rmbHeld is true exactly while the chord is held. Hold-type
|
||||
// chords also fire Press on key-down + Release on key-up; we
|
||||
// ignore the in-between Hold ticks here (the mouse-move handler
|
||||
// checks _rmbHeld each frame anyway).
|
||||
// RMB orbit and instant mouse-look remain the first accepted input
|
||||
// transition. Their state is owned by the gameplay/pointer owners.
|
||||
if (_gameplayInputFrame?.HandlePointerAction(action, activation) == true)
|
||||
return;
|
||||
|
||||
|
|
@ -3958,20 +3850,10 @@ public sealed class GameWindow : IDisposable
|
|||
/// </summary>
|
||||
private void AdjustActiveSensitivity(float factor)
|
||||
{
|
||||
string modeLabel;
|
||||
float current;
|
||||
if (_playerMode && _cameraController?.IsChaseMode == true)
|
||||
{ modeLabel = "Chase"; current = _sensChase; }
|
||||
else if (_cameraController?.IsFlyMode == true)
|
||||
{ modeLabel = "Fly"; current = _sensFly; }
|
||||
else
|
||||
{ modeLabel = "Orbit"; current = _sensOrbit; }
|
||||
|
||||
float next = MathF.Min(3.0f, MathF.Max(0.005f, current * factor));
|
||||
if (modeLabel == "Chase") _sensChase = next;
|
||||
else if (modeLabel == "Fly") _sensFly = next;
|
||||
else _sensOrbit = next;
|
||||
_debugVm?.AddToast($"{modeLabel} sens {next:F3}x");
|
||||
if (_cameraPointerInput is not { } pointer)
|
||||
return;
|
||||
string message = pointer.AdjustSensitivity(factor);
|
||||
_debugVm?.AddToast(message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -4040,28 +3922,7 @@ public sealed class GameWindow : IDisposable
|
|||
/// retail wheel feel).
|
||||
/// </summary>
|
||||
private void HandleScrollAction(AcDream.UI.Abstractions.Input.InputAction action)
|
||||
{
|
||||
if (_cameraController is null) return;
|
||||
float dir = (action == AcDream.UI.Abstractions.Input.InputAction.ScrollUp) ? 1f : -1f;
|
||||
|
||||
if (_playerMode && _cameraController.IsChaseMode)
|
||||
{
|
||||
// Chase mode: zoom (closer on ScrollUp).
|
||||
if (AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null)
|
||||
_retailChaseCamera.AdjustDistance(-dir * 0.8f);
|
||||
else if (_chaseCamera is not null)
|
||||
_chaseCamera.AdjustDistance(-dir * 0.8f);
|
||||
}
|
||||
else if (_cameraController.IsFlyMode)
|
||||
{
|
||||
// Fly mode: no-op (could adjust move speed later).
|
||||
}
|
||||
else
|
||||
{
|
||||
_cameraController.Orbit.Distance = Math.Clamp(
|
||||
_cameraController.Orbit.Distance - dir * 20f, 50f, 2000f);
|
||||
}
|
||||
}
|
||||
=> _cameraPointerInput?.HandleScroll(action);
|
||||
|
||||
private void OnClosing()
|
||||
{
|
||||
|
|
@ -4088,6 +3949,20 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
|
||||
private ResourceShutdownTransaction CreateShutdownTransaction() => new(
|
||||
// Logical cutoff precedes the live session's potentially long graceful
|
||||
// close. Physical event removal follows session retirement, so a bad
|
||||
// Silk accessor cannot prevent F653/transport teardown. The input
|
||||
// context and UI owners remain alive for reset, while copied callbacks
|
||||
// are already inert.
|
||||
new ResourceShutdownStage("input callback deactivation",
|
||||
[
|
||||
new("camera pointer", () => _cameraPointerInput?.Deactivate()),
|
||||
new("dispatcher", () => _inputDispatcher?.Deactivate()),
|
||||
new("mouse source", () => _mouseSource?.Deactivate()),
|
||||
new("keyboard source", () => _kbSource?.Deactivate()),
|
||||
new("retained UI input", () => _uiHost?.QuiesceInput()),
|
||||
new("developer tools input", () => _devToolsInputContext?.Deactivate()),
|
||||
]),
|
||||
// Live-session reset retires the complete streaming window. Every
|
||||
// reset callback owner, especially LandblockStreamer, must remain live
|
||||
// until that transaction converges.
|
||||
|
|
@ -4114,6 +3989,57 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}),
|
||||
]),
|
||||
new ResourceShutdownStage("input callback detach",
|
||||
[
|
||||
new("retained UI input", () => _uiHost?.DeactivateInput()),
|
||||
new("developer tools input", () => _devToolsInputContext?.Dispose()),
|
||||
new("camera pointer", () =>
|
||||
{
|
||||
AcDream.App.Input.CameraPointerInputController? pointer =
|
||||
_cameraPointerInput;
|
||||
if (pointer is null) return;
|
||||
pointer.Dispose();
|
||||
if (!pointer.IsDisposalComplete)
|
||||
throw new InvalidOperationException(
|
||||
"Camera pointer callback removal remains pending.");
|
||||
}),
|
||||
new("combat input subscription", () =>
|
||||
Combat.CombatModeChanged -= SetInputCombatScope),
|
||||
new("dispatcher", () =>
|
||||
{
|
||||
AcDream.UI.Abstractions.Input.InputDispatcher? dispatcher =
|
||||
_inputDispatcher;
|
||||
if (dispatcher is null) return;
|
||||
dispatcher.Fired -= OnInputAction;
|
||||
_movementInput.Unbind(dispatcher);
|
||||
_cameraInput.Unbind(dispatcher);
|
||||
dispatcher.Dispose();
|
||||
if (!dispatcher.IsDisposalComplete)
|
||||
throw new InvalidOperationException(
|
||||
"Input dispatcher source removal remains pending.");
|
||||
_inputDispatcher = null;
|
||||
}),
|
||||
new("mouse source", () =>
|
||||
{
|
||||
AcDream.App.Input.SilkMouseSource? source = _mouseSource;
|
||||
if (source is null) return;
|
||||
source.Dispose();
|
||||
if (!source.IsDisposalComplete)
|
||||
throw new InvalidOperationException(
|
||||
"Mouse source callback removal remains pending.");
|
||||
_mouseSource = null;
|
||||
}),
|
||||
new("keyboard source", () =>
|
||||
{
|
||||
AcDream.App.Input.SilkKeyboardSource? source = _kbSource;
|
||||
if (source is null) return;
|
||||
source.Dispose();
|
||||
if (!source.IsDisposalComplete)
|
||||
throw new InvalidOperationException(
|
||||
"Keyboard source callback removal remains pending.");
|
||||
_kbSource = null;
|
||||
}),
|
||||
]),
|
||||
// Frame composition borrows equipped, effect, audio, and render
|
||||
// owners. Withdraw the graph before the first borrowed owner closes;
|
||||
// the later render-frontend stage still disposes the GL owners.
|
||||
|
|
@ -4126,7 +4052,16 @@ public sealed class GameWindow : IDisposable
|
|||
]),
|
||||
new ResourceShutdownStage("session dependents",
|
||||
[
|
||||
new("mouse capture", () => _gameplayInputFrame?.EndMouseLook()),
|
||||
new("mouse capture", () =>
|
||||
{
|
||||
AcDream.App.Input.CameraPointerInputController? pointer =
|
||||
_cameraPointerInput;
|
||||
if (pointer is null) return;
|
||||
pointer.ReleaseMouseLookAfterSessionRetirement();
|
||||
if (_gameplayInputFrame is { } gameplayFrame)
|
||||
pointer.UnbindGameplayFrame(gameplayFrame);
|
||||
_cameraPointerInput = null;
|
||||
}),
|
||||
new("retail UI", () =>
|
||||
{
|
||||
_retailUiRuntime?.Dispose();
|
||||
|
|
@ -4196,11 +4131,17 @@ public sealed class GameWindow : IDisposable
|
|||
[
|
||||
new("developer tools", () =>
|
||||
{
|
||||
if (_framebufferDevToolsTarget is { } framebufferTarget)
|
||||
{
|
||||
_framebufferResize.UnbindDevTools(framebufferTarget);
|
||||
_framebufferDevToolsTarget = null;
|
||||
}
|
||||
_devToolsFramePresenter = null;
|
||||
_devToolsCameraMenu = null;
|
||||
_devToolsCommandBus = null;
|
||||
_devToolsBackend?.Dispose();
|
||||
_devToolsBackend = null;
|
||||
_devToolsInputContext = null;
|
||||
}),
|
||||
new("portal tunnel", () =>
|
||||
{
|
||||
|
|
@ -4281,8 +4222,6 @@ public sealed class GameWindow : IDisposable
|
|||
if (ReferenceEquals(_windowCallbacks, binding))
|
||||
_windowCallbacks = null;
|
||||
}),
|
||||
new("combat input subscription", ()
|
||||
=> Combat.CombatModeChanged -= SetInputCombatScope),
|
||||
new("input context", () =>
|
||||
{
|
||||
_input?.Dispose();
|
||||
|
|
@ -4299,10 +4238,7 @@ public sealed class GameWindow : IDisposable
|
|||
]));
|
||||
|
||||
private void OnFocusChanged(bool focused)
|
||||
{
|
||||
if (!focused)
|
||||
_gameplayInputFrame?.EndMouseLook();
|
||||
}
|
||||
=> _cameraPointerInput?.HandleFocusChanged(focused);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
|
|
|
|||
420
src/AcDream.App/UI/RetainedUiInputBinding.cs
Normal file
420
src/AcDream.App/UI/RetainedUiInputBinding.cs
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
internal interface IRetainedUiInputBinding : IDisposable
|
||||
{
|
||||
bool IsDisposalComplete { get; }
|
||||
void Attach();
|
||||
void Deactivate();
|
||||
}
|
||||
|
||||
internal interface IRetainedMouseSurface
|
||||
{
|
||||
void AddMouseDown(Action<MouseButton, int, int> callback);
|
||||
void RemoveMouseDown(Action<MouseButton, int, int> callback);
|
||||
void AddMouseUp(Action<MouseButton, int, int> callback);
|
||||
void RemoveMouseUp(Action<MouseButton, int, int> callback);
|
||||
void AddMouseMove(Action<int, int> callback);
|
||||
void RemoveMouseMove(Action<int, int> callback);
|
||||
void AddScroll(Action<int> callback);
|
||||
void RemoveScroll(Action<int> callback);
|
||||
}
|
||||
|
||||
internal sealed class SilkRetainedMouseSurface : IRetainedMouseSurface
|
||||
{
|
||||
private readonly IMouse _mouse;
|
||||
private Action<MouseButton, int, int>? _downCallback;
|
||||
private Action<MouseButton, int, int>? _upCallback;
|
||||
private Action<int, int>? _moveCallback;
|
||||
private Action<int>? _scrollCallback;
|
||||
private readonly Action<IMouse, MouseButton> _down;
|
||||
private readonly Action<IMouse, MouseButton> _up;
|
||||
private readonly Action<IMouse, Vector2> _move;
|
||||
private readonly Action<IMouse, ScrollWheel> _scroll;
|
||||
|
||||
public SilkRetainedMouseSurface(IMouse mouse)
|
||||
{
|
||||
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
|
||||
_down = OnDown;
|
||||
_up = OnUp;
|
||||
_move = OnMove;
|
||||
_scroll = OnScroll;
|
||||
}
|
||||
|
||||
public void AddMouseDown(Action<MouseButton, int, int> callback)
|
||||
{
|
||||
_downCallback = callback ?? throw new ArgumentNullException(nameof(callback));
|
||||
_mouse.MouseDown += _down;
|
||||
}
|
||||
|
||||
public void RemoveMouseDown(Action<MouseButton, int, int> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_downCallback, callback)) return;
|
||||
_mouse.MouseDown -= _down;
|
||||
_downCallback = null;
|
||||
}
|
||||
|
||||
public void AddMouseUp(Action<MouseButton, int, int> callback)
|
||||
{
|
||||
_upCallback = callback ?? throw new ArgumentNullException(nameof(callback));
|
||||
_mouse.MouseUp += _up;
|
||||
}
|
||||
|
||||
public void RemoveMouseUp(Action<MouseButton, int, int> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_upCallback, callback)) return;
|
||||
_mouse.MouseUp -= _up;
|
||||
_upCallback = null;
|
||||
}
|
||||
|
||||
public void AddMouseMove(Action<int, int> callback)
|
||||
{
|
||||
_moveCallback = callback ?? throw new ArgumentNullException(nameof(callback));
|
||||
_mouse.MouseMove += _move;
|
||||
}
|
||||
|
||||
public void RemoveMouseMove(Action<int, int> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_moveCallback, callback)) return;
|
||||
_mouse.MouseMove -= _move;
|
||||
_moveCallback = null;
|
||||
}
|
||||
|
||||
public void AddScroll(Action<int> callback)
|
||||
{
|
||||
_scrollCallback = callback ?? throw new ArgumentNullException(nameof(callback));
|
||||
_mouse.Scroll += _scroll;
|
||||
}
|
||||
|
||||
public void RemoveScroll(Action<int> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_scrollCallback, callback)) return;
|
||||
_mouse.Scroll -= _scroll;
|
||||
_scrollCallback = null;
|
||||
}
|
||||
|
||||
private void OnDown(IMouse sender, MouseButton button) =>
|
||||
_downCallback?.Invoke(button, (int)sender.Position.X, (int)sender.Position.Y);
|
||||
private void OnUp(IMouse sender, MouseButton button) =>
|
||||
_upCallback?.Invoke(button, (int)sender.Position.X, (int)sender.Position.Y);
|
||||
private void OnMove(IMouse _, Vector2 position) =>
|
||||
_moveCallback?.Invoke((int)position.X, (int)position.Y);
|
||||
private void OnScroll(IMouse _, ScrollWheel scroll) =>
|
||||
_scrollCallback?.Invoke((int)scroll.Y);
|
||||
}
|
||||
|
||||
internal sealed class RetainedMouseInputBinding : IRetainedUiInputBinding
|
||||
{
|
||||
private readonly IRetainedMouseSurface _surface;
|
||||
private readonly UiRoot _root;
|
||||
private readonly HostQuiescenceGate _quiescence;
|
||||
private readonly Action<MouseButton, int, int> _down;
|
||||
private readonly Action<MouseButton, int, int> _up;
|
||||
private readonly Action<int, int> _move;
|
||||
private readonly Action<int> _scroll;
|
||||
private readonly bool[] _attached = new bool[4];
|
||||
private ResourceShutdownTransaction? _detach;
|
||||
private bool _attachStarted;
|
||||
private int _disposeRequested;
|
||||
private int _active;
|
||||
|
||||
public RetainedMouseInputBinding(
|
||||
IRetainedMouseSurface surface,
|
||||
UiRoot root,
|
||||
HostQuiescenceGate quiescence)
|
||||
{
|
||||
_surface = surface ?? throw new ArgumentNullException(nameof(surface));
|
||||
_root = root ?? throw new ArgumentNullException(nameof(root));
|
||||
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
|
||||
_down = OnDown;
|
||||
_up = OnUp;
|
||||
_move = OnMove;
|
||||
_scroll = OnScroll;
|
||||
}
|
||||
|
||||
public bool IsDisposalComplete => _attached.All(static value => !value);
|
||||
|
||||
public void Attach()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(
|
||||
Volatile.Read(ref _disposeRequested) != 0,
|
||||
this);
|
||||
if (_attachStarted)
|
||||
throw new InvalidOperationException("Retained mouse attachment has already started.");
|
||||
_attachStarted = true;
|
||||
try
|
||||
{
|
||||
_attached[0] = true;
|
||||
_surface.AddMouseDown(_down);
|
||||
_attached[1] = true;
|
||||
_surface.AddMouseUp(_up);
|
||||
_attached[2] = true;
|
||||
_surface.AddMouseMove(_move);
|
||||
_attached[3] = true;
|
||||
_surface.AddScroll(_scroll);
|
||||
Volatile.Write(ref _active, 1);
|
||||
}
|
||||
catch (Exception attachError)
|
||||
{
|
||||
Deactivate();
|
||||
RollBackOrThrow(attachError);
|
||||
}
|
||||
}
|
||||
|
||||
public void Deactivate() => Interlocked.Exchange(ref _active, 0);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Interlocked.Exchange(ref _disposeRequested, 1);
|
||||
Deactivate();
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
|
||||
private void OnDown(MouseButton button, int x, int y) =>
|
||||
Invoke(() => _root.OnMouseDown(MapButton(button), x, y));
|
||||
private void OnUp(MouseButton button, int x, int y) =>
|
||||
Invoke(() => _root.OnMouseUp(MapButton(button), x, y));
|
||||
private void OnMove(int x, int y) => Invoke(() => _root.OnMouseMove(x, y));
|
||||
private void OnScroll(int amount) => Invoke(() => _root.OnScroll(amount));
|
||||
|
||||
private void Invoke(Action callback) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
callback();
|
||||
});
|
||||
|
||||
private ResourceShutdownTransaction EnsureDetachTransaction() =>
|
||||
_detach ??= new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage("retained mouse callbacks",
|
||||
[
|
||||
new("scroll", () => Remove(3)),
|
||||
new("move", () => Remove(2)),
|
||||
new("up", () => Remove(1)),
|
||||
new("down", () => Remove(0)),
|
||||
]));
|
||||
|
||||
private void Remove(int index)
|
||||
{
|
||||
if (!_attached[index]) return;
|
||||
switch (index)
|
||||
{
|
||||
case 0: _surface.RemoveMouseDown(_down); break;
|
||||
case 1: _surface.RemoveMouseUp(_up); break;
|
||||
case 2: _surface.RemoveMouseMove(_move); break;
|
||||
case 3: _surface.RemoveScroll(_scroll); break;
|
||||
default: throw new ArgumentOutOfRangeException(nameof(index));
|
||||
}
|
||||
_attached[index] = false;
|
||||
}
|
||||
|
||||
private void RollBackOrThrow(Exception attachError)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
catch (Exception rollbackError)
|
||||
{
|
||||
throw new AggregateException(
|
||||
"Retained mouse registration and rollback both failed.",
|
||||
new InvalidOperationException("Retained mouse registration failed.", attachError),
|
||||
rollbackError);
|
||||
}
|
||||
throw new InvalidOperationException(
|
||||
"Retained mouse registration failed and was rolled back.", attachError);
|
||||
}
|
||||
|
||||
private static UiMouseButton MapButton(MouseButton button) => button switch
|
||||
{
|
||||
MouseButton.Left => UiMouseButton.Left,
|
||||
MouseButton.Right => UiMouseButton.Right,
|
||||
MouseButton.Middle => UiMouseButton.Middle,
|
||||
_ => UiMouseButton.Left,
|
||||
};
|
||||
}
|
||||
|
||||
internal interface IRetainedKeyboardSurface
|
||||
{
|
||||
void AddKeyDown(Action<Key> callback);
|
||||
void RemoveKeyDown(Action<Key> callback);
|
||||
void AddKeyUp(Action<Key> callback);
|
||||
void RemoveKeyUp(Action<Key> callback);
|
||||
void AddKeyChar(Action<char> callback);
|
||||
void RemoveKeyChar(Action<char> callback);
|
||||
}
|
||||
|
||||
internal sealed class SilkRetainedKeyboardSurface : IRetainedKeyboardSurface
|
||||
{
|
||||
private readonly IKeyboard _keyboard;
|
||||
private Action<Key>? _downCallback;
|
||||
private Action<Key>? _upCallback;
|
||||
private Action<char>? _charCallback;
|
||||
private readonly Action<IKeyboard, Key, int> _down;
|
||||
private readonly Action<IKeyboard, Key, int> _up;
|
||||
private readonly Action<IKeyboard, char> _char;
|
||||
|
||||
public SilkRetainedKeyboardSurface(IKeyboard keyboard)
|
||||
{
|
||||
_keyboard = keyboard ?? throw new ArgumentNullException(nameof(keyboard));
|
||||
_down = OnDown;
|
||||
_up = OnUp;
|
||||
_char = OnChar;
|
||||
}
|
||||
|
||||
public void AddKeyDown(Action<Key> callback)
|
||||
{
|
||||
_downCallback = callback ?? throw new ArgumentNullException(nameof(callback));
|
||||
_keyboard.KeyDown += _down;
|
||||
}
|
||||
|
||||
public void RemoveKeyDown(Action<Key> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_downCallback, callback)) return;
|
||||
_keyboard.KeyDown -= _down;
|
||||
_downCallback = null;
|
||||
}
|
||||
|
||||
public void AddKeyUp(Action<Key> callback)
|
||||
{
|
||||
_upCallback = callback ?? throw new ArgumentNullException(nameof(callback));
|
||||
_keyboard.KeyUp += _up;
|
||||
}
|
||||
|
||||
public void RemoveKeyUp(Action<Key> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_upCallback, callback)) return;
|
||||
_keyboard.KeyUp -= _up;
|
||||
_upCallback = null;
|
||||
}
|
||||
|
||||
public void AddKeyChar(Action<char> callback)
|
||||
{
|
||||
_charCallback = callback ?? throw new ArgumentNullException(nameof(callback));
|
||||
_keyboard.KeyChar += _char;
|
||||
}
|
||||
|
||||
public void RemoveKeyChar(Action<char> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_charCallback, callback)) return;
|
||||
_keyboard.KeyChar -= _char;
|
||||
_charCallback = null;
|
||||
}
|
||||
|
||||
private void OnDown(IKeyboard _, Key key, int __) => _downCallback?.Invoke(key);
|
||||
private void OnUp(IKeyboard _, Key key, int __) => _upCallback?.Invoke(key);
|
||||
private void OnChar(IKeyboard _, char value) => _charCallback?.Invoke(value);
|
||||
}
|
||||
|
||||
internal sealed class RetainedKeyboardInputBinding : IRetainedUiInputBinding
|
||||
{
|
||||
private readonly IRetainedKeyboardSurface _surface;
|
||||
private readonly UiRoot _root;
|
||||
private readonly HostQuiescenceGate _quiescence;
|
||||
private readonly Action<Key> _down;
|
||||
private readonly Action<Key> _up;
|
||||
private readonly Action<char> _char;
|
||||
private readonly bool[] _attached = new bool[3];
|
||||
private ResourceShutdownTransaction? _detach;
|
||||
private bool _attachStarted;
|
||||
private int _disposeRequested;
|
||||
private int _active;
|
||||
|
||||
public RetainedKeyboardInputBinding(
|
||||
IRetainedKeyboardSurface surface,
|
||||
UiRoot root,
|
||||
HostQuiescenceGate quiescence)
|
||||
{
|
||||
_surface = surface ?? throw new ArgumentNullException(nameof(surface));
|
||||
_root = root ?? throw new ArgumentNullException(nameof(root));
|
||||
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
|
||||
_down = key => Invoke(() => _root.OnKeyDown((int)key));
|
||||
_up = key => Invoke(() => _root.OnKeyUp((int)key));
|
||||
_char = value => Invoke(() => _root.OnChar(value));
|
||||
}
|
||||
|
||||
public bool IsDisposalComplete => _attached.All(static value => !value);
|
||||
|
||||
public void Attach()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(
|
||||
Volatile.Read(ref _disposeRequested) != 0,
|
||||
this);
|
||||
if (_attachStarted)
|
||||
throw new InvalidOperationException("Retained keyboard attachment has already started.");
|
||||
_attachStarted = true;
|
||||
try
|
||||
{
|
||||
_attached[0] = true;
|
||||
_surface.AddKeyDown(_down);
|
||||
_attached[1] = true;
|
||||
_surface.AddKeyUp(_up);
|
||||
_attached[2] = true;
|
||||
_surface.AddKeyChar(_char);
|
||||
Volatile.Write(ref _active, 1);
|
||||
}
|
||||
catch (Exception attachError)
|
||||
{
|
||||
Deactivate();
|
||||
RollBackOrThrow(attachError);
|
||||
}
|
||||
}
|
||||
|
||||
public void Deactivate() => Interlocked.Exchange(ref _active, 0);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Interlocked.Exchange(ref _disposeRequested, 1);
|
||||
Deactivate();
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
|
||||
private void Invoke(Action callback) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
callback();
|
||||
});
|
||||
|
||||
private ResourceShutdownTransaction EnsureDetachTransaction() =>
|
||||
_detach ??= new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage("retained keyboard callbacks",
|
||||
[
|
||||
new("character", () => Remove(2)),
|
||||
new("up", () => Remove(1)),
|
||||
new("down", () => Remove(0)),
|
||||
]));
|
||||
|
||||
private void Remove(int index)
|
||||
{
|
||||
if (!_attached[index]) return;
|
||||
switch (index)
|
||||
{
|
||||
case 0: _surface.RemoveKeyDown(_down); break;
|
||||
case 1: _surface.RemoveKeyUp(_up); break;
|
||||
case 2: _surface.RemoveKeyChar(_char); break;
|
||||
default: throw new ArgumentOutOfRangeException(nameof(index));
|
||||
}
|
||||
_attached[index] = false;
|
||||
}
|
||||
|
||||
private void RollBackOrThrow(Exception attachError)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
catch (Exception rollbackError)
|
||||
{
|
||||
throw new AggregateException(
|
||||
"Retained keyboard registration and rollback both failed.",
|
||||
new InvalidOperationException("Retained keyboard registration failed.", attachError),
|
||||
rollbackError);
|
||||
}
|
||||
throw new InvalidOperationException(
|
||||
"Retained keyboard registration failed and was rolled back.", attachError);
|
||||
}
|
||||
}
|
||||
|
|
@ -49,15 +49,27 @@ public sealed class UiHost : System.IDisposable
|
|||
public IKeyboard? Keyboard { get; private set; }
|
||||
|
||||
private long _startTicks = System.Environment.TickCount64;
|
||||
private readonly List<System.Action> _inputUnsubscribers = new();
|
||||
private readonly HostQuiescenceGate _quiescence;
|
||||
private readonly List<IRetainedUiInputBinding> _inputBindings = new();
|
||||
private ResourceShutdownTransaction? _inputShutdown;
|
||||
private ResourceShutdownTransaction? _shutdown;
|
||||
private bool _disposeRequested;
|
||||
private bool _disposed;
|
||||
|
||||
public UiHost(GL gl, string shaderDir, BitmapFont? defaultFont = null)
|
||||
: this(gl, shaderDir, defaultFont, new HostQuiescenceGate())
|
||||
{
|
||||
}
|
||||
|
||||
internal UiHost(
|
||||
GL gl,
|
||||
string shaderDir,
|
||||
BitmapFont? defaultFont,
|
||||
HostQuiescenceGate quiescence)
|
||||
{
|
||||
TextRenderer = new TextRenderer(gl, shaderDir);
|
||||
DefaultFont = defaultFont;
|
||||
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
|
||||
}
|
||||
|
||||
// ── Per-frame ──────────────────────────────────────────────────────
|
||||
|
|
@ -86,25 +98,21 @@ public sealed class UiHost : System.IDisposable
|
|||
System.ObjectDisposedException.ThrowIf(_disposeRequested || _disposed, this);
|
||||
System.ArgumentNullException.ThrowIfNull(mouse);
|
||||
|
||||
void OnMouseDown(IMouse sender, MouseButton button) =>
|
||||
Root.OnMouseDown(MapButton(button), (int)sender.Position.X, (int)sender.Position.Y);
|
||||
void OnMouseUp(IMouse sender, MouseButton button) =>
|
||||
Root.OnMouseUp(MapButton(button), (int)sender.Position.X, (int)sender.Position.Y);
|
||||
void OnMouseMove(IMouse sender, Vector2 position) =>
|
||||
Root.OnMouseMove((int)position.X, (int)position.Y);
|
||||
void OnScroll(IMouse sender, ScrollWheel scroll) => Root.OnScroll((int)scroll.Y);
|
||||
|
||||
mouse.MouseDown += OnMouseDown;
|
||||
mouse.MouseUp += OnMouseUp;
|
||||
mouse.MouseMove += OnMouseMove;
|
||||
mouse.Scroll += OnScroll;
|
||||
_inputUnsubscribers.Add(() =>
|
||||
var binding = new RetainedMouseInputBinding(
|
||||
new SilkRetainedMouseSurface(mouse),
|
||||
Root,
|
||||
_quiescence);
|
||||
_inputBindings.Add(binding);
|
||||
try
|
||||
{
|
||||
mouse.MouseDown -= OnMouseDown;
|
||||
mouse.MouseUp -= OnMouseUp;
|
||||
mouse.MouseMove -= OnMouseMove;
|
||||
mouse.Scroll -= OnScroll;
|
||||
});
|
||||
binding.Attach();
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (binding.IsDisposalComplete)
|
||||
_inputBindings.Remove(binding);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void WireKeyboard(IKeyboard kb)
|
||||
|
|
@ -112,28 +120,54 @@ public sealed class UiHost : System.IDisposable
|
|||
System.ObjectDisposedException.ThrowIf(_disposeRequested || _disposed, this);
|
||||
System.ArgumentNullException.ThrowIfNull(kb);
|
||||
Keyboard = kb; // last wired keyboard wins (one-keyboard desktop)
|
||||
void OnKeyDown(IKeyboard sender, Key key, int scanCode) => Root.OnKeyDown((int)key);
|
||||
void OnKeyUp(IKeyboard sender, Key key, int scanCode) => Root.OnKeyUp((int)key);
|
||||
void OnKeyChar(IKeyboard sender, char value) => Root.OnChar(value);
|
||||
|
||||
kb.KeyDown += OnKeyDown;
|
||||
kb.KeyUp += OnKeyUp;
|
||||
kb.KeyChar += OnKeyChar;
|
||||
_inputUnsubscribers.Add(() =>
|
||||
var binding = new RetainedKeyboardInputBinding(
|
||||
new SilkRetainedKeyboardSurface(kb),
|
||||
Root,
|
||||
_quiescence);
|
||||
_inputBindings.Add(binding);
|
||||
try
|
||||
{
|
||||
kb.KeyDown -= OnKeyDown;
|
||||
kb.KeyUp -= OnKeyUp;
|
||||
kb.KeyChar -= OnKeyChar;
|
||||
});
|
||||
binding.Attach();
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (binding.IsDisposalComplete)
|
||||
_inputBindings.Remove(binding);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static UiMouseButton MapButton(MouseButton b) => b switch
|
||||
/// <summary>
|
||||
/// Stops retained-device delivery without retiring the window tree or GL
|
||||
/// renderer. Safe to call before a potentially long live-session close.
|
||||
/// </summary>
|
||||
public void QuiesceInput()
|
||||
{
|
||||
MouseButton.Left => UiMouseButton.Left,
|
||||
MouseButton.Right => UiMouseButton.Right,
|
||||
MouseButton.Middle => UiMouseButton.Middle,
|
||||
_ => UiMouseButton.Left,
|
||||
};
|
||||
_disposeRequested = true;
|
||||
foreach (IRetainedUiInputBinding binding in _inputBindings)
|
||||
binding.Deactivate();
|
||||
Keyboard = null;
|
||||
}
|
||||
|
||||
/// <summary>Physically removes every retained input edge after quiescence.</summary>
|
||||
public void DeactivateInput()
|
||||
{
|
||||
QuiesceInput();
|
||||
|
||||
_inputShutdown ??= new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage(
|
||||
"retained UI input subscriptions",
|
||||
_inputBindings
|
||||
.AsEnumerable()
|
||||
.Reverse()
|
||||
.Select((binding, index) => new ResourceShutdownOperation(
|
||||
$"input binding {index}",
|
||||
binding.Dispose))
|
||||
.ToArray()));
|
||||
_inputShutdown.CompleteOrThrow();
|
||||
if (_inputShutdown.IsComplete)
|
||||
_inputBindings.Clear();
|
||||
}
|
||||
|
||||
// ── Window manager forwarders (delegate to UiRoot) ─────────────────
|
||||
|
||||
|
|
@ -169,12 +203,8 @@ public sealed class UiHost : System.IDisposable
|
|||
|
||||
_disposeRequested = true;
|
||||
_shutdown ??= CreateShutdownTransaction(
|
||||
_inputUnsubscribers.AsEnumerable().Reverse().ToArray(),
|
||||
() =>
|
||||
{
|
||||
_inputUnsubscribers.Clear();
|
||||
Keyboard = null;
|
||||
},
|
||||
[DeactivateInput],
|
||||
() => { },
|
||||
WindowManager.Dispose,
|
||||
TextRenderer.Dispose);
|
||||
_shutdown.CompleteOrThrow();
|
||||
|
|
|
|||
|
|
@ -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