From 663c8238b6e10eeb51f445f7978b055f316e0e98 Mon Sep 17 00:00:00 2001 From: Roman Kitaev <802.11g@bk.ru> Date: Tue, 23 Jun 2026 01:34:41 +0300 Subject: [PATCH 1/2] fix: recover stuck mouselook when regaining window focus on macOS --- .context/patterns.md | 1 + CombatMode/Bindings.xml | 3 ++ CombatMode/Config/ConfigMouseLook.lua | 20 +++++++ CombatMode/Core/FreeLookController.lua | 72 ++++++++++++++++++++++++++ CombatMode/Core/Runtime.lua | 2 + 5 files changed, 98 insertions(+) diff --git a/.context/patterns.md b/.context/patterns.md index 3fc8138..d759796 100644 --- a/.context/patterns.md +++ b/.context/patterns.md @@ -13,6 +13,7 @@ Suggested buckets (map to existing rules): ## CombatMode-specific reminders - **Mouselook / free-look / CVars:** free-look state machine lives in `Core/FreeLookController.lua`; **all addon-owned `SetCVar` writes route through** `Core/RuntimeCVarManager.lua` (`CM.SetCVar*` helpers). Modules should compute/decide values locally, then call the manager to perform the write. Keep enable/disable paths symmetric. +- **macOS stuck-cursor freeze:** a `MouselookStop()` issued while the window is backgrounded (auto-unlock from a watched frame during alt-tab) leaves the OS mouse capture stuck — frozen cursor/camera on return while `IsMouselooking()` lies. No focus event exists. `CM.ResyncMouselookOnFocusRegain` recovers the case where frames halt while backgrounded (fullscreen); when the window keeps rendering (windowed background FPS), `OnUpdate` can't detect it, so the `Combat Mode - Reset Mouse Look` keybind (`CombatMode_ResetMouseLook`, FreeLookController) re-grabs+releases as manual recovery. - **Override bindings / secure buttons:** follow existing `SecureActionButtonTemplate` and `Core/BindingOverrides.lua` patterns. - **Vendored libs:** do not rewrite `CombatMode/Libs/**`; see `.cursor/rules/combatmode-vendored-libs.mdc`. diff --git a/CombatMode/Bindings.xml b/CombatMode/Bindings.xml index eee1053..071a72d 100644 --- a/CombatMode/Bindings.xml +++ b/CombatMode/Bindings.xml @@ -4,6 +4,9 @@ CombatMode_CursorModeKey(keystate) end + + CombatMode_ResetMouseLook() + if keystate == "down" or keystate == "up" then CombatMode_HealingRadialKey(keystate) diff --git a/CombatMode/Config/ConfigMouseLook.lua b/CombatMode/Config/ConfigMouseLook.lua index 4d11e60..7d5d4aa 100644 --- a/CombatMode/Config/ConfigMouseLook.lua +++ b/CombatMode/Config/ConfigMouseLook.lua @@ -188,6 +188,26 @@ CM.Config.MouseLookOptions = { return (GetBindingKey("INTERACTMOUSEOVER")) end, }, + resetMouseLook = { + type = "keybinding", + name = "|cffffd700Reset - |cffE52B50Mouse Look|r|r", + desc = "Recovers a stuck cursor/camera that can happen on |cffffd700macOS|r: when a panel (bags, |cffffd700LFG|r ready check, etc.) turns |cffE52B50Mouse Look|r off while |cffffd700WoW|r is in the background, the |cffffd700OS|r mouse capture is left stuck. On returning the camera is frozen and the cursor is gone.\n\nPress this to re-grab and release the camera, bringing the cursor back.\n\n|cff909090This can't be done automatically: the game exposes no window-focus event and keeps rendering while in the background, so the addon has no way to detect the frozen state from the inside. A manual keypress is the only reliable recovery.|r", + width = 1.15, + order = 5.05, + set = function(_, key) + CM.TryApplyBindingChange("reset mouse look keybinding", function() + local oldKey = (GetBindingKey("Combat Mode - Reset Mouse Look")) + if oldKey then + SetBinding(oldKey) + end + SetBinding(key, "Combat Mode - Reset Mouse Look") + SaveBindings(GetCurrentBindingSet()) + end) + end, + get = function() + return (GetBindingKey("Combat Mode - Reset Mouse Look")) + end, + }, spacing = Spacing("full", 5.1), pulseCursor = { type = "toggle", diff --git a/CombatMode/Core/FreeLookController.lua b/CombatMode/Core/FreeLookController.lua index be80955..cba2f10 100644 --- a/CombatMode/Core/FreeLookController.lua +++ b/CombatMode/Core/FreeLookController.lua @@ -16,6 +16,7 @@ local CM = LibStub("AceAddon-3.0"):GetAddon("CombatMode") -- WoW API local C_Timer = _G.C_Timer local GameTooltip = _G.GameTooltip +local GetFramerate = _G.GetFramerate local GetTime = _G.GetTime local InCinematic = _G.InCinematic local IsInCinematicScene = _G.IsInCinematicScene @@ -24,6 +25,7 @@ local IsMouselooking = _G.IsMouselooking local MouselookStart = _G.MouselookStart local MouselookStop = _G.MouselookStop local SpellIsTargeting = _G.SpellIsTargeting +local time = _G.time -- Lua stdlib local string = _G.string @@ -32,6 +34,13 @@ local string = _G.string local FreeLookOverride = false -- Changes when Free Look state is modified through user input ("Toggle / Hold" keybind and "/cm" cmd) local CursorModeShowTime = 0 -- GetTime() when cursor was unlocked via keybind (for spurious key-up filter) +local FOCUS_REGAIN_GAP_SECONDS = 0.5 +local FOCUS_REGAIN_WALLCLOCK_SECONDS = 2 +local FOCUS_REGAIN_MIN_FPS = 6 +local lastOnUpdateRealTime = 0 +local lastOnUpdateWallTime = 0 +local wasBackgrounded = false + -- This prevents the auto running bug. function CM.IsDefaultMouseActionBeingUsed() return IsMouseButtonDown("LeftButton") or IsMouseButtonDown("RightButton") @@ -218,3 +227,66 @@ function _G.CombatMode_CursorModeKey(keystate) FreeLookOverride = false end end + +-- Manual recovery for the macOS stuck-cursor freeze (a MouselookStop issued while the +-- window was backgrounded leaves the OS capture stuck). Re-grab then release to clear it; +-- OnUpdate re-locks afterward if free look should still be active. +function _G.CombatMode_ResetMouseLook() + MouselookStart() + if C_Timer and C_Timer.After then + C_Timer.After(0, MouselookStop) + else + MouselookStop() + end +end + +-- macOS: a MouselookStart/Stop issued while the window is backgrounded (e.g. an LFG popup +-- auto-unlocks Mouse Look during alt-tab) leaves the OS mouse capture desynced — frozen +-- camera/input on return. No focus event exists, so a slow/halted frame is treated as +-- "backgrounded"; on the first normal frame after one, force a clean mouselook transition +-- to the intended state (Stop→Start to re-grab, or Start→Stop to clear a stuck capture). +local function FrameLooksBackgrounded(realGap, wallGap) + if realGap >= FOCUS_REGAIN_GAP_SECONDS or wallGap >= FOCUS_REGAIN_WALLCLOCK_SECONDS then + return true + end + return (GetFramerate and GetFramerate() or 60) < FOCUS_REGAIN_MIN_FPS +end + +function CM.ResyncMouselookOnFocusRegain() + local now, nowWall = GetTime(), time() + local prev, prevWall = lastOnUpdateRealTime, lastOnUpdateWallTime + lastOnUpdateRealTime, lastOnUpdateWallTime = now, nowWall + if prev == 0 or prevWall == 0 then + return + end + + if FrameLooksBackgrounded(now - prev, nowWall - prevWall) then + wasBackgrounded = true + return + end + if not wasBackgrounded then + return + end + wasBackgrounded = false + + local shouldLook = not (FreeLookOverride or CM.ShouldFreeLookBeOff()) + if shouldLook then + MouselookStop() + else + MouselookStart() + end + local function settle() + if shouldLook then + if not (FreeLookOverride or CM.ShouldFreeLookBeOff()) then + MouselookStart() + end + else + MouselookStop() + end + end + if C_Timer and C_Timer.After then + C_Timer.After(0, settle) + else + settle() + end +end diff --git a/CombatMode/Core/Runtime.lua b/CombatMode/Core/Runtime.lua index e6eb8a1..d46d783 100644 --- a/CombatMode/Core/Runtime.lua +++ b/CombatMode/Core/Runtime.lua @@ -245,6 +245,8 @@ This is (in most cases) extremely excessive, hence why we're adding a throttle. local ON_UPDATE_INTERVAL = 0.15 local TIME_SINCE_LAST_UPDATE = 0 function _G.CombatMode_OnUpdate(_, elapsed) + CM.ResyncMouselookOnFocusRegain() + -- Making this thread-safe by keeping track of the last update cycle TIME_SINCE_LAST_UPDATE = TIME_SINCE_LAST_UPDATE + elapsed From 8e92ad4678df63a7f6502649d120fe1d5d1108b2 Mon Sep 17 00:00:00 2001 From: Roman Kitaev <802.11g@bk.ru> Date: Tue, 23 Jun 2026 01:34:41 +0300 Subject: [PATCH 2/2] perf: cache custom condition compile and short-circuit cursor unlock frame scan --- CombatMode/Core/AutoCursorUnlock.lua | 34 ++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/CombatMode/Core/AutoCursorUnlock.lua b/CombatMode/Core/AutoCursorUnlock.lua index 0d1d12e..60c4c9c 100644 --- a/CombatMode/Core/AutoCursorUnlock.lua +++ b/CombatMode/Core/AutoCursorUnlock.lua @@ -36,13 +36,15 @@ local function CursorUnlockFrameVisible(frameArr) return false end - for _, frameName in pairs(frameArr) do + for _, frameName in ipairs(frameArr) do local curFrame = _G[frameName] if curFrame and curFrame.IsVisible and curFrame:IsVisible() then CM.DebugPrintThrottled("cursorUnlock", frameName .. " is visible, preventing re-locking.") return true end end + + return false end local function CursorUnlockFrameGroupVisible(frameNameGroups) @@ -57,31 +59,43 @@ local function CursorUnlockFrameGroupVisible(frameNameGroups) return true end end + + return false end function CM.IsUnlockFrameVisible() - local isGenericPanelOpen = (GetUIPanel("left") or GetUIPanel("right") or GetUIPanel("center")) - and true - or false + if GetUIPanel("left") or GetUIPanel("right") or GetUIPanel("center") then + return true + end + return CursorUnlockFrameVisible(CM.Constants.FramesToCheck) or CursorUnlockFrameVisible(CM.DB.global.watchlist) or CursorUnlockFrameGroupVisible(CM.Constants.WildcardFramesToCheck) - or isGenericPanelOpen end +local compiledCustomConditionSource = nil +local compiledCustomConditionFunc = nil + function CM.IsCustomConditionTrue() - if not CM.DB.global.customCondition then + local source = CM.DB.global.customCondition + if not source or source == "" then return false end - local func, err = loadstring(CM.DB.global.customCondition) + if source ~= compiledCustomConditionSource then + compiledCustomConditionSource = source + local func, err = loadstring(source) + compiledCustomConditionFunc = func + if not func then + CM.DebugPrint("Invalid custom condition " .. err) + end + end - if not func then - CM.DebugPrint("Invalid custom condition " .. err) + if not compiledCustomConditionFunc then return false end - local success, result = pcall(func) + local success, result = pcall(compiledCustomConditionFunc) if not success then CM.DebugPrint("Error executing custom condition: " .. result)