Files
DopingControl/ui/matrix.lua
T
2026-08-03 09:47:19 +02:00

3073 lines
117 KiB
Lua

-- DopingControl ui/matrix.lua
-- Main window: titlebar (coverage pill, unassigned chip, scan age,
-- buttons), tabs, toolbar
-- (filter + legend), slot header row, scrollable role-grouped matrix,
-- sticky "missing per slot" footer, frame foot hints, TEST MODE banner,
-- whisper buttons, role badge (left-click confirm / right-click cycle),
-- toast.
--
-- Binding UI rules implemented here:
-- Fixed visual system: layout tree, palette, cell states,
-- group headers, tabs, toolbar, footer, titlebar, whisper, tooltips,
-- trinket tiles; role interaction is the badge click UX below
-- (no role popover).
-- DEBUFFS tab visuals: header icon tiles, afflicted =
-- MISSING with debuff icon, no whisper; equipment quality border +
-- 8px enchant corner marker; far tag (>= DC.FAR_LIMIT yd, diagnostic);
-- MX.PAD = 22 border-band clearance.
-- Role badge: left-click = confirm the
-- displayed role, right-click = DC_Roles.cycle + save + toast (no-op
-- for single-entry classes); badge tooltip lists the class's actual
-- cycle options (MX.RoleTooltipLines); header icons 20px (the 26px
-- tile sits in a 30px row -- 30px icons crossed both row hairlines);
-- weapon HAS tooltip shows the temp-enchant name when the model
-- delivers one (store field weaponMainName).
-- Last-known display (IRON RULE: last-known data is
-- DISPLAY-ONLY -- it never counts as HAS/MISSING in gaps, pills, sums,
-- footer or report; those keep treating the cell as UNKNOWN):
-- UNKNOWN cells carrying cell.last = { state, detail, icon, item, age }
-- render the cached visual dimmed (MX.DIM_ALPHA, texture/FontString
-- level -- the gray UNKNOWN border stays crisp); cells without .last
-- stay the plain gray "?". Equipment rows with rowVM.gearUnreadable
-- show ONE pooled row-spanning message ("out of range - items not
-- readable (NNyd)") when no cached gear exists, or the dimmed tiles +
-- an "out of range" status chip when it does (MX.UnreadRowMode).
-- Tooltip on cached cells: UNKNOWN reason first, then
-- "Last known (N min ago): <detail>" (MX.AgeText / MX.LastLine).
-- Sum column and pills: NO change. Refresh passes GetTime() as the
-- model's `now` (5th build param) so cell.last.age exists.
-- DEBUFFS is a WARNING SIGNAL,
-- not expectation fulfillment -- display artifacts only,
-- gap/sort semantics unchanged: sum column renders rowVM.debuffCount
-- ("0" faint when clean, "N" red when >0, "--" unreadable --
-- MX.DebuffSumText); CLEAN cells render the near-invisible NOTEXP dot
-- instead of the green check (afflicted cells keep red tile + icon);
-- HIT tab (its own tab, slot kind "hit": melee, ranged and one column
-- per magic school): cells render the percentage with one decimal.
-- A cell AT OR ABOVE its cap renders like a fulfilled cell -- green
-- background AND green border (the HAS colours), not merely green text
-- -- so a capped raid is readable at a glance across 40 rows; below the
-- cap the cell keeps the neutral resistance tile with parchment text,
-- faint on a plain 0, "?" when unreadable, and the near-invisible
-- NOTEXP dot for a player without a ranged weapon. Never red: nothing
-- on this tab is a gap (MX.HitCellStyle owns that mapping).
-- Cell tooltip = MX.HitTooltipLines: the value as a plain ADDITION --
-- one right-aligned row per source (items, set bonuses, auras, talents,
-- weapon skill), "?" for a source that could not be read -- then the
-- generic/school-specific split on a school column, weapon-skill
-- assumption incl. the book note, dual wield incl. the off-hand hit
-- talent, and the school-specific set-bonus row where one applies);
-- header tiles use the slot label/sub and get no "expected for:" line;
-- the footer shows the column average with one decimal.
-- RESIST and HIT share every display-tab rule (MX.IsDisplayTab): "--"
-- in the sum column, neutral "display" group pill, average footer.
-- group pill via MX.DebuffPillText ("clean" green iff debuffTotal==0 and
-- all readable, "N debuffs" red when >0, gray "0 debuffs" while
-- unreadables remain), "N unreadable" chip stays; footer label swaps to
-- "AFFLICTED PER DEBUFF" (MX.FooterLabel). The bold red sum idea has
-- no 1.12 bold flag -- color alone carries the emphasis.
--
-- Rendering reads ONLY the vm from DC_Model.build -- never the raw store,
-- except store.source (TEST MODE banner + chat kill switch) and
-- store.scanAt (age ticker).
--
-- WoW adaptations of the visual design (1.12 has no CSS):
-- * header + footer live OUTSIDE the scroll frame (no sticky in 1.12).
-- * suggested role badge: no dashed borders on 1.12 -> "?" suffix on the
-- badge text + dimmer border + tooltip reason.
-- * glyphs: HAS/MISSING use the vanilla check/cross textures (tinted);
-- UNKNOWN "?" and skip middle-dot are FontStrings. Aura HAS cells with
-- a known buff texture (cell.icon) and equipment cells with a known
-- item (cell.item) render the icon on a lazy named per-cell texture
-- (DopingControlCell<i>Icon) instead of the glyph. Caret = "-"/"+"
-- (quest-log convention), whisper button = "PST", unreadable sum = "--"
-- (the unicode carets/envelope/em-dash are not in the 1.12 font).
-- * decorative logo glyph + tab chip -> a tab tooltip note instead.
--
-- OnUpdate is used ONLY for: scan-age ticker (1 s throttle, on the main
-- frame so it stops when hidden) + toast fade. No per-frame allocation:
-- the ticker builds at most one short string per second and the guarded
-- setters skip identical values.
--
-- Structure note (Lua 5.0): closures may carry at most 32 upvalues, so the
-- WoW section keeps ALL frame references in one `UI` table and all private
-- helpers in one `M` table -- each function then closes over a handful of
-- tables instead of dozens of individual locals.
--
-- Pure Lua 5.0. The pure layout/predicate section at the top is offline
-- dofile-testable; everything that touches frames sits behind
-- `if CreateFrame then`.
DopingControl = DopingControl or {}
local DC = DopingControl
DC_Matrix = DC_Matrix or {}
local MX = DC_Matrix
-- ==================================================================
-- PURE SECTION -- layout math + predicates (offline-testable)
-- ==================================================================
-- Layout constants. CELL 30x23 and TRK width 34 are fixed (the whole
-- grid is tuned around them); the rest is derived spacing.
MX.CELL_W = 30
MX.CELL_H = 23
MX.TRK_W = 34
MX.CELL_GAP = 3
MX.NAME_COL_MIN = 150
MX.ROLE_COL_W = 62
MX.SUM_COL_W = 36
MX.PAD = 22 -- must clear the 16px opaque panel_border band with
-- >=6px inset (was 12,
-- which put left-edge texts inside the border art)
MX.ROW_H = 27
MX.GROUP_H = 24
MX.MAX_COLS = 16 -- "N slots hidden" fallback only ABOVE this
-- (equipment: 11 ench + neck + 2 rings + 2 trinkets)
MX.SCROLLBAR_W = 34 -- 6 gap + 16 bar (UIPanelScrollFrameTemplate puts the
-- bar at +6..+22 right of the scroll frame edge) + 12
-- clearance before the 16px opaque border band
MX.MIN_FRAME_W = 580 -- titlebar content needs this much
MX.WHISPER_COOLDOWN = 30 -- seconds per player
MX.HEADER_ICON = 20 -- header tile icon edge (the tile is
-- 26px high inside a 30px row -- a 30px icon crossed
-- both row hairlines, seen in-game)
-- Tab strip geometry. The strip starts at MX.PAD and must still fit the
-- NARROWEST window (MX.MIN_FRAME_W, the titlebar minimum) with the same
-- padding on the right -- one more tab therefore costs width per tab, not
-- window width. Pinned by test_matrix_layout so adding a tab cannot
-- silently push the last one under the frame border.
MX.TAB_W = 84
MX.TAB_PITCH = 88
-- Transient state (tab/filter are not persisted; collapse
-- lives in db).
MX.state = MX.state or { tab = "CONSUMABLES", gapsOnly = false }
MX.lastWhisper = MX.lastWhisper or {} -- [playerName] = GetTime() of last whisper
MX.hooks = MX.hooks or {} -- init wires: scan / report / options
-- Column layout for a slot array. Columns: Player | Role | slots... | Sum.
-- Returns { nameW, roleX, cols = { {x, w, id} ... }, sumX, totalW }.
-- All x offsets are from the window's left edge and include MX.PAD.
function MX.ColumnLayout(slots, nameW)
nameW = nameW or MX.NAME_COL_MIN
if nameW < MX.NAME_COL_MIN then
nameW = MX.NAME_COL_MIN
end
local roleX = MX.PAD + nameW + MX.CELL_GAP
local x = roleX + MX.ROLE_COL_W + MX.CELL_GAP
local cols = {}
local n = table.getn(slots)
for i = 1, n do
local w = MX.CELL_W
if slots[i].kind == "trinket" then
w = MX.TRK_W
end
cols[i] = { x = x, w = w, id = slots[i].id }
x = x + w + MX.CELL_GAP
end
local sumX = x
local totalW = sumX + MX.SUM_COL_W + MX.PAD
return { nameW = nameW, roleX = roleX, cols = cols, sumX = sumX, totalW = totalW }
end
-- Window width: content width + scrollbar allowance, clamped so the
-- titlebar content always fits.
function MX.FrameWidth(totalW)
local w = totalW + MX.SCROLLBAR_W
if w < MX.MIN_FRAME_W then
w = MX.MIN_FRAME_W
end
return w
end
-- Column cap: render at most `cap` slot columns, report how
-- many are hidden. Model/whisper/report still use the FULL slot set.
function MX.VisibleSlots(slots, cap)
cap = cap or MX.MAX_COLS
local n = table.getn(slots)
if n <= cap then
return slots, 0
end
local out = {}
for i = 1, cap do
out[i] = slots[i]
end
return out, n - cap
end
-- Filter predicate: "Gaps only" hides readable+complete rows but KEEPS
-- unreadable rows and rows with gaps.
-- "Complete" mirrors the model's own ready-pill definition (core/model.lua:
-- readable AND gaps==0 AND unknowns==0) rather than gaps==0 alone -- on
-- RESIST/HIT (M.IsDisplayTab) gaps can NEVER be nonzero (classify()'s
-- resist/hit branches never return MISSING, it is a display-only tab by
-- design), so gaps==0 is true for EVERY row there, verified or not. Without
-- the unknowns check "Gaps only" could not tell a genuinely clean row from
-- one nobody could actually read -- row.readable tracks aurasRead, which is
-- independent of resistsRead/hitRead, so a player is routinely "readable"
-- while their resistance/hit channel specifically failed (foreign
-- UNIT_FIELD_RESISTANCES is never sent by this server at all, so RESIST
-- reads UNKNOWN for nearly every non-self player). Checking unknowns too
-- fixes that without any tab-specific branch: on the expectation tabs
-- (CONSUMABLES/CLASSBUFFS/EQUIPMENT/DEBUFFS) a readable row's cells are
-- gated by the SAME flag that made it readable, so unknowns is already 0
-- there and this adds nothing -- except for the one existing edge case that
-- shared the exact same bug (a gearRead/weaponMain-specific read failing
-- despite aurasRead succeeding), which this also fixes for free.
function MX.RowVisible(row, gapsOnly)
if not gapsOnly then
return true
end
if not row.readable then
return true
end
return row.gaps > 0 or (row.unknowns or 0) > 0
end
-- Unassigned-chip predicate (pure): the titlebar chip renders only while
-- at least one player's role is a heuristic suggestion (vm.unassigned
-- from DC_Model.build). n > 0 => visible; 0 or absent aggregate => hidden.
function MX.UnassignedVisible(unassigned)
if not unassigned then
return false
end
return (unassigned.count or 0) > 0
end
-- Collapse/filter arithmetic: how many player rows are shown vs hidden.
-- groups = vm.groups-shaped array; collapsed = { [role] = true }.
function MX.CountVisibleRows(groups, collapsed, gapsOnly)
collapsed = collapsed or {}
local shown, total = 0, 0
local nG = table.getn(groups)
for gi = 1, nG do
local g = groups[gi]
local nR = table.getn(g.rows)
total = total + nR
if not collapsed[g.role] then
for ri = 1, nR do
if MX.RowVisible(g.rows[ri], gapsOnly) then
shown = shown + 1
end
end
end
end
return shown, total - shown
end
-- Whisper eligibility mirror incl. 30 s per-player cooldown.
-- Returns canSend(bool), remainingCooldown(number|nil).
-- rowVM.whisperEligible (readable AND >=1 gap in the active tab) comes
-- from DC_Model.build; this only adds the cooldown on top.
function MX.CanWhisper(row, now, lastTimes)
if not (row and row.whisperEligible) then
return false, nil
end
lastTimes = lastTimes or MX.lastWhisper
local name = row.player and row.player.name
local last = name and lastTimes[name]
if last and (now - last) < MX.WHISPER_COOLDOWN then
return false, MX.WHISPER_COOLDOWN - (now - last)
end
return true, nil
end
-- Cooldown-table eviction (every t[key]=now throttle table
-- gets a periodic sweep): drop entries older than twice the cooldown.
-- gets a periodic sweep): drop entries older than twice the cooldown.
-- Called from Refresh -- the table is small (distinct whispered names),
-- so a full pass per refresh is cheap. Clearing fields during a pairs
-- traversal is explicitly allowed in Lua 5.0.
function MX.SweepWhispers(now, lastTimes)
lastTimes = lastTimes or MX.lastWhisper
for name, t in pairs(lastTimes) do
if (now - t) > (MX.WHISPER_COOLDOWN * 2) then
lastTimes[name] = nil
end
end
end
-- Known spell ID(s) for a buff name, as a display string ("17626" or
-- "1459/1460/..." for rank lists) -- nil when no table knows the name or
-- the entry is name-match-only (spellID 0 / empty ids). Used by the HAS
-- cell tooltip: the spell ID is mandatory tooltip content,
-- and name-matched cells carry no ID in their detail string.
function MX.KnownSpellIds(name)
if type(name) ~= "string" then
return nil
end
local c = DC.CONSUMABLES and DC.CONSUMABLES[name]
if c then
if c.spellID and c.spellID > 0 then
return tostring(c.spellID)
end
return nil
end
local b = DC.CLASSBUFFS and DC.CLASSBUFFS[name]
if b and b.ids then
local n = table.getn(b.ids)
if n > 0 then
local parts = {}
for i = 1, n do
table.insert(parts, tostring(b.ids[i]))
end
return table.concat(parts, "/")
end
end
return nil
end
-- Trinket monogram fallback (no icon texture known): first letter of each
-- word, max 3 ("Hand of Justice" -> "HOJ"); single words use their first
-- three letters ("Earthstrike" -> "EAR").
function MX.Monogram(name)
if not name or name == "" then
return "?"
end
local out = ""
local inWord = false
local len = string.len(name)
for i = 1, len do
local ch = string.sub(name, i, i)
if string.find(ch, "%a") then
if not inWord then
out = out .. string.upper(ch)
inWord = true
if string.len(out) >= 3 then
break
end
end
else
inWord = false
end
end
if string.len(out) < 2 then
local w = ""
for i = 1, len do
local ch = string.sub(name, i, i)
if string.find(ch, "%a") then
w = w .. string.upper(ch)
end
if string.len(w) >= 3 then
break
end
end
if string.len(w) > string.len(out) then
out = w
end
end
if out == "" then
out = "?"
end
return out
end
-- Far-tag text: "<n>yd", floored. Rendered
-- next to the player name only while rowVM.far is set -- the MODEL decides
-- (readable and distance >= DC.FAR_LIMIT); distance stays diagnostic and
-- never changes any state/sum/pill.
function MX.FarTag(distance)
if not distance then
return nil
end
return string.format("%dyd", distance)
end
-- ------------------------------------------------------------------
-- Last-known display helpers. IRON RULE: the
-- cache is DISPLAY-ONLY -- nothing in here feeds gaps/sums/pills/footer,
-- and none of these helpers is consulted by any counting path.
-- ------------------------------------------------------------------
-- Content dim for cached observations. Applied at texture/FontString
-- level only -- never on the cell frame -- so the gray UNKNOWN border
-- stays crisp.
MX.DIM_ALPHA = 0.45
-- Pure: should opening the window trigger a scan? (fixed bug:
-- after a reload NOTHING scanned until a roster event fired while the
-- window was shown -- the window opened empty with "no scan yet").
-- Stale after 60s; test mode never auto-scans; without a clock
-- no staleness claim is made.
function MX.WantScanOnShow(testMode, scanAt, now)
if testMode then
return false
end
if not scanAt then
return true
end
if now and (now - scanAt) > 60 then
return true
end
return false
end
-- Age text for a cached observation: "45 s ago" under a minute, then
-- "12 min ago" (floored). nil age (no cache timestamp) -> nil.
function MX.AgeText(seconds)
if seconds == nil then
return nil
end
if seconds < 60 then
return math.floor(seconds) .. " s ago"
end
return math.floor(seconds / 60) .. " min ago"
end
-- Equipment-row render mode (decision tree):
-- "plain" -> row is not gear-unreadable: normal cell painting
-- "message" -> gear unreadable, NO cached gear: cells hide, ONE dim
-- row-spanning line explains why
-- "dimmed" -> gear unreadable, cached gear present: dimmed tiles
-- render instead; the status chip carries "out of range"
-- rowVM.gearUnreadable is already tab-scoped by the model (only ever
-- true on EQUIPMENT), so no tab check happens here.
function MX.UnreadRowMode(row, hasAnyLast)
if not (row and row.gearUnreadable) then
return "plain"
end
if hasAnyLast then
return "dimmed"
end
return "message"
end
-- Row-spanning message for a gear-unreadable equipment row without
-- cached gear; distance appended when known (diagnostic, floored yards).
-- The row-spanning line for a player whose GEAR could not be read. It
-- states what was OBSERVED -- no equipment slot returned an item link --
-- and appends the measured distance when there is one, so the reader can
-- judge the range hypothesis instead of being handed it as a verdict:
-- distance is only one of the ways this read comes back empty.
function MX.UnreadRowText(distance)
local s = "items not readable - no equipment slot returned an item link"
if distance then
s = s .. " (" .. MX.FarTag(distance) .. ")"
end
return s
end
-- Tooltip line for a cached observation:
-- "Last known (12 min ago): Flask of the Titans". Table details (trinket
-- HAS) degrade to their item name; absent detail degrades to the cached
-- state; absent age drops the parenthesis.
function MX.LastLine(last)
if type(last) ~= "table" then
return nil
end
local d = last.detail
local dtxt
if type(d) == "table" then
dtxt = d.name or (type(last.item) == "table" and last.item.name)
or "item"
elseif d ~= nil and d ~= "" then
dtxt = tostring(d)
elseif type(last.item) == "table" and last.item.name then
dtxt = last.item.name
elseif last.state == "HAS" then
dtxt = "present"
else
dtxt = "missing"
end
local age = MX.AgeText(last.age)
if age then
return "Last known (" .. age .. "): " .. dtxt
end
return "Last known: " .. dtxt
end
-- Singular role display names (badge, toasts, tooltips). The WoW section
-- reads these too -- single source for the role wording.
MX.ROLE_NAME = { TANK = "Tank", MELEE = "Melee", RANGED = "Ranged",
CASTER = "Caster", HEALER = "Healer" }
-- Class token -> display word ("WARRIOR" -> "Warrior") for the fixed-role
-- tooltip line; nil/empty tokens degrade to generic wording.
function MX.ClassTitle(class)
if type(class) ~= "string" or class == "" then
return "this class"
end
return string.upper(string.sub(class, 1, 1))
.. string.lower(string.sub(class, 2))
end
-- Role badge tooltip content as a PURE string
-- array; the WoW tooltip builder only applies colors. Lines:
-- 1) "Role: X" (confirmed) or "Suggestion: X" (heuristic)
-- 2) "Reason: <reason>" -- suggested only
-- .) "Left-click: confirm role"
-- .) "Right-click: cycle: A / B / C" from DC_Roles.CLASS_ROLES[class]
-- (the class's ACTUAL options, full list), or
-- "Role is fixed for <Class>" for single-entry/unknown classes
-- (right-click is a no-op there).
-- DC_Roles.CLASS_ROLES is read at CALL time and nil-guarded (parallel
-- builder owns core/roles.lua) -- absent table degrades to the fixed line.
function MX.RoleTooltipLines(class, role, suggested, reason)
local lines = {}
local roleName = MX.ROLE_NAME[role] or role or "?"
if suggested then
table.insert(lines, "Suggestion: " .. roleName)
table.insert(lines, "Reason: " .. (reason or "unknown"))
else
table.insert(lines, "Role: " .. roleName)
end
table.insert(lines, "Left-click: confirm role")
local list = DC_Roles and DC_Roles.CLASS_ROLES
and DC_Roles.CLASS_ROLES[class]
if list and table.getn(list) >= 2 then
local parts = {}
local n = table.getn(list)
for i = 1, n do
table.insert(parts, MX.ROLE_NAME[list[i]] or list[i])
end
table.insert(lines, "Right-click: cycle: "
.. table.concat(parts, " / "))
else
table.insert(lines, "Role is fixed for " .. MX.ClassTitle(class))
end
return lines
end
-- Weapon cell HAS detail discriminator: the model puts the
-- temp-enchant NAME into detail when store.weaponMainName is set, else the
-- generic string below. Returns the name, or nil on the generic/absent
-- detail (tooltip then keeps the standard HAS path).
MX.WEAPON_GENERIC = "main hand temp enchant"
function MX.WeaponEnchantName(detail)
if type(detail) ~= "string" then
return nil
end
if detail == MX.WEAPON_GENERIC or detail == "" then
return nil
end
return detail
end
-- Scan-age text (titlebar, ticks 1/s): "Ns ago" under 60 s, else minutes.
function MX.FormatScanAge(seconds)
if seconds == nil then
return "no scan yet"
end
if seconds < 60 then
return "Scan: " .. math.floor(seconds) .. " s ago"
end
return "Scan: " .. math.floor(seconds / 60) .. " min ago"
end
-- ------------------------------------------------------------------
-- DEBUFFS warning-signal helpers. The tab reports
-- what IS wrong, not what fulfills an expectation: rowVM.debuffCount is
-- the TOTAL count of distinct debuff names on the player (including any
-- beyond the 10 shown columns), nil when not readable; group.debuffTotal
-- sums it over readable rows. sumHas/sumExpected stay nil on this tab.
-- ------------------------------------------------------------------
-- DISPLAY tabs (RESIST + HIT): static columns, no expectations, no gaps.
-- Mirrors DC_Model.IsDisplayTab -- the model is the authority, this is the
-- rendering side of the same rule (delegated when the model is loaded, so
-- the two can never disagree; the literal list keeps this pure section
-- standalone under the offline harness).
function MX.IsDisplayTab(tab)
if DC_Model and DC_Model.IsDisplayTab then
return DC_Model.IsDisplayTab(tab)
end
return tab == "RESIST" or tab == "HIT"
end
-- Footer strip label per tab (footer counts stay the model's numbers).
function MX.FooterLabel(tab)
if tab == "DEBUFFS" then
return "AFFLICTED PER DEBUFF"
elseif MX.IsDisplayTab(tab) then
return "AVERAGE (READABLE)"
end
return "MISSING PER SLOT"
end
-- Sum-column text for a DEBUFFS row: quiet "0" when clean, red "N" when
-- afflicted, "--" when the row is not readable (debuffCount nil counts as
-- not readable -- the model only sets it on readable rows).
-- Returns { text = string, colorKey = "faint"|"fehlt" } (DC.COLORS key).
function MX.DebuffSumText(count, readable)
if not readable or count == nil then
return { text = "--", colorKey = "faint" }
end
if count > 0 then
return { text = tostring(count), colorKey = "fehlt" }
end
return { text = "0", colorKey = "faint" }
end
-- Group pill on DEBUFFS: green "clean" ONLY when zero debuffs AND every
-- member readable (an unreadable member forbids the clean claim -- same
-- rule as UNKNOWN-never-ready); red "N debuffs" whenever any exist; gray
-- neutral "0 debuffs" while zero-but-unreadables-remain. nil total =
-- model without the debuffTotal field -> "?" (callers guard anyway).
-- Returns { text = string, kind = "good"|"bad"|"unknown" }.
function MX.DebuffPillText(debuffTotal, allReadable)
if debuffTotal == nil then
return { text = "?", kind = "unknown" }
end
if debuffTotal > 0 then
local noun = " debuffs"
if debuffTotal == 1 then
noun = " debuff"
end
return { text = debuffTotal .. noun, kind = "bad" }
end
if allReadable then
return { text = "clean", kind = "good" }
end
return { text = "0 debuffs", kind = "unknown" }
end
-- ------------------------------------------------------------------
-- Hit columns (the HIT tab). The model already did every bit of math --
-- these helpers only turn a hit cell into text and color keys, so the
-- numbers on screen can never disagree with the ones in the report.
-- ------------------------------------------------------------------
-- Cell number: always one decimal, so a column of hit values stays
-- aligned. Single source for the rounding is the model's formatter; the
-- local fallback only keeps this pure section standalone.
function MX.HitText(v)
if DC_Model and DC_Model.HitText then
return DC_Model.HitText(v)
end
if type(v) ~= "number" then
return "?"
end
return string.format("%.1f", v)
end
-- Cell text color key (DC.COLORS): green once the cap is met, parchment
-- below it, faint for a plain 0 -- most melee players carry no spell hit
-- and most casters no melee hit, and that zero should stay quiet rather
-- than shout. Never red: a hit number is information, not a gap.
function MX.HitColorKey(cvm)
if not cvm or type(cvm.value) ~= "number" then
return "unbek"
end
if cvm.capMet then
return "hat"
end
if cvm.value <= 0 then
return "faint"
end
return "parch"
end
-- Full tile style of a HAS hit cell: { bg, border, text } DC.COLORS keys.
-- A cell at or above its cap gets the FULFILLED look -- green background
-- AND green border, the same pair a green check sits on everywhere else --
-- because green text alone is invisible when scanning 40 rows for the
-- players who are still short. Below the cap the cell keeps the neutral
-- resistance tile: informative, not alarming. Never the red MISSING pair;
-- nothing on a display tab is a gap.
function MX.HitCellStyle(cvm)
if cvm and cvm.capMet then
return { bg = "hatBg", border = "hatBorder", text = "hat" }
end
return { bg = "theadBg", border = "line", text = MX.HitColorKey(cvm) }
end
-- "arcane" -> "Arcane": slot school tokens are lower case, tooltip prose
-- is not. Pure ASCII single-byte handling is enough (the six school
-- tokens are ASCII by construction).
function MX.SchoolTitle(school)
if type(school) ~= "string" or school == "" then
return ""
end
return string.upper(string.sub(school, 1, 1))
.. string.lower(string.sub(school, 2))
end
-- The one thing this addon can NOT read off another player: the quest
-- weapon-skill books. Stated in every melee/ranged hit tooltip so the
-- assumed skill is never mistaken for a measurement.
MX.HIT_BOOK_NOTE = "A quest weapon-skill book (+3 skill, about 0.6% hit)"
.. " cannot be detected on other players - tick it in the options if"
.. " it applies."
-- SET BONUSES are counted, once per set (scan/hit.lua H.SumSets). The
-- bonus line IS printed on every piece, which is why a per-item sum would
-- multiply it -- but the tooltip also states the set name, the piece count
-- and each bonus's threshold, so the honest answer is to group by set and
-- count each bonus once. The note only tells the reader that a set line in
-- the breakdown means the whole set, not one item.
MX.HIT_SETBONUS_NOTE = "Set bonuses count once per set, not once per"
.. " piece - a set line names the set and how many pieces it needed."
-- Hit cell tooltip content as a PURE array of
-- { text = string, color = <DC.COLORS key>, wrap = 1|nil }; the WoW
-- tooltip builder only applies the colors. Returns nil for states this
-- builder does not own (UNKNOWN keeps the generic not-readable tooltip).
function MX.HitTooltipLines(sd, cvm)
if not (sd and cvm) then
return nil
end
local lines = {}
-- add(text, color, wrap) -- a plain sentence
-- addv(label, value, color, valueColor) -- a BREAKDOWN row: label left,
-- value flush right (W.TipDouble). Every addend of the hit number is
-- one of these, so the figures line up in a single column and the
-- total can be checked by adding them up on screen.
local function add(text, color, wrap)
table.insert(lines, { text = text, color = color or "parch",
wrap = wrap })
end
local function addv(label, value, color, valueColor)
table.insert(lines, { text = label, right = value,
color = color or "parchDim",
rightColor = valueColor or color or "parch" })
end
if cvm.state == DC.STATE.NOTEXP then
-- The only NOTEXP a hit column can produce is "this player has no
-- ranged-hit question". The REASON is the model's string, printed
-- verbatim: an empty ranged slot and an equipped wand/relic are
-- different causes, and naming a wand for a player who carries
-- none would be a wrong explanation.
local why = (type(cvm.detail) == "string") and cvm.detail
or "no ranged attack weapon"
add(sd.full .. ": " .. why, "parch")
add("so there is no ranged hit cap to meet", "parchDim", 1)
return lines
end
if cvm.state ~= DC.STATE.HAS then
return nil
end
local b = (type(cvm.detail) == "table") and cvm.detail or {}
local value = cvm.value or b.value or 0
local function pct(v)
return MX.HitText(v) .. "%"
end
local function plus(v)
return "+" .. MX.HitText(v) .. "%"
end
-- ---- the number, then the addition that produces it ----------------
addv(sd.full, pct(value), "parch", cvm.capMet and "hat" or "parch")
addv(" from items", plus(b.gear or 0))
addv(" from set bonuses", plus(b.set or 0))
if b.aurasRead == false then
-- the aura channel failed THIS scan -- an unknown quantity, not a
-- verified zero, and a "+0.0%" would claim the opposite
addv(" from auras", "?", "parchDim", "unbek")
else
addv(" from auras", plus(b.aura or 0))
end
if b.talentsRead then
addv(" from talents", plus(b.talent or 0))
else
addv(" from talents", "?", "parchDim", "unbek")
end
-- Weapon skill sits on THIS side of the comparison (core/model.lua):
-- the boss's floor does not move, the attacker's skill is what he
-- brings. Shown for melee/ranged only -- spell hit knows no skill.
if b.kind ~= "spell" and b.skill then
local base = b.skillBase or 300
local over = b.skill - base
local label = " from " .. (b.skillName and string.lower(b.skillName)
or "weapon") .. " skill " .. b.skill
if over > 0 then
label = label .. " (+" .. over .. " over " .. base .. ")"
end
addv(label, plus(b.skillHit or 0))
end
if b.school then
-- a school column adds its school-only bonuses on top of the
-- generic number; they are addends of THIS column and belong in
-- the same addition
local title = MX.SchoolTitle(b.school)
addv(" " .. string.lower(title) .. " only, from items",
plus(b.gearSchool or 0))
if (b.setSchool or 0) > 0 then
addv(" " .. string.lower(title) .. " only, from set bonuses",
plus(b.setSchool))
end
addv(" " .. string.lower(title) .. " only, from talents",
plus(b.talentSchool or 0))
end
if b.talentsRead == false then
add("talents not received yet - the total will rise once they"
.. " arrive", "parchDim", 1)
end
if b.aurasRead == false then
add("the buff read failed for this player, so aura hit is unknown",
"parchDim", 1)
end
-- ---- the cap, as its own little addition ---------------------------
local cap = cvm.cap or b.cap
if cap then
-- one flat number: what a +3-level boss demands. It does not move
-- with the attacker's weapon skill -- that is an addend above.
addv("Cap vs a +3-level boss", pct(cap), "parch")
if cvm.capMet then
add("at or above the cap", "hat")
else
add("missing " .. pct(cap - value) .. " to the cap", "parch")
end
if b.capUnverified then
add("this cap is the commonly quoted value, not one measured"
.. " on this server", "parchDim", 1)
end
end
-- ---- what the weapon-skill number itself rests on -------------------
if b.kind ~= "spell" then
if b.skill then
if b.skillExact then
add("weapon skill measured on your own character",
"parchDim", 1)
elseif b.book then
add("weapon skill assumed: 300 + racial bonus + the skill"
.. " book you ticked", "parchDim", 1)
else
add("weapon skill assumed: 300 + racial bonus. A quest skill"
.. " book (+3 skill, about 0.6% hit) cannot be detected"
.. " on other players - tick it in the options if it"
.. " applies", "parchDim", 1)
end
else
add("Weapon skill: unknown - the equipped weapon could not be"
.. " identified", "parch", 1)
end
if b.dualWield then
addv("Dual wield: white swings need", pct(b.capWhite or 0),
"parchDim")
if (b.talentOffhand or 0) > 0 then
-- an off-hand hit talent raises OFF-HAND swings only, so
-- it is deliberately absent from the number at the top --
-- naming it here is the whole point of carrying it
addv(" off-hand only, from talents",
plus(b.talentOffhand))
end
end
end
if type(b.schools) == "table" then
-- generic spell column: school-specific hit counts against that
-- school ONLY, so it is never part of the number above
local names = {}
for school in pairs(b.schools) do
table.insert(names, school)
end
table.sort(names)
for i = 1, table.getn(names) do
addv(" " .. names[i] .. " only (not in the total above)",
plus(b.schools[names[i]]))
end
end
add("display only", "parchDim", 1)
return lines
end
-- Header-tile "expected for:" roles line (seam fix):
-- db.expectations is PER CLASS now -- [role][class][slotId] -- so the old
-- [role][slotId] read returned nil for every slot ("expected for: no
-- role" on ALL header tooltips). A role counts as expecting the slot when
-- ANY performing class resolves to expected, with the SAME fallback
-- fallback as the model: an explicit class line wins, a missing/non-table
-- line falls back to the DC.DEFAULT_EXPECT role line. Legacy role x slot
-- tables (the RenderHeader fallback while db.expectations is nil) keep
-- working through the direct boolean read -- their per-class semantics
-- are identical to the model's corollary (every combo = the role line).
function MX.ExpectedForString(slotId, expectTbl)
local parts = {}
local n = table.getn(DC.ROLES)
for i = 1, n do
local role = DC.ROLES[i]
local byClass = expectTbl and expectTbl[role]
local def = DC.DEFAULT_EXPECT and DC.DEFAULT_EXPECT[role]
local expected = false
if type(byClass) == "table" then
if byClass[slotId] == true then
expected = true -- legacy role x slot line
else
local classes = DC_Roles and DC_Roles.ROLE_CLASSES
and DC_Roles.ROLE_CLASSES[role]
if classes then
for c = 1, table.getn(classes) do
local line = byClass[classes[c]]
if type(line) ~= "table" then
line = def -- rule-5 fallback, as in the model
end
if line and line[slotId] then
expected = true
break
end
end
elseif def and def[slotId] then
-- defensive: DC_Roles absent -> role-line fallback
expected = true
end
end
elseif def and def[slotId] then
-- absent role table: SAME rule-5 fallback as the model's
-- expectLine -- the tooltip must agree with the rendered
-- columns (an absent role still contributes its default line)
expected = true
end
if expected then
table.insert(parts, DC.ROLE_LABEL[role])
end
end
if table.getn(parts) == 0 then
return "no role"
end
return table.concat(parts, ", ")
end
-- Tab/filter state setters (state part is pure; re-render only in-game).
function MX.SetTab(id)
local n = table.getn(DC.TABS)
local valid = false
for i = 1, n do
if DC.TABS[i] == id then
valid = true
end
end
if not valid then
return
end
MX.state.tab = id
if MX.Refresh then
MX.Refresh()
end
end
function MX.SetFilter(gapsOnly)
MX.state.gapsOnly = gapsOnly and true or false
if MX.Refresh then
MX.Refresh()
end
end
-- Current tab id -- ui/report.lua asks DC_Matrix.GetTab() for it when it
-- builds its own vm (report of the ACTIVE tab, not always CONSUMABLES).
function MX.GetTab()
return MX.state.tab
end
-- ==================================================================
-- WOW SECTION -- frames, rendering, interaction
-- ==================================================================
if CreateFrame then
local W = DC_Widgets
local C = DC.COLORS
local S = DC.STATE
local UI = { -- every frame reference + pool lives here (upvalue budget)
headerTiles = {}, headerMax = 0,
footerFS = {}, footerMax = 0,
groupHeads = {},
rowPool = {}, rowMax = 0,
cellIconN = 0, -- running index for named per-cell icon textures
cellCornerN = 0, -- running index for named per-cell corner markers
}
local M = {} -- private helpers (factories, painters, tooltip builders)
-- vertical metrics (frame-local, negative y = down)
local LC = {
TITLE_H = 30, TABS_H = 24, TOOLBAR_H = 24,
CONTENT_TOP = -88, -- -(10 + TITLE_H + TABS_H + TOOLBAR_H)
BANNER_H = 20, HEADER_H = 30, FOOTER_H = 18, FOOT_H = 14,
MAX_SCROLL_H = 560, MIN_SCROLL_H = 40, -- 560 = fixed design value
BAR_X_OFF = 16, -- banner/header/footer bars clear the border band
-- (panel_border.tga is ~16px fully opaque;
-- checked in-game: 10 still left 6px
-- of the bars inside the band)
DIMBORDER = {}, -- dimmer role borders for SUGGESTED badges
}
for i = 1, table.getn(DC.ROLES) do
local role = DC.ROLES[i]
local b = C["roleBorder" .. role]
LC.DIMBORDER[role] = { r = b.r * 0.55, g = b.g * 0.55, b = b.b * 0.55 }
end
function M.db()
return DopingControlDB
end
function M.store()
return DC.store or { players = {}, source = "scan" }
end
function M.roleName(role)
return MX.ROLE_NAME[role] or role or "?"
end
-- ------------------------------------------------------------------
-- tooltip builders
-- ------------------------------------------------------------------
function M.qualityColors(q)
-- custom accents for the common raid qualities; DC.QUALITY for the rest
if q == 3 then
return C.rareText, C.rareBorder
elseif q == 4 then
return C.epicText, C.epicBorder
end
local base = DC.QUALITY[q or 1] or DC.QUALITY[1]
return base, base
end
function M.CellTooltip(cell)
local sd, cvm, row = cell.dcSD, cell.dcVM, cell.dcRow
if not (sd and cvm and row) then
return
end
local state = cvm.state
-- Equipment cell with a known item: native
-- item tooltip via SetHyperlink -- it includes the green enchant line,
-- which is the ONLY way to show WHAT the item is enchanted with (no
-- enchant-name table exists in 1.12). Re-SetOwner with ANCHOR_LEFT
-- (clears the AttachTooltip cursor anchor) + one colored state line.
-- Uncached items may render an empty tooltip on the first hover (the
-- client fires the server query); acceptable, fills once cached.
-- The custom tooltip below stays as the fallback when item is nil.
local item = cvm.item
if (sd.kind == "ench" or sd.kind == "trinket")
and type(item) == "table" and item.id then
GameTooltip:SetOwner(cell, "ANCHOR_LEFT")
GameTooltip:SetHyperlink("item:" .. item.id .. ":" .. (item.enchant or 0))
if sd.kind == "ench" then
if state == S.HAS then
GameTooltip:AddLine("Enchanted", C.hat.r, C.hat.g, C.hat.b)
elseif state == S.MISSING then
GameTooltip:AddLine("Not enchanted",
C.fehlt.r, C.fehlt.g, C.fehlt.b)
elseif state == S.NOTEXP then
-- two distinct reasons share this state (core/model.lua):
-- a subtype exemption (wand/holdable, detail carries the
-- "<subType> - not enchantable" string) vs. the enchant
-- simply not being expected for this role/class (detail
-- nil) -- the tile itself renders identically (bright,
-- quality border, no marker), only the reason differs
if cvm.detail then
GameTooltip:AddLine("This item cannot be enchanted",
C.parch.r, C.parch.g, C.parch.b)
else
GameTooltip:AddLine("Enchant not expected for this role/class",
C.parch.r, C.parch.g, C.parch.b)
end
end
elseif state == S.HAS then
GameTooltip:AddLine("Equipped", C.hat.r, C.hat.g, C.hat.b)
end
GameTooltip:Show()
return
end
-- Debuffs tab: afflicted = MISSING
-- carrying the debuff icon, clean = HAS with no icon. UNKNOWN falls
-- through to the generic branch below; NOTEXP does not occur here
-- (expectations are not consulted on DEBUFFS).
if sd.kind == "debuff" and state == S.MISSING then
W.TipLine(tostring(cvm.detail or ("Afflicted: " .. sd.full)), C.fehlt)
W.TipLine("counts as a gap until the debuff is gone", C.parchDim, 1)
return
elseif sd.kind == "debuff" and state == S.HAS then
W.TipLine("Clean - no " .. sd.full, C.hat)
return
end
-- Resistances tab: display only, never a gap (state is always HAS or
-- UNKNOWN -- UNKNOWN falls through to the generic branch below).
-- Provenance line (honesty rule, see core/model.lua's classify()
-- resist branch and scan/engine.lua's AssembleResists header): a
-- GEAR-RECONSTRUCTED total (row.player.resistsSource == "gear" -- the
-- server never transmits UNIT_FIELD_RESISTANCES for a foreign player
-- at all, so this is the ONLY source a foreign row can ever carry) is
-- not the same kind of fact as a measured UnitResistance/GetUnitField
-- reading, and the cell must say so rather than presenting a rebuilt
-- number as an unqualified measurement. Every other source -- direct
-- token/guid/field reads, and the always-measured own character
-- (resistsSource nil) -- keeps the plain "display only" line.
if sd.kind == "resist" and state == S.HAS then
W.TipLine(sd.full .. ": " .. tostring(cvm.value or 0), C.parch)
if row.player and row.player.resistsSource == "gear" then
W.TipLine("reconstructed from equipment + race (not measured)",
C.parchDim, 1)
else
W.TipLine("display only", C.parchDim, 1)
end
return
end
-- Hit columns: value, cap and how it was derived, gear breakdown,
-- weapon skill assumption incl. the book note, dual wield, school
-- extras -- all of it composed by the pure builder, which returns nil
-- for UNKNOWN so those cells keep the generic not-readable tooltip.
if sd.kind == "hit" then
local hitLines = MX.HitTooltipLines(sd, cvm)
if hitLines then
local nH = table.getn(hitLines)
for i = 1, nH do
local hl = hitLines[i]
if hl.right then
-- breakdown row: value flush right, so the addends of
-- the hit number line up in one column
W.TipDouble(hl.text, hl.right, C[hl.color] or C.parchDim,
C[hl.rightColor] or C[hl.color] or C.parch)
else
W.TipLine(hl.text, C[hl.color] or C.parch, hl.wrap)
end
end
return
end
end
if state == S.HAS then
-- Weapon cell with a KNOWN temp-enchant name (
-- self = hidden-tooltip scan, foreign = SuperWoW
-- GetWeaponEnchantInfo): the name is the title line; the generic
-- weapon detail falls through to the standard HAS path below.
if sd.kind == "weapon" then
local ench = MX.WeaponEnchantName(cvm.detail)
if ench then
W.TipLine(ench, C.parch)
W.TipLine("main hand temp enchant", C.parchDim, 1)
return
end
end
if sd.kind == "trinket" and type(cvm.detail) == "table" then
local e = cvm.detail
local qc = M.qualityColors(e.quality)
W.TipLine(e.name or sd.full, qc)
W.TipLine(sd.full .. " - inventory slot " .. sd.invSlot, C.parchDim, 1)
else
-- Fixed HAS tooltip content: buff name,
-- spell ID ("ID before name"), example item, slot. ID-match
-- details already embed "(spell N)"; name matches get the
-- table-known ID(s) appended here.
local headline = tostring(cvm.detail or sd.full)
local plain = headline
local cut = string.find(plain, " (spell ", 1, true)
if cut then
plain = string.sub(plain, 1, cut - 1)
else
local ids = MX.KnownSpellIds(plain)
if ids then
headline = headline .. " (spell " .. ids .. ")"
end
end
W.TipLine(headline, C.hat)
-- example item: the matched entry's item, else the slot list
local entry = (DC.CONSUMABLES and DC.CONSUMABLES[plain])
or (DC.CLASSBUFFS and DC.CLASSBUFFS[plain])
local example = (entry and entry.item) or sd.items
if example then
W.TipLine("e.g. " .. example, C.parchDim, 1)
end
W.TipLine("Slot " .. sd.full, C.parch, 1)
end
elseif state == S.MISSING then
if sd.kind == "trinket" then
W.TipLine(sd.full .. " empty", C.fehlt)
W.TipLine("unit readable, no trinket equipped - counts as a gap",
C.parchDim, 1)
elseif sd.kind == "ench" and cvm.detail ~= "no enchant" then
-- empty gear slot (no item at all, cvm.detail == "<full>
-- empty" -- core/model.lua): ALWAYS a gap, independent of the
-- expectation checkbox -- distinct wording from the real
-- "item present, no enchant" case right below
W.TipLine(tostring(cvm.detail or (sd.full .. " empty")), C.fehlt)
W.TipLine("unit readable, no item equipped - counts as a gap"
.. " regardless of expectations", C.parchDim, 1)
else
W.TipLine("Missing: " .. sd.full, C.fehlt)
W.TipLine("unit readable, slot empty - expected for "
.. M.roleName(row.role), C.parchDim, 1)
end
elseif state == S.UNKNOWN then
-- cells with cached data lead with the UNKNOWN
-- reason ("offline" / "out of range" / "read failed"), then the
-- aged observation. The cache NEVER upgrades the cell (iron rule).
local last = (type(cvm.last) == "table") and cvm.last or nil
local cause = row.unreadNote or tostring(cvm.detail or "no data")
if last then
W.TipLine(cause, C.unbek)
W.TipLine(MX.LastLine(last) or "no cached data", C.parch, 1)
W.TipLine("display only - still counts as UNKNOWN",
C.parchDim, 1)
return
end
W.TipLine("Not readable", C.unbek)
if cause == "offline" then
cause = "offline (UnitIsConnected = false)"
elseif row.player and row.player.distance then
cause = cause .. " - dist " .. math.floor(row.player.distance)
.. " yd recorded (diagnosis, not a filter)"
end
W.TipLine(cause, C.parch, 1)
W.TipLine("UNKNOWN is never condensed into MISSING", C.parchDim, 1)
else -- NOTEXP
-- Three different causes land here, and only ONE of them is the
-- expectation matrix (core/model.lua): a class exemption (":689",
-- detail "no ranged enchant for this class"), a legitimately empty
-- exemptable slot (":705", detail "no item") and the actual
-- expectation checkbox (detail nil). Both exemptions are decided
-- BEFORE `expected` is ever read, so pointing their tooltip at the
-- Options grid sent the user to a switch that cannot change the
-- cell. Print the model's own reason where it has one.
if cvm.detail then
W.TipLine(tostring(cvm.detail), C.parch)
W.TipLine("decided by class/item - the expectation matrix does"
.. " not apply here", C.parchDim, 1)
else
W.TipLine("Not expected for role " .. M.roleName(row.role),
C.parch)
W.TipLine("change via Options - expectation matrix",
C.parchDim, 1)
end
end
end
function M.HeaderTooltip(tile)
local sd = tile.dcSD
if not sd then
return
end
W.TipLine(sd.full, W.WHITE)
if sd.kind == "debuff" or sd.kind == "resist" then
-- debuff/resist header tooltip is
-- the full name only; no items line, no expected-for line
-- (expectations are never consulted on either tab)
return
end
if sd.kind == "hit" then
-- hit columns: one sentence on where the number comes from --
-- there is no hit API on this client, it is summed off the
-- equipped items (plus auras and, for the own character,
-- talents). No expected-for line (no expectations here).
if sd.school then
W.TipLine("generic spell hit plus everything that only helps "
.. MX.SchoolTitle(sd.school) .. " spells - gear and talents",
C.parchDim, 1)
else
W.TipLine("summed from the equipped items - no client API"
.. " reports hit", C.parchDim, 1)
end
return
end
if sd.items then
W.TipLine(sd.items, C.parch, 1)
end
if sd.kind == "trinket" then
W.TipLine("Equipped item shown; only an EMPTY slot counts as a gap",
C.parchDim, 1)
end
W.TipLine("expected for: " .. (tile.dcExpFor or "-"), C.parchDim, 1)
end
function M.BadgeTooltip(badge)
local row = badge.dcRow
if not row then
return
end
-- content comes from the pure builder (current role +
-- reason when suggested, then the left/right click actions with the
-- class's actual cycle options); this only applies colors.
local lines = MX.RoleTooltipLines(row.player and row.player.class,
row.role, row.suggested, row.reason)
local n = table.getn(lines)
for i = 1, n do
if i == 1 then
W.TipLine(lines[i], row.suggested and C.goldHi or W.WHITE)
elseif row.suggested and i == 2 then
W.TipLine(lines[i], C.parch, 1) -- reason line
else
W.TipLine(lines[i], C.parchDim, 1)
end
end
end
function M.WhisperTooltip(btn)
local row = btn.dcRow
if not row then
return
end
W.TipLine("Whisper " .. row.player.name, W.WHITE)
W.TipLine("sends their missing " .. (DC.TAB_LABEL[MX.state.tab] or "")
.. " items - one click, one whisper, never automatic", C.parchDim, 1)
local canW, remaining = MX.CanWhisper(row, GetTime())
if not canW and remaining then
W.TipLine("cooldown: " .. math.ceil(remaining) .. " s remaining", C.unbek)
end
if M.store().source == "sim" then
W.TipLine("test mode: prints a local note, sends nothing",
C.bannerYellowText, 1)
end
end
function M.SumTooltip(sumf)
local row = sumf.dcRow
if not row then
return
end
if not row.readable then
W.TipLine("not readable - no claim", C.unbek)
return
end
if MX.IsDisplayTab(MX.state.tab) then
W.TipLine("display only - no expectation on this tab", C.parchDim)
return
end
-- DEBUFFS: no x/y expectation math on this tab --
-- sumHas/sumExpected are nil, the column carries the TOTAL distinct
-- debuff count (rowVM.debuffCount) instead.
if row.sumHas == nil then
local n = row.debuffCount or 0
if n > 0 then
local noun = (n == 1) and " distinct debuff" or " distinct debuffs"
W.TipLine(n .. noun .. " on this player", C.fehlt)
else
W.TipLine("No debuffs - clean", C.hat)
end
W.TipLine("counts every debuff name, including any beyond the"
.. " shown columns", C.parchDim, 1)
return
end
W.TipLine(row.sumHas .. " of " .. row.sumExpected
.. " expected slots active", W.WHITE)
if row.unknowns and row.unknowns > 0 then
W.TipLine(row.unknowns .. " expected slot(s) not verifiable"
.. " (UNKNOWN) - excluded from both numbers", C.unbek, 1)
end
end
function M.PillTooltip(pill)
local g = pill.dcG
if not g then
return
end
-- DEBUFFS: the pill is a warning aggregate, not a
-- readiness claim. Nil-guarded: a model without debuffTotal
-- falls through to the ready wording (matches the rendered pill).
if MX.state.tab == "DEBUFFS" and g.debuffTotal ~= nil then
if g.debuffTotal > 0 then
local noun = (g.debuffTotal == 1) and " debuff" or " debuffs"
W.TipLine(g.debuffTotal .. noun
.. " across this group's readable players", C.fehlt)
elseif g.readable == g.total then
W.TipLine("Clean - no debuffs on any player", C.hat)
else
W.TipLine("No debuffs found, but not every player is readable",
C.parch)
end
W.TipLine("warning signal, not an expectation - green only when"
.. " every player is readable and clean", C.parchDim, 1)
if g.unreadable > 0 then
W.TipLine(g.unreadable .. " not readable", C.unbek)
end
return
end
if MX.IsDisplayTab(MX.state.tab) then
W.TipLine("Display only - no expectation tracked here", C.parch)
if g.unreadable > 0 then
W.TipLine(g.unreadable .. " not readable", C.unbek)
end
return
end
W.TipLine(g.ready .. " of " .. g.total .. " verified complete", W.WHITE)
W.TipLine("green only when every player is readable and has no gaps"
.. " - UNKNOWN never counts as ready", C.parchDim, 1)
if g.unreadable > 0 then
W.TipLine(g.unreadable .. " not readable", C.unbek)
end
end
function M.CoverageTooltip()
local cov = MX.vm and MX.vm.coverage
if not cov then
return
end
W.TipLine(cov.readable .. " of " .. cov.total .. " raid members readable",
W.WHITE)
W.TipLine(cov.offline .. " offline \194\183 " .. cov.readfailed
.. " read failed", C.parch)
if cov.far and cov.far > 0 then
-- Diagnostic count only -- and a SUBSET of the readable number
-- above, not a fourth bucket beside offline/read failed: a player
-- who could not be read is never counted as far (core/model.lua).
-- The threshold is inclusive there (distance >= FAR_LIMIT), so
-- ">" would misstate the boundary by exactly one player standing
-- at 40 yd.
W.TipLine("at or beyond " .. (DC.FAR_LIMIT or 40) .. " yd: "
.. cov.far, C.parch)
W.TipLine("readable, already counted above - distance is"
.. " diagnostic and never filters", C.parchDim, 1)
end
W.TipLine("Determined from the read result itself, not from a range"
.. " assumption", C.parchDim, 1)
end
-- ------------------------------------------------------------------
-- unassigned chip (titlebar, right of the coverage pill): players whose
-- role is only a heuristic suggestion (vm.unassigned). Lazy singleton
-- like the toast, hidden while every role is confirmed. Plain Frame ->
-- NOT clickable; assignment happens on the role badge in the player row.
-- ------------------------------------------------------------------
function M.UnassignedTooltip()
local vm = MX.vm
local ua = vm and vm.unassigned
if not ua then
return
end
W.TipLine("Players without a confirmed role", C.goldHi)
-- class colors, looked up from the vm rows (rendering reads only
-- the vm); tooltip opens are event-driven, so this small pass does
-- not violate the no-alloc-per-frame rule
local classOf = {}
local nG = table.getn(vm.groups)
for gi = 1, nG do
local g = vm.groups[gi]
local nR = table.getn(g.rows)
for ri = 1, nR do
local row = g.rows[ri]
if row.suggested and row.player then
classOf[row.player.name] = row.player.class
end
end
end
local n = table.getn(ua.names)
for i = 1, n do
local nm = ua.names[i]
local cc = classOf[nm] and C["class" .. classOf[nm]]
W.TipLine(nm, cc or C.parch)
end
W.TipLine("Click the role badge in a player row to assign.",
C.parchDim, 1)
end
function M.EnsureUnassignedPill()
if UI.unassignedPill then
return UI.unassignedPill
end
local pill = W.MakePill(UI.main, "DopingControlUnassignedPill")
pill:SetPoint("LEFT", UI.coveragePill, "RIGHT", 8, 0)
pill:SetHeight(17)
W.SetBackdropG(pill, C.void, C.line) -- coverage-pill look ...
W.SetTextColorG(pill.text, C.goldHi) -- ... with gold warning text
W.AttachTooltip(pill, M.UnassignedTooltip, "ANCHOR_LEFT")
UI.unassignedPill = pill
return pill
end
-- ------------------------------------------------------------------
-- toast (simple fading FontString frame; OnUpdate only while shown)
-- ------------------------------------------------------------------
function M.EnsureToast()
if UI.toast then
return UI.toast
end
local toast = CreateFrame("Frame", "DopingControlToast", UI.main)
toast:SetFrameStrata("DIALOG")
toast:SetHeight(24)
toast:SetPoint("BOTTOM", UI.main, "BOTTOM", 0, 26)
toast:SetBackdrop(W.SOLID_BACKDROP)
W.SetBackdropG(toast, C.bannerGreenBg, C.btnGreenBorder)
toast.text = W.MakeText(toast, 10, C.btnGreenText)
toast.text:SetPoint("CENTER", toast, "CENTER", 0, 0)
toast:SetScript("OnUpdate", function()
local e = GetTime() - (this.dcT0 or 0)
if e >= 3.0 then
this:Hide()
elseif e > 2.6 then
this:SetAlpha((3.0 - e) / 0.4)
end
end)
toast:Hide()
UI.toast = toast
return toast
end
function MX.ShowToast(text)
M.EnsureToast()
UI.toast.text:SetText(text)
UI.toast:SetWidth(UI.toast.text:GetStringWidth() + 32)
UI.toast.dcT0 = GetTime()
UI.toast:SetAlpha(1)
UI.toast:Show()
end
-- ------------------------------------------------------------------
-- whisper (the ONLY SendChatMessage in this file; dead in sim mode)
-- ------------------------------------------------------------------
function M.WhisperClick()
local row = this.dcRow
if not row then
return
end
local now = GetTime()
local canW = MX.CanWhisper(row, now)
if not canW then
return
end
local text = DC_Model.whisperText(row, DC.TAB_LABEL[MX.state.tab], MX.vm.slots)
if M.store().source == "sim" then
-- Test mode: chat outputs are dead -> local note only.
if DEFAULT_CHAT_FRAME then
DEFAULT_CHAT_FRAME:AddMessage("DopingControl (test mode) - whisper"
.. " suppressed. Would send to " .. row.player.name .. ": " .. text,
0.88, 0.79, 0.54)
end
else
SendChatMessage(text, "WHISPER", nil, row.player.name)
end
MX.lastWhisper[row.player.name] = now
MX.Refresh()
end
-- ------------------------------------------------------------------
-- pooled factories (create on demand, never destroy, hide surplus)
-- ------------------------------------------------------------------
function M.EnsureHeaderTile(i)
local t = UI.headerTiles[i]
if t then
return t
end
t = CreateFrame("Frame", "DopingControlHeaderTile" .. i, UI.headerRow)
t:SetHeight(LC.HEADER_H - 4)
t:SetBackdrop(W.SOLID_BACKDROP)
W.SetBackdropG(t, C.panel2, C.line)
t:EnableMouse(true)
t.label = W.MakeText(t, 9, C.parch)
t.label:SetPoint("TOP", t, "TOP", 0, -4)
t.sub = W.MakeText(t, 7, C.faint)
t.sub:SetPoint("BOTTOM", t, "BOTTOM", 0, 3)
W.AttachTooltip(t, M.HeaderTooltip, "ANCHOR_LEFT")
UI.headerTiles[i] = t
if i > UI.headerMax then
UI.headerMax = i
end
return t
end
function M.EnsureFooterCount(i)
local fs = UI.footerFS[i]
if fs then
return fs
end
fs = W.MakeText(UI.footerRow, 9, C.faint)
UI.footerFS[i] = fs
if i > UI.footerMax then
UI.footerMax = i
end
return fs
end
function M.GroupHeadClick()
local d = M.db()
if not d then
return
end
if not d.collapse then
d.collapse = {}
end
if d.collapse[this.dcRole] then
d.collapse[this.dcRole] = nil
else
d.collapse[this.dcRole] = true
end
MX.Refresh()
end
function M.EnsureGroupHead(i)
local gh = UI.groupHeads[i]
if gh then
return gh
end
gh = CreateFrame("Button", "DopingControlGroupHead" .. i, UI.scrollChild)
gh:SetHeight(MX.GROUP_H)
gh.bg = gh:CreateTexture(nil, "BACKGROUND")
gh.bg:SetAllPoints(gh)
gh.bg:SetTexture(0.063, 0.078, 0.106) -- #10141b
gh.bar = gh:CreateTexture(nil, "ARTWORK")
gh.bar:SetPoint("TOPLEFT", gh, "TOPLEFT", 0, 0)
gh.bar:SetPoint("BOTTOMLEFT", gh, "BOTTOMLEFT", 0, 0)
gh.bar:SetWidth(3)
gh.caret = W.MakeText(gh, 10, C.dim)
gh.caret:SetPoint("LEFT", gh, "LEFT", 10, 0)
gh.label = W.MakeText(gh, 10)
gh.label:SetPoint("LEFT", gh, "LEFT", 24, 0)
gh.pill = W.MakePill(gh, "DopingControlGroupHead" .. i .. "Pill")
gh.pill:SetPoint("LEFT", gh.label, "RIGHT", 12, 0)
W.AttachTooltip(gh.pill, M.PillTooltip, "ANCHOR_LEFT")
gh.unread = W.MakeText(gh, 9, C.unbek)
gh.unread:SetPoint("LEFT", gh.pill, "RIGHT", 10, 0)
gh.gcount = W.MakeText(gh, 9, C.dim)
gh.gcount:SetPoint("RIGHT", gh, "RIGHT", -10, 0)
gh:SetScript("OnClick", M.GroupHeadClick)
UI.groupHeads[i] = gh
return gh
end
-- Role badge clicks (replaces the earlier popover idea):
-- LEFT = confirm the DISPLAYED role (clears the suggested state).
-- RIGHT = DC_Roles.cycle -> next plausible role for the class, saved
-- immediately (same persistence routing as confirm) + toast.
-- Single-entry/unknown classes: no-op, no toast.
function M.BadgeClick()
local row = this.dcRow
if not row then
return
end
local d = M.db()
if d and not d.roles then
d.roles = {}
end
local src = M.store().source
if arg1 == "RightButton" then
local class = row.player and row.player.class
local list = DC_Roles.CLASS_ROLES and DC_Roles.CLASS_ROLES[class]
if not (list and table.getn(list) >= 2 and DC_Roles.cycle) then
return -- role is fixed for this class (or cycle not loaded)
end
local nextRole = DC_Roles.cycle(d, src, row.player.name, class,
row.role)
MX.ShowToast("Role: " .. M.roleName(nextRole) .. " (saved)")
MX.Refresh()
return
end
DC_Roles.confirm(d, src, row.player.name, row.role)
local verb = row.suggested and "Suggestion confirmed: " or "Role confirmed: "
local where = " (stored on the name)"
if src == "sim" then
where = " (test mode - not saved)"
end
MX.ShowToast(verb .. row.player.name .. " -> " .. M.roleName(row.role)
.. where)
MX.Refresh()
end
function M.EnsureRow(i)
local rf = UI.rowPool[i]
if rf then
return rf
end
rf = CreateFrame("Frame", "DopingControlRow" .. i, UI.scrollChild)
rf.dcIndex = i -- pool index -> stable names for lazy row children
rf:SetHeight(MX.ROW_H)
-- alternating-row wash: created FIRST so the meaning-carrying
-- unreadable wash below draws on top of it, never the other way round
rf.zebra = rf:CreateTexture(nil, "BACKGROUND")
rf.zebra:SetAllPoints(rf)
rf.zebra:SetTexture(C.zebra.r, C.zebra.g, C.zebra.b)
rf.zebra:SetAlpha(DC.ZEBRA_ALPHA)
rf.zebra:Hide()
rf.bg = rf:CreateTexture(nil, "BACKGROUND")
rf.bg:SetAllPoints(rf)
rf.bg:SetTexture(0.118, 0.133, 0.165) -- unreadable-row wash
rf.bg:SetAlpha(0.25)
rf.bg:Hide()
rf.name = W.MakeText(rf, 10)
rf.name:SetPoint("LEFT", rf, "LEFT", MX.PAD + 2, 0)
rf.name:SetJustifyH("LEFT")
rf.status = W.MakeText(rf, 8, C.faint)
rf.status:SetPoint("LEFT", rf.name, "RIGHT", 4, 0)
-- out-of-range tag: "<n>yd" right of the
-- name/status pair, melee-orange so it reads as a warning but not as
-- an error; shown only while rowVM.far. Pooled with the row.
rf.farFS = W.MakeText(rf, 8, C.roleMELEE or C.dim)
rf.farFS:SetPoint("LEFT", rf.status, "RIGHT", 4, 0)
rf.farFS:Hide()
-- whisper button, right-aligned inside the name column
rf.wsp = CreateFrame("Button", nil, rf) -- pool child (factory exception)
rf.wsp:SetWidth(26)
rf.wsp:SetHeight(14)
rf.wsp:SetPoint("LEFT", rf, "LEFT", MX.PAD + MX.NAME_COL_MIN - 28, 0)
rf.wsp:SetBackdrop(W.SOLID_BACKDROP)
W.SetBackdropG(rf.wsp, C.panel2, C.goldSoft)
rf.wsp.text = W.MakeText(rf.wsp, 7, C.goldDim)
rf.wsp.text:SetPoint("CENTER", rf.wsp, "CENTER", 0, 0)
rf.wsp.text:SetText("PST")
rf.wsp:SetScript("OnClick", M.WhisperClick)
W.AttachTooltip(rf.wsp, M.WhisperTooltip, "ANCHOR_LEFT")
-- role badge (right-click cycles, so the factory's
-- left-only registration is widened here)
rf.badge = W.MakeBadge(rf, nil)
rf.badge:RegisterForClicks("LeftButtonUp", "RightButtonUp")
rf.badge:SetPoint("LEFT", rf, "LEFT",
MX.PAD + MX.NAME_COL_MIN + MX.CELL_GAP + 3, 0)
rf.badge:SetScript("OnClick", M.BadgeClick)
W.AttachTooltip(rf.badge, M.BadgeTooltip, "ANCHOR_LEFT")
-- sum (own mini frame so it can carry a tooltip)
rf.sum = CreateFrame("Frame", nil, rf) -- pool child (factory exception)
rf.sum:SetWidth(MX.SUM_COL_W)
rf.sum:SetHeight(MX.ROW_H)
rf.sum:EnableMouse(true)
rf.sum.text = W.MakeText(rf.sum, 10, C.parch)
rf.sum.text:SetPoint("CENTER", rf.sum, "CENTER", 0, 0)
W.AttachTooltip(rf.sum, M.SumTooltip, "ANCHOR_LEFT")
rf.cells = {}
rf.cellMax = 0
UI.rowPool[i] = rf
if i > UI.rowMax then
UI.rowMax = i
end
return rf
end
-- Lazy pooled row-spanning message: ONE named FontString
-- per row, shown only in "message" mode (equipment row, gear unreadable,
-- no cached gear) -- the slot cells hide underneath it. Created on first
-- need, never destroyed; position set by PaintRow from the live layout.
function M.EnsureUnreadMsg(rf)
if rf.unreadMsg then
return rf.unreadMsg
end
local fs = rf:CreateFontString("DopingControlRow" .. rf.dcIndex
.. "UnreadMsg", "OVERLAY")
W.ApplyFont(fs, 9)
W.SetTextColorG(fs, C.unbek)
fs:SetJustifyH("LEFT")
rf.unreadMsg = fs
return fs
end
function M.EnsureCell(rf, i)
local cell = rf.cells[i]
if cell then
return cell
end
cell = W.MakeCell(rf, nil, MX.CELL_W, MX.CELL_H) -- pool child (factory exception)
W.AttachTooltip(cell, M.CellTooltip, "ANCHOR_CURSOR")
rf.cells[i] = cell
if i > rf.cellMax then
rf.cellMax = i
end
return cell
end
-- Lazy named icon texture per cell: shows buff
-- icons (aura HAS with cell.icon) and item icons (equipment cell.item).
-- Created once on first need, never destroyed; the standard 0.08..0.92
-- crop is constant, so it is set here exactly once. SetTexture goes
-- through W.SetTextureG (dcLastTex guard) -- no re-set on repaint.
function M.EnsureIconTex(cell)
if cell.iconTex then
return cell.iconTex
end
UI.cellIconN = UI.cellIconN + 1
local t = cell:CreateTexture("DopingControlCell" .. UI.cellIconN .. "Icon",
"ARTWORK")
t:SetPoint("CENTER", cell, "CENTER", 0, 0)
t:SetWidth(18)
t:SetHeight(18)
t:SetTexCoord(0.08, 0.92, 0.08, 0.92)
t:Hide()
cell.iconTex = t
return t
end
function M.HideIconTex(cell)
if cell.iconTex then
cell.iconTex:Hide()
end
end
-- Lazy named corner marker per cell: 8px
-- solid square bottom-right. It carries the ENCHANT state of an equipment
-- item tile (green = enchanted, red = ench-kind slot without enchant and
-- not exempt) now that the tile border shows item QUALITY. OVERLAY layer
-- so it stays visible on top of the 18px ARTWORK item icon. Trinkets and
-- NOTEXP/EMPTY/UNKNOWN cells never show it.
function M.EnsureCornerTex(cell)
if cell.cornerTex then
return cell.cornerTex
end
UI.cellCornerN = UI.cellCornerN + 1
local t = cell:CreateTexture("DopingControlCell" .. UI.cellCornerN
.. "Corner", "OVERLAY")
t:SetPoint("BOTTOMRIGHT", cell, "BOTTOMRIGHT", -2, 2)
t:SetWidth(8)
t:SetHeight(8)
t:SetTexture(W.SOLID_TEX)
t:Hide()
cell.cornerTex = t
return t
end
function M.HideCornerTex(cell)
if cell.cornerTex then
cell.cornerTex:Hide()
end
end
-- Lazy named icon texture per header tile (DEBUFFS tab):
-- MX.HEADER_ICON px (20 -- a 30px icon crossed both row
-- hairlines of the 26px tile), standard 0.08..0.92 crop, replaces the
-- label text when the model knows the debuff texture. Tile pool indices
-- are stable -> stable name.
function M.EnsureHeaderIcon(tile, i)
if tile.iconTex then
return tile.iconTex
end
local t = tile:CreateTexture("DopingControlHeaderTile" .. i .. "Icon",
"ARTWORK")
t:SetPoint("CENTER", tile, "CENTER", 0, 0)
t:SetWidth(MX.HEADER_ICON)
t:SetHeight(MX.HEADER_ICON)
t:SetTexCoord(0.08, 0.92, 0.08, 0.92)
t:Hide()
tile.iconTex = t
return t
end
-- ------------------------------------------------------------------
-- painters (change-guarded: style keys / guarded setters skip no-ops)
-- ------------------------------------------------------------------
-- Item tile: equipment (ench/trinket) cell whose vm carries cell.item
-- ({ id, enchant, quality, texture, name },
-- present whenever the gear entry is a table -- HAS, enchant-missing, or
-- NOTEXP with an item: subtype-exempt (wand/holdable) or the enchant
-- simply not expected for this role/class). The tile border ALWAYS
-- carries the item QUALITY color (unknown quality -> the state border as
-- fallback); the ENCHANT state rides ONLY the 8px corner marker (green
-- enchant>0, red enchant==0 on a non-exempt ench slot). NOTEXP renders as
-- a normal BRIGHT tile -- quality border, no marker, no dimming: with
-- neck/ring enchants defaulting unexpected, dimming every neck/ring in
-- the raid would read as a data failure rather than a deliberate
-- "not tracked" choice. The 0.45 dim stays EXCLUSIVE to the last-known
-- (cached) rendering below (M.PaintLastCell). Icon preferred over
-- monogram whenever item.texture is present.
function M.PaintItemTile(cell, sd, state, item)
local bg, border, textColor
local marker = nil
if sd.kind == "trinket" then
local qText, qBorder = M.qualityColors(item.quality)
bg, border, textColor = C.theadBg, qBorder, qText
else -- ench-kind with a real item: neutral tile + quality border
local qText, qBorder
if item.quality ~= nil then
qText, qBorder = M.qualityColors(item.quality)
end
bg = C.theadBg
textColor = qText or C.parch
if state == S.HAS then
border = qBorder or C.hatBorder
marker = C.hat
elseif state == S.MISSING then
border = qBorder or C.fehltBorder
marker = C.fehlt
else -- NOTEXP: bright quality-bordered tile, no marker (see header)
border = qBorder or C.line
end
end
W.SetBackdropG(cell, bg, border)
if marker then
local mk = M.EnsureCornerTex(cell)
W.SetVertexG(mk, marker)
W.SetAlphaG(mk, 1) -- reset: cell may come out of dimmed rendering
mk:Show()
else
M.HideCornerTex(cell)
end
cell.icon:Hide()
if item.texture then
cell.text:Hide()
local t = M.EnsureIconTex(cell)
W.SetTextureG(t, item.texture)
W.SetAlphaG(t, 1)
t:Show()
else
M.HideIconTex(cell)
W.SetTextG(cell.text, MX.Monogram(item.name))
W.SetTextColorG(cell.text, textColor)
W.SetAlphaG(cell.text, 1)
cell.text:Show()
end
end
-- Last-known rendering (IRON RULE: display only). The
-- cell KEEPS the gray UNKNOWN backdrop + border -- it still IS an UNKNOWN
-- cell -- and only the content region (item icon / buff icon / check /
-- cross / monogram) shows the cached observation at MX.DIM_ALPHA. Alpha
-- sits on the texture/FontString, NEVER on the cell frame, so the border
-- stays crisp; every normal painter resets content alpha to 1 through
-- the same guarded setter. Ench tiles keep their (dimmed) corner marker
-- -- on a tile it is what encodes enchanted vs not.
function M.PaintLastCell(cell, sd, last)
W.SetBackdropG(cell, C.unbekBg, C.unbekBorder)
local item = nil
if (sd.kind == "ench" or sd.kind == "trinket")
and type(last.item) == "table" then
item = last.item
end
if item then
if sd.kind == "ench" then
local mk = M.EnsureCornerTex(cell)
W.SetVertexG(mk, (last.state == S.HAS) and C.hat or C.fehlt)
W.SetAlphaG(mk, MX.DIM_ALPHA)
mk:Show()
else
M.HideCornerTex(cell)
end
cell.icon:Hide()
if item.texture then
cell.text:Hide()
local t = M.EnsureIconTex(cell)
W.SetTextureG(t, item.texture)
W.SetAlphaG(t, MX.DIM_ALPHA)
t:Show()
else
M.HideIconTex(cell)
W.SetTextG(cell.text, MX.Monogram(item.name))
W.SetTextColorG(cell.text, C.unbek)
W.SetAlphaG(cell.text, MX.DIM_ALPHA)
cell.text:Show()
end
elseif last.icon then
-- cached aura icon (HAS) or cached debuff icon (MISSING)
cell.text:Hide()
cell.icon:Hide()
M.HideCornerTex(cell)
local t = M.EnsureIconTex(cell)
W.SetTextureG(t, last.icon)
W.SetAlphaG(t, MX.DIM_ALPHA)
t:Show()
elseif last.state == S.HAS then
cell.text:Hide()
M.HideIconTex(cell)
M.HideCornerTex(cell)
cell.icon:SetWidth(16)
cell.icon:SetHeight(16)
W.SetTextureG(cell.icon, W.CHECK_TEX)
cell.icon:SetTexCoord(0, 1, 0, 1)
W.SetVertexG(cell.icon, C.hat)
W.SetAlphaG(cell.icon, MX.DIM_ALPHA)
cell.icon:Show()
elseif last.state == S.MISSING then
cell.text:Hide()
M.HideIconTex(cell)
M.HideCornerTex(cell)
cell.icon:SetWidth(13)
cell.icon:SetHeight(13)
W.SetTextureG(cell.icon, W.CROSS_TEX)
cell.icon:SetTexCoord(0, 1, 0, 1)
W.SetVertexG(cell.icon, C.fehlt)
W.SetAlphaG(cell.icon, MX.DIM_ALPHA)
cell.icon:Show()
else
-- unexpected cached state (NOTEXP etc.): plain UNKNOWN "?"
cell.icon:Hide()
M.HideIconTex(cell)
M.HideCornerTex(cell)
W.SetTextG(cell.text, "?")
W.SetTextColorG(cell.text, C.unbek)
W.SetAlphaG(cell.text, 1)
cell.text:Show()
end
end
function M.PaintCell(cell, sd, cvm, row)
cell.dcSD = sd
cell.dcVM = cvm
cell.dcRow = row
local state = cvm.state
-- Cell shape: aura/weapon cells may carry .icon (texture
-- path, only when HAS; weapon always nil for now), ench/trinket cells
-- may carry .item. Both OPTIONAL -- every read below is nil-guarded,
-- absent fields fall back to the glyph rendering.
local item = nil
if (sd.kind == "ench" or sd.kind == "trinket")
and type(cvm.item) == "table" and state ~= S.UNKNOWN then
item = cvm.item
end
local icon = nil
if state == S.HAS and (sd.kind == "aura" or sd.kind == "weapon") then
icon = cvm.icon -- only set when HAS; weapon: always nil
elseif state == S.MISSING and sd.kind == "debuff" then
icon = cvm.icon -- afflicted cell = debuff texture
end
-- UNKNOWN cell carrying cell.last renders the cached
-- visual dimmed; without .last it stays the plain gray "?". The cache
-- is OPTIONAL vm data -- everything below is nil-guarded.
local last = nil
if state == S.UNKNOWN and type(cvm.last) == "table" and cvm.last.state then
last = cvm.last
end
local key = state
if last then
-- dim flag baked into the style key so the change guard repaints
-- when a cell flips between plain UNKNOWN and cached rendering
local li = last.item
key = "LAST:" .. tostring(last.state) .. ":" .. tostring(last.icon)
.. ":" .. tostring(li and li.texture) .. ":"
.. tostring(li and li.name) .. ":" .. tostring(li and li.quality)
elseif item then
key = "ITEM:" .. state .. ":" .. tostring(item.texture) .. ":"
.. tostring(item.quality) .. ":" .. tostring(item.name)
elseif icon then
key = "ICON:" .. state .. ":" .. icon
elseif state == S.HAS and sd.kind == "trinket" and type(cvm.detail) == "table" then
-- legacy trinket detail table (older model shape) -- fallback path
local e = cvm.detail
key = "TRK:" .. tostring(e.texture) .. ":" .. tostring(e.quality)
.. ":" .. tostring(e.name)
elseif state == S.HAS and sd.kind == "debuff" then
-- clean debuff cell shares the NOTEXP visual --
-- own style key so pooled cells repaint when the tab switches
-- (a plain "HAS" key would keep the green check from other tabs)
key = "DBCLEAN"
elseif state == S.HAS and sd.kind == "resist" then
-- RESIST: the number IS the content -- own style key so a
-- changed value repaints (a plain "HAS" key would not)
key = "RESIST:" .. tostring(cvm.value or 0)
elseif state == S.HAS and sd.kind == "hit" then
-- hit columns: value AND cap state drive the look, so both go
-- into the style key
key = "HIT:" .. tostring(cvm.value or 0) .. ":"
.. tostring(cvm.capMet)
end
if cell.dcStyle == key then
return
end
cell.dcStyle = key
if last then
M.PaintLastCell(cell, sd, last)
elseif item then
M.PaintItemTile(cell, sd, state, item)
elseif state == S.HAS and sd.kind == "trinket" and type(cvm.detail) == "table" then
-- legacy trinket path (model without cell.item): unchanged
local e = cvm.detail
local qText, qBorder = M.qualityColors(e.quality)
W.SetBackdropG(cell, C.theadBg, qBorder)
M.HideIconTex(cell)
M.HideCornerTex(cell)
if e.texture then
cell.text:Hide()
cell.icon:SetWidth(18)
cell.icon:SetHeight(18)
W.SetTextureG(cell.icon, e.texture)
cell.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
W.SetVertexG(cell.icon, W.WHITE)
W.SetAlphaG(cell.icon, 1)
cell.icon:Show()
else
cell.icon:Hide()
W.SetTextG(cell.text, MX.Monogram(e.name))
W.SetTextColorG(cell.text, qText)
W.SetAlphaG(cell.text, 1)
cell.text:Show()
end
elseif state == S.HAS and icon then
-- aura HAS with known buff texture: icon instead of the check
-- glyph, green HAS backdrop kept
W.SetBackdropG(cell, C.hatBg, C.hatBorder)
cell.text:Hide()
cell.icon:Hide()
M.HideCornerTex(cell)
local t = M.EnsureIconTex(cell)
W.SetTextureG(t, icon)
W.SetAlphaG(t, 1)
t:Show()
elseif state == S.MISSING and icon then
-- afflicted debuff cell: the debuff icon on the
-- red MISSING backdrop -- same lazy icon mechanism as aura HAS
W.SetBackdropG(cell, C.fehltBg, C.fehltBorder)
cell.text:Hide()
cell.icon:Hide()
M.HideCornerTex(cell)
local t = M.EnsureIconTex(cell)
W.SetTextureG(t, icon)
W.SetAlphaG(t, 1)
t:Show()
elseif state == S.HAS and sd.kind == "debuff" then
-- clean cell = quiet near-invisible dot (NOTEXP
-- visual): on a warning-signal tab the absence of a debuff is
-- the unremarkable normal state, not a fulfilled expectation --
-- NO green check. Afflicted cells keep the red tile + icon above.
W.SetBackdropG(cell, C.skip, C.skipBorder)
cell.icon:Hide()
M.HideIconTex(cell)
M.HideCornerTex(cell)
W.SetTextG(cell.text, "\194\183")
W.SetTextColorG(cell.text, C.skipGlyph)
W.SetAlphaG(cell.text, 1)
cell.text:Show()
elseif state == S.HAS and sd.kind == "resist" then
-- RESIST: neutral tile, the number is the text -- 0 is legitimate
-- and renders dimmed (faint), any other value in parch. Never
-- green/red -- this tab is display only, never a gap.
W.SetBackdropG(cell, C.theadBg, C.line)
cell.icon:Hide()
M.HideIconTex(cell)
M.HideCornerTex(cell)
local v = cvm.value or 0
W.SetTextG(cell.text, tostring(v))
if v > 0 then
W.SetTextColorG(cell.text, C.parch)
else
W.SetTextColorG(cell.text, C.faint)
end
W.SetAlphaG(cell.text, 1)
cell.text:Show()
elseif state == S.HAS and sd.kind == "hit" then
-- hit: the percentage IS the content. At or above the cap the
-- whole tile turns green (background + border, the fulfilled
-- look) so a capped player is visible at a glance across 40 rows;
-- below the cap it keeps the neutral resistance tile with
-- parchment text, faint on a plain 0. Never red: a hit number
-- below its cap is information on a display tab, not a gap.
local hs = MX.HitCellStyle(cvm)
W.SetBackdropG(cell, C[hs.bg] or C.theadBg, C[hs.border] or C.line)
cell.icon:Hide()
M.HideIconTex(cell)
M.HideCornerTex(cell)
W.SetTextG(cell.text, MX.HitText(cvm.value or 0))
W.SetTextColorG(cell.text, C[hs.text] or C.parch)
W.SetAlphaG(cell.text, 1)
cell.text:Show()
elseif state == S.HAS then
W.SetBackdropG(cell, C.hatBg, C.hatBorder)
cell.text:Hide()
M.HideIconTex(cell)
M.HideCornerTex(cell)
cell.icon:SetWidth(16)
cell.icon:SetHeight(16)
W.SetTextureG(cell.icon, W.CHECK_TEX)
cell.icon:SetTexCoord(0, 1, 0, 1)
W.SetVertexG(cell.icon, C.hat)
W.SetAlphaG(cell.icon, 1)
cell.icon:Show()
elseif state == S.MISSING then
W.SetBackdropG(cell, C.fehltBg, C.fehltBorder)
cell.text:Hide()
M.HideIconTex(cell)
M.HideCornerTex(cell)
cell.icon:SetWidth(13)
cell.icon:SetHeight(13)
W.SetTextureG(cell.icon, W.CROSS_TEX)
cell.icon:SetTexCoord(0, 1, 0, 1)
W.SetVertexG(cell.icon, C.fehlt)
W.SetAlphaG(cell.icon, 1)
cell.icon:Show()
elseif state == S.UNKNOWN then
W.SetBackdropG(cell, C.unbekBg, C.unbekBorder)
cell.icon:Hide()
M.HideIconTex(cell)
M.HideCornerTex(cell)
W.SetTextG(cell.text, "?")
W.SetTextColorG(cell.text, C.unbek)
W.SetAlphaG(cell.text, 1)
cell.text:Show()
else -- NOTEXP: near-invisible middle dot
W.SetBackdropG(cell, C.skip, C.skipBorder)
cell.icon:Hide()
M.HideIconTex(cell)
M.HideCornerTex(cell)
W.SetTextG(cell.text, "\194\183")
W.SetTextColorG(cell.text, C.skipGlyph)
W.SetAlphaG(cell.text, 1)
cell.text:Show()
end
end
function M.PaintGroupHead(gh, g, collapsed)
gh.dcRole = g.role
local rc = C["role" .. g.role]
W.SetVertexG(gh.bar, rc)
W.SetTextG(gh.caret, collapsed and "+" or "-")
W.SetTextG(gh.label, string.upper(g.label))
W.SetTextColorG(gh.label, rc)
-- ready pill: green iff EVERY member is readable and verified complete.
-- DEBUFFS: warning pill instead -- "clean" green
-- only when debuffTotal==0 AND all readable, red "N debuffs" when >0,
-- gray neutral while zero-but-unreadable. Nil-guarded: a
-- model without debuffTotal keeps the ready pill.
local pill = gh.pill
pill.dcG = g
if MX.state.tab == "DEBUFFS" and g.debuffTotal ~= nil then
local pr = MX.DebuffPillText(g.debuffTotal, g.readable == g.total)
W.SetTextG(pill.text, pr.text)
if pr.kind == "good" then
W.SetBackdropG(pill, C.hatBg, C.hatBorder)
W.SetTextColorG(pill.text, C.hat)
elseif pr.kind == "bad" then
W.SetBackdropG(pill, C.fehltBg, C.fehltBorder)
W.SetTextColorG(pill.text, C.fehlt)
else
W.SetBackdropG(pill, C.unbekBg, C.unbekBorder)
W.SetTextColorG(pill.text, C.unbek)
end
elseif MX.IsDisplayTab(MX.state.tab) then
-- neutral "info" style: display only, never a readiness claim --
-- no green/red, just a quiet gray label
W.SetTextG(pill.text, "display")
W.SetBackdropG(pill, C.panel2, C.line)
W.SetTextColorG(pill.text, C.dim)
else
W.SetTextG(pill.text, g.ready .. "/" .. g.total .. " ready")
if g.ready == g.total then
W.SetBackdropG(pill, C.hatBg, C.hatBorder)
W.SetTextColorG(pill.text, C.hat)
else
W.SetBackdropG(pill, C.fehltBg, C.fehltBorder)
W.SetTextColorG(pill.text, C.fehlt)
end
end
pill:SetWidth(pill.text:GetStringWidth() + 16)
if g.unreadable > 0 then
W.SetTextG(gh.unread, "\194\183 " .. g.unreadable .. " unreadable")
else
W.SetTextG(gh.unread, "")
end
if collapsed then
W.SetTextG(gh.gcount, "")
else
local gapsTxt
if g.gaps > 0 then
gapsTxt = "|cffd05252" .. g.gaps .. " gaps|r"
else
gapsTxt = g.gaps .. " gaps"
end
W.SetTextG(gh.gcount, g.total .. " players \194\183 " .. gapsTxt
.. " \194\183 " .. g.readable .. "/" .. g.total .. " readable")
end
end
function M.PaintRow(rf, row, layout, slots, now)
local p = row.player
local n = table.getn(slots)
-- equipment-row render mode. hasAnyLast = any visible
-- cell carries cached gear (cell.last); only consulted while the row
-- is gear-unreadable, so the scan stays off the common path.
local hasAnyLast = false
if row.gearUnreadable then
for i = 1, n do
local cvm = row.cells[slots[i].id]
if cvm and cvm.last then
hasAnyLast = true
break
end
end
end
local mode = MX.UnreadRowMode(row, hasAnyLast)
-- name in class color; unreadable rows dimmed + washed background
local cc = C["class" .. (p.class or "")] or C.ink
W.SetTextG(rf.name, p.name)
W.SetTextColorG(rf.name, cc)
W.SetAlphaG(rf.name, row.readable and 1 or 0.55)
if row.readable then
rf.bg:Hide()
else
rf.bg:Show()
end
-- status: subgroup + unreadable reason. In "dimmed" mode the
-- chip carries rowVM.unreadNote ("out of range") -- the spanning
-- message is replaced by dimmed tiles, so the reason moves up here;
-- in "message" mode the spanning line has it.
local st = ""
if p.subgroup then
st = "G" .. p.subgroup
end
local note = nil
if not row.readable then
note = row.unreadableReason or "not readable"
elseif mode == "dimmed" then
note = row.unreadNote or "out of range"
end
if note then
if st ~= "" then
st = st .. " (" .. note .. ")"
else
st = "(" .. note .. ")"
end
end
W.SetTextG(rf.status, st)
-- far tag: diagnostic only, never changes any state/sum/pill (item 5).
-- Suppressed in "message" mode -- the spanning line already carries
-- the distance, a second "NNyd" next to the name would double it.
if row.far and p.distance and mode ~= "message" then
W.SetTextG(rf.farFS, MX.FarTag(p.distance))
rf.farFS:Show()
else
rf.farFS:Hide()
end
-- whisper button (readable AND >=1 gap only; dim during cooldown)
if row.whisperEligible then
rf.wsp.dcRow = row
local canW = MX.CanWhisper(row, now)
W.SetAlphaG(rf.wsp, canW and 1 or 0.4)
rf.wsp:Show()
else
rf.wsp:Hide()
end
-- role badge (suggested: "?" suffix + dimmer border -- 1.12 stand-in
-- for a dashed border)
local badge = rf.badge
badge.dcRow = row
W.SetTextG(badge.text, row.suggested and (row.role .. "?") or row.role)
W.SetTextColorG(badge.text, C["role" .. row.role] or C.ink)
W.SetBackdropG(badge, C.panel,
row.suggested and LC.DIMBORDER[row.role]
or C["roleBorder" .. row.role] or C.line)
-- cells / row-spanning message: in "message" mode
-- (gear unreadable, no cached gear) the slot cells hide and ONE dim
-- pooled FontString spans the slot columns; "dimmed" and "plain" run
-- the normal cell loop -- cached cells dim inside PaintCell.
if mode == "message" then
for i = 1, rf.cellMax do
rf.cells[i]:Hide()
end
local msg = M.EnsureUnreadMsg(rf)
local x0 = (layout.cols[1] and layout.cols[1].x) or layout.sumX
if msg.dcX ~= x0 or msg.dcSumX ~= layout.sumX then
msg.dcX = x0
msg.dcSumX = layout.sumX
msg:ClearAllPoints()
msg:SetPoint("LEFT", rf, "LEFT", x0 + 2, 0)
msg:SetWidth(layout.sumX - x0 - MX.CELL_GAP - 2)
end
W.SetTextG(msg, MX.UnreadRowText(p.distance))
msg:Show()
else
if rf.unreadMsg then
rf.unreadMsg:Hide()
end
for i = 1, n do
local sd = slots[i]
local col = layout.cols[i]
local cell = M.EnsureCell(rf, i)
if cell.dcX ~= col.x or cell.dcW ~= col.w then
cell.dcX = col.x
cell.dcW = col.w
cell:ClearAllPoints()
cell:SetPoint("LEFT", rf, "LEFT", col.x, 0)
cell:SetWidth(col.w)
end
M.PaintCell(cell, sd, row.cells[sd.id], row)
cell:Show()
end
for i = n + 1, rf.cellMax do
rf.cells[i]:Hide()
end
end
-- sum
if rf.sum.dcX ~= layout.sumX then
rf.sum.dcX = layout.sumX
rf.sum:ClearAllPoints()
rf.sum:SetPoint("LEFT", rf, "LEFT", layout.sumX, 0)
end
rf.sum.dcRow = row
if MX.state.tab == "DEBUFFS"
and (row.debuffCount ~= nil or row.sumHas == nil) then
-- no x/y on this tab -- the column carries the
-- TOTAL distinct debuff count ("0" faint when clean, red when >0,
-- "--" unreadable). Red alone carries the emphasis (no 1.12 bold).
-- Degradation guard: a model (sumHas set, no
-- debuffCount) keeps the old x/y path below.
local dst = MX.DebuffSumText(row.debuffCount, row.readable)
W.SetTextG(rf.sum.text, dst.text)
W.SetTextColorG(rf.sum.text, C[dst.colorKey] or C.faint)
elseif MX.IsDisplayTab(MX.state.tab) then
-- no x/y on the display tabs either (row.sumHas is nil, see
-- core/model.lua) -- the existing "no sum" glyph, not a new one
W.SetTextG(rf.sum.text, "--")
W.SetTextColorG(rf.sum.text, C.faint)
elseif row.readable then
W.SetTextG(rf.sum.text, row.sumHas .. "/" .. row.sumExpected)
-- green only when verified complete; UNKNOWN cells tint the sum
-- grey so a "7/7" next to a red ready pill does not read as green
-- (UNKNOWN is excluded from both numbers)
local scol = C.hat
if row.gaps > 0 then
scol = C.fehlt
elseif row.unknowns and row.unknowns > 0 then
scol = C.unbek
end
W.SetTextColorG(rf.sum.text, scol)
else
W.SetTextG(rf.sum.text, "--")
W.SetTextColorG(rf.sum.text, C.faint)
end
end
-- ------------------------------------------------------------------
-- expected-for string for header tile tooltips: pure MX.ExpectedForString
-- (per-class shape aware; offline-tested)
-- ------------------------------------------------------------------
-- scan-age ticker (1 s throttle; runs only while the window is shown)
-- ------------------------------------------------------------------
function M.TickerOnUpdate()
local now = GetTime()
if now < (this.dcNext or 0) then
return
end
this.dcNext = now + 1
local st = DC.store
if st and st.scanAt then
W.SetTextG(UI.scanAgeFS, MX.FormatScanAge(now - st.scanAt))
else
W.SetTextG(UI.scanAgeFS, MX.FormatScanAge(nil))
end
end
-- ------------------------------------------------------------------
-- window construction (once, lazily)
-- ------------------------------------------------------------------
function M.TabClick()
MX.SetTab(this.dcTab)
end
function M.FilterClick()
MX.SetFilter(this.dcGapsOnly)
end
function M.BuildTitlebar(f)
UI.titleFS = W.MakeText(f, 13, C.goldHi)
UI.titleFS:SetPoint("TOPLEFT", f, "TOPLEFT", 14, -13)
UI.titleFS:SetText("DopingControl")
local pill = W.MakePill(f, "DopingControlCoveragePill")
pill:SetPoint("LEFT", UI.titleFS, "RIGHT", 12, 0)
pill:SetHeight(17)
W.SetBackdropG(pill, C.void, C.line)
W.SetTextColorG(pill.text, C.parch)
pill.bar = pill:CreateTexture("DopingControlCoveragePillBar", "ARTWORK")
pill.bar:SetTexture(0.55, 0.61, 0.69)
pill.bar:SetHeight(3)
pill.bar:SetPoint("BOTTOMLEFT", pill, "BOTTOMLEFT", 2, 2)
pill.bar:SetWidth(1)
W.AttachTooltip(pill, M.CoverageTooltip, "ANCHOR_LEFT")
UI.coveragePill = pill
UI.scanAgeFS = W.MakeText(f, 9, C.dim)
UI.scanAgeFS:SetPoint("LEFT", pill, "RIGHT", 12, 0)
UI.scanAgeFS:SetText("no scan yet")
local closeBtn = W.MakeButton(f, "DopingControlCloseButton", "X", 22, 19)
closeBtn:SetPoint("TOPRIGHT", f, "TOPRIGHT", -10, -10)
closeBtn:SetScript("OnClick", function()
UI.main:Hide()
end)
local optionsBtn = W.MakeButton(f, "DopingControlOptionsButton", "Options", 62, 19)
optionsBtn:SetPoint("RIGHT", closeBtn, "LEFT", -6, 0)
optionsBtn:SetScript("OnClick", function()
if MX.hooks.options then
MX.hooks.options()
elseif DC_Options and DC_Options.Toggle then
DC_Options.Toggle()
end
end)
local reportBtn = W.MakeButton(f, "DopingControlReportButton", "Report...", 66, 19)
reportBtn:SetPoint("RIGHT", optionsBtn, "LEFT", -6, 0)
reportBtn:SetScript("OnClick", function()
if MX.hooks.report then
MX.hooks.report()
elseif DC_Report and DC_Report.Toggle then
DC_Report.Toggle()
end
end)
local scanBtn = W.MakeButton(f, "DopingControlScanButton", "Scan", 50, 19)
scanBtn:SetPoint("RIGHT", reportBtn, "LEFT", -6, 0)
scanBtn:SetScript("OnClick", function()
if MX.hooks.scan then
MX.hooks.scan()
end
end)
end
function M.BuildTabs(f)
UI.tabButtons = {}
local tabY = -(10 + LC.TITLE_H)
local tabX = MX.PAD
for i = 1, table.getn(DC.TABS) do
local id = DC.TABS[i]
local b = CreateFrame("Button", "DopingControlTab" .. i, f)
b:SetHeight(20)
b:SetWidth(MX.TAB_W)
b:SetPoint("TOPLEFT", f, "TOPLEFT", tabX, tabY)
b:SetBackdrop(W.SOLID_BACKDROP)
b.text = W.MakeText(b, 10, C.dim)
b.text:SetPoint("CENTER", b, "CENTER", 0, 0)
b.text:SetText(DC.TAB_LABEL[id])
b.dcTab = id
b:SetScript("OnClick", M.TabClick)
if id == "CLASSBUFFS" then
W.AttachTooltip(b, function()
W.TipLine("Class buffs", W.WHITE)
W.TipLine("decision 12.4 still open - group assignment vs"
.. " self-responsibility", C.parchDim, 1)
end, "ANCHOR_LEFT")
end
UI.tabButtons[i] = b
tabX = tabX + MX.TAB_PITCH
end
end
function M.BuildToolbar(f)
UI.filterButtons = {}
local tbY = -(10 + LC.TITLE_H + LC.TABS_H)
local filterLabel = W.MakeText(f, 9, C.dim)
filterLabel:SetPoint("TOPLEFT", f, "TOPLEFT", MX.PAD, tbY - 5)
filterLabel:SetText("Filter:")
local fx = MX.PAD + 34
local defs = { { label = "All", gapsOnly = false, w = 36 },
{ label = "Gaps only", gapsOnly = true, w = 66 } }
for i = 1, 2 do
local b = CreateFrame("Button", "DopingControlFilter" .. i, f)
b:SetHeight(17)
b:SetWidth(defs[i].w)
b:SetPoint("TOPLEFT", f, "TOPLEFT", fx, tbY - 1)
b:SetBackdrop(W.SOLID_BACKDROP)
b.text = W.MakeText(b, 9, C.dim)
b.text:SetPoint("CENTER", b, "CENTER", 0, 0)
b.text:SetText(defs[i].label)
b.dcGapsOnly = defs[i].gapsOnly
b:SetScript("OnClick", M.FilterClick)
UI.filterButtons[i] = b
fx = fx + defs[i].w + 2
end
local hintFS = W.MakeText(f, 8, C.faint)
hintFS:SetPoint("TOPLEFT", f, "TOPLEFT", fx + 12, tbY - 6)
hintFS:SetText("Grouped by role \194\183 gaps first \194\183 click a group"
.. " header to collapse")
-- legend (right side), built right-to-left so it follows frame width
local legend = { { "not expected", C.skip, C.skipBorder },
{ "not readable", C.unbekBg, C.unbekBorder },
{ "missing", C.fehltBg, C.fehltBorder },
{ "active", C.hatBg, C.hatBorder } }
local anchor = nil
for i = 1, 4 do
local lfs = W.MakeText(f, 8, C.dim)
if anchor then
lfs:SetPoint("RIGHT", anchor, "LEFT", -8, 0)
else
lfs:SetPoint("TOPRIGHT", f, "TOPRIGHT", -MX.PAD, tbY - 6)
end
lfs:SetText(legend[i][1])
local sw = CreateFrame("Frame", "DopingControlLegendSwatch" .. i, f)
sw:SetWidth(10)
sw:SetHeight(10)
sw:SetPoint("RIGHT", lfs, "LEFT", -3, 0)
sw:SetBackdrop(W.SOLID_BACKDROP)
W.SetBackdropG(sw, legend[i][2], legend[i][3])
anchor = sw
end
UI.hiddenFS = W.MakeText(f, 8, C.goldDim)
UI.hiddenFS:SetPoint("RIGHT", anchor, "LEFT", -16, 0)
UI.hiddenFS:SetText("")
end
function M.BuildBody(f)
-- TEST MODE banner
local banner = CreateFrame("Frame", "DopingControlBanner", f)
banner:SetHeight(LC.BANNER_H - 2)
banner:SetBackdrop(W.SOLID_BACKDROP)
W.SetBackdropG(banner, C.bannerYellowBg, C.bannerYellowBorder)
banner.text = W.MakeText(banner, 9, C.bannerYellowText)
banner.text:SetPoint("CENTER", banner, "CENTER", 0, 0)
banner.text:SetText("TEST MODE - simulated raid \194\183 chat output disabled")
banner:Hide()
UI.banner = banner
-- header row (OUTSIDE the scroll frame -- 1.12 has no sticky)
local hr = CreateFrame("Frame", "DopingControlHeaderRow", f)
hr:SetHeight(LC.HEADER_H)
hr:SetBackdrop(W.SOLID_BACKDROP)
W.SetBackdropG(hr, C.theadBg, C.goldSoft)
UI.headerRow = hr
UI.headPlayerFS = W.MakeText(hr, 8, C.faint)
-- bar-relative x: PAD - BAR_X_OFF + 2 = 8px inset inside the bar
-- (>=6px) and frame-x PAD+2 = aligned with row names
UI.headPlayerFS:SetPoint("LEFT", hr, "LEFT", MX.PAD - LC.BAR_X_OFF + 2, 0)
UI.headPlayerFS:SetText("PLAYER")
UI.headRoleFS = W.MakeText(hr, 8, C.faint)
UI.headRoleFS:SetText("ROLE")
UI.headSumFS = W.MakeText(hr, 8, C.faint)
UI.headSumFS:SetText("SUM")
-- scroll frame
UI.scrollFrame = CreateFrame("ScrollFrame", "DopingControlScrollFrame", f,
"UIPanelScrollFrameTemplate")
UI.scrollChild = CreateFrame("Frame", "DopingControlScrollChild", UI.scrollFrame)
UI.scrollChild:SetWidth(600)
UI.scrollChild:SetHeight(100)
UI.scrollFrame:SetScrollChild(UI.scrollChild)
-- empty state (no scan yet)
UI.emptyFS = W.MakeText(f, 11, C.parch)
UI.emptyFS:SetPoint("CENTER", UI.scrollFrame, "CENTER", 0, 14)
UI.emptyFS:SetText("No raid, no party - nothing to check")
UI.emptyFS:Hide()
UI.emptyHintFS = W.MakeText(f, 9, C.faint)
UI.emptyHintFS:SetPoint("TOP", UI.emptyFS, "BOTTOM", 0, -6)
UI.emptyHintFS:SetText("/dc test shows a simulated raid \194\183"
.. " /dc probe writes the M0 probe")
UI.emptyHintFS:Hide()
-- footer row (sticky "missing per slot", OUTSIDE the scroll frame)
local fr = CreateFrame("Frame", "DopingControlFooterRow", f)
fr:SetHeight(LC.FOOTER_H)
fr:SetBackdrop(W.SOLID_BACKDROP)
W.SetBackdropG(fr, C.theadBg, C.goldSoft)
UI.footerRow = fr
UI.footLabelFS = W.MakeText(fr, 8, C.faint)
UI.footLabelFS:SetText("MISSING PER SLOT")
-- frame foot hints
local foot = W.MakeText(f, 8, C.faint)
foot:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", MX.PAD, 16)
foot:SetText("PST whispers that player's missing items of the current tab"
.. " - on click only \194\183 Expectations per role: Options -"
.. " expectation matrix")
UI.footFS = foot
end
function M.BuildFrame()
if UI.main then
return UI.main
end
local f = CreateFrame("Frame", "DopingControlFrame", UIParent)
UI.main = f
f:SetWidth(MX.MIN_FRAME_W)
f:SetHeight(300)
f:SetBackdrop(W.PANEL_BACKDROP)
f:SetBackdropColor(1, 1, 1, 0.97)
f:SetToplevel(true)
f:SetMovable(true)
f:EnableMouse(true)
f:SetClampedToScreen(true)
f:RegisterForDrag("LeftButton")
f:SetScript("OnDragStart", function()
this:StartMoving()
end)
f:SetScript("OnDragStop", function()
this:StopMovingOrSizing()
local d = M.db()
if d then
d.framePos = { point = "TOPLEFT", relPoint = "BOTTOMLEFT",
x = this:GetLeft(), y = this:GetTop() }
end
end)
local d = M.db()
local pos = d and d.framePos
if pos and pos.x and pos.y then
f:SetPoint(pos.point or "TOPLEFT", UIParent, pos.relPoint or "BOTTOMLEFT",
pos.x, pos.y)
else
f:SetPoint("CENTER", UIParent, "CENTER", 0, 40)
end
f:SetScript("OnUpdate", M.TickerOnUpdate)
table.insert(UISpecialFrames, "DopingControlFrame")
M.BuildTitlebar(f)
M.BuildTabs(f)
M.BuildToolbar(f)
M.BuildBody(f)
f:Hide()
return f
end
-- ------------------------------------------------------------------
-- render (rebuilds nothing, repaints pooled frames; hides surplus)
-- ------------------------------------------------------------------
function M.RenderChrome(vm, layout, frameW, hiddenCount)
-- tabs + filter styling
for i = 1, table.getn(DC.TABS) do
local b = UI.tabButtons[i]
local active = (DC.TABS[i] == MX.state.tab)
W.SetTextColorG(b.text, active and C.goldHi or C.dim)
W.SetBackdropG(b, active and C.panel or C.panel2,
active and C.goldSoft or C.line)
end
for i = 1, 2 do
local b = UI.filterButtons[i]
local active = (b.dcGapsOnly == MX.state.gapsOnly)
W.SetTextColorG(b.text, active and C.btnGreenText or C.dim)
W.SetBackdropG(b, active and C.btnGreenBg or C.panel2,
active and C.btnGreenBorder or C.line)
end
if hiddenCount > 0 then
W.SetTextG(UI.hiddenFS, hiddenCount .. " slots hidden")
else
W.SetTextG(UI.hiddenFS, "")
end
-- coverage pill + bar
local cov = vm.coverage
local pill = UI.coveragePill
-- the tab's OWN read channel, named: gear/hit/resistances are not the
-- aura channel, and one shared "readable" number claimed full
-- coverage on tabs that knew far less (see DC_Model.ChannelRead)
local covN = cov.channelReadable or cov.readable
local covLabel = "readable"
if DC_Model and DC_Model.CoverageLabel then
covLabel = DC_Model.CoverageLabel(vm.tab)
end
W.SetTextG(pill.text, covN .. "/" .. cov.total .. " " .. covLabel)
pill:SetWidth(pill.text:GetStringWidth() + 18)
local frac = 0
if cov.total > 0 then
frac = covN / cov.total
end
local barW = math.floor((pill.text:GetStringWidth() + 14) * frac)
if barW < 1 then
barW = 1
end
if pill.dcBarW ~= barW then
pill.dcBarW = barW
pill.bar:SetWidth(barW)
end
-- unassigned chip: only while suggestions exist (n > 0), else hidden
local ua = vm.unassigned
if MX.UnassignedVisible(ua) then
local upill = M.EnsureUnassignedPill()
if upill.dcCount ~= ua.count then
upill.dcCount = ua.count
W.SetTextG(upill.text, ua.count .. " unassigned")
upill:SetWidth(upill.text:GetStringWidth() + 18)
end
upill:Show()
elseif UI.unassignedPill then
UI.unassignedPill:Hide()
end
-- scan-age text yields its anchor to the chip while it is shown
local ageAnchor = UI.coveragePill
if MX.UnassignedVisible(ua) then
ageAnchor = UI.unassignedPill
end
if UI.scanAgeFS.dcAnchor ~= ageAnchor then
UI.scanAgeFS.dcAnchor = ageAnchor
UI.scanAgeFS:ClearAllPoints()
UI.scanAgeFS:SetPoint("LEFT", ageAnchor, "RIGHT", 12, 0)
end
end
function M.RenderHeader(layout, slots, yTop, frameW)
local hr = UI.headerRow
hr:ClearAllPoints()
hr:SetPoint("TOPLEFT", UI.main, "TOPLEFT", LC.BAR_X_OFF, yTop)
hr:SetWidth(frameW - LC.BAR_X_OFF * 2)
local d = M.db()
local expectTbl = (d and d.expectations) or DC.DEFAULT_EXPECT or {}
local nSlots = table.getn(slots)
for i = 1, nSlots do
local sd = slots[i]
local col = layout.cols[i]
local tile = M.EnsureHeaderTile(i)
if tile.dcX ~= col.x or tile.dcW ~= col.w then
tile.dcX = col.x
tile.dcW = col.w
tile:ClearAllPoints()
tile:SetPoint("TOPLEFT", hr, "TOPLEFT", col.x - LC.BAR_X_OFF, -2)
tile:SetWidth(col.w)
end
tile.dcSD = sd
if sd.kind == "debuff" or sd.kind == "resist"
or sd.kind == "hit" then
-- expectations are not consulted on DEBUFFS/RESIST (hit
-- columns included)
tile.dcExpFor = nil
else
tile.dcExpFor = MX.ExpectedForString(sd.id, expectTbl)
end
-- DEBUFFS header tiles: the debuff icon (MX.HEADER_ICON px,
-- standard crop) replaces the label text when the model knows a
-- texture; the
-- fallback for icon-less debuff columns is a monogram of the
-- name (slot.label is nil on debuff columns by design).
if sd.icon then
local it = M.EnsureHeaderIcon(tile, i)
W.SetTextureG(it, sd.icon)
it:Show()
tile.label:Hide()
tile.sub:Hide()
else
if tile.iconTex then
tile.iconTex:Hide()
end
local lbl = sd.label
if not lbl and sd.kind == "debuff" then
lbl = MX.Monogram(sd.full)
end
W.SetTextG(tile.label, lbl or "")
W.SetTextG(tile.sub, sd.sub or "")
tile.label:Show()
tile.sub:Show()
end
tile:Show()
end
for i = nSlots + 1, UI.headerMax do
UI.headerTiles[i]:Hide()
end
if UI.headRoleFS.dcX ~= layout.roleX then
UI.headRoleFS.dcX = layout.roleX
UI.headRoleFS:ClearAllPoints()
UI.headRoleFS:SetPoint("LEFT", hr, "LEFT",
layout.roleX - LC.BAR_X_OFF + 12, 0)
end
if UI.headSumFS.dcX ~= layout.sumX then
UI.headSumFS.dcX = layout.sumX
UI.headSumFS:ClearAllPoints()
UI.headSumFS:SetPoint("LEFT", hr, "LEFT",
layout.sumX - LC.BAR_X_OFF + 6, 0)
end
end
-- ------------------------------------------------------------------
-- Scroll-range resync -- a 1.12 gotcha that shows up as "the list will
-- not scroll all the way down once every group is expanded".
--
-- Growing the scroll CHILD is not enough. The template's scrollbar keeps
-- whatever min/max it was last handed, so expanding a group while the bar
-- already sits at the OLD bottom leaves the new rows unreachable: the
-- thumb simply refuses to travel further. The arithmetic above is fine
-- (content height counts every group head and every visible row) -- what
-- was missing is telling the BAR about it.
--
-- UpdateScrollChildRect makes the frame recompute its range now rather
-- than on some later engine tick; SetMinMaxValues then does exactly what
-- Blizzard's own ScrollFrame_OnScrollRangeChanged does
-- (FrameXML/UIPanelTemplates.lua) -- we simply stop depending on WHETHER
-- that handler fires. Both are existence-guarded: this must never be the
-- thing that breaks the window.
-- ------------------------------------------------------------------
function M.SyncScrollRange()
local sf = UI.scrollFrame
if not sf or not sf.GetName then
return
end
if sf.UpdateScrollChildRect then
sf:UpdateScrollChildRect()
end
local name = sf:GetName()
local bar = name and getglobal(name .. "ScrollBar")
if not bar or not sf.GetVerticalScrollRange then
return
end
local range = sf:GetVerticalScrollRange() or 0
if range < 0 then
range = 0
end
bar:SetMinMaxValues(0, range)
-- clamp the thumb: after a COLLAPSE the old value can sit past the new
-- bottom, which would leave the view parked in empty space
local v = bar:GetValue() or 0
if v > range then
bar:SetValue(range)
end
end
function M.RenderRows(vm, layout, slots)
local d = M.db()
local collapsed = (d and d.collapse) or {}
local gapsOnly = MX.state.gapsOnly
local now = GetTime()
local y = 0
local rowIdx = 0
local nGroups = table.getn(vm.groups)
for gi = 1, nGroups do
local g = vm.groups[gi]
local isCollapsed = collapsed[g.role] and true or false
local gh = M.EnsureGroupHead(gi)
gh:ClearAllPoints()
gh:SetPoint("TOPLEFT", UI.scrollChild, "TOPLEFT", MX.PAD, y)
gh:SetWidth(layout.totalW - MX.PAD * 2)
M.PaintGroupHead(gh, g, isCollapsed)
gh:Show()
y = y - MX.GROUP_H
if not isCollapsed then
local nRows = table.getn(g.rows)
for ri = 1, nRows do
local row = g.rows[ri]
if MX.RowVisible(row, gapsOnly) then
rowIdx = rowIdx + 1
local rf = M.EnsureRow(rowIdx)
rf:ClearAllPoints()
rf:SetPoint("TOPLEFT", UI.scrollChild, "TOPLEFT", 0, y)
rf:SetWidth(layout.totalW)
M.PaintRow(rf, row, layout, slots, now)
-- zebra by VISIBLE row index, not by the roster
-- position: with "Gaps only" on or a group collapsed
-- the stripes must still alternate on screen
if math.mod(rowIdx, 2) == 0 then
rf.zebra:Show()
else
rf.zebra:Hide()
end
rf:Show()
y = y - MX.ROW_H
end
end
end
end
for i = rowIdx + 1, UI.rowMax do
UI.rowPool[i]:Hide()
end
return -y -- content height
end
function M.RenderFooter(vm, layout, slots, footerY, frameW)
local fr = UI.footerRow
fr:ClearAllPoints()
fr:SetPoint("TOPLEFT", UI.main, "TOPLEFT", LC.BAR_X_OFF, footerY)
fr:SetWidth(frameW - LC.BAR_X_OFF * 2)
-- label per tab -- afflicted counts on DEBUFFS,
-- missing counts everywhere else (numbers stay the model's footer)
W.SetTextG(UI.footLabelFS, MX.FooterLabel(MX.state.tab))
if UI.footLabelFS.dcX ~= layout.nameW then
UI.footLabelFS.dcX = layout.nameW
UI.footLabelFS:ClearAllPoints()
UI.footLabelFS:SetPoint("RIGHT", fr, "LEFT",
layout.roleX + MX.ROLE_COL_W - LC.BAR_X_OFF, 0)
end
local nSlots = table.getn(slots)
for i = 1, nSlots do
local col = layout.cols[i]
local fs = M.EnsureFooterCount(i)
local cx = col.x - LC.BAR_X_OFF + col.w / 2
if fs.dcX ~= cx then
fs.dcX = cx
fs:ClearAllPoints()
fs:SetPoint("CENTER", fr, "LEFT", cx, 0)
end
if MX.IsDisplayTab(MX.state.tab) then
-- average, not a count -- parch, never red (display only).
-- Hit columns average with one decimal, like their cells.
local avgTbl = vm.colAvg or vm.resistAvg
local avg = (avgTbl and avgTbl[col.id]) or 0
local sd = slots[i]
if sd and sd.kind == "hit" then
W.SetTextG(fs, MX.HitText(avg))
else
W.SetTextG(fs, tostring(avg))
end
W.SetTextColorG(fs, C.parch)
else
local entry = vm.footer[col.id]
local count = entry and entry.count or 0
W.SetTextG(fs, tostring(count))
W.SetTextColorG(fs, count > 0 and C.fehlt or C.faint)
end
fs:Show()
end
for i = nSlots + 1, UI.footerMax do
UI.footerFS[i]:Hide()
end
end
function M.Render()
local st = M.store()
local vm = MX.vm
-- columns: cap at MAX_COLS, layout from the VISIBLE set
local slots, hiddenCount = MX.VisibleSlots(vm.slots)
local layout = MX.ColumnLayout(slots, MX.NAME_COL_MIN)
MX.layout = layout
local frameW = MX.FrameWidth(layout.totalW)
UI.main:SetWidth(frameW)
M.RenderChrome(vm, layout, frameW, hiddenCount)
-- banner (source read from the store -- the one permitted raw read)
local yTop = LC.CONTENT_TOP
if st.source == "sim" then
UI.banner:ClearAllPoints()
UI.banner:SetPoint("TOPLEFT", UI.main, "TOPLEFT", LC.BAR_X_OFF, yTop)
UI.banner:SetWidth(frameW - LC.BAR_X_OFF * 2)
UI.banner:Show()
yTop = yTop - LC.BANNER_H
else
UI.banner:Hide()
end
M.RenderHeader(layout, slots, yTop, frameW)
local contentH = M.RenderRows(vm, layout, slots) + 4
if contentH < LC.MIN_SCROLL_H then
contentH = LC.MIN_SCROLL_H
end
local scrollH = contentH
if scrollH > LC.MAX_SCROLL_H then
scrollH = LC.MAX_SCROLL_H
end
UI.scrollChild:SetWidth(layout.totalW)
UI.scrollChild:SetHeight(contentH)
local scrollTop = yTop - LC.HEADER_H - 1
UI.scrollFrame:ClearAllPoints()
UI.scrollFrame:SetPoint("TOPLEFT", UI.main, "TOPLEFT", 0, scrollTop)
UI.scrollFrame:SetWidth(frameW - MX.SCROLLBAR_W - 4)
UI.scrollFrame:SetHeight(scrollH)
M.SyncScrollRange()
-- empty state
if vm.coverage.total == 0 then
UI.emptyFS:Show()
UI.emptyHintFS:Show()
else
UI.emptyFS:Hide()
UI.emptyHintFS:Hide()
end
-- footer (counts over ALL readable players -- independent of filter
-- and collapse; same numbers as /dc report)
local footerY = scrollTop - scrollH - 1
M.RenderFooter(vm, layout, slots, footerY, frameW)
-- total frame height
UI.main:SetHeight(-(footerY) + LC.FOOTER_H + LC.FOOT_H + 20)
end
-- ------------------------------------------------------------------
-- public API (Toggle / Show / Refresh / SetTab + hooks)
-- ------------------------------------------------------------------
-- Rebuild the vm via DC_Model and re-render. Rendering reads ONLY the vm
-- (plus store.source/scanAt for banner + ticker).
function MX.Refresh()
if not UI.main or not UI.main:IsShown() then
return
end
MX.SweepWhispers(GetTime()) -- evict stale whisper-cooldown entries
local st = M.store()
local d = M.db()
local expectTbl = (d and d.expectations) or DC.DEFAULT_EXPECT or {}
-- pass GetTime() as the model's `now` (optional 5th
-- build param) so UNKNOWN cells with cached data get
-- cell.last.age, and db.skillBooks as the 6th so the hit columns
-- resolve the weapon-skill assumption with the user's current book
-- ticks (a tick therefore lands on THIS refresh, not on the next
-- scan). Extra args are ignored by a model without them.
MX.vm = DC_Model.build(st, function(p)
return DC_Roles.resolve(d, st.source, p)
end, expectTbl, MX.state.tab, GetTime(), d and d.skillBooks)
M.Render()
end
function MX.Show()
M.BuildFrame()
UI.main:Show()
MX.Refresh()
local db = DopingControlDB
local st = DC.store
if MX.WantScanOnShow(db and db.testMode, st and st.scanAt, GetTime())
and DC_Scan and DC_Scan.Start then
DC_Scan.Start()
end
end
function MX.Toggle()
if UI.main and UI.main:IsShown() then
UI.main:Hide()
else
MX.Show()
end
end
function MX.Hide()
if UI.main then
UI.main:Hide()
end
end
function MX.IsShown()
return UI.main ~= nil and UI.main:IsShown()
end
end -- if CreateFrame