2542 lines
113 KiB
Lua
2542 lines
113 KiB
Lua
-- TotemBar - ui.lua
|
|
-- The totem bar frame: 4 element buttons plus a Totemic Recall button.
|
|
-- Left-click an element button to cast its chosen totem; right-click
|
|
-- clears the slot. Hovering an element button pops an upward flyout of
|
|
-- the element's other known totems: left-click one to cast it once,
|
|
-- right-click one to set it as the slot's new default. Each element
|
|
-- button also carries an OmniCC-style remaining-duration timer text (hybrid
|
|
-- source: pfUI libtotem's GetTotemInfo when present, else TotemBar's
|
|
-- own cast-tracking - see core/cast.lua). No per-button text labels;
|
|
-- hover the button for a tooltip naming the element/totem. Each element
|
|
-- button also carries a native action-button-style radial cooldown
|
|
-- swipe (CooldownFrameTemplate) reflecting the chosen totem's spellbook
|
|
-- cooldown, IN ADDITION to the duration timer text. The Recall button's
|
|
-- icon pulses whenever any element totem is currently out of range.
|
|
-- WoW-API-only file; not offline-tested, only syntax-checked (see
|
|
-- tools/luatests notes in the repo).
|
|
|
|
TotemBar = TotemBar or {}
|
|
|
|
-- Defensive fallback in case DEFAULT_CHAT_FRAME is ever unset this early
|
|
-- in the loading sequence (guards against a later/leaner client build).
|
|
local ChatOut = DEFAULT_CHAT_FRAME or ChatFrame1
|
|
|
|
local BUTTON_SIZE = 36
|
|
-- 10 (was 4): the 48px round ring frames overhang their 36px buttons by
|
|
-- 6px per side; adjacent opaque ring bands (screen radius ~22.9px) need
|
|
-- button centers >= ~45.8px apart to not overlap. 36+10 = 46 clears it,
|
|
-- for the bar row and the flyout column alike. Now a live-adjustable
|
|
-- UPVALUE (not a constant): the options panel's "Button spacing" slider
|
|
-- (range 10-30px, TotemBarDB.buttonGap) reassigns this in place via
|
|
-- TotemBar.SetButtonGap below - every closure that reads BUTTON_GAP
|
|
-- (element/flyout/assign-panel positioning, ApplyBarLayout) sees the new
|
|
-- value on its next read. 10px is still the floor: below that, adjacent
|
|
-- ring bands start to overlap per the geometry above.
|
|
local BUTTON_GAP = TotemBar.DEFAULT_BUTTON_GAP or 10
|
|
|
|
-- Bar-layout grid (feature: selectable bar arrangement, TotemBarDB.barLayout,
|
|
-- see ApplyBarLayout below). Maps each saved layout string to its column
|
|
-- count; row count then falls out of math.ceil(buttonCount / cols). Cycle
|
|
-- order for the options panel's cycle button lives in TotemBar.BAR_LAYOUTS.
|
|
local BAR_LAYOUT_COLS = { ["1x6"] = 6, ["2x3"] = 3, ["3x2"] = 2 }
|
|
TotemBar.BAR_LAYOUTS = { "1x6", "2x3", "3x2" }
|
|
|
|
-- Rows use the SAME pitch as columns (user request: tight grid). The timer
|
|
-- text below a row slightly overlaps the next row's ring overhang; it draws
|
|
-- on the OVERLAY layer above it and stays readable.
|
|
|
|
-- Timer-text OnUpdate throttle: refresh at most this often (seconds),
|
|
-- not every frame, to keep the shared/guild-addon perf budget sane.
|
|
-- 0.1s (was 0.2): the same tick also refreshes the ripple wave INPUTS (the
|
|
-- wave animation itself runs every frame in UpdateWaves, driven off these).
|
|
-- Still allocation-free per tick.
|
|
local TIMER_UPDATE_INTERVAL = 0.1
|
|
local timerElapsed = 0
|
|
|
|
-- 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
|
|
-- <WoW>\imports\totembar_scan.txt via SuperWoW's ExportFile (when
|
|
-- present) so it can be read off-client without pasting from chat.
|
|
function TotemBar.PrintScan()
|
|
local names = TotemBar.scanSpellbook()
|
|
local count = 0
|
|
local dump = ""
|
|
ChatOut:AddMessage("TotemBar: scanning spellbook for totems...")
|
|
for i = 1, table.getn(names) do
|
|
if string.find(names[i], "Totem", 1, true) then
|
|
ChatOut:AddMessage(" " .. names[i])
|
|
dump = dump .. names[i] .. "\n"
|
|
count = count + 1
|
|
end
|
|
end
|
|
ChatOut:AddMessage("TotemBar: " .. count .. " totem spell(s) found.")
|
|
-- ExportFile appends .txt itself; pass the name WITHOUT extension.
|
|
if ExportFile then
|
|
ExportFile("totembar_scan", "totems=" .. count .. "\n" .. dump)
|
|
end
|
|
end
|
|
|
|
local function HandleSlashCommand(msg)
|
|
local cmd = string.lower(msg or "")
|
|
if cmd == "" then
|
|
TotemBar.ToggleBar()
|
|
elseif cmd == "lock" then
|
|
TotemBarDB.locked = not TotemBarDB.locked
|
|
if TotemBarDB.locked then
|
|
ChatOut:AddMessage("TotemBar: bar locked.")
|
|
else
|
|
ChatOut:AddMessage("TotemBar: bar unlocked (drag to move).")
|
|
end
|
|
elseif cmd == "scan" then
|
|
TotemBar.PrintScan()
|
|
elseif cmd == "assign" then
|
|
-- Dev aid: inject a sample assignment to exercise the pending panel
|
|
-- + accept/decline flow end-to-end without any transport.
|
|
local sample = {
|
|
Fire = "Searing Totem",
|
|
Earth = "Strength of Earth Totem",
|
|
Water = "Mana Spring Totem",
|
|
Air = "Grace of Air Totem",
|
|
}
|
|
local ok, reason = TotemBar.ReceiveAssignment(sample, "TEST assignment")
|
|
if ok then
|
|
ChatOut:AddMessage("TotemBar: injected TEST assignment (click OK on the panel to apply).")
|
|
else
|
|
ChatOut:AddMessage("TotemBar: assign failed - " .. tostring(reason))
|
|
end
|
|
elseif cmd == "options" or cmd == "opt" then
|
|
TotemBar.ToggleOptions()
|
|
elseif cmd == "bind" then
|
|
if TotemBar.ToggleBindMode then TotemBar.ToggleBindMode() end
|
|
elseif cmd == "manadump" then
|
|
if TotemBar.dumpManaScan then TotemBar.dumpManaScan() end
|
|
elseif cmd == "tdump" then
|
|
if TotemBar.DumpTimerState then TotemBar.DumpTimerState() end
|
|
elseif string.find(cmd, "^pulsecal") then
|
|
local _, _, sub = string.find(cmd, "^pulsecal%s*(%a*)")
|
|
if TotemBar.PulseCal then TotemBar.PulseCal(sub or "") end
|
|
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
|