From ad9138470478969c9e2c5329943385e99434dda9 Mon Sep 17 00:00:00 2001
From: Edgar J San Martin <[EMAIL REDACTED]>
Date: Tue, 30 Dec 2025 17:15:47 -0500
Subject: [PATCH] Base GCMouse raw input implementation
Fix duplicate button/scroll events when GCMouse active
Fix duplicate events and add thread-safe atomic for GCMouse
Fix GCMouse relative mode sync when connected after mode enabled
Respect SDL_HINT_MOUSE_RELATIVE_SYSTEM_SCALE in GCMouse handler
Fix variable shadowing in GCMouse motion handler
---
docs/README-macos.md | 16 ++
src/video/cocoa/SDL_cocoamouse.h | 5 +
src/video/cocoa/SDL_cocoamouse.m | 257 +++++++++++++++++++++++++++++-
src/video/cocoa/SDL_cocoavideo.m | 11 +-
src/video/cocoa/SDL_cocoawindow.m | 5 +
5 files changed, 284 insertions(+), 10 deletions(-)
diff --git a/docs/README-macos.md b/docs/README-macos.md
index 147174c015e4c..0a97b3d5b9d4f 100644
--- a/docs/README-macos.md
+++ b/docs/README-macos.md
@@ -246,6 +246,22 @@ You are free to modify your Cocoa app with generally no consequence
to SDL. You cannot, however, easily change the SDL window itself.
Functionality may be added in the future to help this.
+
+## Raw Mouse Input
+
+On macOS 11.0 (Big Sur) and later, SDL uses the Game Controller framework's
+GCMouse API to provide raw, unaccelerated mouse input in relative mode. This
+is ideal for games and applications requiring precise 1:1 mouse movement.
+
+On older macOS versions, SDL falls back to NSEvent-based mouse input, which
+includes system mouse acceleration.
+
+To use accelerated (system-scaled) mouse movement on macOS 11.0+, set the hint:
+
+```c
+SDL_SetHint(SDL_HINT_MOUSE_RELATIVE_SYSTEM_SCALE, "1");
+```
+
# Bug reports
Bugs are tracked at [the GitHub issue tracker](https://github.com/libsdl-org/SDL/issues/).
diff --git a/src/video/cocoa/SDL_cocoamouse.h b/src/video/cocoa/SDL_cocoamouse.h
index cc2711a12a029..d9ce0e841d0fd 100644
--- a/src/video/cocoa/SDL_cocoamouse.h
+++ b/src/video/cocoa/SDL_cocoamouse.h
@@ -32,6 +32,11 @@ extern void Cocoa_HandleMouseWheel(SDL_Window *window, NSEvent *event);
extern void Cocoa_HandleMouseWarp(CGFloat x, CGFloat y);
extern void Cocoa_QuitMouse(SDL_VideoDevice *_this);
+extern void Cocoa_InitGCMouse(void);
+extern bool Cocoa_GCMouseRelativeMode(void);
+extern bool Cocoa_HasGCMouse(void);
+extern void Cocoa_QuitGCMouse(void);
+
struct SDL_CursorData
{
NSTimer *frameTimer;
diff --git a/src/video/cocoa/SDL_cocoamouse.m b/src/video/cocoa/SDL_cocoamouse.m
index 17fb24452c31b..88f0744d39f96 100644
--- a/src/video/cocoa/SDL_cocoamouse.m
+++ b/src/video/cocoa/SDL_cocoamouse.m
@@ -27,6 +27,8 @@
#include "../../events/SDL_mouse_c.h"
+#import <GameController/GameController.h>
+
#if 0
#define DEBUG_COCOAMOUSE
#endif
@@ -254,6 +256,219 @@ + (NSCursor *)invisibleCursor
return Cocoa_CreateSystemCursor(id);
}
+// GCMouse support for raw (unaccelerated) mouse input on macOS 11.0+
+static id cocoa_mouse_connect_observer = nil;
+static id cocoa_mouse_disconnect_observer = nil;
+// Atomic for thread-safe access during high-frequency mouse input
+static SDL_AtomicInt cocoa_gcmouse_relative_mode;
+static bool cocoa_has_gcmouse = false;
+static SDL_MouseWheelDirection cocoa_mouse_scroll_direction = SDL_MOUSEWHEEL_NORMAL;
+
+static void Cocoa_UpdateGCMouseScrollDirection(void)
+{
+ Boolean keyExistsAndHasValidFormat = NO;
+ Boolean naturalScrollDirection = CFPreferencesGetAppBooleanValue(
+ CFSTR("com.apple.swipescrolldirection"),
+ kCFPreferencesAnyApplication,
+ &keyExistsAndHasValidFormat);
+ if (!keyExistsAndHasValidFormat) {
+ // Couldn't read the preference, assume natural scrolling direction
+ naturalScrollDirection = YES;
+ }
+ if (naturalScrollDirection) {
+ cocoa_mouse_scroll_direction = SDL_MOUSEWHEEL_FLIPPED;
+ } else {
+ cocoa_mouse_scroll_direction = SDL_MOUSEWHEEL_NORMAL;
+ }
+}
+
+static bool Cocoa_SetGCMouseRelativeMode(bool enabled)
+{
+ SDL_SetAtomicInt(&cocoa_gcmouse_relative_mode, enabled ? 1 : 0);
+ return true;
+}
+
+static void Cocoa_OnGCMouseButtonChanged(SDL_MouseID mouseID, Uint8 button,
+ BOOL pressed)
+{
+ Uint64 timestamp = SDL_GetTicksNS();
+ SDL_SendMouseButton(timestamp, SDL_GetMouseFocus(), mouseID, button,
+ pressed);
+}
+
+static void Cocoa_OnGCMouseConnected(GCMouse *mouse)
+ API_AVAILABLE(macos(11.0))
+{
+ SDL_MouseID mouseID = (SDL_MouseID)(uintptr_t)mouse;
+
+ SDL_AddMouse(mouseID, NULL);
+ cocoa_has_gcmouse = true;
+
+ // Sync with SDL's current relative mode state (may have been set before
+ // GCMouse connected)
+ SDL_Mouse *sdl_mouse = SDL_GetMouse();
+ if (sdl_mouse && sdl_mouse->relative_mode) {
+ SDL_SetAtomicInt(&cocoa_gcmouse_relative_mode, 1);
+ }
+
+ mouse.mouseInput.leftButton.pressedChangedHandler =
+ ^(GCControllerButtonInput *button, float value, BOOL pressed) {
+ Cocoa_OnGCMouseButtonChanged(mouseID, SDL_BUTTON_LEFT, pressed);
+ };
+ mouse.mouseInput.middleButton.pressedChangedHandler =
+ ^(GCControllerButtonInput *button, float value, BOOL pressed) {
+ Cocoa_OnGCMouseButtonChanged(mouseID, SDL_BUTTON_MIDDLE, pressed);
+ };
+ mouse.mouseInput.rightButton.pressedChangedHandler =
+ ^(GCControllerButtonInput *button, float value, BOOL pressed) {
+ Cocoa_OnGCMouseButtonChanged(mouseID, SDL_BUTTON_RIGHT, pressed);
+ };
+
+ int auxiliary_button = SDL_BUTTON_X1;
+ for (GCControllerButtonInput *btn in mouse.mouseInput.auxiliaryButtons) {
+ const int current_button = auxiliary_button;
+ btn.pressedChangedHandler =
+ ^(GCControllerButtonInput *button, float value, BOOL pressed) {
+ Cocoa_OnGCMouseButtonChanged(mouseID, current_button, pressed);
+ };
+ ++auxiliary_button;
+ }
+
+ mouse.mouseInput.mouseMovedHandler =
+ ^(GCMouseInput *mouseInput, float deltaX, float deltaY) {
+ if (Cocoa_GCMouseRelativeMode()) {
+ // Skip raw input if user wants system-scaled (accelerated) deltas
+ SDL_Mouse *m = SDL_GetMouse();
+ if (m && m->enable_relative_system_scale) {
+ return;
+ }
+ Uint64 timestamp = SDL_GetTicksNS();
+ SDL_SendMouseMotion(timestamp, SDL_GetMouseFocus(), mouseID,
+ true, deltaX, -deltaY);
+ }
+ };
+
+ mouse.mouseInput.scroll.valueChangedHandler =
+ ^(GCControllerDirectionPad *dpad, float xValue, float yValue) {
+ Uint64 timestamp = SDL_GetTicksNS();
+ // Raw scroll values: vertical in first axis, horizontal in second.
+ // Vertical values are inverted compared to SDL conventions.
+ float vertical = -xValue;
+ float horizontal = yValue;
+
+ if (cocoa_mouse_scroll_direction == SDL_MOUSEWHEEL_FLIPPED) {
+ vertical = -vertical;
+ horizontal = -horizontal;
+ }
+ SDL_SendMouseWheel(timestamp, SDL_GetMouseFocus(), mouseID,
+ horizontal, vertical,
+ cocoa_mouse_scroll_direction);
+ };
+ Cocoa_UpdateGCMouseScrollDirection();
+
+ // Use high-priority queue for low-latency input
+ dispatch_queue_t queue = dispatch_queue_create("org.libsdl.input.mouse",
+ DISPATCH_QUEUE_SERIAL);
+ dispatch_set_target_queue(queue,
+ dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0));
+ mouse.handlerQueue = queue;
+}
+
+static void Cocoa_OnGCMouseDisconnected(GCMouse *mouse)
+ API_AVAILABLE(macos(11.0))
+{
+ SDL_MouseID mouseID = (SDL_MouseID)(uintptr_t)mouse;
+
+ mouse.mouseInput.mouseMovedHandler = nil;
+ mouse.mouseInput.leftButton.pressedChangedHandler = nil;
+ mouse.mouseInput.middleButton.pressedChangedHandler = nil;
+ mouse.mouseInput.rightButton.pressedChangedHandler = nil;
+ mouse.mouseInput.scroll.valueChangedHandler = nil;
+
+ for (GCControllerButtonInput *button in mouse.mouseInput.auxiliaryButtons) {
+ button.pressedChangedHandler = nil;
+ }
+
+ SDL_RemoveMouse(mouseID);
+
+ // Check if any GCMouse devices remain
+ if (@available(macOS 11.0, *)) {
+ cocoa_has_gcmouse = ([GCMouse mice].count > 0);
+ }
+}
+
+void Cocoa_InitGCMouse(void)
+{
+ @autoreleasepool {
+ if (@available(macOS 11.0, *)) {
+ NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
+
+ cocoa_mouse_connect_observer = [center
+ addObserverForName:GCMouseDidConnectNotification
+ object:nil
+ queue:nil
+ usingBlock:^(NSNotification *note) {
+ GCMouse *mouse = note.object;
+ Cocoa_OnGCMouseConnected(mouse);
+ }];
+
+ cocoa_mouse_disconnect_observer = [center
+ addObserverForName:GCMouseDidDisconnectNotification
+ object:nil
+ queue:nil
+ usingBlock:^(NSNotification *note) {
+ GCMouse *mouse = note.object;
+ Cocoa_OnGCMouseDisconnected(mouse);
+ }];
+
+ // Enumerate already-connected mice
+ for (GCMouse *mouse in [GCMouse mice]) {
+ Cocoa_OnGCMouseConnected(mouse);
+ }
+ }
+ }
+}
+
+bool Cocoa_GCMouseRelativeMode(void)
+{
+ return SDL_GetAtomicInt(&cocoa_gcmouse_relative_mode) != 0;
+}
+
+bool Cocoa_HasGCMouse(void)
+{
+ return cocoa_has_gcmouse;
+}
+
+void Cocoa_QuitGCMouse(void)
+{
+ @autoreleasepool {
+ if (@available(macOS 11.0, *)) {
+ NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
+
+ if (cocoa_mouse_connect_observer) {
+ [center removeObserver:cocoa_mouse_connect_observer
+ name:GCMouseDidConnectNotification
+ object:nil];
+ cocoa_mouse_connect_observer = nil;
+ }
+
+ if (cocoa_mouse_disconnect_observer) {
+ [center removeObserver:cocoa_mouse_disconnect_observer
+ name:GCMouseDidDisconnectNotification
+ object:nil];
+ cocoa_mouse_disconnect_observer = nil;
+ }
+
+ for (GCMouse *mouse in [GCMouse mice]) {
+ Cocoa_OnGCMouseDisconnected(mouse);
+ }
+
+ cocoa_has_gcmouse = false;
+ SDL_SetAtomicInt(&cocoa_gcmouse_relative_mode, 0);
+ }
+ }
+}
+
static void Cocoa_FreeCursor(SDL_Cursor *cursor)
{
@autoreleasepool {
@@ -360,19 +575,29 @@ static bool Cocoa_SetRelativeMouseMode(bool enabled)
{
CGError result;
+ // Update GCMouse relative mode state if available
+ if (Cocoa_HasGCMouse()) {
+ Cocoa_SetGCMouseRelativeMode(enabled);
+ }
+
if (enabled) {
SDL_Window *window = SDL_GetKeyboardFocus();
if (window) {
- /* We will re-apply the relative mode when the window finishes being moved,
- * if it is being moved right now.
+ /* We will re-apply the relative mode when the window finishes
+ * being moved, if it is being moved right now.
*/
- SDL_CocoaWindowData *data = (__bridge SDL_CocoaWindowData *)window->internal;
+ SDL_CocoaWindowData *data =
+ (__bridge SDL_CocoaWindowData *)window->internal;
if ([data.listener isMovingOrFocusClickPending]) {
return true;
}
- // make sure the mouse isn't at the corner of the window, as this can confuse things if macOS thinks a window resize is happening on the first click.
- const CGPoint point = CGPointMake((float)(window->x + (window->w / 2)), (float)(window->y + (window->h / 2)));
+ // Make sure the mouse isn't at the corner of the window, as this
+ // can confuse things if macOS thinks a window resize is happening
+ // on the first click.
+ const CGPoint point = CGPointMake(
+ (float)(window->x + (window->w / 2)),
+ (float)(window->y + (window->h / 2)));
Cocoa_HandleMouseWarp(point.x, point.y);
CGWarpMouseCursorPosition(point);
}
@@ -590,6 +815,17 @@ void Cocoa_HandleMouseEvent(SDL_VideoDevice *_this, NSEvent *event)
return;
}
+ // When GCMouse is active in relative mode, it handles motion events
+ // directly with raw (unaccelerated) deltas. Skip NSEvent-based motion
+ // unless the user wants system-scaled (accelerated) input.
+ if (Cocoa_HasGCMouse() && Cocoa_GCMouseRelativeMode()) {
+ if (!mouse->enable_relative_system_scale) {
+ // GCMouse is providing raw input, skip NSEvent deltas
+ return;
+ }
+ // SYSTEM_SCALE is enabled: use NSEvent accelerated deltas instead
+ }
+
// Ignore events that aren't inside the client area (i.e. title bar.)
if ([event window]) {
NSRect windowRect = [[[event window] contentView] frame];
@@ -606,14 +842,21 @@ void Cocoa_HandleMouseEvent(SDL_VideoDevice *_this, NSEvent *event)
deltaX += (lastMoveX - data->lastWarpX);
deltaY += ((videodata.mainDisplayHeight - lastMoveY) - data->lastWarpY);
- DLog("Motion was (%g, %g), offset to (%g, %g)", [event deltaX], [event deltaY], deltaX, deltaY);
+ DLog("Motion was (%g, %g), offset to (%g, %g)", [event deltaX],
+ [event deltaY], deltaX, deltaY);
}
- SDL_SendMouseMotion(Cocoa_GetEventTimestamp([event timestamp]), mouse->focus, mouseID, true, deltaX, deltaY);
+ SDL_SendMouseMotion(Cocoa_GetEventTimestamp([event timestamp]),
+ mouse->focus, mouseID, true, deltaX, deltaY);
}
void Cocoa_HandleMouseWheel(SDL_Window *window, NSEvent *event)
{
+ // GCMouse handles scroll events directly, skip NSEvent path to avoid duplicates
+ if (Cocoa_HasGCMouse()) {
+ return;
+ }
+
SDL_MouseID mouseID = SDL_DEFAULT_MOUSE_ID;
SDL_MouseWheelDirection direction;
CGFloat x, y;
diff --git a/src/video/cocoa/SDL_cocoavideo.m b/src/video/cocoa/SDL_cocoavideo.m
index fa343aca5d37e..081e162be0920 100644
--- a/src/video/cocoa/SDL_cocoavideo.m
+++ b/src/video/cocoa/SDL_cocoavideo.m
@@ -209,10 +209,14 @@ static bool Cocoa_VideoInit(SDL_VideoDevice *_this)
return false;
}
- // Assume we have a mouse and keyboard
- // We could use GCMouse and GCKeyboard if we needed to, as is done in SDL_uikitevents.m
+ // Initialize GCMouse for raw input on macOS 11.0+
+ Cocoa_InitGCMouse();
+
+ // Add default keyboard and mouse if GCMouse didn't provide any
SDL_AddKeyboard(SDL_DEFAULT_KEYBOARD_ID, NULL);
- SDL_AddMouse(SDL_DEFAULT_MOUSE_ID, NULL);
+ if (!Cocoa_HasGCMouse()) {
+ SDL_AddMouse(SDL_DEFAULT_MOUSE_ID, NULL);
+ }
data.allow_spaces = SDL_GetHintBoolean(SDL_HINT_VIDEO_MAC_FULLSCREEN_SPACES, true);
data.trackpad_is_touch_only = SDL_GetHintBoolean(SDL_HINT_TRACKPAD_IS_TOUCH_ONLY, false);
@@ -233,6 +237,7 @@ void Cocoa_VideoQuit(SDL_VideoDevice *_this)
SDL_CocoaVideoData *data = (__bridge SDL_CocoaVideoData *)_this->internal;
Cocoa_QuitModes(_this);
Cocoa_QuitKeyboard(_this);
+ Cocoa_QuitGCMouse();
Cocoa_QuitMouse(_this);
Cocoa_QuitPen(_this);
SDL_DestroyMutex(data.swaplock);
diff --git a/src/video/cocoa/SDL_cocoawindow.m b/src/video/cocoa/SDL_cocoawindow.m
index 9225c225beb62..79c17713eb7a9 100644
--- a/src/video/cocoa/SDL_cocoawindow.m
+++ b/src/video/cocoa/SDL_cocoawindow.m
@@ -1717,6 +1717,11 @@ - (BOOL)processHitTest:(NSEvent *)theEvent
static void Cocoa_SendMouseButtonClicks(SDL_Mouse *mouse, NSEvent *theEvent, SDL_Window *window, Uint8 button, bool down)
{
+ // GCMouse handles button events directly, skip NSEvent path to avoid duplicates
+ if (Cocoa_HasGCMouse()) {
+ return;
+ }
+
SDL_MouseID mouseID = SDL_DEFAULT_MOUSE_ID;
//const int clicks = (int)[theEvent clickCount];
SDL_Window *focus = SDL_GetKeyboardFocus();