-- 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 -- 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 Rev 2 - floating round icons + ripple pulse (spec -- docs/superpowers/specs/2026-07-09-pulse-ui-design.md, section "Rev 2"). -- 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. local MAX_FLYOUT_ICONS = 6 -- 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 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 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 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 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 -- cached out-of-range tint state, 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() elseif TotemBar.confidentNoneOut and TotemBar.confidentNoneOut() then -- Confidently nothing out (tracked this session, all expired): don't waste -- Totemic Recall's 6s cooldown on a no-op cast. After a /reload the tracking is -- empty and 1.12 can't re-detect a pre-reload totem, so confidentNoneOut fails -- open there and the recall goes through the else branch below (see core/cast.lua). ChatOut:AddMessage("TotemBar: no totems out - not recalling (saves the 6s cooldown).") else -- Snapshot for the refund learner runs AFTER the cast: activeTotems -- is still populated here (clearActiveTotems runs last). CastSpellByName(RECALL_SPELL_NAME) if TotemBar.snapshotRecallCost then TotemBar.snapshotRecallCost() end -- Totemic Recall drops every active totem at once; clear our -- own-tracking timers so the icons' countdowns disappear too -- (GetTotemInfo, if present, will also reflect this). TotemBar.clearActiveTotems() 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 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 -- 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 gtiDuration = duration if start and duration then gtiRemaining = TotemBar.remaining(start, duration, now) end end local remainingVal = TotemBar.resolveRemaining(gtiActive, gtiRemaining, ownRemaining) 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) AND, when -- pfUI's libtotem is present, GetTotemInfo(i) agreeing the -- slot is still active. That second check is what keeps a -- totem someone/something DESTROYED (burned, killed) before -- its timer ran out from flashing red - once GTI says the -- slot is gone, we just go back to normal, not red. local rangeActive = (ownRecord ~= nil) if rangeActive and hasGTI and not gtiActive then rangeActive = false end if not rangeActive then if btn.tintRed then btn.icon:SetVertexColor(1, 1, 1) btn.tintRed = false end else local hasBuff = TotemBar.hasBuffWithIcon(ownRecord.icon) if hasBuff 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 if btn.tintRed then btn.icon:SetVertexColor(1, 1, 1) btn.tintRed = false end elseif ownRecord.everHadBuff then -- Had the buff earlier from this cast, don't have it -- now: wandered out of the totem's range. if not btn.tintRed then btn.icon:SetVertexColor(1, 0.35, 0.35) btn.tintRed = true end else -- Either a non-buff totem (Searing/Magma/Grounding/ -- etc. never grant a matching buff, so everHadBuff -- stays false forever -> never red) or we simply -- haven't been in range yet since this cast. if btn.tintRed then btn.icon:SetVertexColor(1, 1, 1) btn.tintRed = false end end end 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 gtiActive, gtiDuration = active, duration if start and duration then gtiRemaining = TotemBar.remaining(start, duration, now) end end end local remainingVal = TotemBar.resolveRemaining(gtiActive, gtiRemaining, ownRemaining) 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 .. "\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 -- \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 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") 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 rec.pulseAnchor = GetTime() end end end end) SLASH_TOTEMBAR1 = "/tb" SlashCmdList = SlashCmdList or {} SlashCmdList["TOTEMBAR"] = HandleSlashCommand