Files
2026-08-30 15:37:01 +02:00

2684 lines
122 KiB
Lua

-- TotemBar - ui.lua
-- The totem bar frame: 4 element buttons plus a Totemic Recall button.
-- Left-click an element button to cast its chosen totem; right-click
-- clears the slot. Hovering an element button pops an upward flyout of
-- the element's other known totems: left-click one to cast it once,
-- right-click one to set it as the slot's new default. Each element
-- button also carries an OmniCC-style remaining-duration timer text (hybrid
-- source: pfUI libtotem's GetTotemInfo when present, else TotemBar's
-- own cast-tracking - see core/cast.lua). No per-button text labels;
-- hover the button for a tooltip naming the element/totem. Each element
-- button also carries a native action-button-style radial cooldown
-- swipe (CooldownFrameTemplate) reflecting the chosen totem's spellbook
-- cooldown, IN ADDITION to the duration timer text. The Recall button's
-- icon pulses whenever any element totem is currently out of range.
-- WoW-API-only file; not offline-tested, only syntax-checked (see
-- tools/luatests notes in the repo).
TotemBar = TotemBar or {}
-- Defensive fallback in case DEFAULT_CHAT_FRAME is ever unset this early
-- in the loading sequence (guards against a later/leaner client build).
local ChatOut = DEFAULT_CHAT_FRAME or ChatFrame1
local BUTTON_SIZE = 36
-- 10 (was 4): the 48px round ring frames overhang their 36px buttons by
-- 6px per side; adjacent opaque ring bands (screen radius ~22.9px) need
-- button centers >= ~45.8px apart to not overlap. 36+10 = 46 clears it,
-- for the bar row and the flyout column alike. Now a live-adjustable
-- UPVALUE (not a constant): the options panel's "Button spacing" slider
-- (range 10-30px, TotemBarDB.buttonGap) reassigns this in place via
-- TotemBar.SetButtonGap below - every closure that reads BUTTON_GAP
-- (element/flyout/assign-panel positioning, ApplyBarLayout) sees the new
-- value on its next read. 10px is still the floor: below that, adjacent
-- ring bands start to overlap per the geometry above.
local BUTTON_GAP = TotemBar.DEFAULT_BUTTON_GAP or 10
-- Bar-layout grid (feature: selectable bar arrangement, TotemBarDB.barLayout,
-- see ApplyBarLayout below). Maps each saved layout string to its column
-- count; row count then falls out of math.ceil(buttonCount / cols). Cycle
-- order for the options panel's cycle button lives in TotemBar.BAR_LAYOUTS.
local BAR_LAYOUT_COLS = { ["1x6"] = 6, ["2x3"] = 3, ["3x2"] = 2 }
TotemBar.BAR_LAYOUTS = { "1x6", "2x3", "3x2" }
-- Rows use the SAME pitch as columns (user request: tight grid). The timer
-- text below a row slightly overlaps the next row's ring overhang; it draws
-- on the OVERLAY layer above it and stays readable.
-- Timer-text OnUpdate throttle: refresh at most this often (seconds),
-- not every frame, to keep the shared/guild-addon perf budget sane.
-- 0.1s (was 0.2): the same tick also refreshes the ripple wave INPUTS (the
-- wave animation itself runs every frame in UpdateWaves, driven off these).
-- Still allocation-free per tick.
local TIMER_UPDATE_INTERVAL = 0.1
local timerElapsed = 0
-- Destroyed-totem liveness poll throttle (see TotemBar.GUID_LIVENESS_POLL_INTERVAL,
-- core/cast.lua): a SLOWER gate layered on top of the 0.1s tick above, so
-- UnitExists/UnitHealth/UnitIsDeadOrGhost are only actually called once
-- every ~0.5s even though UpdateTimerDisplays itself runs 10x/sec. GetTime()-based
-- rather than an arg1 accumulator since UpdateTimerDisplays has no frame
-- delta of its own to accumulate.
local lastGuidLivenessCheck = nil
-- Fallback icon for an unresolved/unknown totem name (flyout icons,
-- ResolveTotemIcon, the pending-assignment panel). Element buttons' own
-- empty-slot state uses the custom sheet's per-element glyph instead
-- (see SetElementIcon/ELEMENT_EMPTY_CELL above), not this constant.
local EMPTY_ICON = "Interface\\Icons\\INV_Misc_QuestionMark"
-- Totemic Recall: exact icon is verified at button-creation time via a
-- spellbook scan (GetSpellTexture); this is only the last-resort
-- fallback if that scan fails to resolve a texture.
local RECALL_SPELL_NAME = "Totemic Recall"
local RECALL_ICON_FALLBACK = "Interface\\Icons\\Spell_Nature_AstralRecal"
-- Pulse UI - floating round icons + ripple pulse.
-- Ring flipbook: 8x8 grid texture (64 cells). Cells 1..62 = duration-arc
-- fill frames (0=empty .. 61=full), cell 63 = wave ring (ripple), cell 64 =
-- the decorative frame band - always shown, hides the icon's square
-- corners so it reads as a floating round icon. Texture generated by
-- tools/gen_ring_textures.js; path WITHOUT extension (client resolves
-- .blp/.tga).
local RING_TEXTURE = "Interface\\AddOns\\TotemBar\\textures\\ring_round"
local RING_FRAMES = 62
local RING_GRID_COLS = 8
-- Cell 64 = the permanent decorative frame band (now baked-in shadow look,
-- see the art contract in the Rev 6 texture generator) - always shown,
-- hides the icon's square corners so it reads as a floating round icon.
-- (Rev 5's user-selectable ring-skin picker, which swapped this cell for a
-- separate ring_tracks.tga, was reverted per user feedback - see
-- ApplyRoundFrame below.)
local RING_WAVE_CELL = 63
local RING_TRACK_CELL = 64
-- Built once at load (core/pulse.lua loads before ui.lua, see .toc); the
-- per-tick path only indexes this table.
local allRingCoords = TotemBar.buildRingTexCoords(64, RING_GRID_COLS)
-- Screen px the ring/wave textures are drawn at, centered on the 36px
-- button - the ring extends past the button's own edges so the decorative
-- frame band's opaque interior fully covers the icon's square corners (see
-- the texture generator's geometry rationale comment).
local RING_TEX_SIZE = 48
local WAVE_DURATION = 0.8 -- ripple lifetime, seconds
local WAVE_MAX_SCALE = 1.9 -- wave grows from RING_TEX_SIZE to *WAVE_MAX_SCALE
local WAVE_BASE_ALPHA = 0.85 -- wave alpha at spawn, fades to 0 by WAVE_DURATION
-- Circular pulse countdown (Rev 6): the linear pulse bar (Rev 1) was
-- replaced with a thin white inner arc drawn ON the band itself, since a
-- round readout fits the round slots better than a straight bar (user
-- feedback). Same flipbook mechanics as the duration ring (ringFill) above,
-- just a separate all-white texture so it reads distinctly from the
-- element-colored duration arc. Independently toggleable
-- (TotemBarDB.showPulseBars, key name kept from the old linear bar - no
-- migration needed) from the ripple wave (TotemBarDB.showPulseWaves).
local PULSEARC_TEXTURE = "Interface\\AddOns\\TotemBar\\textures\\ring_pulsearc"
-- Pulse UI Rev 3: anticipation glow + spawn ripple + round hover/pushed
-- click states, all drawn from one FX sheet (tools/gen_ring_textures.js):
-- 2x2 grid of 128px cells. Cell 1 = annular anticipation halo, cell 2 =
-- round hover ring, cell 3 = pushed press disc, cell 4 = spawn flash
-- (reserved). Path WITHOUT extension, same convention as RING_TEXTURE.
local FX_TEXTURE = "Interface\\AddOns\\TotemBar\\textures\\ring_fx"
local fxCoords = TotemBar.buildRingTexCoords(4, 2)
local FX_GLOW_CELL = 1 -- annular anticipation halo (drawn at 64px)
local FX_HOVER_CELL = 2 -- round hover ring (drawn at RING_TEX_SIZE)
local FX_PUSHED_CELL = 3 -- press disc (drawn at RING_TEX_SIZE)
local FX_FLASH_CELL = 4 -- reserved (spawn flash disc)
local GLOW_TEX_SIZE = 64
local GLOW_RAMP_START = 0.78 -- glow builds over the last 22% before a pulse
local GLOW_MAX_ALPHA = 0.7
local SPAWN_WAVE_DURATION = 0.6
-- Custom icon sheet (tools/gen_icons.js): 4x4 grid of 128px cells.
local ICONS_TEXTURE = "Interface\\AddOns\\TotemBar\\textures\\icons"
local iconCoords = TotemBar.buildRingTexCoords(16, 4)
local ELEMENT_EMPTY_CELL = { Fire = 1, Earth = 2, Water = 3, Air = 4 }
-- cell 5 reserved (custom recall icon, reverted per user feedback)
local ICON_DROPSET_CELL = 6
local ICON_CROP = 0.08 -- standard action-button crop for real spell icons
-- UI chrome sheet + panel skin (tools/gen_ui_textures.js).
local UI_TEXTURE = "Interface\\AddOns\\TotemBar\\textures\\ui"
local uiCoords = TotemBar.buildRingTexCoords(4, 2)
local UI_SHADOW_CELL = 1
local UI_EMBLEM_CELL = 2
local UI_DIVIDER_CELL = 3
local PANEL_BACKDROP = {
bgFile = "Interface\\AddOns\\TotemBar\\textures\\panel_bg",
edgeFile = "Interface\\AddOns\\TotemBar\\textures\\panel_border",
tile = true, tileSize = 128, edgeSize = 16,
insets = { left = 5, right = 5, top = 5, bottom = 5 },
}
TotemBar.PANEL_BACKDROP = PANEL_BACKDROP -- shared with options.lua / bind.lua
TotemBar.UI_TEXTURE = UI_TEXTURE
TotemBar.uiCoords = uiCoords
TotemBar.UI_EMBLEM_CELL = UI_EMBLEM_CELL
TotemBar.UI_DIVIDER_CELL = UI_DIVIDER_CELL
local ELEMENT_COLORS = {
Fire = { 1.00, 0.45, 0.10 },
Earth = { 0.40, 0.85, 0.30 },
Water = { 0.25, 0.60, 1.00 },
Air = { 0.70, 0.55, 1.00 },
}
local elementButtons = {} -- element -> button frame
local recallButton = nil -- Totemic Recall button frame (for the auto-recall indicator refresh)
local dropSetButton = nil -- DropSet button frame (for RefreshPulseUI's ring-skin re-apply)
-- True when ANY element's totem is currently out-of-range (the
-- buff-presence red tint, see UpdateTimerDisplays). Recomputed once per
-- UpdateTimerDisplays pass; drives the Recall button's icon pulse
-- (OnRecallUpdate below) as a "go recall + redeploy" visual prompt.
local anyOutOfRange = false
-- Hover flyout: hovering an element button pops a column of icon
-- buttons UPWARD for the OTHER known totems of that element (all known
-- for the element minus the currently-chosen default). Left-click one to
-- cast it ONCE without changing the slot's default; right-click one to
-- make it the slot's new default. Shares one frame + a pool of icon
-- buttons.
-- Computed from the static totem map, not hard-coded: Air has SEVEN totems,
-- and a hard-coded 6 silently dropped the last one (Tranquil Air Totem)
-- whenever the Air slot was empty -- the flyout then lists ALL known totems of
-- the element, not "all minus the chosen default".
local MAX_FLYOUT_ICONS = TotemBar.maxTotemsPerElement() -- most totems any single element has
local FLYOUT_PAD = 4 -- inner padding inside the flyout frame
local FLYOUT_GAP = 2 -- gap between element button top and flyout bottom
local FLYOUT_HIDE_INTERVAL = 0.1 -- throttle for the mouse-leave hide check
local flyoutIcons = {} -- pooled flyout icon buttons (created once)
local flyoutFrame = nil -- lazily created shared flyout frame
local flyoutElement = nil -- element currently shown in the flyout
local flyoutOwnerButton = nil -- element button the flyout is anchored to
local flyoutElapsed = 0 -- throttle accumulator for the hide check
-- Forward declarations so functions defined later can be referenced by
-- closures created earlier in the file (standard Lua forward-decl idiom:
-- `local foo` then later `foo = function() ... end` / `function foo()`).
local RefreshButton
local RefreshCooldown
local EnsureFlyoutFrame
local ShowFlyout
local RefreshFlyoutCooldowns
local RefreshFlyoutMana
local HideFlyout
local OnFlyoutUpdate
local CreateElementButton
local CreateRecallButton
local CreateDropSetButton
local RefreshRecallIndicator
local OnRecallUpdate
local UpdateTimerDisplays
local UpdateWaves
local OnBarUpdate
local OnDragStart
local OnDragStop
local EnsureAssignFrame
local assignFrame -- lazily created pending-suggestion panel
-- Finds the spellbook index of a known spell by exact name, or nil.
-- Cache-backed accessor from core/spellindex.lua (loaded earlier in the
-- TOC) -- used to be its own fresh linear GetSpellName() scan here.
local FindSpellIndexByName = TotemBar.findSpellIndex
-- Fills a tooltip with a totem spell, resolved to the HIGHEST known rank.
-- A first-name-match slot (FindSpellIndexByName) is RANK 1, so SetSpell on
-- it showed rank-1 mana/damage/duration - misleading, since the name-based
-- CAST always uses the highest rank. Also appends the rank to the name
-- line, which 1.12's SetSpell omits entirely.
local function SetSpellTooltip(tip, name)
local idx = (TotemBar.findHighestRankSlot and TotemBar.findHighestRankSlot(name))
or FindSpellIndexByName(name)
if not idx then
tip:SetText(name)
return
end
tip:SetSpell(idx, BOOKTYPE_SPELL)
local _, rank = GetSpellName(idx, BOOKTYPE_SPELL)
if rank and rank ~= "" then
tip:AppendText(" |cffa8a8a8(" .. rank .. ")|r")
end
end
-- Resolves the icon texture path for whatever totem is currently chosen
-- for `element`, or nil when the slot is empty/unresolved. Callers route
-- the result through SetElementIcon, which draws the sheet's per-element
-- glyph for nil instead of a spellbook texture.
local function GetElementIcon(element)
local db = TotemBarDB
local name = db and db.chosen and db.chosen[element]
if not name then
return nil
end
local idx = FindSpellIndexByName(name)
if not idx then
return nil
end
return GetSpellTexture(idx, BOOKTYPE_SPELL)
end
-- Resolves (iconTexture, known) for an arbitrary totem name: the spellbook
-- texture when the player knows it, else the empty/question-mark icon and
-- known=false (the pending panel greys unknown totems).
local function ResolveTotemIcon(name)
if not name then
return EMPTY_ICON, false
end
local idx = FindSpellIndexByName(name)
if not idx then
return EMPTY_ICON, false
end
local texture = GetSpellTexture(idx, BOOKTYPE_SPELL)
return texture or EMPTY_ICON, true
end
-- Resolves the Totemic Recall icon by scanning the spellbook for the
-- real spell (so it matches whatever texture TWoW actually ships),
-- falling back to a hardcoded texture if the scan can't find it.
local function GetRecallIcon()
local idx = FindSpellIndexByName(RECALL_SPELL_NAME)
if idx then
local texture = GetSpellTexture(idx, BOOKTYPE_SPELL)
if texture then
return texture
end
end
return RECALL_ICON_FALLBACK
end
-- Sets an element button's icon: real spell icon (standard crop) when a
-- totem is chosen/resolved, element glyph from the sheet when empty.
-- SetTexCoord persists across SetTexture, so both branches must set it.
local function SetElementIcon(btn, texturePath)
if texturePath then
btn.icon:SetTexture(texturePath)
btn.icon:SetTexCoord(ICON_CROP, 1 - ICON_CROP, ICON_CROP, 1 - ICON_CROP)
else
local c = iconCoords[ELEMENT_EMPTY_CELL[btn.element] or 1]
btn.icon:SetTexture(ICONS_TEXTURE)
btn.icon:SetTexCoord(c.l, c.r, c.t, c.b)
end
end
RefreshButton = function(element)
local btn = elementButtons[element]
if not btn then
return
end
SetElementIcon(btn, GetElementIcon(element))
RefreshCooldown(element)
end
-- Refreshes element `element`'s native cooldown swipe (the same radial
-- CooldownFrameTemplate widget standard action buttons use) to match its
-- currently-chosen totem's spellbook cooldown. Clears the swipe (start=0)
-- when the slot is empty or unresolved. Called from RefreshButton (i.e.
-- on selection changes and once at bar-build time) and from the
-- SPELL_UPDATE_COOLDOWN event handler below - NEVER from the per-tick
-- timer OnUpdate, since re-calling CooldownFrame_SetTimer every tick
-- would restart the swipe's animation instead of letting it play.
RefreshCooldown = function(element)
local btn = elementButtons[element]
if not btn then
return
end
local db = TotemBarDB
local totemName = db and db.chosen and db.chosen[element]
local idx = totemName and FindSpellIndexByName(totemName)
if not idx then
CooldownFrame_SetTimer(btn.cd, 0, 0, 0)
return
end
local start, duration, enable = GetSpellCooldown(idx, BOOKTYPE_SPELL)
CooldownFrame_SetTimer(btn.cd, start, duration, enable)
end
-- Glue for core/assign.lua's pending-assignment logic: it stays WoW-API-
-- light and reaches the spellbook / bar refresh through these slots.
-- isTotemKnown resolves a totem name against the live spellbook; RefreshAll
-- re-skins every element button (used after a pending assignment is applied).
TotemBar.isTotemKnown = function(name)
return FindSpellIndexByName(name) ~= nil
end
TotemBar.RefreshAll = function()
local elements = TotemBar.TOTEM_ELEMENTS
for i = 1, table.getn(elements) do
RefreshButton(elements[i])
end
end
-- Bind-mode key overlays: a small top-right FontString on each bindable
-- button/flyout icon, ALWAYS shown (action-bar-hotkey style) whenever a key
-- is bound, hidden only when nothing is bound. Independent of bind mode.
-- Declared here, BEFORE EnsureFlyoutFrame/CreateElementButton/
-- CreateRecallButton/CreateDropSetButton (all of which call
-- registerBindOverlay from their function bodies): Lua resolves a `local`
-- as an upvalue only for code appearing lexically after its declaration, so
-- this block must sit above every factory that references it, not just
-- above CreateElementButton (EnsureFlyoutFrame is defined earlier in the
-- file than the element/recall/dropset button factories).
local bindOverlayTargets = {} -- { frame = button, action = fn()->command }
local function ensureBindOverlay(frame)
if frame.bindKeyText then
return frame.bindKeyText
end
local fs = frame:CreateFontString(nil, "OVERLAY")
fs:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE")
fs:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -1, -1)
fs:SetTextColor(1, 0.9, 0.2)
fs:Hide()
frame.bindKeyText = fs
return fs
end
-- action is a function returning the binding command for this frame right now
-- (flyout icons change totem), or nil.
local function registerBindOverlay(frame, actionFn)
ensureBindOverlay(frame)
tinsert(bindOverlayTargets, { frame = frame, action = actionFn })
end
TotemBar.refreshBindOverlays = function()
for i = 1, table.getn(bindOverlayTargets) do
local t = bindOverlayTargets[i]
local fs = t.frame.bindKeyText
local cmd = t.action()
local key = cmd and GetBindingKey(cmd) or nil
if key then
fs:SetText(TotemBar.shortenKey(key))
fs:Show()
else
fs:Hide()
end
end
end
-- Replaces the square Blizzard hover/pushed art with round FX-sheet states,
-- sized to the ring frame so they hug the round button. Declared here,
-- BEFORE EnsureFlyoutFrame (same ordering constraint as registerBindOverlay
-- above): the pooled flyout icons' creation loop lives inside
-- EnsureFlyoutFrame and calls this, so it must already be a visible upvalue
-- at that point in the file, not just before CreateElementButton/
-- CreateRecallButton/CreateDropSetButton further down.
local function ApplyRoundClickStates(btn, size)
btn:SetHighlightTexture(FX_TEXTURE, "ADD")
local h = btn:GetHighlightTexture()
if h then
local c = fxCoords[FX_HOVER_CELL]
h:SetTexCoord(c.l, c.r, c.t, c.b)
h:ClearAllPoints()
h:SetPoint("CENTER", btn, "CENTER", 0, 0)
h:SetWidth(size)
h:SetHeight(size)
end
btn:SetPushedTexture(FX_TEXTURE)
local p = btn:GetPushedTexture()
if p then
local c = fxCoords[FX_PUSHED_CELL]
p:SetTexCoord(c.l, c.r, c.t, c.b)
p:ClearAllPoints()
p:SetPoint("CENTER", btn, "CENTER", 0, 0)
p:SetWidth(size)
p:SetHeight(size)
end
end
-- Repositions every pooled flyout icon per the current BUTTON_GAP (mirrors
-- the bottom-up stacking math used when the icons are first built, below).
-- The icon pool is built lazily ONCE and its anchors are baked in at that
-- point - they don't reflow themselves - so this must be called again
-- whenever BUTTON_GAP changes live (TotemBar.SetButtonGap), not just at
-- build time. `frame` lets the build-time call pass the not-yet-assigned
-- local `f` directly; later calls omit it and fall back to the flyoutFrame
-- upvalue (guarded: a no-op if the flyout has never been built yet).
local function layoutFlyoutIcons(frame)
local f = frame or flyoutFrame
if not f then
return
end
for i = 1, MAX_FLYOUT_ICONS do
local ico = flyoutIcons[i]
if ico then
ico:ClearAllPoints()
ico:SetPoint("BOTTOM", f, "BOTTOM", 0, FLYOUT_PAD + (i - 1) * (BUTTON_SIZE + BUTTON_GAP))
end
end
end
-- Lazily builds the single shared flyout frame plus its pool of icon
-- buttons (TotemBarFlyoutIcon1..MAX_FLYOUT_ICONS), stacked bottom-up so
-- ShowFlyout can just Show the first N. DIALOG strata so it draws above
-- the bar backdrop. Same anti-bevel icon treatment as the element
-- buttons (opaque black backdrop under an inset, cropped ARTWORK icon).
EnsureFlyoutFrame = function()
if flyoutFrame then
return flyoutFrame
end
-- Parent to the bar (not UIParent) so the flyout inherits the bar's
-- scale (UI-size slider) and hides with it. Still DIALOG strata below,
-- so it draws above the bar backdrop regardless of parent.
local f = CreateFrame("Frame", "TotemBarFlyout", TotemBarFrame)
f:SetFrameStrata("DIALOG")
f:SetWidth(BUTTON_SIZE + FLYOUT_PAD * 2)
f:SetHeight(BUTTON_SIZE + FLYOUT_PAD * 2)
f:SetBackdrop({
bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
tile = true, tileSize = 16, edgeSize = 16,
insets = { left = 4, right = 4, top = 4, bottom = 4 },
})
-- Floating icons: fully transparent flyout backdrop/border (the flyout
-- frame itself is now just an invisible hit-test/layout area).
f:SetBackdropColor(0, 0, 0, 0)
f:SetBackdropBorderColor(0, 0, 0, 0)
f:EnableMouse(true) -- so gaps/padding still count as "over the flyout"
f:SetClampedToScreen(true)
f:Hide()
for i = 1, MAX_FLYOUT_ICONS do
local ico = CreateFrame("Button", "TotemBarFlyoutIcon" .. i, f)
ico:SetWidth(BUTTON_SIZE)
ico:SetHeight(BUTTON_SIZE)
-- Bottom-up: icon 1 just inside the bottom padding, each next
-- one a full button+gap higher (so nearest the element button
-- is first). Anchor applied below via layoutFlyoutIcons (once the
-- whole pool exists), not here - the same helper re-applies it
-- live when BUTTON_GAP changes.
ico:SetBackdrop({
bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
tile = true, tileSize = 16, edgeSize = 12,
insets = { left = 3, right = 3, top = 3, bottom = 3 },
})
ico:SetBackdropColor(0, 0, 0, 0)
ico:SetBackdropBorderColor(0, 0, 0, 0)
local icon = ico:CreateTexture("TotemBarFlyoutIcon" .. i .. "Icon", "ARTWORK")
icon:SetPoint("TOPLEFT", ico, "TOPLEFT", 3, -3)
icon:SetPoint("BOTTOMRIGHT", ico, "BOTTOMRIGHT", -3, 3)
icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
ico.icon = icon
-- Grounding shadow ellipse (Rev 4 UI chrome), same as the element
-- buttons' - BACKGROUND layer, no glow on flyout icons so ordering
-- relative to other regions doesn't matter here.
local shadow = ico:CreateTexture("TotemBarFlyoutIcon" .. i .. "Shadow", "BACKGROUND")
shadow:SetTexture(UI_TEXTURE)
local sc = uiCoords[UI_SHADOW_CELL]
shadow:SetTexCoord(sc.l, sc.r, sc.t, sc.b)
shadow:SetPoint("CENTER", ico, "CENTER", 0, -15)
shadow:SetWidth(58)
shadow:SetHeight(20)
shadow:SetAlpha(0.7) -- softened, see the element buttons' plate note
ico.shadow = shadow
-- Floating round frame, same decorative band as the element
-- buttons (no fill/tint - just the permanent frame). Flyout icons
-- are BUTTON_SIZE (36px) too, so the proportional ring size
-- S*(48/36) is just RING_TEX_SIZE (48px) again.
local ringTrack = ico:CreateTexture("TotemBarFlyoutIcon" .. i .. "RingTrack", "OVERLAY")
ringTrack:SetTexture(RING_TEXTURE)
local rtc = allRingCoords[RING_TRACK_CELL]
ringTrack:SetTexCoord(rtc.l, rtc.r, rtc.t, rtc.b)
ringTrack:SetPoint("CENTER", ico, "CENTER", 0, 0)
ringTrack:SetWidth(RING_TEX_SIZE)
ringTrack:SetHeight(RING_TEX_SIZE)
ico.ringTrack = ringTrack
-- Native cooldown swipe over the flyout icon, so the player can
-- see which alternative totems are on cooldown while choosing.
-- Same "Model" (NOT "Cooldown") frame-type caveat as the element
-- buttons: vanilla 1.12 has no dedicated Cooldown widget type -
-- CooldownFrameTemplate is a Model template here (see the element
-- button's cd note above). Driven once at flyout-open by
-- ShowFlyout (the widget animates itself from start+duration), so
-- no per-frame refresh for this transient popup.
local cd = CreateFrame("Model", "TotemBarFlyoutIcon" .. i .. "Cooldown", ico, "CooldownFrameTemplate")
cd:SetAllPoints(ico.icon)
cd:SetFrameLevel(ico:GetFrameLevel())
ico.cd = cd
-- Round hover/pushed states (Rev 3), same FX sheet as the bar
-- buttons; flyout icons are BUTTON_SIZE, sized like the bar's ring.
ApplyRoundClickStates(ico, RING_TEX_SIZE)
ico:RegisterForClicks("LeftButtonUp", "RightButtonUp")
-- Left-click: cast once for the element the flyout is currently
-- showing. recordCast() drives THAT element's timer to the
-- actually-cast totem, without touching the slot's chosen
-- default. Right-click: make this totem the slot's new chosen
-- default and re-populate the flyout in place (so the
-- newly-chosen totem drops out of the "others" list and the
-- previously-chosen one appears), keeping the flyout open.
ico:SetScript("OnClick", function()
if not (this.totemName and flyoutElement) then
return
end
if arg1 == "RightButton" then
local element = flyoutElement
local owner = flyoutOwnerButton
TotemBarDB.chosen[element] = this.totemName
RefreshButton(element)
if owner then
ShowFlyout(owner, element)
end
else
CastSpellByName(this.totemName)
TotemBar.recordCast(flyoutElement, this.totemName)
-- Immediate cooldown feedback: don't wait for
-- SPELL_UPDATE_COOLDOWN. Refreshes the bar button's swipe
-- (in case the cast totem is also this element's chosen
-- default) and the flyout icon's own swipe.
RefreshCooldown(flyoutElement)
RefreshFlyoutCooldowns()
end
end)
ico:SetScript("OnEnter", function()
if this.totemName then
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
SetSpellTooltip(GameTooltip, this.totemName)
GameTooltip:AddLine("Left-click: cast / Right-click: set default", 1, 1, 1)
GameTooltip:Show()
end
end)
ico:SetScript("OnLeave", function()
GameTooltip:Hide()
end)
ico:Hide()
registerBindOverlay(ico, function()
if ico.totemName then
return "TOTEMBAR_TOTEM_" .. TotemBar.bindingSuffix(ico.totemName)
end
return nil
end)
flyoutIcons[i] = ico
end
-- Anchors every pooled icon per BUTTON_GAP now that the whole pool
-- exists (flyoutFrame isn't assigned yet at this point in the function,
-- hence passing `f` explicitly - see layoutFlyoutIcons's own comment).
layoutFlyoutIcons(f)
-- OnUpdate only fires while the frame is shown, so this hide-check
-- naturally stops running once the flyout is hidden.
f:SetScript("OnUpdate", OnFlyoutUpdate)
flyoutFrame = f
return f
end
-- Populates and shows the flyout above `button` for `element`, listing
-- every known totem of that element EXCEPT the currently-chosen default.
-- Shows nothing if there are no "others".
ShowFlyout = function(button, element)
local f = EnsureFlyoutFrame()
local db = TotemBarDB
local spellNames = TotemBar.scanSpellbook()
local known = TotemBar.knownTotems(spellNames, element)
local chosen = db and db.chosen and db.chosen[element]
local count = 0
for i = 1, table.getn(known) do
local totemName = known[i]
if totemName ~= chosen and count < MAX_FLYOUT_ICONS then
count = count + 1
local ico = flyoutIcons[count]
ico.totemName = totemName
local idx = FindSpellIndexByName(totemName)
local texture = idx and GetSpellTexture(idx, BOOKTYPE_SPELL)
ico.icon:SetTexture(texture or EMPTY_ICON)
-- Drive the swipe once here at flyout-open so the player can
-- see which alternative totems are on cooldown; the widget
-- animates itself from start+duration, no per-tick refresh.
if idx then
local start, duration, enable = GetSpellCooldown(idx, BOOKTYPE_SPELL)
CooldownFrame_SetTimer(ico.cd, start, duration, enable)
else
CooldownFrame_SetTimer(ico.cd, 0, 0, 0)
end
ico:Show()
end
end
for j = count + 1, MAX_FLYOUT_ICONS do
flyoutIcons[j]:Hide()
flyoutIcons[j].totemName = nil
-- Clear the swipe on unused pool icons so a stale cooldown from a
-- previous open doesn't linger if this slot is reused later.
CooldownFrame_SetTimer(flyoutIcons[j].cd, 0, 0, 0)
end
if count == 0 then
-- No other known totems for this element; don't show an empty box.
flyoutElement = nil
flyoutOwnerButton = nil
f:Hide()
return
end
flyoutElement = element
flyoutOwnerButton = button
flyoutElapsed = 0
f:SetHeight(count * (BUTTON_SIZE + BUTTON_GAP) - BUTTON_GAP + FLYOUT_PAD * 2)
f:ClearAllPoints()
f:SetPoint("BOTTOM", button, "TOP", 0, FLYOUT_GAP)
f:Show()
-- Freshly-populated flyout icons need their overlays re-driven right
-- away (rather than waiting for the next UPDATE_BINDINGS/toggle) so
-- keys show immediately while bind mode is already on.
if TotemBar.refreshBindOverlays then
TotemBar.refreshBindOverlays()
end
RefreshFlyoutMana()
end
-- Re-drives the cooldown swipe on every CURRENTLY-SHOWN pooled flyout
-- icon, re-resolving each one's spellbook index fresh (so a just-cast
-- totem's swipe reflects the cooldown that cast just started). No-op if
-- the flyout isn't open. Doesn't touch icon texture/totemName/layout -
-- only the swipe - so it's safe to call from anywhere without disturbing
-- ShowFlyout's own population pass (which sets the initial swipe state
-- itself, including clearing unused pool icons; left as-is here).
RefreshFlyoutCooldowns = function()
if not (flyoutFrame and flyoutFrame:IsShown()) then
return
end
for i = 1, MAX_FLYOUT_ICONS do
local ico = flyoutIcons[i]
if ico:IsShown() and ico.totemName then
local idx = FindSpellIndexByName(ico.totemName)
if idx then
local start, duration, enable = GetSpellCooldown(idx, BOOKTYPE_SPELL)
CooldownFrame_SetTimer(ico.cd, start, duration, enable)
else
CooldownFrame_SetTimer(ico.cd, 0, 0, 0)
end
end
end
end
-- Dims every shown flyout icon the player cannot currently afford, on the same
-- rule as the bar buttons (TotemBar.iconTintFor / notEnoughMana). Flyout icons
-- have no range state of their own -- they are alternatives that aren't out --
-- so only the mana half applies. Cheap: the cost lookup is cached, and the
-- flyout only exists while it is hovered.
RefreshFlyoutMana = function()
if not (flyoutFrame and flyoutFrame:IsShown()) then
return
end
local playerMana = (type(UnitMana) == "function") and UnitMana("player") or nil
for i = 1, MAX_FLYOUT_ICONS do
local ico = flyoutIcons[i]
if ico:IsShown() and ico.totemName then
-- No Clearcasting exemption (2026-08-28): Elemental Focus does not
-- cover totems, see core/manacost.lua.
local oom = TotemBar.notEnoughMana(TotemBar.getTotemManaCost(ico.totemName), playerMana)
local r, g, b, key = TotemBar.iconTintFor(false, oom)
if key ~= ico.tintKey then
ico.icon:SetVertexColor(r, g, b)
ico.tintKey = key
end
end
end
end
HideFlyout = function()
if flyoutFrame then
flyoutFrame:Hide()
end
flyoutElement = nil
flyoutOwnerButton = nil
end
-- Throttled mouse-leave check: hide the flyout once the cursor is over
-- NEITHER the owning element button NOR the flyout itself. This keeps
-- it open while the mouse travels from the button up onto the flyout.
-- No per-frame allocation - just the accumulator and two MouseIsOver
-- geometry checks, gated to run ~every FLYOUT_HIDE_INTERVAL seconds.
OnFlyoutUpdate = function()
flyoutElapsed = flyoutElapsed + arg1
if flyoutElapsed < FLYOUT_HIDE_INTERVAL then
return
end
flyoutElapsed = 0
RefreshFlyoutMana()
if flyoutOwnerButton and (MouseIsOver(flyoutFrame) or MouseIsOver(flyoutOwnerButton)) then
return
end
HideFlyout()
end
-- Applies the floating round-icon frame to one button: permanent
-- decorative-frame texture (ringTrack) + duration-arc texture/tint
-- (ringFill, where present) + wave tint (pulseWave, where present), and
-- blanks the button's own square backdrop/border - the frame ring now IS
-- the visual frame, always (no ring-option gate: even with the duration
-- arc hidden, the permanent ring frame stays visible and the button must
-- never show a black box behind it). Used for element buttons (which have
-- ringFill/pulseWave) and the Recall/DropSet buttons (which don't, guarded
-- below) alike.
local function ApplyRoundFrame(btn)
btn.ringTrack:SetTexture(RING_TEXTURE)
local trc = allRingCoords[RING_TRACK_CELL]
btn.ringTrack:SetTexCoord(trc.l, trc.r, trc.t, trc.b)
if btn.ringFill then
btn.ringFill:SetTexture(RING_TEXTURE)
-- No element-color assignment here anymore: the duration ring is
-- now a time-remaining traffic light (green->yellow->orange->red),
-- recolored every tick in UpdateTimerDisplays via TotemBar.timeColor.
btn.ringLastIdx = nil -- force a fill-frame re-apply on the next tick
end
if btn.pulseArc then
local c = ELEMENT_COLORS[btn.element]
if c then
btn.pulseArc:SetVertexColor(c[1], c[2], c[3], 0.95)
end
end
if btn.pulseWave then
local c = ELEMENT_COLORS[btn.element]
if c then
btn.pulseWave:SetVertexColor(c[1], c[2], c[3])
end
end
if btn.glow then
local c = ELEMENT_COLORS[btn.element]
if c then
btn.glow:SetVertexColor(c[1], c[2], c[3])
end
end
btn:SetBackdropColor(0, 0, 0, 0)
btn:SetBackdropBorderColor(0, 0, 0, 0)
end
-- The timer text hangs below the button (the pulse arc and ripple wave are
-- both centered ON the button, so neither competes for this anchor).
local function ApplyTimerAnchor(btn)
btn.timerText:ClearAllPoints()
btn.timerText:SetPoint("TOP", btn, "BOTTOM", 0, -1)
end
-- Deterministic-stacking child frame for regions that must render above
-- ALL of a button's own regions regardless of draw layer (2026-07-16,
-- HIGHLIGHT-hover regression fix). Background: f3644c5 moved
-- ringFill/pulseWave/pulseArc (and the Recall button's autoIndicator) to
-- the HIGHLIGHT layer to beat ringTrack's OVERLAY layer - but 1.12 only
-- renders a Button's HIGHLIGHT draw layer while the mouse is hovering it
-- (in-game confirmed: every fill ring/indicator went invisible except
-- under the cursor). Layers were never going to work for "always-on-top,
-- always visible" content on a Button. The fix used here instead (the same
-- pattern used elsewhere for row-highlight panel frames): WoW's cross-frame
-- draw order is primarily (strata,
-- frame level) - a CHILD FRAME at a higher FrameLevel than its parent
-- draws every one of its own regions above ALL of the parent's regions,
-- no matter which of the 5 draw layers either side uses. So a small,
-- mouse-transparent child frame pinned over the button's own bounds,
-- one level higher, gives deterministic top-of-stack rendering without
-- spending any of the parent's own 5-layer budget.
local function CreateRingOverlay(btn, name)
local overlay = CreateFrame("Frame", name, btn)
overlay:EnableMouse(false) -- pure visual layer, must not steal btn's hover/click
overlay:SetAllPoints(btn) -- deckungsgleich with btn; CENTER anchors below resolve identically
overlay:SetFrameLevel(btn:GetFrameLevel() + 1)
return overlay
end
CreateElementButton = function(element, index)
local name = "TotemBarButton" .. element
local btn = CreateFrame("Button", name, TotemBarFrame)
btn:SetWidth(BUTTON_SIZE)
btn:SetHeight(BUTTON_SIZE)
btn:SetPoint("LEFT", TotemBarFrame, "LEFT", (index - 1) * (BUTTON_SIZE + BUTTON_GAP) + BUTTON_GAP, 0)
-- Backdrop template kept (needed for SetBackdropColor/BorderColor
-- below) but floating: ApplyRoundFrame (called later in this function)
-- blanks both to fully transparent - the decorative ring frame is the
-- only visible frame around the icon now.
btn:SetBackdrop({
bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
tile = true, tileSize = 16, edgeSize = 12,
insets = { left = 3, right = 3, top = 3, bottom = 3 },
})
local icon = btn:CreateTexture(name .. "Icon", "ARTWORK")
-- Inset a few px from the button's own edges (the floating decorative
-- ring frame, drawn OVERLAY on top, covers this margin and the icon's
-- square corners - see ApplyRoundFrame/the texture generator's
-- geometry rationale).
icon:SetPoint("TOPLEFT", btn, "TOPLEFT", 3, -3)
icon:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -3, 3)
btn.icon = icon
-- Set ahead of SetElementIcon below (which reads btn.element to pick
-- the empty-slot glyph cell); reassigned harmlessly again further
-- down alongside the rest of the button's setup, same repeat-write
-- pattern as the element-tint lookup ahead of ApplyRoundFrame below.
btn.element = element
-- SetElementIcon sets both texture and texcoord (SetTexCoord persists
-- across SetTexture, so both the real-icon crop and the sheet-glyph
-- coords always get (re)applied together - see its own comment).
SetElementIcon(btn, GetElementIcon(element))
-- Native action-button-style radial cooldown swipe (the same
-- CooldownFrameTemplate widget the default action bars use),
-- covering the icon area. This is IN ADDITION to the OmniCC-style
-- duration timer text below; RefreshCooldown() drives it from
-- events, see there for why it's not driven off the per-tick timer.
--
-- Frame type is "Model", NOT "Cooldown": vanilla 1.12 has no
-- dedicated Cooldown widget type (that was added in TBC/2.0) -
-- CooldownFrameTemplate is a Model-type template in 1.12 FrameXML
-- that renders Interface\Cooldown\UI-Cooldown-Indicator.mdx (same
-- approach pfUI's own libtotem/action bars use). CreateFrame with
-- type "Cooldown" would error on this client.
local cd = CreateFrame("Model", name .. "Cooldown", btn, "CooldownFrameTemplate")
cd:SetAllPoints(icon)
-- Match the button's own frame level instead of the default child
-- bump (parent level + 1): WoW's cross-frame draw order is primarily
-- (strata, level) - at the default +1 level the ENTIRE swipe frame
-- would draw above ALL of btn's own regions regardless of draw
-- layer, including the OVERLAY timer text below. At the SAME level,
-- ordering falls back to per-region draw layer, letting the OVERLAY
-- timer text render on top of the swipe as intended. Still flagged
-- for an in-game visual check (see file header note).
cd:SetFrameLevel(btn:GetFrameLevel())
btn.cd = cd
-- OmniCC-style remaining-duration text, anchored BELOW the button
-- (centered under the icon) so it doesn't overlap the native
-- cooldown swipe (btn.cd) rendered on top of the icon. Parented to
-- btn (a Button, like the element buttons themselves), not to the
-- bar's backdrop Frame - plain 1.12 Frames don't clip child regions
-- (no SetClipsChildren in this client), so text hanging below the
-- bar's backdrop still renders fully; flagged for an in-game visual
-- check regardless (see file header note). Hidden by default;
-- UpdateTimerDisplays() shows/updates it.
--
-- Left parented to btn (NOT moved to ringOverlay), 2026-07-16
-- HIGHLIGHT-hover regression fix: geometry check (btn height 36,
-- ring RING_TEX_SIZE 48 centered on btn -> ring overhangs 6px past
-- every edge) puts ring:GetBottom() at btn:GetBottom()-6, while this
-- FontString's SetPoint("TOP", btn, "BOTTOM", 0, -1) puts its OWN top
-- at only btn:GetBottom()-1 - i.e. a ~5px vertical band where the two
-- regions' bounding boxes technically overlap, directly under the
-- button's center. In practice that band is the ring's transparent
-- annulus hole (the band's opaque pixels hug its outer 48px edge, not
-- its center) plus the font's own ascender padding above the glyphs,
-- so no visible ink collision - stays on btn/OVERLAY, unchanged.
local timerText = btn:CreateFontString(name .. "Timer", "OVERLAY")
timerText:SetFont("Fonts\\FRIZQT__.TTF", 14, "OUTLINE")
timerText:SetPoint("TOP", btn, "BOTTOM", 0, -1)
timerText:SetJustifyH("CENTER")
timerText:SetText("")
timerText:Hide()
btn.timerText = timerText
btn.timerVisible = false -- cached shown-state, avoid redundant Show/Hide
btn.timerLastText = nil -- cached last string, avoid redundant SetText
btn.timerLastLow = nil -- cached last <=5s tint state, avoid redundant SetTextColor
btn.tintRed = false -- last out-of-range verdict (read by the Recall button's pulse)
btn.tintKey = 0 -- cached composed tint (range + out-of-mana), avoid redundant SetVertexColor
-- Set here (ahead of the later btn.element = element below) because
-- ApplyRoundFrame, called at the end of this block, needs it for the
-- element-tint color lookup; the later assignment is then a harmless
-- no-op repeat of the same value.
btn.element = element
-- Grounding shadow ellipse (Rev 4 UI chrome). BACKGROUND layer - the
-- anticipation glow below used to also be BACKGROUND on the (wrong)
-- assumption that same-layer draw order follows creation order in this
-- client; the 2026-07-16 ring-occlusion investigation live-confirmed
-- that's false (ringTrack was randomly hiding ringFill despite being
-- created first) - 1.12 discards SetDrawLayer's sublevel argument, so
-- same-MAIN-layer siblings really do have undefined relative order.
-- glow now sits on its own BORDER layer instead (below), which is the
-- real fix: no assumption needed when the two don't share a layer.
local shadow = btn:CreateTexture(name .. "Shadow", "BACKGROUND")
shadow:SetTexture(UI_TEXTURE)
local sc = uiCoords[UI_SHADOW_CELL]
shadow:SetTexCoord(sc.l, sc.r, sc.t, sc.b)
shadow:SetPoint("CENTER", btn, "CENTER", 0, -15)
shadow:SetWidth(58)
shadow:SetHeight(20)
-- Softened after preview review: full-strength plate read boxy on light
-- ground; flatter + more transparent grounds without drawing attention.
shadow:SetAlpha(0.7)
btn.shadow = shadow
-- Pulse UI Rev 2: floating round frame (permanent decorative band +
-- duration-arc fill) + ripple wave. Sized to RING_TEX_SIZE (48px)
-- centered on the 36px button, NOT SetAllPoints - the ring extends
-- past the button's own edges (see the geometry rationale in the
-- texture generator).
--
-- Frame-level stacking (2026-07-16, HIGHLIGHT-hover regression fix -
-- see CreateRingOverlay's own comment for the full story): ringTrack,
-- ringFill, pulseWave and pulseArc are all centered on the same point
-- at the same RING_TEX_SIZE, i.e. all four spatially overlap. A prior
-- fix moved the three dynamic ones to HIGHLIGHT to beat ringTrack's
-- OVERLAY - but 1.12 only paints a Button's HIGHLIGHT layer on hover,
-- so that made them invisible almost always. ringTrack is the one
-- PERMANENT member of this quartet (always shown, see ringTrack:Show()
-- below) so it stays right where it was: parented to btn, OVERLAY
-- layer. ringFill/pulseWave/pulseArc move to a dedicated child frame
-- (ringOverlay) one FrameLevel above btn, which puts all three above
-- ringTrack unconditionally, regardless of draw layer. Within that
-- child frame the three still need to not collide with EACH OTHER
-- (1.12 discards SetDrawLayer's sublevel argument, so same-main-layer
-- siblings have undefined relative order) - given 3 items and a full
-- 5-layer budget to itself now, each gets its own layer:
-- ringFill = ARTWORK (base: the colored duration-remaining ring)
-- pulseWave = OVERLAY (the brief per-pulse ripple must read above
-- the fill it pulses over)
-- pulseArc = HIGHLIGHT (the thin white countdown-to-next-pulse
-- readout; per its own file comment it must
-- "read distinctly" from ringFill, so it gets
-- the topmost layer to stay legible even
-- while a wave is mid-flash)
-- All three are still spatially anchored to the SAME center point as
-- before (now via ringOverlay, which is deckungsgleich with btn -
-- SetAllPoints - so a CENTER anchor resolves to the identical pixel
-- as anchoring to btn directly).
local ringTrack = btn:CreateTexture(name .. "RingTrack", "OVERLAY")
ringTrack:SetPoint("CENTER", btn, "CENTER", 0, 0)
ringTrack:SetWidth(RING_TEX_SIZE)
ringTrack:SetHeight(RING_TEX_SIZE)
ringTrack:Show() -- the frame is permanent, unlike ringFill/pulseWave below
btn.ringTrack = ringTrack
local ringOverlay = CreateRingOverlay(btn, name .. "RingOverlay")
btn.ringOverlay = ringOverlay
local ringFill = ringOverlay:CreateTexture(name .. "RingFill", "ARTWORK")
ringFill:SetPoint("CENTER", ringOverlay, "CENTER", 0, 0)
ringFill:SetWidth(RING_TEX_SIZE)
ringFill:SetHeight(RING_TEX_SIZE)
ringFill:Hide()
btn.ringFill = ringFill
btn.ringLastIdx = nil -- cached flipbook frame, avoid redundant SetTexCoord
btn.ringVisible = false -- cached shown-state, avoid redundant Show/Hide
-- Ripple wave: expanding, fading ring in the element color, one wave
-- per pulse (replaces the old pulse progress bar). Animated every
-- frame by UpdateWaves below (allocation-free), inputs refreshed by
-- the 0.1s timer tick in UpdateTimerDisplays.
local pulseWave = ringOverlay:CreateTexture(name .. "PulseWave", "OVERLAY")
pulseWave:SetTexture(RING_TEXTURE)
local wc = allRingCoords[RING_WAVE_CELL]
pulseWave:SetTexCoord(wc.l, wc.r, wc.t, wc.b)
pulseWave:SetPoint("CENTER", ringOverlay, "CENTER", 0, 0)
pulseWave:SetWidth(RING_TEX_SIZE)
pulseWave:SetHeight(RING_TEX_SIZE)
pulseWave:Hide()
btn.pulseWave = pulseWave
btn.waveVisible = false
-- Per-frame wave animation inputs, refreshed by the 0.1s tick; nil
-- interval means "no wave for this slot right now".
btn.waveInterval = nil
btn.waveOrigin = nil -- phase origin (pulseAnchor or start)
btn.waveOneshotAt = nil -- detonation time for oneshot waves
-- Circular pulse countdown: element-colored inner arc on the band,
-- fills clockwise toward the next pulse and resets (user feedback: the
-- wave alone doesn't show WHEN; a circular bar fits the round slots).
-- Colored below by ApplyRoundFrame (called at the end of this function)
-- via ELEMENT_COLORS - no fixed color set here.
local pulseArc = ringOverlay:CreateTexture(name .. "PulseArc", "HIGHLIGHT")
pulseArc:SetTexture(PULSEARC_TEXTURE)
pulseArc:SetPoint("CENTER", ringOverlay, "CENTER", 0, 0)
pulseArc:SetWidth(RING_TEX_SIZE)
pulseArc:SetHeight(RING_TEX_SIZE)
pulseArc:Hide()
btn.pulseArc = pulseArc
btn.pulseArcLastIdx = nil
btn.pulseVisible = false
-- Anticipation glow: annular halo just outside the ring band, fading in
-- over the last stretch before a pulse. BORDER layer (moved from
-- BACKGROUND, 2026-07-16 - see the shadow comment above): stays below
-- ARTWORK (icon) as intended, but no longer shares a layer with shadow.
local glow = btn:CreateTexture(name .. "Glow", "BORDER")
glow:SetTexture(FX_TEXTURE)
local gc = fxCoords[FX_GLOW_CELL]
glow:SetTexCoord(gc.l, gc.r, gc.t, gc.b)
glow:SetPoint("CENTER", btn, "CENTER", 0, 0)
glow:SetWidth(GLOW_TEX_SIZE)
glow:SetHeight(GLOW_TEX_SIZE)
glow:Hide()
btn.glow = glow
btn.glowVisible = false
btn.spawnAt = nil -- one-shot spawn ripple origin (set by the tick)
btn.lastStartSeen = nil -- cast-change detector
btn.waveOneshotDelay = nil -- arming window length for oneshot glow
ApplyRoundFrame(btn)
ApplyTimerAnchor(btn)
-- No normal texture: UI-Quickslot2's bevel bleeds through
-- transparent icon art (see backdrop above). Round hover/pushed FX
-- states (Rev 3) only appear on click/hover, so they don't bleed.
ApplyRoundClickStates(btn, RING_TEX_SIZE)
btn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
btn.element = element
-- Left-click casts the slot's chosen totem. Right-click clears the
-- slot (sets no default); picking a new default happens via the
-- hover flyout's right-click instead (see EnsureFlyoutFrame above).
btn:SetScript("OnClick", function()
local clickedElement = this.element
if arg1 == "RightButton" then
TotemBarDB.chosen[clickedElement] = nil
RefreshButton(clickedElement)
else
local db = TotemBarDB
local totemName = db and db.chosen and db.chosen[clickedElement]
if totemName then
CastSpellByName(totemName)
TotemBar.recordCast(clickedElement, totemName)
else
ChatOut:AddMessage("TotemBar: no totem chosen for " .. clickedElement .. " (hover for known totems, right-click one to set it as default)")
end
end
end)
btn:SetScript("OnEnter", function()
local db = TotemBarDB
local totemName = db and db.chosen and db.chosen[this.element]
GameTooltip:SetOwner(this, "ANCHOR_TOP")
if totemName then
SetSpellTooltip(GameTooltip, totemName)
GameTooltip:AddLine("Left-click: cast / Right-click: clear", 1, 1, 1)
else
GameTooltip:SetText(this.element .. " (empty)")
GameTooltip:AddLine("Hover for known totems, right-click one to set default", 1, 1, 1)
end
GameTooltip:Show()
-- Pop the "cast one of the others" flyout above this button.
-- The flyout hides itself via its own throttled mouse-leave
-- check (OnFlyoutUpdate), so no OnLeave handling is needed here.
ShowFlyout(this, this.element)
end)
btn:SetScript("OnLeave", function()
GameTooltip:Hide()
end)
elementButtons[element] = btn
registerBindOverlay(btn, function() return "TOTEMBAR_CAST_" .. string.upper(element) end)
return btn
end
-- Refreshes the Recall button's small "A" auto-recall indicator to
-- match TotemBarDB.autoRecall: shown (greenish) when on, hidden when
-- off. Called once at button creation and again after every right-click
-- toggle (see CreateRecallButton below).
RefreshRecallIndicator = function()
if not recallButton or not recallButton.autoIndicator then
return
end
if TotemBarDB and TotemBarDB.autoRecall then
recallButton.autoIndicator:SetTextColor(0.3, 1, 0.3)
recallButton.autoIndicator:Show()
else
recallButton.autoIndicator:Hide()
end
end
-- Public alias so options.lua can refresh the "A" auto-recall indicator
-- after the auto-recall checkbox is toggled.
TotemBar.RefreshRecallIndicator = RefreshRecallIndicator
-- Pulses the Recall button's icon alpha while anyOutOfRange is true (set
-- by UpdateTimerDisplays) - a "go recall + redeploy" visual prompt.
-- Time-based (GetTime()), so the pulse stays smooth regardless of frame
-- rate; no table/string allocation, just float math + SetAlpha. Once
-- anyOutOfRange goes false, resets to full alpha exactly once (cached
-- via iconPulsing) instead of calling SetAlpha every single frame.
OnRecallUpdate = function()
if anyOutOfRange then
this.icon:SetAlpha(0.35 + 0.65 * math.abs(math.sin(GetTime() * 3)))
this.iconPulsing = true
elseif this.iconPulsing then
this.icon:SetAlpha(1)
this.iconPulsing = false
end
end
-- Totemic Recall: left-click casts it immediately (no dropdown, no
-- per-element timer - recall has no duration of its own, it just clears
-- totems). Right-click instead toggles TotemBarDB.autoRecall, the flag
-- that decides whether TotemBar.recallAndCastAll() (the Totems macro's
-- entry point, core/cast.lua) prepends a recall before redeploying. A
-- small "A" FontString overlay reflects the current flag state.
CreateRecallButton = function(index)
local name = "TotemBarButtonRecall"
local btn = CreateFrame("Button", name, TotemBarFrame)
btn:SetWidth(BUTTON_SIZE)
btn:SetHeight(BUTTON_SIZE)
btn:SetPoint("LEFT", TotemBarFrame, "LEFT", (index - 1) * (BUTTON_SIZE + BUTTON_GAP) + BUTTON_GAP, 0)
-- Backdrop template kept (needed for SetBackdropColor/BorderColor
-- below) but floating: ApplyRoundFrame blanks both to fully transparent
-- - the decorative ring frame (below) is the only visible frame now.
btn:SetBackdrop({
bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
tile = true, tileSize = 16, edgeSize = 12,
insets = { left = 3, right = 3, top = 3, bottom = 3 },
})
local icon = btn:CreateTexture(name .. "Icon", "ARTWORK")
icon:SetPoint("TOPLEFT", btn, "TOPLEFT", 3, -3)
icon:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -3, 3)
icon:SetTexture(GetRecallIcon())
icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
btn.icon = icon
btn.iconPulsing = false -- cached: whether the icon's alpha is currently != 1 (avoids redundant SetAlpha calls once the pulse stops)
-- Auto-recall indicator: small "A" in the icon's top-left corner. Sits
-- inside the area ringTrack (below) is documented to cover ("covers
-- the icon's square corners" - see the element button's icon comment)
-- - i.e. a REAL, code-confirmed spatial collision with ringTrack, not
-- a borderline one. A same-layer HIGHLIGHT promotion (2026-07-16
-- ring-occlusion follow-up) tried to win that collision the same way
-- ringFill's did, and broke exactly the same way once confirmed
-- in-game: 1.12 only paints a Button's HIGHLIGHT layer on hover, so
-- the "A" went invisible except under the cursor. Fixed the same way
-- as the element buttons' ring stack (see CreateRingOverlay): its own
-- small child frame one FrameLevel above btn, so it renders above
-- ringTrack deterministically regardless of layer. Doesn't share
-- CreateElementButton's ringOverlay (different button, different
-- frame tree) - a second, button-local overlay, OVERLAY layer is
-- fine since it's the only region on it (no sibling to collide with).
-- RefreshRecallIndicator() (called below, and after every toggle)
-- shows/hides and colors it based on TotemBarDB.autoRecall.
local autoOverlay = CreateRingOverlay(btn, name .. "AutoOverlay")
btn.autoOverlay = autoOverlay
local autoIndicator = autoOverlay:CreateFontString(name .. "Auto", "OVERLAY")
autoIndicator:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE")
autoIndicator:SetPoint("TOPLEFT", icon, "TOPLEFT", 1, -1)
autoIndicator:SetJustifyH("LEFT")
autoIndicator:SetText("A")
autoIndicator:Hide()
btn.autoIndicator = autoIndicator
-- Grounding shadow ellipse (Rev 4 UI chrome). No glow on this button,
-- so creation order relative to other regions doesn't matter here.
local shadow = btn:CreateTexture(name .. "Shadow", "BACKGROUND")
shadow:SetTexture(UI_TEXTURE)
local sc = uiCoords[UI_SHADOW_CELL]
shadow:SetTexCoord(sc.l, sc.r, sc.t, sc.b)
shadow:SetPoint("CENTER", btn, "CENTER", 0, -15)
shadow:SetWidth(58)
shadow:SetHeight(20)
-- Softened after preview review: full-strength plate read boxy on light
-- ground; flatter + more transparent grounds without drawing attention.
shadow:SetAlpha(0.7)
btn.shadow = shadow
-- Same floating round frame as the element buttons (permanent
-- decorative band, no fill/tint - no ringFill/pulseWave on this
-- button, guarded inside ApplyRoundFrame).
local ringTrack = btn:CreateTexture(name .. "RingTrack", "OVERLAY")
ringTrack:SetPoint("CENTER", btn, "CENTER", 0, 0)
ringTrack:SetWidth(RING_TEX_SIZE)
ringTrack:SetHeight(RING_TEX_SIZE)
btn.ringTrack = ringTrack
ApplyRoundFrame(btn)
-- No normal texture (UI-Quickslot2 bevel bleeds through transparent
-- icon art). Round hover/pushed FX states (Rev 3) only show on
-- click/hover, no bleed.
ApplyRoundClickStates(btn, RING_TEX_SIZE)
btn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
-- "Go recall + redeploy" visual prompt: pulses the icon's alpha
-- while anyOutOfRange is true (set by UpdateTimerDisplays). Only
-- touches btn.icon's alpha, never btn.autoIndicator, so the "A"
-- auto-recall flag text stays steady/visible throughout.
btn:SetScript("OnUpdate", OnRecallUpdate)
btn:SetScript("OnClick", function()
if arg1 == "RightButton" then
TotemBarDB.autoRecall = not TotemBarDB.autoRecall
if TotemBarDB.autoRecall then
ChatOut:AddMessage("TotemBar: auto-recall before setting ON")
else
ChatOut:AddMessage("TotemBar: auto-recall before setting OFF")
end
RefreshRecallIndicator()
else
-- Shared with the TOTEMBAR_RECALL keybind (bind.lua) so the two manual
-- paths cannot drift apart: TotemBar.manualRecall does the gate, the
-- cooldown check, the queue-safe cast, the refund snapshot and the
-- own-tracking wipe (see core/cast.lua). Only the chat feedback is ours.
local action = TotemBar.manualRecall()
if action == "none-out" then
-- Confidently nothing out (cast something this session, all expired):
-- don't waste Totemic Recall's 6s cooldown on a no-op cast. After a
-- /reload the session flag is clear and 1.12 can't re-detect a pre-reload
-- totem, so the gate fails open there. Pressing again also overrides it,
-- for a totem we cannot see (e.g. dropped from the action bar).
ChatOut:AddMessage("TotemBar: no totems out - not recalling (saves the 6s cooldown). Press again to recall anyway.")
elseif action == "cooldown" then
ChatOut:AddMessage("TotemBar: Totemic Recall isn't ready yet - totem timers kept.")
end
end
end)
btn:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_TOP")
GameTooltip:SetText(RECALL_SPELL_NAME)
GameTooltip:AddLine("Left-click: recall now", 1, 1, 1)
local state = "OFF"
if TotemBarDB and TotemBarDB.autoRecall then
state = "ON"
end
GameTooltip:AddLine("Auto Recall Toggle (right-click): " .. state, 1, 1, 1)
local activeCost = TotemBar.sumActiveCost(TotemBar.activeTotems, TotemBar.TOTEM_ELEMENTS, GetTime(), TotemBar.getTotemManaCost, TotemBar.remaining)
local pct = (TotemBarDB and TotemBarDB.recallRefundPct) or 0.25
local refund = TotemBar.refundAmount(pct, activeCost)
if refund > 0 then
GameTooltip:AddLine("Refund: ~" .. refund .. " mana", 0.6, 0.6, 1)
end
GameTooltip:Show()
end)
btn:SetScript("OnLeave", function()
GameTooltip:Hide()
end)
recallButton = btn
RefreshRecallIndicator()
registerBindOverlay(btn, function() return "TOTEMBAR_RECALL" end)
return btn
end
-- The "drop set" button: one click casts all four chosen totems
-- (TotemBar.recallAndCastAll, with its 2s double-press guard). Placed after
-- the Recall button.
CreateDropSetButton = function(index)
local name = "TotemBarButtonDropSet"
local btn = CreateFrame("Button", name, TotemBarFrame)
btn:SetWidth(BUTTON_SIZE)
btn:SetHeight(BUTTON_SIZE)
btn:SetPoint("LEFT", TotemBarFrame, "LEFT", (index - 1) * (BUTTON_SIZE + BUTTON_GAP) + BUTTON_GAP, 0)
btn:SetBackdrop({
bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
tile = true, tileSize = 16, edgeSize = 12,
insets = { left = 3, right = 3, top = 3, bottom = 3 },
})
local icon = btn:CreateTexture(name .. "Icon", "ARTWORK")
icon:SetPoint("TOPLEFT", btn, "TOPLEFT", 3, -3)
icon:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -3, 3)
icon:SetTexture(ICONS_TEXTURE)
local dc = iconCoords[ICON_DROPSET_CELL]
icon:SetTexCoord(dc.l, dc.r, dc.t, dc.b)
btn.icon = icon
-- Grounding shadow ellipse (Rev 4 UI chrome). No glow on this button,
-- so creation order relative to other regions doesn't matter here.
local shadow = btn:CreateTexture(name .. "Shadow", "BACKGROUND")
shadow:SetTexture(UI_TEXTURE)
local sc = uiCoords[UI_SHADOW_CELL]
shadow:SetTexCoord(sc.l, sc.r, sc.t, sc.b)
shadow:SetPoint("CENTER", btn, "CENTER", 0, -15)
shadow:SetWidth(58)
shadow:SetHeight(20)
-- Softened after preview review: full-strength plate read boxy on light
-- ground; flatter + more transparent grounds without drawing attention.
shadow:SetAlpha(0.7)
btn.shadow = shadow
-- Same floating round frame as the element buttons (permanent
-- decorative band, no fill/tint - no ringFill/pulseWave on this
-- button, guarded inside ApplyRoundFrame).
local ringTrack = btn:CreateTexture(name .. "RingTrack", "OVERLAY")
ringTrack:SetPoint("CENTER", btn, "CENTER", 0, 0)
ringTrack:SetWidth(RING_TEX_SIZE)
ringTrack:SetHeight(RING_TEX_SIZE)
btn.ringTrack = ringTrack
ApplyRoundFrame(btn)
-- Round hover/pushed FX states (Rev 3), same sheet as the other
-- bar buttons.
ApplyRoundClickStates(btn, RING_TEX_SIZE)
btn:RegisterForClicks("LeftButtonUp")
btn:SetScript("OnClick", function()
TotemBar.recallAndCastAll()
end)
btn:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_TOP")
GameTooltip:SetText("Drop all totems")
GameTooltip:AddLine("Left-click: cast your whole set", 1, 1, 1)
local cost = TotemBar.sumChosenCost(TotemBarDB.chosen, TotemBar.TOTEM_ELEMENTS, TotemBar.getTotemManaCost)
if cost and cost > 0 then
GameTooltip:AddLine("Mana: " .. cost, 0.6, 0.6, 1)
end
GameTooltip:Show()
end)
btn:SetScript("OnLeave", function() GameTooltip:Hide() end)
registerBindOverlay(btn, function() return "TOTEMBAR_DROPSET" end)
dropSetButton = btn
return btn
end
-- Refreshes every element button's remaining-duration timer text.
-- HYBRID source: prefers pfUI libtotem's GetTotemInfo(slot) when
-- present and reporting the slot active (also catches totems cast
-- outside TotemBar); otherwise falls back to TotemBar's own
-- cast-tracking (TotemBar.activeTotems). Called at most ~5x/sec by
-- OnBarUpdate below, never per-frame.
UpdateTimerDisplays = function()
local now = GetTime()
local hasGTI = (type(GetTotemInfo) == "function")
local elements = TotemBar.TOTEM_ELEMENTS
local activeTotems = TotemBar.activeTotems
local outOfRangeFound = false -- OR-accumulator across this pass; written to anyOutOfRange at the end
-- Destroyed-totem liveness poll gate for this pass (see local
-- lastGuidLivenessCheck above and TotemBar.GUID_LIVENESS_POLL_INTERVAL,
-- core/cast.lua) -- computed ONCE per call, not per element, so all four
-- slots share the same ~0.5s cadence.
local doLivenessPoll = TotemBar.shouldPollLiveness(lastGuidLivenessCheck, now,
TotemBar.GUID_LIVENESS_POLL_INTERVAL)
if doLivenessPoll then
lastGuidLivenessCheck = now
end
-- Mana input for the out-of-mana dim, read ONCE per pass rather than per
-- button. (The Clearcasting buff walk that used to sit here is gone with the
-- exemption itself -- Elemental Focus does not cover totems, see
-- core/manacost.lua -- so this pass no longer scans buffs at all.)
local playerMana = (type(UnitMana) == "function") and UnitMana("player") or nil
for i = 1, table.getn(elements) do
local element = elements[i]
local btn = elementButtons[element]
if btn then
-- Own-tracking: compute remaining, evicting the record the
-- moment it expires (per-element table, no growth).
local ownRecord = activeTotems[element]
local ownRemaining = nil
if ownRecord then
ownRemaining = TotemBar.remaining(ownRecord.start, ownRecord.duration, now)
if not ownRemaining or ownRemaining <= 0 then
activeTotems[element] = nil
ownRemaining = nil
ownRecord = nil -- expired: not "active" for the range-tint check below either
end
end
-- Destroyed-totem liveness poll: only runs once a GUID was
-- latched (core/cast.lua's UNIT_CASTEVENT/UNIT_MODEL_CHANGED
-- hooks) AND this pass is due (doLivenessPoll, ~0.5s cadence).
-- On a client without SuperWoW no record ever gets a guid, so
-- this is a no-op there -- today's behaviour, unchanged. A
-- destroyed verdict clears the record the SAME way natural
-- expiry does above, so the countdown text, duration ring,
-- pulse animation and out-of-range tint below all disappear in
-- one step -- they already derive from ownRecord's presence.
if ownRecord and ownRecord.guid and doLivenessPoll then
local exists, guid = nil, nil
if type(UnitExists) == "function" then
exists, guid = UnitExists(ownRecord.guid)
end
local health, deadOrGhost = nil, nil
if exists then
if type(UnitHealth) == "function" then
health = UnitHealth(ownRecord.guid)
end
if type(UnitIsDeadOrGhost) == "function" then
deadOrGhost = UnitIsDeadOrGhost(ownRecord.guid)
end
end
if TotemBar.totemDestroyed(exists, health, deadOrGhost) then
-- Tombstones the element (see TotemBar.evictDestroyedTotem /
-- TotemBar.destroyedTombstone, core/cast.lua) so
-- resolveRemaining below stops trusting GTI for it until
-- the evicted record's own natural expiry -- otherwise
-- pfUI's libtotem (itself just another blind duration
-- timer) keeps reporting the SAME destroyed totem active
-- and the countdown/ring/pulse reappear from the GTI
-- branch a moment after being cleared here (adversarial-
-- review finding #1). Mutates TotemBar.activeTotems, the
-- SAME table `activeTotems` above already points to, so
-- ownRecord/ownRemaining are nil'd out explicitly to
-- match rather than re-reading it.
TotemBar.evictDestroyedTotem(element)
ownRemaining = nil
ownRecord = nil
end
end
-- Spawn ripple: a NEW own cast (start changed) fires one ripple.
-- The freshness check keeps restored/old records from replaying.
-- Gated on showPulseWaves (the ripple's own toggle), not
-- showPulseBars.
if TotemBarDB.showPulseWaves then
local startNow = ownRecord and ownRecord.start
if startNow and startNow ~= btn.lastStartSeen then
btn.lastStartSeen = startNow
if now - startNow < 1.5 then
btn.spawnAt = startNow
end
elseif not ownRecord then
btn.lastStartSeen = nil
end
end
-- pfUI libtotem, when present: Fire=1, Earth=2, Water=3,
-- Air=4 - i.e. exactly TotemBar.TOTEM_ELEMENTS' own order.
local gtiActive, gtiRemaining, gtiName, gtiStart, gtiDuration
if hasGTI then
local active, tname, start, duration = GetTotemInfo(i)
gtiActive = active
gtiName = tname
gtiStart = start
-- Mastery parity: libtotem reports FLAT book durations, own
-- tracking stores the Totemic Mastery-inflated one. Without
-- scaling GTI by the same factor the timer flips source at the
-- book duration and the countdown jumps back UP instead of
-- running out (see TotemBar.gtiDurationWithMastery).
gtiDuration = TotemBar.gtiDurationWithMastery(duration, tname,
TotemBar.hasTotemicMastery and TotemBar.hasTotemicMastery())
if start and gtiDuration then
gtiRemaining = TotemBar.remaining(start, gtiDuration, now)
end
-- Latch: did libtotem ever report THIS record's totem active?
-- Only then may a later "slot inactive" veto the range tint
-- below (TotemBar.rangeTintActive).
if ownRecord and gtiActive and gtiName == ownRecord.totemName then
ownRecord.gtiTracked = true
end
end
-- Destroyed-totem tombstone (adversarial-review finding #1, see
-- TotemBar.evictDestroyedTotem above and TotemBar.tombstoneActive/
-- resolveRemaining, core/cast.lua): while active, resolveRemaining
-- below must not trust GTI for this element even though hasGTI/
-- gtiActive above were computed with no knowledge of the eviction.
-- Swept once expired so the table never holds a stale entry past
-- its own usefulness (bounded to 4 keys regardless, but tidy).
local tombstoned = TotemBar.tombstoneActive(TotemBar.destroyedTombstone[element], now)
if not tombstoned and TotemBar.destroyedTombstone[element] then
TotemBar.destroyedTombstone[element] = nil
end
local remainingVal = TotemBar.resolveRemaining(gtiActive, gtiRemaining, ownRemaining, tombstoned)
if remainingVal and TotemBarDB.showTimerText then
if not btn.timerVisible then
btn.timerText:Show()
btn.timerVisible = true
end
local text = TotemBar.formatRemaining(remainingVal)
if text ~= btn.timerLastText then
btn.timerText:SetText(text)
btn.timerLastText = text
end
local isLow = remainingVal <= 5
if isLow ~= btn.timerLastLow then
if isLow then
btn.timerText:SetTextColor(1, 0.15, 0.15, 1)
else
btn.timerText:SetTextColor(1, 1, 1, 1)
end
btn.timerLastLow = isLow
end
elseif btn.timerVisible then
btn.timerText:Hide()
btn.timerVisible = false
btn.timerLastText = nil
btn.timerLastLow = nil
end
-- Duration ring: flipbook frame from remaining/total, tinted as
-- a time-remaining traffic light (green->yellow->orange->red -
-- user feedback: element color didn't convey urgency). Gated on
-- showDurationRing for the ARC only (ringFill) - ringTrack (the
-- permanent decorative frame band) is never hidden here.
if TotemBarDB.showDurationRing and remainingVal then
local totalDur = TotemBar.resolveDuration(gtiActive, gtiRemaining, gtiDuration,
ownRemaining, ownRecord and ownRecord.duration)
local idx = TotemBar.ringFrameIndex(remainingVal, totalDur, RING_FRAMES)
if idx ~= btn.ringLastIdx then
local rc = allRingCoords[idx + 1]
btn.ringFill:SetTexCoord(rc.l, rc.r, rc.t, rc.b)
btn.ringLastIdx = idx
end
local tr, tg, tb
if totalDur and totalDur > 0 then
tr, tg, tb = TotemBar.timeColor(remainingVal / totalDur)
else
tr, tg, tb = 1, 1, 1
end
btn.ringFill:SetVertexColor(tr, tg, tb)
if not btn.ringVisible then
btn.ringFill:Show()
btn.ringVisible = true
end
elseif btn.ringVisible then
btn.ringFill:Hide()
btn.ringVisible = false
btn.ringLastIdx = nil
end
-- Ripple wave + pulse arc inputs: only for totems with
-- PULSE_DATA. Phase origin: own record start (anchored by real
-- gain events where available) or libtotem's start for casts
-- made outside TotemBar. Both the wave's animation AND the
-- pulse arc's flipbook frame are driven every FRAME in
-- UpdateWaves below (moved off this 0.1s tick - a 10Hz flipbook
-- update read as choppy, see rev7 report) - this block just
-- refreshes what they should animate toward. showPulseWaves and
-- showPulseBars are independent display toggles, but the arc
-- reads the SAME waveInterval/waveOrigin/waveOneshotAt/
-- waveOneshotDelay fields as the wave, so they're populated
-- whenever EITHER toggle is on.
local waveSet = false
if remainingVal then
local totemName = (ownRecord and ownRecord.totemName) or gtiName
local pd = totemName and TotemBar.PULSE_DATA[totemName]
if pd then
local startAt = (ownRecord and ownRecord.start) or gtiStart
if startAt then
if TotemBarDB.showPulseWaves or TotemBarDB.showPulseBars then
if pd.ptype == "oneshot" then
btn.waveInterval = nil
btn.waveOneshotAt = startAt + pd.delay
btn.waveOrigin = nil
btn.waveOneshotDelay = pd.delay
else
btn.waveInterval = pd.interval
btn.waveOrigin = (ownRecord and ownRecord.pulseAnchor) or startAt
btn.waveOneshotAt = nil
btn.waveOneshotDelay = nil
end
waveSet = true
end
end
end
end
if not waveSet then
btn.waveInterval = nil
btn.waveOneshotAt = nil
btn.waveOrigin = nil
btn.waveOneshotDelay = nil
if btn.waveVisible then
btn.pulseWave:Hide()
btn.waveVisible = false
end
end
-- Pulse arc hide-on-expiry: UpdateWaves (every frame) recomputes
-- arcRatio from the fields above and will already hide it within
-- the next frame once waveInterval/waveOneshotAt go nil, but that
-- can be up to one frame stale right after this tick - not worth
-- a duplicate hide-path here (see rev7 report).
-- Out-of-range tint: buff-presence based. A totem's party
-- buff uses the SAME icon texture as the totem spell itself
-- (verified in-game), so "am I in range?" == "do I have a
-- buff whose texture matches this totem's icon?".
--
-- ACTIVE requires an own-tracking record (ownRecord, already
-- nil'd above once its stored duration expires). pfUI's libtotem
-- may veto it - that is what keeps a totem someone/something
-- DESTROYED (burned, killed) before its timer ran out from
-- flashing red - but ONLY for a slot libtotem actually tracked
-- (gtiTracked, latched above). Its single cast queue records at
-- most one totem per multi-drop, so vetoing on a merely MISSING
-- GTI record silenced the tint and the Recall pulse for most of
-- a freshly dropped set. See TotemBar.rangeTintActive.
local rangeActive = TotemBar.rangeTintActive(ownRecord ~= nil, hasGTI,
ownRecord and ownRecord.gtiTracked, gtiActive)
local rangeRed = false
if rangeActive then
if TotemBar.hasBuffWithIcon(ownRecord.icon) then
-- In range: remember it (self-learning - marks this
-- as a buff totem so a later drop-off can be told
-- apart from a totem that simply never grants one).
ownRecord.everHadBuff = true
elseif ownRecord.everHadBuff then
-- Had the buff earlier from this cast, don't have it
-- now: wandered out of the totem's range. (A non-buff
-- totem - Searing/Magma/Grounding - never sets
-- everHadBuff, so it can never turn red here.)
rangeRed = true
end
end
-- Out-of-mana dim for the totem THIS slot would cast (the chosen
-- one, not the record's - the record is a totem already standing,
-- which costs nothing to keep). Composed with the range tint in one
-- place: two independent SetVertexColor call sites would race, and
-- whichever ran last would win (see TotemBar.iconTintFor).
local chosenName = TotemBarDB.chosen and TotemBarDB.chosen[element]
local oom = false
if chosenName then
oom = TotemBar.notEnoughMana(TotemBar.getTotemManaCost(chosenName), playerMana)
end
local tr2, tg2, tb2, tintKey = TotemBar.iconTintFor(rangeRed, oom)
if tintKey ~= btn.tintKey then
btn.icon:SetVertexColor(tr2, tg2, tb2)
btn.tintKey = tintKey
end
btn.tintRed = rangeRed
if btn.tintRed then
outOfRangeFound = true
end
end
end
anyOutOfRange = outOfRangeFound
end
-- Re-applies every Pulse-UI setting to the live buttons (options panel
-- setters call this; cheap, not on any hot path).
function TotemBar.RefreshPulseUI()
local elements = TotemBar.TOTEM_ELEMENTS
for i = 1, table.getn(elements) do
local btn = elementButtons[elements[i]]
if btn then
ApplyRoundFrame(btn)
ApplyTimerAnchor(btn)
if not TotemBarDB.showDurationRing and btn.ringVisible then
btn.ringFill:Hide()
btn.ringVisible = false
btn.ringLastIdx = nil
end
if not TotemBarDB.showPulseWaves then
btn.waveInterval = nil
btn.waveOneshotAt = nil
btn.waveOrigin = nil
btn.waveOneshotDelay = nil
btn.spawnAt = nil
if btn.waveVisible then
btn.pulseWave:Hide()
btn.waveVisible = false
end
if btn.glow then
btn.glow:Hide()
btn.glowVisible = false
end
end
if not TotemBarDB.showPulseBars and btn.pulseVisible then
btn.pulseArc:Hide()
btn.pulseVisible = false
btn.pulseArcLastIdx = nil
end
if not TotemBarDB.showTimerText and btn.timerVisible then
btn.timerText:Hide()
btn.timerVisible = false
btn.timerLastText = nil
btn.timerLastLow = nil
end
end
end
-- Recall/DropSet share ApplyRoundFrame with the element buttons
-- (harmless re-apply of their static ring-frame texture; kept for
-- symmetry with the element-button refresh above). Flyout icons don't
-- go through ApplyRoundFrame (see EnsureFlyoutFrame) but their ring
-- frame texture is likewise static now, set once at creation - no
-- per-refresh work needed for them.
if recallButton then
ApplyRoundFrame(recallButton)
end
if dropSetButton then
ApplyRoundFrame(dropSetButton)
end
end
-- Dev aid, one line of live WoW-object state for a single ring texture
-- (ringFill, ringTrack, the icon, or timerText), called by
-- TotemBar.DumpRingRenderState below. Every getter is pcall-wrapped
-- individually ("?" on failure/absence) - this must never itself error,
-- not even on a stale/nil texture object.
-- GetTexCoord's return arity is UNCONFIRMED on this 1.12 client (4-value
-- left/right/top/bottom vs the 8-value corner form retail uses) - its
-- result is captured via a table constructor (`{ pcall(...) }`) instead
-- of assuming a fixed number of return values, so the dump itself is the
-- in-game measurement that settles the question.
-- rect=[...]/layer= added alongside (2026-07-16, occlusion follow-up): the
-- screen-space GetLeft/Right/Top/Bottom quad and GetDrawLayer, so a stale
-- anchor (ringFill not actually over the button) or a same-layer sibling
-- collision (1.12 discards SetDrawLayer sublevels - same main layer means
-- undefined draw order between siblings) show up directly in the dump
-- instead of needing to be inferred from shown/alpha/texCoord alone.
local function DumpRingTextureLine(element, label, tex)
if not tex then
return " " .. element .. " " .. label .. ": (nil texture object)\n"
end
local shownOk, shown = pcall(tex.IsShown, tex)
local texOk, texPath = pcall(tex.GetTexture, tex)
local alphaOk, alpha = pcall(tex.GetAlpha, tex)
local wOk, w = pcall(tex.GetWidth, tex)
local hOk, h = pcall(tex.GetHeight, tex)
local leftOk, left = pcall(tex.GetLeft, tex)
local rightOk, right = pcall(tex.GetRight, tex)
local topOk, top = pcall(tex.GetTop, tex)
local bottomOk, bottom = pcall(tex.GetBottom, tex)
local layerOk, layer = pcall(tex.GetDrawLayer, tex)
local parentShown, parentAlpha = "?", "?"
local parentOk, parent = pcall(tex.GetParent, tex)
if parentOk and parent then
local pShownOk, pShown = pcall(parent.IsShown, parent)
local pAlphaOk, pAlpha = pcall(parent.GetAlpha, parent)
if pShownOk then parentShown = tostring(pShown) end
if pAlphaOk then parentAlpha = tostring(pAlpha) end
end
local coordParts = { pcall(tex.GetTexCoord, tex) }
local coordStr = "?"
if coordParts[1] then
local n = table.getn(coordParts)
local s = ""
for ci = 2, n do
if s ~= "" then s = s .. "," end
s = s .. tostring(coordParts[ci])
end
coordStr = "[" .. s .. "]"
end
return " " .. element .. " " .. label .. ": shown=" .. (shownOk and tostring(shown) or "?")
.. " texture='" .. (texOk and tostring(texPath) or "?") .. "'"
.. " alpha=" .. (alphaOk and tostring(alpha) or "?")
.. " texCoord=" .. coordStr
.. " w=" .. (wOk and tostring(w) or "?")
.. " h=" .. (hOk and tostring(h) or "?")
.. " parentShown=" .. parentShown
.. " parentAlpha=" .. parentAlpha
.. " rect=[" .. (leftOk and tostring(left) or "?") .. "," .. (topOk and tostring(top) or "?")
.. "," .. (rightOk and tostring(right) or "?") .. "," .. (bottomOk and tostring(bottom) or "?") .. "]"
.. " layer=" .. (layerOk and tostring(layer) or "?") .. "\n"
end
-- Dev aid, one line per region returned by a button's own :GetRegions()
-- (2026-07-16, occlusion follow-up). GetRegions() is Blizzard's own
-- z-order-relevant enumeration of the regions directly parented to a
-- frame - within a shared main draw layer (see the sublevel-discard note
-- above) it's the best available proxy for actual sibling paint order, so
-- walking it WITH the index is how we tell "ringTrack drawn 2nd, after
-- ringFill" apart from "ringTrack drawn 1st, before ringFill" from outside
-- the client. Deliberately only ever called on a single small element
-- button (~8 regions), never on a big container frame - a wide frame can
-- carry hundreds of regions and walking all of them was the cause of an
-- earlier crash, hence the hard cap below regardless of how many the
-- client reports.
local REGION_WALK_CAP = 24
local function DumpRegionLine(idx, region)
if not region then
return " [" .. idx .. "] (nil region)\n"
end
local typeOk, objType = pcall(region.GetObjectType, region)
local nameOk, name = pcall(region.GetName, region)
local layerOk, layer = pcall(region.GetDrawLayer, region)
local shownOk, shown = pcall(region.IsShown, region)
local leftOk, left = pcall(region.GetLeft, region)
local topOk, top = pcall(region.GetTop, region)
-- Texture and FontString expose different "what is this" getters
-- (GetTexture vs GetText); try both, pcall-guarded, and use whichever
-- succeeds - a region of the other kind simply fails the wrong one.
local content = "?"
local texOk, texPath = pcall(region.GetTexture, region)
if texOk and texPath ~= nil then
content = "texture='" .. tostring(texPath) .. "'"
else
local textOk, text = pcall(region.GetText, region)
if textOk and text ~= nil then
content = "text='" .. tostring(text) .. "'"
end
end
return " [" .. idx .. "] name=" .. (nameOk and tostring(name) or "?")
.. " type=" .. (typeOk and tostring(objType) or "?")
.. " layer=" .. (layerOk and tostring(layer) or "?")
.. " shown=" .. (shownOk and tostring(shown) or "?")
.. " " .. content
.. " left=" .. (leftOk and tostring(left) or "?")
.. " top=" .. (topOk and tostring(top) or "?") .. "\n"
end
-- Dev aid, called from TotemBar.DumpTimerState (core/cast.lua)'s /tb tdump
-- so the export stays a single file/single command. Purpose: the pure
-- ring math (resolveRemaining/resolveDuration/ringFrameIndex) was already
-- proven correct offline for the Searing-ring report (text shows, no
-- ring), so this section captures what DumpTimerState's own-tracking/GTI
-- dump can't: the RENDER side. Per element button: (a) which button
-- instance is bound to the element, (b) the cached ring state this same
-- render path reads/writes (btn.ringVisible/btn.ringLastIdx - the exact
-- fields UpdateTimerDisplays above uses, not a guess), (c) a same-tick
-- recompute of resolveDuration/ringFrameIndex/timeColor with the SAME
-- inputs the render path would use right now, (d) the countdown
-- FontString's own live state as a control group (it's the one the player
-- actually sees), and (e) the LIVE WoW state of both ring textures (ringFill, the
-- gated arc in question, and ringTrack, the always-on decorative band -
-- if ringTrack is ALSO dark then the whole assembly, not just the fill,
-- is hidden/mispositioned upstream of this addon's own Show/Hide calls).
function TotemBar.DumpRingRenderState()
local now = GetTime()
local hasGTI = (type(GetTotemInfo) == "function")
local elements = TotemBar.TOTEM_ELEMENTS
local activeTotems = TotemBar.activeTotems
local out = "\n-- render state (per-element ring + text, live WoW objects) --\n"
for i = 1, table.getn(elements) do
local element = elements[i]
local btn = elementButtons[element]
if not btn then
out = out .. element .. ": (no button)\n"
else
local btnName = "?"
local okName, nameVal = pcall(btn.GetName, btn)
if okName and nameVal then
btnName = nameVal
end
-- Same recompute inputs/precedence as UpdateTimerDisplays
-- above, same tick (i.e. what the render path would compute
-- if it ran again right now).
local ownRecord = activeTotems[element]
local ownRemaining = nil
if ownRecord then
ownRemaining = TotemBar.remaining(ownRecord.start, ownRecord.duration, now)
end
local gtiActive, gtiRemaining, gtiDuration
if hasGTI then
local ok, active, tname, start, duration = pcall(GetTotemInfo, i)
if ok then
-- Same mastery scaling UpdateTimerDisplays applies, so this
-- dump keeps reporting what the render path would compute.
gtiActive = active
gtiDuration = TotemBar.gtiDurationWithMastery(duration, tname,
TotemBar.hasTotemicMastery and TotemBar.hasTotemicMastery())
if start and gtiDuration then
gtiRemaining = TotemBar.remaining(start, gtiDuration, now)
end
end
end
-- Same tombstone read UpdateTimerDisplays' live pass does (see
-- its own comment) -- read-only here, the live pass owns
-- sweeping expired entries.
local tombstoned = TotemBar.tombstoneActive(TotemBar.destroyedTombstone[element], now)
local remainingVal = TotemBar.resolveRemaining(gtiActive, gtiRemaining, ownRemaining, tombstoned)
local totalDur = TotemBar.resolveDuration(gtiActive, gtiRemaining, gtiDuration,
ownRemaining, ownRecord and ownRecord.duration)
local idxStr = "n/a"
if remainingVal then
idxStr = tostring(TotemBar.ringFrameIndex(remainingVal, totalDur, RING_FRAMES))
end
local colorStr = "n/a"
if totalDur and totalDur > 0 and remainingVal then
local okC, cr, cg, cb = pcall(TotemBar.timeColor, remainingVal / totalDur)
if okC then
colorStr = "(" .. tostring(cr) .. "," .. tostring(cg) .. "," .. tostring(cb) .. ")"
end
end
out = out .. element .. ": btn=" .. btnName
.. " ringVisible=" .. tostring(btn.ringVisible)
.. " ringLastIdx=" .. tostring(btn.ringLastIdx)
.. " | recompute: remainingVal=" .. tostring(remainingVal)
.. " totalDur=" .. tostring(totalDur)
.. " ringIdx=" .. idxStr
.. " timeColor=" .. colorStr
.. " tombstoned=" .. tostring(tombstoned) .. "\n"
-- Control group: the countdown text the player actually sees, so a
-- mismatch (text shown, ring both cached+live hidden) pins the
-- bug to the ring specifically, not a shared render gate.
-- Guarded on btn.timerText itself (not just pcall on its
-- methods): indexing .IsShown/.GetText on a nil timerText
-- would error while pcall's OWN arguments are being evaluated,
-- i.e. outside pcall's protection - shouldn't happen (created
-- unconditionally in CreateElementButton) but this dump must
-- never be the thing that errors.
local tShown, tText = "?", "?"
local tLeft, tTop, tRight, tBottom, tLayer = "?", "?", "?", "?", "?"
if btn.timerText then
local tShownOk, tShownVal = pcall(btn.timerText.IsShown, btn.timerText)
local tTextOk, tTextVal = pcall(btn.timerText.GetText, btn.timerText)
local tLeftOk, tLeftVal = pcall(btn.timerText.GetLeft, btn.timerText)
local tRightOk, tRightVal = pcall(btn.timerText.GetRight, btn.timerText)
local tTopOk, tTopVal = pcall(btn.timerText.GetTop, btn.timerText)
local tBottomOk, tBottomVal = pcall(btn.timerText.GetBottom, btn.timerText)
local tLayerOk, tLayerVal = pcall(btn.timerText.GetDrawLayer, btn.timerText)
if tShownOk then tShown = tostring(tShownVal) end
if tTextOk then tText = tostring(tTextVal) end
if tLeftOk then tLeft = tostring(tLeftVal) end
if tRightOk then tRight = tostring(tRightVal) end
if tTopOk then tTop = tostring(tTopVal) end
if tBottomOk then tBottom = tostring(tBottomVal) end
if tLayerOk then tLayer = tostring(tLayerVal) end
end
out = out .. " " .. element .. " timerText: shown=" .. tShown
.. " text='" .. tText .. "'"
.. " rect=[" .. tLeft .. "," .. tTop .. "," .. tRight .. "," .. tBottom .. "]"
.. " layer=" .. tLayer .. "\n"
out = out .. DumpRingTextureLine(element, "ringFill", btn.ringFill)
out = out .. DumpRingTextureLine(element, "ringTrack", btn.ringTrack)
out = out .. DumpRingTextureLine(element, "icon", btn.icon)
-- Region walk (2026-07-16, occlusion follow-up): btn is a
-- small element button (icon/shadow/ringTrack/glow/timerText,
-- ~5 regions now that ringFill/pulseWave/pulseArc moved off
-- btn - see below) - safe to enumerate in full, unlike a big
-- container frame. Index order is GetRegions()'s own return
-- order, our best proxy for actual same-layer sibling paint
-- order (see DumpRegionLine above).
local regionParts = { pcall(btn.GetRegions, btn) }
if regionParts[1] then
local total = table.getn(regionParts) - 1
local capped = total
if capped > REGION_WALK_CAP then capped = REGION_WALK_CAP end
out = out .. " " .. element .. " regions (count=" .. total .. ", showing " .. capped .. "):\n"
for ri = 1, capped do
out = out .. DumpRegionLine(ri, regionParts[ri + 1])
end
else
out = out .. " " .. element .. " regions: (GetRegions failed)\n"
end
-- Second region walk (2026-07-16, ring-overlay fix): ringFill/
-- pulseWave/pulseArc now live on btn.ringOverlay, a separate
-- higher-FrameLevel child frame (see CreateRingOverlay) - they
-- no longer show up in btn:GetRegions() above at all, so the
-- dump would silently go blind on exactly the textures this
-- tool exists to inspect without this second walk. Same small
-- frame (~3 regions), same cap.
if btn.ringOverlay then
local overlayParts = { pcall(btn.ringOverlay.GetRegions, btn.ringOverlay) }
if overlayParts[1] then
local oTotal = table.getn(overlayParts) - 1
local oCapped = oTotal
if oCapped > REGION_WALK_CAP then oCapped = REGION_WALK_CAP end
out = out .. " " .. element .. " ringOverlay regions (count=" .. oTotal .. ", showing " .. oCapped .. "):\n"
for ri = 1, oCapped do
out = out .. DumpRegionLine(ri, overlayParts[ri + 1])
end
else
out = out .. " " .. element .. " ringOverlay regions: (GetRegions failed)\n"
end
else
out = out .. " " .. element .. " ringOverlay: (nil)\n"
end
end
end
return out
end
-- Ripple animation + pulse-arc flipbook: expanding/fading ring in the
-- element color (one wave per pulse) and the circular countdown arc, both
-- driven every FRAME (pure float math + Set calls, no allocation, same
-- class as OnRecallUpdate - the arc joined the wave here in rev7, was a
-- 0.1s-tick flipbook update before and read as choppy). Inputs are
-- refreshed by the 0.1s tick in UpdateTimerDisplays above. Wave/glow are
-- gated on showPulseWaves, the arc on showPulseBars - independent toggles
-- that happen to share the same waveInterval/waveOrigin/waveOneshotAt/
-- waveOneshotDelay fields (see the tick's comment).
UpdateWaves = function()
local now = GetTime()
local elements = TotemBar.TOTEM_ELEMENTS
for i = 1, table.getn(elements) do
local btn = elementButtons[elements[i]]
if btn then
if TotemBarDB.showPulseWaves then
local frac = nil
if btn.spawnAt then
frac = TotemBar.oneshotWaveFrac(btn.spawnAt, 0, now, SPAWN_WAVE_DURATION)
if not frac then
btn.spawnAt = nil
end
end
if not frac then
if btn.waveInterval then
local ratio = TotemBar.pulseRatio(btn.waveOrigin, nil, btn.waveInterval, now)
frac = TotemBar.waveFrac(ratio, btn.waveInterval, WAVE_DURATION)
elseif btn.waveOneshotAt then
frac = TotemBar.oneshotWaveFrac(btn.waveOneshotAt, 0, now, WAVE_DURATION)
end
end
if frac then
local size = RING_TEX_SIZE * (1 + (WAVE_MAX_SCALE - 1) * frac)
btn.pulseWave:SetWidth(size)
btn.pulseWave:SetHeight(size)
btn.pulseWave:SetAlpha(WAVE_BASE_ALPHA * (1 - frac))
if not btn.waveVisible then
btn.pulseWave:Show()
btn.waveVisible = true
end
elseif btn.waveVisible then
btn.pulseWave:Hide()
btn.waveVisible = false
end
-- Anticipation glow: ramps up as the next pulse (or the
-- oneshot detonation) approaches; hidden otherwise. Tied to
-- the wave's own toggle (showPulseWaves), unchanged.
local ga = nil
if btn.waveInterval then
local gratio = TotemBar.pulseRatio(btn.waveOrigin, nil, btn.waveInterval, now)
ga = TotemBar.glowRamp(gratio, GLOW_RAMP_START)
elseif btn.waveOneshotAt and btn.waveOneshotDelay and now < btn.waveOneshotAt then
ga = TotemBar.glowRamp(1 - ((btn.waveOneshotAt - now) / btn.waveOneshotDelay), GLOW_RAMP_START)
end
if ga then
btn.glow:SetAlpha(GLOW_MAX_ALPHA * ga)
if not btn.glowVisible then
btn.glow:Show()
btn.glowVisible = true
end
elseif btn.glowVisible then
btn.glow:Hide()
btn.glowVisible = false
end
elseif btn.waveVisible or btn.glowVisible then
-- Toggled off (RefreshPulseUI normally already clears these
-- immediately; this is the per-frame safety net).
if btn.waveVisible then
btn.pulseWave:Hide()
btn.waveVisible = false
end
if btn.glowVisible then
btn.glow:Hide()
btn.glowVisible = false
end
end
-- Pulse arc: continuous countdown readout toward the next
-- pulse/detonation (own toggle: showPulseBars, independent of
-- the wave/glow above). A duplicate pulseRatio()/oneshotRatio()
-- call vs. the wave block above is cheap and keeps this block
-- self-contained (see rev7 report).
local arcRatio = nil
if TotemBarDB.showPulseBars then
if btn.waveInterval then
arcRatio = TotemBar.pulseRatio(btn.waveOrigin, nil, btn.waveInterval, now)
elseif btn.waveOneshotAt and btn.waveOneshotDelay then
arcRatio = TotemBar.oneshotRatio(btn.waveOneshotAt - btn.waveOneshotDelay, btn.waveOneshotDelay, now)
if arcRatio and arcRatio >= 1 then arcRatio = nil end
end
end
if arcRatio then
local idx = math.floor(arcRatio * (RING_FRAMES - 1) + 0.5)
if idx ~= btn.pulseArcLastIdx then
local rc = allRingCoords[idx + 1]
btn.pulseArc:SetTexCoord(rc.l, rc.r, rc.t, rc.b)
btn.pulseArcLastIdx = idx
end
if not btn.pulseVisible then
btn.pulseArc:Show()
btn.pulseVisible = true
end
elseif btn.pulseVisible then
btn.pulseArc:Hide()
btn.pulseVisible = false
btn.pulseArcLastIdx = nil
end
end
end
end
OnBarUpdate = function()
UpdateWaves()
timerElapsed = timerElapsed + arg1
if timerElapsed < TIMER_UPDATE_INTERVAL then
return
end
timerElapsed = 0
UpdateTimerDisplays()
end
OnDragStart = function()
if not TotemBarDB.locked then
this:StartMoving()
end
end
OnDragStop = function()
this:StopMovingOrSizing()
local point, _, relPoint, x, y = this:GetPoint()
TotemBarDB.point = point
TotemBarDB.relPoint = relPoint
TotemBarDB.x = x
TotemBarDB.y = y
end
-- (Re)applies the current BUTTON_GAP to the pending-assignment panel: frame
-- width (4 icons + gaps + the fixed accept-button margin) and each icon's
-- horizontal anchor - both baked in once when the panel is first built, so
-- (like layoutFlyoutIcons above) this must be called again from
-- TotemBar.SetButtonGap when the spacing slider changes live, not just at
-- build time. `frame` lets the build-time call pass the not-yet-assigned
-- local `f` directly; later calls omit it and fall back to the assignFrame
-- upvalue (guarded: a no-op if the panel has never been built yet).
local function layoutAssignIcons(frame)
local f = frame or assignFrame
if not f then
return
end
f:SetWidth(4 * (BUTTON_SIZE + BUTTON_GAP) + BUTTON_GAP + 40)
for i = 1, 4 do
local ico = f.icons and f.icons[i]
if ico then
ico:ClearAllPoints()
ico:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT",
BUTTON_GAP + (i - 1) * (BUTTON_SIZE + BUTTON_GAP), 6)
end
end
end
-- Lazily builds the pending-suggestion panel: a heading label, a row of up
-- to 4 element-ordered totem icons, an Accept button, and a close "X".
-- Built once, reused; event-driven show/hide (no OnUpdate, no per-frame
-- allocation). Anchored above the bar.
EnsureAssignFrame = function()
if assignFrame then
return assignFrame
end
-- Parent to the bar so the pending panel inherits the bar's scale
-- (UI-size slider), like the flyout. Still DIALOG strata below. Width
-- is set below by layoutAssignIcons (once f.icons exists), not here.
local f = CreateFrame("Frame", "TotemBarAssignFrame", TotemBarFrame)
f:SetFrameStrata("DIALOG")
f:SetHeight(BUTTON_SIZE + 34)
-- Rev 4 UI chrome: shared panel skin (bespoke border/bg) instead of the
-- generic tooltip textures. The bg texture carries the color now, so
-- the tint just needs to be near-opaque white.
f:SetBackdrop(PANEL_BACKDROP)
f:SetBackdropColor(1, 1, 1, 0.97)
f:SetClampedToScreen(true)
f:ClearAllPoints()
f:SetPoint("BOTTOM", TotemBarFrame, "TOP", 0, 6)
local heading = f:CreateFontString("TotemBarAssignHeading", "OVERLAY")
heading:SetFont("Fonts\\FRIZQT__.TTF", 11, "OUTLINE")
heading:SetPoint("TOP", f, "TOP", 0, -6)
heading:SetText("Assigned set")
f.heading = heading
f.icons = {}
for i = 1, 4 do
local ico = f:CreateTexture("TotemBarAssignIcon" .. i, "ARTWORK")
ico:SetWidth(BUTTON_SIZE)
ico:SetHeight(BUTTON_SIZE)
-- Anchor applied below via layoutAssignIcons (once the whole row
-- exists), not here - the same helper re-applies it live when
-- BUTTON_GAP changes.
ico:SetTexCoord(0.08, 0.92, 0.08, 0.92)
f.icons[i] = ico
end
-- Sets the frame width and every icon's anchor per BUTTON_GAP now that
-- the whole row exists (assignFrame isn't assigned yet at this point in
-- the function, hence passing `f` explicitly - see layoutAssignIcons's
-- own comment).
layoutAssignIcons(f)
local accept = CreateFrame("Button", "TotemBarAssignAccept", f, "UIPanelButtonTemplate")
accept:SetWidth(28)
accept:SetHeight(BUTTON_SIZE)
accept:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -6, 6)
accept:SetText("OK")
accept:SetScript("OnClick", function()
TotemBar.ApplyPending()
end)
accept:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:SetText("Apply assigned set")
GameTooltip:AddLine("Sets these as your chosen totems (no cast).", 1, 1, 1)
GameTooltip:Show()
end)
accept:SetScript("OnLeave", function() GameTooltip:Hide() end)
f.accept = accept
local close = CreateFrame("Button", "TotemBarAssignClose", f, "UIPanelCloseButton")
close:SetWidth(22)
close:SetHeight(22)
close:SetPoint("TOPRIGHT", f, "TOPRIGHT", -2, -2)
close:SetScript("OnClick", function()
TotemBar.ClearAssignment()
end)
f.close = close
f:Hide()
assignFrame = f
return f
end
-- Populates and shows the panel from TotemBar.pending. Unknown totems are
-- greyed (desaturated + dimmed). Called by ReceiveAssignment via the hook.
TotemBar.ShowAssignPanel = function()
local p = TotemBar.pending
if not p then
return
end
local f = EnsureAssignFrame()
f.heading:SetText(p.label or "Assigned set")
local elements = TotemBar.TOTEM_ELEMENTS
for i = 1, 4 do
local element = elements[i]
local name = element and p.set[element]
local ico = f.icons[i]
if name then
local tex, known = ResolveTotemIcon(name)
ico:SetTexture(tex)
if known then
ico:SetVertexColor(1, 1, 1)
ico:SetAlpha(1)
else
ico:SetVertexColor(0.5, 0.5, 0.5)
ico:SetAlpha(0.5)
end
ico:Show()
else
ico:Hide()
end
end
f:Show()
end
-- Hides the panel (called by ClearAssignment / ApplyPending via the hook).
TotemBar.HideAssignPanel = function()
if assignFrame then
assignFrame:Hide()
end
end
-- Repositions the bar buttons (up to six) into the grid configured by
-- TotemBarDB.barLayout and resizes the bar frame to match. Buttons are
-- ordered: the four element buttons (TOTEM_ELEMENTS order), then Recall,
-- then DropSet. The DropSet button is optional (TotemBarDB.showDropSetButton,
-- options panel "Show drop-all button"): when off it is left out of the grid
-- AND hidden, which drops the count to 5 - it is always the LAST slot in
-- every layout table, so slots 1..5 stay exactly where they were and the
-- 2x2 element block never breaks. Called once at the end of BuildUI (after
-- every button exists), from options.lua's layout cycle button, and from
-- that checkbox's setter. No-op before BuildUI has run (TotemBarFrame
-- doesn't exist yet).
TotemBar.ApplyBarLayout = function()
if not TotemBarFrame then
return
end
-- nil-safe read: an old SavedVariables file (or a call before
-- ensureDefaults) means "show", matching the default.
local showDrop = TotemBarDB.showDropSetButton
if showDrop == nil then
showDrop = true
end
local buttons = {}
local elements = TotemBar.TOTEM_ELEMENTS
for i = 1, table.getn(elements) do
tinsert(buttons, elementButtons[elements[i]])
end
tinsert(buttons, recallButton)
if showDrop then
tinsert(buttons, dropSetButton)
end
-- The button frame itself always exists (BuildUI creates it
-- unconditionally, so the toggle needs no UI rebuild and the bind
-- overlay stays registered) - only its visibility follows the option.
-- The TOTEMBAR_DROPSET keybinding keeps working either way: it is bound
-- to the cast function, not to this frame.
if dropSetButton then
if showDrop then
dropSetButton:Show()
else
dropSetButton:Hide()
end
end
local count = table.getn(buttons)
local layout = TotemBarDB.barLayout
-- Explicit per-button {col, row} slots (core/optionslogic.lua): the 2x3
-- layout keeps the four elements as a 2x2 block in the first two columns
-- with Recall/DropSet as the third column, which no row-major fill order
-- produces - hence a positions table per layout instead of index math.
local positions = TotemBar.BAR_LAYOUT_POSITIONS[layout] or TotemBar.BAR_LAYOUT_POSITIONS["1x6"]
local cols = BAR_LAYOUT_COLS[layout] or 6
local extra = 0
for i = 1, count do
local btn = buttons[i]
local pos = positions[i]
if btn and pos then
btn:ClearAllPoints()
btn:SetPoint("TOPLEFT", TotemBarFrame, "TOPLEFT",
BUTTON_GAP + pos[1] * (BUTTON_SIZE + BUTTON_GAP),
-(BUTTON_GAP + pos[2] * (BUTTON_SIZE + BUTTON_GAP + extra)))
end
end
local width, height = TotemBar.barDimensions(count, cols, BUTTON_SIZE, BUTTON_GAP, extra)
TotemBarFrame:SetWidth(width)
TotemBarFrame:SetHeight(height)
end
function TotemBar.BuildUI()
if TotemBarFrame then
return
end
local numElements = table.getn(TotemBar.TOTEM_ELEMENTS)
-- Placeholder size: TotemBar.ApplyBarLayout() (called below, once every
-- button exists) positions each button per the configured grid and
-- resizes the frame for real - this initial size is never seen on screen.
local frame = CreateFrame("Frame", "TotemBarFrame", UIParent)
frame:SetWidth(BUTTON_SIZE + BUTTON_GAP * 2)
frame:SetHeight(BUTTON_SIZE + BUTTON_GAP * 2)
frame:SetBackdrop({
bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
tile = true, tileSize = 16, edgeSize = 16,
insets = { left = 4, right = 4, top = 4, bottom = 4 },
})
-- Floating icons: the bar's own backdrop is fully transparent (an
-- invisible drag/hit-test area only - size/anchors unchanged).
frame:SetBackdropColor(0, 0, 0, 0)
frame:SetBackdropBorderColor(0, 0, 0, 0)
frame:SetScale(TotemBarDB.scale or 1.0)
frame:SetMovable(true)
frame:EnableMouse(true)
frame:RegisterForDrag("LeftButton")
frame:SetScript("OnDragStart", OnDragStart)
frame:SetScript("OnDragStop", OnDragStop)
frame:ClearAllPoints()
frame:SetPoint(TotemBarDB.point, UIParent, TotemBarDB.relPoint, TotemBarDB.x, TotemBarDB.y)
for i = 1, numElements do
local element = TotemBar.TOTEM_ELEMENTS[i]
CreateElementButton(element, i)
RefreshCooldown(element) -- initial swipe state for whatever's already chosen
end
CreateRecallButton(numElements + 1)
CreateDropSetButton(numElements + 2)
-- Positions every button per TotemBarDB.barLayout and gives the frame
-- its real size (see ApplyBarLayout above and the placeholder-size
-- note on the CreateFrame call).
TotemBar.ApplyBarLayout()
frame:SetScript("OnUpdate", OnBarUpdate)
frame:Show()
if TotemBarDB.hidden then
frame:Hide()
end
-- Show any already-bound key labels right after login (overlays are
-- always visible now, not gated on bind mode - see refreshBindOverlays).
TotemBar.refreshBindOverlays()
end
-- Scales the bar while keeping its TOP-LEFT corner visually fixed, so the
-- bar grows/shrinks toward the bottom-right instead of drifting away from
-- where the player put it. SetScale scales the anchor offset too, so a naive
-- SetScale moves the frame; here we capture the top-left's ABSOLUTE screen
-- position (local coord * effective scale = pixels), apply the new scale,
-- then re-anchor TOPLEFT so those pixels are preserved. Persists the new
-- scale + anchor so the next login reproduces it.
function TotemBar.SetBarScale(newScale)
local f = TotemBarFrame
if not f then
return
end
if not newScale or newScale <= 0 then
newScale = 1
end
local before = f:GetEffectiveScale()
local left = f:GetLeft()
local top = f:GetTop()
f:SetScale(newScale)
if left and top and before then
local leftPx = left * before
local topPx = top * before
local after = f:GetEffectiveScale()
f:ClearAllPoints()
f:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", leftPx / after, topPx / after)
end
if TotemBarDB then
TotemBarDB.scale = newScale
local p, _, rp, x, y = f:GetPoint()
if p then
TotemBarDB.point = p
TotemBarDB.relPoint = rp
TotemBarDB.x = x
TotemBarDB.y = y
end
end
end
-- Live-applies a new button gap (px, range 10-30): reassigns the shared
-- BUTTON_GAP upvalue (every closure that reads it - element/flyout/assign-
-- panel positioning, ApplyBarLayout - sees the new value on its next read,
-- no re-declaration needed) and persists it. Two icon pools bake their
-- BUTTON_GAP-dependent anchors in once at build time instead of recomputing
-- them on every read (unlike ApplyBarLayout, which already re-reads
-- BUTTON_GAP fresh every call and just needs re-running): the flyout icon
-- pool and the pending-assignment panel, both re-laid-out here via their
-- own helpers (layoutFlyoutIcons/layoutAssignIcons above), each a no-op if
-- that particular frame was never built yet. Driven by the options panel's
-- "Button spacing" slider.
function TotemBar.SetButtonGap(newGap)
if not newGap then
newGap = TotemBar.DEFAULT_BUTTON_GAP or 10
end
newGap = TotemBar.clampValue(newGap, 10, 30)
BUTTON_GAP = newGap
if TotemBarDB then
TotemBarDB.buttonGap = newGap
end
layoutFlyoutIcons()
layoutAssignIcons()
if TotemBar.ApplyBarLayout then
TotemBar.ApplyBarLayout()
end
end
-- Shows/hides the bar and persists the choice (TotemBarDB.hidden). Driven
-- by the options panel's "Show bar" checkbox, the minimap right-click, and
-- the bare /tb command.
function TotemBar.ToggleBar()
if not TotemBarFrame then
return
end
if TotemBarFrame:IsShown() then
TotemBarFrame:Hide()
TotemBarDB.hidden = true
else
TotemBarFrame:Show()
TotemBarDB.hidden = false
end
end
-- Resets the bar's saved anchor to centered defaults and re-anchors it.
-- Does NOT change scale. Driven by the options panel's "Reset position".
function TotemBar.ResetPosition()
TotemBarDB.point = "CENTER"
TotemBarDB.relPoint = "CENTER"
TotemBarDB.x = 0
TotemBarDB.y = 0
if TotemBarFrame then
TotemBarFrame:ClearAllPoints()
TotemBarFrame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
end
end
-- "/tb scan" dev-aid: one-shot print of every spellbook entry that looks
-- like a totem, so the static element map in core/totemdata.lua can be
-- checked against the live TurtleWoW client. Also writes the list to
-- <WoW>\imports\totembar_scan.txt via SuperWoW's ExportFile (when
-- present) so it can be read off-client without pasting from chat.
function TotemBar.PrintScan()
local names = TotemBar.scanSpellbook()
local count = 0
local dump = ""
ChatOut:AddMessage("TotemBar: scanning spellbook for totems...")
for i = 1, table.getn(names) do
if string.find(names[i], "Totem", 1, true) then
ChatOut:AddMessage(" " .. names[i])
dump = dump .. names[i] .. "\n"
count = count + 1
end
end
ChatOut:AddMessage("TotemBar: " .. count .. " totem spell(s) found.")
-- ExportFile appends .txt itself; pass the name WITHOUT extension.
if ExportFile then
ExportFile("totembar_scan", "totems=" .. count .. "\n" .. dump)
end
end
local function HandleSlashCommand(msg)
local cmd = string.lower(msg or "")
if cmd == "" then
TotemBar.ToggleBar()
elseif cmd == "lock" then
TotemBarDB.locked = not TotemBarDB.locked
if TotemBarDB.locked then
ChatOut:AddMessage("TotemBar: bar locked.")
else
ChatOut:AddMessage("TotemBar: bar unlocked (drag to move).")
end
elseif cmd == "scan" then
TotemBar.PrintScan()
elseif cmd == "assign" then
-- Dev aid: inject a sample assignment to exercise the pending panel
-- + accept/decline flow end-to-end without any transport.
local sample = {
Fire = "Searing Totem",
Earth = "Strength of Earth Totem",
Water = "Mana Spring Totem",
Air = "Grace of Air Totem",
}
local ok, reason = TotemBar.ReceiveAssignment(sample, "TEST assignment")
if ok then
ChatOut:AddMessage("TotemBar: injected TEST assignment (click OK on the panel to apply).")
else
ChatOut:AddMessage("TotemBar: assign failed - " .. tostring(reason))
end
elseif cmd == "options" or cmd == "opt" then
TotemBar.ToggleOptions()
elseif cmd == "bind" then
if TotemBar.ToggleBindMode then TotemBar.ToggleBindMode() end
elseif cmd == "manadump" then
if TotemBar.dumpManaScan then TotemBar.dumpManaScan() end
elseif cmd == "tdump" then
if TotemBar.DumpTimerState then TotemBar.DumpTimerState() end
elseif string.find(cmd, "^pulsecal") then
local _, _, sub = string.find(cmd, "^pulsecal%s*(%a*)")
if TotemBar.PulseCal then TotemBar.PulseCal(sub or "") end
elseif string.find(cmd, "^tprobe") then
local _, _, sub = string.find(cmd, "^tprobe%s*(%a*)")
if TotemBar.TProbe then TotemBar.TProbe(sub or "") end
else
ChatOut:AddMessage("TotemBar: unknown command '" .. msg .. "'. Usage: /tb, /tb lock, /tb scan, /tb assign, /tb options, /tb bind, /tb manadump, /tb tdump, /tb pulsecal, /tb tprobe")
end
end
-- SPELL_UPDATE_COOLDOWN fires whenever any spell's cooldown starts or
-- ends (the standard signal the default action bars key off of) - this
-- is the event-driven trigger for RefreshCooldown, kept separate from
-- the throttled per-tick timer OnUpdate (see RefreshCooldown's comment
-- for why: SetTimer-on-every-tick would restart the swipe animation).
local eventFrame = CreateFrame("Frame", "TotemBarEventFrame", UIParent)
eventFrame:RegisterEvent("ADDON_LOADED")
eventFrame:RegisterEvent("SPELL_UPDATE_COOLDOWN")
eventFrame:RegisterEvent("SPELLS_CHANGED")
eventFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS")
eventFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_CREATURE_BUFFS")
eventFrame:SetScript("OnEvent", function()
if event == "ADDON_LOADED" and arg1 == "TotemBar" then
TotemBar.ensureDefaults()
-- BUTTON_GAP was initialized above (file scope, load time) to the
-- file-load default, before TotemBarDB was even readable - it must
-- be overwritten by the SAVED value now, BEFORE BuildUI runs, or
-- the very first layout bakes in the default instead of whatever
-- the player set via the options panel's "Button spacing" slider.
BUTTON_GAP = TotemBar.clampValue(TotemBarDB.buttonGap or TotemBar.DEFAULT_BUTTON_GAP or 10, 10, 30)
TotemBar.BuildUI()
eventFrame:UnregisterEvent("ADDON_LOADED")
elseif event == "SPELLS_CHANGED" then
-- BuildUI resolves each slot's icon off the live spellbook at
-- ADDON_LOADED time, but the spellbook (GetSpellName/
-- GetSpellTexture) isn't reliably populated that early after a
-- login/reload - so a SAVED totem's icon lookup falls through to
-- the sheet's empty-slot glyph even though its name is stored
-- fine. SPELLS_CHANGED fires once the spellbook is ready (and
-- whenever it later changes), so re-resolve every icon here.
local elements = TotemBar.TOTEM_ELEMENTS
for i = 1, table.getn(elements) do
RefreshButton(elements[i])
end
if recallButton and recallButton.icon then
-- Re-set BOTH texture and crop texcoord: SetTexCoord persists
-- across SetTexture, so a stale non-crop coord state must not
-- survive a texture re-resolve.
recallButton.icon:SetTexture(GetRecallIcon())
recallButton.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
end
elseif event == "SPELL_UPDATE_COOLDOWN" then
local elements = TotemBar.TOTEM_ELEMENTS
for i = 1, table.getn(elements) do
RefreshCooldown(elements[i])
end
RefreshFlyoutCooldowns()
elseif event == "CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS"
or event == "CHAT_MSG_SPELL_PERIODIC_CREATURE_BUFFS" then
-- Re-anchor pulse phase on REAL observed periodic gains (spec 5.5).
local totemName = TotemBar.parseSelfGain(arg1)
if totemName then
local element = TotemBar.elementOf(totemName)
local rec = element and TotemBar.activeTotems[element]
if rec and rec.totemName == totemName then
-- These lines carry no caster, so another shaman's
-- identically-named totem is indistinguishable from ours.
-- Accept at most one anchor per pulse interval so a foreign
-- tick train can't drag our phase off-beat.
local pd = TotemBar.pulseInfo(totemName)
local nowGain = GetTime()
if TotemBar.shouldAnchorPulse(rec.pulseAnchor, pd and pd.interval, nowGain) then
rec.pulseAnchor = nowGain
end
end
end
end
end)
SLASH_TOTEMBAR1 = "/tb"
SlashCmdList = SlashCmdList or {}
SlashCmdList["TOTEMBAR"] = HandleSlashCommand