DopingControl 0.7.0

This commit is contained in:
2026-10-15 12:29:40 +02:00
commit e6561d3bce
48 changed files with 19254 additions and 0 deletions
+118
View File
@@ -0,0 +1,118 @@
-- DopingControl ui/cellsheet.lua
--
-- The matrix draws one small box per cell: a solid background with a 1px
-- border whose color carries the state. Done with SetBackdrop that costs NINE
-- texture regions per cell (measured in the client: bare frame 0, backdrop
-- with bgFile only 1, backdrop with edgeFile 9). At 805 cells that is ~7200
-- regions of pure decoration.
--
-- Instead every box is baked into one sheet and a cell owns a single texture
-- region that picks its box with SetTexCoord. The border is part of the image,
-- so the result is pixel-identical -- the sheet is generated from the same
-- DC.COLORS palette by tools/gen-cellsheet.js.
--
-- Pure Lua on purpose: no WoW API at file scope, so the offline tests can
-- dofile it under lua50.exe.
DC_Cells = {}
DC_Cells.SHEET = {
path = "Interface\\AddOns\\DopingControl\\textures\\cells",
size = 256, -- power of two, mandatory: a 12x12 TGA fails silently
tileW = 64, -- holds the widest sprite (34) with room to spare
tileH = 32, -- holds CELL_H (23)
perRow = 4, -- 256 / 64
}
-- Cell height never varies; only the width does (trinket columns are 34).
DC_Cells.CELL_H = 23
DC_Cells.WIDTHS = { 30, 34 }
-- Every (background, border) pair a CELL can take, in sheet order. Borders
-- given as a number are DC.QUALITY indices, everything else is a DC.COLORS
-- key. Order is the wire format of the sheet: appending is safe, reordering
-- means regenerating the TGA.
DC_Cells.VARIANTS = {
{ name = "HAS", bg = "hatBg", border = "hatBorder" },
{ name = "MISSING", bg = "fehltBg", border = "fehltBorder" },
{ name = "UNKNOWN", bg = "unbekBg", border = "unbekBorder" },
{ name = "SKIP", bg = "skip", border = "skipBorder" },
{ name = "NEUTRAL", bg = "theadBg", border = "line" },
{ name = "ITEM_HAS", bg = "theadBg", border = "hatBorder" },
{ name = "ITEM_MISSING", bg = "theadBg", border = "fehltBorder" },
{ name = "Q_RARE", bg = "theadBg", border = "rareBorder" },
{ name = "Q_EPIC", bg = "theadBg", border = "epicBorder" },
{ name = "Q_0", bg = "theadBg", border = 0 },
{ name = "Q_1", bg = "theadBg", border = 1 },
{ name = "Q_2", bg = "theadBg", border = 2 },
{ name = "Q_5", bg = "theadBg", border = 5 },
{ name = "Q_6", bg = "theadBg", border = 6 },
}
DC_Cells.INDEX = {}
for i = 1, table.getn(DC_Cells.VARIANTS) do
DC_Cells.INDEX[DC_Cells.VARIANTS[i].name] = i
end
-- Tile slot of a variant/width pair: each variant owns two consecutive tiles,
-- one per width, in DC_Cells.WIDTHS order.
local function slotOf(name, w)
local vi = DC_Cells.INDEX[name]
if not vi then
return nil
end
for k = 1, table.getn(DC_Cells.WIDTHS) do
if DC_Cells.WIDTHS[k] == w then
return (vi - 1) * table.getn(DC_Cells.WIDTHS) + (k - 1)
end
end
return nil
end
DC_Cells.SlotOf = slotOf
-- SetTexCoord arguments for one box. Returns four numbers or nil -- nil so a
-- typo shows up as a missing box instead of silently sampling tile 0.
function DC_Cells.Coords(name, w)
local slot = slotOf(name, w)
if not slot then
return nil
end
local S = DC_Cells.SHEET
local col = math.mod(slot, S.perRow)
local row = math.floor(slot / S.perRow)
local x0 = col * S.tileW
local y0 = row * S.tileH
return x0 / S.size,
(x0 + w) / S.size,
y0 / S.size,
(y0 + DC_Cells.CELL_H) / S.size
end
-- State + slot kind + item quality -> variant name. Mirrors exactly what the
-- old SetBackdropG call sites in ui/matrix.lua chose -- which, for a nil
-- quality, differed BY KIND (see ui/matrix.lua's M.PaintItemTile, old lines
-- 1965-1990, confirmed at tools/luatests/test_cellsheet.lua):
-- * trinket: M.qualityColors(item.quality) ran unconditionally, and for
-- quality == nil that resolves to DC.QUALITY[1] (common/white) via the
-- `q or 1` fallback -- state-INDEPENDENT.
-- * ench: quality == nil skipped M.qualityColors and fell back to
-- C.hatBorder/C.fehltBorder/C.line BY STATE -- exactly
-- ITEM_HAS/ITEM_MISSING/NEUTRAL.
-- So trinket's nil-quality case must NOT share ench's state-based fallback.
function DC_Cells.VariantFor(state, kind, quality)
if kind == "ench" or kind == "trinket" then
if quality == 3 then return "Q_RARE" end
if quality == 4 then return "Q_EPIC" end
if quality ~= nil then return "Q_" .. quality end
if kind == "trinket" then
return "Q_1"
end
if state == "HAS" then return "ITEM_HAS" end
if state == "MISSING" then return "ITEM_MISSING" end
return "NEUTRAL"
end
if state == "HAS" then return "HAS" end
if state == "MISSING" then return "MISSING" end
if state == "UNKNOWN" then return "UNKNOWN" end
return "SKIP"
end
+4078
View File
File diff suppressed because it is too large Load Diff
+1919
View File
File diff suppressed because it is too large Load Diff
+447
View File
@@ -0,0 +1,447 @@
-- DopingControl ui/report.lua
-- Report preview overlay + raid-chat send queue.
--
-- Pure part (offline-testable):
-- DC_Report.SendDisabled(source) -> bool (true iff source == "sim")
-- DC_Report.QueueCreate(lines) -> queue (independent copy of lines)
-- DC_Report.QueueTick(q, now, src) -> line-to-send-now | nil
-- DC_Report.QueueLabel(q) -> "sending k/n" button label
-- DC_Report.BuildLines(store, db, tab) -> lines, vm (via DC_Model)
--
-- Queue semantics:
-- * First line goes out immediately on Send; every further line waits
-- >= SEND_INTERVAL (0.6 s) measured from the PREVIOUS ACTUAL SEND
-- (the client chat throttle is ~10 msgs/10 s --
-- 0.6 s spacing stays safely under it even with other addons chatting).
-- * ABORT: if store.source flips to "sim" mid-drain, the very next tick
-- aborts the queue without sending (synthetic names must
-- never reach real chat). An aborted queue never resumes.
-- * In sim mode the Send button never activates in the first place
-- (SendDisabled predicate), with tooltip "disabled in test mode".
--
-- WoW wiring (behind `if CreateFrame then`): overlay frame
-- DopingControlReportFrame, DIALOG strata, anchored over the matrix frame,
-- scrollable preview FontString showing DC_Model.reportLines output EXACTLY
-- as it will be sent, buttons "Send to raid chat" + "Close".
-- SendChatMessage(line, "RAID") only ever fires from the queue drainer here
-- (single-sender rule).
--
-- Deliberate resolutions:
-- * DC_Report.Open() is exposed with OPTIONAL (vm, tab) arguments: the
-- matrix "Report..." button may pass its already-built vm; with no
-- arguments the report builds its own vm from DC.store via
-- DC_Model/DC_Roles and the current tab (DC_Matrix.GetTab()/.tab if
-- the matrix exposes it, else DC.currentTab, else "CONSUMABLES").
-- * The matrix frame global name is not fixed; anchoring
-- tries DC_Matrix.frame, then DopingControlFrame /
-- DopingControlMatrixFrame, else UIParent.
-- * Closing the overlay (Close / ESC) while draining cancels the rest of
-- the queue -- hidden frames get no OnUpdate on 1.12, so a hidden
-- "paused" queue would silently resume minutes later; cancel is the
-- predictable behavior.
DopingControl = DopingControl or {}
local DC = DopingControl
DC_Report = DC_Report or {}
local R = DC_Report
-- ==================================================================
-- Pure queue core
-- ==================================================================
R.SEND_INTERVAL = 0.6 -- seconds between two sent lines
R.CHAT_TYPE = "RAID"
-- Send is dead exactly in test mode. nil source (no scan
-- yet) is NOT sim: sending a "coverage 0/0" report is pointless but safe.
function R.SendDisabled(source)
return source == "sim"
end
-- The chat API rejects the WHOLE message with "Invalid escape code in chat
-- message" when it meets a "|" that does not open a valid escape sequence
-- (|c |r |H |h |T |t |n ||). Our header line uses " | " as a plain ASCII
-- separator, so every report was refused. "||" is the escape for a literal
-- pipe and still renders as a single "|", which is why this runs at the
-- send point and NOT inside DC_Model.reportLines: the preview shows what
-- reportLines returns and must keep showing the text as it APPEARS in chat.
-- Report lines are pure ASCII by design (no item links, no colour codes),
-- so doubling every pipe cannot damage a real escape sequence here.
function R.ChatSafe(line)
if not line then
return line
end
return (string.gsub(line, "|", "||"))
end
-- Create a drain queue from an array of lines. Takes an independent copy
-- (the preview may be rebuilt while an old queue is still draining).
function R.QueueCreate(lines)
local q = {
lines = {},
n = 0,
sent = 0,
nextAt = 0, -- absolute time the next send is allowed
done = false,
aborted = false,
}
local n = table.getn(lines)
for i = 1, n do
table.insert(q.lines, lines[i])
end
q.n = n
if n == 0 then
q.done = true
end
return q
end
-- One drain step. now = current time (GetTime() in-game), source = current
-- store source ("scan"/"sim"/nil). Returns the line to send NOW, or nil.
-- Sets q.done when the last line went out, q.aborted on a sim flip.
function R.QueueTick(q, now, source)
if not q or q.done or q.aborted then
return nil
end
if source == "sim" then
q.aborted = true -- never resumes
return nil
end
if now < q.nextAt then
return nil
end
q.sent = q.sent + 1
local line = q.lines[q.sent]
q.nextAt = now + R.SEND_INTERVAL
if q.sent >= q.n then
q.done = true
end
return line
end
-- Button label while a queue exists.
function R.QueueLabel(q)
if not q then
return nil
end
if q.aborted then
return "aborted"
end
return string.format("sending %d/%d", q.sent, q.n)
end
-- ==================================================================
-- Line building (pure given a store; uses DC_Model + DC_Roles)
-- ==================================================================
-- "14:04"-style timestamp: WoW 1.12 exposes a global date(); plain
-- Lua 5.0 has os.date.
function R.TimeStr()
if type(date) == "function" then
return date("%H:%M")
end
if os and type(os.date) == "function" then
return os.date("%H:%M")
end
return "--:--"
end
-- Build the report lines for a store + db + tab. Returns lines, vm.
function R.BuildLines(store, dbt, tab)
store = store or DC.store or { players = {} }
tab = tab or "CONSUMABLES"
-- Same expectation resolver the matrix uses, or the report preview
-- would show different columns and different gap totals than the grid
-- it was opened from (demo mode). Safe for the SEND path too: the demo
-- layer only applies over the "sim" store, and R.SendDisabled blocks
-- sending on exactly that source. Guarded because ui/report.lua is
-- loadable without ui/matrix.lua (tools/luatests/test_report_queue.lua).
local expect
if DC_Matrix and DC_Matrix.ExpectTable then
expect = DC_Matrix.ExpectTable(dbt)
else
expect = (dbt and dbt.expectations) or DC.DEFAULT_EXPECT or {}
end
local function roleOf(p)
return DC_Roles.resolve(dbt, store.source, p)
end
local vm = DC_Model.build(store, roleOf, expect, tab)
local label = (DC.TAB_LABEL and DC.TAB_LABEL[tab]) or tab
return DC_Model.reportLines(vm, label, R.TimeStr()), vm
end
-- ==================================================================
-- WoW wiring
-- ==================================================================
if CreateFrame then
local frame = nil -- DopingControlReportFrame (lazy)
local previewText = nil -- the preview FontString
local scrollChild = nil
local infoText = nil
local sendBtn = nil
local sendBlocker = nil -- invisible tooltip overlay while disabled
local lines = nil -- current preview lines (array)
local queue = nil -- active drain queue or nil
local function db()
return DopingControlDB
end
local function currentSource()
return DC.store and DC.store.source
end
local function currentTab()
if DC_Matrix then
if DC_Matrix.GetTab then
return DC_Matrix.GetTab()
end
if DC_Matrix.tab then
return DC_Matrix.tab
end
end
return DC.currentTab or "CONSUMABLES"
end
-- The matrix frame global name is not guaranteed; try the likely
-- handles, fall back safely.
local function anchorFrame()
if DC_Matrix and DC_Matrix.frame then
return DC_Matrix.frame
end
local m = getglobal("DopingControlFrame")
or getglobal("DopingControlMatrixFrame")
return m or UIParent
end
local function panelBackdrop()
if DC_Widgets and DC_Widgets.PANEL_BACKDROP then
return DC_Widgets.PANEL_BACKDROP
end
if DC.PANEL_BACKDROP then
return DC.PANEL_BACKDROP
end
return {
bgFile = "Interface\\AddOns\\DopingControl\\textures\\panel_bg",
edgeFile = "Interface\\AddOns\\DopingControl\\textures\\panel_border",
tile = true, tileSize = 128, edgeSize = 16,
insets = { left = 5, right = 5, top = 5, bottom = 5 },
}
end
-- Send button + blocker state machine: draining -> disabled "sending
-- k/n"; sim -> disabled with tooltip blocker; else enabled.
local function UpdateSendState()
if not sendBtn then
return
end
if queue and not queue.done and not queue.aborted then
sendBtn:Disable()
sendBtn:SetText(R.QueueLabel(queue))
sendBlocker:Hide()
elseif R.SendDisabled(currentSource()) then
sendBtn:Disable()
sendBtn:SetText("Send to raid chat")
sendBlocker:Show()
else
sendBtn:Enable()
sendBtn:SetText("Send to raid chat")
sendBlocker:Hide()
end
end
-- OnUpdate drainer (installed on the frame only while a queue exists;
-- no allocation on the idle path).
local function OnUpdateDrain()
if not queue then
this:SetScript("OnUpdate", nil)
return
end
local line = R.QueueTick(queue, GetTime(), currentSource())
if line then
SendChatMessage(R.ChatSafe(line), R.CHAT_TYPE)
UpdateSendState()
end
if queue.done or queue.aborted then
queue = nil
this:SetScript("OnUpdate", nil)
UpdateSendState()
end
end
local function OnSendClick()
if queue then
return
end
if not lines or table.getn(lines) == 0 then
return
end
if R.SendDisabled(currentSource()) then
return
end
queue = R.QueueCreate(lines)
frame:SetScript("OnUpdate", OnUpdateDrain)
UpdateSendState()
end
local function BuildReportFrame()
if frame then
return frame
end
local f = CreateFrame("Frame", "DopingControlReportFrame", UIParent)
f:SetWidth(460)
f:SetHeight(340)
f:SetFrameStrata("DIALOG")
f:SetPoint("CENTER", anchorFrame(), "CENTER", 0, 0)
f:SetBackdrop(panelBackdrop())
f:SetBackdropColor(1, 1, 1, 0.97)
f:SetMovable(true)
f:EnableMouse(true)
f:RegisterForDrag("LeftButton")
f:SetScript("OnDragStart", function() this:StartMoving() end)
f:SetScript("OnDragStop", function() this:StopMovingOrSizing() end)
f:SetClampedToScreen(true)
-- title (gold)
local title = f:CreateFontString("DopingControlReportTitle",
"ARTWORK", "GameFontNormal")
title:SetPoint("TOPLEFT", f, "TOPLEFT", 16, -14)
local g = DC.COLORS and DC.COLORS.goldHi
if g then
title:SetTextColor(g.r, g.g, g.b)
else
title:SetTextColor(0.85, 0.66, 0.31)
end
title:SetText("Report preview")
-- info line (line count + pacing note)
infoText = f:CreateFontString("DopingControlReportInfo",
"ARTWORK", "GameFontNormalSmall")
infoText:SetPoint("TOPLEFT", f, "TOPLEFT", 16, -30)
infoText:SetTextColor(0.6, 0.6, 0.6)
infoText:SetText("")
-- close X
local closeX = CreateFrame("Button", "DopingControlReportCloseX",
f, "UIPanelCloseButton")
closeX:SetPoint("TOPRIGHT", f, "TOPRIGHT", -8, -8)
-- scrollable preview: shows reportLines output EXACTLY as sent
local scroll = CreateFrame("ScrollFrame", "DopingControlReportScroll",
f, "UIPanelScrollFrameTemplate")
scroll:SetPoint("TOPLEFT", f, "TOPLEFT", 16, -48)
scroll:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -36, 44)
scrollChild = CreateFrame("Frame", "DopingControlReportScrollChild",
scroll)
scrollChild:SetWidth(400)
scrollChild:SetHeight(1)
scroll:SetScrollChild(scrollChild)
previewText = scrollChild:CreateFontString("DopingControlReportText",
"ARTWORK", "GameFontHighlightSmall")
previewText:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", 0, 0)
previewText:SetWidth(390)
previewText:SetJustifyH("LEFT")
previewText:SetJustifyV("TOP")
-- mono-ish: 1.12 ships no true monospace; the condensed number
-- font keeps the indented name lists readable
previewText:SetFont("Fonts\\ARIALN.TTF", 12)
-- buttons
sendBtn = CreateFrame("Button", "DopingControlReportSendButton",
f, "UIPanelButtonTemplate")
sendBtn:SetWidth(150)
sendBtn:SetHeight(22)
sendBtn:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 16, 14)
sendBtn:SetText("Send to raid chat")
sendBtn:SetScript("OnClick", OnSendClick)
local closeBtn = CreateFrame("Button", "DopingControlReportCloseButton",
f, "UIPanelButtonTemplate")
closeBtn:SetWidth(80)
closeBtn:SetHeight(22)
closeBtn:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -16, 14)
closeBtn:SetText("Close")
closeBtn:SetScript("OnClick", function() f:Hide() end)
if pfUI and pfUI.api then
if pfUI.api.SkinButton then
pfUI.api.SkinButton(sendBtn)
pfUI.api.SkinButton(closeBtn)
end
if pfUI.api.SkinCloseButton then
pfUI.api.SkinCloseButton(closeX)
end
end
-- invisible overlay for the disabled-in-test-mode tooltip
-- (disabled buttons receive no mouse events on 1.12)
sendBlocker = CreateFrame("Frame", "DopingControlReportSendBlocker", f)
sendBlocker:SetAllPoints(sendBtn)
sendBlocker:SetFrameLevel(sendBtn:GetFrameLevel() + 2)
sendBlocker:EnableMouse(true)
sendBlocker:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:SetText("disabled in test mode", 1, 1, 1)
GameTooltip:AddLine("Simulated names never reach real chat.", 0.8, 0.8, 0.8)
GameTooltip:AddLine("The preview stays fully usable.", 0.8, 0.8, 0.8)
GameTooltip:Show()
end)
sendBlocker:SetScript("OnLeave", function() GameTooltip:Hide() end)
sendBlocker:Hide()
-- closing cancels a running drain (see header)
f:SetScript("OnHide", function()
if queue then
queue = nil
this:SetScript("OnUpdate", nil)
end
end)
-- ESC closes
table.insert(UISpecialFrames, "DopingControlReportFrame")
f:Hide()
frame = f
return f
end
-- Rebuild the preview content. vm/tab optional (see header).
function R.Refresh(vm, tab)
BuildReportFrame()
tab = tab or currentTab()
if vm then
local label = (DC.TAB_LABEL and DC.TAB_LABEL[tab]) or tab
lines = DC_Model.reportLines(vm, label, R.TimeStr())
else
lines = R.BuildLines(DC.store, db(), tab)
end
local n = table.getn(lines)
previewText:SetText(table.concat(lines, "\n"))
scrollChild:SetHeight(n * 14 + 24)
infoText:SetText(string.format(
"%d line%s - sent one per %.1f s", n, (n == 1) and "" or "s",
R.SEND_INTERVAL))
UpdateSendState()
end
-- Public entry point (matrix "Report..." button, /dc report).
function R.Open(vm, tab)
local f = BuildReportFrame()
R.Refresh(vm, tab)
f:Show()
end
function R.Toggle(vm, tab)
local f = BuildReportFrame()
if f:IsShown() then
f:Hide()
else
R.Open(vm, tab)
end
end
end
+463
View File
@@ -0,0 +1,463 @@
-- DopingControl ui/widgets.lua
-- Shared UI building blocks: panel backdrop, pooled-widget factories
-- (cell / pill / badge / button), tooltip helpers, pfUI-aware font helper,
-- change-guarded setters (only touch a region when the value changed).
--
-- Factory + pooling patterns follow common 1.12 addon practice
-- (pfUI-style WHITE8X8 backdrops, pooled regions, save-and-call hooks).
--
-- Pure Lua 5.0 dofile-loadable: top level only defines tables/functions;
-- CreateFrame & friends are only referenced INSIDE factory functions, which
-- are never called offline.
DopingControl = DopingControl or {}
local DC = DopingControl
DC_Widgets = DC_Widgets or {}
local W = DC_Widgets
-- ------------------------------------------------------------------
-- Texture / font constants
-- ------------------------------------------------------------------
W.SOLID_TEX = "Interface\\Buttons\\WHITE8X8" -- 1px-border backdrop base
W.FONT = "Fonts\\FRIZQT__.TTF" -- default client font
W.CHECK_TEX = "Interface\\Buttons\\UI-CheckBox-Check" -- HAS glyph (tinted green)
W.CROSS_TEX = "Interface\\Buttons\\UI-GroupLoot-Pass-Up" -- MISSING glyph (tinted red)
-- Main window backdrop (textures shipped in DopingControl\textures\).
W.PANEL_BACKDROP = {
bgFile = "Interface\\AddOns\\DopingControl\\textures\\panel_bg",
edgeFile = "Interface\\AddOns\\DopingControl\\textures\\panel_border",
tile = true, tileSize = 128, edgeSize = 16,
insets = { left = 5, right = 5, top = 5, bottom = 5 },
}
-- Solid 1px-border backdrop for cells/pills/badges/buttons (pfUI-style
-- WHITE8X8 recipe; bg + border colored via SetBackdropColor /
-- SetBackdropBorderColor).
W.SOLID_BACKDROP = {
bgFile = W.SOLID_TEX,
edgeFile = W.SOLID_TEX,
tile = false, edgeSize = 1,
insets = { left = 1, right = 1, top = 1, bottom = 1 },
}
W.WHITE = { r = 1, g = 1, b = 1 }
-- ------------------------------------------------------------------
-- Cell-box fallback palette: variant name -> (bg, border) color reference,
-- used ONLY when ui/cellsheet.lua's baked sheet is unavailable (see
-- W.HasCellSheet below). `border` is a DC.COLORS key, or a number that
-- indexes DC.QUALITY -- same convention as DC_Cells.VARIANTS in
-- ui/cellsheet.lua, which this table intentionally mirrors: colors are
-- pulled LIVE from DC.COLORS/DC.QUALITY (core/const.lua, already loaded --
-- ui/cellsheet.lua only bakes that same palette into a texture, it does
-- not own it), so nothing here duplicates a color VALUE. The pairing
-- itself (which key goes with which variant) can't be derived
-- mechanically -- it is the one piece of information that has to exist
-- twice given the failure mode this guards (ui/cellsheet.lua not loaded
-- at all). tools/luatests/test_widgets.lua cross-checks every entry
-- against the real DC_Cells.VARIANTS so the two cannot silently drift.
W.CELL_FALLBACK_VARIANTS = {
HAS = { bg = "hatBg", border = "hatBorder" },
MISSING = { bg = "fehltBg", border = "fehltBorder" },
UNKNOWN = { bg = "unbekBg", border = "unbekBorder" },
SKIP = { bg = "skip", border = "skipBorder" },
NEUTRAL = { bg = "theadBg", border = "line" },
ITEM_HAS = { bg = "theadBg", border = "hatBorder" },
ITEM_MISSING = { bg = "theadBg", border = "fehltBorder" },
Q_RARE = { bg = "theadBg", border = "rareBorder" },
Q_EPIC = { bg = "theadBg", border = "epicBorder" },
Q_0 = { bg = "theadBg", border = 0 },
Q_1 = { bg = "theadBg", border = 1 },
Q_2 = { bg = "theadBg", border = 2 },
Q_5 = { bg = "theadBg", border = 5 },
Q_6 = { bg = "theadBg", border = 6 },
}
-- ------------------------------------------------------------------
-- DC_Cells fallback shim -- covers a mid-session /reload where THIS file
-- (ui/widgets.lua, an EXISTING file whose new content /reload happily
-- re-executes) deploys, but ui/cellsheet.lua (a NEW file added in the same
-- change) does not: 1.12's /reload re-runs existing files but never loads
-- a file that was not already part of the running session (verified
-- in-game). Left alone, DC_Cells stays nil, and it is not only
-- W.MakeCell/W.SetCellBox below that touch it -- ui/matrix.lua calls
-- DC_Cells.VariantFor(...) directly at two paint sites (M.PaintItemTile
-- and the legacy trinket path, ui/matrix.lua ~2182/2381 -- out of scope
-- for this change) and would error on the very first painted item/trinket
-- cell regardless of anything done here.
--
-- The shim supplies ONLY the pure classification function -- state/kind/
-- quality -> variant NAME string -- no sheet, no texture, no coordinate
-- math, because none of that can work without ui/cellsheet.lua's baked
-- sheet file. It is a verbatim copy of DC_Cells.VariantFor's logic (see
-- ui/cellsheet.lua); test_widgets.lua cross-checks the two stay in sync.
-- Installs only when DC_Cells does not already exist, so the normal
-- (full-restart) load path -- where ui/cellsheet.lua runs before this file
-- per DopingControl.toc and sets the real DC_Cells -- is untouched.
if not DC_Cells then
DC_Cells = {
VariantFor = function(state, kind, quality)
if kind == "ench" or kind == "trinket" then
if quality == 3 then return "Q_RARE" end
if quality == 4 then return "Q_EPIC" end
if quality ~= nil then return "Q_" .. quality end
if kind == "trinket" then
return "Q_1"
end
if state == "HAS" then return "ITEM_HAS" end
if state == "MISSING" then return "ITEM_MISSING" end
return "NEUTRAL"
end
if state == "HAS" then return "HAS" end
if state == "MISSING" then return "MISSING" end
if state == "UNKNOWN" then return "UNKNOWN" end
return "SKIP"
end,
}
end
-- Whether ui/cellsheet.lua's baked sheet is actually available (as
-- opposed to the shim above, which never sets .SHEET). One seam shared by
-- W.MakeCell and W.SetCellBox, and directly testable offline without
-- CreateFrame (unlike W.MakeCell itself).
function W.HasCellSheet()
return DC_Cells ~= nil and DC_Cells.SHEET ~= nil
end
-- ------------------------------------------------------------------
-- Font helper (we keep OUR pixel
-- size -- the matrix layout depends on it -- and only take pfUI's font
-- FAMILY when pfUI is present; forcing pfUI's global font size would break
-- the 30x23 cell grid).
-- ------------------------------------------------------------------
function W.ApplyFont(fs, size)
size = size or 10
if pfUI and pfUI.font_default then
fs:SetFont(pfUI.font_default, size)
else
fs:SetFont(W.FONT, size)
end
end
-- ------------------------------------------------------------------
-- Change-guarded setters (convention: cache last-applied value on the
-- region as dcLast*; only call the real setter when the value changed.
-- Color guards compare by TABLE REFERENCE -- callers always pass the
-- shared DC.COLORS / DC.QUALITY tables, so this is exact and zero-alloc).
-- ------------------------------------------------------------------
function W.SetTextG(fs, text)
if fs.dcLastText ~= text then
fs.dcLastText = text
fs:SetText(text)
end
end
function W.SetTextColorG(fs, c)
if fs.dcLastColor ~= c then
fs.dcLastColor = c
fs:SetTextColor(c.r, c.g, c.b)
end
end
function W.SetBackdropG(f, bg, border)
if f.dcLastBg ~= bg then
f.dcLastBg = bg
f:SetBackdropColor(bg.r, bg.g, bg.b, bg.a or 1)
end
if f.dcLastBorder ~= border then
f.dcLastBorder = border
f:SetBackdropBorderColor(border.r, border.g, border.b, 1)
end
end
function W.SetTextureG(tex, path)
if tex.dcLastTex ~= path then
tex.dcLastTex = path
tex:SetTexture(path)
end
end
function W.SetVertexG(tex, c)
if tex.dcLastVertex ~= c then
tex.dcLastVertex = c
tex:SetVertexColor(c.r, c.g, c.b)
end
end
function W.SetAlphaG(region, a)
if region.dcLastAlpha ~= a then
region.dcLastAlpha = a
region:SetAlpha(a)
end
end
-- ------------------------------------------------------------------
-- FontString factory
-- ------------------------------------------------------------------
function W.MakeText(parent, size, color, layer)
local fs = parent:CreateFontString(nil, layer or "OVERLAY")
W.ApplyFont(fs, size)
if color then
W.SetTextColorG(fs, color)
end
return fs
end
-- ------------------------------------------------------------------
-- Cell factory: 30x23 state cell (the 30x23 size is part of the fixed
-- visual layout). The box (background + 1px border) is one texture region
-- sampled from the baked sheet in ui/cellsheet.lua -- SetBackdrop with an
-- edgeFile costs NINE texture regions per cell, this costs one. The icon
-- (check/cross glyph OR trinket item icon) and FontString (?, skip dot,
-- trinket monogram) are created on demand via W.CellIcon / W.CellText;
-- the matrix paint code shows exactly one of them per state.
-- Pool children stay anonymous (documented factory exception).
-- ------------------------------------------------------------------
function W.MakeCell(parent, name, w, h)
local c = CreateFrame("Frame", name, parent)
c:SetWidth(w or 30)
c:SetHeight(h or 23)
c:EnableMouse(true)
c.dcW = w or 30
if W.HasCellSheet() then
-- The state box (background + 1px border) comes from one baked sheet:
-- SetBackdrop with an edgeFile costs NINE texture regions per cell,
-- this costs one. See ui/cellsheet.lua.
c.boxTex = c:CreateTexture(nil, "BACKGROUND")
c.boxTex:SetAllPoints(c)
c.boxTex:SetTexture(DC_Cells.SHEET.path)
-- Without an initial texcoord the region would show the WHOLE sheet
-- stretched across the cell until the first paint. Start on NEUTRAL.
local l, r, t, b = DC_Cells.Coords("NEUTRAL", c.dcW)
if l then
c.boxTex:SetTexCoord(l, r, t, b)
c.dcBoxVariant = "NEUTRAL"
c.dcBoxW = c.dcW
end
else
-- Fallback: ui/cellsheet.lua did not load this session (see
-- W.HasCellSheet above) -- pre-refactor SetBackdrop path (no
-- boxTex region at all) so the matrix still draws instead of
-- erroring on a nil DC_Cells.SHEET. W.SetBackdropG and
-- W.SOLID_BACKDROP are untouched by the sprite refactor, so this
-- is exactly what W.MakeCell did before it.
c:SetBackdrop(W.SOLID_BACKDROP)
W.SetCellBox(c, "NEUTRAL")
end
return c
end
-- Icon and FontString on demand: a cell shows one or the other, and in 1.12
-- eagerly created regions cost even while hidden.
function W.CellIcon(cell)
if not cell.icon then
cell.icon = cell:CreateTexture(nil, "ARTWORK")
cell.icon:SetPoint("CENTER", cell, "CENTER", 0, 0)
cell.icon:SetWidth(16)
cell.icon:SetHeight(16)
cell.icon:Hide()
end
return cell.icon
end
function W.CellText(cell)
if not cell.text then
cell.text = cell:CreateFontString(nil, "OVERLAY")
W.ApplyFont(cell.text, 9)
cell.text:SetPoint("CENTER", cell, "CENTER", 0, 0)
end
return cell.text
end
-- Guards on variant AND width (cell.dcBoxW, the width the box was last
-- painted at) -- NOT variant alone. ui/matrix.lua resizes a pooled cell in
-- place when its column width changes (trinket columns are 34, normal
-- cells 30: see ui/matrix.lua's column-layout path around SetWidth(col.w))
-- and only ever writes cell.dcW; it has no notion of dcBoxVariant and isn't
-- expected to invalidate it. If this guard only checked variant, a cell
-- that kept its variant across such a resize would keep a stale texcoord
-- sized for the OLD width -- the baked border would sit off the new cell
-- edge. Tracking dcBoxW here keeps the guard self-contained: correctness
-- does not depend on any caller convention.
function W.SetCellBox(cell, variant)
if not W.HasCellSheet() then
-- Fallback: same pre-refactor SetBackdrop recipe, colors pulled
-- LIVE from DC.COLORS/DC.QUALITY via W.CELL_FALLBACK_VARIANTS
-- above. Never touches cell.boxTex -- a fallback cell (see
-- W.MakeCell) does not have one. W.SetBackdropG already
-- change-guards by table reference, so no separate
-- dcBoxVariant/dcBoxW bookkeeping is needed here.
local v = W.CELL_FALLBACK_VARIANTS[variant]
if not v then
return
end
local bg = DC.COLORS[v.bg]
local border = v.border
if type(border) == "number" then
border = DC.QUALITY[border]
else
border = DC.COLORS[border]
end
if not bg or not border then
return
end
W.SetBackdropG(cell, bg, border)
return
end
if cell.dcBoxVariant == variant and cell.dcBoxW == cell.dcW then
return
end
local l, r, t, b = DC_Cells.Coords(variant, cell.dcW)
if not l then
return
end
cell.dcBoxVariant = variant
cell.dcBoxW = cell.dcW
cell.boxTex:SetTexCoord(l, r, t, b)
end
-- ------------------------------------------------------------------
-- Pill factory (ready pill, coverage pill): bordered mini frame with a
-- centered FontString; width is adjusted by the caller to fit the text.
-- ------------------------------------------------------------------
function W.MakePill(parent, name)
local p = CreateFrame("Frame", name, parent)
p:SetWidth(70)
p:SetHeight(16)
p:SetBackdrop(W.SOLID_BACKDROP)
p:EnableMouse(true)
p.text = p:CreateFontString(nil, "OVERLAY")
W.ApplyFont(p.text, 9)
p.text:SetPoint("CENTER", p, "CENTER", 0, 0)
return p
end
-- ------------------------------------------------------------------
-- Role badge factory: clickable bordered button with centered label.
-- Suggested roles render with a "?" suffix + dimmer border (matrix paints
-- that -- 1.12 has no dashed borders, see the adaptation note there).
-- ------------------------------------------------------------------
function W.MakeBadge(parent, name)
local b = CreateFrame("Button", name, parent)
b:SetWidth(56)
b:SetHeight(16)
b:SetBackdrop(W.SOLID_BACKDROP)
b.text = b:CreateFontString(nil, "OVERLAY")
W.ApplyFont(b.text, 8)
b.text:SetPoint("CENTER", b, "CENTER", 0, 0)
b:RegisterForClicks("LeftButtonUp")
return b
end
-- ------------------------------------------------------------------
-- Dark gold-trim button (titlebar Scan/Report/Options/X).
-- ------------------------------------------------------------------
function W.MakeButton(parent, name, label, width, height)
local C = DC.COLORS
local b = CreateFrame("Button", name, parent)
b:SetWidth(width or 64)
b:SetHeight(height or 19)
b:SetBackdrop(W.SOLID_BACKDROP)
W.SetBackdropG(b, C.panel2, C.goldSoft)
b.text = b:CreateFontString(nil, "OVERLAY")
W.ApplyFont(b.text, 10)
b.text:SetPoint("CENTER", b, "CENTER", 0, 0)
W.SetTextColorG(b.text, C.goldHi)
b.text:SetText(label)
b:SetScript("OnEnter", function()
W.SetBackdropG(this, C.panel3, C.gold)
end)
b:SetScript("OnLeave", function()
W.SetBackdropG(this, C.panel2, C.goldSoft)
end)
return b
end
-- ------------------------------------------------------------------
-- Tooltip helpers. Convention: the builder only ADDS
-- lines; AttachTooltip calls SetOwner before and Show after -- AddLine
-- never calls Show itself.
-- AttachTooltip CHAINS any existing OnEnter/OnLeave (no HookScript on
-- 1.12 -- standard save-and-call pattern).
-- ------------------------------------------------------------------
function W.TipLine(text, c, wrap)
if not GameTooltip then
return
end
local r, g, b = 1, 1, 1
if c then
r, g, b = c.r, c.g, c.b
end
if wrap then
GameTooltip:AddLine(text, r, g, b, 1)
else
GameTooltip:AddLine(text, r, g, b)
end
end
-- Two-column tooltip line: label left, value flush RIGHT. This is what
-- makes a breakdown read like an addition on paper -- the values line up
-- in a column instead of trailing their labels at ragged positions. 1.12
-- has no monospace font and no tab stops; AddDoubleLine is the only way to
-- get a right-aligned column, and it exists in this client's FrameXML.
-- `cr` defaults to the label color when omitted.
function W.TipDouble(left, right, c, cr)
if not GameTooltip then
return
end
local r, g, b = 1, 1, 1
if c then
r, g, b = c.r, c.g, c.b
end
local r2, g2, b2 = r, g, b
if cr then
r2, g2, b2 = cr.r, cr.g, cr.b
end
GameTooltip:AddDoubleLine(left, right, r, g, b, r2, g2, b2)
end
-- W.dcTipOwner: our own record of which of OUR frames currently owns
-- GameTooltip. WoW 1.12's GameTooltip has no "GetOwner" method (confirmed
-- against FrameXML-1.12.1: zero real hits -- the only "GetOwner" match
-- anywhere in that tree is the unrelated global function
-- GetOwnerAuctionItems). Every caller in this addon that ever needs "who
-- currently owns the tooltip" goes through W.AttachTooltip to open one in
-- the first place, so this is the single place that needs to record it.
-- Set right after SetOwner
-- (matching order: SetOwner, then the builder runs, then Show -- a builder
-- that itself re-SetOwner()s the SAME frame, like M.CellTooltip's
-- ANCHOR_LEFT override, does not change who owns it). Cleared in OnLeave,
-- guarded on identity so a late/out-of-order OnLeave can never clobber a
-- newer owner.
W.dcTipOwner = nil
function W.AttachTooltip(frame, builder, anchor)
frame.dcTipBuilder = builder
frame.dcTipAnchor = anchor or "ANCHOR_LEFT"
frame.dcOldEnter = frame:GetScript("OnEnter")
frame.dcOldLeave = frame:GetScript("OnLeave")
frame:SetScript("OnEnter", function()
if this.dcOldEnter then
this.dcOldEnter()
end
if GameTooltip and this.dcTipBuilder then
GameTooltip:SetOwner(this, this.dcTipAnchor)
W.dcTipOwner = this
this.dcTipBuilder(this)
GameTooltip:Show()
end
end)
frame:SetScript("OnLeave", function()
if this.dcOldLeave then
this.dcOldLeave()
end
if W.dcTipOwner == this then
W.dcTipOwner = nil
end
if GameTooltip then
GameTooltip:Hide()
end
end)
end