From 0586f2f8817b58a6383696516b185aa5c77d370d Mon Sep 17 00:00:00 2001 From: Tal Rotbart Date: Sat, 25 Jul 2026 13:55:11 +1000 Subject: [PATCH 1/4] macos: reset keyboard state when the window regains focus SDL3 has no DirectInput-style KEY_LOST, so modifier key-up events swallowed while unfocused (Cmd-Tab, Mission Control) left Alt/Ctrl stuck down and silently broke modifier hotkeys. Mirror the Win32 device-loss path by calling resetKeys() on SDL_EVENT_WINDOW_FOCUS_GAINED, and guard refreshAltKeys() against focus events arriving before the message stream exists. Co-Authored-By: Claude Fable 5 --- Core/GameEngine/Source/GameClient/Input/Keyboard.cpp | 4 ++++ Generals/Code/GameEngineDevice/Source/SDL3GameEngine.cpp | 7 +++++++ GeneralsMD/Code/GameEngineDevice/Source/SDL3GameEngine.cpp | 7 +++++++ 3 files changed, 18 insertions(+) diff --git a/Core/GameEngine/Source/GameClient/Input/Keyboard.cpp b/Core/GameEngine/Source/GameClient/Input/Keyboard.cpp index ea1c5e442..659dfe727 100644 --- a/Core/GameEngine/Source/GameClient/Input/Keyboard.cpp +++ b/Core/GameEngine/Source/GameClient/Input/Keyboard.cpp @@ -769,6 +769,10 @@ void Keyboard::resetKeys() //------------------------------------------------------------------------------------------------- void Keyboard::refreshAltKeys() const { + // GeneralsX @bugfix 10/07/2026 Guard against focus events arriving before the message stream exists. + if (TheMessageStream == nullptr) + return; + if (BitIsSet(m_keyStatus[KEY_LALT].state, KEY_STATE_DOWN)) { GameMessage* msg = TheMessageStream->appendMessage(GameMessage::MSG_RAW_KEY_UP); diff --git a/Generals/Code/GameEngineDevice/Source/SDL3GameEngine.cpp b/Generals/Code/GameEngineDevice/Source/SDL3GameEngine.cpp index 71d57e57b..dce07fdb1 100644 --- a/Generals/Code/GameEngineDevice/Source/SDL3GameEngine.cpp +++ b/Generals/Code/GameEngineDevice/Source/SDL3GameEngine.cpp @@ -276,6 +276,13 @@ void SDL3GameEngine::pollSDL3Events(void) case SDL_EVENT_WINDOW_FOCUS_GAINED: m_IsActive = true; + // GeneralsX @bugfix 10/07/2026 SDL3 has no DirectInput-style KEY_LOST, so modifier + // key-up events swallowed while unfocused (Cmd-Tab, Mission Control) leave Alt/Ctrl + // stuck down and silently break modifier hotkeys like Ctrl+number. Mirror the Win32 + // device-loss path by resetting keyboard state when focus returns. + if (TheKeyboard) { + TheKeyboard->resetKeys(); + } break; case SDL_EVENT_WINDOW_FOCUS_LOST: diff --git a/GeneralsMD/Code/GameEngineDevice/Source/SDL3GameEngine.cpp b/GeneralsMD/Code/GameEngineDevice/Source/SDL3GameEngine.cpp index 81d83a4cb..8ff37e9fb 100644 --- a/GeneralsMD/Code/GameEngineDevice/Source/SDL3GameEngine.cpp +++ b/GeneralsMD/Code/GameEngineDevice/Source/SDL3GameEngine.cpp @@ -599,6 +599,13 @@ void SDL3GameEngine::pollSDL3Events(void) case SDL_EVENT_WINDOW_FOCUS_GAINED: m_IsActive = true; + // GeneralsX @bugfix 10/07/2026 SDL3 has no DirectInput-style KEY_LOST, so modifier + // key-up events swallowed while unfocused (Cmd-Tab, Mission Control) leave Alt/Ctrl + // stuck down and silently break modifier hotkeys like Ctrl+number. Mirror the Win32 + // device-loss path by resetting keyboard state when focus returns. + if (TheKeyboard) { + TheKeyboard->resetKeys(); + } if (TheMouse) { TheMouse->regainFocus(); TheMouse->refreshCursorCapture(); From 0a2e4f9f901f59f127408290a8f0b01c02540755 Mon Sep 17 00:00:00 2001 From: Tal Rotbart Date: Sat, 25 Jul 2026 13:55:52 +1000 Subject: [PATCH 2/4] sagepatch: move window snap from Ctrl+1..5 to Cmd+Option+1..5 SagePatch's SDL_PollEvent interposer consumed Ctrl+1..5 for window snapping, which silently swallowed the game's control-group assignment hotkeys on every launch that loads the patch. Cmd+Option+number is unbound in the game (Cmd now acts as Ctrl in-game, so bare Cmd+number must stay free for control groups too). Co-Authored-By: Claude Fable 5 --- Patches/SagePatch/src/common/Init.cpp | 2 +- Patches/SagePatch/src/common/KeyHandler.cpp | 16 +++++++++++----- Patches/SagePatch/src/common/WindowPosition.cpp | 2 +- docs/PATCHES/SAGEPATCH.md | 2 +- scripts/build/macos/deploy-macos-zh.sh | 2 +- 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/Patches/SagePatch/src/common/Init.cpp b/Patches/SagePatch/src/common/Init.cpp index 847740f1d..eaa12127f 100644 --- a/Patches/SagePatch/src/common/Init.cpp +++ b/Patches/SagePatch/src/common/Init.cpp @@ -28,7 +28,7 @@ void init() { SAGEPATCH_LOG(" F11 screenshot (PNG to ~/Pictures/GeneralsX)"); SAGEPATCH_LOG(" Scroll Lock toggle cursor lock"); SAGEPATCH_LOG(" Ctrl+PageUp/Dn brightness +/-"); - SAGEPATCH_LOG(" Ctrl+1..5 window position (center / TL / TR / BL / BR)"); + SAGEPATCH_LOG(" Cmd+Option+1..5 window position (center / TL / TR / BL / BR)"); } void shutdown() {} diff --git a/Patches/SagePatch/src/common/KeyHandler.cpp b/Patches/SagePatch/src/common/KeyHandler.cpp index 8fe87adf4..8e6b5319c 100644 --- a/Patches/SagePatch/src/common/KeyHandler.cpp +++ b/Patches/SagePatch/src/common/KeyHandler.cpp @@ -11,6 +11,12 @@ bool handleKeyDown(const SDL_KeyboardEvent& ev) { if (!window) return false; const bool ctrl = (ev.mod & SDL_KMOD_CTRL) != 0; + // Window snap uses Cmd+Option: the game binds Ctrl+1..0 to control groups, + // and on macOS Cmd also acts as Ctrl in-game, so any bare-modifier+number + // combo would shadow them. Cmd+Option+number is unbound in the game. + const bool gui = (ev.mod & SDL_KMOD_GUI) != 0; + const bool alt = (ev.mod & SDL_KMOD_ALT) != 0; + const bool snapMod = gui && alt; switch (ev.key) { case SDLK_F11: @@ -29,19 +35,19 @@ bool handleKeyDown(const SDL_KeyboardEvent& ev) { break; case SDLK_1: - if (ctrl) { moveWindow(window, WindowPosition::Center); return true; } + if (snapMod) { moveWindow(window, WindowPosition::Center); return true; } break; case SDLK_2: - if (ctrl) { moveWindow(window, WindowPosition::TopLeft); return true; } + if (snapMod) { moveWindow(window, WindowPosition::TopLeft); return true; } break; case SDLK_3: - if (ctrl) { moveWindow(window, WindowPosition::TopRight); return true; } + if (snapMod) { moveWindow(window, WindowPosition::TopRight); return true; } break; case SDLK_4: - if (ctrl) { moveWindow(window, WindowPosition::BottomLeft); return true; } + if (snapMod) { moveWindow(window, WindowPosition::BottomLeft); return true; } break; case SDLK_5: - if (ctrl) { moveWindow(window, WindowPosition::BottomRight); return true; } + if (snapMod) { moveWindow(window, WindowPosition::BottomRight); return true; } break; default: diff --git a/Patches/SagePatch/src/common/WindowPosition.cpp b/Patches/SagePatch/src/common/WindowPosition.cpp index 95e915ed0..89816d78d 100644 --- a/Patches/SagePatch/src/common/WindowPosition.cpp +++ b/Patches/SagePatch/src/common/WindowPosition.cpp @@ -1,4 +1,4 @@ -// Window position presets — Ctrl+1..5 to snap the window to common positions +// Window position presets — Cmd+Option+1..5 to snap the window to common positions // on the active display. SDL3 does the work; works on every platform. #include "SagePatch/Features.h" diff --git a/docs/PATCHES/SAGEPATCH.md b/docs/PATCHES/SAGEPATCH.md index 1d1dca009..2db72df2e 100644 --- a/docs/PATCHES/SAGEPATCH.md +++ b/docs/PATCHES/SAGEPATCH.md @@ -98,7 +98,7 @@ Game process (GeneralsXZH) │ └── SDL_PollEvent gets replaced (interpose table on macOS, │ symbol override + dlsym RTLD_NEXT on Linux) │ │ - │ └── F11, Scroll Lock, Ctrl+PgUp/Dn, Ctrl+1..5 → SagePatch handlers + │ └── F11, Scroll Lock, Ctrl+PgUp/Dn, Cmd+Option+1..5 → SagePatch handlers │ │ │ └── Per-platform: screencapture / ImageMagick, │ CoreGraphics gamma / XF86VidMode, SDL_SetWindowPosition diff --git a/scripts/build/macos/deploy-macos-zh.sh b/scripts/build/macos/deploy-macos-zh.sh index 68b71f200..30c26177b 100755 --- a/scripts/build/macos/deploy-macos-zh.sh +++ b/scripts/build/macos/deploy-macos-zh.sh @@ -192,7 +192,7 @@ export DYLD_LIBRARY_PATH="\${SCRIPT_DIR}:\${DYLD_LIBRARY_PATH:-}" # SagePatch (optional QoL features). Loaded via DYLD_INSERT_LIBRARIES so it # can interpose SDL3 functions for hot-keys (F11 screenshot, Scroll Lock cursor -# lock, Ctrl+PageUp/PageDown brightness, Ctrl+1..5 window snap). +# lock, Ctrl+PageUp/PageDown brightness, Cmd+Option+1..5 window snap). if [[ -f "\${SCRIPT_DIR}/libsage_patch.dylib" && "\${SAGE_PATCH_DISABLED:-0}" != "1" ]]; then if [[ -n "\${DYLD_INSERT_LIBRARIES:-}" ]]; then export DYLD_INSERT_LIBRARIES="\${SCRIPT_DIR}/libsage_patch.dylib:\${DYLD_INSERT_LIBRARIES}" From 4420f299fab5637cb314ea6d5f6b0212fba91c40 Mon Sep 17 00:00:00 2001 From: Tal Rotbart Date: Sat, 25 Jul 2026 13:55:52 +1000 Subject: [PATCH 3/4] macos: map Cmd to Ctrl so Mac-idiomatic hotkey combos work Cmd+1..0 now assigns/uses control groups just like Ctrl+1..0, and Cmd works for every other Ctrl combo (force attack, etc). The physical Ctrl key keeps working unchanged. macOS only: on Linux the GUI scancode is the desktop's Super key and stays unmapped. Co-Authored-By: Claude Fable 5 --- .../Source/SDL3Device/GameClient/SDL3Keyboard.cpp | 7 +++++++ .../Source/SDL3Device/GameClient/SDL3Keyboard.cpp | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/Generals/Code/GameEngineDevice/Source/SDL3Device/GameClient/SDL3Keyboard.cpp b/Generals/Code/GameEngineDevice/Source/SDL3Device/GameClient/SDL3Keyboard.cpp index 8f2fa9d35..803f92665 100644 --- a/Generals/Code/GameEngineDevice/Source/SDL3Device/GameClient/SDL3Keyboard.cpp +++ b/Generals/Code/GameEngineDevice/Source/SDL3Device/GameClient/SDL3Keyboard.cpp @@ -269,6 +269,13 @@ KeyVal SDL3Keyboard::translateScanCodeToKeyVal(unsigned char scan) case SDL_SCANCODE_RSHIFT: return KEY_RSHIFT; case SDL_SCANCODE_LCTRL: return KEY_LCTRL; // GeneralsX @bugfix BenderAI 13/02/2026 Fix key constant name case SDL_SCANCODE_RCTRL: return KEY_RCTRL; // GeneralsX @bugfix BenderAI 13/02/2026 Fix key constant name +#ifdef __APPLE__ + // GeneralsX @feature 22/07/2026 Cmd acts as Ctrl so Mac-idiomatic combos like + // Cmd+1 work for control groups. Physical Ctrl still works too. macOS only: + // on Linux the GUI key is the desktop's Super key and must stay unmapped. + case SDL_SCANCODE_LGUI: return KEY_LCTRL; + case SDL_SCANCODE_RGUI: return KEY_RCTRL; +#endif case SDL_SCANCODE_LALT: return KEY_LALT; // GeneralsX @bugfix BenderAI 13/02/2026 Fix key constant name case SDL_SCANCODE_RALT: return KEY_RALT; // GeneralsX @bugfix BenderAI 13/02/2026 Fix key constant name diff --git a/GeneralsMD/Code/GameEngineDevice/Source/SDL3Device/GameClient/SDL3Keyboard.cpp b/GeneralsMD/Code/GameEngineDevice/Source/SDL3Device/GameClient/SDL3Keyboard.cpp index 77d1fa7e3..9bb349cab 100644 --- a/GeneralsMD/Code/GameEngineDevice/Source/SDL3Device/GameClient/SDL3Keyboard.cpp +++ b/GeneralsMD/Code/GameEngineDevice/Source/SDL3Device/GameClient/SDL3Keyboard.cpp @@ -261,6 +261,13 @@ KeyVal SDL3Keyboard::translateScanCodeToKeyVal(unsigned char scan) case SDL_SCANCODE_RSHIFT: return KEY_RSHIFT; case SDL_SCANCODE_LCTRL: return KEY_LCTRL; // GeneralsX @bugfix BenderAI 13/02/2026 Fix key constant name case SDL_SCANCODE_RCTRL: return KEY_RCTRL; // GeneralsX @bugfix BenderAI 13/02/2026 Fix key constant name +#ifdef __APPLE__ + // GeneralsX @feature 22/07/2026 Cmd acts as Ctrl so Mac-idiomatic combos like + // Cmd+1 work for control groups. Physical Ctrl still works too. macOS only: + // on Linux the GUI key is the desktop's Super key and must stay unmapped. + case SDL_SCANCODE_LGUI: return KEY_LCTRL; + case SDL_SCANCODE_RGUI: return KEY_RCTRL; +#endif case SDL_SCANCODE_LALT: return KEY_LALT; // GeneralsX @bugfix BenderAI 13/02/2026 Fix key constant name case SDL_SCANCODE_RALT: return KEY_RALT; // GeneralsX @bugfix BenderAI 13/02/2026 Fix key constant name From cc938a2dd2733b99ea64eae74338e4cdbebc076f Mon Sep 17 00:00:00 2001 From: Tal Rotbart Date: Sat, 25 Jul 2026 13:56:06 +1000 Subject: [PATCH 4/4] input: pan the camera with trackpad two-finger scroll Trackpad scrolls are distinguished from mouse-wheel notches by their precise fractional deltas and horizontal axis (with a 300ms latch for mid-gesture whole-number ticks), un-flipped per SDL's natural-scrolling direction flag, and carried as two extra arguments on the existing MSG_RAW_MOUSE_WHEEL message so UI listbox scrolling keeps working. LookAtXlat pans via userScrollBy using the same magnitude convention and speed options as the other scroll modes; Alt/Option + scroll zooms, since a trackpad has no discrete wheel, and real mouse wheels keep the classic zoom. Horizontal-only pan events skip the window manager so it does not misread zero vertical spin as a wheel-down. Co-Authored-By: Claude Fable 5 --- Core/GameEngine/Include/GameClient/Mouse.h | 3 ++ .../Source/GameClient/Input/Mouse.cpp | 10 ++++- .../GameClient/MessageStream/LookAtXlat.cpp | 34 +++++++++++++++ .../GameClient/MessageStream/WindowXlat.cpp | 6 +++ .../SDL3Device/GameClient/SDL3Mouse.cpp | 42 ++++++++++++++++++- .../GameClient/MessageStream/LookAtXlat.cpp | 34 +++++++++++++++ .../GameClient/MessageStream/WindowXlat.cpp | 6 +++ .../SDL3Device/GameClient/SDL3Mouse.cpp | 42 ++++++++++++++++++- 8 files changed, 174 insertions(+), 3 deletions(-) diff --git a/Core/GameEngine/Include/GameClient/Mouse.h b/Core/GameEngine/Include/GameClient/Mouse.h index 51817f005..832b22c43 100644 --- a/Core/GameEngine/Include/GameClient/Mouse.h +++ b/Core/GameEngine/Include/GameClient/Mouse.h @@ -104,6 +104,9 @@ struct MouseIO Int wheelPos; /**< mouse wheel position, 0 is no event, + is up/away from user while - is down/toward user */ + Real wheelPanX; /**< precise trackpad scroll delta X; 0 for real wheel events. + Devices without trackpad support leave these at 0. */ + Real wheelPanY; ///< precise trackpad scroll delta Y; 0 for real wheel events ICoord2D deltaPos; ///< overall change in mouse pointer this frame MouseButtonState leftState; // button state: None (no event), Up, Down, DoubleClick diff --git a/Core/GameEngine/Source/GameClient/Input/Mouse.cpp b/Core/GameEngine/Source/GameClient/Input/Mouse.cpp index 2666c34d5..887b32ddc 100644 --- a/Core/GameEngine/Source/GameClient/Input/Mouse.cpp +++ b/Core/GameEngine/Source/GameClient/Input/Mouse.cpp @@ -198,6 +198,8 @@ void Mouse::processMouseEvent( Int index ) m_currMouse.rightEvent = MOUSE_EVENT_NONE; m_currMouse.middleEvent = MOUSE_EVENT_NONE; m_currMouse.wheelPos = 0; + m_currMouse.wheelPanX = 0.0f; + m_currMouse.wheelPanY = 0.0f; // what type of movement commands are we setup for if( m_inputMovesAbsolute == TRUE ) @@ -218,6 +220,8 @@ void Mouse::processMouseEvent( Int index ) // Cumulate Wheel Adjustments m_currMouse.wheelPos += m_mouseEvents[ index ].wheelPos; + m_currMouse.wheelPanX += m_mouseEvents[ index ].wheelPanX; + m_currMouse.wheelPanY += m_mouseEvents[ index ].wheelPanY; // Check Left Mouse State if( m_mouseEvents[ index ].leftState != MBS_None ) @@ -822,13 +826,17 @@ void Mouse::createStreamMessages() // wheel pos msg = nullptr; - if( m_currMouse.wheelPos != 0 ) + if( m_currMouse.wheelPos != 0 || m_currMouse.wheelPanX != 0.0f || m_currMouse.wheelPanY != 0.0f ) { msg = TheMessageStream->appendMessage( GameMessage::MSG_RAW_MOUSE_WHEEL ); msg->appendPixelArgument( m_currMouse.pos ); // TheSuperHackers @bugfix Use float wheel delta to preserve fractional values from touchpad input msg->appendRealArgument( m_currMouse.wheelPos / (Real)MOUSE_WHEEL_DELTA ); msg->appendIntegerArgument( TheKeyboard->getModifierFlags() ); + // GeneralsX @feature 21/07/2026 Precise trackpad pan deltas (both zero for real wheels); + // consumers that only read the first three arguments are unaffected. + msg->appendRealArgument( m_currMouse.wheelPanX ); + msg->appendRealArgument( m_currMouse.wheelPanY ); } } diff --git a/Generals/Code/GameEngine/Source/GameClient/MessageStream/LookAtXlat.cpp b/Generals/Code/GameEngine/Source/GameClient/MessageStream/LookAtXlat.cpp index 1b7109eac..d850849d7 100644 --- a/Generals/Code/GameEngine/Source/GameClient/MessageStream/LookAtXlat.cpp +++ b/Generals/Code/GameEngine/Source/GameClient/MessageStream/LookAtXlat.cpp @@ -416,6 +416,40 @@ GameMessageDisposition LookAtTranslator::translateGameMessage(const GameMessage { m_lastMouseMoveTimeMsec = timeGetTime(); + // GeneralsX @feature 21/07/2026 Trackpad two-finger scroll pans the camera + // (map follows the fingers); Alt/Option + scroll zooms instead, since a + // trackpad has no discrete wheel. Real mouse wheels keep the classic zoom. + Real panX = 0.0f; + Real panY = 0.0f; + if (msg->getArgumentCount() >= 5) + { + panX = msg->getArgument( 3 )->real; + panY = msg->getArgument( 4 )->real; + } + if (panX != 0.0f || panY != 0.0f) + { + const Int modifiers = msg->getArgument( 2 )->integer; + if (modifiers & KEY_STATE_ALT) + { + TheTacticalView->userZoom( -panY * View::ZoomHeightPerSecond ); + } + else if (TheGameLogic->isInGame()) + { + // Match the magnitude convention of the other scroll modes: keyboard + // scroll feeds scrollBy ~SCROLL_AMT * keyboardScrollFactor (~100) per + // frame, so one full-strength trackpad tick (~1.0) maps to that order. + // The user's scroll-speed option scales it like the other modes. + // Signs: SDL wheel Y is positive away from the user; the chosen + // orientation moves the camera with the swipe direction. + const Real trackpadPanFactor = 120.0f * TheGlobalData->m_keyboardScrollFactor; + Coord2D delta; + delta.x = -panX * TheGlobalData->m_horizontalScrollSpeedFactor * trackpadPanFactor; + delta.y = panY * TheGlobalData->m_verticalScrollSpeedFactor * trackpadPanFactor; + TheTacticalView->userScrollBy( &delta ); + } + break; + } + const Real spin = msg->getArgument( 1 )->real; const Real zoom = -spin * View::ZoomHeightPerSecond; TheTacticalView->userZoom(zoom); diff --git a/Generals/Code/GameEngine/Source/GameClient/MessageStream/WindowXlat.cpp b/Generals/Code/GameEngine/Source/GameClient/MessageStream/WindowXlat.cpp index 863b1da61..152a0bfb0 100644 --- a/Generals/Code/GameEngine/Source/GameClient/MessageStream/WindowXlat.cpp +++ b/Generals/Code/GameEngine/Source/GameClient/MessageStream/WindowXlat.cpp @@ -268,6 +268,12 @@ GameMessageDisposition WindowTranslator::translateGameMessage(const GameMessage // get wheel position Real wheelPos = msg->getArgument( 1 )->real; + // GeneralsX @feature 21/07/2026 Horizontal-only trackpad pan carries no + // vertical spin; UI windows have nothing to do with it, so let it pass + // through to the camera translator instead of faking a wheel direction. + if( wheelPos == 0.0f ) + break; + // process wheel event GameWindowMessage gwm = rawMouseToWindowMessage( msg ); if( TheWindowManager ) diff --git a/Generals/Code/GameEngineDevice/Source/SDL3Device/GameClient/SDL3Mouse.cpp b/Generals/Code/GameEngineDevice/Source/SDL3Device/GameClient/SDL3Mouse.cpp index e759fc424..e7b02f62b 100644 --- a/Generals/Code/GameEngineDevice/Source/SDL3Device/GameClient/SDL3Mouse.cpp +++ b/Generals/Code/GameEngineDevice/Source/SDL3Device/GameClient/SDL3Mouse.cpp @@ -31,6 +31,7 @@ #include "SDL3Device/GameClient/SDL3Mouse.h" #include #include +#include // GeneralsX @bugfix felipebraz 18/02/2026 Include GameLogic for frame tracking #include "GameLogic/GameLogic.h" @@ -756,9 +757,43 @@ void SDL3Mouse::translateWheelEvent(const SDL_MouseWheelEvent& event, MouseIO *r // GeneralsX @bugfix felipebraz 18/02/2026 Normalize timestamp to milliseconds (SDL3 uses nanoseconds) result->time = (Uint32)(event.timestamp / 1000000); + // GeneralsX @feature 21/07/2026 Trackpad two-finger pan support. + // Honor macOS "natural scrolling": SDL flags flipped values with + // direction == SDL_MOUSEWHEEL_FLIPPED; un-flip so downstream sign + // conventions are stable regardless of the user's system setting. + float wheelX = event.x; + float wheelY = event.y; + if (event.direction == SDL_MOUSEWHEEL_FLIPPED) { + wheelX = -wheelX; + wheelY = -wheelY; + } + + // Distinguish trackpad scrolls from wheel notches: trackpads produce + // precise fractional deltas and/or a horizontal axis, while wheels tick + // in whole notches on Y only. The latch keeps mid-gesture events that + // happen to land on whole numbers classified as trackpad. + const bool preciseNow = (wheelX != 0.0f) || + (wheelY != truncf(wheelY)) || + (wheelY != 0.0f && fabsf(wheelY) < 1.0f); + static Uint64 s_lastPreciseTimestampNs = 0; + bool isTrackpad = preciseNow; + if (preciseNow) { + s_lastPreciseTimestampNs = event.timestamp; + } else if (event.timestamp - s_lastPreciseTimestampNs < 300000000ULL) { // 300 ms + isTrackpad = true; + } + + if (isTrackpad) { + result->wheelPanX = wheelX; + result->wheelPanY = wheelY; + } else { + result->wheelPanX = 0.0f; + result->wheelPanY = 0.0f; + } + // SDL3 wheel: positive = up/away, negative = down/toward user // Multiply by MOUSE_WHEEL_DELTA (120) to match Windows behavior - result->wheelPos = (Int)(event.y * MOUSE_WHEEL_DELTA); + result->wheelPos = (Int)(wheelY * MOUSE_WHEEL_DELTA); result->leftState = MBS_None; result->rightState = MBS_None; @@ -879,6 +914,11 @@ void SDL3Mouse::translateEvent(UnsignedInt eventIndex, MouseIO *result) int rawX = 0, rawY = 0; Uint32 windowID = 0; + // Pan deltas only exist on wheel events; clear them up front so motion and + // button translations never carry a stale pan from a previous buffer slot. + result->wheelPanX = 0.0f; + result->wheelPanY = 0.0f; + // Switch on event type and delegate to appropriate translation method switch (event.type) { case SDL_EVENT_MOUSE_MOTION: diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/LookAtXlat.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/LookAtXlat.cpp index 77a8177e4..ece8ef8cf 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/LookAtXlat.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/LookAtXlat.cpp @@ -415,6 +415,40 @@ GameMessageDisposition LookAtTranslator::translateGameMessage(const GameMessage { m_lastMouseMoveTimeMsec = timeGetTime(); + // GeneralsX @feature 21/07/2026 Trackpad two-finger scroll pans the camera + // (map follows the fingers); Alt/Option + scroll zooms instead, since a + // trackpad has no discrete wheel. Real mouse wheels keep the classic zoom. + Real panX = 0.0f; + Real panY = 0.0f; + if (msg->getArgumentCount() >= 5) + { + panX = msg->getArgument( 3 )->real; + panY = msg->getArgument( 4 )->real; + } + if (panX != 0.0f || panY != 0.0f) + { + const Int modifiers = msg->getArgument( 2 )->integer; + if (modifiers & KEY_STATE_ALT) + { + TheTacticalView->userZoom( -panY * View::ZoomHeightPerSecond ); + } + else if (TheGameLogic->isInGame()) + { + // Match the magnitude convention of the other scroll modes: keyboard + // scroll feeds scrollBy ~SCROLL_AMT * keyboardScrollFactor (~100) per + // frame, so one full-strength trackpad tick (~1.0) maps to that order. + // The user's scroll-speed option scales it like the other modes. + // Signs: SDL wheel Y is positive away from the user; the chosen + // orientation moves the camera with the swipe direction. + const Real trackpadPanFactor = 120.0f * TheGlobalData->m_keyboardScrollFactor; + Coord2D delta; + delta.x = -panX * TheGlobalData->m_horizontalScrollSpeedFactor * trackpadPanFactor; + delta.y = panY * TheGlobalData->m_verticalScrollSpeedFactor * trackpadPanFactor; + TheTacticalView->userScrollBy( &delta ); + } + break; + } + const Real spin = msg->getArgument( 1 )->real; const Real zoom = -spin * View::ZoomHeightPerSecond; TheTacticalView->userZoom(zoom); diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/WindowXlat.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/WindowXlat.cpp index 6833ce37c..2a031bc79 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/WindowXlat.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/WindowXlat.cpp @@ -286,6 +286,12 @@ GameMessageDisposition WindowTranslator::translateGameMessage(const GameMessage // get wheel position Real wheelPos = msg->getArgument( 1 )->real; + // GeneralsX @feature 21/07/2026 Horizontal-only trackpad pan carries no + // vertical spin; UI windows have nothing to do with it, so let it pass + // through to the camera translator instead of faking a wheel direction. + if( wheelPos == 0.0f ) + break; + // process wheel event GameWindowMessage gwm = rawMouseToWindowMessage( msg ); if( TheWindowManager ) diff --git a/GeneralsMD/Code/GameEngineDevice/Source/SDL3Device/GameClient/SDL3Mouse.cpp b/GeneralsMD/Code/GameEngineDevice/Source/SDL3Device/GameClient/SDL3Mouse.cpp index 47953aa03..e464f3738 100644 --- a/GeneralsMD/Code/GameEngineDevice/Source/SDL3Device/GameClient/SDL3Mouse.cpp +++ b/GeneralsMD/Code/GameEngineDevice/Source/SDL3Device/GameClient/SDL3Mouse.cpp @@ -31,6 +31,7 @@ #include "SDL3Device/GameClient/SDL3Mouse.h" #include #include +#include // GeneralsX @bugfix felipebraz 18/02/2026 Include GameLogic for frame tracking #include "GameLogic/GameLogic.h" @@ -755,9 +756,43 @@ void SDL3Mouse::translateWheelEvent(const SDL_MouseWheelEvent& event, MouseIO *r // GeneralsX @bugfix felipebraz 18/02/2026 Normalize timestamp to milliseconds (SDL3 uses nanoseconds) result->time = (Uint32)(event.timestamp / 1000000); + // GeneralsX @feature 21/07/2026 Trackpad two-finger pan support. + // Honor macOS "natural scrolling": SDL flags flipped values with + // direction == SDL_MOUSEWHEEL_FLIPPED; un-flip so downstream sign + // conventions are stable regardless of the user's system setting. + float wheelX = event.x; + float wheelY = event.y; + if (event.direction == SDL_MOUSEWHEEL_FLIPPED) { + wheelX = -wheelX; + wheelY = -wheelY; + } + + // Distinguish trackpad scrolls from wheel notches: trackpads produce + // precise fractional deltas and/or a horizontal axis, while wheels tick + // in whole notches on Y only. The latch keeps mid-gesture events that + // happen to land on whole numbers classified as trackpad. + const bool preciseNow = (wheelX != 0.0f) || + (wheelY != truncf(wheelY)) || + (wheelY != 0.0f && fabsf(wheelY) < 1.0f); + static Uint64 s_lastPreciseTimestampNs = 0; + bool isTrackpad = preciseNow; + if (preciseNow) { + s_lastPreciseTimestampNs = event.timestamp; + } else if (event.timestamp - s_lastPreciseTimestampNs < 300000000ULL) { // 300 ms + isTrackpad = true; + } + + if (isTrackpad) { + result->wheelPanX = wheelX; + result->wheelPanY = wheelY; + } else { + result->wheelPanX = 0.0f; + result->wheelPanY = 0.0f; + } + // SDL3 wheel: positive = up/away, negative = down/toward user // Multiply by MOUSE_WHEEL_DELTA (120) to match Windows behavior - result->wheelPos = (Int)(event.y * MOUSE_WHEEL_DELTA); + result->wheelPos = (Int)(wheelY * MOUSE_WHEEL_DELTA); result->leftState = MBS_None; result->rightState = MBS_None; @@ -878,6 +913,11 @@ void SDL3Mouse::translateEvent(UnsignedInt eventIndex, MouseIO *result) int rawX = 0, rawY = 0; Uint32 windowID = 0; + // Pan deltas only exist on wheel events; clear them up front so motion and + // button translations never carry a stale pan from a previous buffer slot. + result->wheelPanX = 0.0f; + result->wheelPanY = 0.0f; + // Switch on event type and delegate to appropriate translation method switch (event.type) { case SDL_EVENT_MOUSE_MOTION: