diff --git a/.github/damage-meter-chat-reports.png b/.github/damage-meter-chat-reports.png new file mode 100644 index 000000000..83d3d523b Binary files /dev/null and b/.github/damage-meter-chat-reports.png differ diff --git a/.tools/damage-meter-report-tests/README.md b/.tools/damage-meter-report-tests/README.md new file mode 100644 index 000000000..163e7a52f --- /dev/null +++ b/.tools/damage-meter-report-tests/README.md @@ -0,0 +1,41 @@ +# Damage meter report checks + +Requires Python and `lupa` (with its Lua 5.1 runtime): + +```sh +python -m pip install lupa +python .tools/damage-meter-report-tests/run_reports.py +``` + +The runner compiles the damage meter and its options with Lua 5.1, exercises +the real report module against mocked WoW APIs, and executes the real header +button lifecycle against lightweight frame objects. It never controls WoW or +sends live chat. These files are excluded from addon packages by `.pkgmeta`. + +Coverage includes ranked damage/healing reports; current, overall and named +segments; counts and filtered deaths; secret/empty data; UTF-8 message limits; +destination validation; ordered one-click sends; cancellation; opt-in button +creation; dialog event cleanup; and native UI factory, accent and scale contracts. +Native widget rendering and server delivery still require in-game verification. + +## Live verification + +1. With a fresh profile, confirm no report button appears. Enable **Damage + Meters > Header > Enable Chat Reports** and check every window's header. +2. Open damage and healing reports for current, overall and boss segments. + Compare the preview, totals, rates, percentages and ordering with the meter. +3. Choose five entries and click **Send Report** once. Check the header and + available ranked rows arrive in order in Say, Party, Raid, Instance Chat, + Guild, an authorized Officer channel, Whisper, and a joined channel. +4. Check one-player reports, a longer preview, scrolling, narrow meter headers, + alternate accent colors, panel scales, and a non-English locale. +5. Close during a paced send, start combat, leave the destination group, disable + reports, delete the originating window, and switch profiles. Confirm pending + lines stop, the dialog closes as applicable, and no Lua errors appear. +6. Attach before/after screenshots of the headers, dialog and resulting chat. + +Say and custom-channel sends outside an instance stay on the original click's +call stack. Other destinations use a cancellable 0.3-second interval solely +to pace the requested messages. There is no background collection, polling, or +automatic report. Blizzard chat permissions, rate limits and delivery remain +server-controlled; a successful API call is not a server acknowledgement. diff --git a/.tools/damage-meter-report-tests/header_scenarios.lua b/.tools/damage-meter-report-tests/header_scenarios.lua new file mode 100644 index 000000000..e8ed35ecf --- /dev/null +++ b/.tools/damage-meter-report-tests/header_scenarios.lua @@ -0,0 +1,42 @@ +-- Execute the real window's report-button lifecycle with lightweight controls. +local cfg, created, opened, closed = {}, 0, 0, 0 +local W = {settingsBtn={}, segmentBtn={}, modeBtn={}, resetBtn={}, winActionBtn={}} +W.hdrBtns = {W.settingsBtn, W.segmentBtn, W.modeBtn, W.resetBtn, W.winActionBtn} +local env = setmetatable({ + W = W, + DB = function() return cfg end, + EllesmereUI = {L=function(s) return s end}, + ns = {Report={ + Open=function(window) assert(window==W); opened=opened+1 end, + Close=function(window) assert(window==W); closed=closed+1 end, + }}, + MakeHeaderBtn=function(texture, offset, tooltip, click) + created=created+1 + assert(texture=="dm_report.png" and tooltip=="Report to Chat") + return {Click=click, Hide=function(self) self.hidden=true end} + end, +}, {__index=_G}) +local factory=assert(loadstring(REPORT_BUTTON_SOURCE, "report-button lifecycle")) +setfenv(factory, env); factory() +W.SyncReportButton() +assert(created==0 and #W.hdrBtns==5, "default-off builds no button") +cfg.reportEnabled=true +local hooked=0 +W.HookHeaderButton=function(button) assert(button==W.reportBtn); hooked=hooked+1 end +W.SyncReportButton() +assert(created==1 and #W.hdrBtns==6 and W.hdrBtns[4]==W.reportBtn, "enable inserts report control") +assert(hooked==1, "late-created report button inherits header hover behavior") +assert(W.hdrBtns[5]==W.resetBtn and W.hdrBtns[6]==W.winActionBtn, "existing controls keep order") +W.reportBtn.Click() +assert(opened==1, "click reports from owning window") +W.SyncReportButton() +assert(created==1 and #W.hdrBtns==6, "header refresh does not duplicate controls") +cfg.reportEnabled=false +W.SyncReportButton() +assert(W.reportBtn.hidden and #W.hdrBtns==5 and closed==1, "disable hides control and closes dialog") +cfg.reportEnabled=true +W.SyncReportButton() +assert(created==1 and #W.hdrBtns==6, "re-enable reuses control") +assert(hooked==1, "re-enable does not duplicate hover hooks") +assert(W.hdrBtns[1]==W.settingsBtn and W.hdrBtns[3]==W.modeBtn, "other header controls unchanged") +print("PASS: header opt-in, ordering, ownership, disable and re-enable scenarios") diff --git a/.tools/damage-meter-report-tests/report_scenarios.lua b/.tools/damage-meter-report-tests/report_scenarios.lua new file mode 100644 index 000000000..a97f7897a --- /dev/null +++ b/.tools/damage-meter-report-tests/report_scenarios.lua @@ -0,0 +1,283 @@ +-- Run with Lua 5.1 from EllesmereUIDamageMeters. No live chat is sent. +local checks = 0 +local function check(ok, message) assert(ok, message); checks = checks + 1 end +local cfg, timers, sent, notices = {reportEnabled=true}, {}, {}, {} +local combat, instance, party, raid, guild = false, false, false, false, false +local hardware = true +local secret = setmetatable({}, {__tostring = function() error("Secret stringified") end}) +function issecretvalue(v) return rawequal(v, secret) end +function InCombatLockdown() return combat end +LE_PARTY_CATEGORY_HOME, LE_PARTY_CATEGORY_INSTANCE = 1, 2 +function IsInInstance() return instance end +function IsInGroup(category) return category == 2 and instance or category == 1 and (party or raid) end +function IsInRaid() return raid end +function IsInGuild() return guild end +local customId = 7 +function GetChannelList() return customId, "TestChannel", false, 2, "Unavailable", true end +function GetChannelName(name) return name == "TestChannel" and customId or 0 end +C_Timer = { NewTimer = function(delay, fn) + check(delay >= 0.3, "throttled") + local timer = {Cancel=function(self) self.cancelled=true end} + timers[#timers + 1] = function() if not timer.cancelled then fn() end end + return timer +end } +C_ChatInfo = { SendChatMessage = function(message, channel, ...) + if not instance and (channel == "SAY" or channel == "CHANNEL") then + assert(hardware, "Outdoor chat must remain on the click's call stack") + end + sent[#sent + 1] = {message, channel, ...} +end } +DEFAULT_CHAT_FRAME = { AddMessage = function(_, text) notices[#notices + 1] = text end } +local function drain() + hardware=false + while #timers > 0 do table.remove(timers, 1)() end + hardware=true +end +Enum = { DamageMeterType = {DamageDone=0, HealingDone=1, DamageTaken=2, AvoidableDamageTaken=3, + EnemyDamageTaken=4, Interrupts=5, Dispels=6, Deaths=7}, DamageMeterSessionType = {Current=0, Overall=1} } +local types = {[0]="Damage Done", "Healing Done", "Damage Taken", "Avoidable Damage Taken", "Enemy Damage Taken", "Interrupts", "Dispels", "Deaths"} +EllesmereUI = { + L = function(s) return s end, + Lf = function(s, ...) return string.format((s:gsub("%%[12]%$", "%%")), ...) end, +} +local lastMenu +local ns = { EDM = { DB = function() return cfg end }, ReportContext = { + TypeNames=types, SessionNames={[0]="Current", [1]="Overall"}, + Timer=function(n) return string.format("%d:%02d", math.floor(n/60), math.floor(n%60)) end, + Duration=function() return 100 end, Abbreviate=function(n) return tostring(n) end, + HideMenu=function() lastMenu=nil end, +} } +local current = {totalAmount=1000, combatSources={}} +for i = 1, 6 do current.combatSources[i] = {name="Player"..i.."-Realm", totalAmount=(7-i)*40, amountPerSecond=(7-i)*0.4} end +local overall = {totalAmount=4000, combatSources={{name="Healer-Realm", totalAmount=2000, amountPerSecond=20}}} +local past = {totalAmount=800, combatSources={{name="BossPlayer", totalAmount=800, amountPerSecond=8}}} +local fetched +C_DamageMeter = { + GetCombatSessionFromType=function(segment, metric) fetched={segment, metric}; return segment == 1 and overall or current end, + GetCombatSessionFromID=function(id, metric) fetched={id, metric}; return past end, + GetAvailableCombatSessions=function() return {{sessionID=42, name="Test Boss"}} end, +} +local W = {curDMType=0, curSession=0} +assert(loadfile("EllesmereUIDamageMeters_Report.lua"))("EllesmereUIDamageMeters", ns) +local R = ns.Report +local snapshot = assert(R.Snapshot(W)) +local lines = R.Lines(snapshot, 5) +check(#lines == 6, "header plus top five") +check(lines[2]:find("Player1%-Realm: 240"), "rank and realm preserved") +check(lines[2]:find("24.0%%"), "percent uses entire segment, including rows outside top five") +check(lines[2]:find("DPS"), "damage rate") +check(lines[1]:find("Current %[1:40%]"), "current title and duration") +check(current.combatSources[1].name == "Player1-Realm", "source data untouched") +current.combatSources[1], current.combatSources[6] = current.combatSources[6], current.combatSources[1] +check(R.Lines(assert(R.Snapshot(W)), 1)[2]:find("Player1"), "sort all sources before truncation") +check(current.combatSources[1].name == "Player6-Realm", "sorting never mutates live source list") +W.curSession, W.curDMType = 1, 1 +snapshot = assert(R.Snapshot(W)) +check(fetched[1]==1 and fetched[2]==1, "healing window uses own overall selection") +check(R.Lines(snapshot,5)[2]:find("HPS") and R.Lines(snapshot,5)[1]:find("Overall"), "healing overall report") +W.curSessionID=42 +snapshot=assert(R.Snapshot(W)) +check(fetched[1]==42 and R.Lines(snapshot,5)[1]:find("Test Boss"), "historical session by ID") +W.curSessionID=nil; W.curDMType=5 +check(not R.Lines(assert(R.Snapshot(W)),5)[2]:find("/s"), "interrupt counts have no rate") +W.curDMType=6 +check(not R.Lines(assert(R.Snapshot(W)),5)[2]:find("HPS"), "dispel counts have no healing rate") +W.curDMType=0; W.curSession=0 +combat=true +check(R.Snapshot(W)==nil, "combat rejected") +combat=false +for _, field in ipairs({"name","totalAmount","amountPerSecond"}) do + local old=current.combatSources[1][field]; current.combatSources[1][field]=secret + check(R.Snapshot(W)==nil, "secret source "..field.." rejected") + current.combatSources[1][field]=old +end +current.totalAmount=secret; check(R.Snapshot(W)==nil, "secret denominator rejected"); current.totalAmount=1000 +local saved=current.combatSources +current.combatSources={}; check(R.Snapshot(W)==nil, "empty report rejected"); current.combatSources=saved +W.curDMType=7 +W.Refresh=function() W._barSources={{name="DeadPlayer", deathTimeSeconds=25}} end +check(R.Lines(assert(R.Snapshot(W)),5)[2]=="1. DeadPlayer: 0:25", "death report uses filtered chronological meter rows") +W.curDMType=0 +snapshot=assert(R.Snapshot(W)) +snapshot.rows[1].name=string.rep(string.char(195,169), 180) +local long=R.Lines(snapshot,5)[2] +check(#long<=255 and long:sub(-3)=="...", "UTF-8 chat byte cap") +check(long:sub(1, -4):match(string.char(195,169).."$") ~= nil, "truncation preserves UTF-8 character boundary") +check(R.Resolve("PARTY")==nil, "solo party unavailable") +party=true; check(R.Resolve("PARTY")=="PARTY", "home party route") +raid=true; check(R.Resolve("RAID")=="RAID", "raid route") +instance=true; check(R.Resolve("INSTANCE_CHAT")=="INSTANCE_CHAT", "explicit instance route") +guild=true; check(R.Resolve("GUILD")=="GUILD" and R.Resolve("OFFICER")=="OFFICER", "guild and officer routes") +local channel, target=R.Resolve("CHANNEL:TestChannel") +check(channel=="CHANNEL" and target==7, "custom channel ID resolved from current name") +channel,target=R.Resolve("WHISPER", " Friend-Realm ") +check(channel=="WHISPER" and target=="Friend-Realm", "whisper destination") +check(R.Resolve("WHISPER", "")==nil, "empty whisper rejected") +local done +sent={}; lines={"Header", "One", "Two"} +check(R.Send(lines,"PARTY",nil,function(message) done=message end), "send accepted") +check(#sent==1, "first line sent on click") +check(not R.Send(lines,"PARTY"), "overlapping reports blocked") +lines[2]="Mutated"; W.curDMType=1 +drain() +check(#sent==3 and sent[2][1]=="One" and sent[3][2]=="PARTY", "queued immutable report") +check(done=="Report sent.", "completion callback") +sent={}; R.Send({"Header","One"},"GUILD"); combat=true; drain(); combat=false +check(#sent==1, "stop on combat") +sent={}; R.Send({"Header","One"},"PARTY"); party=false; raid=false; drain() +check(#sent==1, "stop when leaving party") +sent={}; R.Send({"Header","One"},"CHANNEL:TestChannel"); customId=9; drain() +check(#sent==1, "channel renumbering never sends to another channel") +sent={}; R.Send({"Header","One"},"GUILD"); R.Cancel(); drain() +check(#sent==1, "cancel stops pending lines") +local realSend=C_ChatInfo.SendChatMessage +C_ChatInfo.SendChatMessage=function() error("chat rejected") end +R.Send({"Header","One"},"GUILD",nil,function(message) done=message end) +check(done:find("could not send"), "chat errors handled") +C_ChatInfo.SendChatMessage=realSend + +-- Minimal UI model: execute the actual dialog and click its controls. +local F={} +local noop=function() end +F.ClearAllPoints=noop +setmetatable(F,{__index=function(_,key) + if not key:match("^[A-Z]") then return nil end + if key:match("^Set") or key:match("^Register") or key=="EnableMouse" or key=="EnableMouseWheel" or key=="EnableKeyboard" or key=="ClearFocus" or key=="StartMoving" or key=="StopMovingOrSizing" then return noop end + error("Unexpected frame method: "..key) +end}) +function CreateFrame(kind,name,parent) + local f=setmetatable({scripts={},shown=true,text="",enabled=true,parent=parent},{__index=F}) + if name then _G[name]=f end + return f +end +function F:RegisterEvent(event) self.events = self.events or {}; self.events[event] = true end +function F:UnregisterAllEvents() self.events = {} end +function F:SetValue(value) self.value = value; if self.scripts.OnValueChanged then self.scripts.OnValueChanged(self,value) end end +function F:GetValue() return self.value or 0 end +function F:CreateTexture() return CreateFrame() end +function F:CreateFontString() return CreateFrame() end +function F:SetScript(k,v) self.scripts[k]=v end +function F:SetText(text) self.text=text end +function F:GetText() return self.text end +function F:SetSize(w,h) self.width,self.height=w,h end +function F:SetHeight(h) self.height=h end +function F:SetWidth(w) self.width=w end +function F:GetHeight() return self.height or 100 end +function F:GetWidth() return self.width or 100 end +function F:GetFrameLevel() return 100 end +function F:SetVerticalScroll(value) self.scroll=value end +function F:GetVerticalScroll() return self.scroll or 0 end +function F:IsMouseOver() return false end +function F:SetColorTexture(...) self.color={...} end +function F:SetVertexColor(...) self.color={...} end +function F:SetScale(scale) self.scale=scale end +function F:GetStringHeight() local _, n=self.text:gsub("\n", ""); return (n+1)*14 end +function F:Show() local shown=self.shown; self.shown=true; if not shown and self.scripts.OnShow then self.scripts.OnShow(self) end end +function F:Hide() local shown=self.shown; self.shown=false; if shown 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:Enable() self.enabled=true end +function F:Disable() self.enabled=false end +function F:Click() if self.enabled and self.scripts.OnClick then self.scripts.OnClick(self) end end +UIParent=CreateFrame(); UISpecialFrames={} +-- The real dropdown/styled-button factories live in the parent/options addon. +-- Stub only these primitives; the report panel's layout and handlers run above. +EllesmereUI._popupFrames={} +EllesmereUI.EXPRESSWAY="Expressway.ttf" +EllesmereUI.WB_COLOURS={} +EllesmereUI.BTN_TXT_A=0.9 +EllesmereUI.DD_BG_R,EllesmereUI.DD_BG_G,EllesmereUI.DD_BG_B,EllesmereUI.DD_BG_A=0.075,0.113,0.141,0.7 +local accent={0.1,0.7,0.9} +local accentCallback +function EllesmereUI.GetAccentColor() return unpack(accent) end +function EllesmereUI.GetPopupScale() return 1.25 end +function EllesmereUI.RegAccent(entry) accentCallback=entry.fn end +function EllesmereUI.MakeFont(parent) return parent:CreateFontString() end +function EllesmereUI.SolidTex(parent) return parent:CreateTexture() end +function EllesmereUI.MakeBorder() return {SetColor=function(self,...) self.color={...} end} end +function EllesmereUI.MakeStyledButton(b,text,size,colors,onclick) + local label=CreateFrame(); label:SetText(text) + b:SetScript("OnClick",onclick) + return b:CreateTexture(),EllesmereUI.MakeBorder(),label +end +local dropdownBuilds=0 +local function buildDropdown(parent,width,level,values,order,get,set,disabled) + dropdownBuilds=dropdownBuilds+1 + local b=CreateFrame("Button",nil,parent); b.label=CreateFrame() + b._refreshLabel=function() b.label:SetText(values[get()] or "") end + b._invalidateMenu=b._refreshLabel + b:SetScript("OnClick",function() + lastMenu={} + for _, key in ipairs(order) do + local k=key + lastMenu[#lastMenu+1]={text=values[k],onClick=function() if not disabled or not disabled(k) then set(k) end end} + end + end) + return b +end +function EllesmereUI:EnsureLoaded() self.BuildDropdownControl=buildDropdown end +local function menuClick(key) + for _, item in ipairs(lastMenu) do if item.text==key then item.onClick(); return end end + error("Missing menu item: "..key) +end +cfg={}; W.curDMType=0; W.curSession=0 +R.Open(W) +check(EllesmereUIDMReport==nil and dropdownBuilds==0, "disabled reports build no UI") +check(not R.Send({"Header","One"},"GUILD"), "disabled reports cannot send") +cfg.reportEnabled=true +R.Open(W) +local p=EllesmereUIDMReport +check(p:IsShown() and p.key=="INSTANCE_CHAT" and p.count==5, "dialog defaults to instance and five entries") +check(p.subtitle.text:find("Damage Done") and #p.lines==6, "dialog describes damage snapshot") +check(dropdownBuilds==2, "uses actual shared dropdown factory boundary for both controls") +check(p.events.PLAYER_REGEN_DISABLED and p.events.GROUP_ROSTER_UPDATE, "events registered only for visible dialog") +check(p.scale==1.25, "popup inherits panel scale") +check(#EllesmereUI._popupFrames==1, "registered for live panel-scale changes") +check(p.accent.color[3]==0.9, "profile accent applied on open") +accent={0.7,0.2,0.5}; accentCallback(unpack(accent)) +check(p.accent.color[1]==0.7 and p.icon.color[3]==0.5, "live theme callback updates accent and report icon") +p.countBtn:Click(); menuClick("3") +check(p.count==3 and cfg.reportLines==3 and #p.lines==4, "entry selector updates preview and setting") +p.channelBtn:Click(); menuClick("Whisper") +check(p.whisper.shown, "recipient field shown for whisper") +p.whisper:SetText("Friend-Realm"); sent={}; p.sendBtn:Click(); drain() +check(#sent==4 and sent[1][2]=="WHISPER" and sent[1][4]=="Friend-Realm", "dialog sends preview to chosen recipient") +check(not p.sendBtn.enabled, "completed report cannot accidentally resend") +p.countBtn:Click(); menuClick("5") +check(p.sendBtn.enabled, "changing report options enables new report") +p.channelBtn:Click(); menuClick("Guild") +check(not p.whisper.shown, "recipient hidden for guild") +sent={}; p.sendBtn:Click(); p.closeBtn:Click(); drain() +check(#sent==1 and not p:IsShown(), "close cancels report") +check(next(p.events)==nil and p.snapshot==nil and p.window==nil, "closed dialog releases events and snapshot") +instance=false; cfg.reportChannel="SAY"; cfg.reportLines=1 +R.Open(W); sent={}; p.sendBtn:Click() +check(#sent==2 and #timers==0, "one click sends header and single-player row to outdoor Say") +check(not p.sendBtn.enabled and p.sendBtn.label.text=="Send Report", "no send-next-line state") +local onePlayerHeight=p:GetHeight() +p.countBtn:Click(); menuClick("5"); sent={}; p.sendBtn:Click() +check(#sent==6 and #timers==0, "one click sends header plus five rows to outdoor Say") +check(p:GetHeight()>onePlayerHeight, "preview height follows row count") +for i=1,5 do check(sent[i+1][1]:find("^"..i.."%."), "ranked rows sent in order") end +p.channelBtn:Click(); menuClick("9. TestChannel"); sent={}; p.sendBtn:Click() +check(#sent==6 and sent[6][2]=="CHANNEL" and sent[6][4]==9 and #timers==0, "outdoor custom channel sends complete report on click") +p:Hide(); W.curDMType=1; W.curSession=1; R.Open(W) +check(p.subtitle.text:find("Healing Done %- Overall"), "second window uses healing overall") +combat=true; p.scripts.OnEvent(p,"PLAYER_REGEN_DISABLED"); combat=false +check(not p:IsShown(), "combat closes dialog") +check(#sent==6, "opening menus never sends chat") + +R.Open(W) +cfg.reportEnabled=false +R.Close(W) +check(not p:IsShown() and next(p.events)==nil, "disabling reports closes dialog and releases events") +R.Open(W) +check(not p:IsShown() and dropdownBuilds==2, "disabled reopen cannot build or show UI") +cfg.reportEnabled=true +R.Open(W) +p.channelBtn:Click(); menuClick("Guild"); sent={}; p.sendBtn:Click() +cfg.reportEnabled=false; drain() +check(#sent==1, "disabling during a queued report stops unsent rows") +R.Close() +check(not p:IsShown() and next(p.events)==nil, "profile rebuild closes reports") +print("PASS: "..checks.." total report checks (Lua ".._VERSION..")") diff --git a/.tools/damage-meter-report-tests/run_reports.py b/.tools/damage-meter-report-tests/run_reports.py new file mode 100644 index 000000000..a41644088 --- /dev/null +++ b/.tools/damage-meter-report-tests/run_reports.py @@ -0,0 +1,23 @@ +"""Run with Python + lupa. No interaction with the live WoW client.""" +import os +from pathlib import Path + +from lupa.lua51 import LuaRuntime + +tests = Path(__file__).resolve().parent +root = tests.parents[1] +meter = root / "EllesmereUIDamageMeters" +os.chdir(meter) +lua = LuaRuntime(unpack_returned_tuples=True) +compile_lua = lua.eval("function(source, name) local f, err = loadstring(source, name); assert(f, err) end") +for path in [*meter.glob("*.lua"), root / "EllesmereUIOptions/EUI_DamageMeters_Options.lua"]: + compile_lua(path.read_text(encoding="utf-8-sig"), str(path)) +print("PASS: meter and options files compile with Lua 5.1 (including local/upvalue limits)") +lua.execute((tests / "report_scenarios.lua").read_text(encoding="utf-8")) + +source = (meter / "EllesmereUIDamageMeters.lua").read_text(encoding="utf-8-sig") +start = source.index(" function W.SyncReportButton()") +end = source.index(" W.SyncReportButton()", start) +lua = LuaRuntime(unpack_returned_tuples=True) +lua.globals().REPORT_BUTTON_SOURCE = source[start:end] +lua.execute((tests / "header_scenarios.lua").read_text(encoding="utf-8")) diff --git a/EllesmereUIDamageMeters/EllesmereUIDamageMeters.lua b/EllesmereUIDamageMeters/EllesmereUIDamageMeters.lua index 92cdce4a2..dc8836734 100644 --- a/EllesmereUIDamageMeters/EllesmereUIDamageMeters.lua +++ b/EllesmereUIDamageMeters/EllesmereUIDamageMeters.lua @@ -131,6 +131,7 @@ local DM_DEFAULTS = { standaloneTimerShowOOC = false, standaloneTimerDesatOOC = false, refreshRate = 1, + reportEnabled = false, -- opt-in chat reports; controls are built lazily hideResetButton = false, -- display the "reset data" button on the damage meter header -- toggleWindowsKey (unset by default) is the hotkey that hides/shows every -- meter window at once. Runtime only: the hidden state is never saved, so a @@ -355,11 +356,13 @@ local function EnsureHeaderButtonsHoverHooks(W) W.header:HookScript("OnEnter", Show) W.header:HookScript("OnLeave", MaybeHide) end + -- Reuse the same hover behavior for controls created after initial setup. + W.HookHeaderButton = function(btn) + btn:HookScript("OnEnter", Show) + btn:HookScript("OnLeave", MaybeHide) + end if W.hdrBtns then - for _, btn in ipairs(W.hdrBtns) do - btn:HookScript("OnEnter", Show) - btn:HookScript("OnLeave", MaybeHide) - end + for _, btn in ipairs(W.hdrBtns) do W.HookHeaderButton(btn) end end end @@ -2193,6 +2196,19 @@ local function ShowEDMMenu(items, anchorBtn) _edmMenu:Show() end +-- Shared formatting for the optional report dialog. No frames or events here. +ns.ReportContext = { + HideMenu = function() + if _edmMenu then _edmMenu:Hide() end + if _edmSub then _edmSub:Hide() end + end, + TypeNames = DM_TYPE_NAMES, + SessionNames = SESSION_TYPE_NAMES, + Duration = GetBreakdownDuration, + Abbreviate = AbbrevNumber, + Timer = FormatTimer, +} + -- Window Factory: creates a fully independent damage meter window with its own frame tree, bar -- pool, scroll state, source window, home screen, and refresh cycle. Returns a window table W with all state and a Destroy method. local UpdateSATimerText -- forward declaration (defined in standalone timer section) @@ -2879,6 +2895,28 @@ local function CreateDMWindow(winIdx) -- Ordered list of header buttons for live resize/reposition W.hdrBtns = { W.settingsBtn, W.segmentBtn, W.modeBtn, W.resetBtn, W.winActionBtn } + function W.SyncReportButton() + local enabled = DB().reportEnabled == true + if enabled and not W.reportBtn then + W.reportBtn = MakeHeaderBtn("dm_report.png", 0, EllesmereUI.L("Report to Chat"), function() + ns.Report.Open(W) + end) + if W.HookHeaderButton then W.HookHeaderButton(W.reportBtn) end + end + if not W.reportBtn then return end + local index + for i, button in ipairs(W.hdrBtns) do + if button == W.reportBtn then index = i; break end + end + if enabled then + if not index then table.insert(W.hdrBtns, 4, W.reportBtn) end + else + if index then table.remove(W.hdrBtns, index) end + W.reportBtn:Hide() + ns.Report.Close(W) + end + end + W.SyncReportButton() LayoutHeaderButtons(W, cfg, btnSize) -- Truncate the header title so it never runs under the right-side icons; mirrors the icon layout math (N buttons of hdrIconSize spaced by btnPad from the right) rather than relying on GetLeft (can lag a SetPoint) @@ -4470,6 +4508,7 @@ local function CreateDMWindow(winIdx) -- Destroy function W.Destroy() + if ns.Report then ns.Report.Close(W) end if W._hoverTicker then W._hoverTicker:Cancel() end resizeFrame:SetScript("OnUpdate", nil) -- Unregister from global visibility system (prevents ghost resurrection) @@ -4660,6 +4699,7 @@ ns.ApplyHeader = function() if w.timerText then SetDMFont(w.timerText, hdrFS) end + w.SyncReportButton() LayoutHeaderButtons(w, cfg, iconSz) -- Close icon is 2px larger than other icons if w._closeIconTex then @@ -5551,6 +5591,7 @@ initFrame:SetScript("OnEvent", function(self) -- Profile swap rebuild: tear down all windows and recreate from new profile _G._EDM_Apply = function() + if ns.Report then ns.Report.Close() end -- Supersede any staggered build still in flight, so its remaining steps do not -- assign over the windows created below and orphan them _buildGen = _buildGen + 1 diff --git a/EllesmereUIDamageMeters/EllesmereUIDamageMeters.toc b/EllesmereUIDamageMeters/EllesmereUIDamageMeters.toc index bc2092635..1480acfd0 100644 --- a/EllesmereUIDamageMeters/EllesmereUIDamageMeters.toc +++ b/EllesmereUIDamageMeters/EllesmereUIDamageMeters.toc @@ -11,6 +11,7 @@ # Main Luas EllesmereUIDamageMeters.lua +EllesmereUIDamageMeters_Report.lua EllesmereUIDamageMeters_SpellHistory.lua # Options diff --git a/EllesmereUIDamageMeters/EllesmereUIDamageMeters_Report.lua b/EllesmereUIDamageMeters/EllesmereUIDamageMeters_Report.lua new file mode 100644 index 000000000..6f8f6d339 --- /dev/null +++ b/EllesmereUIDamageMeters/EllesmereUIDamageMeters_Report.lua @@ -0,0 +1,485 @@ +if EUI_CLIENT_BLOCKED then return end +-- Chat reports use a readable snapshot of this window's metric and segment. +-- Independent of Details; nothing is collected or sent in the background. +local _, ns = ... +local EUI, C = EllesmereUI, ns.ReportContext +if not EUI or not C then return end +local R = {} +ns.Report = R +local panel, sending +local function Settings() return ns.EDM.DB() end +local RESTRICTED = EllesmereUI.L("Report data is not readable yet. Try again after combat.") + +local function Secret(v) return issecretvalue(v) end +local function Number(v) + return not Secret(v) and type(v) == "number" and v == v and v >= 0 and v < math.huge +end +local function Clean(s) + if Secret(s) or type(s) ~= "string" then return nil end + return (s:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "") + :gsub("|H.-|h(.-)|h", "%1"):gsub("|T.-|t", ""):gsub("|A.-|a", "") + :gsub("|", ""):gsub("[%c]", " ")) +end +-- WoW's chat limit is in bytes. Do not cut through a UTF-8 character. +local function ChatLine(s) + s = Clean(s) or "" + if #s <= 255 then return s end + local last = 253 + while last > 1 and s:byte(last) >= 128 and s:byte(last) < 192 do last = last - 1 end + return s:sub(1, last - 1) .. "..." +end +local function Notify(message) + if DEFAULT_CHAT_FRAME then DEFAULT_CHAT_FRAME:AddMessage("EllesmereUI: " .. message) end +end + +local function ReadSnapshot(W) + if InCombatLockdown() then return nil, RESTRICTED end + local api = C_DamageMeter + local session + if W.curSessionID then + session = api.GetCombatSessionFromID(W.curSessionID, W.curDMType) + else + session = api.GetCombatSessionFromType(W.curSession, W.curDMType) + end + if Secret(session) then return nil, RESTRICTED end + if not session then return nil, EllesmereUI.L("There is no data in this segment to report.") end + if Secret(session.combatSources) then return nil, RESTRICTED end + local sources = session.combatSources + if type(sources) ~= "table" or #sources == 0 then return nil, EllesmereUI.L("There is no data in this segment to report.") end + + local deaths = W.curDMType == Enum.DamageMeterType.Deaths + -- Match the meter's chronological death list and its Feign Death filtering. + if deaths then + W.Refresh() + sources = W._barSources or {} + end + local total = session.totalAmount + if not deaths and not Number(total) then return nil, RESTRICTED end + local rateLabel + if W.curDMType == Enum.DamageMeterType.DamageDone then rateLabel = "DPS" + elseif W.curDMType == Enum.DamageMeterType.HealingDone then rateLabel = "HPS" + elseif W.curDMType ~= Enum.DamageMeterType.Interrupts and W.curDMType ~= Enum.DamageMeterType.Dispels and not deaths then rateLabel = "/s" end + local duration = C.Duration(W.curSession, W.curSessionID) + if not Number(duration) then duration = nil end + local rows = {} + for i, source in ipairs(sources) do + if Secret(source) then return nil, RESTRICTED end + local name = Clean(source.name) + if not name or name == "" then return nil, RESTRICTED end + if deaths then + local time = source.deathTimeSeconds + if not Number(time) then return nil, RESTRICTED end + rows[#rows + 1] = { name = name, time = time, order = i } + else + local amount, rate = source.totalAmount, source.amountPerSecond + if not Number(amount) then return nil, RESTRICTED end + if rateLabel then + if Secret(rate) then return nil, RESTRICTED end + if rate == nil and duration and duration > 0 then rate = amount / duration end + if not Number(rate) then return nil, RESTRICTED end + end + if amount > 0 then rows[#rows + 1] = { name = name, amount = amount, rate = rate, order = i } end + end + end + if #rows == 0 or (not deaths and total <= 0) then return nil, EllesmereUI.L("There is no data in this segment to report.") end + if not deaths then + table.sort(rows, function(a, b) + if a.amount == b.amount then return a.order < b.order end + return a.amount > b.amount + end) + end + local segment = EllesmereUI.L(C.SessionNames[W.curSession] or "Current") + if W.curSessionID then + segment = EllesmereUI.L("Previous Combat") + for _, s in ipairs(api.GetAvailableCombatSessions() or {}) do + if not Secret(s.sessionID) and s.sessionID == W.curSessionID then + segment = Clean(s.name) or segment + break + end + end + end + local title = EllesmereUI.L(C.TypeNames[W.curDMType] or "Damage Done") .. " - " .. segment + if duration and duration > 0 then title = title .. " [" .. C.Timer(duration) .. "]" end + return { title = title, rows = rows, total = total, rateLabel = rateLabel, deaths = deaths } +end + +function R.Snapshot(W) + local ok, snapshot, err = pcall(ReadSnapshot, W) + if not ok then return nil, RESTRICTED end + return snapshot, err +end + +function R.Lines(snapshot, count) + count = math.max(1, math.min(40, math.floor(tonumber(count) or 5))) + local lines = { ChatLine("EllesmereUI: " .. snapshot.title) } + for i = 1, math.min(count, #snapshot.rows) do + local row = snapshot.rows[i] + local value + if snapshot.deaths then + value = C.Timer(row.time) + else + value = C.Abbreviate(row.amount) + if snapshot.rateLabel then value = value .. " (" .. C.Abbreviate(row.rate) .. " " .. snapshot.rateLabel .. ")" end + value = value .. string.format(" - %.1f%%", row.amount / snapshot.total * 100) + end + lines[#lines + 1] = ChatLine(i .. ". " .. row.name .. ": " .. value) + end + return lines +end + +local function InstanceGroup() return IsInGroup(LE_PARTY_CATEGORY_INSTANCE) end +function R.Destinations() + local entries = { + { key = "SAY", text = EllesmereUI.L("Say") }, + { key = "PARTY", text = EllesmereUI.L("Party"), disabled = not IsInGroup(LE_PARTY_CATEGORY_HOME) }, + { key = "RAID", text = EllesmereUI.L("Raid"), disabled = not IsInRaid(LE_PARTY_CATEGORY_HOME) }, + { key = "INSTANCE_CHAT", text = EllesmereUI.L("Instance Chat"), disabled = not InstanceGroup() }, + { key = "GUILD", text = EllesmereUI.L("Guild"), disabled = not IsInGuild() }, + { key = "OFFICER", text = EllesmereUI.L("Officer"), disabled = not IsInGuild() }, + { key = "WHISPER", text = EllesmereUI.L("Whisper") }, + } + local channels = { GetChannelList() } + for i = 1, #channels, 3 do + if not channels[i + 2] then + entries[#entries + 1] = { key = "CHANNEL:" .. channels[i + 1], text = channels[i] .. ". " .. channels[i + 1] } + end + end + return entries +end + +function R.Resolve(key, recipient) + for _, entry in ipairs(R.Destinations()) do + if entry.key == key then + if entry.disabled then return nil, nil, EllesmereUI.L("That chat channel is unavailable.") end + if key == "WHISPER" then + recipient = Clean(recipient) + recipient = recipient and recipient:match("^%s*(.-)%s*$") + if not recipient or recipient == "" or recipient:find("%s") then return nil, nil, EllesmereUI.L("Enter a player name, including realm if needed.") end + return key, recipient + end + if key:sub(1, 8) == "CHANNEL:" then + local id = GetChannelName(key:sub(9)) + if not id or id == 0 then return nil, nil, EllesmereUI.L("That chat channel is unavailable.") end + return "CHANNEL", id + end + return key + end + end + return nil, nil, EllesmereUI.L("That chat channel is unavailable.") +end + +-- One click submits the entire report. Outdoor Say/custom-channel calls stay +-- on the original hardware-event stack; a timer loses that authorization. +-- Other destinations pace only the requested chat lines, with no further clicks. +-- The timer is transport pacing, never a gate for reading combat data. +function R.Send(lines, key, recipient, onDone) + if Settings().reportEnabled ~= true then return false, EllesmereUI.L("Chat reports are disabled.") end + if #lines < 2 or #lines > 41 then return false, EllesmereUI.L("There is no data in this segment to report.") end + if sending then return false, EllesmereUI.L("A report is already being sent.") end + if InCombatLockdown() then return false, RESTRICTED end + local channel, target, err = R.Resolve(key, recipient) + if not channel then return false, err end + local send = C_ChatInfo.SendChatMessage + local job = { lines = {}, index = 1, onDone = onDone } + for i, line in ipairs(lines) do job.lines[i] = ChatLine(line) end + sending = job + local function finish(message) + if sending ~= job then return end + sending = nil + if onDone then onDone(message) end + end + local immediate = not IsInInstance() and (channel == "SAY" or channel == "CHANNEL") + local function step() + if sending ~= job then return end + if Settings().reportEnabled ~= true then finish(EllesmereUI.L("Chat reports are disabled.")); return end + if InCombatLockdown() then finish(EllesmereUI.L("Report stopped because combat started.")); return end + local liveChannel, liveTarget = R.Resolve(key, recipient) + if liveChannel ~= channel or liveTarget ~= target then finish(EllesmereUI.L("Report stopped because the destination changed.")); return end + local ok = pcall(send, job.lines[job.index], channel, nil, target) + if not ok then finish(EllesmereUI.L("WoW could not send this report to that channel.")); return end + job.index = job.index + 1 + if job.index > #job.lines then finish(EllesmereUI.L("Report sent.")) + elseif not immediate then job.timer = C_Timer.NewTimer(0.3, step) end + end + if immediate then + while sending == job do step() end + else + step() + end + return true +end + +function R.Cancel() + local job = sending + sending = nil + if job and job.timer then job.timer:Cancel() end + if job and job.onDone then job.onDone(EllesmereUI.L("Report stopped.")) end +end + +-- Use the same primitives and dropdown factory as the EllesmereUI options UI. +local function Font(parent, size, alpha) + local fs = EUI.MakeFont(parent, size, nil, 1, 1, 1, alpha or 1) + fs:SetJustifyH("LEFT") + return fs +end +local function Button(parent, text, width, onClick) + local b = CreateFrame("Button", nil, parent) + b:SetSize(width, 30) + b.bg, b.border, b.label = EUI.MakeStyledButton(b, text, 13, EUI.WB_COLOURS, onClick) + b:SetScript("OnDisable", function() b:SetAlpha(0.4) end) + b:SetScript("OnEnable", function() b:SetAlpha(1) end) + return b +end +local function HideMenus() + C.HideMenu() + if not panel then return end + for _, b in ipairs({panel.channelBtn, panel.countBtn}) do + if b and b._ddMenu then b._ddMenu:Hide() end + end +end +local function SetControlsEnabled(enabled) + for _, b in ipairs({panel.channelBtn, panel.countBtn, panel.sendBtn}) do + if enabled then b:Enable() else b:Disable() end + end + panel.whisper:EnableKeyboard(enabled) +end + +local function UpdateThumb() + local p = panel + local visible = p.scroll:GetHeight() + local range = math.max(0, p.preview:GetHeight() - visible) + p.track:SetShown(range > 0) + if range <= 0 then return end + local trackH = p.track:GetHeight() + local thumbH = math.max(24, trackH * visible / (visible + range)) + p.thumb:SetHeight(thumbH) + p.track:SetMinMaxValues(0, range) + if p.track:GetValue() ~= p.scroll:GetVerticalScroll() then + p.track:SetValue(p.scroll:GetVerticalScroll()) + end +end +local function ScrollTo(value) + local p = panel + local range = math.max(0, p.preview:GetHeight() - p.scroll:GetHeight()) + p.scroll:SetVerticalScroll(math.max(0, math.min(range, value))) + UpdateThumb() +end +local function UpdatePreview() + local p = panel + p.lines = R.Lines(p.snapshot, p.count) + local text = table.concat(p.lines, "\n") + p.preview:SetText(text) + p.preview:ClearFocus() + p.previewMeasure:SetText(text) + -- Measure only after the panel is visible; hidden FontString geometry can + -- be zero on the real client. Reserve two rows even for a single player. + local contentH = math.max(#p.lines * 22 + 16, p.previewMeasure:GetStringHeight() + 16) + local previewH = math.min(192, math.max(74, contentH)) + local whisper = p.key == "WHISPER" + local previewTop = whisper and 240 or 184 + p.whisper:SetShown(whisper); p.whisperLabel:SetShown(whisper) + p.previewHeading:ClearAllPoints(); p.previewHeading:SetPoint("TOPLEFT", 18, -(previewTop - 25)) + p.summary:ClearAllPoints(); p.summary:SetPoint("TOPRIGHT", -18, -(previewTop - 25)) + p.summary:SetText(EllesmereUI.Lf("%1$d entries / %2$d messages", #p.lines - 1, #p.lines)) + p.previewBox:ClearAllPoints(); p.previewBox:SetPoint("TOPLEFT", 18, -previewTop) + p.previewBox:SetSize(484, previewH) + p.scroll:SetSize(464, previewH - 16) + p.preview:SetHeight(contentH - 16) + p:SetHeight(previewTop + previewH + 90) + p.countBtn:_refreshLabel(); p.channelBtn:_refreshLabel() + if not sending then SetControlsEnabled(true) end + p.status:SetText(EllesmereUI.L("One click sends the header and every entry shown below.")) + p.sendBtn.label:SetText(EllesmereUI.L("Send Report")) + p.closeBtn.label:SetText(EllesmereUI.L("Close")) + ScrollTo(0) +end +local function RefreshDestinations() + local p = panel + for k in pairs(p.channelValues) do if k ~= "_menuOpts" then p.channelValues[k] = nil end end + for i = #p.channelOrder, 1, -1 do p.channelOrder[i] = nil end + p.unavailable = {} + for _, entry in ipairs(R.Destinations()) do + p.channelOrder[#p.channelOrder + 1] = entry.key + p.channelValues[entry.key] = entry.text + p.unavailable[entry.key] = entry.disabled + end + if p.channelBtn then p.channelBtn:_invalidateMenu() end +end +local function SelectChannel(key) + local p = panel + for _, entry in ipairs(R.Destinations()) do + if entry.key == key and not entry.disabled then + p.key = key + Settings().reportChannel = key + UpdatePreview() + return true + end + end +end +local function ApplyAccent(r, g, b) + local p = panel + p.accent:SetColorTexture(r, g, b, 1) + p.icon:SetVertexColor(r, g, b, 1) + p.previewHeading:SetTextColor(r, g, b, 0.9) + if not p.sendBtn:IsMouseOver() then + p.sendBtn.bg:SetColorTexture(r, g, b, 0.12) + p.sendBtn.border:SetColor(r, g, b, 0.45) + end +end + +local function CreatePanel() + local p = CreateFrame("Frame", "EllesmereUIDMReport", UIParent) + panel = p + p:Hide(); p:SetSize(520, 360); p:SetPoint("CENTER"); p:SetClampedToScreen(true) + p:SetFrameStrata("DIALOG"); p:SetFrameLevel(100); p:EnableMouse(true) + p:SetMovable(true) + EUI.SolidTex(p, "BACKGROUND", 0.06, 0.08, 0.10, 1):SetAllPoints() + p.border = EUI.MakeBorder(p, 1, 1, 1, 0.15, EUI.PanelPP) + p.accent = EUI.SolidTex(p, "BORDER", 1, 1, 1, 1) + p.accent:SetPoint("TOPLEFT"); p.accent:SetPoint("TOPRIGHT"); p.accent:SetHeight(2) + p.header = CreateFrame("Frame", nil, p) + p.header:SetPoint("TOPLEFT", 0, -2); p.header:SetPoint("TOPRIGHT", 0, -2); p.header:SetHeight(66) + p.header:EnableMouse(true); p.header:RegisterForDrag("LeftButton") + p.header:SetScript("OnDragStart", function() p:StartMoving() end) + p.header:SetScript("OnDragStop", function() p:StopMovingOrSizing() end) + p.icon = p.header:CreateTexture(nil, "ARTWORK") + p.icon:SetTexture("Interface\\AddOns\\EllesmereUIDamageMeters\\Media\\dm_report.png") + p.icon:SetSize(32, 32); p.icon:SetPoint("TOPLEFT", 12, -8) + p.title = Font(p.header, 16); p.title:SetPoint("TOPLEFT", 48, -15); p.title:SetText(EllesmereUI.L("Report to Chat")) + p.subtitle = Font(p.header, 12, 0.6) + p.subtitle:SetPoint("TOPLEFT", 18, -44); p.subtitle:SetWidth(484); p.subtitle:SetWordWrap(false) + local divider = EUI.SolidTex(p, "BORDER", 1, 1, 1, 0.08) + divider:SetPoint("TOPLEFT", 18, -74); divider:SetPoint("TOPRIGHT", -18, -74); divider:SetHeight(1) + local channelLabel = Font(p, 12, 0.65); channelLabel:SetPoint("TOPLEFT", 18, -88); channelLabel:SetText(EllesmereUI.L("Send to")) + local countLabel = Font(p, 12, 0.65); countLabel:SetPoint("TOPLEFT", 346, -88); countLabel:SetText(EllesmereUI.L("Entries")) + p.channelValues = {_menuOpts = {parent = p}} + p.channelOrder = {} + RefreshDestinations() + p.channelBtn = EUI.BuildDropdownControl(p, 312, p:GetFrameLevel()+2, p.channelValues, p.channelOrder, + function() return p.key end, function(key) SelectChannel(key) end, + function(key) return p.unavailable[key] end) + p.channelBtn:SetPoint("TOPLEFT", 18, -108) + local countValues, countOrder = {_menuOpts = {parent = p}}, {1,3,5,10,15,20,25,40} + for _, n in ipairs(countOrder) do countValues[n] = tostring(n) end + p.countBtn = EUI.BuildDropdownControl(p, 156, p:GetFrameLevel()+2, countValues, countOrder, + function() return p.count or 5 end, function(n) + p.count = n; Settings().reportLines = n; UpdatePreview() + end) + p.countBtn:SetPoint("TOPLEFT", 346, -108) + p.whisperLabel = Font(p, 12, 0.65); p.whisperLabel:SetPoint("TOPLEFT", 18, -152); p.whisperLabel:SetText(EllesmereUI.L("Player-Realm")) + p.whisper = CreateFrame("EditBox", nil, p) + p.whisper:SetSize(484, 28); p.whisper:SetPoint("TOPLEFT", 18, -172) + p.whisper:SetAutoFocus(false); p.whisper:SetMaxLetters(100) + p.whisper:SetFont(EUI.EXPRESSWAY, 13, ""); p.whisper:SetTextColor(1,1,1,0.9); p.whisper:SetTextInsets(10,10,0,0) + EUI.SolidTex(p.whisper, "BACKGROUND", EUI.DD_BG_R, EUI.DD_BG_G, EUI.DD_BG_B, EUI.DD_BG_A):SetAllPoints() + EUI.MakeBorder(p.whisper, 1, 1, 1, EUI.DD_BRD_A, EUI.PanelPP) + p.whisper:SetScript("OnEscapePressed", function(self) self:ClearFocus() end) + p.whisper:SetScript("OnEnterPressed", function(self) self:ClearFocus() end) + p.previewHeading = Font(p, 11); p.previewHeading:SetText(EllesmereUI.L("REPORT PREVIEW")) + p.summary = Font(p, 11, 0.45); p.summary:SetJustifyH("RIGHT") + p.previewBox = CreateFrame("Frame", nil, p) + EUI.SolidTex(p.previewBox, "BACKGROUND", 0,0,0,0.22):SetAllPoints() + EUI.MakeBorder(p.previewBox, 1,1,1,0.08, EUI.PanelPP) + p.scroll = CreateFrame("ScrollFrame", nil, p.previewBox) + p.scroll:SetPoint("TOPLEFT", 10,-8); p.scroll:EnableMouseWheel(true) + p.preview = CreateFrame("EditBox", nil, p.scroll) + p.preview:SetMultiLine(true); p.preview:SetAutoFocus(false); p.preview:SetWidth(452) + p.preview:SetFont(EUI.EXPRESSWAY, 13, ""); p.preview:SetTextColor(1,1,1,0.85) + p.preview:SetTextInsets(0,0,0,0); p.preview:SetSpacing(5) + p.previewMeasure = Font(p,13); p.previewMeasure:SetWidth(452); p.previewMeasure:SetSpacing(5) + p.previewMeasure:SetAlpha(0); p.previewMeasure:SetPoint("TOPLEFT", p.previewBox,"TOPLEFT",10,-8) + p.preview:SetScript("OnEscapePressed", function(self) self:ClearFocus() end) + p.preview:SetScript("OnTextChanged", function(self, userInput) + if userInput then self:SetText(table.concat(p.lines, "\n")) end + end) + p.scroll:SetScrollChild(p.preview) + p.scroll:SetScript("OnMouseWheel", function(_, delta) ScrollTo(p.scroll:GetVerticalScroll() - delta * 44) end) + p.track = CreateFrame("Slider", nil, p.previewBox) + p.track:SetWidth(4); p.track:SetPoint("TOPRIGHT", -5,-8); p.track:SetPoint("BOTTOMRIGHT", -5,8) + p.track:SetOrientation("VERTICAL"); p.track:SetMinMaxValues(0, 1); p.track:SetValueStep(1) + p.track:SetObeyStepOnDrag(true) + EUI.SolidTex(p.track,"BACKGROUND",1,1,1,0.03):SetAllPoints() + p.thumb = EUI.SolidTex(p.track,"ARTWORK",1,1,1,0.27); p.thumb:SetSize(4,24) + p.track:SetThumbTexture(p.thumb) + p.track:SetScript("OnValueChanged",function(_, value) ScrollTo(value) end) + p.track:SetScript("OnEnter",function() p.thumb:SetColorTexture(1,1,1,0.5) end) + p.track:SetScript("OnLeave",function() p.thumb:SetColorTexture(1,1,1,0.27) end) + p.status = Font(p,11,0.6); p.status:SetPoint("BOTTOMLEFT",18,54); p.status:SetSize(484,26) + p.status:SetJustifyV("MIDDLE") + p.sendBtn = Button(p,EllesmereUI.L("Send Report"),312,function() + p.whisper:ClearFocus(); p.preview:ClearFocus(); HideMenus() + SetControlsEnabled(false); p.sendBtn.label:SetText(EllesmereUI.L("Sending...")) + p.closeBtn.label:SetText(EllesmereUI.L("Cancel")); p.status:SetText(EllesmereUI.L("Sending the complete report...")) + local ok, err = R.Send(p.lines,p.key,p.whisper:GetText(),function(message) + SetControlsEnabled(true); p.sendBtn.label:SetText(EllesmereUI.L("Send Report")) + p.closeBtn.label:SetText(EllesmereUI.L("Close")) + p.status:SetText(message) + if message == EllesmereUI.L("Report sent.") then p.sendBtn:Disable() end + end) + if not ok then + SetControlsEnabled(true); p.sendBtn.label:SetText(EllesmereUI.L("Send Report")) + p.closeBtn.label:SetText(EllesmereUI.L("Close")); p.status:SetText(err) + end + end) + p.sendBtn:SetPoint("BOTTOMLEFT",18,16) + p.sendBtn:SetScript("OnEnter",function() + local r,g,b = EUI.GetAccentColor() + p.sendBtn.bg:SetColorTexture(r,g,b,0.22); p.sendBtn.border:SetColor(r,g,b,0.75) + p.sendBtn.label:SetTextColor(1,1,1,1) + end) + p.sendBtn:SetScript("OnLeave",function() + local r,g,b = EUI.GetAccentColor() + p.sendBtn.bg:SetColorTexture(r,g,b,0.12); p.sendBtn.border:SetColor(r,g,b,0.45) + p.sendBtn.label:SetTextColor(1,1,1,EUI.BTN_TXT_A) + end) + p.closeBtn = Button(p,EllesmereUI.L("Close"),156,function() p:Hide() end) + p.closeBtn:SetPoint("BOTTOMRIGHT",-18,16) + p:SetScript("OnHide",function() + R.Cancel(); HideMenus(); p.whisper:ClearFocus(); p.preview:ClearFocus() + p:UnregisterAllEvents(); p:StopMovingOrSizing() + p.window, p.snapshot, p.lines = nil, nil, nil + end) + p:SetScript("OnShow",function() + p:RegisterEvent("PLAYER_REGEN_DISABLED") + p:RegisterEvent("CHAT_MSG_CHANNEL_NOTICE") + p:RegisterEvent("GROUP_ROSTER_UPDATE"); p:RegisterEvent("PLAYER_GUILD_UPDATE") + end) + p:SetScript("OnEvent",function(_, event) + if event == "PLAYER_REGEN_DISABLED" then p:Hide() + elseif p:IsShown() then RefreshDestinations() end + end) + EUI.RegAccent({type="callback",obj=p,fn=function(r,g,b) + if p:IsShown() then ApplyAccent(r,g,b) end + end}) + EUI._popupFrames[#EUI._popupFrames+1] = {popup=p} + UISpecialFrames[#UISpecialFrames+1] = "EllesmereUIDMReport" + return p +end + +function R.Close(W) + if panel and (not W or panel.window == W) then panel:Hide() end +end + +function R.Open(W) + if Settings().reportEnabled ~= true then return end + if sending then Notify(EllesmereUI.L("A report is already being sent.")); return end + if panel and panel:IsShown() and panel.window == W then panel:Hide(); return end + local snapshot, err = R.Snapshot(W) + if not snapshot then Notify(err); return end + -- Native dropdowns are deferred with the options widgets. Load them on the + -- first report click; do not create a second imitation of the design system. + if not EUI.BuildDropdownControl then EUI:EnsureLoaded() end + if not EUI.BuildDropdownControl then Notify(EllesmereUI.L("EllesmereUI options must be enabled to open the report dialog.")); return end + HideMenus() + local p = panel or CreatePanel() + p.window, p.snapshot = W, snapshot + p.count = math.max(1, math.min(40, math.floor(tonumber(Settings().reportLines) or 5))) + p.subtitle:SetText(snapshot.title) + p:SetScale(EUI.GetPopupScale()) + p:Show() + RefreshDestinations() + local default = InstanceGroup() and "INSTANCE_CHAT" or IsInRaid(LE_PARTY_CATEGORY_HOME) and "RAID" + or IsInGroup(LE_PARTY_CATEGORY_HOME) and "PARTY" or "SAY" + if not SelectChannel(Settings().reportChannel or default) then SelectChannel(default) end + ApplyAccent(EUI.GetAccentColor()) +end diff --git a/EllesmereUILocales/_keys.txt b/EllesmereUILocales/_keys.txt index e4e971518..afcaf352b 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 +# (821 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) @@ -11,6 +11,7 @@ %1$d Death -%2$s %1$d Deaths -%2$s %1$d did not fit +%1$d entries / %2$d messages %1$s (max %2$s) %1$s Extra Ability %1$s Module @@ -59,6 +60,7 @@ A profile named \ A quick view of every font setting in one place. A quick view of every texture setting in one place. +A report is already being sent. ADD NEW AFK Abilities @@ -89,6 +91,7 @@ Add To Add Variant Add \ Add a Buff Spell +Add a report button to each meter header. Choose a chat channel and preview the selected segment before sending. Add a talent variant of this entry Add an action Add in Blizzard CDM @@ -157,6 +160,7 @@ Changes made in MultiBag will affect the positions of items in default Blizzard Changes made in OneBag will affect the positions of items in default Blizzard bags Changes the crosshair color when your current target is out of range. Chat recall is unavailable here. Use Alt+Up and Alt+Down. +Chat reports are disabled. Check All Checked transforms are removed automatically when applied to you. Choose Zones @@ -274,12 +278,14 @@ Edit Tracked Auras Edit in %1$s Editing Spec Element Options +EllesmereUI options must be enabled to open the report dialog. Empty Action Menu Empty Slots Enable Enable %1$s to edit its text settings. Enable %1$s to edit its texture settings. Enable %1$s to edit these settings. +Enable Chat Reports Enable hash lines first Enable/Disable Bar per Form Enable/Disable per Form @@ -293,6 +299,7 @@ Enter a new name for \ Enter a new name for this group: Enter a new name for this indicator: Enter a new name: +Enter a player name, including realm if needed. Enter a valid spell ID Enter a volume from 0 to 100 for %1$s: Enter amount in gold: @@ -300,6 +307,7 @@ Enter the spell ID to add to this filter. Enter the spell ID to always show on nameplates. Enter the spell ID to exclude from nameplates. Enter the spell ID to track on this indicator. +Entries Equipment Slot Everything Exclude This Spell @@ -339,6 +347,7 @@ Great Vault Group Grow Grow Direction +Guild H Match H Matched HS @@ -388,6 +397,7 @@ Inherited from %1$s Inherited from %1$s. Edit it there, or use the tile toggle to enable or disable it for this spec. Inherited from %1$s. Editable only there. Inky +Instance Chat Interrupted Invalid import string. Inventory @@ -484,6 +494,8 @@ Nothing to add OFFLINE OH OK +Officer +One click sends the header and every entry shown below. One-time copy of your Player Aura Bars filter setups into these filters. Same-named filters are OVERWRITTEN; the two lists stay separate afterwards. OneBag OneBank @@ -501,6 +513,7 @@ PER-SPELL OPTIONS PREVIEW Page Paragon +Party Party Keystones Paste Interrupted Paste your profile string here... @@ -509,6 +522,7 @@ Percent Performance Reminder Pinned Items Pixel Glow Settings +Player-Realm Popular Presets Position and size settings are not synced and keep each profile's own values. Positions @@ -522,6 +536,7 @@ Preview Preview Mode Previewing Override: %1$s Previewing Overrides: %1$s, %2$s +Previous Combat Processing Profession %1$d Profile @@ -533,7 +548,9 @@ Quest Item Hotkey Quickbind Quickbind: hover a spell, press a key REAGENTS +REPORT PREVIEW Racial +Raid Raid Tools cannot be toggled by slash command in combat -- use the keybind. Raid Tools is disabled in the EllesmereUI options. Raid Tools is hidden in a raid without leader or assist -- none of its buttons work there. @@ -565,6 +582,12 @@ Renders this slot's element above the rest of the nameplate. Renown Repaired all items for %s Replace with Buff +Report data is not readable yet. Try again after combat. +Report sent. +Report stopped because combat started. +Report stopped because the destination changed. +Report stopped. +Report to Chat Reposition freely with Reposition this element with Shift+Click and Drag. Reposition this element within Blizzard Edit Mode @@ -588,6 +611,7 @@ Save Save & Exit Save Style Preset Saved +Say Search Features... Search Module Settings... Search Spells @@ -606,6 +630,10 @@ Select a talent... Select or add an entry Select which specs you want %1$s to be assigned to Select... +Send Report +Send to +Sending the complete report... +Sending... Server Time Settings Shift + Left-Click Drag to permanently save a panel's position. @@ -675,11 +703,13 @@ Target Target Unit Targeting: Targets +That chat channel is unavailable. The pasted string could not be read completely. Please paste it again. The scriptProfile CVar is enabled. The single stack threshold is off while Multiple Thresholds is on. The taintLog CVar is enabled. The taintLog and scriptProfile CVars are enabled. +There is no data in this segment to report. These are the same settings found on each module's own pages, not separate ones. Thickness This Action Bar is also used as the Moonkin Form bar.\nChanging spells on a Druid for this bar will also change them on your Moonkin Form bar. @@ -740,11 +770,13 @@ Warbank Weapon Welcome to EllesmereUI What's new in EllesmereUI. +Whisper Whistle Width % Window Skins Window scale, icon zoom and item level settings are shared with the Bags page. Window skins changed for this profile. A UI reload is needed to apply them. +WoW could not send this report to that channel. World Wrap Wrong Demon diff --git a/EllesmereUIOptions/EUI_DamageMeters_Options.lua b/EllesmereUIOptions/EUI_DamageMeters_Options.lua index 133d0946b..8481c1426 100644 --- a/EllesmereUIOptions/EUI_DamageMeters_Options.lua +++ b/EllesmereUIOptions/EUI_DamageMeters_Options.lua @@ -635,6 +635,15 @@ initFrame:SetScript("OnEvent", function(self) end y = y - h + -- Optional report control; keep the final header row filled left to right. + _, h = W:DualRow(parent, y, + { type="toggle", text=EllesmereUI.L("Enable Chat Reports"), + tooltip=EllesmereUI.L("Add a report button to each meter header. Choose a chat channel and preview the selected segment before sending."), + getValue=function() return Cfg("reportEnabled") == true end, + setValue=function(v) Set("reportEnabled", v); ApplyHdr() end }, + { type="label", text="" }) + y = y - h + -- ── BAR DESIGN ────────────────────────────────────────────────── _, h = W:SectionHeader(parent, "BARS", y); y = y - h