Skip to content

Commit 9750d61

Browse files
Feature: mouse capture to main Window (#1805)
* feat: window mouse relative mode (crossplatform) refactor: disable/enable mouse capture, inside ViewBox feature: mouse capture (crossplatform) Signed-off-by: Maximilien Noal <noal.maximilien@gmail.com> fix: address mouse capture review feedback (#1962) * Initial plan * fix: address all mouse capture PR review comments Co-authored-by: maximilien-noal <1087524+maximilien-noal@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: maximilien-noal <1087524+maximilien-noal@users.noreply.github.com> fix: linux xorg mouse grab / release API usage feat: implement mouse capture functionality for macOS, Windows, and Linux refactor: Debug.Assert pr feedback chore: remove asserts refactor: X11 backend for mouse capture refactor: remove mouse capture backend implementations and update MainWindow for pointer capture refactor: simplify mouse capture logic by removing pointer parameter and related code chore: Remove mouse capture hint logic and update cycles UI focus Removed UpdateMouseCaptureHint from MainWindowViewModel and its usage. Assigned x:Name to cycles limiting NumericUpDown and added a GotFocus handler to redirect focus to the video buffer, preventing unwanted focus retention. Updated MainWindow constructor and cleaned up related code. fix: Add focus handler for Time Modifier NumericUpDown Assigned x:Name to the Time Modifier NumericUpDown and wired its GotFocus event to reuse the CyclesLimitingNumericUpDown_GotFocus handler, ensuring consistent focus behavior with other numeric controls. chore: Removed video Scale menu (does not work anymore) chore: removed Alt-Fx tooltips (does not work since Docker framework usage) feat: implement SDL2 relative mouse capture via Silk.NET.SDL Agent-Logs-Url: https://github.com/OpenRakis/Spice86/sessions/2ec37724-28f3-4554-87ea-77d4ac254956 Co-authored-by: maximilien-noal <1087524+maximilien-noal@users.noreply.github.com> fix: rename SdlDeltaScale to VirtualScreenWidth and fix typo Agent-Logs-Url: https://github.com/OpenRakis/Spice86/sessions/2ec37724-28f3-4554-87ea-77d4ac254956 Co-authored-by: maximilien-noal <1087524+maximilien-noal@users.noreply.github.com> feat: introduce IMouseCaptureBackend wrapper with WindowsMouseCaptureBackend Agent-Logs-Url: https://github.com/OpenRakis/Spice86/sessions/f8d95506-9f12-49e7-bdd2-d1d12481410c Co-authored-by: maximilien-noal <1087524+maximilien-noal@users.noreply.github.com> fix: guard OnClosed unsubscribe by UsesRelativeMouseMode and remove unused Marshal calls Agent-Logs-Url: https://github.com/OpenRakis/Spice86/sessions/f8d95506-9f12-49e7-bdd2-d1d12481410c Co-authored-by: maximilien-noal <1087524+maximilien-noal@users.noreply.github.com> fix: restore mouse capture hint in window title bar Agent-Logs-Url: https://github.com/OpenRakis/Spice86/sessions/12eed9de-aed2-49b0-b862-7c9f82e8b39b Co-authored-by: maximilien-noal <1087524+maximilien-noal@users.noreply.github.com> fix: Dispose pattern Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * chore: remove part of a comment --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
1 parent 370a1e5 commit 9750d61

12 files changed

Lines changed: 863 additions & 59 deletions

src/Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
<PackageVersion Include="Xaml.Behaviors.Avalonia" Version="12.0.0" />
5959
<PackageVersion Include="Spice86.Audio" Version="11.4.0" />
6060
<PackageVersion Include="FastExpressionCompiler" Version="5.4.1" />
61+
<PackageVersion Include="Silk.NET.SDL" Version="2.23.0" />
6162
<PackageVersion Include="Tmds.DBus.Protocol" Version="0.92.0" />
6263
</ItemGroup>
6364
</Project>

src/Spice86/App.axaml.cs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,6 @@ private void OnSplashWindowLoaded(
5353
desktop.Args!)!;
5454
Spice86DependencyInjection dependencyInjection = new(configuration, mainWindow);
5555
if (mainWindow.DataContext is MainWindowViewModel mainVm) {
56-
mainVm.CloseMainWindow += (_, _) => mainWindow.Close();
57-
mainVm.InvalidateBitmap += mainWindow.Image.InvalidateVisual;
58-
mainWindow.Image.PointerMoved += (s, e) => mainVm.OnMouseMoved(e, mainWindow.Image);
59-
mainWindow.Image.PointerPressed += (s, e) => mainVm.OnMouseButtonDown(e, mainWindow.Image);
60-
mainWindow.Image.PointerReleased += (s, e) => mainVm.OnMouseButtonUp(e, mainWindow.Image);
6156
mainVm.Disposing += dependencyInjection.Dispose;
6257
}
6358
desktop.MainWindow = mainWindow;
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
namespace Spice86.Native;
2+
3+
using System;
4+
5+
/// <summary>
6+
/// Abstraction over platform-specific mouse capture strategies.
7+
/// </summary>
8+
internal interface IMouseCaptureBackend : IDisposable {
9+
/// <summary>
10+
/// Gets a value indicating whether mouse capture is currently active.
11+
/// </summary>
12+
bool IsCaptured { get; }
13+
14+
/// <summary>
15+
/// Gets a value indicating whether this backend hides the cursor and reports only relative
16+
/// mouse deltas (e.g. SDL relative mode). When <see langword="false"/>, the cursor remains
17+
/// visible and the host Avalonia pointer events continue to carry absolute coordinates.
18+
/// </summary>
19+
bool UsesRelativeMouseMode { get; }
20+
21+
/// <summary>
22+
/// Performs any one-time platform initialisation required before
23+
/// <see cref="EnableCapture"/> or <see cref="DisableCapture"/> may be called.
24+
/// </summary>
25+
/// <param name="nativeWindowHandle">
26+
/// The platform-native window handle (HWND on Windows, X11 Window ID on Linux, NSWindow* on macOS).
27+
/// </param>
28+
/// <returns><see langword="true"/> if initialisation succeeded; otherwise <see langword="false"/>.</returns>
29+
bool TryInitialize(nint nativeWindowHandle);
30+
31+
/// <summary>Enables mouse capture.</summary>
32+
/// <returns><see langword="true"/> if capture was successfully enabled.</returns>
33+
bool EnableCapture();
34+
35+
/// <summary>Disables mouse capture.</summary>
36+
/// <returns><see langword="true"/> if capture was successfully disabled.</returns>
37+
bool DisableCapture();
38+
39+
/// <summary>
40+
/// Returns the accumulated relative mouse motion since the last call.
41+
/// Always returns <c>(0, 0)</c> for backends where <see cref="UsesRelativeMouseMode"/> is
42+
/// <see langword="false"/>.
43+
/// </summary>
44+
/// <param name="dx">Horizontal delta in pixels (positive = right).</param>
45+
/// <param name="dy">Vertical delta in pixels (positive = down).</param>
46+
void GetRelativeMouseDelta(out int dx, out int dy);
47+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
namespace Spice86.Native;
2+
3+
using Silk.NET.SDL;
4+
5+
using System;
6+
using System.IO;
7+
8+
/// <summary>
9+
/// SDL2-based mouse capture backend for non-Windows platforms.
10+
/// Uses <c>SDL_SetRelativeMouseMode</c> to hide the cursor and report relative mouse deltas.
11+
/// Precompiled SDL2 native libraries are bundled via Silk.NET.SDL (Ultz.Native.SDL),
12+
/// covering macOS (universal) and Linux (x64/arm64/arm).
13+
/// </summary>
14+
internal sealed class SdlMouseCapture : IMouseCaptureBackend {
15+
private Sdl? _sdl;
16+
private bool _initialized;
17+
private bool _isCaptured;
18+
private bool _disposed;
19+
20+
/// <summary>
21+
/// Gets a value indicating whether the SDL subsystem was successfully initialized.
22+
/// </summary>
23+
public bool IsInitialized => _initialized;
24+
25+
/// <summary>
26+
/// Gets a value indicating whether relative mouse capture is currently active.
27+
/// </summary>
28+
public bool IsCaptured => _isCaptured;
29+
30+
/// <inheritdoc/>
31+
/// <remarks><see langword="true"/>: cursor is hidden and only deltas are reported.</remarks>
32+
public bool UsesRelativeMouseMode => true;
33+
34+
/// <summary>
35+
/// Initializes the SDL video subsystem.
36+
/// If the SDL2 native library is unavailable, this returns <see langword="false"/> without throwing.
37+
/// </summary>
38+
/// <param name="nativeWindowHandle">The platform-native window handle (HWND on Windows, X11 Window ID on Linux, NSWindow* on macOS).</param>
39+
/// <returns><see langword="true"/> if initialization succeeded; otherwise <see langword="false"/>.</returns>
40+
public bool TryInitialize(nint nativeWindowHandle) {
41+
if (_initialized) {
42+
return true;
43+
}
44+
45+
if (nativeWindowHandle == nint.Zero) {
46+
return false;
47+
}
48+
49+
try {
50+
_sdl = Sdl.GetApi();
51+
} catch (FileNotFoundException) {
52+
return false;
53+
} catch (DllNotFoundException) {
54+
return false;
55+
}
56+
57+
// Suppress SDL's default signal handlers so they don't interfere with the host app.
58+
_sdl.SetHint("SDL_NO_SIGNAL_HANDLERS", "1");
59+
60+
// Initialize only the video subsystem so SDL can manage mouse state.
61+
int result = _sdl.Init(Sdl.InitVideo);
62+
if (result != 0) {
63+
_sdl.Dispose();
64+
_sdl = null;
65+
return false;
66+
}
67+
68+
_initialized = true;
69+
return true;
70+
}
71+
72+
/// <summary>
73+
/// Enables SDL relative mouse mode.
74+
/// The cursor is hidden and mouse motion is reported as relative deltas only.
75+
/// </summary>
76+
/// <returns><see langword="true"/> if relative mode was enabled successfully.</returns>
77+
public bool EnableCapture() {
78+
if (!_initialized || _isCaptured || _sdl is null) {
79+
return false;
80+
}
81+
82+
int result = _sdl.SetRelativeMouseMode(SdlBool.True);
83+
if (result != 0) {
84+
return false;
85+
}
86+
87+
_isCaptured = true;
88+
return true;
89+
}
90+
91+
/// <summary>
92+
/// Disables SDL relative mouse mode, restoring normal cursor behaviour.
93+
/// </summary>
94+
/// <returns><see langword="true"/> if relative mode was disabled successfully.</returns>
95+
public bool DisableCapture() {
96+
if (!_initialized || !_isCaptured || _sdl is null) {
97+
return false;
98+
}
99+
100+
int result = _sdl.SetRelativeMouseMode(SdlBool.False);
101+
if (result != 0) {
102+
return false;
103+
}
104+
105+
_isCaptured = false;
106+
return true;
107+
}
108+
109+
/// <summary>
110+
/// Retrieves the accumulated relative mouse motion since the last call.
111+
/// Analogous to SDL_GetRelativeMouseState used in dosbox-staging.
112+
/// </summary>
113+
/// <param name="dx">Horizontal delta in screen pixels (positive = right).</param>
114+
/// <param name="dy">Vertical delta in screen pixels (positive = down).</param>
115+
public void GetRelativeMouseDelta(out int dx, out int dy) {
116+
if (!_initialized || _sdl is null) {
117+
dx = 0;
118+
dy = 0;
119+
return;
120+
}
121+
122+
// SDL_PumpEvents processes pending OS events so GetRelativeMouseState is up to date.
123+
_sdl.PumpEvents();
124+
125+
int x = 0;
126+
int y = 0;
127+
unsafe {
128+
_sdl.GetRelativeMouseState(&x, &y);
129+
}
130+
131+
dx = x;
132+
dy = y;
133+
}
134+
135+
/// <inheritdoc/>
136+
public void Dispose() {
137+
if (_disposed) {
138+
return;
139+
}
140+
141+
_disposed = true;
142+
143+
if (_sdl is null) {
144+
return;
145+
}
146+
147+
if (_isCaptured) {
148+
_sdl.SetRelativeMouseMode(SdlBool.False);
149+
_isCaptured = false;
150+
}
151+
152+
if (_initialized) {
153+
_sdl.QuitSubSystem(Sdl.InitVideo);
154+
_initialized = false;
155+
}
156+
157+
_sdl.Dispose();
158+
_sdl = null;
159+
}
160+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
namespace Spice86.Native;
2+
3+
using System;
4+
5+
/// <summary>
6+
/// Windows-specific mouse capture backend. Uses <c>ClipCursor</c> to confine the cursor
7+
/// to the emulator window, and <c>SetCapture</c>/<c>ReleaseCapture</c> to ensure mouse
8+
/// messages continue reaching the window even when the cursor temporarily touches an edge.
9+
/// Avalonia <c>PointerMoved</c> events still fire with absolute window-relative coordinates
10+
/// — no delta polling is required.
11+
/// </summary>
12+
internal sealed class WindowsMouseCaptureBackend : IMouseCaptureBackend {
13+
private nint _windowHandle;
14+
private bool _isCaptured;
15+
private bool _disposed;
16+
17+
/// <inheritdoc/>
18+
public bool IsCaptured => _isCaptured;
19+
20+
/// <inheritdoc/>
21+
/// <remarks>
22+
/// <see langword="false"/>: the cursor remains visible and Avalonia pointer events carry
23+
/// absolute coordinates. The host window simply clips the cursor to its bounds.
24+
/// </remarks>
25+
public bool UsesRelativeMouseMode => false;
26+
27+
/// <inheritdoc/>
28+
public bool TryInitialize(nint nativeWindowHandle) {
29+
if (nativeWindowHandle == nint.Zero) {
30+
return false;
31+
}
32+
33+
_windowHandle = nativeWindowHandle;
34+
return true;
35+
}
36+
37+
/// <inheritdoc/>
38+
/// <remarks>
39+
/// May be called repeatedly (e.g. on window move/resize) to refresh the clip rectangle.
40+
/// </remarks>
41+
public bool EnableCapture() {
42+
if (_windowHandle == nint.Zero) {
43+
return false;
44+
}
45+
46+
bool gotClientRect = WindowsMouseCaptureInterop.GetClientRect(_windowHandle, out WindowsMouseCaptureInterop.ClipRect clientRect);
47+
if (!gotClientRect) {
48+
return false;
49+
}
50+
51+
WindowsMouseCaptureInterop.WinPoint topLeft = new WindowsMouseCaptureInterop.WinPoint { X = clientRect.Left, Y = clientRect.Top };
52+
WindowsMouseCaptureInterop.WinPoint bottomRight = new WindowsMouseCaptureInterop.WinPoint { X = clientRect.Right, Y = clientRect.Bottom };
53+
54+
bool topLeftConverted = WindowsMouseCaptureInterop.ClientToScreen(_windowHandle, ref topLeft);
55+
if (!topLeftConverted) {
56+
return false;
57+
}
58+
59+
bool bottomRightConverted = WindowsMouseCaptureInterop.ClientToScreen(_windowHandle, ref bottomRight);
60+
if (!bottomRightConverted) {
61+
return false;
62+
}
63+
64+
WindowsMouseCaptureInterop.ClipRect screenRect = new WindowsMouseCaptureInterop.ClipRect {
65+
Left = topLeft.X,
66+
Top = topLeft.Y,
67+
Right = bottomRight.X,
68+
Bottom = bottomRight.Y
69+
};
70+
71+
WindowsMouseCaptureInterop.SetCapture(_windowHandle);
72+
bool clipResult = WindowsMouseCaptureInterop.ClipCursor(ref screenRect);
73+
if (clipResult) {
74+
_isCaptured = true;
75+
}
76+
77+
return clipResult;
78+
}
79+
80+
/// <inheritdoc/>
81+
public bool DisableCapture() {
82+
WindowsMouseCaptureInterop.ReleaseCapture();
83+
bool clipResult = WindowsMouseCaptureInterop.ClipCursor(IntPtr.Zero);
84+
_isCaptured = false;
85+
return clipResult;
86+
}
87+
88+
/// <inheritdoc/>
89+
/// <remarks>
90+
/// Always returns <c>(0, 0)</c>: absolute coordinates come from Avalonia pointer events.
91+
/// </remarks>
92+
public void GetRelativeMouseDelta(out int dx, out int dy) {
93+
dx = 0;
94+
dy = 0;
95+
}
96+
97+
/// <inheritdoc/>
98+
public void Dispose() {
99+
if (_disposed) {
100+
return;
101+
}
102+
103+
_disposed = true;
104+
105+
if (_isCaptured) {
106+
DisableCapture();
107+
}
108+
}
109+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
namespace Spice86.Native;
2+
3+
using System;
4+
using System.Runtime.InteropServices;
5+
6+
/// <summary>
7+
/// Windows user32.dll P/Invoke declarations used by <see cref="WindowsMouseCaptureBackend"/>.
8+
/// </summary>
9+
internal static partial class WindowsMouseCaptureInterop {
10+
/// <summary>Screen-coordinate rectangle used by <see cref="ClipCursor(ref ClipRect)"/>.</summary>
11+
[StructLayout(LayoutKind.Sequential)]
12+
internal struct ClipRect {
13+
public int Left;
14+
public int Top;
15+
public int Right;
16+
public int Bottom;
17+
}
18+
19+
/// <summary>Point structure used by <see cref="ClientToScreen"/>.</summary>
20+
[StructLayout(LayoutKind.Sequential)]
21+
internal struct WinPoint {
22+
public int X;
23+
public int Y;
24+
}
25+
26+
/// <summary>Confines the cursor to the supplied rectangle (screen coordinates).</summary>
27+
[LibraryImport("user32.dll", SetLastError = true, EntryPoint = "ClipCursor")]
28+
[return: MarshalAs(UnmanagedType.Bool)]
29+
internal static partial bool ClipCursor(ref ClipRect rect);
30+
31+
/// <summary>Removes the cursor clipping rectangle.</summary>
32+
[LibraryImport("user32.dll", SetLastError = true, EntryPoint = "ClipCursor")]
33+
[return: MarshalAs(UnmanagedType.Bool)]
34+
internal static partial bool ClipCursor(IntPtr rect);
35+
36+
/// <summary>Retrieves the client-area bounding rectangle of the specified window.</summary>
37+
[LibraryImport("user32.dll", SetLastError = true)]
38+
[return: MarshalAs(UnmanagedType.Bool)]
39+
internal static partial bool GetClientRect(IntPtr hWnd, out ClipRect lpRect);
40+
41+
/// <summary>Converts a client-area coordinate to screen coordinates.</summary>
42+
[LibraryImport("user32.dll", SetLastError = true)]
43+
[return: MarshalAs(UnmanagedType.Bool)]
44+
internal static partial bool ClientToScreen(IntPtr hWnd, ref WinPoint lpPoint);
45+
46+
/// <summary>Sets the mouse capture to the specified window.</summary>
47+
[LibraryImport("user32.dll", SetLastError = true)]
48+
internal static partial IntPtr SetCapture(IntPtr hWnd);
49+
50+
/// <summary>Releases mouse capture from a window.</summary>
51+
[LibraryImport("user32.dll", SetLastError = true)]
52+
[return: MarshalAs(UnmanagedType.Bool)]
53+
internal static partial bool ReleaseCapture();
54+
}

0 commit comments

Comments
 (0)