-- 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): " (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 -- (DopingControlCellIcon) 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: "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: " -- 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 " 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 = , 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 -- " - 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 == " -- 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: "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