From fc268779d6cf90b578473750ad3c9b287907fbe6 Mon Sep 17 00:00:00 2001 From: wanghwplus <250630272+wanghwplus@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:34:02 +0800 Subject: [PATCH 1/2] feat(raidframes): drag a global Target Unit / Context Menu into a spec Both are seeded into cc.globals at DB creation and cannot be deleted there, so their key was shared by every character on the account with no way to vary it per spec. Dragging one onto the Spec Bindings sidebar now copies it into the active spec, seeded from the global so it starts behaving identically and only has to be rebound. Deleting the copy falls straight back to the global. Resolution had to grow a type rule to match: GetActiveBindings de-duped on key alone, so moving a spec's Target Unit off Left Click left the global's Left Click still targeting -- the copy read as an addition, not an override. Both the shadow rule and the "is this binding in force" predicate are now single functions (GetShadowedTypes / BindingIsLive) that resolution, FindKeyConflicts and the options page all share, so a shadowed global can no longer be reported as a live keybind conflict or drawn as active while the spec copy fires. The drop suppresses the click the engine delivers with the drag's mouse-up (same reason the CDM icon reorder keeps a dragEndTime) and defers its rebuild a frame through ns._ccRebuild, so the page is never torn down from inside the handler the engine is still dispatching on. Nothing changes for anyone who does not drag: with no spec copies present the shadow set is empty and resolution is byte-for-byte what it was. --- .../EUI_RaidFrames_ClickCast.lua | 331 +++++++++++++++++- 1 file changed, 325 insertions(+), 6 deletions(-) diff --git a/EllesmereUIRaidFrames/EUI_RaidFrames_ClickCast.lua b/EllesmereUIRaidFrames/EUI_RaidFrames_ClickCast.lua index 96ab6b1f..2761fde0 100644 --- a/EllesmereUIRaidFrames/EUI_RaidFrames_ClickCast.lua +++ b/EllesmereUIRaidFrames/EUI_RaidFrames_ClickCast.lua @@ -249,20 +249,50 @@ local function GetGlobalBindings() return cc and cc.globals or {} end --- Merge globals + current spec. Spec overrides globals on key conflict. +-- Types whose global entry may be shadowed by a per-spec copy. Target Unit and +-- Context Menu are seeded into cc.globals at DB creation and are not deletable +-- there, so without this the whole account shares one key for them. +local SPEC_OVERRIDABLE = { target = true, menu = true } + +-- The one condition under which a binding takes part in resolution. Anything +-- asking "is this binding in force?" must go through here, so a keyless or +-- disabled entry can never be reported as live by one caller and dead by +-- another. +local function BindingIsLive(b) + return b.enabled ~= false and b.key ~= nil +end + +-- Overridable types the active spec currently shadows. Single source of truth +-- for the shadow rule: GetActiveBindings enforces it, FindKeyConflicts must not +-- flag a shadowed global as a conflict, and the options page dims by it. +-- A spec entry whose key was cleared shadows nothing -- the global is live +-- again, and every consumer has to agree on that. +local function GetShadowedTypes() + local shadowed = {} + for _, b in ipairs(GetSpecBindings()) do + if SPEC_OVERRIDABLE[b.type] and BindingIsLive(b) then shadowed[b.type] = true end + end + return shadowed +end + +-- Merge globals + current spec. Spec overrides globals on key conflict, and -- +-- for the overridable types above -- on type conflict too: rebinding a spec's +-- Target Unit to a different key has to retire the global key, otherwise both +-- would target and the spec entry would read as an addition, not an override. -- Only includes enabled bindings. Respects master enable toggle. local function GetActiveBindings() local cc = GetClickCastDB() if not cc or not cc.enabled then return {} end local result, usedKeys = {}, {} + local shadowed = GetShadowedTypes() for _, b in ipairs(GetSpecBindings()) do - if b.enabled ~= false and b.key then + if BindingIsLive(b) then result[#result + 1] = b usedKeys[b.key] = true end end for _, b in ipairs(cc.globals) do - if b.enabled ~= false and b.key and not usedKeys[b.key] then + if BindingIsLive(b) and not usedKeys[b.key] and not shadowed[b.type] then result[#result + 1] = b end end @@ -1557,6 +1587,53 @@ function ns.CC_RemoveGlobalBinding(index) ns.CC_ApplyBindings() end +ns.CC_SPEC_OVERRIDABLE = SPEC_OVERRIDABLE + +-- Returns: index of the active spec's copy of this type (or nil), whether a spec +-- exists at all, and whether that copy actually shadows the global. +-- +-- The last two are NOT the same question and callers need both: the index alone +-- governs "would a second drag duplicate this entry?", while `shadows` governs +-- "is the global dead?". A copy whose key was cleared still occupies the slot +-- but leaves the global live, so dimming the global on index alone would tell +-- the user their global key is inactive while it is in fact the one firing. +function ns.CC_FindSpecOverride(bindingType) + local cc = GetClickCastDB() + if not cc then return nil, false, false end + local specID = GetCurrentSpecID() + if not specID then return nil, false, false end + local list = cc.specs[specID] + if not list then return nil, true, false end + for i, b in ipairs(list) do + if b.type == bindingType then return i, true, BindingIsLive(b) end + end + return nil, true, false +end + +-- Copy a global Target Unit / Context Menu binding into the active spec. +-- GetActiveBindings walks spec bindings first and de-dups globals on key, so the +-- copy shadows its global source the moment it exists -- no separate resolution +-- path is needed, and deleting the copy falls straight back to the global. +-- Returns the new spec index, or nil plus a reason ("nospec" / "exists"). +function ns.CC_OverrideGlobalInSpec(gb) + if not gb or not SPEC_OVERRIDABLE[gb.type] then return nil, "type" end + local cc = GetClickCastDB() + if not cc then return nil, "type" end + local existing, hasSpec = ns.CC_FindSpecOverride(gb.type) + if not hasSpec then return nil, "nospec" end + if existing then return nil, "exists", existing end + local specID = GetCurrentSpecID() + if not cc.specs[specID] then cc.specs[specID] = {} end + -- Seeded from the global so the override starts behaving identically and the + -- user only has to rebind it. target/menu carry no hovercast state (the + -- center panel gates those rows on hasAdvancedOpts), so key + oocOnly is the + -- binding's entire surface. + tinsert(cc.specs[specID], { type = gb.type, key = gb.key, + enabled = true, oocOnly = gb.oocOnly }) + ns.CC_ApplyBindings() + return #cc.specs[specID] +end + function ns.CC_SetGlobalBindingKey(bindingType, newKey) local cc = GetClickCastDB() if not cc then return end @@ -1599,8 +1676,15 @@ local function FindKeyConflicts(keyStr, excludeBinding) local conflicts = {} local cc = GetClickCastDB() if not cc then return conflicts end + -- A global shadowed by a spec override is not in force, so its key is free. + -- Without this, moving a spec's Target Unit off BUTTON1 leaves the global + -- still holding BUTTON1 in storage and every later attempt to bind BUTTON1 + -- draws a phantom conflict against a binding that no longer fires. It also + -- stops the seeded copy from reporting the same name twice right after a + -- drag, when global and override still share a key. + local shadowed = GetShadowedTypes() for _, b in ipairs(cc.globals) do - if b ~= excludeBinding and b.key == keyStr then + if b ~= excludeBinding and b.key == keyStr and not shadowed[b.type] then conflicts[#conflicts + 1] = ns.CC_GetBindingName(b) end end @@ -1842,6 +1926,181 @@ function ns.CC_Init() end end +------------------------------------------------------------------------------- +-- Global -> Spec drag +-- Target Unit / Context Menu exist only as fixed global entries, so the sole +-- way to give a spec its own key is to copy one across. Drag is the whole +-- gesture: no popup, and nothing changes for users who never drag. +-- +-- The ghost is a single frame reused across drags and parented to UIParent +-- (the page root is destroyed and rebuilt on every RebuildPage, which would +-- take a page-parented ghost with it mid-drag). +------------------------------------------------------------------------------- +function ns.CC_EnsureDragGhost(fontPath, outlineFlag) + local g = ns._ccDragGhost + if not g then + g = CreateFrame("Frame", nil, UIParent) + g:SetSize(200, 40) + g:SetFrameStrata("TOOLTIP") + g:EnableMouse(false) -- must never win the drop-zone hit test + g:Hide() + local bg = g:CreateTexture(nil, "BACKGROUND") + bg:SetAllPoints(); bg:SetColorTexture(0, 0, 0, 0.75) + g.icon = g:CreateTexture(nil, "ARTWORK") + g.icon:SetSize(32, 32) + g.icon:SetPoint("LEFT", g, "LEFT", 4, 0) + g.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) + g.label = g:CreateFontString(nil, "OVERLAY") + g.label:SetPoint("LEFT", g.icon, "RIGHT", 8, 0) + g.label:SetPoint("RIGHT", g, "RIGHT", -6, 0) + g.label:SetJustifyH("LEFT") + g.label:SetWordWrap(false) + g.label:SetTextColor(1, 1, 1, 1) + ns._ccDragGhost = g + end + -- A FontString with no font set renders nothing, so never leave it unset. + g.label:SetFont(fontPath or "Fonts\\FRIZQT__.TTF", 12, outlineFlag or "") + return g +end + +-- tile: the global-side tile. gb: its binding. ctx carries the page's font +-- settings for the ghost; the rebuild is reached through ns._ccRebuild instead, +-- so a drop always drives the page that is live when the timer fires. +function ns.CC_AttachSpecOverrideDrag(tile, gb, ctx) + tile:RegisterForDrag("LeftButton") + + -- The engine still delivers the drop's mouse-up as a click on the source + -- tile (same reason the CDM icon reorder keeps a dragEndTime). Left alone + -- it re-selects this global tile and fires a SECOND page rebuild right on + -- top of the drop's own -- from a tile that the first rebuild has already + -- orphaned, so a stale closure ends up driving the page singleton. + local origClick = tile:GetScript("OnClick") + tile:SetScript("OnClick", function(self, ...) + if GetTime() - (ns._ccDragEndTime or 0) < 0.2 then return end + if origClick then origClick(self, ...) end + end) + + local function StopDrag() + local g = ns._ccDragGhost + if g then g:Hide() end + if EllesmereUI.Mouse then EllesmereUI.Mouse.UnsubscribeFrame("ccSpecDrag") end + if ns._ccDropHint then ns._ccDropHint:Hide() end + end + -- Belt and braces: a drag interrupted by anything other than OnDragStop + -- (page rebuild, panel close) would otherwise strand the ghost on screen + -- holding a Mouse service subscription open. + tile:HookScript("OnHide", StopDrag) + + tile:SetScript("OnDragStart", function() + -- The tile's own hover tooltip is still up at this point and would + -- follow the drag around the screen. + EllesmereUI.HideWidgetTooltip() + local g = ns.CC_EnsureDragGhost(ctx.fontPath, ctx.outlineFlag) + g.icon:SetTexture(ns.CC_GetBindingIcon(gb)) + g.label:SetText(EllesmereUI.L(ns.CC_GetBindingName(gb))) + g:SetWidth(32 + 12 + max(60, g.label:GetStringWidth() + 10)) + g:Show() + local hint = ns._ccDropHint + if hint then + -- Resolved per drag, not cached at build time: the page is not + -- rebuilt on a spec change, so a cached spec name could be stale. + -- Saying up front that a drag is a no-op beats letting the user + -- complete it and wonder why nothing was added. + local existing, hasSpec, shadows = ns.CC_FindSpecOverride(gb.type) + local msg + if not hasSpec then + msg = EllesmereUI.L("No active specialization") + elseif shadows then + msg = EllesmereUI.L("Already overridden for this specialization") + elseif existing then + -- Slot taken but unbound, so this global is still what fires. + -- Claiming "already overridden" here would repeat the very lie + -- the shadows/exists split exists to prevent. + msg = EllesmereUI.L("An unbound entry already exists here. Drop to select it and give it a key.") + else + msg = EllesmereUI.Lf("Drop to override for %1$s", GetCurrentSpecName()) + end + hint.text:SetText(msg) + hint:Show() + end + local Mouse = EllesmereUI.Mouse + if not Mouse then return end + local function Follow(cx, cy) + local s = UIParent:GetEffectiveScale() + g:ClearAllPoints() + g:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", cx / s + 14, cy / s - 6) + end + -- motionOnly: the ghost only needs a new position when the cursor moves, + -- so the service parks the per-frame driver while it sits still. + Mouse.SubscribeFrame("ccSpecDrag", Follow, true) + Follow(Mouse.Get()) -- place it now; the driver only fires next frame + end) + + local function FinishDrop() + local newIdx, reason, existingIdx = ns.CC_OverrideGlobalInSpec(gb) + if newIdx then + ns._ccSelSide = "spec"; ns._ccSelIndex = newIdx + elseif reason == "exists" then + -- Already has a slot: select it rather than silently doing nothing, + -- so the drag still lands somewhere visible (and an unbound slot is + -- exactly what the user needs to reach to give it a key). + ns._ccSelSide = "spec"; ns._ccSelIndex = existingIdx + elseif reason == "nospec" then + EllesmereUI:ShowConfirmPopup({ + title = EllesmereUI.L("No Specialization"), + message = EllesmereUI.L("Spec bindings need an active specialization. Pick one first."), + confirmText = EllesmereUI.L("Okay"), + hideCancel = true, + }) + return + else + return + end + -- Deferred a frame, and dispatched through ns rather than this closure: + -- tearing the page down from inside the drag handler destroys the very + -- tile the engine is still dispatching on, and any later rebuild driven + -- by an orphaned closure re-enters CC_BuildPage with a dead parent. + -- ns._ccRebuild always points at the live page's rebuild. + C_Timer.After(0, function() + -- ns._ccRoot is the liveness signal: the panel-close and + -- module-switch teardowns both clear it, and rebuilding after one + -- of those would resurrect the page behind a closed panel. + if ns._ccRoot and ns._ccRebuild then ns._ccRebuild() end + end) + end + + tile:SetScript("OnDragStop", function() + local dropped = ns._ccDropZone and ns._ccDropZone:IsMouseOver() + StopDrag() + ns._ccDragEndTime = GetTime() + if not dropped then return end + -- The copy carries the global's key across, and that key can already be + -- taken by a spell in this spec. Two spec entries on one key both reach + -- the apply pass and the later one wins the attribute, so a silent + -- insert would kill the existing binding with no feedback. Warn on the + -- same terms as every other key assignment here (BuildKeybindButton). + local ccdb = GetClickCastDB() + local conflicts = (gb.key and not (ccdb and ccdb.hideKeyWarning)) + and FindKeyConflicts(gb.key, gb) or nil + if conflicts and #conflicts > 0 then + EllesmereUI:ShowConfirmPopup({ + title = EllesmereUI.L("Duplicate Keybind"), + message = EllesmereUI.Lf("%s is already assigned to:\n%s", + ns.CC_FormatKey(gb.key), table.concat(conflicts, ", ")), + confirmText = EllesmereUI.L("Okay"), + cancelText = EllesmereUI.L("Don't Show Again"), + onConfirm = FinishDrop, + onCancel = function() + if ccdb then ccdb.hideKeyWarning = true end + FinishDrop() + end, + }) + return + end + FinishDrop() + end) +end + ------------------------------------------------------------------------------- -- Options Page Builder -- Layout: Left sidebar (Global) | Center (Options + Per-Binding) | Right sidebar (Spec) @@ -1873,6 +2132,10 @@ function ns.CC_BuildPage(pageName, parent, yOffset) if ns._ccSpecPopup then ns._ccSpecPopup:Hide(); ns._ccSpecPopup = nil end -- QB popup is NOT cleaned up here -- it stays open during rebuilds if ns._ccSpellStrip then ns._ccSpellStrip:Hide(); ns._ccSpellStrip:SetParent(nil); ns._ccSpellStrip = nil end + -- Both live on the root that was just torn down; drop the stale references + -- so a drag can never hit-test against an orphaned sidebar. + ns._ccDropZone = nil + ns._ccDropHint = nil local root = CreateFrame("Frame", nil, scrollFrame) root:SetSize(parentW, visibleH) @@ -2320,6 +2583,10 @@ function ns.CC_BuildPage(pageName, parent, yOffset) if ns._ccRoot then ns._ccRoot:Hide(); ns._ccRoot:SetParent(nil); ns._ccRoot = nil end ns.CC_BuildPage(pageName, parent, yOffset) end + -- Latest-wins handle for deferred rebuilds. Each build overwrites it, so a + -- callback that fires after the page was rebuilt underneath it reaches the + -- current page instead of re-running a dead closure's captured parent. + ns._ccRebuild = RebuildPage local function SelectBinding(side, idx) ns._ccSelSide = side @@ -2361,13 +2628,30 @@ function ns.CC_BuildPage(pageName, parent, yOffset) for i, gb in ipairs(globals) do local isSel = selectedSide == "global" and selectedIndex == i local canDelete = gb.type ~= "target" and gb.type ~= "menu" and gb.type ~= "dispel" and gb.type ~= "external" and gb.type ~= "trinket1" and gb.type ~= "trinket2" and gb.type ~= "dynamicrez" - BuildTile(leftChild, leftY, gb, isSel, "global", i, + local gTile = BuildTile(leftChild, leftY, gb, isSel, "global", i, SelectBinding, canDelete and function(idx2) ns.CC_RemoveGlobalBinding(idx2) ns._ccSelSide = nil; ns._ccSelIndex = nil RebuildPage() end or nil) + if ns.CC_SPEC_OVERRIDABLE[gb.type] then + ns.CC_AttachSpecOverrideDrag(gTile, gb, { + fontPath = fontPath, outlineFlag = outlineFlag, + }) + -- Dimmed while the active spec actually shadows it: the two tiles + -- otherwise show the same key with no sign of which one is live. + -- Keyed on `shadows`, not on the override merely existing -- an + -- override with its key cleared leaves this global in force. + local _, _, shadowed = ns.CC_FindSpecOverride(gb.type) + if shadowed then gTile:SetAlpha(0.4) end + gTile:HookScript("OnEnter", function(self) + EllesmereUI.ShowWidgetTooltip(self, shadowed + and EllesmereUI.L("Overridden by a Spec Bindings entry for the current specialization. Delete that entry to make this key live again.") + or EllesmereUI.L("Drag onto Spec Bindings to give the current specialization its own key. The spec copy overrides this one; delete it to fall back here.")) + end) + gTile:HookScript("OnLeave", function() EllesmereUI.HideWidgetTooltip() end) + end leftY = leftY - TILE_H end @@ -2609,6 +2893,28 @@ function ns.CC_BuildPage(pageName, parent, yOffset) rightHeader:SetPoint("TOP", rightOuter, "TOP", 0, -18) rightHeader:SetText(EllesmereUI.L("Spec Bindings")) + -- Drop target for a global Target Unit / Context Menu dragged in from the + -- left. The hint layer only exists while a drag is in flight, so the sidebar + -- looks and behaves exactly as before for everyone else. + ns._ccDropZone = rightOuter + do + local hint = CreateFrame("Frame", nil, rightOuter) + hint:SetAllPoints() + hint:SetFrameLevel(rightOuter:GetFrameLevel() + 20) + hint:EnableMouse(false) + local hintBg = hint:CreateTexture(nil, "BACKGROUND") + hintBg:SetAllPoints() + hintBg:SetColorTexture(accentColor.r, accentColor.g, accentColor.b, 0.10) + EllesmereUI.MakeBorder(hint, accentColor.r, accentColor.g, accentColor.b, 0.9, PP) + local hintTxt = MakeFont(hint, 12, 1, 1, 1, 0.9) + hintTxt:SetPoint("TOP", rightHeader, "BOTTOM", 0, -12) + hintTxt:SetWidth(sidebarW - 20) + hintTxt:SetJustifyH("CENTER") + hint.text = hintTxt -- filled in per drag by CC_AttachSpecOverrideDrag + hint:Hide() + ns._ccDropHint = hint + end + local rightScroll = CreateFrame("ScrollFrame", nil, rightOuter) rightScroll:SetPoint("TOPLEFT", rightOuter, "TOPLEFT", 0, -38) rightScroll:SetPoint("BOTTOMRIGHT", rightOuter, "BOTTOMRIGHT", 0, 0) @@ -2627,13 +2933,26 @@ function ns.CC_BuildPage(pageName, parent, yOffset) local specBinds = GetSpecBindings() for i, sb in ipairs(specBinds) do local isSel = selectedSide == "spec" and selectedIndex == i - BuildTile(rightChild, rightY, sb, isSel, "spec", i, + local sTile = BuildTile(rightChild, rightY, sb, isSel, "spec", i, SelectBinding, function(idx2) ns.CC_RemoveSpecBinding(idx2) ns._ccSelSide = nil; ns._ccSelIndex = nil RebuildPage() end) + if ns.CC_SPEC_OVERRIDABLE[sb.type] then + -- Unbound it overrides nothing -- the global is what fires -- so say + -- that rather than repeat the override claim over an inert tile. + -- Through the shared predicate, never an inline copy of it: this + -- tooltip and GetActiveBindings must never disagree on "live". + local live = BindingIsLive(sb) + sTile:HookScript("OnEnter", function(self) + EllesmereUI.ShowWidgetTooltip(self, live + and EllesmereUI.L("Overrides the global binding of the same name for this specialization only. Delete it to go back to the global key.") + or EllesmereUI.L("Unbound, so the global binding of the same name is still what fires. Assign a key to override it.")) + end) + sTile:HookScript("OnLeave", function() EllesmereUI.HideWidgetTooltip() end) + end rightY = rightY - TILE_H end From 206aef290be2846dcb1640990a592be24562fe07 Mon Sep 17 00:00:00 2001 From: wanghwplus <250630272+wanghwplus@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:34:06 +0800 Subject: [PATCH 2/2] chore: regenerate locale keys --- Locales/_keys.txt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Locales/_keys.txt b/Locales/_keys.txt index 421a2ea2..eb95ac4d 100644 --- a/Locales/_keys.txt +++ b/Locales/_keys.txt @@ -1,6 +1,6 @@ # Auto-generated by .tools/extract-locale-keys.sh -- do not edit by hand. # Canonical list of translatable English keys passed as string literals -# (661 unique). Regenerate after wrapping new strings. Keys passed as +# (671 unique). Regenerate after wrapping new strings. Keys passed as # variables are not listed here -- use the in-game /euiloc harvester for # the complete runtime set. (Raid) @@ -94,7 +94,9 @@ All Warbank Tabs All selected profiles will keep their %1$s settings in sync: changes made on any of them carry over to the others. Choose which profile's settings the group starts from. Already assigned to %s Already on %s +Already overridden for this specialization Already used by another card for this spec +An unbound entry already exists here. Drop to select it and give it a key. Anchor Anchored Apply @@ -224,10 +226,12 @@ Don't Show Again Done Drag a bar to move it into another group Drag categories on bag sidebar to reposition, group or ungroup. +Drag onto Spec Bindings to give the current specialization its own key. The spec copy overrides this one; delete it to fall back here. Drag to Reorder Drag to Reorder Bars Drag to Reorder Roles Draw tick lines across the bar at positions you choose. +Drop to override for %1$s Duplicate Keybind Duration (seconds) Dynamic Rez @@ -386,6 +390,8 @@ New Profile No No Bars - Click to Add No Saved Presets +No Specialization +No active specialization No buffs assigned. Right click a button in the preview to assign buffs. No death recap available No excluded debuffs. @@ -412,8 +418,10 @@ OneWarbank Opacity Open Great Vault Overall %1$s +Overridden by a Spec Bindings entry for the current specialization. Delete that entry to make this key live again. Override Active: Overrides +Overrides the global binding of the same name for this specialization only. Delete it to go back to the global key. Overwrite Existing Settings Overwrite Window & Tooltip Settings? PER-SPELL OPTIONS @@ -542,6 +550,7 @@ Snap to: Select Element Spacing Spec Spec Bindings +Spec bindings need an active specialization. Pick one first. Speed Spell ID Spell Name @@ -604,6 +613,7 @@ UI Scale Issue: Profile was made at %1$d%%, yours is %2$d%% UI Scale Mismatch Ultrawide Import Unavailable with Enhancement 5-bar style. +Unbound, so the global binding of the same name is still what fires. Assign a key to override it. Uncheck All Unknown Unknown Item