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)