diff --git a/.github/chat-meters-embed.png b/.github/chat-meters-embed.png new file mode 100644 index 000000000..17e917825 Binary files /dev/null and b/.github/chat-meters-embed.png differ diff --git a/.tools/chat-meters-tests/README.md b/.tools/chat-meters-tests/README.md new file mode 100644 index 000000000..7d4dc5923 --- /dev/null +++ b/.tools/chat-meters-tests/README.md @@ -0,0 +1,32 @@ +# Chat meters regression tests + +From the repository root, with Python 3 and `lupa==2.8` installed: + +```sh +python .tools/chat-meters-tests/run.py +``` + +The runner compiles each affected source file with Lua 5.1, then executes the +complete chat-host controller and the actual production placement, unlock, +visibility, sidebar and Combat Log functions against simulated WoW frames. +The runner extracts those closures because the surrounding modules initialize +the rest of the WoW UI. Their guards are not duplicated in the mock. + +Covered behavior: + +- No host frames, subscriptions, timers or hooks on a fresh disabled profile. +- Exactly two existing windows adopted; extra windows left standalone. +- Full header controls, transparent backgrounds and sidebar placement. +- All 32 combinations of edit/unlock/settings/resize flags and selected view. +- Native placement/size/mover writes cannot override embedding or saved geometry. +- Live geometry following without additional combat-data fetches. +- Native tab clicks, Combat Log restoration and instance/manual selection. +- Hidden Settings icon, hidden sidebar, unavailable host and chat passthrough. +- Partial/empty window rebuilds, replacements, profile opt-out and restoration. +- Disable removes event subscriptions/callbacks; re-enable reuses allocated frames. + +The mocks reject writes to native chat frames/tabs and any controller timer or +method hook. Display-container alpha is exercised separately through the engine. +These tests cannot certify live-client taint, combat restrictions, rendering, +anchor resolution or performance. An in-game pass on Midnight 12.1 is still required. +The directory is excluded from release packages by the existing `.pkgmeta`. diff --git a/.tools/chat-meters-tests/mock_wow.lua b/.tools/chat-meters-tests/mock_wow.lua new file mode 100644 index 000000000..ad3b5dd55 --- /dev/null +++ b/.tools/chat-meters-tests/mock_wow.lua @@ -0,0 +1,179 @@ +-- Behavioral mocks, deliberately reject any write to a native chat frame/tab. +local F = {} +local function writable(f) assert(not f.native, "Attempt to modify native chat: " .. tostring(f.name)) end +function F:SetParent(v) + writable(self) + if self.parent~=v then self.parentChanges=(self.parentChanges or 0)+1 end + self.parent = v +end +function F:GetParent() return self.parent end +function F:GetName() return self.name end +function F:SetSize(w,h) + writable(self) + local changed=self.w~=w or self.h~=h + self.w,self.h = w,h + if changed and self.scripts.OnSizeChanged then self.scripts.OnSizeChanged(self,w,h) end +end +function F:SetWidth(v) writable(self); self.w = v end +function F:SetHeight(v) writable(self); self.h = v end +function F:GetWidth() return self.w end +function F:GetHeight() return self.h end +function F:SetScale(v) writable(self); self.scale = v end +function F:GetScale() return self.scale end +function F:GetEffectiveScale() return self.scale * (self.parent and self.parent:GetEffectiveScale() or 1) end +function F:SetFrameStrata(v) writable(self); self.strata = v end +function F:GetFrameStrata() return self.strata end +function F:SetFrameLevel(v) writable(self); self.level = v end +function F:GetFrameLevel() return self.level end +function F:SetAlpha(v) writable(self); self.alpha = v end +function F:GetAlpha() return self.alpha end +function F:SetClampedToScreen(v) writable(self); self.clamped = v end +function F:IsClampedToScreen() return self.clamped end +function F:IsProtected() return self.protected or false end +function F:SetPoint(...) writable(self); self.points[#self.points+1] = {...} end +function F:GetPoint(i) return unpack(self.points[i or 1] or {}) end +function F:GetNumPoints() return #self.points end +function F:ClearAllPoints() writable(self); self.points = {} end +function F:SetAllPoints() writable(self) end +function F:GetLeft() return self.left or 20 end +function F:GetRight() return self.right or (self:GetLeft()+self.w) end +function F:GetBottom() return self.bottom or 280 end +function F:Show() writable(self); self.shown = true end +function F:Hide() + writable(self) + local wasShown=self.shown + self.shown = false + if wasShown and self.scripts.OnHide then self.scripts.OnHide(self) end +end +function F:SetShown(v) if v then self:Show() else self:Hide() end end +function F:IsShown() return self.shown end +function F:IsVisible() return self.shown and (not self.parent or self.parent:IsVisible()) end +function F:EnableMouse() writable(self) end +function F:EnableMouseWheel() writable(self) end +function F:RegisterForClicks() writable(self) end +function F:SetScript(k,v) writable(self); self.scripts[k] = v end +function F:HookScript(k,v) + writable(self) + local old=self.scripts[k] + self.scripts[k]=function(...) if old then old(...) end; v(...) end +end +function F:GetScript(k) return self.scripts[k] end +function F:RegisterEvent(k) self.events[k] = true end +function F:SetText(v) writable(self); self.text = v end +function F:SetTextColor() writable(self) end +function F:SetColorTexture() writable(self) end +function F:SetFont() writable(self) end +function F:SetJustifyH() writable(self) end +function F:SetWordWrap() writable(self) end +function F:SetChecked(v) self.checked = v end +function F:GetChecked() return self.checked end +function F:CreateTexture() return CreateFrame("Texture",nil,self) end +function F:CreateFontString() return CreateFrame("FontString",nil,self) end +function F:SetOwner() end +function F:AddLine() end +FRAMES = {} +function CreateFrame(kind,name,parent,template) + local f = setmetatable({kind=kind,name=name,parent=parent,points={},scripts={},events={}, + w=300,h=200,scale=1,strata="MEDIUM",level=1,alpha=1,shown=true,clamped=false}, {__index=F}) + if name then _G[name] = f end + FRAMES[#FRAMES+1] = f + if template == "InterfaceOptionsCheckButtonTemplate" then f.Text=f:CreateFontString() end + return f +end +UIParent = CreateFrame("Frame","UIParent") +GameTooltip = CreateFrame("Frame") +ChatFrame1 = CreateFrame("Frame","ChatFrame1",UIParent) +ChatFrame1.native = true +ChatFrame2 = CreateFrame("Frame","ChatFrame2",UIParent) +ChatFrame2.native = true +ChatFrame1Tab = CreateFrame("Button","ChatFrame1Tab",UIParent) +ChatFrame1Tab.right = 120; ChatFrame1Tab.native = true +ChatFrame2Tab = CreateFrame("Button","ChatFrame2Tab",UIParent) +ChatFrame2Tab.right = 360; ChatFrame2Tab.native = true +GENERAL_CHAT_DOCK = { DOCKED_CHAT_FRAMES={ChatFrame1,ChatFrame2} } +SELECTED = ChatFrame1 +function FCFDock_GetSelectedWindow() return SELECTED end +function FCFDock_SelectWindow() error("Must not select native chat") end +SECRET = {} +function issecretvalue(v) return v == SECRET end +function MouseIsOver(f) return f.mouseOver == true end +ZONE_KIND, ZONE_ID = "none", 0 +function IsInInstance() return ZONE_KIND ~= "none", ZONE_KIND end +function GetInstanceInfo() return "Zone",ZONE_KIND,0,0,0,0,0,ZONE_ID end +Enum = { DamageMeterType = {DamageDone=0,HealingDone=2} } +SlashCmdList = {} +local pending = {} +C_Timer = { + After=function(_,f) pending[#pending+1]=f end, + NewTicker=function(_,f) return { callback=f, Cancel=function(s) s.cancelled=true end } end, +} +function Flush() + local q=pending; pending={} + for _,f in ipairs(q) do f() end +end +function Event(name,...) + for _,f in ipairs(FRAMES) do if f.events[name] then f.scripts.OnEvent(f,name,...) end end + Flush() +end +function hooksecurefunc(t,k,hook) + assert(type(t)=="table" and not t.native, "Native hook") + local old=t[k] + t[k]=function(...) local result=old(...); hook(...); return result end +end +Settings = { + RegisterCanvasLayoutCategory=function() return {GetID=function() return 123 end} end, + RegisterAddOnCategory=function() end, + OpenToCategory=function(id) OPENED_CATEGORY=id end, +} +CHAT_CONFIG = {enabled=true,tabHeight=24} +PANEL = CreateFrame("Frame",nil,UIParent); PANEL:SetSize(520,280) +CHAT_DATA = {bg=PANEL,_bgIns={b=-34}} +CHAT_DATA.sidebar=CreateFrame("Frame",nil,UIParent) +CHAT_DATA.portalBtn=CreateFrame("Button",nil,CHAT_DATA.sidebar) +CHAT_DATA.settingsBtn=CreateFrame("Button",nil,CHAT_DATA.sidebar) +CHAT_DATA.settingsBtn:SetSize(22,22) +CHAT_DATA.settingsBtn:SetPoint("TOP",CHAT_DATA.portalBtn,"BOTTOM",0,-10) +CHAT = { ECHAT={DB=function() return CHAT_CONFIG end, ResetIdleTimer=function() end} } +CHAT._chatWins={ + [ChatFrame1]={smf=CreateFrame("Frame",nil,PANEL),track=CreateFrame("Frame",nil,PANEL)}, + [ChatFrame2]={smf=CreateFrame("Frame",nil,PANEL),track=CreateFrame("Frame",nil,PANEL)}, +} +ChatFrame2.FontStringContainer=CreateFrame("Frame",nil,UIParent) +ChatFrame2.ScrollBar={Track=CreateFrame("Frame",nil,UIParent)} +CombatLogQuickButtonFrame_Custom=CreateFrame("Frame",nil,UIParent) +DM = {_windows={}} +EllesmereUI = { + _ModuleNS={EllesmereUIChat=CHAT,EllesmereUIDamageMeters=DM}, + _chatCFD=function() return CHAT_DATA end, + GetFontPath=function() return "font.ttf" end, + GetAccentColor=function() return 0,1,0 end, +} +SAVED = { width=375,height=180,position={x=1000,y=300} } +function NewWindow(kind) + local w={curDMType=kind,windowLocked=false} + w.frame=CreateFrame("Frame",nil,UIParent) + w.frame:SetSize(SAVED.width,SAVED.height) + w.frame:SetPoint("TOPLEFT",UIParent,"BOTTOMLEFT",1000,300) + w.frame:SetClampedToScreen(true) + w.header=CreateFrame("Frame",nil,w.frame) + w.frame._bg=w.frame:CreateTexture() + w.header._hdrBg=w.header:CreateTexture() + w.header:SetScript("OnMouseDown",function() w.dragAttempted=true end) + w.header:SetScript("OnMouseUp",function() w.homeOpened=true end) + w.titleText=w.header:CreateFontString() + w.timerText=w.header:CreateFontString() + w.hdrBtns={} + for i=1,5 do + w.hdrBtns[i]=CreateFrame("Button",nil,w.header) + w.hdrBtns[i]:SetPoint("RIGHT",w.header,"RIGHT",-i*22,0) + end + w.segmentBtn=w.hdrBtns[2] + w.resizeGrip=CreateFrame("Button",nil,w.frame) + w.lockBtn=CreateFrame("Button",nil,w.frame) + w.UpdateVisibility=function() if w.hideRule then w.frame:Hide() else w.frame:Show() end end + w.Refresh=function() w.refreshes=(w.refreshes or 0)+1 end + w.FitTitle=function() end + w.ApplyPosition=function() end + return w +end +DM._windows={NewWindow(0),NewWindow(2),NewWindow(5)} diff --git a/.tools/chat-meters-tests/run.py b/.tools/chat-meters-tests/run.py new file mode 100644 index 000000000..cb98cd7b6 --- /dev/null +++ b/.tools/chat-meters-tests/run.py @@ -0,0 +1,47 @@ +"""Run with Python 3 + lupa. Uses the actual Lua 5.1 controller and core closures.""" +from pathlib import Path +from lupa.lua51 import LuaRuntime + +here = Path(__file__).resolve().parent +root = here.parents[1] +lua = LuaRuntime(unpack_returned_tuples=True) +dm = (root / "EllesmereUIDamageMeters/EllesmereUIDamageMeters.lua").read_text(encoding="utf-8") +chat = (root / "EllesmereUIChat/EllesmereUIChat.lua").read_text(encoding="utf-8") +engine = (root / "EllesmereUIChat/EllesmereUIChat_Engine.lua").read_text(encoding="utf-8") + +paths = ["EllesmereUIDamageMeters/EllesmereUIDamageMeters.lua", + "EllesmereUIDamageMeters/EllesmereUIDamageMeters_Chat.lua", + "EllesmereUIChat/EllesmereUIChat.lua", + "EllesmereUIChat/EllesmereUIChat_Engine.lua", + "EllesmereUIChat/EllesmereUIChat_Tabs.lua", + "EllesmereUIOptions/EUI_DamageMeters_Options.lua"] +for path in paths: + lua.execute("assert(loadstring(...))", (root / path).read_text(encoding="utf-8")) + +def section(source, start, end): + return source[source.index(start):source.index(end, source.index(start))] + +lua.execute((here / "mock_wow.lua").read_text()) +lua.execute((here / "setup.lua").read_text()) +# Compile original function bodies against minimal module locals; guards are not +# reimplemented in the mock. This catches regressions in the production writers. +lua.execute("local ns, EUI, _windows, MIN_W, MIN_H, MAX_WINDOWS = DM, EllesmereUI, DM._windows, 150, 50, 5\n" + "local function DB() return CONFIG end\nlocal function WinDB() return SAVED end\n" + + section(dm, "ns.ApplyWinPosition = function", "-- Bookmarks are shared") + + section(dm, "ns.RegisterDMUnlock = function", "local _combatEndTime") + + section(dm, "ns.ApplyDMSize = function", "-- Standalone Combat Timer")) +lua.execute("local ns, EUI, ECHAT = CHAT, EllesmereUI, CHAT.ECHAT\nlocal CFD = EUI._chatCFD\n" + "local _sidebarFadeTarget, _sidebarFadeAlpha = 1, 1\nlocal function GetTabAreaHeight() return 24 end\n" + + section(chat, "function ECHAT.ApplySidebarIconVisibility()", "-- Map of sidebar-icon visibility key")) +lua.execute("local ns, ECHAT, WINS = CHAT, CHAT.ECHAT, CHAT._chatWins\n" + "local function IsCombatLog(cf) return cf == ChatFrame2 end\n" + "local function SetBarMouse() end\nlocal function RebuildWindowFromBuffer() end\n" + + section(engine, "function ECHAT.EngineUpdateCombatLogHost()", "-- Mirrors, on our own standalone event frame")) +lua.execute("function InstallWindowWriters(W)\nlocal frame, wdb, EUI, ns = W.frame, SAVED, EllesmereUI, DM\n" + "local function DB() return CONFIG end\n" + + section(dm, " function W.UpdateVisibility()", " if EUI.RegisterVisibilityUpdater then EUI.RegisterVisibilityUpdater(W.UpdateVisibility)") + + "\nW.ApplyPosition = function() DM.ApplyWinPosition(frame, wdb, W.idx) end\nend") +lua.execute("for i,w in ipairs(DM._windows) do w.idx=i; InstallWindowWriters(w) end") +lua.execute((root / paths[1]).read_text(), "EllesmereUIDamageMeters", lua.globals().DM) +lua.execute((here / "scenarios.lua").read_text()) +print("PASS: Lua 5.1 syntax, lazy opt-in, native ownership, sidebar and lifecycle regressions") diff --git a/.tools/chat-meters-tests/scenarios.lua b/.tools/chat-meters-tests/scenarios.lua new file mode 100644 index 000000000..7eb6e0837 --- /dev/null +++ b/.tools/chat-meters-tests/scenarios.lua @@ -0,0 +1,134 @@ +local e, damage, healing, extra = EllesmereUI, unpack(DM._windows) +local frameCount = #FRAMES +DM.ApplyChatMeters() +assert(#FRAMES == frameCount and not DM._chatMeters, "Disabled means no host, frames, events or hooks") +CONFIG.chatEmbedEnabled = true +DM.RegisterDMUnlock() +local host = DM._chatMeters +assert(host and host.enabled and not host.active) +assert(VISIBILITY_GENERATION==1, "Adoption invalidates the cached mouseover predicate") +assert(damage.frame:GetParent()==host.body and healing.frame:GetParent()==host.body) +assert(extra.frame:GetParent()==UIParent and extra.frame:IsVisible()) +assert(host.button:IsShown() and host.button:GetParent()==CHAT_DATA.sidebar) +local _, anchor = CHAT_DATA.settingsBtn:GetPoint() +assert(anchor==host.button, "Sidebar entry precedes Settings") +host:SetActive(true) +assert(damage.frame:IsVisible() and healing.frame:IsVisible()) +assert(CHAT._chatWins[ChatFrame1].smf:GetAlpha()==0) +assert(damage.frame._bg:GetAlpha()==0 and damage.header._hdrBg:GetAlpha()==0) +for _, b in ipairs(damage.hdrBtns) do assert(b:IsVisible(), "Every header button remains") end +assert(not damage.resizeGrip:IsVisible() and not damage.lockBtn:IsVisible()) + +local parentChanges = damage.frame.parentChanges +local element = e._unlockRegisteredElements.EDM_Win1 +local originalVisibility = damage.UpdateVisibility +for flags=0,15 do + EDIT_MODE=flags%2==1 + e._unlockActive=math.floor(flags/2)%2==1 + DM._optionsOpen=math.floor(flags/4)%2==1 + CHAT._chatSizingActive=math.floor(flags/8)%2==1 + for _, active in ipairs({false,true}) do + host:SetActive(active) + DM.ApplyDMSize() + damage.ApplyPosition() + damage.UpdateVisibility() + damage.frame:Show() -- settings preview uses direct Show calls + assert(damage.frame:GetParent()==host.body and damage.frame:IsVisible()==active) + assert(host.button:IsShown() and damage.frame.parentChanges==parentChanges) + assert(element.getFrame("EDM_Win1")==nil) + element.setWidth("EDM_Win1",900) + element.setHeight("EDM_Win1",900) + element.savePos("EDM_Win1","CENTER","CENTER",7,8) + element.clearPos("EDM_Win1") + assert(SAVED.width==375 and SAVED.height==180 and SAVED.position.x==1000) + end +end +assert(damage.UpdateVisibility==originalVisibility) +e._unlockActive, DM._optionsOpen, CHAT._chatSizingActive = false, false, false +host:SetActive(true) +local refreshes=damage.refreshes +for width=160,700,20 do + PANEL:SetSize(width,300) + assert(damage.frame:GetWidth()==(width-8)/2) + assert(damage.frame:GetParent()==host.body and damage.frame:IsVisible()) +end +assert(damage.refreshes==refreshes, "Resize only changes geometry, not combat-data fetches") +assert(damage.frame.parentChanges==parentChanges) +assert(element.getSize("EDM_Win1")==375, "Mover size is the saved standalone size") +HIDE_METERS=true; damage.UpdateVisibility(); assert(damage.frame:IsVisible()); HIDE_METERS=false + +ChatFrame1Tab.mouseOver=true +Event("GLOBAL_MOUSE_DOWN","LeftButton") +ChatFrame1Tab.mouseOver=false +assert(not host.active and not damage.frame:IsVisible()) +assert(CHAT._chatWins[ChatFrame1].smf:GetAlpha()==1) +host:SetActive(true) +SELECTED=ChatFrame2; CHAT._onMetersSelectionChanged(SELECTED) +assert(not host.active) +CHAT.ECHAT.EngineUpdateCombatLogHost() +host:SetActive(true) +assert(ChatFrame2.FontStringContainer:GetAlpha()==0 and CombatLogQuickButtonFrame_Custom:GetAlpha()==0) +host:SetActive(false) +assert(ChatFrame2.FontStringContainer:GetAlpha()==1 and CombatLogQuickButtonFrame_Custom:GetAlpha()==1) +host:SetActive(true) +SELECTED=ChatFrame1; CHAT._onMetersSelectionChanged(SELECTED) +assert(ChatFrame2.FontStringContainer:GetAlpha()==0, "Selection change cannot reveal deselected log") + +ZONE_KIND,ZONE_ID="party",1; Event("ZONE_CHANGED_NEW_AREA") +assert(not host.active, "Auto dungeon defaults off") +CONFIG.chatEmbedAutoDungeon=true +ZONE_ID=2; Event("ZONE_CHANGED_NEW_AREA"); assert(host.active) +host:SetActive(false); Event("PLAYER_ENTERING_WORLD"); assert(not host.active) +CONFIG.chatEmbedAutoRaid=true +ZONE_KIND,ZONE_ID="raid",3; Event("ZONE_CHANGED_NEW_AREA"); assert(host.active) +ZONE_KIND,ZONE_ID="none",0; Event("ZONE_CHANGED_NEW_AREA"); assert(host.active) +CONFIG.chatEmbedReturnOnExit=true +ZONE_KIND,ZONE_ID="raid",4; Event("ZONE_CHANGED_NEW_AREA") +ZONE_KIND,ZONE_ID="none",0; Event("ZONE_CHANGED_NEW_AREA"); assert(not host.active) + +CHAT_CONFIG.showSettings=false; CHAT.ECHAT.ApplySidebarIcons() +assert(host.button:IsShown() and not CHAT_DATA.settingsBtn:IsShown()) +CHAT_CONFIG.sidebarVisibility="never"; CHAT.ECHAT.ApplySidebarIconVisibility() +assert(not host.button:IsShown()) +CHAT_CONFIG.sidebarVisibility="always"; CHAT.ECHAT.ApplySidebarIcons() +host:SetActive(true) +CHAT._chatPassthrough=true; CHAT._onMetersHostChanged() +assert(not damage.frame:IsVisible() and damage.frame:GetParent()==host.body) +CHAT._chatPassthrough=false; CHAT._onMetersHostChanged(); assert(damage.frame:IsVisible()) +local panel=CHAT_DATA.bg; CHAT_DATA.bg=nil; CHAT._onMetersHostChanged() +assert(not host.root:IsShown() and damage.frame:GetParent()==host.body) +CHAT_DATA.bg=panel; CHAT._onMetersHostChanged(); assert(damage.frame:IsVisible()) +CHAT_CONFIG.enabled=false; CHAT._onMetersHostChanged() +assert(not damage.frame:IsVisible() and damage.frame:GetParent()==host.body) +CHAT_CONFIG.enabled=true; CHAT._onMetersHostChanged() + +damage.curDMType=5; DM.ApplyChatMeters(); assert(host.left==damage) +host:Retire(healing); healing.frame:Hide(); healing.frame:SetParent(nil) +table.remove(DM._windows,2); DM.ApplyChatMeters() +assert(host.left==damage and not host.right and damage.frame:IsVisible() and host.button:IsShown()) +local replacement=NewWindow(2); replacement.idx=3; InstallWindowWriters(replacement) +table.insert(DM._windows,replacement); DM.RegisterDMUnlock() +assert(host.right==replacement and replacement.frame:GetParent()==host.body) + +CONFIG.chatEmbedEnabled=false; DM.ApplyChatMeters() +assert(not host.enabled and not host.button:IsShown() and not next(host.events.events)) +assert(not CHAT._onMetersHostChanged and not CHAT._onMetersSelectionChanged and not CHAT._onMetersStyleChanged) +assert(VISIBILITY_GENERATION>=3, "Disable invalidates mouseover visibility") +assert(damage.frame:GetParent()==UIParent and damage.frame:GetWidth()==375) +assert(damage.frame._bg:GetAlpha()==1 and damage.header._hdrBg:GetAlpha()==1) +assert(damage.resizeGrip:GetParent()==damage.frame and not damage._chatHost) +assert(element.getFrame("EDM_Win1")==damage.frame) +frameCount=#FRAMES; DM.ApplyChatMeters(); assert(#FRAMES==frameCount) +CONFIG.chatEmbedEnabled=true; DM.ApplyChatMeters(); assert(#FRAMES==frameCount, "Reuse lazily allocated host") +-- Profile rebuild: retired frames stay dead; replacements are adopted in the +-- same lifecycle call, while a profile with embedding off stays standalone. +for i=#DM._windows,1,-1 do + local w=DM._windows[i] + if w._chatHost then host:Retire(w) end + w.frame:Hide(); w.frame:SetParent(nil); table.remove(DM._windows,i) +end +DM.ApplyChatMeters(); assert(host.waiting:IsShown() and host.button:IsShown()) +DM._windows[1]=NewWindow(0); DM._windows[1].idx=1; InstallWindowWriters(DM._windows[1]) +DM.ApplyChatMeters(); assert(DM._windows[1].frame:GetParent()==host.body) +CONFIG.chatEmbedEnabled=false; DM.ApplyChatMeters() +assert(DM._windows[1].frame:GetParent()==UIParent and not healing.frame:IsShown()) diff --git a/.tools/chat-meters-tests/setup.lua b/.tools/chat-meters-tests/setup.lua new file mode 100644 index 000000000..534882c0f --- /dev/null +++ b/.tools/chat-meters-tests/setup.lua @@ -0,0 +1,62 @@ +CONFIG = { chatEmbedEnabled=false, chatEmbedAutoDungeon=false, chatEmbedAutoRaid=false, + chatEmbedReturnOnExit=false } +DM.EDM = { DB=function() return CONFIG end } +EllesmereUI.L = function(s) return s end +EllesmereUI.ShowWidgetTooltip = function() end +EllesmereUI.HideWidgetTooltip = function() end +EllesmereUI.ShowModule = function(_, name) OPENED_MODULE = name end +EllesmereUI.EvalVisibility = function() return not HIDE_METERS end +EllesmereUI.RequestVisibilityUpdate = function() VISIBILITY_GENERATION=(VISIBILITY_GENERATION or 0)+1 end +EllesmereUI.MakeUnlockElement = function(t) return t end +EllesmereUI._unlockRegisteredElements = {} +EllesmereUI.RegisterUnlockElements = function(_, elements) + for _, e in ipairs(elements) do EllesmereUI._unlockRegisteredElements[e.key] = e end +end +EllesmereUI.UnregisterUnlockElement = function(_, key) + assert(key ~= "EDM_Win1" and key ~= "EDM_Win2", "Must not delete embedded saved links") + EllesmereUI._unlockRegisteredElements[key] = nil +end +CHAT.ECHAT.ResolveSidebarIconOrder = function() return {"showPortals","showSettings"} end +CHAT.ECHAT.ApplyIconFreeMove = function() end +CHAT.ECHAT.ResetIdleTimer = function() IDLE_RESETS = (IDLE_RESETS or 0) + 1 end +CHAT.ECHAT.SetMetersView = function() error("Production engine must replace this stub") end +-- A host must never schedule a polling loop, delayed gate, or install method hooks. +C_Timer = setmetatable({}, {__index=function() error("Unexpected timer in chat host") end}) +hooksecurefunc = function() error("Unexpected hook in chat host") end + +local F = getmetatable(PANEL).__index +function F:UnregisterEvent(name) self.events[name] = nil end +function F:UnregisterAllEvents() self.events = {} end +function F:GetNumMessages() return 0 end +function F:GetSize() return self:GetWidth(), self:GetHeight() end +function F:GetWidth() + if self.allPoints then return self.allPoints:GetWidth() end + local a, b = self.points[1], self.points[2] + if a and b and a[1]=="TOPLEFT" and b[1]=="BOTTOMRIGHT" then + return a[2]:GetWidth() + (b[4] or 0) - (a[4] or 0) + end + return self.w +end +function F:GetHeight() + if self.allPoints then return self.allPoints:GetHeight() end + local a, b = self.points[1], self.points[2] + if a and b and a[1]=="TOPLEFT" and b[1]=="BOTTOMRIGHT" then + return a[2]:GetHeight() + (a[5] or 0) - (b[5] or 0) + end + return self.h +end +local function Resolve(f) + local width, height = f:GetWidth(), f:GetHeight() + if width ~= f.resolvedWidth or height ~= f.resolvedHeight then + f.resolvedWidth, f.resolvedHeight = width, height + if f.scripts.OnSizeChanged then f.scripts.OnSizeChanged(f, width, height) end + for _, child in ipairs(FRAMES) do + if child.parent == f then Resolve(child) end + end + end +end +local setSize, setPoint = F.SetSize, F.SetPoint +function F:SetSize(w,h) setSize(self,w,h); Resolve(self) end +function F:SetPoint(...) setPoint(self,...); Resolve(self) end +function F:SetAllPoints(f) self.allPoints=f; Resolve(self) end +function F:ClearAllPoints() assert(not self.native); self.points={}; self.allPoints=nil end diff --git a/EllesmereUIChat/EllesmereUIChat.lua b/EllesmereUIChat/EllesmereUIChat.lua index 17561a7ed..4cfdc4d53 100644 --- a/EllesmereUIChat/EllesmereUIChat.lua +++ b/EllesmereUIChat/EllesmereUIChat.lua @@ -1085,6 +1085,7 @@ function ECHAT.ApplyPanelHost() ReseatPanel(ns._sidebarSeparateBorder, host) ReseatPanel(ns._chatHoverOverlay, host) _panelHost = host + if ns._onMetersHostChanged then ns._onMetersHostChanged() end end -- The on-demand popups are built the first time they are used and can be built @@ -1351,6 +1352,7 @@ function ECHAT.ApplySidebarIconVisibility() local sbHidden = sbMode == "never" or (sbMode == "mouseover" and _sidebarFadeTarget == 0 and _sidebarFadeAlpha == 0) or ns._chatPassthrough == true + if ns._metersSidebarButton then ns._metersSidebarButton:SetShown(not sbHidden) end local PAIRS = { { "showFriends", "friendsBtn", "friendsCount" }, { "showGuild", "guildBtn", "guildCount" }, @@ -1372,6 +1374,21 @@ function ECHAT.ApplySidebarIconVisibility() end end +-- The optional meter entry participates in the same chain as Settings, even +-- when Settings itself is hidden. No extra entry exists until opt-in. +local function PlaceMetersButton(sb, anchor, gap, topShift, hidden) + local button = ns._metersSidebarButton + if not button then return anchor end + button:SetParent(sb) + local size = 22 * (ECHAT.DB().sidebarIconScale or 1) + button:SetSize(size, size) + button:ClearAllPoints() + if anchor then button:SetPoint("TOP", anchor, "BOTTOM", 0, -gap) + else button:SetPoint("TOP", sb, "TOP", 0, -gap + topShift) end + button:SetShown(not hidden) + return not hidden and button or anchor +end + -- Show/hide individual sidebar icons and re-anchor visible ones to close gaps function ECHAT.ApplySidebarIcons() local cfg = ECHAT.DB() @@ -1411,7 +1428,12 @@ function ECHAT.ApplySidebarIcons() or ns._chatPassthrough == true local anchor = nil + local metersPlaced = false for _, key in ipairs(chainOrder) do + if key == "showSettings" and ns._metersSidebarButton then + anchor = PlaceMetersButton(sb, anchor, ICON_GAP, iconTopShift, sbHidden) + metersPlaced = true + end local refs = CHAIN_REFS[key] local btn = refs and sbd[refs.btn] if btn then @@ -1431,6 +1453,10 @@ function ECHAT.ApplySidebarIcons() end end + if ns._metersSidebarButton and not metersPlaced then + PlaceMetersButton(sb, anchor, ICON_GAP, iconTopShift, sbHidden) + end + -- Scroll is independent; when anchored to the chat panel it is exempt -- from the hidden-sidebar cutoff. if sbd.scrollBtn then @@ -2178,6 +2204,7 @@ end -- Apply icon color to all sidebar icons function ECHAT.ApplyIconColor() + if ns._onMetersStyleChanged then ns._onMetersStyleChanged() end local cfg = ECHAT.DB() local cf1 = _G.ChatFrame1 local sb = cf1 and CFD(cf1).sidebar @@ -2274,6 +2301,7 @@ end -- Scale sidebar icon buttons and friends count text function ECHAT.ApplySidebarIconScale() + if ns._onMetersStyleChanged then ns._onMetersStyleChanged() end local cfg = ECHAT.DB() local scale = cfg.sidebarIconScale or 1.0 local cf1 = _G.ChatFrame1 @@ -3264,6 +3292,7 @@ local function SetChatStackShown(shown) -- Gate for SyncChatFrameState: the panels' shown-follow must not undo -- this hide (the Blizzard frames stay shown, so the follow would). ns._chatStackHidden = not shown + if ns._onMetersHostChanged then ns._onMetersHostChanged() end for i = 1, 20 do local cf = _G["ChatFrame" .. i] local d = cf and CFD(cf) @@ -3352,6 +3381,7 @@ local function SetChatMousePassthrough(on) if _chatPassthrough == on then return end _chatPassthrough = on ns._chatPassthrough = on + if ns._onMetersHostChanged then ns._onMetersHostChanged() end -- Reveal BEFORE the restore pass: TabsSweepBlizzard (run by the tab -- restore inside PassthroughFrames) derives its click state from -- ns._chatPassthrough OR ns._chatStackHidden. With the stack still @@ -5453,12 +5483,14 @@ initFrame:SetScript("OnEvent", function(self) local idleTimer = nil local function IsIdleApplicable() + if ns._metersViewWindow then return false end local cfg = ECHAT.DB() local vis = cfg.visibility or "always" return vis ~= "never" end local function StartIdleFade() + if ns._metersViewWindow then return end if ECHAT.DB().idleFadeEnabled == false then return end if _idleFadeActive then return end _idleFadeActive = true diff --git a/EllesmereUIChat/EllesmereUIChat_Engine.lua b/EllesmereUIChat/EllesmereUIChat_Engine.lua index bb7515f8e..3758ee747 100644 --- a/EllesmereUIChat/EllesmereUIChat_Engine.lua +++ b/EllesmereUIChat/EllesmereUIChat_Engine.lua @@ -146,6 +146,7 @@ local function LayoutWindowSMF(cf) -- edge, and the input covers the hidden lines' hit-zones. smf:SetPoint("TOPLEFT", d.bg, "TOPLEFT", -il, -(it + 6 + (d._smfTopExtra or 0))) smf:SetPoint("BOTTOMRIGHT", d.bg, "BOTTOMRIGHT", -ir, -ib) + if ns._onMetersHostChanged then ns._onMetersHostChanged() end end ECHAT.EngineLayoutWindows = function() for cf in pairs(WINS) do LayoutWindowSMF(cf) end @@ -1057,7 +1058,7 @@ function ECHAT.EngineUpdateCombatLogHost() if not cf2 or not IsCombatLog or not IsCombatLog(cf2) then return end local selected = GENERAL_CHAT_DOCK and FCFDock_GetSelectedWindow and FCFDock_GetSelectedWindow(GENERAL_CHAT_DOCK) - local host = (selected == cf2) + local host = (selected == cf2 and ns._metersViewWindow ~= cf2) if host == (ns._clHosted or false) then return end ns._clHosted = host local win = WINS[cf2] @@ -1088,6 +1089,35 @@ function ECHAT.EngineUpdateCombatLogHost() end end +-- The optional meter view suppresses display layers only. Blizzard's selected +-- dock window, frame fields and scripts are never changed. Combat Log goes +-- through the existing visual host path above rather than a second frame owner. +local metersQuickAlpha +function ECHAT.SetMetersView(cf) + local previous = ns._metersViewWindow + if previous == cf then return end + ns._metersViewWindow = cf + if previous and WINS[previous] then + WINS[previous].smf:SetAlpha(1) + if WINS[previous].track then WINS[previous].track:SetAlpha(1) end + end + ECHAT.EngineUpdateCombatLogHost() + if cf and WINS[cf] then + WINS[cf].smf:SetAlpha(0) + if WINS[cf].track then WINS[cf].track:SetAlpha(0) end + end + local quick = _G.CombatLogQuickButtonFrame_Custom + if quick then + if cf == _G.ChatFrame2 then + metersQuickAlpha = quick:GetAlpha() + quick:SetAlpha(0) + elseif previous == _G.ChatFrame2 then + quick:SetAlpha(metersQuickAlpha) + metersQuickAlpha = nil + end + end +end + ------------------------------------------------------------------------------- -- Mirrors, on our own standalone event frame (never chat-frame hooks): -- UPDATE_CHAT_COLOR recolor stored lines whose chatTypeID matches diff --git a/EllesmereUIChat/EllesmereUIChat_Tabs.lua b/EllesmereUIChat/EllesmereUIChat_Tabs.lua index 269584d7b..9a38c581d 100644 --- a/EllesmereUIChat/EllesmereUIChat_Tabs.lua +++ b/EllesmereUIChat/EllesmereUIChat_Tabs.lua @@ -500,6 +500,7 @@ local function RefreshNow() local cfg = DB() local selected = SelectedWindow() local height = TabHeight() + if ns._onMetersSelectionChanged then ns._onMetersSelectionChanged(selected) end local fontPath = TabFontPath() local fontSize = cfg.tabFontSize or 11 local fontFlag = TabFontFlag() diff --git a/EllesmereUIDamageMeters/EllesmereUIDamageMeters.lua b/EllesmereUIDamageMeters/EllesmereUIDamageMeters.lua index 92cdce4a2..f7c9c1331 100644 --- a/EllesmereUIDamageMeters/EllesmereUIDamageMeters.lua +++ b/EllesmereUIDamageMeters/EllesmereUIDamageMeters.lua @@ -76,6 +76,10 @@ local DM_DEFAULTS = { global = {}, profile = { dm = { + chatEmbedEnabled = false, + chatEmbedAutoDungeon = false, + chatEmbedAutoRaid = false, + chatEmbedReturnOnExit = false, visibility = "always", barTexture = "atrocity", fontSize = 11, @@ -397,7 +401,7 @@ ns.ApplyWinPosition = function(frame, wdb, idx) -- function, so referencing it here would read a nil global and the guard -- would silently never fire. local W = ns._windows and ns._windows[idx] - if W and W.resizing then return end + if W and (W.resizing or W._chatHost) then return end local pos = wdb.position -- Unlock-anchored window (e.g. one meter window anchored to another): the -- anchor system owns the position ENTIRELY -- the standard Build* guard @@ -467,6 +471,7 @@ ns._DM_TYPE_NAMES = DM_TYPE_NAMES -- keys; deletion re-keys anchor/match links via ShiftIndexedAnchorKeys (see W.Destroy). Called -- on login, window add/remove, and profile swap; slots beyond the live count unregister. ns.RegisterDMUnlock = function() + if ns.ApplyChatMeters then ns.ApplyChatMeters() end if not EUI or not EUI.RegisterUnlockElements or not EUI.MakeUnlockElement then return end local MK = EUI.MakeUnlockElement local function winOf(key) @@ -482,17 +487,22 @@ ns.RegisterDMUnlock = function() order = 650 + i, getFrame = function(key) local w = winOf(key) + if w and w._chatHost then return nil end return w and w.frame end, getSize = function(key) local w, idx = winOf(key) + if w and w._chatHost then + local saved = w._chatHost.records[w] + return saved.width, saved.height + end if w and w.frame then return w.frame:GetWidth(), w.frame:GetHeight() end local wdb = idx and WinDB(idx) return wdb and wdb.width or 375, wdb and wdb.height or 150 end, setWidth = function(key, newW) local w, idx = winOf(key) - if not w or not w.frame then return end + if not w or not w.frame or w._chatHost then return end local PPu = EUI.PP local v = math.max(MIN_W, newW or MIN_W) v = PPu and PPu.Snap and PPu.Snap(v) or math.floor(v + 0.5) @@ -502,7 +512,7 @@ ns.RegisterDMUnlock = function() end, setHeight = function(key, newH) local w, idx = winOf(key) - if not w or not w.frame then return end + if not w or not w.frame or w._chatHost then return end local PPu = EUI.PP local v = math.max(MIN_H, newH or MIN_H) v = PPu and PPu.Snap and PPu.Snap(v) or math.floor(v + 0.5) @@ -511,7 +521,7 @@ ns.RegisterDMUnlock = function() end, savePos = function(key, point, relPoint, x, y) local w, idx = winOf(key) - if not idx then return end + if not idx or (w and w._chatHost) then return end WinDB(idx).position = { point = point, relPoint = relPoint or point, x = x, y = y } if w and w.ApplyPosition and not EUI._unlockActive then w.ApplyPosition() end end, @@ -531,7 +541,8 @@ ns.RegisterDMUnlock = function() return nil end, clearPos = function(key) - local _, idx = winOf(key) + local w, idx = winOf(key) + if w and w._chatHost then return end if idx then WinDB(idx).position = nil end end, applyPos = function(key) @@ -2667,18 +2678,22 @@ local function CreateDMWindow(winIdx) for _, w in ipairs(_windows) do w.UpdateVisibility() end end }, "---", - { text = L("Width"), isInput = true, + { text = L("Width"), isInput = W._chatHost == nil, + isDisabled = function() return W._chatHost ~= nil end, getValue = function() return math.floor(frame:GetWidth() + 0.5) end, setValue = function(v) + if W._chatHost then return end -- resize the chat pane while embedded local left, top = frame:GetLeft(), frame:GetTop() frame:SetSize(math.max(MIN_W, v), frame:GetHeight()) if left and top then frame:ClearAllPoints(); frame:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", left, top) end wdb.width = math.floor(frame:GetWidth() + 0.5) end, min = MIN_W }, - { text = L("Height"), isInput = true, + { text = L("Height"), isInput = W._chatHost == nil, + isDisabled = function() return W._chatHost ~= nil end, getValue = function() return math.floor(frame:GetHeight() + 0.5) end, setValue = function(v) + if W._chatHost then return end local left, top = frame:GetLeft(), frame:GetTop() frame:SetSize(frame:GetWidth(), math.max(MIN_H, v)) if left and top then frame:ClearAllPoints(); frame:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", left, top) end @@ -2753,6 +2768,7 @@ local function CreateDMWindow(winIdx) -- mode button and the "Default on M+ Start" key-start hook so both stay in sync. function W.SetDMType(dmType) W.curDMType = dmType; wdb.curDMType = dmType + if ns.ApplyChatMeters then ns.ApplyChatMeters() end if W.CloseSource then W.CloseSource() end W.Refresh() if W._modeIcon then @@ -2919,6 +2935,7 @@ local function CreateDMWindow(winIdx) local SNAP_THRESH = 6 local function SnapDragPosition() + if W._chatHost then return end local myLeft = frame:GetLeft() local myRight = frame:GetRight() if not myLeft or not myRight then return end @@ -3068,7 +3085,7 @@ local function CreateDMWindow(winIdx) end) header:SetScript("OnMouseDown", function(_, button) - if button ~= "LeftButton" or W.windowLocked then return end + if button ~= "LeftButton" or W.windowLocked or W._chatHost then return end if EUI.InProtectedInstance and EUI.InProtectedInstance() then return end local cx, cy = GetCursorPosition(); local es = frame:GetEffectiveScale() dragStartCX = cx/es; dragStartCY = cy/es @@ -3300,7 +3317,7 @@ local function CreateDMWindow(winIdx) end) W.resizeGrip:SetScript("OnMouseDown", function(_, button) - if button ~= "LeftButton" or W.windowLocked then return end + if button ~= "LeftButton" or W.windowLocked or W._chatHost then return end if EUI.InProtectedInstance and EUI.InProtectedInstance() then return end local left, top = frame:GetLeft(), frame:GetTop() if left and top then @@ -4298,6 +4315,7 @@ local function CreateDMWindow(winIdx) RefreshHome() elseif button == "LeftButton" then W.curDMType = dmType; wdb.curDMType = dmType + if ns.ApplyChatMeters then ns.ApplyChatMeters() end W._modeIcon:SetTexture(DM_TYPE_ICONS[dmType] or DM_TYPE_ICONS[Enum.DamageMeterType.DamageDone]) W.HideHome(); W.CloseSource(); W.Refresh() end @@ -4438,6 +4456,7 @@ local function CreateDMWindow(winIdx) -- Visibility function W.UpdateVisibility() if not frame then return end + if W._chatHost then frame:SetAlpha(1); frame:EnableMouse(true); frame:Show(); return end local c = DB() if EUI._unlockActive or ns._optionsOpen then frame:SetAlpha(1); frame:EnableMouse(true); frame:Show(); return end -- Hotkey toggle outranks every configured rule but yields to the two modes above @@ -4459,6 +4478,7 @@ local function CreateDMWindow(winIdx) if EUI.RegisterMouseoverTarget then -- Hover-gated sets only reveal while their conditions pass; a legacy single "mouseover" behaves exactly as before EUI.RegisterMouseoverTarget(frame, function() + if W._chatHost then return false end -- The mouseover scanner shows the frame without going through UpdateVisibility, -- so the toggle has to be refused here as well if ns._toggleHidden then return false end @@ -4470,6 +4490,7 @@ local function CreateDMWindow(winIdx) -- Destroy function W.Destroy() + if W._chatHost then W._chatHost:Retire(W) end if W._hoverTicker then W._hoverTicker:Cancel() end resizeFrame:SetScript("OnUpdate", nil) -- Unregister from global visibility system (prevents ghost resurrection) @@ -4706,7 +4727,7 @@ end ns.ApplyDMSize = function() for _, w in ipairs(_windows) do local wdb = WinDB(w.idx) - if w.frame then + if w.frame and not w._chatHost then if wdb.width then w.frame:SetWidth(math.max(MIN_W, wdb.width)) end if wdb.height then w.frame:SetHeight(math.max(MIN_H, wdb.height)) end end @@ -5544,6 +5565,7 @@ initFrame:SetScript("OnEvent", function(self) return end _windows[winIdx] = CreateDMWindow(winIdx) + if ns.ApplyChatMeters then ns.ApplyChatMeters() end ns.ApplyWindowBorder() C_Timer.After(0, CreateNextWindow) end @@ -5557,6 +5579,7 @@ initFrame:SetScript("OnEvent", function(self) -- Destroy existing windows (frame cleanup only, don't touch DB) for i = #_windows, 1, -1 do local w = _windows[i] + if w._chatHost then w._chatHost:Retire(w) end if w._hoverTicker then w._hoverTicker:Cancel() end if EUI.UnregisterVisibilityUpdater and w.UpdateVisibility then EUI.UnregisterVisibilityUpdater(w.UpdateVisibility) diff --git a/EllesmereUIDamageMeters/EllesmereUIDamageMeters.toc b/EllesmereUIDamageMeters/EllesmereUIDamageMeters.toc index bc2092635..11a8d3e8c 100644 --- a/EllesmereUIDamageMeters/EllesmereUIDamageMeters.toc +++ b/EllesmereUIDamageMeters/EllesmereUIDamageMeters.toc @@ -6,11 +6,13 @@ ## Author: Ellesmere ## Version: 9.1.8 ## Dependencies: EllesmereUI +## OptionalDeps: EllesmereUIChat ## SavedVariables: EllesmereUIDamageMetersDB ## IconTexture: Interface\AddOns\EllesmereUI\media\eg-logo.tga # Main Luas EllesmereUIDamageMeters.lua +EllesmereUIDamageMeters_Chat.lua EllesmereUIDamageMeters_SpellHistory.lua # Options diff --git a/EllesmereUIDamageMeters/EllesmereUIDamageMeters_Chat.lua b/EllesmereUIDamageMeters/EllesmereUIDamageMeters_Chat.lua new file mode 100644 index 000000000..e37b5f860 --- /dev/null +++ b/EllesmereUIDamageMeters/EllesmereUIDamageMeters_Chat.lua @@ -0,0 +1,328 @@ +------------------------------------------------------------------------------- +-- Optional chat host. No frames, events or hooks until explicitly enabled. +-- Placement ownership is shared with the meter module, never inferred from +-- settings/edit-mode visibility. Only disabling the feature releases live meters. +------------------------------------------------------------------------------- +local _, ns = ... +local EUI = EllesmereUI +if not (EUI and ns.EDM) then return end +local Host = {} +Host.__index = Host + +local function Selected() + return GENERAL_CHAT_DOCK and FCFDock_GetSelectedWindow(GENERAL_CHAT_DOCK) +end + +local function SaveFrame(f) + local points = {} + for i = 1, f:GetNumPoints() do points[i] = { f:GetPoint(i) } end + return { parent = f:GetParent(), points = points, width = f:GetWidth(), height = f:GetHeight(), + scale = f:GetScale(), strata = f:GetFrameStrata(), level = f:GetFrameLevel(), + alpha = f:GetAlpha(), shown = f:IsShown(), clamped = f:IsClampedToScreen() } +end + +local function RestoreFrame(f, saved) + f:SetParent(saved.parent) + f:SetScale(saved.scale) + f:SetFrameStrata(saved.strata) + f:SetFrameLevel(saved.level) + f:SetClampedToScreen(saved.clamped) + f:ClearAllPoints() + for _, point in ipairs(saved.points) do f:SetPoint(unpack(point)) end + f:SetSize(saved.width, saved.height) + f:SetAlpha(saved.alpha) + f:SetShown(saved.shown) +end + +function Host:PaintIcon() + local cfg = self.chat.ECHAT.DB() + local r, g, b = cfg.iconR or 1, cfg.iconG or 1, cfg.iconB or 1 + if self.active or cfg.iconUseAccent then r, g, b = EUI.GetAccentColor() end + local alpha = (self.active or self.hovered) and 0.9 or 0.4 + if self.iconR == r and self.iconG == g and self.iconB == b and self.iconAlpha == alpha then return end + self.iconR, self.iconG, self.iconB, self.iconAlpha = r, g, b, alpha + for _, line in ipairs(self.button.lines) do line:SetColorTexture(r, g, b, alpha) end +end + +function Host:Build() + self.root = CreateFrame("Frame", nil, UIParent) + self.root:Hide() + self.body = CreateFrame("Frame", nil, self.root) + self.body:EnableMouse(true) + self.body:EnableMouseWheel(true) + self.body:SetScript("OnMouseWheel", function() end) + self.body:Hide() + self.body:SetScript("OnSizeChanged", function() self:LayoutMeters() end) + self.parking = CreateFrame("Frame", nil, self.root) + self.parking:Hide() + self.waiting = self.body:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + self.waiting:SetPoint("CENTER") + self.waiting:SetText(EllesmereUI.L("Create Damage Done and Healing Done windows to embed them here.")) + + local button = CreateFrame("Button", nil, UIParent) + self.button = button + button:SetSize(22, 22) + button:Hide() + button:RegisterForClicks("LeftButtonUp", "RightButtonUp") + button.lines = {} + for i, height in ipairs({ 7, 15, 11 }) do + local x = 3 + (i - 1) * 6 + for _, edge in ipairs({ {x, 3, 4, 1}, {x, height + 2, 4, 1}, + {x, 3, 1, height}, {x + 3, 3, 1, height} }) do + local line = button:CreateTexture(nil, "ARTWORK") + line:SetPoint("BOTTOMLEFT", button, "BOTTOMLEFT", edge[1], edge[2]) + line:SetSize(edge[3], edge[4]) + line.box = edge + button.lines[#button.lines + 1] = line + end + end + button:SetScript("OnSizeChanged", function(_, width) + local scale = width / 22 + for _, line in ipairs(button.lines) do + local box = line.box + line:ClearAllPoints() + line:SetPoint("BOTTOMLEFT", button, "BOTTOMLEFT", box[1] * scale, box[2] * scale) + line:SetSize(box[3] * scale, box[4] * scale) + end + end) + button:SetScript("OnClick", function(_, mouseButton) + if mouseButton == "RightButton" then EUI:ShowModule("EllesmereUIDamageMeters") + else self:SetActive(not self.active) end + end) + button:SetScript("OnEnter", function() + self.hovered = true + self:PaintIcon() + EUI.ShowWidgetTooltip(button, "Meters\nLeft-click: switch chat / meters\nRight-click: Damage Meters settings") + end) + button:SetScript("OnLeave", function() + self.hovered = false + self:PaintIcon() + EUI.HideWidgetTooltip() + end) + + self.events = CreateFrame("Frame") + self.events:SetScript("OnEvent", function(_, event, mouseButton) + if event == "GLOBAL_MOUSE_DOWN" then + if mouseButton ~= "LeftButton" then return end + -- Hardware-click observation only; native docking/tab scripts stay intact. + for _, cf in ipairs(GENERAL_CHAT_DOCK.DOCKED_CHAT_FRAMES) do + local tab = _G[cf:GetName() .. "Tab"] + if tab and tab:IsShown() and MouseIsOver(tab) and not MouseIsOver(button) then + self:SetActive(false) + return + end + end + else + self:CheckZone() + self:BindPanel() + end + end) +end + +function Host:LayoutMeters() + if not self.enabled then return end + local width, height = self.body:GetWidth(), self.body:GetHeight() + if not width or not height or issecretvalue(width) or issecretvalue(height) + or width <= 8 or height <= 4 then return end + local meterWidth = (width - 6) / 2 + for i = 1, 2 do + local w + if i == 1 then w = self.left else w = self.right end + if w then + local f = w.frame + -- The anchors are installed on adoption; only sizes change here. + if f:GetWidth() ~= meterWidth or f:GetHeight() ~= height - 2 then + f:SetSize(meterWidth, height - 2) + w.FitTitle() + end + end + end +end + +function Host:BindPanel() + if not self.enabled then return end + local chat, selected = self.chat, Selected() + if self.active and selected ~= self.selected then self:SetActive(false) end + local data = selected and EUI._chatCFD(selected) + local panel = data and data.bg + if not panel or chat.ECHAT.DB().enabled == false then + self.root:Hide() + return + end + if self.root:GetParent() ~= panel then + self.root:SetParent(panel) + self.root:ClearAllPoints() + self.root:SetAllPoints(panel) + self.root:SetFrameLevel(panel:GetFrameLevel() + 20) + end + local sidebar = EUI._chatCFD(ChatFrame1).sidebar + if sidebar and self.button:GetParent() ~= sidebar then chat.ECHAT.ApplySidebarIcons() end + local cfg = chat.ECHAT.DB() + local ins = data._bgIns + local top = 2 + (data._smfTopExtra or 0) + local bottom = cfg.inputOnTop and 2 or math.max(2, -((ins and ins.b) or -6) - 8) + if self.top ~= top or self.bottom ~= bottom then + self.top, self.bottom = top, bottom + self.body:ClearAllPoints() + self.body:SetPoint("TOPLEFT", self.root, "TOPLEFT", 1, -top) + self.body:SetPoint("BOTTOMRIGHT", self.root, "BOTTOMRIGHT", -1, bottom) + end + local shown = not chat._chatStackHidden and not chat._chatPassthrough + if self.root:IsShown() ~= shown then self.root:SetShown(shown) end + self.body:SetShown(self.active == true) + self:PaintIcon() +end + +function Host:SetActive(on) + on = on == true and self.enabled == true + if self.active == on then return end + self.active = on + self.selected = on and Selected() or nil + self.chat.ECHAT.SetMetersView(on and self.selected or nil) + if on then self.events:RegisterEvent("GLOBAL_MOUSE_DOWN") + else self.events:UnregisterEvent("GLOBAL_MOUSE_DOWN") end + self.body:SetShown(on) + self:BindPanel() + self:PaintIcon() + if self.chat.ECHAT.ResetIdleTimer then self.chat.ECHAT.ResetIdleTimer() end + if on then + if self.left then self.left.Refresh() end + if self.right then self.right.Refresh() end + end +end + +function Host:Attach(w, slot) + if not w or w._chatHost then return end + local f = w.frame + local saved = SaveFrame(f) + saved.resizeGrip = SaveFrame(w.resizeGrip) + saved.lockBtn = SaveFrame(w.lockBtn) + saved.bodyAlpha = f._bg:GetAlpha() + saved.headerAlpha = w.header._hdrBg:GetAlpha() + self.records[w] = saved + w._chatHost = self + w.resizeGrip:SetParent(self.parking) + w.lockBtn:SetParent(self.parking) + f._bg:SetAlpha(0) + w.header._hdrBg:SetAlpha(0) + f:SetParent(self.body) + f:SetScale(1) + f:SetFrameStrata(self.body:GetFrameStrata()) + f:SetFrameLevel(self.body:GetFrameLevel() + 1) + f:SetClampedToScreen(false) + f:ClearAllPoints() + if slot == 1 then f:SetPoint("TOPLEFT", self.body, "TOPLEFT", 1, -1) + else f:SetPoint("TOPLEFT", self.body, "TOP", 2, -1) end + f:SetAlpha(1) + f:Show() + EUI._unlockRegistrationDirty = true + return true +end + +function Host:Retire(w) + local saved = self.records[w] + if saved then + -- Retired controls belong with their discarded window, not in the + -- long-lived parking frame used by the next profile's meters. + RestoreFrame(w.resizeGrip, saved.resizeGrip) + RestoreFrame(w.lockBtn, saved.lockBtn) + end + self.records[w] = nil + w._chatHost = nil + if self.left == w then self.left = nil end + if self.right == w then self.right = nil end +end + +function Host:SyncWindows() + for _, w in ipairs(ns._windows) do + if not self.left and w ~= self.right and w.curDMType == Enum.DamageMeterType.DamageDone then self.left = w end + if not self.right and w ~= self.left and w.curDMType == Enum.DamageMeterType.HealingDone then self.right = w end + end + local attachedLeft = self:Attach(self.left, 1) + local attachedRight = self:Attach(self.right, 2) + -- Mouseover visibility caches its predicate; ownership changes invalidate it. + if (attachedLeft or attachedRight) and EUI.RequestVisibilityUpdate then EUI.RequestVisibilityUpdate() end + self:LayoutMeters() + self.waiting:SetShown(not self.left and not self.right) +end + +function Host:CheckZone() + local inside, kind = IsInInstance() + local _, _, _, _, _, _, _, id = GetInstanceInfo() + if self.zoneKind == kind and self.zoneID == id then return end + local wasInstance = self.zoneKind == "party" or self.zoneKind == "raid" + self.zoneKind, self.zoneID = kind, id + local cfg = ns.EDM.DB() + if inside and ((kind == "party" and cfg.chatEmbedAutoDungeon) or (kind == "raid" and cfg.chatEmbedAutoRaid)) then + self:SetActive(true) + elseif wasInstance and kind ~= "party" and kind ~= "raid" and cfg.chatEmbedReturnOnExit then + self:SetActive(false) + end +end + +function Host:Enable() + self.enabled = true + self.chat._metersSidebarButton = self.button + self.chat._onMetersHostChanged = function() self:BindPanel() end + self.chat._onMetersStyleChanged = function() + local size = 22 * (self.chat.ECHAT.DB().sidebarIconScale or 1) + self.button:SetSize(size, size) + self:PaintIcon() + end + self.chat._onMetersSelectionChanged = function(selected) + if self.active and selected ~= self.selected then self:SetActive(false) end + self:BindPanel() + end + self.events:RegisterEvent("PLAYER_ENTERING_WORLD") + self.events:RegisterEvent("ZONE_CHANGED_NEW_AREA") + self.chat.ECHAT.ApplySidebarIcons() + self:BindPanel() + self:SyncWindows() + self:CheckZone() +end + +function Host:Disable() + self:SetActive(false) + self.enabled = false + self.events:UnregisterAllEvents() + self.chat._metersSidebarButton = nil + self.chat._onMetersHostChanged = nil + self.chat._onMetersStyleChanged = nil + self.chat._onMetersSelectionChanged = nil + self.chat.ECHAT.ApplySidebarIcons() + self.button:Hide() + self.root:Hide() + for w, saved in pairs(self.records) do + w._chatHost = nil + RestoreFrame(w.frame, saved) + RestoreFrame(w.resizeGrip, saved.resizeGrip) + RestoreFrame(w.lockBtn, saved.lockBtn) + w.frame._bg:SetAlpha(saved.bodyAlpha) + w.header._hdrBg:SetAlpha(saved.headerAlpha) + w.ApplyPosition() + w.UpdateVisibility() + w.FitTitle() + w.Refresh() + self.records[w] = nil + end + self.left, self.right, self.zoneKind, self.zoneID = nil, nil, nil, nil + EUI._unlockRegistrationDirty = true + if EUI.RequestVisibilityUpdate then EUI.RequestVisibilityUpdate() end +end + +function ns.ApplyChatMeters() + local host = ns._chatMeters + if not ns.EDM.DB().chatEmbedEnabled then + if host and host.enabled then host:Disable() end + return + end + local chat = EUI._ModuleNS.EllesmereUIChat + if not (chat and chat.ECHAT.SetMetersView) then return end + if not host then + host = setmetatable({ chat = chat, records = {} }, Host) + ns._chatMeters = host + host:Build() + end + if not host.enabled then host:Enable() + else host:BindPanel(); host:SyncWindows() end +end diff --git a/EllesmereUILocales/_keys.txt b/EllesmereUILocales/_keys.txt index e4e971518..495cc9ca7 100644 --- a/EllesmereUILocales/_keys.txt +++ b/EllesmereUILocales/_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 -# (789 unique). Regenerate after wrapping new strings. Keys passed as +# (790 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) @@ -198,6 +198,7 @@ Cosmetic Could not build the export string. Create Create Custom Buff Manager +Create Damage Done and Healing Done windows to embed them here. Create Group With Create Macro Create Sync Group diff --git a/EllesmereUIOptions/EUI_DamageMeters_Options.lua b/EllesmereUIOptions/EUI_DamageMeters_Options.lua index 133d0946b..f809db576 100644 --- a/EllesmereUIOptions/EUI_DamageMeters_Options.lua +++ b/EllesmereUIOptions/EUI_DamageMeters_Options.lua @@ -192,6 +192,29 @@ initFrame:SetScript("OnEvent", function(self) if EllesmereUI.ClearContentHeader then EllesmereUI:ClearContentHeader() end parent._showRowDivider = true + -- Opt-in chat hosting; the controller is created only by the first enable. + _, h = W:SectionHeader(parent, "CHAT METERS", y); y = y - h + local function ChatToggle(key, label, tooltip) + return { type = "toggle", text = label, tooltip = tooltip, + getValue = function() return Cfg(key) == true end, + setValue = function(value) + Set(key, value) + if ns.ApplyChatMeters then ns.ApplyChatMeters() end + end } + end + _, h = W:DualRow(parent, y, + ChatToggle("chatEmbedEnabled", "Embed Meters in Chat", + "Show existing Damage Done and Healing Done windows side by side using the Meters sidebar icon. Requires EllesmereUI Chat. Resize the chat pane to resize both meters. Disable to restore separate windows."), + ChatToggle("chatEmbedReturnOnExit", "Return to Chat on Exit", + "Return to chat when leaving a dungeon or raid.")) + y = y - h + _, h = W:DualRow(parent, y, + ChatToggle("chatEmbedAutoDungeon", "Auto Show in Dungeons", + "Select Meters when entering a dungeon. Manual chat selection lasts until the next instance transition."), + ChatToggle("chatEmbedAutoRaid", "Auto Show in Raids", + "Select Meters when entering a raid. Manual chat selection lasts until the next instance transition.")) + y = y - h + local function Refresh() if ns.RefreshMeter then ns.RefreshMeter() end end local function ApplyHdr() if ns.ApplyHeader then ns.ApplyHeader() end end local function ApplyBrd() if ns.ApplyBorder then ns.ApplyBorder() end end