@@ -1333,6 +1333,7 @@ function CC:StartDiagnosticTicker(frame)
13331333 DF :DebugError (" CLICK" , " BINDINGS VANISHED on %s at tick %d! wrapEnter=%d wrapLeave=%d isSecureMO=%s visible=%s mouseOver=%s combat=%s" ,
13341334 frameName , CC .diagTickCount , wrapEnterCount , wrapLeaveCount , tostring (isSecureMouseover ),
13351335 tostring (frame :IsVisible ()), tostring (frame :IsMouseOver ()), tostring (InCombatLockdown ()))
1336+ CC :RequestBindingRepair (" bindings-vanished" )
13361337 end
13371338
13381339 -- Detect mouseoverbutton desync: we think we're hovering this frame,
@@ -1343,6 +1344,7 @@ function CC:StartDiagnosticTicker(frame)
13431344 CC .diagDesyncReported = true
13441345 DF :DebugError (" CLICK" , " MOUSEOVERBUTTON DESYNC on %s at tick %d! dfIsSecureMouseover=nil wrapEnter=%d wrapLeave=%d kbActive=%s" ,
13451346 frameName , CC .diagTickCount , wrapEnterCount , wrapLeaveCount , tostring (bindingsActive ))
1347+ CC :RequestBindingRepair (" mouseover-desync" )
13461348 end
13471349 else
13481350 CC .diagDesyncReported = nil
@@ -1560,19 +1562,22 @@ function CC:SetupSecureHandlers(frame)
15601562 if wrapEnterFired and postCheck ~= " ok" then
15611563 DF :DebugError (" CLICK" , " POST-CHECK FAILED on %s! mouseoverbutton=%s after phase 7 — self reference lost during OnEnter" ,
15621564 frameName , postCheck )
1565+ CC :RequestBindingRepair (" post-check-" .. postCheck )
15631566 end
15641567
15651568 -- Key diagnostic: WrapScript didn't complete all phases
15661569 if wrapEnterFired and enterPhase < 7 and hasKeyboardBindings then
15671570 DF :DebugError (" CLICK" , " WRAPSCRIPT INCOMPLETE on %s! phase=%d (expected 7) prev=%s" ,
15681571 frameName , enterPhase , prevMouseover )
1572+ CC :RequestBindingRepair (" wrap-incomplete-phase" .. enterPhase )
15691573 end
15701574
15711575 -- Key diagnostic: hover is on but WrapScript didn't activate keyboard bindings
15721576 if hasKeyboardBindings and not bindingsActive then
15731577 DF :DebugWarn (" CLICK" , " HOVER BUT NO KB BINDINGS on %s! Key presses will go to action bar (phase=%d)" , frameName , enterPhase )
15741578 if not wrapEnterFired then
15751579 DF :DebugWarn (" CLICK" , " WrapScript OnEnter DID NOT FIRE (enterCount=%d leaveCount=%d)" , wrapEnterCount , wrapLeaveCount )
1580+ CC :RequestBindingRepair (" wrap-not-firing" )
15761581 DF :DebugWarn (" CLICK" , " frame visible=%s shown=%s mouseOver=%s combat=%s" ,
15771582 tostring (self :IsVisible ()), tostring (self :IsShown ()),
15781583 tostring (self :IsMouseOver ()), tostring (InCombatLockdown ()))
@@ -1590,6 +1595,17 @@ function CC:SetupSecureHandlers(frame)
15901595 tostring (self .dfKeyboardHandlersSetup ), tostring (self .dfClickCastRegistered ))
15911596 end
15921597
1598+ -- Self-heal detection: this frame's snippet is empty but the profile
1599+ -- has keyboard binds — likely wiped by a transient refresh (bug #976).
1600+ -- One-shot per frame: the flag is reset when a rebuild gives the frame
1601+ -- a real snippet again, so legitimately-empty frames don't loop.
1602+ if not hasKeyboardBindings and self .dfIsDandersFrame
1603+ and not self .dfSnippetRepairTried and CC :ProfileHasKeyboardBindings () then
1604+ self .dfSnippetRepairTried = true
1605+ DF :DebugWarn (" CLICK" , " EMPTY SNIPPET on %s but profile has keyboard binds" , frameName )
1606+ CC :RequestBindingRepair (" empty-snippet" )
1607+ end
1608+
15931609 -- Warn if mouse click-cast attributes are missing
15941610 if not type1 or type1 == " " then
15951611 DF :DebugWarn (" CLICK" , " NO TYPE1 on %s - left-click won't cast!" , frameName )
@@ -1758,6 +1774,39 @@ function CC:UpdateFrameBindingAttributes(frame)
17581774 -- Store the snippet - _onenter will run this on every hover
17591775 local snippet = table.concat (snippetLines , " \n " )
17601776 frame :SetAttribute (" dfBindingSnippet" , snippet )
1777+
1778+ -- Frame has a real snippet again — allow the empty-snippet self-heal
1779+ -- detection to fire once more if it ever gets wiped (bug #976)
1780+ if snippet ~= " " then
1781+ frame .dfSnippetRepairTried = nil
1782+ end
1783+ end
1784+
1785+ -- Returns true if any enabled binding in the unified macro map needs the
1786+ -- hover snippet path (keyboard, scroll, or meta-mouse). Used by the
1787+ -- self-heal detection to tell "snippet legitimately empty" apart from
1788+ -- "snippet wiped". Cached per macro-map instance so hover hooks stay cheap.
1789+ function CC :ProfileHasKeyboardBindings ()
1790+ if not self .unifiedMacroMap then return false end
1791+ if self .kbCheckMap == self .unifiedMacroMap then
1792+ return self .kbCheckResult
1793+ end
1794+
1795+ local result = false
1796+ for _ , data in pairs (self .unifiedMacroMap ) do
1797+ local binding = data .templateBinding
1798+ local bindType = binding .bindType or " mouse"
1799+ local isMetaMouse = (bindType == " mouse" ) and binding .modifiers
1800+ and binding .modifiers :lower ():find (" meta" )
1801+ if bindType == " key" or bindType == " scroll" or isMetaMouse then
1802+ result = true
1803+ break
1804+ end
1805+ end
1806+
1807+ self .kbCheckMap = self .unifiedMacroMap
1808+ self .kbCheckResult = result
1809+ return result
17611810end
17621811
17631812-- Legacy alias for compatibility
@@ -1790,6 +1839,87 @@ function CC:RefreshKeyboardBindings()
17901839 self .pendingKeyboardRefresh = false
17911840end
17921841
1842+ -- ============================================================
1843+ -- SELF-HEALING BINDING REPAIR (bug #976)
1844+ -- Keyboard hover-binds can die mid-session (stale mouseoverbutton in the
1845+ -- restricted environment, spurious state-driver clears, wiped snippets)
1846+ -- and previously only /reload recovered. The OnEnter/ticker diagnostics
1847+ -- already DETECT all of these; this repair path fixes them out of combat:
1848+ -- 1. reset the restricted env's mouseoverbutton tracking
1849+ -- 2. clear stray override bindings so keys can't stay stolen
1850+ -- 3. rebuild the macro map + every frame's binding snippet
1851+ -- All repair actions are insecure out-of-combat calls the addon already
1852+ -- uses elsewhere — no new taint surface. In combat the repair is queued
1853+ -- and OnCombatEnd runs it.
1854+ -- ============================================================
1855+
1856+ local REPAIR_COOLDOWN = 5 -- seconds between repair attempts
1857+
1858+ -- Safe to call from anywhere, including combat and secure-hook callbacks
1859+ function CC :RequestBindingRepair (reason )
1860+ if InCombatLockdown () then
1861+ if not self .pendingBindingRepair then
1862+ DF :DebugWarn (" CLICK" , " Binding repair queued for combat end (%s)" , tostring (reason ))
1863+ end
1864+ self .pendingBindingRepair = reason
1865+ return
1866+ end
1867+ self :RunBindingRepair (reason )
1868+ end
1869+
1870+ function CC :RunBindingRepair (reason , force )
1871+ if InCombatLockdown () then
1872+ self .pendingBindingRepair = reason
1873+ return
1874+ end
1875+ if not self .db or not self .db .enabled then return end
1876+
1877+ if not force then
1878+ if self .lastBindingRepair and (GetTime () - self .lastBindingRepair ) < REPAIR_COOLDOWN then
1879+ return
1880+ end
1881+ end
1882+ self .lastBindingRepair = GetTime ()
1883+
1884+ DF :DebugWarn (" CLICK" , " Running binding repair (%s)" , tostring (reason ))
1885+
1886+ -- 1. Reset restricted-env hover tracking. A stale mouseoverbutton
1887+ -- reference aborts every OnEnter WrapScript at the cross-frame
1888+ -- cleanup step, killing keyboard binds addon-wide until /reload.
1889+ if self .header and self .header .Execute then
1890+ pcall (function ()
1891+ self .header :Execute ([[ mouseoverbutton = nil ]] )
1892+ end )
1893+ end
1894+
1895+ -- 2. Clear stray override bindings + hover state. After the env reset
1896+ -- the OnLeave wrap can no longer clear these (mouseoverbutton ~= self),
1897+ -- so clear them here or keys would stay stolen from the action bars.
1898+ local function scrub (frame )
1899+ if frame .GetAttribute and frame :GetAttribute (" dfBindingsActive" ) then
1900+ pcall (ClearOverrideBindings , frame )
1901+ frame :SetAttribute (" dfBindingsActive" , nil )
1902+ frame :SetAttribute (" dfIsSecureMouseover" , nil )
1903+ end
1904+ end
1905+ if self .registeredFrames then
1906+ for frame in pairs (self .registeredFrames ) do
1907+ scrub (frame )
1908+ end
1909+ end
1910+ if DF .unitFrames then
1911+ for _ , frame in pairs (DF .unitFrames ) do
1912+ scrub (frame )
1913+ end
1914+ end
1915+
1916+ -- 3. Rebuild the macro map and every frame's snippet, in case snippets
1917+ -- were wiped by a transient refresh. The next OnEnter re-applies
1918+ -- bindings from the fresh snippet.
1919+ self .unifiedMacroMap = self :BuildUnifiedMacroMap ()
1920+ self :RefreshKeyboardBindings ()
1921+ end
1922+
17931923-- Legacy function - now calls RefreshKeyboardBindings
17941924function CC :BuildKeyboardBindingSnippets ()
17951925 self :RefreshKeyboardBindings ()
0 commit comments