commit 8dbc411e8032a4e566504309324ec8dff7098d4a Author: ShempError <689+shemperror@noreply.octowow.st> Date: Wed Aug 5 15:54:45 2026 +0200 TotemBar v0.2.5 diff --git a/Bindings.xml b/Bindings.xml new file mode 100644 index 0000000..90dda88 --- /dev/null +++ b/Bindings.xml @@ -0,0 +1,34 @@ + + TotemBar.dropSetKey(keystate); + TotemBar.CastRecall(); + TotemBar.ToggleBar(); + TotemBar.ToggleOptions(); + TotemBar.ToggleBindMode(); + TotemBar.CastElement("Fire"); + TotemBar.CastElement("Earth"); + TotemBar.CastElement("Water"); + TotemBar.CastElement("Air"); + TotemBar.CastTotem("Searing Totem"); + TotemBar.CastTotem("Fire Nova Totem"); + TotemBar.CastTotem("Magma Totem"); + TotemBar.CastTotem("Flametongue Totem"); + TotemBar.CastTotem("Frost Resistance Totem"); + TotemBar.CastTotem("Earthbind Totem"); + TotemBar.CastTotem("Stoneclaw Totem"); + TotemBar.CastTotem("Stoneskin Totem"); + TotemBar.CastTotem("Strength of Earth Totem"); + TotemBar.CastTotem("Tremor Totem"); + TotemBar.CastTotem("Fire Resistance Totem"); + TotemBar.CastTotem("Healing Stream Totem"); + TotemBar.CastTotem("Mana Spring Totem"); + TotemBar.CastTotem("Poison Cleansing Totem"); + TotemBar.CastTotem("Disease Cleansing Totem"); + TotemBar.CastTotem("Mana Tide Totem"); + TotemBar.CastTotem("Grace of Air Totem"); + TotemBar.CastTotem("Grounding Totem"); + TotemBar.CastTotem("Nature Resistance Totem"); + TotemBar.CastTotem("Sentry Totem"); + TotemBar.CastTotem("Windfury Totem"); + TotemBar.CastTotem("Windwall Totem"); + TotemBar.CastTotem("Tranquil Air Totem"); + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9b2c568 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,195 @@ +# Changelog + +All notable changes to TotemBar are documented here. + +## v0.2.5 — 2026-07-25 + +### Added +- **New option: "Show drop-all button".** The drop-all-totems button can now be + hidden from the bar, for anyone who drives it purely from a keybind or a macro + and would rather not spend the slot on it. On by default, so nothing changes + unless you turn it off. + - **The keybind keeps working while the button is hidden.** It is bound to the + cast function, not to the frame, and the entry stays listed in the Esc key + bindings menu — otherwise you could never assign it again. + - **The bar resizes instead of leaving a gap.** In the 1×6 layout it shrinks by + one slot. In 2×3 and 3×2 the frame keeps its size on purpose: Recall still + needs the last column, and shrinking there would break up the 2×2 block of + the four element totems. What remains is one empty grid cell in the trailing + corner — invisible, and it simply adds drag area. + +## v0.2.4 — 2026-07-14 + +### Fixed +- **Totemic Recall no longer refuses to fire after a /reload.** The "no totems + out — not recalling" guard could block a legitimate recall: TotemBar tracks + which totems you've dropped in memory only, and a `/reload` wipes that memory. + On 1.12 there is no `GetTotemInfo` / totem unit-id to re-detect a totem that + was placed before the reload, so the addon wrongly concluded nothing was out + and refused to recall. The guard now blocks **only** when it is certain nothing + is out (it tracked totems this session and they've all expired); after a + /reload the state is unknown, so it fails open and lets the recall through. + Worst case is one needless recall right after a reload — far better than a + blocked one. + +## v0.2.3 — 2026-07-13 + +### Fixed +- **Totemic Recall no longer sweeps away a freshly-placed set (nampower).** + With nampower installed, dropping your set while the global cooldown was + active could make the new totems appear and then instantly vanish: nampower + queued Totemic Recall behind the GCD, so it fired *after* the (off-GCD) + totems were placed and pulled them right back down. TotemBar now casts + Recall via `CastSpellByNameNoQueue` when nampower provides it, so a + GCD-blocked Recall is skipped for that press instead of firing late. No + effect for setups without nampower. + +### Changed +- **The drop-set keybind now recalls on key-down and places on key-release.** + This keeps the recall and the totem placements as separate hardware events; + for a normal tap the difference is imperceptible. + +## v0.2.2 — 2026-07-10 + +### Added +- **"Button spacing" slider** in the options panel (10–30 px) — adjusts the + gap between bar buttons live, including the flyout and assignment panel. + +### Fixed +- **Keybind casts now start TotemBar's own duration/pulse timers.** + Per-totem and element key bindings call the same tracking as click-casts. + Previously, setups without pfUI got no timer/ring/pulse feedback for + keybind-cast totems, and even with pfUI the spawn ripple and out-of-range + tint were missing for them. + +### Removed +- **Dead `pulseGlow` setting** — the anticipation glow is part of "Show + pulse waves"; stale saved values are cleaned up automatically. + +## v0.2.1 — 2026-07-10 + +### Fixed +- **Tooltips showed rank 1 instead of your highest rank.** Both totem + tooltips (element buttons and the flyout) resolved the spell by its first + spellbook match — which is rank 1 — so mana cost, damage and duration read + far too low. Display-only: the actual cast is name-based and always used + your highest rank. Tooltips now resolve to the highest known rank **and + show the rank** next to the spell name (vanilla's tooltip omits it). + +## v0.2.0 — 2026-07-10 + +**The Pulse UI release** — a complete visual rework of the bar around one idea: +you should see at a glance *how long* each totem still stands and *when* it +pulses next. + +### Added +- **Round floating slots.** Buttons are free-floating round icons in a subtle + shadow-rim frame — the square boxes and backdrops are gone. Flyout, Recall + and drop-set share the round look (round hover/pressed states included). +- **Duration ring (time traffic light).** The outer ring drains with the + totem's remaining lifetime and blends green → yellow → orange → red as time + runs out. +- **Pulse ring.** A thin inner arc in the totem's element color fills toward + the next pulse and resets when it fires — animated per frame, no stutter. +- **Server-accurate pulse timing.** Intervals and first-tick behavior come + from TurtleWoW server data, not guesswork: e.g. Tremor pulses every **4s** + (not the commonly cited 3s) and ticks **immediately** on placement, while + Magma/Healing Stream/Mana Spring first pulse one full interval after + placement. Water totems additionally re-anchor on the real observed + mana/heal ticks. +- **Pulse waves & anticipation glow.** An expanding ripple in the element + color marks each pulse; a soft halo builds around the ring during the last + stretch before it. A spawn ripple confirms every placement. +- **Bar layouts.** Cycle between 1×6, 2×3 (elements grouped as a 2×2 block) + and 3×2 in the options panel. +- **Custom art set.** Element glyphs for empty slots (no more question + marks), a four-element drop-set icon, a minimap glyph, a custom options + panel skin with rounded corners, and grounding shadow plates under the + slots. All art is generated by scripts in `tools/` — reproducible, no + binary-only assets. +- **New options:** Show duration ring · Show pulse ring · Show pulse waves · + Show countdown text · Layout. +- **`/tb pulsecal` (dev aid):** captures pulse-timing telemetry to + `imports\totembar_pulsecal.txt` (SuperWoW) for verifying timings in-game. + +### Changed +- Button spacing widened (4 → 10 px) to give the ring frames air; multi-row + layouts use the same tight pitch vertically. +- Options panel restyled with the custom skin and section dividers. +- The remaining-time countdown text is now optional (on by default). + +## v0.1.3 — 2026-07-09 + +### Fixed +- **Totemic Recall no longer wastes its 6-second cooldown.** Recall — via the + button, its keybinding, or the "Totems" macro's auto-recall — now only fires + when you actually have totems out. Casting it with nothing down put Recall on + its 6s cooldown, so a set placed right after couldn't be recalled for 6 + seconds. With nothing out, TotemBar now skips the cast (with a brief note). + +## v0.1.2 — 2026-07-09 + +### Added +- **Mana cost on the drop-set tooltip** — hovering the drop-set button (and the + "Totems" macro action) shows the total mana to drop your four chosen totems. +- **Refund on the Totemic Recall tooltip** — shows the mana you'll get back, + computed live from the totems currently out (expired ones are excluded) and a + refund percentage that is learned from your real recall mana-gain (defaults to + 25% until learned). +- **Totemic Mastery duration** — when the talent is skilled, helpful totems' + own-tracked timers get the +20% duration so they no longer read short. +- `/tb manadump` dev aid — writes the raw mana-cost scan (resolved rank, tooltip + lines, parsed cost) to `imports\totembar_manadump.txt`. + +### Fixed +- Totem mana costs were read from **rank 1** instead of the highest rank you + actually cast, so the displayed mana was far too low. The scan now resolves + the highest known rank. + +## v0.1.1 — 2026-07-09 + +### Added +- **Drop-set button** on the bar (right of the Totemic Recall button): one + left-click casts all four chosen totems (the same action as the "Totems" + macro, with the 2-second double-press guard). +- **Key bindings** — a **TotemBar** section in the Esc → Key Bindings menu: + Drop all totems, Totemic Recall, Toggle bar, Toggle options, Toggle key-bind + mode, Cast *element* totem (chosen, one per element), and — under per-element + sub-sections — a binding to cast **every individual totem** (Cast Searing + Totem, Cast Windfury Totem, …). +- **Hover-bind mode** (`/tb bind`, or the options panel's "Key bind mode" + button): hover any bar button or a flyout totem and press a key to bind it + (a button binds its action; a flyout totem binds that specific totem). ESC + over a button clears its key; ESC over empty space (or the options button) + exits. A "Key-Bind Mode ACTIVE" box is shown while active. The + currently-bound key is displayed on each button at all times (abbreviated, + e.g. `N7` for Num Pad 7). Bindings are saved by the game and persist. + +### Notes +- Totemic Recall cast **deliberately** (the Recall button, or its keybinding) + is never guarded — only the Totems macro's built-in recall respects the 2s + double-press guard. + +## v0.1.0 — 2026-07-09 + +Initial public release. + +### Added +- Totem bar: four element buttons + a Totemic Recall button. Left-click an + element casts its chosen totem; right-click clears the slot. Hover an element + for a flyout of the element's other known totems (left-click to cast once, + right-click to set as the new default). +- One-press "Totems" macro (`TotemBar.recallAndCastAll()`) with a 2-second + double-press recall guard (a rapid second press won't recall the totems you + just dropped). +- Native cooldown swipes + OmniCC-style duration timers; out-of-range red tint + (buff-presence based); the Recall button pulses when a totem is out of range. +- Real in-game spell tooltips on the element buttons and the flyout. +- Minimap button (orbit-draggable; left-click = options, right-click = toggle + bar); pfUI-safe, with `/tb options` as a fallback. +- Options panel: lock bar, auto-recall, show bar, recall guard, cycle-reset + gap, UI size (scales the bar, anchored top-left), reset position, create + "Totems" macro, and per-setting tooltips. Adopts pfUI's look when pfUI is + installed, a clean Blizzard look otherwise. +- Assignment-receiver API (`TotemBar.ReceiveAssignment`) for a future raid + totem-assignment addon — see [`docs/API.md`](docs/API.md). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e1bc95c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ShempError + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9c25184 --- /dev/null +++ b/README.md @@ -0,0 +1,205 @@ +# TotemBar + +A lightweight shaman totem bar for **TurtleWoW / WoW 1.12.1 (Vanilla)**. + +Pick one totem per element, cast it with a click, cycle alternatives from a +hover flyout, or drop all four with a single press. Every slot shows **how +long the totem still stands** (outer ring, green → red) and **when it pulses +next** (inner ring in the element's color) — with timings taken from actual +server data, not guesswork. No dependencies; adopts the +[pfUI](https://github.com/shagu/pfUI) look automatically when present. + +TotemBar in action: round slots with duration and pulse rings, totems placed in the world + +## What's new — v0.2.0 · *The Pulse UI release* · 2026-07-10 + +- **Round floating slots** — the square boxes are gone; icons float in a + subtle shadow-rim frame, with round hover/pressed states everywhere. +- **Time traffic light** — the outer ring drains with the totem's remaining + lifetime and shifts green → yellow → orange → red. +- **Pulse ring** — a thin element-colored inner arc fills toward the next + pulse and resets when it fires, animated per frame. +- **Server-accurate pulse timing** — intervals and first-tick behavior from + TurtleWoW server data (Tremor pulses every **4s** and immediately on + placement; Magma and the water totems first pulse one interval in). Water + totems re-anchor on the real observed mana/heal ticks. +- **Pulse waves & glow** — a ripple in the element color marks each pulse; a + halo builds just before it. +- **Bar layouts** — 1×6, 2×3 (elements as a 2×2 block) or 3×2. +- **Custom art set** — element glyphs for empty slots, drop-set icon, minimap + glyph, skinned options panel. All generated by scripts in `tools/`. + +**Version history** — details in [CHANGELOG.md](CHANGELOG.md): + +- **v0.2.5** (2026-07-25) — new option **"Show drop-all button"**: hide the + drop-all-totems button if you fire it from a keybind or macro and would rather + not spend a slot on it. The keybind keeps working while it's hidden, and in the + 1×6 layout the bar shrinks instead of leaving dead space. +- **v0.2.4** (2026-07-14) — Totemic Recall no longer refuses to fire after a + `/reload`: the "no totems out" guard relied on in-memory tracking that a reload + wipes, and 1.12 offers no way to re-detect totems placed beforehand. Also a + shared spellbook index, killing a multi-millisecond hitch when dropping four + totems from one macro press. +- **v0.2.3** (2026-07-13) — fixes a bug where dropping your set could make the + new totems vanish an instant later: with nampower, Totemic Recall was queued + behind the global cooldown and fired *after* the (off-GCD) totems were + placed, sweeping them. Recall now bypasses the spell queue so it can't pull a + fresh set. +- **v0.2.2** (2026-07-10) — "Button spacing" slider in the options panel; + keybind casts now feed TotemBar's own duration/pulse timers (previously + only click-casts and pfUI did); removed the dead `pulseGlow` setting. +- **v0.2.1** (2026-07-10) — tooltips now show your highest known rank (they + read rank-1 values before; the cast itself was always highest-rank) and + display the rank next to the name. +- **v0.1.3** (2026-07-09) — Totemic Recall no longer wastes its 6s cooldown + when no totems are out. +- **v0.1.2** (2026-07-09) — mana cost on the drop-set tooltip, live refund + estimate on the Recall tooltip, Totemic Mastery duration bonus, rank-aware + mana scan. +- **v0.1.1** (2026-07-09) — key bindings (Esc menu section for every action + and every single totem), drop-set bar button, hover-bind mode with + per-button key labels. +- **v0.1.0** (2026-07-09) — first public release: element buttons, hover + flyout, "Totems" macro with recall guard, minimap button, options panel, + duration timers, out-of-range tint. + +## Screenshots + +| The bar & flyout | Options panel | +| :---: | :---: | +| The totem bar with Recall tooltip
Hover flyout with spell tooltip | Options panel | + +| Key bindings | Hover-bind mode | +| :---: | :---: | +| Esc menu key bindings section | Hover-bind mode with key labels | + +*(Click any image for full size.)* + +## Features + +**Casting** +- One button per element (Fire / Earth / Water / Air): left-click casts the + chosen totem, right-click clears the slot. +- Hover flyout with your other known totems per element — left-click casts + once, right-click makes it the new default. +- **Drop-set button** and a one-press **"Totems" macro** cast all four chosen + totems at once, guarded against accidental double presses (so a fast second + press won't recall the set you just placed). +- Dedicated **Totemic Recall** button that only fires when totems are + actually out — never wastes Recall's 6s cooldown. Right-click toggles + auto-recall-before-redeploy. + +**At-a-glance timing** +- **Duration ring:** the outer ring drains with the totem's lifetime, + colored green → yellow → orange → red by remaining time. +- **Pulse ring:** the inner arc (element color) fills toward the next pulse — + intervals and first-tick behavior derived from TurtleWoW server data, water + totems locked onto their real observed ticks. +- **Pulse waves:** an expanding ripple marks each pulse, a soft halo + announces it just before, and a spawn ripple confirms each placement. +- Optional countdown text under each slot; totems tint red when you leave + their range (buff-presence based), and the Recall button pulses while + anything is out of range. +- Native cooldown swipes and real in-game spell tooltips everywhere. + +**Control** +- **Key bindings:** an Esc → Key Bindings section for every action — including + a binding for every single totem you know — plus a fast **hover-bind mode** + (hover a button, press a key). Bound keys show on the buttons. +- **Layouts:** one row (1×6), two rows (2×3, elements grouped 2×2) or three + rows (3×2), switchable live. +- **Minimap button** (drag to reposition, left-click options, right-click + toggles the bar) and a skinned **options panel** for everything else. +- **Assignment seam** for future raid tools: an external addon can *suggest* + a totem set via `TotemBar.ReceiveAssignment` — shown as a one-click apply + panel, never auto-cast. See [`docs/API.md`](docs/API.md). + +## Install + +**Option A — download (simplest):** + +1. Grab **`TotemBar-vX.Y.Z.zip`** from + [**Releases**](https://github.com/ShempError/TotemBar/releases). + *(Use the Release zip, not "Code → Download ZIP" — that names the folder + `TotemBar-master`, which WoW won't load.)* +2. Extract the **`TotemBar`** folder into `Interface\AddOns\`. +3. Restart the client. + +**Option B — git (auto-updatable):** + +``` +cd Interface/AddOns +git clone https://github.com/ShempError/TotemBar.git TotemBar +``` + +`master` is the stable release channel — git-based managers +(GitAddonsManager, OctoWoW) stay current with a `git pull`. `dev` is +work-in-progress. + +## Usage + +| Action | Effect | +| --- | --- | +| Left-click element button | Cast that element's chosen totem | +| Right-click element button | Clear that element's slot | +| Hover element button | Flyout with the element's other known totems | +| Left-click flyout totem | Cast it once (default unchanged) | +| Right-click flyout totem | Set it as the slot's new default | +| Left-click Recall | Cast Totemic Recall (only when totems are out) | +| Right-click Recall | Toggle auto-recall | +| Left-click drop-set button | Cast all four chosen totems | +| Minimap button | Left: options · Right: toggle bar · Drag: reposition | + +**Slash commands:** `/tb` (toggle bar) · `/tb options` · `/tb lock` · +`/tb bind` (hover-bind mode) · `/tb scan`, `/tb assign`, `/tb manadump`, +`/tb pulsecal` (dev aids). + +**The "Totems" macro:** options panel → **Create 'Totems' macro**, then drag +the `Totems` macro to your action bar. One press recalls (if auto-recall is +on) and redeploys all four chosen totems. + +## Options + +Open with the minimap button or `/tb options`. + +| Option | Does | +| --- | --- | +| Lock bar | Disable dragging | +| Auto-recall before setting | Recall first when the macro/drop-set fires | +| Show bar | Show/hide the whole bar | +| Show drop-all button | Show/hide the drop-all-totems button; its keybind keeps working either way | +| Show duration ring | Outer remaining-time ring (traffic-light colors) | +| Show pulse ring | Inner next-pulse arc (element color) | +| Show pulse waves | Ripple + glow effects on pulses | +| Show countdown text | Remaining-seconds text under each slot | +| Layout | Cycle 1×6 / 2×3 / 3×2 | +| Recall guard (sec) | Double-press protection window | +| Cycle reset gap (sec) | When macro cycling restarts at totem 1 | +| UI size | Scales bar + flyout from the top-left corner | +| Reset position / Create macro / Key bind mode | One-click actions | + +## Requirements + +- A WoW 1.12.1 client (TurtleWoW). **No addon dependencies.** + +Optional, auto-detected: **pfUI** (skinned widgets; its `libtotem` feeds the +timers as an extra source) and **SuperWoW** (only used by the `/tb scan` / +`/tb pulsecal` dev aids for file exports). + +## For developers + +- Public API for companion addons: [`docs/API.md`](docs/API.md). +- Pure-logic modules under `core/` are unit-tested offline against a real + Lua 5.0.3 interpreter (`tools/luatests/`, 14 suites). WoW-API files are + syntax-checked; in-game behavior is verified on TurtleWoW. +- All textures are **generated**: `tools/gen_*.js` (zero-dependency Node) + render the ring/FX/icon/panel sheets as 1.12-safe TGAs, and + `tools/preview_*.js` composite them into PNG previews for review without + launching the client. +- `/reload` covers changes to existing `.lua` files; a client restart is + needed for `.toc` changes, new files, or changed textures (the client + caches them). + +## License + +MIT (see LICENSE). diff --git a/TotemBar.toc b/TotemBar.toc new file mode 100644 index 0000000..dc92068 --- /dev/null +++ b/TotemBar.toc @@ -0,0 +1,25 @@ +## Interface: 11200 +## Title: TotemBar +## Notes: Shaman totem bar - pick one totem per element, cast it, or spam-cycle all four from a macro. +## Author: ShempError +## Version: 0.2.5 +## SavedVariables: TotemBarDB + +core\totemdata.lua +core\spellindex.lua +core\known.lua +core\cast.lua +core\config.lua +core\assign.lua +core\optionslogic.lua +core\bindlogic.lua +core\manacost.lua +core\pulsedata.lua +core\pulse.lua +core\pulseparse.lua +core\pulsecal.lua +ui.lua +options.lua +minimap.lua +Bindings.xml +bind.lua diff --git a/bind.lua b/bind.lua new file mode 100644 index 0000000..b923c91 --- /dev/null +++ b/bind.lua @@ -0,0 +1,231 @@ +-- TotemBar - bind.lua +-- Key-binding labels (for the Esc > Key Bindings menu) + the cast entry +-- points the Bindings.xml bodies call. Hover-bind mode is added below in a +-- later step. Loaded AFTER Bindings.xml (see the .toc). + +TotemBar = TotemBar or {} + +local ChatOut = DEFAULT_CHAT_FRAME or ChatFrame1 + +-- Section + fixed-action labels. +BINDING_HEADER_TOTEMBAR = "TotemBar" +BINDING_NAME_TOTEMBAR_DROPSET = "Drop all totems" +BINDING_NAME_TOTEMBAR_RECALL = "Totemic Recall" +BINDING_NAME_TOTEMBAR_TOGGLEBAR = "Toggle bar" +BINDING_NAME_TOTEMBAR_TOGGLEOPTIONS = "Toggle options" +BINDING_NAME_TOTEMBAR_TOGGLEBIND = "Toggle key-bind mode" + +-- Per-element "cast the chosen totem" labels + per-element sub-headers. +do + local elements = TotemBar.TOTEM_ELEMENTS + for i = 1, table.getn(elements) do + local e = elements[i] + setglobal("BINDING_NAME_TOTEMBAR_CAST_" .. string.upper(e), "Cast " .. e .. " totem (chosen)") + setglobal("BINDING_HEADER_TOTEMBAR_" .. string.upper(e), "TotemBar: " .. e .. " Totems") + end + -- Per-totem labels (must match Bindings.xml names via bindingSuffix). + for i = 1, table.getn(elements) do + local e = elements[i] + local list = TotemBar.TOTEMS_BY_ELEMENT[e] + for j = 1, table.getn(list) do + local totem = list[j] + setglobal("BINDING_NAME_TOTEMBAR_TOTEM_" .. TotemBar.bindingSuffix(totem), "Cast " .. totem) + end + end +end + +-- Cast a specific totem by name (no-op in-game if not known). +function TotemBar.CastTotem(name) + if name then + CastSpellByName(name) + -- keybind casts must feed the own-tracking timers exactly like the + -- flyout click handler does (ui.lua) - without this, vanilla-only + -- setups get no duration ring/pulse for keybind-cast totems. + local element = TotemBar.elementOf(name) + if element then + TotemBar.recordCast(element, name) + end + end +end + +-- Cast the currently-chosen totem for an element. +function TotemBar.CastElement(element) + local n = TotemBarDB and TotemBarDB.chosen and TotemBarDB.chosen[element] + if n then + CastSpellByName(n) + -- keybind casts must feed the own-tracking timers exactly like the + -- flyout click handler does (ui.lua) - without this, vanilla-only + -- setups get no duration ring/pulse for keybind-cast totems. + TotemBar.recordCast(element, n) + else + ChatOut:AddMessage("TotemBar: no totem chosen for " .. tostring(element) .. ".") + end +end + +-- Cast Totemic Recall and clear own-tracking (mirrors the Recall button). +function TotemBar.CastRecall() + -- Don't waste Totemic Recall's 6s cooldown when nothing is out -- but only block when we + -- are CONFIDENT of that (tracked totems this session, all expired). After a /reload the + -- tracking is empty and 1.12 has no way to re-detect a pre-reload totem, so confidentNoneOut + -- fails open there and lets the recall through (see core/cast.lua). + if TotemBar.confidentNoneOut and TotemBar.confidentNoneOut() then + ChatOut:AddMessage("TotemBar: no totems out - not recalling (saves the 6s cooldown).") + return + end + CastSpellByName("Totemic Recall") + if TotemBar.snapshotRecallCost then TotemBar.snapshotRecallCost() end + if TotemBar.clearActiveTotems then + TotemBar.clearActiveTotems() + end +end + +-- Stub so the TOTEMBAR_TOGGLEBIND binding never calls nil before Task 4 +-- fills it in. Replaced (same name) by the real implementation below. +if not TotemBar.ToggleBindMode then + function TotemBar.ToggleBindMode() + ChatOut:AddMessage("TotemBar: key-bind mode not available.") + end +end + +-- ===== Hover-bind mode ===== +-- Toggle a mode where hovering a bar button / flyout totem and pressing a +-- key binds that key to the thing's action (buttons -> CLICK binding, flyout +-- totems -> the named per-totem binding). ESC clears the hovered thing's +-- binding. Bindings persist via the client (SaveBindings), no addon SV. + +local bindMode = false +local captureFrame = nil +local infoBox = nil + +-- Base keys that are pure modifiers - never bind these alone. +local BARE_MODIFIERS = { + LSHIFT = true, RSHIFT = true, LCTRL = true, RCTRL = true, + LALT = true, RALT = true, UNKNOWN = true, +} + +function TotemBar.isBindMode() + return bindMode +end + +-- Resolves the mouse-focused frame to a binding action. Uses the pure +-- TotemBar.actionForButton with the frame's global name and, for a flyout +-- icon, its current .totemName. +local function actionForFocus(focus) + if not focus then + return nil + end + local totemName = focus.totemName -- set on flyout icon buttons when shown + local fname = focus.GetName and focus:GetName() or nil + return TotemBar.actionForButton(fname, totemName) +end + +local function ensureCaptureFrame() + if captureFrame then + return captureFrame + end + local f = CreateFrame("Frame", "TotemBarBindCapture", UIParent) + f:SetAllPoints(UIParent) + f:SetFrameStrata("FULLSCREEN_DIALOG") + f:EnableKeyboard(false) + f:Hide() + f:SetScript("OnKeyUp", function() + local key = arg1 -- NOTE: OnKeyUp arg1 = key name string (matches pfUI's hoverbind capture) + if not key or BARE_MODIFIERS[key] then + return + end + local focus = GetMouseFocus() + local action = actionForFocus(focus) + if key == "ESCAPE" and not action then + -- Keyboard capture swallows all keys while bind mode is on, so + -- ESC over nothing is the keyboard exit (the options button also + -- toggles it off). ESC over a button clears that button below. + TotemBar.ToggleBindMode() + return + end + if not action then + return + end + if key == "ESCAPE" then + local k1, k2 = GetBindingKey(action) + if k1 then SetBinding(k1) end + if k2 then SetBinding(k2) end + else + local full = TotemBar.modifierPrefix(IsAltKeyDown(), IsControlKeyDown(), IsShiftKeyDown()) .. key + SetBinding(full) -- clear whatever `full` was bound to + SetBinding(full, action) -- bind it to this action + end + SaveBindings(GetCurrentBindingSet()) + if TotemBar.refreshBindOverlays then + TotemBar.refreshBindOverlays() + end + end) + captureFrame = f + return f +end + +-- Lazily builds a visible on-screen info box shown while bind mode is +-- active, so the mode (which otherwise only shows via a chat message and +-- stays on until ESC) is hard to miss. Rev 4 UI chrome: shared bespoke +-- panel skin (ui.lua's PANEL_BACKDROP), matching the rest of the addon's +-- chrome, regardless of pfUI presence. +local function ensureBindInfoBox() + if infoBox then + return infoBox + end + local f = CreateFrame("Frame", "TotemBarBindInfo", UIParent) + f:SetWidth(380) + f:SetHeight(80) + f:SetPoint("TOP", UIParent, "TOP", 0, -160) + f:SetFrameStrata("FULLSCREEN_DIALOG") + -- Cheap guard: bind.lua loads LAST per the .toc (after ui.lua), so + -- TotemBar.PANEL_BACKDROP is always set by the time this runs - the + -- fallback only protects against a future load-order change. + local backdrop = TotemBar.PANEL_BACKDROP or { + bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background", + edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border", + tile = true, tileSize = 32, edgeSize = 32, + insets = { left = 11, right = 12, top = 12, bottom = 11 }, + } + f:SetBackdrop(backdrop) + f:SetBackdropColor(1, 1, 1, 0.97) + local t = f:CreateFontString("TotemBarBindInfoText", "OVERLAY", "GameFontNormal") + t:SetPoint("CENTER", f, "CENTER", 0, 0) + t:SetJustifyH("CENTER") + t:SetText("|cff33ffccKey-Bind Mode ACTIVE|r\nHover a bar button or flyout totem and press a key to bind it.\nESC over a button clears its key. ESC over empty space (or the options button) exits.") + if pfUI and pfUI.font_default then + t:SetFont(pfUI.font_default, 12) + end + f:Hide() + infoBox = f + return f +end + +function TotemBar.ToggleBindMode() + bindMode = not bindMode + local f = ensureCaptureFrame() + if bindMode then + f:EnableKeyboard(true) + f:Show() + ensureBindInfoBox():Show() + ChatOut:AddMessage("TotemBar: key-bind mode ON - hover a button or flyout totem and press a key to bind. ESC over a button clears its key; ESC over nothing (or the options button) exits.") + else + f:EnableKeyboard(false) + f:Hide() + if infoBox then + infoBox:Hide() + end + ChatOut:AddMessage("TotemBar: key-bind mode OFF.") + end + if TotemBar.refreshBindOverlays then + TotemBar.refreshBindOverlays() + end +end + +-- Refresh overlays whenever the client's bindings change. +local bindEvents = CreateFrame("Frame", "TotemBarBindEventFrame", UIParent) +bindEvents:RegisterEvent("UPDATE_BINDINGS") +bindEvents:SetScript("OnEvent", function() + if TotemBar.refreshBindOverlays then + TotemBar.refreshBindOverlays() + end +end) diff --git a/core/assign.lua b/core/assign.lua new file mode 100644 index 0000000..96a71a7 --- /dev/null +++ b/core/assign.lua @@ -0,0 +1,147 @@ +-- TotemBar - core/assign.lua +-- Assignment receiver seam: the contract + pure logic by which an external +-- assigner (future "ShamiPower") hands TotemBar a proposed totem set. +-- WoW-API-light so the logic below is offline-testable under real Lua 5.0. +-- The pending-suggestion UI lives in ui.lua and is reached through the +-- optional hook slots (ShowAssignPanel/HideAssignPanel/isTotemKnown/ +-- RefreshAll) that ui.lua fills in at load time. +-- +-- An "assignment set" is a table keyed by element -> totem spell name: +-- { Fire = "Searing Totem", Air = "Windfury Totem", ... } +-- Any subset of elements; a missing element means "nothing for that slot". + +TotemBar = TotemBar or {} + +-- True if `key` is one of the four totem elements. +function TotemBar.isElement(key) + if not key then + return false + end + local elements = TotemBar.TOTEM_ELEMENTS + for i = 1, table.getn(elements) do + if elements[i] == key then + return true + end + end + return false +end + +-- Validates an assignment set. Returns true, or false plus a reason string. +function TotemBar.validateAssignment(set) + if type(set) ~= "table" then + return false, "set must be a table" + end + local count = 0 + for k, v in pairs(set) do + if not TotemBar.isElement(k) then + return false, "unknown element key: " .. tostring(k) + end + if type(v) ~= "string" or v == "" then + return false, "totem name for " .. tostring(k) .. " must be a non-empty string" + end + count = count + 1 + end + if count == 0 then + return false, "set is empty" + end + return true +end + +-- Shallow copy of a set, keeping only valid element keys. +function TotemBar.copySet(set) + local out = {} + if type(set) == "table" then + for k, v in pairs(set) do + if TotemBar.isElement(k) then + out[k] = v + end + end + end + return out +end + +-- Fresh copy (element -> name) of the currently chosen totems. +function TotemBar.GetChosenSet() + local out = {} + local chosen = TotemBarDB and TotemBarDB.chosen + if chosen then + local elements = TotemBar.TOTEM_ELEMENTS + for i = 1, table.getn(elements) do + local e = elements[i] + if chosen[e] then + out[e] = chosen[e] + end + end + end + return out +end + +-- Splits a set into applied (isKnown(name) true) and skipped (false). +function TotemBar.filterKnown(set, isKnown) + local applied, skipped = {}, {} + for k, v in pairs(set) do + if isKnown(v) then + applied[k] = v + else + skipped[k] = v + end + end + return applied, skipped +end + +-- Current pending assignment: { set = {element=name,...}, label = string } or nil. +-- In-memory only (not a SavedVariable) - an assignment is ephemeral coordination. +TotemBar.pending = nil + +-- Optional hook, filled by an external assigner: called with the applied +-- set table after the player accepts. Nil by default. +TotemBar.onAssignmentApplied = nil + +-- THE SEAM. An external assigner calls this to propose a set. Validates, +-- stores it as the pending suggestion (replacing any prior), and asks the +-- UI to show the panel. Does NOT apply. Returns true, or false + reason. +function TotemBar.ReceiveAssignment(set, label) + local ok, reason = TotemBar.validateAssignment(set) + if not ok then + return false, reason + end + TotemBar.pending = { set = TotemBar.copySet(set), label = label } + if TotemBar.ShowAssignPanel then + TotemBar.ShowAssignPanel() + end + return true +end + +-- Drops any pending suggestion and hides the panel (decline / post-apply). +function TotemBar.ClearAssignment() + TotemBar.pending = nil + if TotemBar.HideAssignPanel then + TotemBar.HideAssignPanel() + end +end + +-- Applies the pending suggestion: sets each KNOWN totem as the chosen +-- default for its element (unknown totems are skipped), refreshes the bar, +-- clears pending, hides the panel, and fires onAssignmentApplied. No cast. +function TotemBar.ApplyPending() + local p = TotemBar.pending + if not p then + return + end + local isKnown = TotemBar.isTotemKnown or function() return true end + local applied = TotemBar.filterKnown(p.set, isKnown) + TotemBarDB.chosen = TotemBarDB.chosen or {} + for element, name in pairs(applied) do + TotemBarDB.chosen[element] = name + end + if TotemBar.RefreshAll then + TotemBar.RefreshAll() + end + TotemBar.pending = nil + if TotemBar.HideAssignPanel then + TotemBar.HideAssignPanel() + end + if TotemBar.onAssignmentApplied then + TotemBar.onAssignmentApplied(applied) + end +end diff --git a/core/bindlogic.lua b/core/bindlogic.lua new file mode 100644 index 0000000..ddaf54f --- /dev/null +++ b/core/bindlogic.lua @@ -0,0 +1,73 @@ +-- TotemBar - core/bindlogic.lua +-- PURE helpers for keybindings + hover-bind mode (no WoW API), offline-testable. + +TotemBar = TotemBar or {} + +-- Binding-name suffix for a totem: uppercase, non-alphanumeric runs -> "_", +-- trimmed. "Grace of Air Totem" -> "GRACE_OF_AIR_TOTEM". MUST match the +-- Bindings.xml generation and the BINDING_NAME_ globals. +function TotemBar.bindingSuffix(name) + if not name then + return "" + end + local up = string.upper(name) + up = string.gsub(up, "[^A-Z0-9]+", "_") + up = string.gsub(up, "^_+", "") + up = string.gsub(up, "_+$", "") + return up +end + +-- Modifier prefix for a key string, order ALT-CTRL-SHIFT (each arg is 1/nil +-- as IsAltKeyDown()/IsControlKeyDown()/IsShiftKeyDown() return on 1.12). +function TotemBar.modifierPrefix(isAlt, isCtrl, isShift) + local p = "" + if isAlt then p = p .. "ALT-" end + if isCtrl then p = p .. "CTRL-" end + if isShift then p = p .. "SHIFT-" end + return p +end + +-- Binding COMMAND for a hovered thing, or nil. A flyout icon (totemName +-- given) -> the named per-totem binding (casts that specific totem, same +-- action as the Esc menu). A bar button (by global frame name) -> the +-- matching NAMED binding that already exists in Bindings.xml (NOT a +-- "CLICK ..." binding - those are unreliable on 1.12, see bind.lua). +function TotemBar.actionForButton(frameName, totemName) + if totemName and totemName ~= "" then + return "TOTEMBAR_TOTEM_" .. TotemBar.bindingSuffix(totemName) + end + if not frameName then + return nil + end + if frameName == "TotemBarButtonRecall" then + return "TOTEMBAR_RECALL" + end + if frameName == "TotemBarButtonDropSet" then + return "TOTEMBAR_DROPSET" + end + local elements = TotemBar.TOTEM_ELEMENTS + for i = 1, table.getn(elements) do + if frameName == "TotemBarButton" .. elements[i] then + return "TOTEMBAR_CAST_" .. string.upper(elements[i]) + end + end + return nil +end + +-- Compact a binding key string for a small button overlay: +-- "SHIFT-NUMPAD7" -> "sN7", "BUTTON4" -> "M4", "MOUSEWHEELUP" -> "MwU". +function TotemBar.shortenKey(key) + if not key or key == "" then + return "" + end + local s = key + s = string.gsub(s, "ALT%-", "a") + s = string.gsub(s, "CTRL%-", "c") + s = string.gsub(s, "SHIFT%-", "s") + s = string.gsub(s, "MOUSEWHEELUP", "MwU") + s = string.gsub(s, "MOUSEWHEELDOWN", "MwD") + s = string.gsub(s, "NUMPAD", "N") + s = string.gsub(s, "BUTTON", "M") + s = string.gsub(s, "SPACE", "Sp") + return s +end diff --git a/core/cast.lua b/core/cast.lua new file mode 100644 index 0000000..f991a1b --- /dev/null +++ b/core/cast.lua @@ -0,0 +1,647 @@ +-- TotemBar - core/cast.lua +-- Cast-cycle logic. The decision-making is split into two PURE, +-- offline-tested functions (nextIndex, findFilledSlot); castNext() is +-- the thin wrapper that touches CastSpellByName / GetTime / TotemBarDB. + +TotemBar = TotemBar or {} + +TotemBar.DEFAULT_GAP_SECONDS = 2 + +-- Anti double-press guard for recallAndCastAll: if a deploy happened within +-- this many seconds, a rapid second press SKIPS the recall (so it doesn't +-- pull the just-placed totems, which are still on their ~1.5s element +-- cooldown and couldn't be re-placed). +TotemBar.DEFAULT_RECALL_GUARD = 2 + +-- Default gap (px) between bar buttons. Matches ui.lua's file-scope +-- BUTTON_GAP default; the options panel's "Button spacing" slider (range +-- 10-30px) live-applies changes via TotemBar.SetButtonGap (ui.lua) and +-- persists them to TotemBarDB.buttonGap (core/config.lua). +TotemBar.DEFAULT_BUTTON_GAP = 10 + +-- Cycle state: which slot was cast last, and when. +TotemBar.castState = TotemBar.castState or { + index = 0, -- 0 = no cast yet (or state was reset) + lastTime = 0, + lastDeployTime = 0, +} + +-- Own-tracking table for the OmniCC-style remaining-duration display in +-- ui.lua: fallback source for when pfUI's libtotem (GetTotemInfo) isn't +-- present, or reports a given slot inactive. element -> {start=, +-- duration=}, both in TotemBar.recordCast() below. +TotemBar.activeTotems = TotemBar.activeTotems or {} + +-- Pure: given the previously cast slot index, the time of that previous +-- cast, the current time, the allowed gap (seconds) and the number of +-- slots, returns the next slot index (1-based) to advance to. +-- +-- - prevIndex <= 0 (never cast yet) -> 1 +-- - now - lastTime > gapSeconds -> 1 (fresh spam, start over) +-- - otherwise -> prevIndex + 1, wrapping from +-- numSlots back to 1 +function TotemBar.nextIndex(prevIndex, lastTime, now, gapSeconds, numSlots) + if not prevIndex or prevIndex <= 0 then + return 1 + end + if not lastTime or (now - lastTime) > gapSeconds then + return 1 + end + local nxt = prevIndex + 1 + if nxt > numSlots then + nxt = 1 + end + return nxt +end + +-- Pure: starting at startIndex, walk forward through `elements` +-- (wrapping past the end back to 1) and return the index of the first +-- element for which chosen[element] is truthy (a totem name). Returns +-- nil if none of the slots are filled. +function TotemBar.findFilledSlot(chosen, elements, startIndex) + local numSlots = table.getn(elements) + if numSlots == 0 or not startIndex then + return nil + end + for tries = 0, numSlots - 1 do + local slot = startIndex + tries + if slot > numSlots then + slot = slot - numSlots + end + if chosen[elements[slot]] then + return slot + end + end + return nil +end + +-- Pure: seconds remaining given a start time, a duration and the +-- current time. May return <= 0 (already expired); callers decide how +-- to treat that. Returns nil if either start or duration is missing. +function TotemBar.remaining(start, duration, now) + if not start or not duration then + return nil + end + return start + duration - now +end + +-- Pure: is any element's own-tracked totem still out (remaining > 0)? +function TotemBar.anyActiveTracked(activeTotems, elements, now, remainingFn) + if not activeTotems then + return false + end + for i = 1, table.getn(elements) do + local rec = activeTotems[elements[i]] + if rec then + local rem = remainingFn(rec.start, rec.duration, now) + if rem and rem > 0 then + return true + end + end + end + return false +end + +-- Pure: decides which of two already-computed remaining-seconds values +-- to show for one element's timer text. GetTotemInfo (pfUI's +-- libtotem), when it reports the slot active, is authoritative; +-- otherwise (absent, or reporting the slot inactive) falls back to +-- TotemBar's own cast-tracking. Returns nil when neither source has +-- time left. +function TotemBar.resolveRemaining(gtiActive, gtiRemaining, ownRemaining) + -- FIX 2026-07-15: trust GTI (pfUI libtotem) ONLY when it reports the slot active + -- AND with positive time left. Previously a stale-active GTI slot (active but + -- gtiRemaining<=0 -- libtotem evicts lazily on read) hit the bare `return nil` and + -- BLOCKED the timer even when own-tracking had a valid time. That produced the + -- Fire/Earth-dead, Air-flickering, Water-ok pattern (pfUI's single non-slot-indexed + -- cast queue loses the race for the slots cast later in a multi-drop). + if gtiActive and gtiRemaining and gtiRemaining > 0 then + return gtiRemaining + end + if ownRemaining and ownRemaining > 0 then + return ownRemaining + end + return nil +end + +-- Pure: OmniCC-style text for an already-known-positive remaining +-- seconds value: whole minutes rounded up from 60s on, plain rounded-up +-- integer seconds below that. +function TotemBar.formatRemaining(remaining) + if remaining >= 60 then + return string.format("%dm", math.ceil(remaining / 60)) + end + return string.format("%d", math.ceil(remaining)) +end + +-- Pure: strips a trailing rank suffix from a spell name as CastSpellByName +-- would receive it -- both "Searing Totem(Rank 4)" (no space, what the +-- client itself produces) and "Searing Totem (Rank 4)" (a space, common in +-- hand-typed macros) are accepted forms. Returns the input unchanged if it +-- carries no such suffix, or nil if name is nil. +function TotemBar.stripRankSuffix(name) + if not name then + return nil + end + local _, _, base = string.find(name, "^(.-)%s*%(.-%)%s*$") + if base and base ~= "" then + return base + end + return name +end + +-- Pure: given a spell name as CastSpellByName/GetSpellName would produce it +-- (optionally rank-suffixed, see stripRankSuffix above), returns the +-- element it belongs to and its rank-stripped base name -- or nil, nil if +-- rawName is nil or isn't one of TotemBar's known totems (core/totemdata.lua). +-- Used by the universal CastSpellByName/CastSpell hooks below to decide +-- whether a cast caught outside TotemBar's own paths is a totem at all. +function TotemBar.elementFromCastName(rawName) + if not rawName then + return nil, nil + end + local baseName = TotemBar.stripRankSuffix(rawName) + local element = TotemBar.elementOf(baseName) + if not element then + return nil, nil + end + return element, baseName +end + +-- Finds the spellbook index of a known spell by exact name, or nil. +-- Used to duplicate ui.lua's own file-local FindSpellIndexByName as its +-- own fresh linear scan (no common "api" module to hang a single copy +-- off). Both now route through core/spellindex.lua's cached +-- TotemBar.findSpellIndex (loaded earlier in the TOC) instead. + +-- Records that `totemName` was just cast into `element`'s slot, into +-- TotemBar's own tracking table (see activeTotems above). Touches +-- GetTime(), a spellbook index/texture scan and (for Searing Totem) a +-- rank scan, so it isn't pure; called from both the bar's left-click +-- path (ui.lua) and castNext()/castAll() below. +-- +-- Also stashes the totem spell's icon texture (rec.icon) and a +-- self-learning "did I ever see this totem's buff" flag +-- (rec.everHadBuff, starts false). ui.lua's out-of-range red-tint +-- feature is buff-presence based: a totem's party buff uses the SAME +-- icon texture as the totem spell itself (verified in-game), so +-- TotemBar.hasBuffWithIcon(rec.icon) tells whether the player is +-- currently benefiting from THIS cast totem. +function TotemBar.recordCast(element, totemName) + if not element or not totemName then + return + end + local highestRank = nil + if totemName == "Searing Totem" and TotemBar.highestKnownRank then + highestRank = TotemBar.highestKnownRank(totemName) + end + local icon = nil + local idx = TotemBar.findSpellIndex(totemName) + if idx then + icon = GetSpellTexture(idx, BOOKTYPE_SPELL) + end + local rec = { + start = GetTime(), + duration = TotemBar.durationWithMastery( + TotemBar.totemDuration(totemName, highestRank), + TotemBar.isHelpfulTotem(totemName), + TotemBar.hasTotemicMastery and TotemBar.hasTotemicMastery()), + totemName = totemName, + icon = icon, + everHadBuff = false, + } + TotemBar.activeTotems[element] = rec +end + +-- Records a totem cast caught by the universal CastSpellByName/CastSpell +-- hooks below, UNLESS TotemBar's own code already recorded this EXACT cast +-- this same GetTime() tick. Every TotemBar-exposed cast entry point +-- (bind.lua's CastTotem/CastElement, castNext/castAll/dropSetKey/ +-- recallAndCastAll above, ui.lua's bar/flyout click handlers) already calls +-- TotemBar.recordCast() itself right after casting -- verified by a full +-- inventory of every Bindings.xml binding, the "Totems" macro, and every +-- click handler (2026-07-16): none of them skip it. So a hook firing for a +-- cast that ALSO went through one of those paths would otherwise +-- double-record in the same frame (harmless -- recordCast just overwrites +-- with a near-identical timestamp -- but trivially avoidable with this one +-- check, so it is). +function TotemBar.recordCastFromHook(element, totemName) + local existing = TotemBar.activeTotems[element] + if existing and existing.totemName == totemName and existing.start == GetTime() then + return + end + TotemBar.recordCast(element, totemName) +end + +-- ===== Universal cast hooks: catch totem casts from ANY path ===== +-- Defense-in-depth for totems cast WITHOUT going through any TotemBar +-- function at all -- e.g. a hand-written macro's own `/cast Searing Totem`, +-- or another addon. Every path TotemBar itself exposes already calls +-- recordCast() (see recordCastFromHook's comment above) so this is a +-- safety net, not the primary fix for a "no countdown" report. +-- +-- Fixed arity on purpose (project rule: no vararg closures) -- both globals +-- have a stable, known 1.12 signature: CastSpellByName(name, onSelf), +-- CastSpell(spellId, bookType). +-- +-- Plain global reassignment, not hooksecurefunc -- pfUI provides +-- hooksecurefunc as its own polyfill (compat/vanilla.lua; native 1.12 has +-- no such function), and TotemBar must not hard-depend on pfUI being +-- loaded. Saving whatever function is CURRENTLY bound to the global and +-- calling it first (before doing our own extra work) composes correctly +-- with pfUI's own libtotem hook on CastSpellByName/CastSpell regardless of +-- addon load order: whichever wraps last just adds a layer on top of +-- whatever was already there. +-- +-- UseAction is intentionally NOT hooked. 1.12 has no GetActionInfo / +-- GetActionSpellId to read a spell off an action-bar slot directly; pfUI's +-- own UseAction coverage (libtotem.lua) only works by scanning a hidden +-- GameTooltip:SetAction(slot) via its private libtipscan library, wired up +-- through hooksecurefunc -- both the tooltip-scan trick and hooksecurefunc +-- are pfUI-internal, not something TotemBar can reimplement without either +-- a hard pfUI dependency or a whole new action-slot tooltip scanner (this +-- file already has a spellbook-index tooltip scanner for mana costs, see +-- core/manacost.lua, but nothing that scans by action slot). Given every +-- TotemBar-native path already records, and pfUI's GetTotemInfo already +-- covers action-bar-dragged totem casts as the resolveRemaining/ +-- resolveDuration fallback source when pfUI is present, this gap was +-- judged not worth the added dependency/complexity. +if type(CastSpellByName) == "function" then + local origCastSpellByName = CastSpellByName + CastSpellByName = function(name, onSelf) + origCastSpellByName(name, onSelf) + local element, baseName = TotemBar.elementFromCastName(name) + if element then + TotemBar.recordCastFromHook(element, baseName) + end + end +end + +if type(CastSpell) == "function" then + local origCastSpell = CastSpell + CastSpell = function(spellId, bookType) + origCastSpell(spellId, bookType) + if type(GetSpellName) == "function" then + local rawName = GetSpellName(spellId, bookType) + local element, baseName = TotemBar.elementFromCastName(rawName) + if element then + TotemBar.recordCastFromHook(element, baseName) + end + end + end +end + +-- Module-scratch table for the buff-texture scan below, reused every +-- call (hasBuffWithIcon runs ~5x/sec, from ui.lua's throttled timer +-- tick) so it doesn't allocate a new table each time. buffScratchLen +-- tracks how far the previous scan filled it, so leftover entries past +-- the new scan's length get nilled out - keeping it a clean, hole-free +-- 1..n array (table.getn needs that to be reliable in Lua 5.0). +local buffScratch = {} +local buffScratchLen = 0 + +-- Pure: given a flat array of buff texture path strings (some entries +-- may be nil) and a totem spell's icon texture path, returns true if +-- any buff texture matches iconPath via a case-insensitive literal +-- substring search (tolerates path/casing differences between +-- GetSpellTexture's and UnitBuff's returned strings). Returns false if +-- iconPath or buffTexList is nil, or nothing matches. +function TotemBar.buffTexturesMatch(buffTexList, iconPath) + if not iconPath or not buffTexList then + return false + end + local needle = string.lower(iconPath) + for i = 1, table.getn(buffTexList) do + local tex = buffTexList[i] + if tex and string.find(string.lower(tex), needle, 1, true) then + return true + end + end + return false +end + +-- Thin WoW-API wrapper: scans the player's current buffs (UnitBuff +-- "player" 1..32, stopping at the first nil slot) into the reusable +-- buffScratch table, then hands it to the pure +-- TotemBar.buffTexturesMatch() above. This is the "am I in this totem's +-- range?" signal for ui.lua's red-tint feature: a totem's party buff +-- shares its spell's icon texture (verified in-game), so having a +-- matching buff means the totem is currently affecting the player. +function TotemBar.hasBuffWithIcon(iconPath) + if not iconPath then + return false + end + local n = 0 + for i = 1, 32 do + local tex = UnitBuff("player", i) + if not tex then + break + end + n = n + 1 + buffScratch[n] = tex + end + for i = n + 1, buffScratchLen do + buffScratch[i] = nil + end + buffScratchLen = n + return TotemBar.buffTexturesMatch(buffScratch, iconPath) +end + +-- Clears every own-tracked totem timer at once (e.g. after Totemic +-- Recall, which drops all active totems simultaneously). +function TotemBar.clearActiveTotems() + for i = 1, table.getn(TotemBar.TOTEM_ELEMENTS) do + TotemBar.activeTotems[TotemBar.TOTEM_ELEMENTS[i]] = nil + end +end + +-- Is at least one totem currently out? Used to avoid wasting Totemic Recall's +-- own 6-second cooldown on a no-op cast: recalling with nothing out still puts +-- Recall on cooldown, so a fresh set placed right after can't be recalled for +-- 6s. Permissive on purpose - returns true if EITHER our own cast-tracking OR +-- GetTotemInfo (pfUI libtotem / SuperWoW, when present) reports a totem out, so +-- a legitimate recall is never wrongly blocked; only when both agree nothing is +-- out do we suppress the cast. +function TotemBar.anyTotemOut() + if TotemBar.anyActiveTracked(TotemBar.activeTotems, TotemBar.TOTEM_ELEMENTS, + GetTime(), TotemBar.remaining) then + return true + end + if GetTotemInfo then + for slot = 1, 4 do + if GetTotemInfo(slot) then + return true + end + end + end + return false +end + +-- pure: does the addon currently hold ANY tracked totem record (regardless of expiry)? +-- TotemBar.activeTotems is an in-memory table cleared to {} on load (see top of this file, +-- NOT persisted to SavedVariables), so a non-empty table proves we have cast at least one +-- totem THIS session and can reason about whether it is still out. +function TotemBar.hasTrackedTotems(activeTotems, elements) + if not activeTotems then return false end + for i = 1, table.getn(elements) do + if activeTotems[elements[i]] then return true end + end + return false +end + +-- Should the manual-recall gate BLOCK the recall? Only when we are CONFIDENT that nothing +-- is out: we have tracked at least one totem THIS session AND all tracked totems have +-- expired. When tracking is empty (fresh load, or after a /reload -- activeTotems is +-- in-memory only and reset on load) the state is UNKNOWN, not "none out": on 1.12 there is +-- no GetTotemInfo / no "totemN" UnitID to re-detect a totem that was cast BEFORE the reload +-- (KG-confirmed 2026-07-14 -- no reload-proof detection exists for all totem types). So in +-- the unknown case FAIL OPEN -- let the recall through rather than falsely reporting "no +-- totems out" and blocking a legitimate recall of a still-standing totem (the bug this fixes: +-- our reloads wiped the tracking while the totems stayed physically out). Worst case of +-- fail-open is one needless recall right after a reload -- far cheaper than a blocked one. +function TotemBar.confidentNoneOut() + if not TotemBar.hasTrackedTotems(TotemBar.activeTotems, TotemBar.TOTEM_ELEMENTS) then + return false + end + return not TotemBar.anyTotemOut() +end + +-- Casts exactly ONE totem per call: the next slot in Fire -> Earth -> +-- Water -> Air order, skipping empty (unassigned) slots. If more than +-- gapSeconds has passed since the previous call, the cycle restarts at +-- the first filled slot (so a fresh spam always begins with totem 1). +-- +-- Intended for a macro: `/script TotemBar.castNext()` +function TotemBar.castNext() + local db = TotemBarDB + local chosen = (db and db.chosen) or {} + local gap = (db and db.gapSeconds) or TotemBar.DEFAULT_GAP_SECONDS + local elements = TotemBar.TOTEM_ELEMENTS + local numSlots = table.getn(elements) + local now = GetTime() + + local state = TotemBar.castState + local startIdx = TotemBar.nextIndex(state.index, state.lastTime, now, gap, numSlots) + local slot = TotemBar.findFilledSlot(chosen, elements, startIdx) + + if not slot then + -- Nothing assigned to any element; nothing to cast. + state.index = 0 + state.lastTime = now + return nil, nil + end + + local element = elements[slot] + local totemName = chosen[element] + CastSpellByName(totemName) + TotemBar.recordCast(element, totemName) + state.index = slot + state.lastTime = now + return totemName, element +end + +-- Casts ALL filled slots in a single call (Fire -> Earth -> Water -> +-- Air). On TurtleWoW each totem element has its own cooldown, so this +-- MAY drop all four from one keypress. Whether 4 CastSpellByName calls +-- in one Lua frame all land (vs only the last "winning") is unverified +-- on this client -- offered as a one-press alternative to castNext() to +-- test in-game. +-- +-- Intended for a macro: `/script TotemBar.castAll()` +function TotemBar.castAll() + local db = TotemBarDB + local chosen = (db and db.chosen) or {} + local elements = TotemBar.TOTEM_ELEMENTS + for i = 1, table.getn(elements) do + local element = elements[i] + local totemName = chosen[element] + if totemName then + CastSpellByName(totemName) + TotemBar.recordCast(element, totemName) + end + end +end + +-- Pure: should recallAndCastAll fire Totemic Recall this call? +-- autoRecall off -> false (never recall) +-- never deployed (nil / <=0) -> true (nothing fresh to protect) +-- last deploy > guard ago -> true +-- last deploy within guard -> false (protect just-placed totems from a +-- rapid accidental second press) +function TotemBar.shouldRecall(autoRecall, lastDeployTime, now, guardSeconds) + if not autoRecall then + return false + end + if not lastDeployTime or lastDeployTime <= 0 then + return true + end + if not guardSeconds then + return true + end + if (now - lastDeployTime) > guardSeconds then + return true + end + return false +end + +-- Casts Totemic Recall bypassing nampower's spell queue when that API is +-- present. Root cause of the "place a set, it vanishes a moment later" bug: +-- under nampower, a plain CastSpellByName of Totemic Recall (a GCD-tied +-- instant) issued while a GCD is active is QUEUED, not cast, and pops at +-- GCD-end -- by which time the totems are already down (TWoW gives each totem +-- element its own non-GCD recovery category, so the 4 totems fire immediately +-- with queue priority). The late Recall then sweeps the fresh set. Bypassing +-- the queue means a GCD-blocked Recall simply fails that press instead of +-- being deferred, so it can never fire late and tear the totems down. Falls +-- back to a plain cast when nampower isn't installed (the function is nil). +-- (KG: NP_QueueInstantSpells default 1 is the deferral; CastSpellByNameNoQueue +-- is nampower's queue-bypass cast.) +local function castRecallNoQueue() + if type(CastSpellByNameNoQueue) == "function" then + CastSpellByNameNoQueue("Totemic Recall") + else + CastSpellByName("Totemic Recall") + end +end + +-- Recall-then-deploy: when TotemBarDB.autoRecall is on (the default - +-- toggleable via the Recall button's right-click, see ui.lua), casts +-- Totemic Recall FIRST (drops existing totems and refunds some mana) +-- and clears own-tracking, then always places all filled slots via +-- castAll(). One keypress = recall + redeploy (or just redeploy, with +-- the flag off). Like castAll, relies on TurtleWoW allowing several +-- CastSpellByName calls in one Lua frame -- verify in-game. +-- +-- Guarded against rapid double-presses: shouldRecall() only fires Recall +-- if the last deploy was more than DEFAULT_RECALL_GUARD seconds ago. A +-- fast accidental second press then just re-attempts placement (a no-op, +-- since the totems are still up and each element is on its own ~1.5s +-- cooldown) instead of recalling the totems that were just placed. +-- +-- Intended for a macro: `/script TotemBar.recallAndCastAll()` +function TotemBar.recallAndCastAll() + local now = GetTime() + local autoRecall = TotemBarDB and TotemBarDB.autoRecall + local guard = (TotemBarDB and TotemBarDB.recallGuardSeconds) or TotemBar.DEFAULT_RECALL_GUARD + if TotemBar.shouldRecall(autoRecall, TotemBar.castState.lastDeployTime, now, guard) + and TotemBar.anyTotemOut() then + castRecallNoQueue() + TotemBar.clearActiveTotems() + end + TotemBar.castAll() + TotemBar.castState.lastDeployTime = now +end + +-- Key-down/key-up split for the DropSet keybind. Bound in Bindings.xml with +-- runOnUp="true", so this runs on BOTH flanks with the global `keystate` set +-- to "down" / "up" beforehand. Casting Totemic Recall on the DOWN stroke and +-- placing the set on the RELEASE. The real fix for the teardown is +-- castRecallNoQueue() (see above) -- bypassing nampower's queue so a +-- GCD-blocked Recall never fires late and sweeps the fresh set. The down/up +-- split adds belt-and-suspenders temporal separation: the Recall on the down +-- stroke has definitively resolved-or-failed (it is never queued) by the time +-- the release places the set. Both flanks are real hardware events, so both +-- may cast (Blizzard's own ActionButtonUp casts on release too), unlike a +-- timer-deferred placement which this client blocks as a non-hardware cast. +-- +-- Still guarded by shouldRecall()'s 2s window (a rapid re-press within the +-- guard skips the Recall so it can't pull the just-placed set). keystate is +-- nil only if the binding ran without runOnUp (misconfig / older client); +-- treat nil like "up" so a single fire at least still PLACES rather than +-- silently doing nothing. +function TotemBar.dropSetKey(keystate) + if keystate == "down" then + local now = GetTime() + local autoRecall = TotemBarDB and TotemBarDB.autoRecall + local guard = (TotemBarDB and TotemBarDB.recallGuardSeconds) or TotemBar.DEFAULT_RECALL_GUARD + if TotemBar.shouldRecall(autoRecall, TotemBar.castState.lastDeployTime, now, guard) + and TotemBar.anyTotemOut() then + castRecallNoQueue() + TotemBar.clearActiveTotems() + end + else + -- Release (or nil fallback): place the set now, in this hardware frame. + TotemBar.castAll() + TotemBar.castState.lastDeployTime = GetTime() + end +end + +-- Dev aid (/tb tdump): dumps TotemBar's own per-element cast-tracking +-- (TotemBar.activeTotems, the resolveRemaining/resolveDuration own-tracking +-- fallback source, see this file's header comment) side by side with the +-- RAW GetTotemInfo(1..4) (pfUI libtotem, when present -- the OTHER, +-- normally-authoritative source), so a "countdown missing" report can be +-- diagnosed off-client without an in-game screenshot: did TotemBar ever +-- record this cast at all, and what does GTI say about the same slot right +-- now? Also appends ui.lua's TotemBar.DumpRingRenderState() output (2026-07- +-- 16, Searing-ring bug: countdown TEXT shows but no pulse RING) -- the pure +-- ring math was already proven correct offline, so this section captures +-- the WoW-side render state instead: cached ring flags plus the LIVE +-- ringFill/ringTrack texture objects (shown/texture/alpha/texCoord/parent), +-- see that function's own comment for the full field list. Written to +-- \imports\tb_tdump.txt via SuperWoW's ExportFile (name WITHOUT +-- the .txt extension -- ExportFile appends it itself); chat fallback when +-- SuperWoW isn't present. GetTotemInfo is pcall-wrapped since it's +-- third-party (pfUI) code this dump must never itself error on. +function TotemBar.DumpTimerState() + local now = GetTime() + local els = TotemBar.TOTEM_ELEMENTS + local activeTotems = TotemBar.activeTotems + local out = "TotemBar timer-state dump (now=" .. tostring(now) .. ")\n" + + out = out .. "\n-- own tracking (TotemBar.activeTotems) --\n" + for i = 1, table.getn(els) do + local element = els[i] + local rec = activeTotems[element] + if rec then + local rem = TotemBar.remaining(rec.start, rec.duration, now) + out = out .. element .. ": spell='" .. tostring(rec.totemName) .. "'" + .. " start=" .. tostring(rec.start) + .. " duration=" .. tostring(rec.duration) + .. " remaining=" .. tostring(rem) .. "\n" + else + out = out .. element .. ": (no record)\n" + end + end + + out = out .. "\n-- raw GetTotemInfo(1..4) (pfUI libtotem, when present) --\n" + if type(GetTotemInfo) == "function" then + for slot = 1, 4 do + local ok, active, name, start, duration = pcall(GetTotemInfo, slot) + if ok then + out = out .. "slot " .. slot .. ": active=" .. tostring(active) + .. " name='" .. tostring(name) .. "'" + .. " start=" .. tostring(start) + .. " duration=" .. tostring(duration) .. "\n" + else + out = out .. "slot " .. slot .. ": pcall error: " .. tostring(active) .. "\n" + end + end + else + out = out .. "(GetTotemInfo not present -- no pfUI libtotem loaded)\n" + end + + -- Render-state section (ui.lua): the pure timer/duration/ring math + -- above was already proven correct offline for the Searing-ring + -- report (text shows, no ring) -- this is the other half, the + -- WoW-side render state (cached ring flags + LIVE texture objects) + -- that this dump previously never captured. pcall-wrapped: ui.lua + -- loads after this file (see TotemBar.toc), but by the time a player + -- runs /tb tdump everything is loaded; the guard just keeps this dump + -- from erroring if that ever isn't true (e.g. a future load-order + -- change) or if TotemBar.DumpRingRenderState itself hits something + -- unexpected. + local okRender, renderOut = pcall(TotemBar.DumpRingRenderState) + if okRender and renderOut then + out = out .. renderOut + else + out = out .. "\n-- render state: unavailable (" .. tostring(renderOut) .. ") --\n" + end + + if ExportFile then + ExportFile("tb_tdump", out) + end + if DEFAULT_CHAT_FRAME then + DEFAULT_CHAT_FRAME:AddMessage("TotemBar: tdump exported.") + end +end diff --git a/core/config.lua b/core/config.lua new file mode 100644 index 0000000..1dfff24 --- /dev/null +++ b/core/config.lua @@ -0,0 +1,84 @@ +-- TotemBar - core/config.lua +-- SavedVariables model. TotemBarDB is declared in the .toc as a +-- SavedVariable; ensureDefaults() fills in anything missing (first run, +-- or a saved file from an older version that lacks newer fields). + +TotemBar = TotemBar or {} + +TotemBarDB = TotemBarDB or {} + +-- TotemBarDB shape: +-- chosen[element] = totemName ("Fire" -> "Searing Totem", ...) +-- gapSeconds = spam-cycle reset gap (seconds) +-- locked = boolean, whether the bar can be dragged +-- autoRecall = boolean, whether recallAndCastAll() prepends +-- Totemic Recall (toggled via the Recall button's +-- right-click, see ui.lua); default ON +-- point/relPoint/x/y = saved frame anchor (see ui.lua) +-- showDurationRing/ringStyle/showPulseBars/showPulseWaves/ +-- showTimerText = Pulse UI (see spec). showPulseBars = the countdown +-- bar (primary "when's the next pulse" readout); +-- showPulseWaves = the ripple (event feedback only); +-- independently toggleable. +-- barLayout = bar arrangement: "1x6"|"2x3"|"3x2" (rows x cols), +-- cycled via the options panel (see ui.lua's +-- ApplyBarLayout / options.lua's layout button). +-- showDropSetButton = boolean, whether the "Drop all totems" (DropSet) +-- button is shown on the bar; default ON, so existing +-- users keep it. Visibility only: the button is always +-- created, and its keybinding (BINDING_NAME_TOTEMBAR_ +-- DROPSET) plus the "Totems" macro keep working while +-- it is hidden - same idea as the "Show bar" toggle. +-- buttonGap = px gap between bar buttons, range 10-30, default +-- TotemBar.DEFAULT_BUTTON_GAP (see core/cast.lua); +-- live-applied via ui.lua's TotemBar.SetButtonGap, +-- set from the options panel's "Button spacing" slider. +function TotemBar.ensureDefaults() + TotemBarDB.chosen = TotemBarDB.chosen or {} + TotemBarDB.gapSeconds = TotemBarDB.gapSeconds or TotemBar.DEFAULT_GAP_SECONDS + if TotemBarDB.locked == nil then + TotemBarDB.locked = false + end + if TotemBarDB.autoRecall == nil then + TotemBarDB.autoRecall = true + end + TotemBarDB.point = TotemBarDB.point or "CENTER" + TotemBarDB.relPoint = TotemBarDB.relPoint or "CENTER" + TotemBarDB.x = TotemBarDB.x or 0 + TotemBarDB.y = TotemBarDB.y or 0 + TotemBarDB.scale = TotemBarDB.scale or 1.0 + TotemBarDB.minimapAngle = TotemBarDB.minimapAngle or 225 + if TotemBarDB.hidden == nil then + TotemBarDB.hidden = false + end + TotemBarDB.recallGuardSeconds = TotemBarDB.recallGuardSeconds or TotemBar.DEFAULT_RECALL_GUARD + TotemBarDB.recallRefundPct = TotemBarDB.recallRefundPct or 0.25 + TotemBarDB.buttonGap = TotemBarDB.buttonGap or TotemBar.DEFAULT_BUTTON_GAP + + -- Pulse UI (spec docs/superpowers/specs/2026-07-09-pulse-ui-design.md): + -- duration ring + pulse bars, all on by default; ringStyle "round" vs + -- "square" is the in-game comparison toggle. + if TotemBarDB.showDurationRing == nil then + TotemBarDB.showDurationRing = true + end + TotemBarDB.ringStyle = TotemBarDB.ringStyle or "round" + if TotemBarDB.showPulseBars == nil then + TotemBarDB.showPulseBars = true + end + if TotemBarDB.showPulseWaves == nil then + TotemBarDB.showPulseWaves = true + end + -- dead setting removed in v0.2.2, clear persisted dust + TotemBarDB.pulseGlow = nil + if TotemBarDB.showTimerText == nil then + TotemBarDB.showTimerText = true + end + + -- Bar contents: the DropSet button is optional, shown by default so an + -- upgrade never makes it disappear (visibility only, see the shape note). + if TotemBarDB.showDropSetButton == nil then + TotemBarDB.showDropSetButton = true + end + + TotemBarDB.barLayout = TotemBarDB.barLayout or "1x6" +end diff --git a/core/known.lua b/core/known.lua new file mode 100644 index 0000000..6062118 --- /dev/null +++ b/core/known.lua @@ -0,0 +1,51 @@ +-- TotemBar - core/known.lua +-- knownTotems() is PURE (no WoW API) and offline-testable. +-- The WoW-API spellbook scan it consumes (scanSpellbook) now lives in +-- core/spellindex.lua, alongside the other spellbook-index accessors +-- (findSpellIndex/findHighestRankSlot/highestKnownRank) -- kept minimal +-- and separate so the pure filtering logic below never has to touch the +-- real API to be tested. + +TotemBar = TotemBar or {} + +-- Pure: given a flat list of spell names (strings) known to the player +-- and an element name ("Fire"/"Earth"/"Water"/"Air"), returns an array +-- of the subset of those names that are totems belonging to that +-- element, in the static map's order. +-- +-- - Names not present in the static map (non-totem spells) are ignored. +-- - Totems in the static map that aren't in spellNames (not known) are +-- omitted from the result. +-- - An unknown/unmapped element returns an empty array. +function TotemBar.knownTotems(spellNames, element) + local result = {} + local candidates = TotemBar.TOTEMS_BY_ELEMENT[element] + if not candidates then + return result + end + + local known = {} + if spellNames then + for i = 1, table.getn(spellNames) do + known[spellNames[i]] = true + end + end + + local n = 0 + for i = 1, table.getn(candidates) do + local name = candidates[i] + if known[name] then + n = n + 1 + result[n] = name + end + end + + return result +end + +-- TotemBar.scanSpellbook() and TotemBar.highestKnownRank() used to be +-- defined here as thin WoW-API wrappers, each doing its own fresh linear +-- GetSpellName() scan. They're now cache-backed views provided by +-- core/spellindex.lua (loaded earlier in the TOC) instead, so a totem +-- drop no longer re-walks the spellbook once per caller. Same names, +-- same signatures, same return values -- callers here are unchanged. diff --git a/core/manacost.lua b/core/manacost.lua new file mode 100644 index 0000000..c36c767 --- /dev/null +++ b/core/manacost.lua @@ -0,0 +1,277 @@ +-- TotemBar - core/manacost.lua +-- Mana-cost reading + refund/duration helpers. The PURE functions here are +-- offline-tested; the WoW-API pieces (hidden-tooltip scan, talent scan, refund +-- learner) are appended in a later task and are guarded so this file loads +-- fine under plain Lua for the tests. + +TotemBar = TotemBar or {} + +-- Pure: mana cost from a tooltip line like "155 Mana" -> 155, else nil. +function TotemBar.parseManaCost(text) + if not text then + return nil + end + -- Anchor to the "Mana" keyword so a numeric line like "30 sec cooldown" + -- can't be mistaken for the cost (don't rely on tooltip line ordering). + local _, _, n = string.find(text, "^(%d+) [Mm]ana") + if n then + return tonumber(n) + end + return nil +end + +-- Pure: sum costFn(chosen[element]) over the elements that have a chosen totem. +function TotemBar.sumChosenCost(chosen, elements, costFn) + local total = 0 + if not chosen then + return 0 + end + for i = 1, table.getn(elements) do + local name = chosen[elements[i]] + if name then + local c = costFn(name) + if c then + total = total + c + end + end + end + return total +end + +-- Pure: sum cost of active totems that are still out (remaining > 0). +function TotemBar.sumActiveCost(activeTotems, elements, now, costFn, remainingFn) + local total = 0 + if not activeTotems then + return 0 + end + for i = 1, table.getn(elements) do + local rec = activeTotems[elements[i]] + if rec then + local rem = remainingFn(rec.start, rec.duration, now) + if rem and rem > 0 then + local c = costFn(rec.totemName) + if c then + total = total + c + end + end + end + end + return total +end + +-- Pure: floored refund. +function TotemBar.refundAmount(pct, activeCost) + if not pct or not activeCost then + return 0 + end + return math.floor(pct * activeCost) +end + +-- Pure: learn refund pct from an observed mana gain; nil if out of sane range. +function TotemBar.learnRefundPct(manaGained, activeCost) + if not manaGained or not activeCost or activeCost <= 0 then + return nil + end + local pct = manaGained / activeCost + if pct < 0.05 or pct > 1.0 then + return nil + end + return pct +end + +-- Pure: helpful totems get Totemic Mastery's +20% duration. +function TotemBar.durationWithMastery(baseDuration, isHelpful, hasMastery) + if hasMastery and isHelpful then + return baseDuration * 1.2 + end + return baseDuration +end + +-- The pure fire-DAMAGE totems do NOT get the +20% helpful-totem duration. +-- Everything else is treated as helpful. VERIFY in-game and adjust. +TotemBar.NON_HELPFUL_TOTEMS = { + ["Searing Totem"] = true, + ["Magma Totem"] = true, + ["Fire Nova Totem"] = true, +} + +function TotemBar.isHelpfulTotem(name) + if not name then + return false + end + if TotemBar.NON_HELPFUL_TOTEMS[name] then + return false + end + return true +end + +-- ===== WoW-API layer (not offline-executed) ===== + +-- TotemBar.findHighestRankSlot(name) used to be defined here as its own +-- fresh linear GetSpellName() scan per call. It's now a cache-backed +-- accessor provided by core/spellindex.lua (loaded earlier in the TOC) -- +-- same name, same signature, same resolved slot (the HIGHEST known rank, +-- which is the one CastSpellByName actually casts; a first-match scan +-- would return rank 1 -> cost far too low, the bug behind "the mana +-- values are wrong"). Shared: ui.lua's tooltips resolve through this too - +-- SetSpell with a first-name-match slot shows the RANK 1 tooltip (wrong +-- mana/duration). + +local scanTip = nil +local manaCache = {} -- name -> cost (only positive results cached) + +-- Reads a totem's mana cost via a hidden tooltip scan (no GetSpellManaCost on +-- 1.12). Reflects cost talents (Tidal Focus, Restorative Totems) automatically. +-- Cached by name; cache cleared on SPELLS_CHANGED (talent/rank changes). +function TotemBar.getTotemManaCost(name) + if not name then return nil end + if manaCache[name] then return manaCache[name] end + local idx = TotemBar.findHighestRankSlot(name) + if not idx then return nil end + if not scanTip then + scanTip = CreateFrame("GameTooltip", "TotemBarScanTooltip", nil, "GameTooltipTemplate") + end + scanTip:SetOwner(WorldFrame, "ANCHOR_NONE") + scanTip:ClearLines() + scanTip:SetSpell(idx, BOOKTYPE_SPELL) + local cost = nil + local lines = scanTip:NumLines() or 0 + for i = 1, lines do + local fs = getglobal("TotemBarScanTooltipTextLeft" .. i) + local text = fs and fs:GetText() + local c = TotemBar.parseManaCost(text) + if c then + cost = c + break + end + end + if cost then manaCache[name] = cost end + return cost +end + +-- Dev aid (/tb manadump): dump the RAW mana-cost scan for the chosen totems + +-- Totemic Recall to \imports\totembar_manadump.txt, so the resolved +-- slot/rank, every tooltip line, and the parsed cost can be verified +-- off-client (ground truth for the mana-cost feature). Chat fallback if no +-- SuperWoW ExportFile. +function TotemBar.dumpManaScan() + local els = TotemBar.TOTEM_ELEMENTS + local chosen = (TotemBarDB and TotemBarDB.chosen) or {} + local list = {} + for i = 1, table.getn(els) do + local nm = chosen[els[i]] + if nm then list[table.getn(list) + 1] = nm end + end + list[table.getn(list) + 1] = "Totemic Recall" + if not scanTip then + scanTip = CreateFrame("GameTooltip", "TotemBarScanTooltip", nil, "GameTooltipTemplate") + end + local out = "TotemBar mana scan dump\n" + local total = 0 + for i = 1, table.getn(list) do + local nm = list[i] + local idx = TotemBar.findHighestRankSlot(nm) + out = out .. "\n[" .. nm .. "] slot=" .. tostring(idx) + if idx then + local rn, rr = GetSpellName(idx, BOOKTYPE_SPELL) + out = out .. " resolved='" .. tostring(rn) .. "' rank='" .. tostring(rr) .. "'\n" + scanTip:SetOwner(WorldFrame, "ANCHOR_NONE") + scanTip:ClearLines() + scanTip:SetSpell(idx, BOOKTYPE_SPELL) + local lines = scanTip:NumLines() or 0 + local cost = nil + for L = 1, lines do + local fs = getglobal("TotemBarScanTooltipTextLeft" .. L) + local text = fs and fs:GetText() + out = out .. " L" .. L .. ": " .. tostring(text) .. "\n" + if not cost then + local c = TotemBar.parseManaCost(text) + if c then cost = c end + end + end + out = out .. " parsedCost=" .. tostring(cost) .. "\n" + if cost and nm ~= "Totemic Recall" then total = total + cost end + else + out = out .. " (not found in spellbook)\n" + end + end + out = out .. "\nsumChosenCost(live)=" .. tostring( + TotemBar.sumChosenCost(chosen, els, TotemBar.getTotemManaCost)) .. "\n" + out = out .. "dumpTotal(chosen)=" .. total .. "\n" + if ExportFile then + ExportFile("totembar_manadump", out) + end + if DEFAULT_CHAT_FRAME then + DEFAULT_CHAT_FRAME:AddMessage( + "TotemBar: mana dump written (imports\\totembar_manadump.txt). chosen sum=" .. total) + end +end + +-- Totemic Mastery (TWoW: +20% helpful-totem duration): cached scan of the +-- talent trees by name. +local masteryCached = false +local function scanMastery() + masteryCached = false + local tabs = (GetNumTalentTabs and GetNumTalentTabs()) or 0 + for tab = 1, tabs do + local num = GetNumTalents(tab) + for i = 1, num do + local tname, _, _, _, rank = GetTalentInfo(tab, i) + if tname == "Totemic Mastery" and rank and rank > 0 then + masteryCached = true + end + end + end +end + +function TotemBar.hasTotemicMastery() + return masteryCached +end + +-- Recall-refund auto-learn. Snapshot the summed cost of the totems currently +-- out just before a DELIBERATE Totemic Recall; when the mana-gain message +-- arrives shortly after, learn the real refund %. +TotemBar.recallPendingCost = 0 +local recallExpectUntil = 0 + +function TotemBar.snapshotRecallCost() + TotemBar.recallPendingCost = TotemBar.sumActiveCost( + TotemBar.activeTotems, TotemBar.TOTEM_ELEMENTS, GetTime(), + TotemBar.getTotemManaCost, TotemBar.remaining) + recallExpectUntil = GetTime() + 2 +end + +-- Events: refresh mastery, clear the cost cache on spell changes, and learn +-- the refund % from the recall mana-gain message. +-- Guarded: CreateFrame is nil under plain offline Lua, so this whole block is +-- skipped there (loadfile/dofile must not error for the test suite). +if CreateFrame then + local mcEvents = CreateFrame("Frame", "TotemBarManaCostEventFrame", UIParent) + mcEvents:RegisterEvent("PLAYER_ENTERING_WORLD") + mcEvents:RegisterEvent("CHARACTER_POINTS_CHANGED") + mcEvents:RegisterEvent("SPELLS_CHANGED") + mcEvents:RegisterEvent("CHAT_MSG_SPELL_SELF_BUFF") + mcEvents:SetScript("OnEvent", function() + if event == "PLAYER_ENTERING_WORLD" or event == "CHARACTER_POINTS_CHANGED" then + scanMastery() + elseif event == "SPELLS_CHANGED" then + manaCache = {} + scanMastery() + elseif event == "CHAT_MSG_SPELL_SELF_BUFF" then + if GetTime() > recallExpectUntil then return end + local msg = arg1 + if not msg then return end + -- Learn only from a Totemic Recall mana-gain within the window. + -- NOTE: exact message text is locale/format dependent - VERIFY in-game + -- (adjust the "Totemic Recall" + number pattern if needed). + if string.find(msg, "Totemic Recall") then + local _, _, num = string.find(msg, "(%d+)") + local gained = tonumber(num) + local pct = TotemBar.learnRefundPct(gained, TotemBar.recallPendingCost) + if pct and TotemBarDB then + TotemBarDB.recallRefundPct = pct + end + end + end + end) +end diff --git a/core/optionslogic.lua b/core/optionslogic.lua new file mode 100644 index 0000000..24e8457 --- /dev/null +++ b/core/optionslogic.lua @@ -0,0 +1,73 @@ +-- TotemBar - core/optionslogic.lua +-- PURE helpers for the options panel + minimap button (no WoW API), so the +-- fiddly bits (orbit math, slider clamping, the macro contract) are +-- offline-testable under real Lua 5.0. The UI (minimap.lua / options.lua) +-- calls these. + +TotemBar = TotemBar or {} + +-- Orbit offset (x, y) for a minimap button at angleDeg on a circle of the +-- given radius. 0deg -> (radius,0); 90 -> (0,radius); 180 -> (-radius,0). +function TotemBar.angleToOffset(angleDeg, radius) + local rad = math.rad(angleDeg) + return math.cos(rad) * radius, math.sin(rad) * radius +end + +-- Clamp v into [minVal, maxVal]. +function TotemBar.clampValue(v, minVal, maxVal) + if v < minVal then + return minVal + end + if v > maxVal then + return maxVal + end + return v +end + +-- The fixed spec for the "Totems" convenience macro: name, body, and a BARE +-- icon file name (no Interface\Icons\ prefix, no extension - TurtleWoW's +-- CreateMacro/EditMacro prepend the path themselves). +function TotemBar.macroSpec() + return "Totems", "/script TotemBar.recallAndCastAll()", "Spell_Nature_TremorTotem" +end + +-- Bar-layout grid data + math (feature: selectable 1x6 / 2x3 / 3x2 bar +-- arrangement, see ui.lua's ApplyBarLayout). Pure so the button positions +-- and pixel-size math can be pinned offline instead of eyeballed in-game. + +-- Explicit per-layout button positions {col, row} (0-based), button order: +-- Fire, Earth, Water, Air, Recall, DropSet. "2x3" groups the four element +-- totems as a 2x2 block in the first two columns (user request); "3x2" +-- groups them as the top 2x2 block with the utility row below. +-- DropSet is deliberately the LAST slot in every layout: when it is hidden +-- (TotemBarDB.showDropSetButton = false) the remaining five buttons keep +-- their exact slots - the element 2x2 block and Recall never move, and no +-- second positions table is needed. The multi-row layouts then leave the +-- trailing grid cell empty, which is invisible (transparent backdrop) and +-- still usable as drag surface. +TotemBar.BAR_LAYOUT_POSITIONS = { + ["1x6"] = { {0,0},{1,0},{2,0},{3,0},{4,0},{5,0} }, + ["2x3"] = { {0,0},{1,0},{0,1},{1,1},{2,0},{2,1} }, + ["3x2"] = { {0,0},{1,0},{0,1},{1,1},{0,2},{1,2} }, +} + +-- Pixel dimensions of the bar frame for a grid of `count` buttons arranged +-- in at most `cols` columns of `size`x`size` buttons with `gap` spacing. +-- `rowPitchExtra` widens the vertical pitch between rows (multi-row layouts: +-- room for the timer text hanging below each row plus the next row's ring +-- overhang - see ui.lua's BUTTON_GAP comment); pass 0 for a single row. +-- height = top gap + rows*size + (rows-1)*(gap+extra) + bottom gap. +-- `cols` is capped at `count` because a grid can never be wider than it has +-- buttons: with the DropSet button hidden, "1x6" is a row of FIVE and the +-- frame must shrink with it instead of trailing dead space. The multi-row +-- layouts still need all of their columns at five buttons, so their size is +-- unchanged (they just leave the last grid cell empty). +function TotemBar.barDimensions(count, cols, size, gap, rowPitchExtra) + if cols > count then + cols = count + end + local rows = math.ceil(count / cols) + local width = cols * (size + gap) + gap + local height = gap + rows * size + (rows - 1) * (gap + rowPitchExtra) + gap + return width, height, rows +end diff --git a/core/pulse.lua b/core/pulse.lua new file mode 100644 index 0000000..835e3ce --- /dev/null +++ b/core/pulse.lua @@ -0,0 +1,171 @@ +-- TotemBar - core/pulse.lua +-- PURE pulse/ring math for the Pulse UI (no WoW API; offline-tested via +-- tools/luatests/test_pulse.lua). ui.lua calls these from its throttled +-- timer tick - keep everything allocation-free except buildRingTexCoords +-- (called once at load). + +TotemBar = TotemBar or {} + +-- 0..1 phase toward the next pulse. Phase origin is anchorAt (the last +-- OBSERVED pulse, stamped by ui.lua's combat-message handler) when set, +-- else placedAt (dead reckoning). Lua 5.0: math.mod, not the % operator. +function TotemBar.pulseRatio(placedAt, anchorAt, interval, now) + if not interval or interval <= 0 then + return nil + end + local origin = anchorAt or placedAt + if not origin or not now then + return nil + end + local elapsed = now - origin + if elapsed < 0 then + return 0 + end + return math.mod(elapsed, interval) / interval +end + +-- 0..1 single fill from placement to detonation (Fire Nova). Clamped at +-- both ends; callers treat >=1 as "detonated" and hide the bar. +function TotemBar.oneshotRatio(placedAt, delay, now) + if not placedAt or not delay or delay <= 0 or not now then + return nil + end + local r = (now - placedAt) / delay + if r < 0 then + return 0 + end + if r > 1 then + return 1 + end + return r +end + +-- Blueprint state D ("pulse imminent"): glow from 85% fill on. +TotemBar.PULSE_IMMINENT_THRESHOLD = 0.85 +function TotemBar.pulseImminent(ratio) + if ratio and ratio >= TotemBar.PULSE_IMMINENT_THRESHOLD then + return true + end + return false +end + +-- Flipbook frame index for the duration ring: 0 = empty ... frameCount-1 = +-- full, rounded to nearest so the ring reads full right after placement. +-- Generic over frameCount; callers pass the arc's actual frame count (Rev 2: +-- 62, cells 0..61 of the ring_round.tga flipbook). +function TotemBar.ringFrameIndex(remaining, duration, frameCount) + if not remaining or not duration or duration <= 0 or not frameCount then + return 0 + end + local ratio = remaining / duration + if ratio < 0 then + ratio = 0 + end + if ratio > 1 then + ratio = 1 + end + return math.floor(ratio * (frameCount - 1) + 0.5) +end + +-- Precomputed SetTexCoord rectangles for a square cols x cols grid texture, +-- row-major, 1-based array indexed by frame+1. Built ONCE at load; the +-- per-tick path just indexes it (zero allocation, zero math). +function TotemBar.buildRingTexCoords(frames, cols) + local coords = {} + local cell = 1 / cols + for i = 0, frames - 1 do + local col = math.mod(i, cols) + local row = math.floor(i / cols) + coords[i + 1] = { + l = col * cell, + r = (col + 1) * cell, + t = row * cell, + b = (row + 1) * cell, + } + end + return coords +end + +-- Duration counterpart of core/cast.lua's resolveRemaining, SAME precedence: +-- GTI (pfUI libtotem) is authoritative while it reports the slot active; +-- otherwise fall back to own tracking. Returns the TOTAL duration whose +-- remaining time resolveRemaining would have returned, or nil. +function TotemBar.resolveDuration(gtiActive, gtiRemaining, gtiDuration, ownRemaining, ownDuration) + -- FIX 2026-07-15: mirror resolveRemaining -- trust GTI only while it reports the + -- slot active WITH positive time left; otherwise fall back to own tracking (a + -- stale-active GTI slot must not block the timer, same Fire/Earth bug). + if gtiActive and gtiRemaining and gtiRemaining > 0 then + return gtiDuration + end + if ownRemaining and ownRemaining > 0 then + return ownDuration + end + return nil +end + +-- Ripple wave phase: seconds since the last pulse mapped to 0..1 over the +-- wave's lifetime, or nil when no wave is showing. For tick totems the wave +-- starts AT each pulse (phase wraps via pulseRatio); waveDur is clamped to +-- half the interval so the wave always dies before the next one spawns. +function TotemBar.waveFrac(ratio, interval, waveDur) + if not ratio or not interval or interval <= 0 or not waveDur or waveDur <= 0 then + return nil + end + local dur = waveDur + if dur > interval * 0.5 then + dur = interval * 0.5 + end + local age = ratio * interval + if age > dur then + return nil + end + return age / dur +end + +-- One-shot (Fire Nova): wave for waveDur seconds AFTER detonation. +function TotemBar.oneshotWaveFrac(placedAt, delay, now, waveDur) + if not placedAt or not delay or not now or not waveDur or waveDur <= 0 then + return nil + end + local age = (now - placedAt) - delay + if age < 0 or age > waveDur then + return nil + end + return age / waveDur +end + +-- Anticipation glow: eased 0..1 ramp over the last (1 - rampStart) fraction +-- of the pulse phase; nil below rampStart (no glow) or on bad inputs. +function TotemBar.glowRamp(ratio, rampStart) + if not ratio or not rampStart or rampStart >= 1 or ratio < rampStart then + return nil + end + local t = (ratio - rampStart) / (1 - rampStart) + if t > 1 then + t = 1 + end + return t * t +end + +-- Traffic-light color for a remaining-time fraction (1 = full, 0 = empty): +-- green above 0.5, blending green->yellow->orange->red as time runs out. +-- Returns r, g, b as three numbers (no table - called per tick). +function TotemBar.timeColor(frac) + if not frac then + return 1, 1, 1 + end + if frac < 0 then frac = 0 end + if frac > 1 then frac = 1 end + if frac >= 0.5 then + -- green (0.25,0.8,0.25) -> yellow (0.95,0.85,0.2) over 1.0..0.5 + local t = (1 - frac) * 2 + return 0.25 + 0.70 * t, 0.80 + 0.05 * t, 0.25 - 0.05 * t + elseif frac >= 0.25 then + -- yellow -> orange (1.0,0.55,0.1) over 0.5..0.25 + local t = (0.5 - frac) * 4 + return 0.95 + 0.05 * t, 0.85 - 0.30 * t, 0.20 - 0.10 * t + end + -- orange -> red (0.90,0.15,0.10) over 0.25..0 + local t = (0.25 - frac) * 4 + return 1.00 - 0.10 * t, 0.55 - 0.40 * t, 0.10 +end diff --git a/core/pulsecal.lua b/core/pulsecal.lua new file mode 100644 index 0000000..dddf2ee --- /dev/null +++ b/core/pulsecal.lua @@ -0,0 +1,155 @@ +-- TotemBar - core/pulsecal.lua +-- Dev-only pulse-calibration telemetry (/tb pulsecal): captures raw +-- totem-related combat lines + our own placements into a fixed ring buffer +-- and exports them via SuperWoW ExportFile, so the dev environment can +-- measure REAL TurtleWoW pulse intervals offline and bake them back into +-- core/pulsedata.lua (verified=true). +-- Pure buffer/serializer on top (offline-tested); WoW-gated capture below. + +TotemBar = TotemBar or {} + +TotemBar.PULSECAL_CAP = 2000 + +-- Pure: push one record into the ring buffer. state = { n = total pushed, +-- idx = next write slot 1..cap }. Record tables are REUSED on wrap (no +-- allocation growth while capturing). +function TotemBar.pulsecalPush(buf, cap, state, t, ev, msg) + local slot = state.idx + local rec = buf[slot] + if not rec then + rec = {} + buf[slot] = rec + end + rec.t = t + rec.e = ev + rec.m = msg + state.n = state.n + 1 + state.idx = slot + 1 + if state.idx > cap then + state.idx = 1 + end +end + +-- Pure: serialize surviving records in chronological order, one +-- "t;event;msg" line each (t with millisecond precision). Allocates - dump +-- time only, never per capture. +function TotemBar.pulsecalFormat(buf, cap, state) + local count = state.n + if count > cap then + count = cap + end + if count == 0 then + return "" + end + local start = state.idx - count + if start < 1 then + start = start + cap + end + local lines = {} + for i = 0, count - 1 do + local slot = start + i + if slot > cap then + slot = slot - cap + end + local rec = buf[slot] + lines[i + 1] = string.format("%.3f;%s;%s", rec.t, rec.e, rec.m) + end + return table.concat(lines, "\n") +end + +-- --------------------------------------------------------------------------- +-- WoW-gated capture (skipped entirely by the offline test runner). +if CreateFrame then + local ChatOut = DEFAULT_CHAT_FRAME or ChatFrame1 + + local capturing = false + local buf = {} + local state = { n = 0, idx = 1 } + + -- Everything that could plausibly carry a totem pulse on 1.12 (no + -- COMBAT_LOG_EVENT_UNFILTERED here - all localized text in arg1). + local CAPTURE_EVENTS = { + "CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS", + "CHAT_MSG_SPELL_PERIODIC_CREATURE_BUFFS", + "CHAT_MSG_SPELL_PERIODIC_PARTY_BUFFS", + "CHAT_MSG_SPELL_PERIODIC_SELF_DAMAGE", + "CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE", + "CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_DAMAGE", + "CHAT_MSG_SPELL_SELF_DAMAGE", + "CHAT_MSG_SPELL_CREATURE_VS_CREATURE_DAMAGE", + "CHAT_MSG_COMBAT_CREATURE_VS_CREATURE_HITS", + "CHAT_MSG_SPELL_AURA_GONE_SELF", + "CHAT_MSG_SPELL_AURA_GONE_PARTY", + } + + local calFrame = CreateFrame("Frame", "TotemBarPulseCalFrame", UIParent) + calFrame:SetScript("OnEvent", function() + if not capturing then + return + end + if event == "UNIT_CASTEVENT" then + -- SuperWoW: casterGUID, targetGUID, type, spellId, castTime. + -- Only record casts whose spell name mentions Totem (SpellInfo + -- is a SuperWoW API; guarded because the event only exists there + -- anyway). + local sname = SpellInfo and SpellInfo(arg4) + if sname and string.find(sname, "Totem", 1, true) then + TotemBar.pulsecalPush(buf, TotemBar.PULSECAL_CAP, state, GetTime(), event, + tostring(arg1) .. ";" .. tostring(arg3) .. ";" .. tostring(sname)) + end + return + end + -- Plain substring guard keeps the hot path cheap; "Totem"-less buff + -- names (Mana Spring / Healing Stream gain lines) are the exception, + -- so include the "You gain" prefix too. + local msg = arg1 + if msg and (string.find(msg, "Totem", 1, true) or string.find(msg, "You gain", 1, true)) then + TotemBar.pulsecalPush(buf, TotemBar.PULSECAL_CAP, state, GetTime(), event, msg) + end + end) + + -- Record our own placements as reference marks (t0 for interval math). + -- Wrapping recordCast is safe here: core/pulsecal.lua loads AFTER + -- core/cast.lua (see .toc) and ui.lua resolves TotemBar.recordCast at + -- call time. + local origRecordCast = TotemBar.recordCast + TotemBar.recordCast = function(element, totemName) + origRecordCast(element, totemName) + if capturing then + TotemBar.pulsecalPush(buf, TotemBar.PULSECAL_CAP, state, GetTime(), + "TB_PLACED", tostring(element) .. ";" .. tostring(totemName)) + end + end + + function TotemBar.PulseCal(sub) + if sub == "start" then + if not capturing then + capturing = true + for i = 1, table.getn(CAPTURE_EVENTS) do + calFrame:RegisterEvent(CAPTURE_EVENTS[i]) + end + -- SuperWoW-only event; pcall-guarded in case a client build + -- rejects unknown event names. + pcall(function() calFrame:RegisterEvent("UNIT_CASTEVENT") end) + end + ChatOut:AddMessage("TotemBar: pulsecal capture STARTED (drop totems, then /tb pulsecal dump).") + elseif sub == "stop" then + capturing = false + calFrame:UnregisterAllEvents() + ChatOut:AddMessage("TotemBar: pulsecal capture stopped (" .. state.n .. " records kept).") + elseif sub == "dump" then + if not ExportFile then + ChatOut:AddMessage("TotemBar: pulsecal dump needs SuperWoW (ExportFile missing).") + return + end + -- Filename WITHOUT extension - the client appends .txt itself. + ExportFile("totembar_pulsecal", TotemBar.pulsecalFormat(buf, TotemBar.PULSECAL_CAP, state)) + ChatOut:AddMessage("TotemBar: pulsecal dump written (imports\\totembar_pulsecal.txt, " + .. state.n .. " records).") + else + local on = capturing and "ON" or "OFF" + ChatOut:AddMessage("TotemBar: pulsecal " .. on .. ", " .. state.n + .. " records. Usage: /tb pulsecal start|stop|dump") + end + end +end diff --git a/core/pulsedata.lua b/core/pulsedata.lua new file mode 100644 index 0000000..60462b6 --- /dev/null +++ b/core/pulsedata.lua @@ -0,0 +1,61 @@ +-- TotemBar - core/pulsedata.lua +-- Pulse metadata per totem name (PURE data, no WoW API). Drives the pulse +-- progress bar in ui.lua. Totems absent from PULSE_DATA get NO pulse bar +-- (better no info than invented info) - notably Searing Totem (irregular +-- attack cadence, not a fixed pulse), Grounding (event-consumed) and every +-- aura totem (continuous aura, no verified pulse mechanic). +-- +-- Values are server-dump-derived (TWoW server dump, snapshot 2024-07, +-- patch_1172 branch; tw_world.sql + SpellAuras.cpp:8647-8714), not book +-- values - source="server" records that provenance. verified=false stays +-- reserved for IN-GAME confirmation: /tb pulsecal (core/pulsecal.lua) +-- captures the real timings; once measured in-game, update the value AND +-- flip verified=true. Spec: +-- docs/superpowers/specs/2026-07-09-pulse-ui-design.md section 3. +-- +-- firstTick: "immediate" means the aura's periodic tick fires at t=0 (the +-- totem is on SpellAuras.cpp's CalculatePeriodic exception list); +-- "delayed" means the first tick lands only after one full amplitude has +-- elapsed. NOTE: the pulseRatio math is identical either way - the bar +-- reaches 1.0 at every tick instant, including the t=0 wrap for +-- "immediate" totems - so firstTick is currently documentative only (no +-- consumer branches on it yet). Kept for future use, e.g. a distinct +-- wave/flash at t=0 for "immediate" totems. + +TotemBar = TotemBar or {} + +-- ptype "tick" -> repeating pulse every `interval` seconds +-- ptype "oneshot" -> single detonation `delay` seconds after placement +-- anchor "selfgain" -> phase re-anchors on observed periodic self-gain +-- combat messages (see core/pulseparse.lua + ui.lua) +TotemBar.PULSE_DATA = { + ["Magma Totem"] = { ptype = "tick", interval = 2.0, firstTick = "delayed", source = "server", verified = false }, + -- 4.0s confirmed by server dump - corrects the earlier 3.0s book value. + ["Tremor Totem"] = { ptype = "tick", interval = 4.0, firstTick = "immediate", source = "server", verified = false }, + ["Earthbind Totem"] = { ptype = "tick", interval = 3.0, firstTick = "immediate", source = "server", verified = false }, + ["Poison Cleansing Totem"] = { ptype = "tick", interval = 5.0, firstTick = "immediate", source = "server", verified = false }, + ["Disease Cleansing Totem"] = { ptype = "tick", interval = 5.0, firstTick = "immediate", source = "server", verified = false }, + ["Healing Stream Totem"] = { ptype = "tick", interval = 2.0, anchor = "selfgain", firstTick = "delayed", source = "server", verified = false }, + ["Mana Spring Totem"] = { ptype = "tick", interval = 2.0, anchor = "selfgain", firstTick = "delayed", source = "server", verified = false }, + -- server models it as a periodic 4s trigger like Magma, but with the 5s + -- totem duration exactly one nova lands at t=4s, so the oneshot model + -- is behaviorally equivalent. + ["Fire Nova Totem"] = { ptype = "oneshot", delay = 4.0, firstTick = "delayed", source = "server", verified = false }, + -- threat pulse every 2s. + ["Stoneclaw Totem"] = { ptype = "tick", interval = 2.0, firstTick = "immediate", source = "server", verified = false }, +} + +-- Periodic gain lines name the BUFF ("You gain 10 Mana from Mana Spring."), +-- not the totem spell - map those source names back to the totem. Exact +-- wording gets pinned by pulsecal telemetry; extend as measured. +TotemBar.PULSE_SOURCE_ALIASES = { + ["Mana Spring"] = "Mana Spring Totem", + ["Healing Stream"] = "Healing Stream Totem", +} + +function TotemBar.pulseInfo(totemName) + if not totemName then + return nil + end + return TotemBar.PULSE_DATA[totemName] +end diff --git a/core/pulseparse.lua b/core/pulseparse.lua new file mode 100644 index 0000000..904046a --- /dev/null +++ b/core/pulseparse.lua @@ -0,0 +1,52 @@ +-- TotemBar - core/pulseparse.lua +-- PURE parser: periodic combat-log line -> totem name (or nil). Used by +-- ui.lua's CHAT_MSG_SPELL_PERIODIC_* handler to re-anchor pulse phase on +-- REAL observed ticks. Lua 5.0: string.find with captures only (no +-- string.match / no method-call syntax). Offline-tested via +-- tools/luatests/test_pulseparse.lua; exact accepted wordings get refined +-- from /tb pulsecal telemetry. + +TotemBar = TotemBar or {} + +-- Strips a trailing roman-numeral rank suffix (" IV", " VII", ...) - the +-- totem-as-caster shape names the ranked unit ("Healing Stream Totem VII"). +local function stripRank(src) + local _, _, base = string.find(src, "^(.-)%s+[IVXLC]+$") + if base and base ~= "" then + return base + end + return src +end + +-- Maps a raw source name to a totem name: alias table first (buff names +-- like "Mana Spring"), then any source ending in " Totem". +local function resolveSource(src) + local alias = TotemBar.PULSE_SOURCE_ALIASES and TotemBar.PULSE_SOURCE_ALIASES[src] + if alias then + return alias + end + -- Every totem's name ends in " Totem" (rank suffixes already stripped), + -- so anchor at the end - a plain substring test would false-positive on + -- player names like "Totemguy". + if string.find(src, "%sTotem$") then + return src + end + return nil +end + +function TotemBar.parseSelfGain(msg) + if not msg then + return nil + end + local _, _, src = string.find(msg, "^You gain %d+ [Mm]ana from (.+)%.$") + if not src then + _, _, src = string.find(msg, "^You gain %d+ [Hh]ealth from (.+)%.$") + end + if not src then + _, _, src = string.find(msg, "^(.-) heals you for %d+%.$") + end + if not src then + return nil + end + return resolveSource(stripRank(src)) +end diff --git a/core/spellindex.lua b/core/spellindex.lua new file mode 100644 index 0000000..0eda29e --- /dev/null +++ b/core/spellindex.lua @@ -0,0 +1,175 @@ +-- TotemBar - core/spellindex.lua +-- A single cached name -> spellbook-slot index, replacing the four +-- separate linear GetSpellName(i, BOOKTYPE_SPELL) scans that used to be +-- duplicated across ui.lua (FindSpellIndexByName), core/cast.lua +-- (findSpellIndexByName), core/manacost.lua (findHighestRankSlot) and +-- core/known.lua (scanSpellbook/highestKnownRank). Each of those walked +-- the WHOLE spellbook on every call; SPELL_UPDATE_COOLDOWN/cast events +-- fire several of them per totem drop, so a 4-totem-drop macro press +-- could re-walk the book several times in one frame (perf review +-- 2026-07-16, TotemBar finding #14: ui.lua:210 + cast.lua:175 + +-- manacost.lua:116). +-- +-- buildSpellIndex() below is PURE (no WoW API) and offline-tested: given +-- a flat array of {name=, rank=} entries in spellbook slot order (array +-- index N == what GetSpellName(N, BOOKTYPE_SPELL) returned for slot N), +-- it returns a name -> record table where each record is: +-- bookIndex - the FIRST (lowest) slot with this name. Mirrors the +-- old FindSpellIndexByName/findSpellIndexByName +-- first-match behavior (icon/cooldown/"is it known" +-- lookups don't care which rank's slot they read -- +-- GetSpellTexture/GetSpellCooldown are identical across +-- ranks of the same totem). +-- maxRankIndex - the slot of the HIGHEST parsed rank. Mirrors the old +-- manacost.lua findHighestRankSlot: this is the slot +-- CastSpellByName actually casts and the one +-- tooltip/mana-cost scans must read. Falls back to the +-- LAST matching slot if no entry's rank string for this +-- name ever parsed a number (matches the old +-- fallback -- ranks are listed ascending, so the last +-- slot is still the best guess). +-- maxRank - the highest parsed rank NUMBER, or nil if none of +-- this name's entries carried a parseable rank. Mirrors +-- the old known.lua highestKnownRank() return value. + +TotemBar = TotemBar or {} + +function TotemBar.buildSpellIndex(entries) + local idx = {} + if not entries then + return idx + end + for i = 1, table.getn(entries) do + local e = entries[i] + local name = e and e.name + if name then + local rec = idx[name] + if not rec then + rec = { bookIndex = i, maxRankIndex = i, maxRank = nil } + idx[name] = rec + end + local _, _, numStr = string.find(e.rank or "", "(%d+)") + local num = numStr and tonumber(numStr) + if num and (not rec.maxRank or num > rec.maxRank) then + rec.maxRank = num + rec.maxRankIndex = i + elseif not rec.maxRank then + -- No rank number has parsed for this name in any entry + -- seen so far; keep sliding the fallback to the LAST + -- matching slot (mirrors the old findHighestRankSlot + -- fallback for names whose rank string never parses). + rec.maxRankIndex = i + end + end + end + return idx +end + +-- ===== WoW-API layer (not offline-executed) ===== +-- Guarded by CreateFrame's presence, same as manacost.lua's own event +-- block, so dofile-ing this module under plain Lua (offline tests) +-- never errors. + +local cachedEntries = nil -- flat array {name=, rank=}, 1..n, book order +local cachedIndex = nil -- name -> {bookIndex, maxRankIndex, maxRank} + +local function scanLiveSpellbook() + local entries = {} + local i = 1 + while true do + local name, rank = GetSpellName(i, BOOKTYPE_SPELL) + if not name then + break + end + entries[i] = { name = name, rank = rank } + i = i + 1 + end + return entries +end + +local function ensureBuilt() + if cachedIndex then + return + end + cachedEntries = scanLiveSpellbook() + cachedIndex = TotemBar.buildSpellIndex(cachedEntries) +end + +-- Drops the cache; the next accessor call below rebuilds it from a +-- fresh scan (lazy -- nothing re-scans until actually asked for). Wired +-- to SPELLS_CHANGED below; also safe to call manually. +function TotemBar.invalidateSpellIndex() + cachedEntries = nil + cachedIndex = nil +end + +-- Returns the cached name -> record index, building it lazily on first +-- access. Lazy-build matters at login/reload: the spellbook +-- (GetSpellName/GetSpellTexture) isn't reliably populated as early as +-- ADDON_LOADED fires (see ui.lua's own SPELLS_CHANGED comment) -- an +-- eager build at file-load time could cache an empty book. +function TotemBar.getSpellIndex() + ensureBuilt() + return cachedIndex +end + +-- Mirrors old FindSpellIndexByName (ui.lua) / findSpellIndexByName +-- (cast.lua): first-match spellbook slot, or nil if unknown. +function TotemBar.findSpellIndex(name) + if not name then + return nil + end + local rec = TotemBar.getSpellIndex()[name] + return rec and rec.bookIndex +end + +-- Mirrors old manacost.lua findHighestRankSlot: the slot of the +-- highest-known rank, or nil if unknown. +function TotemBar.findHighestRankSlot(name) + if not name then + return nil + end + local rec = TotemBar.getSpellIndex()[name] + return rec and rec.maxRankIndex +end + +-- Mirrors old known.lua highestKnownRank: the highest known rank +-- NUMBER, or nil if unknown / no rank string ever parsed. +function TotemBar.highestKnownRank(name) + if not name then + return nil + end + local rec = TotemBar.getSpellIndex()[name] + return rec and rec.maxRank +end + +-- Mirrors old known.lua scanSpellbook: flat array of every spellbook +-- entry's name, ONE PER SLOT (a multi-rank totem appears once per rank +-- it has) -- ShowFlyout's knownTotems() filter and PrintScan's raw dump +-- both rely on that shape. The by-name index above intentionally +-- collapses ranks together, so this stays a separate view over the same +-- cached scan rather than something derived from the index. +function TotemBar.scanSpellbook() + ensureBuilt() + local names = {} + for i = 1, table.getn(cachedEntries) do + names[i] = cachedEntries[i].name + end + return names +end + +-- SPELLS_CHANGED is the event this codebase already relies on elsewhere +-- (manacost.lua's manaCache, ui.lua's icon refresh) for "spellbook +-- changed" -- fires on learning a new spell/rank and on talent changes. +-- LEARNED_SPELL_IN_TAB was checked and does not exist as a 1.12 client +-- event (not in this project's confirmed WoW 1.12 event list; it's a +-- later-expansion API) -- SPELLS_CHANGED alone is the correct signal. +if CreateFrame then + local siEvents = CreateFrame("Frame", "TotemBarSpellIndexEventFrame", UIParent) + siEvents:RegisterEvent("SPELLS_CHANGED") + siEvents:SetScript("OnEvent", function() + if event == "SPELLS_CHANGED" then + TotemBar.invalidateSpellIndex() + end + end) +end diff --git a/core/totemdata.lua b/core/totemdata.lua new file mode 100644 index 0000000..d55bf5c --- /dev/null +++ b/core/totemdata.lua @@ -0,0 +1,137 @@ +-- TotemBar - core/totemdata.lua +-- PURE data module (no WoW API calls) - offline-testable. +-- +-- Static element -> totem name map for vanilla shaman totems. +-- +-- NOTE: TurtleWoW may rename or add totems relative to vanilla 1.12. +-- Use "/tb scan" in-game to print the player's actual known totem spell +-- strings and cross-check them against this map before trusting it. + +TotemBar = TotemBar or {} + +TotemBar.TOTEM_ELEMENTS = { "Fire", "Earth", "Water", "Air" } + +TotemBar.TOTEMS_BY_ELEMENT = { + Fire = { + "Searing Totem", + "Fire Nova Totem", + "Magma Totem", + "Flametongue Totem", + "Frost Resistance Totem", + }, + Earth = { + "Earthbind Totem", + "Stoneclaw Totem", + "Stoneskin Totem", + "Strength of Earth Totem", + "Tremor Totem", + }, + Water = { + "Fire Resistance Totem", + "Healing Stream Totem", + "Mana Spring Totem", + "Poison Cleansing Totem", + "Disease Cleansing Totem", + "Mana Tide Totem", + }, + Air = { + "Grace of Air Totem", + "Grounding Totem", + "Nature Resistance Totem", + "Sentry Totem", + "Windfury Totem", + "Windwall Totem", + "Tranquil Air Totem", + }, +} + +-- Build the reverse lookup (totem name -> element) once at load time. +local elementByName = {} +for elemIdx = 1, table.getn(TotemBar.TOTEM_ELEMENTS) do + local element = TotemBar.TOTEM_ELEMENTS[elemIdx] + local list = TotemBar.TOTEMS_BY_ELEMENT[element] + for totemIdx = 1, table.getn(list) do + elementByName[list[totemIdx]] = element + end +end + +-- Returns the element ("Fire"/"Earth"/"Water"/"Air") a totem spell name +-- belongs to, or nil if the name isn't in the static map. +function TotemBar.elementOf(name) + if not name then + return nil + end + return elementByName[name] +end + +-- Totem lifetime durations (seconds), tuned to TurtleWoW server timers +-- (mirrors pfUI libtotem's verified values). Keyed by totem spell name; +-- any totem not listed here falls back to DEFAULT_TOTEM_DURATION. +-- Searing Totem is rank-aware (see SEARING_TOTEM_DURATIONS below) and +-- is intentionally NOT listed in this flat table. +TotemBar.DEFAULT_TOTEM_DURATION = 120 + +-- Cross-checked 2026-07-16 against pfUI's own libtotem.lua duration table +-- (Interface\AddOns\pfUI\libs\libtotem.lua, Spell_Fire_SearingTotem entry: +-- {[-1]=55,[1]=30,[2]=35,[3]=40,[4]=45,[5]=50,[6]=55}) -- byte-identical to +-- the vanilla values below for every rank. TurtleWoW uses vanilla Searing +-- Totem timers, not a custom table; nothing was missing here. +TotemBar.SEARING_TOTEM_DURATIONS = { + [1] = 30, + [2] = 35, + [3] = 40, + [4] = 45, + [5] = 50, + [6] = 55, +} +TotemBar.SEARING_TOTEM_MAX_RANK = 6 +TotemBar.SEARING_TOTEM_DEFAULT_DURATION = 55 + +TotemBar.TOTEM_DURATIONS = { + -- Fire + ["Magma Totem"] = 20, + ["Fire Nova Totem"] = 5, + ["Flametongue Totem"] = 120, + ["Frost Resistance Totem"] = 120, + -- Earth + ["Stoneclaw Totem"] = 15, + ["Earthbind Totem"] = 45, + ["Stoneskin Totem"] = 120, + ["Strength of Earth Totem"] = 120, + ["Tremor Totem"] = 120, + -- Water + ["Mana Tide Totem"] = 12, + ["Healing Stream Totem"] = 60, + ["Mana Spring Totem"] = 60, + ["Fire Resistance Totem"] = 120, + ["Disease Cleansing Totem"] = 120, + ["Poison Cleansing Totem"] = 120, + -- Air + ["Grounding Totem"] = 45, + ["Grace of Air Totem"] = 120, + ["Nature Resistance Totem"] = 120, + ["Tranquil Air Totem"] = 120, + ["Windfury Totem"] = 120, + ["Windwall Totem"] = 120, +} + +-- Pure: seconds a totem will remain active for, given its exact spell +-- name and (for Searing Totem only) the player's highest known rank +-- number. Unknown/unmapped totems fall back to DEFAULT_TOTEM_DURATION +-- (a safe overestimate, rather than a timer that disappears too early). +function TotemBar.totemDuration(name, highestRank) + if not name then + return TotemBar.DEFAULT_TOTEM_DURATION + end + if name == "Searing Totem" then + if not highestRank or highestRank < 1 then + return TotemBar.SEARING_TOTEM_DEFAULT_DURATION + end + local rank = highestRank + if rank > TotemBar.SEARING_TOTEM_MAX_RANK then + rank = TotemBar.SEARING_TOTEM_MAX_RANK + end + return TotemBar.SEARING_TOTEM_DURATIONS[rank] or TotemBar.SEARING_TOTEM_DEFAULT_DURATION + end + return TotemBar.TOTEM_DURATIONS[name] or TotemBar.DEFAULT_TOTEM_DURATION +end diff --git a/minimap.lua b/minimap.lua new file mode 100644 index 0000000..c5de5b0 --- /dev/null +++ b/minimap.lua @@ -0,0 +1,123 @@ +-- TotemBar - minimap.lua +-- Hand-rolled minimap button (no LibDBIcon on 1.12). Orbits the minimap at a +-- saved angle, drag-repositions (angle persisted), left-click opens the +-- options panel, right-click toggles the bar. pfUI-safe: parent = Minimap, +-- name contains "Minimap", FrameStrata HIGH + level 9 (MEDIUM hides under +-- pfUI). /tb options is the guaranteed access path if pfUI collects/hides +-- the button. WoW-API file (parse-checked only). + +TotemBar = TotemBar or {} + +local button = nil + +-- Custom icon sheet (tools/gen_icons.js, same texture ui.lua's +-- ICONS_TEXTURE points at): 4x4 grid of 128px cells, cell 7 = minimap +-- totem glyph (transparent backing). core/pulse.lua loads before this +-- file (see the .toc), so buildRingTexCoords is already available; built +-- once here at load, not per-tick. +local MINIMAP_ICON_TEXTURE = "Interface\\AddOns\\TotemBar\\textures\\icons" +local MINIMAP_ICON_CELL = 7 +local minimapIconCoords = TotemBar.buildRingTexCoords(16, 4) + +-- Repositions the button on its orbit for the given angle (degrees). +local function PlaceButton(angleDeg) + if not button then + return + end + local radius = (Minimap:GetWidth() / 2) + 5 + local x, y = TotemBar.angleToOffset(angleDeg, radius) + button:ClearAllPoints() + button:SetPoint("CENTER", Minimap, "CENTER", x, y) +end + +local function BuildMinimapButton() + if button then + return + end + + local btn = CreateFrame("Button", "TotemBarMinimapButton", Minimap) + btn:SetWidth(33) + btn:SetHeight(33) + btn:SetFrameStrata("HIGH") + btn:SetFrameLevel(9) + + local icon = btn:CreateTexture(nil, "ARTWORK") + icon:SetTexture(MINIMAP_ICON_TEXTURE) + local ic = minimapIconCoords[MINIMAP_ICON_CELL] + icon:SetTexCoord(ic.l, ic.r, ic.t, ic.b) + icon:SetWidth(20) + icon:SetHeight(20) + icon:SetPoint("CENTER", btn, "CENTER", 0, 0) + + local border = btn:CreateTexture(nil, "OVERLAY") + border:SetTexture("Interface\\Minimap\\MiniMap-TrackingBorder") + -- Vanilla default-UI recipe (MiniMapTrackingButton in FrameXML): a 54px + -- border anchored TOPLEFT(0,0) on a 33px button. The ring is deliberately + -- off-centre WITHIN the texture, so it lands concentric with the icon at + -- this exact anchor/size - do NOT "centre" it (that pushes the ring off). + border:SetWidth(54) + border:SetHeight(54) + border:SetPoint("TOPLEFT", btn, "TOPLEFT", 0, 0) + + btn:SetHighlightTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Highlight") + + btn.angle = TotemBarDB.minimapAngle or 225 + + -- Drag (repositions around the orbit). Drag needs the movement threshold, + -- so a plain click still fires OnClick; the two coexist (verified 1.12 + -- pattern). + btn:RegisterForDrag("LeftButton") + btn.isDragging = false + btn:SetScript("OnDragStart", function() this.isDragging = true end) + btn:SetScript("OnDragStop", function() + this.isDragging = false + TotemBarDB.minimapAngle = this.angle + end) + btn:SetScript("OnUpdate", function() + if not this.isDragging then + return + end + local mx, my = GetCursorPosition() + local scale = Minimap:GetEffectiveScale() + mx = mx / scale + my = my / scale + local cx, cy = Minimap:GetCenter() + this.angle = math.deg(math.atan2(my - cy, mx - cx)) + PlaceButton(this.angle) + end) + + -- Clicks: left = options, right = toggle bar. + btn:RegisterForClicks("LeftButtonUp", "RightButtonUp") + btn:SetScript("OnClick", function() + if arg1 == "RightButton" then + if TotemBar.ToggleBar then TotemBar.ToggleBar() end + else + if TotemBar.ToggleOptions then TotemBar.ToggleOptions() end + end + end) + + btn:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_LEFT") + GameTooltip:SetText("TotemBar") + GameTooltip:AddLine("Left-click: Options", 0.9, 0.9, 0.9) + GameTooltip:AddLine("Right-click: Toggle bar", 0.9, 0.9, 0.9) + GameTooltip:AddLine("Drag: reposition", 0.9, 0.9, 0.9) + GameTooltip:Show() + end) + btn:SetScript("OnLeave", function() GameTooltip:Hide() end) + + button = btn + PlaceButton(btn.angle) +end + +-- Build on ADDON_LOADED (SavedVariables are populated by then, same as the +-- bar). Guarded; never on PLAYER_ENTERING_WORLD (would re-run and leak +-- textures). +local ev = CreateFrame("Frame", "TotemBarMinimapEventFrame", UIParent) +ev:RegisterEvent("ADDON_LOADED") +ev:SetScript("OnEvent", function() + if event == "ADDON_LOADED" and arg1 == "TotemBar" then + BuildMinimapButton() + ev:UnregisterEvent("ADDON_LOADED") + end +end) diff --git a/options.lua b/options.lua new file mode 100644 index 0000000..dc11a94 --- /dev/null +++ b/options.lua @@ -0,0 +1,456 @@ +-- TotemBar - options.lua +-- Standalone options panel (no Blizzard InterfaceOptions integration). Built +-- once (lazy) on first ToggleOptions; repopulated from TotemBarDB on show. +-- Opened by the minimap button's left-click and by /tb options. WoW-API +-- file (parse-checked only). Widget wiring uses 1.12 templates: +-- UICheckButtonTemplate (GetChecked() -> 1/nil) and OptionsSliderTemplate +-- (SetMinMaxValues before SetValue; $parentLow/$parentHigh/$parentText). + +TotemBar = TotemBar or {} + +local ChatOut = DEFAULT_CHAT_FRAME or ChatFrame1 +local optionsFrame = nil + +-- Applies pfUI's font to a FontString (the main "matches pfUI" tweak) at the +-- given size, or falls back to a small game font when pfUI is absent. +local function ApplyFont(fontString, size) + if not fontString then + return + end + if pfUI and pfUI.font_default then + local sz = size or (pfUI_config and pfUI_config.global and pfUI_config.global.font_size) or 12 + fontString:SetFont(pfUI.font_default, sz) + else + fontString:SetFontObject(GameFontNormalSmall) + end +end + +-- Adds a wrapped explanatory GameTooltip on hover. Chains any existing +-- OnEnter/OnLeave (e.g. pfUI's SkinButton hover-highlight) instead of +-- replacing it, so skinning still works. No HookScript on 1.12. +local function AddTooltip(widget, text) + if not widget or not text then + return + end + widget.tbTip = text + local oldEnter = widget:GetScript("OnEnter") + local oldLeave = widget:GetScript("OnLeave") + widget:SetScript("OnEnter", function() + if oldEnter then oldEnter() end + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:SetText(this.tbTip, 1, 1, 1, 1, 1) + GameTooltip:Show() + end) + widget:SetScript("OnLeave", function() + if oldLeave then oldLeave() end + GameTooltip:Hide() + end) +end + +-- Unique widget-name counters (template child labels resolve via getglobal, +-- so every widget needs a non-nil, unique frame name). +local cbIndex = 0 +local slIndex = 0 + +-- Factory: a labeled checkbox wired to getter/setter. getter()->boolean, +-- setter(boolean). Returns the CheckButton. +local function CreateCheckbox(parent, label, getter, setter) + cbIndex = cbIndex + 1 + local name = "TotemBarOptCheck" .. cbIndex + local cb = CreateFrame("CheckButton", name, parent, "UICheckButtonTemplate") + cb:SetWidth(24) + cb:SetHeight(24) + local lbl = getglobal(name .. "Text") + if lbl then + lbl:SetText(label) + ApplyFont(lbl) + end + cb.tbGet = getter + cb.tbSet = setter + cb:SetScript("OnClick", function() + this.tbSet(this:GetChecked() == 1) + end) + if pfUI and pfUI.api and pfUI.api.SkinCheckbox then + pfUI.api.SkinCheckbox(cb) + end + return cb +end + +-- Factory: a labeled slider wired to getter/setter over [minVal,maxVal] in +-- `step` increments. `fmt` is a printf pattern for the live value text +-- (e.g. "UI size: %.2f"). Returns the Slider. +local function CreateSlider(parent, label, minVal, maxVal, step, fmt, getter, setter) + slIndex = slIndex + 1 + local name = "TotemBarOptSlider" .. slIndex + local sl = CreateFrame("Slider", name, parent, "OptionsSliderTemplate") + sl:SetWidth(200) + sl:SetHeight(16) + sl:SetMinMaxValues(minVal, maxVal) -- BEFORE SetValue, else clamps to 0 + sl:SetValueStep(step) + local low, high, txt = getglobal(name .. "Low"), getglobal(name .. "High"), getglobal(name .. "Text") + if low then low:SetText(tostring(minVal)); ApplyFont(low) end + if high then high:SetText(tostring(maxVal)); ApplyFont(high) end + if txt then + -- Left-align the value label above the slider (the template centers it), + -- so it lines up with the left-aligned checkbox labels. pfUI font. + txt:ClearAllPoints() + txt:SetPoint("BOTTOMLEFT", sl, "TOPLEFT", 0, 2) + txt:SetJustifyH("LEFT") + ApplyFont(txt) + end + sl.tbLabel = label + sl.tbFmt = fmt + sl.tbText = txt + sl.tbSet = setter + sl.tbGet = getter + sl:SetScript("OnValueChanged", function() + local v = this:GetValue() + if this.tbText then + this.tbText:SetText(string.format(this.tbFmt, v)) + end + this.tbSet(v) + end) + if pfUI and pfUI.api and pfUI.api.SkinSlider then + pfUI.api.SkinSlider(sl) + end + return sl +end + +-- Label text for the bar-layout cycle button, reflecting the current choice. +local function BarLayoutLabel() + return "Layout: " .. (TotemBarDB.barLayout or "1x6") +end + +-- Factory: a labeled push button wired to an onClick. Returns the Button. +-- Named (project convention: name all frames) so the pfDebug profiler can attribute it. +local function CreateButton(parent, name, label, onClick) + local btn = CreateFrame("Button", name, parent, "UIPanelButtonTemplate") + btn:SetWidth(140) + btn:SetHeight(22) + btn:SetText(label) + btn:SetScript("OnClick", onClick) + if pfUI and pfUI.api and pfUI.api.SkinButton then + pfUI.api.SkinButton(btn) + end + return btn +end + +-- Factory: a static hairline divider spanning the panel's content width at +-- cursor position y (Rev 4 UI chrome, section-break decoration). Anonymous +-- texture - it's a static decoration, not an interactive/named widget. +local function CreateDivider(parent, y) + local d = parent:CreateTexture(nil, "ARTWORK") + d:SetTexture(TotemBar.UI_TEXTURE) + local c = TotemBar.uiCoords[TotemBar.UI_DIVIDER_CELL] + d:SetTexCoord(c.l, c.r, c.t, c.b) + d:SetPoint("TOPLEFT", parent, "TOPLEFT", 14, y) + d:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -14, y) + d:SetHeight(7) + return d +end + +-- All widgets, stored so OnShow can repopulate them from TotemBarDB. +local widgets = {} + +local function BuildOptionsFrame() + if optionsFrame then + return optionsFrame + end + + local f = CreateFrame("Frame", "TotemBarOptionsFrame", UIParent) + f:SetWidth(280) + -- 472 + 44 + 28 + 28 + 44 + 28: room for the header emblem (+16, taller + -- than the old title-only header), two section dividers (+14 each), the + -- "Show pulse waves" checkbox row (+28, the pulse ring's countdown + -- readout sits alongside the ripple), the bar-layout cycle button row + -- (+28), the "Button spacing" slider row (+44, same slider-row + -- height as guard/gap/scale above it), and the "Show drop-all button" + -- checkbox row (+28) - see the layout-cursor deltas below (same + -- mechanism as the Task-5 height bump). Not cosmetic: without the last + -- +28 the "Key bind mode" button would land on the version footer. + f:SetHeight(628) + f:SetPoint("CENTER", UIParent, "CENTER", 0, 0) + f:SetFrameStrata("DIALOG") + -- Rev 4 UI chrome: shared bespoke panel skin (see ui.lua's PANEL_BACKDROP) + -- instead of the Blizzard dialog backdrop or pfUI's generic skin, so the + -- options panel matches the bar's own art (emblem/dividers below pair + -- with this border/bg specifically). + f:SetBackdrop(TotemBar.PANEL_BACKDROP) + f:SetBackdropColor(1, 1, 1, 0.97) + f:SetMovable(true) + f:EnableMouse(true) + f:RegisterForDrag("LeftButton") + f:SetScript("OnDragStart", function() this:StartMoving() end) + f:SetScript("OnDragStop", function() this:StopMovingOrSizing() end) + f:SetClampedToScreen(true) + + -- Plain gold title, no emblem (the Rev-4 totem glyph was removed on + -- user request - the silhouette read, well, unfortunate). + local title = f:CreateFontString("TotemBarOptionsTitle", "OVERLAY", "GameFontNormal") + title:SetPoint("TOPLEFT", f, "TOPLEFT", 16, -16) + title:SetText("TotemBar Options") + ApplyFont(title, 14) + title:SetTextColor(0.85, 0.66, 0.31) + + local close = CreateFrame("Button", "TotemBarOptionsClose", f, "UIPanelCloseButton") + close:SetPoint("TOPRIGHT", f, "TOPRIGHT", -8, -8) + if pfUI and pfUI.api and pfUI.api.SkinCloseButton then + pfUI.api.SkinCloseButton(close, f) + end + + -- Layout cursor (back to the title-only header spacing). + local x = 24 + local y = -44 + local function place(w, dy) + w:SetPoint("TOPLEFT", f, "TOPLEFT", x, y) + y = y + dy + end + + -- Checkboxes. + widgets.lock = CreateCheckbox(f, "Lock bar", + function() return TotemBarDB.locked end, + function(v) TotemBarDB.locked = v end) + place(widgets.lock, -28) + AddTooltip(widgets.lock, "Locks the bar in place so you can't drag it by accident. Uncheck to reposition it.") + + widgets.autoRecall = CreateCheckbox(f, "Auto-recall before setting", + function() return TotemBarDB.autoRecall end, + function(v) + TotemBarDB.autoRecall = v + if TotemBar.RefreshRecallIndicator then TotemBar.RefreshRecallIndicator() end + end) + place(widgets.autoRecall, -28) + AddTooltip(widgets.autoRecall, "When on, the Totems macro casts Totemic Recall before re-dropping your totems (refunds some mana when relocating).") + + widgets.show = CreateCheckbox(f, "Show bar", + function() return not TotemBarDB.hidden end, + function(v) + if v then + TotemBarDB.hidden = false + if TotemBarFrame then TotemBarFrame:Show() end + else + TotemBarDB.hidden = true + if TotemBarFrame then TotemBarFrame:Hide() end + end + end) + place(widgets.show, -28) + AddTooltip(widgets.show, "Shows or hides the totem bar. The choice is remembered between sessions.") + + -- Same kind of switch as "Show bar" above (visibility only, not a + -- feature toggle), hence the same group. ApplyBarLayout hides/shows the + -- button AND re-lays out the grid, so the bar resizes immediately - + -- no /reload needed. + widgets.dropSet = CreateCheckbox(f, "Show drop-all button", + function() return TotemBarDB.showDropSetButton end, + function(v) + TotemBarDB.showDropSetButton = v + if TotemBar.ApplyBarLayout then TotemBar.ApplyBarLayout() end + end) + place(widgets.dropSet, -40) + AddTooltip(widgets.dropSet, "Shows or hides the 'Drop all totems' button on the bar. Its key binding keeps working while it is hidden - rebind it under Key Bindings in the game menu.") + + widgets.ring = CreateCheckbox(f, "Show duration ring", + function() return TotemBarDB.showDurationRing end, + function(v) + TotemBarDB.showDurationRing = v + if TotemBar.RefreshPulseUI then TotemBar.RefreshPulseUI() end + end) + place(widgets.ring, -28) + AddTooltip(widgets.ring, "Shows a colored arc on each button's ring that empties as the totem's remaining time runs out. The ring frame itself always stays visible.") + + widgets.pulse = CreateCheckbox(f, "Show pulse ring", + function() return TotemBarDB.showPulseBars end, + function(v) + TotemBarDB.showPulseBars = v + if TotemBar.RefreshPulseUI then TotemBar.RefreshPulseUI() end + end) + place(widgets.pulse, -28) + AddTooltip(widgets.pulse, "Shows a thin inner ring around the icon that fills up toward the totem's next pulse, then resets.") + + widgets.pulseWaves = CreateCheckbox(f, "Show pulse waves", + function() return TotemBarDB.showPulseWaves end, + function(v) + TotemBarDB.showPulseWaves = v + if TotemBar.RefreshPulseUI then TotemBar.RefreshPulseUI() end + end) + place(widgets.pulseWaves, -28) + AddTooltip(widgets.pulseWaves, "Shows an expanding ripple in the totem's color each time a pulsing totem fires.") + + widgets.timerText = CreateCheckbox(f, "Show countdown text", + function() return TotemBarDB.showTimerText end, + function(v) + TotemBarDB.showTimerText = v + if TotemBar.RefreshPulseUI then TotemBar.RefreshPulseUI() end + end) + place(widgets.timerText, -28) + AddTooltip(widgets.timerText, "Shows the remaining-seconds text under each element button (the ring shows the same information graphically).") + + -- Bar-layout cycle button: click steps through TotemBar.BAR_LAYOUTS + -- (wrapping), saves the choice, and re-lays out the live bar. + widgets.layout = CreateButton(f, "TotemBarOptLayoutButton", BarLayoutLabel(), function() + local layouts = TotemBar.BAR_LAYOUTS + local n = table.getn(layouts) + local current = TotemBarDB.barLayout or "1x6" + local currentIdx = 1 + for i = 1, n do + if layouts[i] == current then + currentIdx = i + end + end + local nextIdx = math.mod(currentIdx, n) + 1 + TotemBarDB.barLayout = layouts[nextIdx] + this:SetText(BarLayoutLabel()) + if TotemBar.ApplyBarLayout then + TotemBar.ApplyBarLayout() + end + end) + place(widgets.layout, -28) + AddTooltip(widgets.layout, "Cycles the bar arrangement: one row of six, two rows of three, or three rows of two.") + + -- Section divider (Rev 4 UI chrome): checkboxes above, sliders below. + CreateDivider(f, y) + y = y - 14 + + -- Sliders (leave headroom below each for its low/high/value text). + widgets.guard = CreateSlider(f, "Recall guard (sec)", 0, 5, 0.5, "Recall guard: %.1fs", + function() return TotemBarDB.recallGuardSeconds end, + function(v) TotemBarDB.recallGuardSeconds = v end) + place(widgets.guard, -44) + AddTooltip(widgets.guard, "A second press of the Totems macro within this many seconds skips Totemic Recall, so a double-press won't pull the totems you just dropped. 0 = no guard.") + + widgets.gap = CreateSlider(f, "Cycle reset gap (sec)", 0.5, 5, 0.5, "Cycle gap: %.1fs", + function() return TotemBarDB.gapSeconds end, + function(v) TotemBarDB.gapSeconds = v end) + place(widgets.gap, -44) + AddTooltip(widgets.gap, "How long a pause (in seconds) restarts the one-per-press cast cycle back at the first totem.") + + widgets.scale = CreateSlider(f, "UI size", 0.5, 2.0, 0.05, "UI size: %.2f", + function() return TotemBarDB.scale end, + function(v) + if TotemBar.SetBarScale then + TotemBar.SetBarScale(v) + elseif TotemBarFrame then + TotemBarDB.scale = v + TotemBarFrame:SetScale(v) + end + end) + place(widgets.scale, -44) + AddTooltip(widgets.scale, "Scales the whole bar. It stays anchored at its top-left corner, and the flyout scales with it.") + + widgets.buttonGap = CreateSlider(f, "Button spacing", 10, 30, 1, "Spacing: %.0fpx", + function() return TotemBarDB.buttonGap end, + function(v) + if TotemBar.SetButtonGap then + TotemBar.SetButtonGap(v) + else + TotemBarDB.buttonGap = v + end + end) + place(widgets.buttonGap, -44) + AddTooltip(widgets.buttonGap, "Distance between the bar buttons. Applies immediately.") + + -- Section divider (Rev 4 UI chrome): sliders above, action buttons below. + CreateDivider(f, y) + y = y - 14 + + optionsFrame = f + -- Buttons (Reset position, Create macro) are added in Task 4. + if TotemBar.BuildOptionsButtons then + TotemBar.BuildOptionsButtons(f, x, y) + end + + -- Repopulate every widget from TotemBarDB whenever the panel shows. + f:SetScript("OnShow", function() + widgets.lock:SetChecked(TotemBarDB.locked and 1 or nil) + widgets.autoRecall:SetChecked(TotemBarDB.autoRecall and 1 or nil) + widgets.show:SetChecked((not TotemBarDB.hidden) and 1 or nil) + widgets.dropSet:SetChecked(TotemBarDB.showDropSetButton and 1 or nil) + widgets.ring:SetChecked(TotemBarDB.showDurationRing and 1 or nil) + widgets.pulse:SetChecked(TotemBarDB.showPulseBars and 1 or nil) + widgets.pulseWaves:SetChecked(TotemBarDB.showPulseWaves and 1 or nil) + widgets.timerText:SetChecked(TotemBarDB.showTimerText and 1 or nil) + widgets.layout:SetText(BarLayoutLabel()) + widgets.guard:SetValue(TotemBar.clampValue(TotemBarDB.recallGuardSeconds, 0, 5)) + widgets.gap:SetValue(TotemBar.clampValue(TotemBarDB.gapSeconds, 0.5, 5)) + widgets.scale:SetValue(TotemBar.clampValue(TotemBarDB.scale, 0.5, 2.0)) + widgets.buttonGap:SetValue(TotemBar.clampValue(TotemBarDB.buttonGap, 10, 30)) + end) + + -- Version footer (bottom-centre), read from the .toc so it always matches + -- the real addon version instead of a hardcoded duplicate. + local ver = (GetAddOnMetadata and GetAddOnMetadata("TotemBar", "Version")) or "0.1.0" + local verFS = f:CreateFontString("TotemBarOptionsVersion", "OVERLAY") + ApplyFont(verFS, 10) + verFS:SetPoint("BOTTOM", f, "BOTTOM", 0, 6) + verFS:SetText("v" .. ver) + verFS:SetTextColor(0.6, 0.6, 0.6) + + -- ESC closes it. + tinsert(UISpecialFrames, "TotemBarOptionsFrame") + + f:Hide() + return f +end + +-- Creates or updates the "Totems" convenience macro from TotemBar.macroSpec. +-- General (account) macro, capped at 18 in 1.12; if the cap is full and no +-- "Totems" macro exists yet, reports and does nothing. Never auto-places it +-- on the action bar (no 1.12 API for that - the player drags it). +local function ApplyTotemsMacro() + local name, body, icon = TotemBar.macroSpec() + local existing = GetMacroIndexByName(name) + if existing and existing > 0 then + EditMacro(existing, name, icon, body, 1, nil) + ChatOut:AddMessage("TotemBar: '" .. name .. "' macro updated - drag it to your action bar.") + return + end + local numGlobal = GetNumMacros() -- returns global, perChar in 1.12 + if numGlobal and numGlobal >= 18 then + ChatOut:AddMessage("TotemBar: macro slots full (18) - free one and retry.") + return + end + CreateMacro(name, icon, body, nil) + ChatOut:AddMessage("TotemBar: '" .. name .. "' macro created - drag it to your action bar.") +end + +-- Adds the Reset-position and Create-macro buttons to the options frame. +-- Called by BuildOptionsFrame (Task 3) after the sliders, at layout cursor +-- (x, yStart). Kept separate so the panel body and the action buttons are +-- two focused units. +function TotemBar.BuildOptionsButtons(f, x, yStart) + -- Full content width (frame width minus the left/right margin x), so the + -- buttons read as a balanced block instead of a narrow left-biased pair. + local w = f:GetWidth() - (x * 2) + + local reset = CreateButton(f, "TotemBarOptResetButton", "Reset position", function() + if TotemBar.ResetPosition then TotemBar.ResetPosition() end + end) + reset:SetWidth(w) + reset:SetPoint("TOPLEFT", f, "TOPLEFT", x, yStart) + AddTooltip(reset, "Moves the bar back to the center of the screen.") + + local macro = CreateButton(f, "TotemBarOptMacroButton", "Create 'Totems' macro", function() + ApplyTotemsMacro() + end) + macro:SetWidth(w) + macro:SetPoint("TOPLEFT", f, "TOPLEFT", x, yStart - 28) + AddTooltip(macro, "Creates or updates a 'Totems' macro that drops all four chosen totems in one press. Drag it from the macro window to your action bar.") + + local bind = CreateButton(f, "TotemBarOptBindButton", "Key bind mode", function() + if TotemBar.ToggleBindMode then TotemBar.ToggleBindMode() end + end) + bind:SetWidth(w) + bind:SetPoint("TOPLEFT", f, "TOPLEFT", x, yStart - 56) + AddTooltip(bind, "Toggle bind mode, then hover any bar button or a flyout totem and press a key to bind it. ESC clears. Bindings are saved automatically.") +end + +-- Show/hide the options panel (builds it lazily). Left-click on the minimap +-- button and /tb options both call this. +function TotemBar.ToggleOptions() + local f = BuildOptionsFrame() + if f:IsShown() then + f:Hide() + else + f:Show() + end +end diff --git a/screenshots/bar.png b/screenshots/bar.png new file mode 100644 index 0000000..697eaeb Binary files /dev/null and b/screenshots/bar.png differ diff --git a/screenshots/bind-mode.png b/screenshots/bind-mode.png new file mode 100644 index 0000000..6823b4a Binary files /dev/null and b/screenshots/bind-mode.png differ diff --git a/screenshots/flyout.png b/screenshots/flyout.png new file mode 100644 index 0000000..0d1082b Binary files /dev/null and b/screenshots/flyout.png differ diff --git a/screenshots/keybindings.png b/screenshots/keybindings.png new file mode 100644 index 0000000..8fbc259 Binary files /dev/null and b/screenshots/keybindings.png differ diff --git a/screenshots/options.png b/screenshots/options.png new file mode 100644 index 0000000..1abf87a Binary files /dev/null and b/screenshots/options.png differ diff --git a/screenshots/pulse-ui.png b/screenshots/pulse-ui.png new file mode 100644 index 0000000..28b5692 Binary files /dev/null and b/screenshots/pulse-ui.png differ diff --git a/textures/icons.tga b/textures/icons.tga new file mode 100644 index 0000000..21da641 Binary files /dev/null and b/textures/icons.tga differ diff --git a/textures/panel_bg.tga b/textures/panel_bg.tga new file mode 100644 index 0000000..4ad5562 Binary files /dev/null and b/textures/panel_bg.tga differ diff --git a/textures/panel_border.tga b/textures/panel_border.tga new file mode 100644 index 0000000..0b292de Binary files /dev/null and b/textures/panel_border.tga differ diff --git a/textures/ring_fx.tga b/textures/ring_fx.tga new file mode 100644 index 0000000..5fe74e1 Binary files /dev/null and b/textures/ring_fx.tga differ diff --git a/textures/ring_pulsearc.tga b/textures/ring_pulsearc.tga new file mode 100644 index 0000000..f295547 Binary files /dev/null and b/textures/ring_pulsearc.tga differ diff --git a/textures/ring_round.tga b/textures/ring_round.tga new file mode 100644 index 0000000..a183c48 Binary files /dev/null and b/textures/ring_round.tga differ diff --git a/textures/ui.tga b/textures/ui.tga new file mode 100644 index 0000000..14d1d63 Binary files /dev/null and b/textures/ui.tga differ diff --git a/ui.lua b/ui.lua new file mode 100644 index 0000000..d92ba80 --- /dev/null +++ b/ui.lua @@ -0,0 +1,2541 @@ +-- TotemBar - ui.lua +-- The totem bar frame: 4 element buttons plus a Totemic Recall button. +-- Left-click an element button to cast its chosen totem; right-click +-- clears the slot. Hovering an element button pops an upward flyout of +-- the element's other known totems: left-click one to cast it once, +-- right-click one to set it as the slot's new default. Each element +-- button also carries an OmniCC-style remaining-duration timer text (hybrid +-- source: pfUI libtotem's GetTotemInfo when present, else TotemBar's +-- own cast-tracking - see core/cast.lua). No per-button text labels; +-- hover the button for a tooltip naming the element/totem. Each element +-- button also carries a native action-button-style radial cooldown +-- swipe (CooldownFrameTemplate) reflecting the chosen totem's spellbook +-- cooldown, IN ADDITION to the duration timer text. The Recall button's +-- icon pulses whenever any element totem is currently out of range. +-- WoW-API-only file; not offline-tested, only syntax-checked (see +-- tools/luatests notes in the repo). + +TotemBar = TotemBar or {} + +-- Defensive fallback in case DEFAULT_CHAT_FRAME is ever unset this early +-- in the loading sequence (guards against a later/leaner client build). +local ChatOut = DEFAULT_CHAT_FRAME or ChatFrame1 + +local BUTTON_SIZE = 36 +-- 10 (was 4): the 48px round ring frames overhang their 36px buttons by +-- 6px per side; adjacent opaque ring bands (screen radius ~22.9px) need +-- button centers >= ~45.8px apart to not overlap. 36+10 = 46 clears it, +-- for the bar row and the flyout column alike. Now a live-adjustable +-- UPVALUE (not a constant): the options panel's "Button spacing" slider +-- (range 10-30px, TotemBarDB.buttonGap) reassigns this in place via +-- TotemBar.SetButtonGap below - every closure that reads BUTTON_GAP +-- (element/flyout/assign-panel positioning, ApplyBarLayout) sees the new +-- value on its next read. 10px is still the floor: below that, adjacent +-- ring bands start to overlap per the geometry above. +local BUTTON_GAP = TotemBar.DEFAULT_BUTTON_GAP or 10 + +-- Bar-layout grid (feature: selectable bar arrangement, TotemBarDB.barLayout, +-- see ApplyBarLayout below). Maps each saved layout string to its column +-- count; row count then falls out of math.ceil(buttonCount / cols). Cycle +-- order for the options panel's cycle button lives in TotemBar.BAR_LAYOUTS. +local BAR_LAYOUT_COLS = { ["1x6"] = 6, ["2x3"] = 3, ["3x2"] = 2 } +TotemBar.BAR_LAYOUTS = { "1x6", "2x3", "3x2" } + +-- Rows use the SAME pitch as columns (user request: tight grid). The timer +-- text below a row slightly overlaps the next row's ring overhang; it draws +-- on the OVERLAY layer above it and stays readable. + +-- Timer-text OnUpdate throttle: refresh at most this often (seconds), +-- not every frame, to keep the shared/guild-addon perf budget sane. +-- 0.1s (was 0.2): the same tick also refreshes the ripple wave INPUTS (the +-- wave animation itself runs every frame in UpdateWaves, driven off these). +-- Still allocation-free per tick. +local TIMER_UPDATE_INTERVAL = 0.1 +local timerElapsed = 0 + +-- Fallback icon for an unresolved/unknown totem name (flyout icons, +-- ResolveTotemIcon, the pending-assignment panel). Element buttons' own +-- empty-slot state uses the custom sheet's per-element glyph instead +-- (see SetElementIcon/ELEMENT_EMPTY_CELL above), not this constant. +local EMPTY_ICON = "Interface\\Icons\\INV_Misc_QuestionMark" + +-- Totemic Recall: exact icon is verified at button-creation time via a +-- spellbook scan (GetSpellTexture); this is only the last-resort +-- fallback if that scan fails to resolve a texture. +local RECALL_SPELL_NAME = "Totemic Recall" +local RECALL_ICON_FALLBACK = "Interface\\Icons\\Spell_Nature_AstralRecal" + +-- Pulse UI Rev 2 - floating round icons + ripple pulse (spec +-- docs/superpowers/specs/2026-07-09-pulse-ui-design.md, section "Rev 2"). +-- Ring flipbook: 8x8 grid texture (64 cells). Cells 1..62 = duration-arc +-- fill frames (0=empty .. 61=full), cell 63 = wave ring (ripple), cell 64 = +-- the decorative frame band - always shown, hides the icon's square +-- corners so it reads as a floating round icon. Texture generated by +-- tools/gen_ring_textures.js; path WITHOUT extension (client resolves +-- .blp/.tga). +local RING_TEXTURE = "Interface\\AddOns\\TotemBar\\textures\\ring_round" +local RING_FRAMES = 62 +local RING_GRID_COLS = 8 +-- Cell 64 = the permanent decorative frame band (now baked-in shadow look, +-- see the art contract in the Rev 6 texture generator) - always shown, +-- hides the icon's square corners so it reads as a floating round icon. +-- (Rev 5's user-selectable ring-skin picker, which swapped this cell for a +-- separate ring_tracks.tga, was reverted per user feedback - see +-- ApplyRoundFrame below.) +local RING_WAVE_CELL = 63 +local RING_TRACK_CELL = 64 +-- Built once at load (core/pulse.lua loads before ui.lua, see .toc); the +-- per-tick path only indexes this table. +local allRingCoords = TotemBar.buildRingTexCoords(64, RING_GRID_COLS) + +-- Screen px the ring/wave textures are drawn at, centered on the 36px +-- button - the ring extends past the button's own edges so the decorative +-- frame band's opaque interior fully covers the icon's square corners (see +-- the texture generator's geometry rationale comment). +local RING_TEX_SIZE = 48 +local WAVE_DURATION = 0.8 -- ripple lifetime, seconds +local WAVE_MAX_SCALE = 1.9 -- wave grows from RING_TEX_SIZE to *WAVE_MAX_SCALE +local WAVE_BASE_ALPHA = 0.85 -- wave alpha at spawn, fades to 0 by WAVE_DURATION + +-- Circular pulse countdown (Rev 6): the linear pulse bar (Rev 1) was +-- replaced with a thin white inner arc drawn ON the band itself, since a +-- round readout fits the round slots better than a straight bar (user +-- feedback). Same flipbook mechanics as the duration ring (ringFill) above, +-- just a separate all-white texture so it reads distinctly from the +-- element-colored duration arc. Independently toggleable +-- (TotemBarDB.showPulseBars, key name kept from the old linear bar - no +-- migration needed) from the ripple wave (TotemBarDB.showPulseWaves). +local PULSEARC_TEXTURE = "Interface\\AddOns\\TotemBar\\textures\\ring_pulsearc" + +-- Pulse UI Rev 3: anticipation glow + spawn ripple + round hover/pushed +-- click states, all drawn from one FX sheet (tools/gen_ring_textures.js): +-- 2x2 grid of 128px cells. Cell 1 = annular anticipation halo, cell 2 = +-- round hover ring, cell 3 = pushed press disc, cell 4 = spawn flash +-- (reserved). Path WITHOUT extension, same convention as RING_TEXTURE. +local FX_TEXTURE = "Interface\\AddOns\\TotemBar\\textures\\ring_fx" +local fxCoords = TotemBar.buildRingTexCoords(4, 2) +local FX_GLOW_CELL = 1 -- annular anticipation halo (drawn at 64px) +local FX_HOVER_CELL = 2 -- round hover ring (drawn at RING_TEX_SIZE) +local FX_PUSHED_CELL = 3 -- press disc (drawn at RING_TEX_SIZE) +local FX_FLASH_CELL = 4 -- reserved (spawn flash disc) +local GLOW_TEX_SIZE = 64 +local GLOW_RAMP_START = 0.78 -- glow builds over the last 22% before a pulse +local GLOW_MAX_ALPHA = 0.7 +local SPAWN_WAVE_DURATION = 0.6 + +-- Custom icon sheet (tools/gen_icons.js): 4x4 grid of 128px cells. +local ICONS_TEXTURE = "Interface\\AddOns\\TotemBar\\textures\\icons" +local iconCoords = TotemBar.buildRingTexCoords(16, 4) +local ELEMENT_EMPTY_CELL = { Fire = 1, Earth = 2, Water = 3, Air = 4 } +-- cell 5 reserved (custom recall icon, reverted per user feedback) +local ICON_DROPSET_CELL = 6 +local ICON_CROP = 0.08 -- standard action-button crop for real spell icons + +-- UI chrome sheet + panel skin (tools/gen_ui_textures.js). +local UI_TEXTURE = "Interface\\AddOns\\TotemBar\\textures\\ui" +local uiCoords = TotemBar.buildRingTexCoords(4, 2) +local UI_SHADOW_CELL = 1 +local UI_EMBLEM_CELL = 2 +local UI_DIVIDER_CELL = 3 +local PANEL_BACKDROP = { + bgFile = "Interface\\AddOns\\TotemBar\\textures\\panel_bg", + edgeFile = "Interface\\AddOns\\TotemBar\\textures\\panel_border", + tile = true, tileSize = 128, edgeSize = 16, + insets = { left = 5, right = 5, top = 5, bottom = 5 }, +} +TotemBar.PANEL_BACKDROP = PANEL_BACKDROP -- shared with options.lua / bind.lua +TotemBar.UI_TEXTURE = UI_TEXTURE +TotemBar.uiCoords = uiCoords +TotemBar.UI_EMBLEM_CELL = UI_EMBLEM_CELL +TotemBar.UI_DIVIDER_CELL = UI_DIVIDER_CELL + +local ELEMENT_COLORS = { + Fire = { 1.00, 0.45, 0.10 }, + Earth = { 0.40, 0.85, 0.30 }, + Water = { 0.25, 0.60, 1.00 }, + Air = { 0.70, 0.55, 1.00 }, +} + +local elementButtons = {} -- element -> button frame +local recallButton = nil -- Totemic Recall button frame (for the auto-recall indicator refresh) +local dropSetButton = nil -- DropSet button frame (for RefreshPulseUI's ring-skin re-apply) + +-- True when ANY element's totem is currently out-of-range (the +-- buff-presence red tint, see UpdateTimerDisplays). Recomputed once per +-- UpdateTimerDisplays pass; drives the Recall button's icon pulse +-- (OnRecallUpdate below) as a "go recall + redeploy" visual prompt. +local anyOutOfRange = false + +-- Hover flyout: hovering an element button pops a column of icon +-- buttons UPWARD for the OTHER known totems of that element (all known +-- for the element minus the currently-chosen default). Left-click one to +-- cast it ONCE without changing the slot's default; right-click one to +-- make it the slot's new default. Shares one frame + a pool of icon +-- buttons. +local MAX_FLYOUT_ICONS = 6 -- most totems any single element has +local FLYOUT_PAD = 4 -- inner padding inside the flyout frame +local FLYOUT_GAP = 2 -- gap between element button top and flyout bottom +local FLYOUT_HIDE_INTERVAL = 0.1 -- throttle for the mouse-leave hide check + +local flyoutIcons = {} -- pooled flyout icon buttons (created once) +local flyoutFrame = nil -- lazily created shared flyout frame +local flyoutElement = nil -- element currently shown in the flyout +local flyoutOwnerButton = nil -- element button the flyout is anchored to +local flyoutElapsed = 0 -- throttle accumulator for the hide check + +-- Forward declarations so functions defined later can be referenced by +-- closures created earlier in the file (standard Lua forward-decl idiom: +-- `local foo` then later `foo = function() ... end` / `function foo()`). +local RefreshButton +local RefreshCooldown +local EnsureFlyoutFrame +local ShowFlyout +local RefreshFlyoutCooldowns +local HideFlyout +local OnFlyoutUpdate +local CreateElementButton +local CreateRecallButton +local CreateDropSetButton +local RefreshRecallIndicator +local OnRecallUpdate +local UpdateTimerDisplays +local UpdateWaves +local OnBarUpdate +local OnDragStart +local OnDragStop +local EnsureAssignFrame +local assignFrame -- lazily created pending-suggestion panel + +-- Finds the spellbook index of a known spell by exact name, or nil. +-- Cache-backed accessor from core/spellindex.lua (loaded earlier in the +-- TOC) -- used to be its own fresh linear GetSpellName() scan here. +local FindSpellIndexByName = TotemBar.findSpellIndex + +-- Fills a tooltip with a totem spell, resolved to the HIGHEST known rank. +-- A first-name-match slot (FindSpellIndexByName) is RANK 1, so SetSpell on +-- it showed rank-1 mana/damage/duration - misleading, since the name-based +-- CAST always uses the highest rank. Also appends the rank to the name +-- line, which 1.12's SetSpell omits entirely. +local function SetSpellTooltip(tip, name) + local idx = (TotemBar.findHighestRankSlot and TotemBar.findHighestRankSlot(name)) + or FindSpellIndexByName(name) + if not idx then + tip:SetText(name) + return + end + tip:SetSpell(idx, BOOKTYPE_SPELL) + local _, rank = GetSpellName(idx, BOOKTYPE_SPELL) + if rank and rank ~= "" then + tip:AppendText(" |cffa8a8a8(" .. rank .. ")|r") + end +end + +-- Resolves the icon texture path for whatever totem is currently chosen +-- for `element`, or nil when the slot is empty/unresolved. Callers route +-- the result through SetElementIcon, which draws the sheet's per-element +-- glyph for nil instead of a spellbook texture. +local function GetElementIcon(element) + local db = TotemBarDB + local name = db and db.chosen and db.chosen[element] + if not name then + return nil + end + local idx = FindSpellIndexByName(name) + if not idx then + return nil + end + return GetSpellTexture(idx, BOOKTYPE_SPELL) +end + +-- Resolves (iconTexture, known) for an arbitrary totem name: the spellbook +-- texture when the player knows it, else the empty/question-mark icon and +-- known=false (the pending panel greys unknown totems). +local function ResolveTotemIcon(name) + if not name then + return EMPTY_ICON, false + end + local idx = FindSpellIndexByName(name) + if not idx then + return EMPTY_ICON, false + end + local texture = GetSpellTexture(idx, BOOKTYPE_SPELL) + return texture or EMPTY_ICON, true +end + +-- Resolves the Totemic Recall icon by scanning the spellbook for the +-- real spell (so it matches whatever texture TWoW actually ships), +-- falling back to a hardcoded texture if the scan can't find it. +local function GetRecallIcon() + local idx = FindSpellIndexByName(RECALL_SPELL_NAME) + if idx then + local texture = GetSpellTexture(idx, BOOKTYPE_SPELL) + if texture then + return texture + end + end + return RECALL_ICON_FALLBACK +end + +-- Sets an element button's icon: real spell icon (standard crop) when a +-- totem is chosen/resolved, element glyph from the sheet when empty. +-- SetTexCoord persists across SetTexture, so both branches must set it. +local function SetElementIcon(btn, texturePath) + if texturePath then + btn.icon:SetTexture(texturePath) + btn.icon:SetTexCoord(ICON_CROP, 1 - ICON_CROP, ICON_CROP, 1 - ICON_CROP) + else + local c = iconCoords[ELEMENT_EMPTY_CELL[btn.element] or 1] + btn.icon:SetTexture(ICONS_TEXTURE) + btn.icon:SetTexCoord(c.l, c.r, c.t, c.b) + end +end + +RefreshButton = function(element) + local btn = elementButtons[element] + if not btn then + return + end + SetElementIcon(btn, GetElementIcon(element)) + RefreshCooldown(element) +end + +-- Refreshes element `element`'s native cooldown swipe (the same radial +-- CooldownFrameTemplate widget standard action buttons use) to match its +-- currently-chosen totem's spellbook cooldown. Clears the swipe (start=0) +-- when the slot is empty or unresolved. Called from RefreshButton (i.e. +-- on selection changes and once at bar-build time) and from the +-- SPELL_UPDATE_COOLDOWN event handler below - NEVER from the per-tick +-- timer OnUpdate, since re-calling CooldownFrame_SetTimer every tick +-- would restart the swipe's animation instead of letting it play. +RefreshCooldown = function(element) + local btn = elementButtons[element] + if not btn then + return + end + local db = TotemBarDB + local totemName = db and db.chosen and db.chosen[element] + local idx = totemName and FindSpellIndexByName(totemName) + if not idx then + CooldownFrame_SetTimer(btn.cd, 0, 0, 0) + return + end + local start, duration, enable = GetSpellCooldown(idx, BOOKTYPE_SPELL) + CooldownFrame_SetTimer(btn.cd, start, duration, enable) +end + +-- Glue for core/assign.lua's pending-assignment logic: it stays WoW-API- +-- light and reaches the spellbook / bar refresh through these slots. +-- isTotemKnown resolves a totem name against the live spellbook; RefreshAll +-- re-skins every element button (used after a pending assignment is applied). +TotemBar.isTotemKnown = function(name) + return FindSpellIndexByName(name) ~= nil +end + +TotemBar.RefreshAll = function() + local elements = TotemBar.TOTEM_ELEMENTS + for i = 1, table.getn(elements) do + RefreshButton(elements[i]) + end +end + +-- Bind-mode key overlays: a small top-right FontString on each bindable +-- button/flyout icon, ALWAYS shown (action-bar-hotkey style) whenever a key +-- is bound, hidden only when nothing is bound. Independent of bind mode. +-- Declared here, BEFORE EnsureFlyoutFrame/CreateElementButton/ +-- CreateRecallButton/CreateDropSetButton (all of which call +-- registerBindOverlay from their function bodies): Lua resolves a `local` +-- as an upvalue only for code appearing lexically after its declaration, so +-- this block must sit above every factory that references it, not just +-- above CreateElementButton (EnsureFlyoutFrame is defined earlier in the +-- file than the element/recall/dropset button factories). +local bindOverlayTargets = {} -- { frame = button, action = fn()->command } + +local function ensureBindOverlay(frame) + if frame.bindKeyText then + return frame.bindKeyText + end + local fs = frame:CreateFontString(nil, "OVERLAY") + fs:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE") + fs:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -1, -1) + fs:SetTextColor(1, 0.9, 0.2) + fs:Hide() + frame.bindKeyText = fs + return fs +end + +-- action is a function returning the binding command for this frame right now +-- (flyout icons change totem), or nil. +local function registerBindOverlay(frame, actionFn) + ensureBindOverlay(frame) + tinsert(bindOverlayTargets, { frame = frame, action = actionFn }) +end + +TotemBar.refreshBindOverlays = function() + for i = 1, table.getn(bindOverlayTargets) do + local t = bindOverlayTargets[i] + local fs = t.frame.bindKeyText + local cmd = t.action() + local key = cmd and GetBindingKey(cmd) or nil + if key then + fs:SetText(TotemBar.shortenKey(key)) + fs:Show() + else + fs:Hide() + end + end +end + +-- Replaces the square Blizzard hover/pushed art with round FX-sheet states, +-- sized to the ring frame so they hug the round button. Declared here, +-- BEFORE EnsureFlyoutFrame (same ordering constraint as registerBindOverlay +-- above): the pooled flyout icons' creation loop lives inside +-- EnsureFlyoutFrame and calls this, so it must already be a visible upvalue +-- at that point in the file, not just before CreateElementButton/ +-- CreateRecallButton/CreateDropSetButton further down. +local function ApplyRoundClickStates(btn, size) + btn:SetHighlightTexture(FX_TEXTURE, "ADD") + local h = btn:GetHighlightTexture() + if h then + local c = fxCoords[FX_HOVER_CELL] + h:SetTexCoord(c.l, c.r, c.t, c.b) + h:ClearAllPoints() + h:SetPoint("CENTER", btn, "CENTER", 0, 0) + h:SetWidth(size) + h:SetHeight(size) + end + btn:SetPushedTexture(FX_TEXTURE) + local p = btn:GetPushedTexture() + if p then + local c = fxCoords[FX_PUSHED_CELL] + p:SetTexCoord(c.l, c.r, c.t, c.b) + p:ClearAllPoints() + p:SetPoint("CENTER", btn, "CENTER", 0, 0) + p:SetWidth(size) + p:SetHeight(size) + end +end + +-- Repositions every pooled flyout icon per the current BUTTON_GAP (mirrors +-- the bottom-up stacking math used when the icons are first built, below). +-- The icon pool is built lazily ONCE and its anchors are baked in at that +-- point - they don't reflow themselves - so this must be called again +-- whenever BUTTON_GAP changes live (TotemBar.SetButtonGap), not just at +-- build time. `frame` lets the build-time call pass the not-yet-assigned +-- local `f` directly; later calls omit it and fall back to the flyoutFrame +-- upvalue (guarded: a no-op if the flyout has never been built yet). +local function layoutFlyoutIcons(frame) + local f = frame or flyoutFrame + if not f then + return + end + for i = 1, MAX_FLYOUT_ICONS do + local ico = flyoutIcons[i] + if ico then + ico:ClearAllPoints() + ico:SetPoint("BOTTOM", f, "BOTTOM", 0, FLYOUT_PAD + (i - 1) * (BUTTON_SIZE + BUTTON_GAP)) + end + end +end + +-- Lazily builds the single shared flyout frame plus its pool of icon +-- buttons (TotemBarFlyoutIcon1..MAX_FLYOUT_ICONS), stacked bottom-up so +-- ShowFlyout can just Show the first N. DIALOG strata so it draws above +-- the bar backdrop. Same anti-bevel icon treatment as the element +-- buttons (opaque black backdrop under an inset, cropped ARTWORK icon). +EnsureFlyoutFrame = function() + if flyoutFrame then + return flyoutFrame + end + + -- Parent to the bar (not UIParent) so the flyout inherits the bar's + -- scale (UI-size slider) and hides with it. Still DIALOG strata below, + -- so it draws above the bar backdrop regardless of parent. + local f = CreateFrame("Frame", "TotemBarFlyout", TotemBarFrame) + f:SetFrameStrata("DIALOG") + f:SetWidth(BUTTON_SIZE + FLYOUT_PAD * 2) + f:SetHeight(BUTTON_SIZE + FLYOUT_PAD * 2) + f:SetBackdrop({ + bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + tile = true, tileSize = 16, edgeSize = 16, + insets = { left = 4, right = 4, top = 4, bottom = 4 }, + }) + -- Floating icons: fully transparent flyout backdrop/border (the flyout + -- frame itself is now just an invisible hit-test/layout area). + f:SetBackdropColor(0, 0, 0, 0) + f:SetBackdropBorderColor(0, 0, 0, 0) + f:EnableMouse(true) -- so gaps/padding still count as "over the flyout" + f:SetClampedToScreen(true) + f:Hide() + + for i = 1, MAX_FLYOUT_ICONS do + local ico = CreateFrame("Button", "TotemBarFlyoutIcon" .. i, f) + ico:SetWidth(BUTTON_SIZE) + ico:SetHeight(BUTTON_SIZE) + -- Bottom-up: icon 1 just inside the bottom padding, each next + -- one a full button+gap higher (so nearest the element button + -- is first). Anchor applied below via layoutFlyoutIcons (once the + -- whole pool exists), not here - the same helper re-applies it + -- live when BUTTON_GAP changes. + + ico:SetBackdrop({ + bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + tile = true, tileSize = 16, edgeSize = 12, + insets = { left = 3, right = 3, top = 3, bottom = 3 }, + }) + ico:SetBackdropColor(0, 0, 0, 0) + ico:SetBackdropBorderColor(0, 0, 0, 0) + + local icon = ico:CreateTexture("TotemBarFlyoutIcon" .. i .. "Icon", "ARTWORK") + icon:SetPoint("TOPLEFT", ico, "TOPLEFT", 3, -3) + icon:SetPoint("BOTTOMRIGHT", ico, "BOTTOMRIGHT", -3, 3) + icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) + ico.icon = icon + + -- Grounding shadow ellipse (Rev 4 UI chrome), same as the element + -- buttons' - BACKGROUND layer, no glow on flyout icons so ordering + -- relative to other regions doesn't matter here. + local shadow = ico:CreateTexture("TotemBarFlyoutIcon" .. i .. "Shadow", "BACKGROUND") + shadow:SetTexture(UI_TEXTURE) + local sc = uiCoords[UI_SHADOW_CELL] + shadow:SetTexCoord(sc.l, sc.r, sc.t, sc.b) + shadow:SetPoint("CENTER", ico, "CENTER", 0, -15) + shadow:SetWidth(58) + shadow:SetHeight(20) + shadow:SetAlpha(0.7) -- softened, see the element buttons' plate note + ico.shadow = shadow + + -- Floating round frame, same decorative band as the element + -- buttons (no fill/tint - just the permanent frame). Flyout icons + -- are BUTTON_SIZE (36px) too, so the proportional ring size + -- S*(48/36) is just RING_TEX_SIZE (48px) again. + local ringTrack = ico:CreateTexture("TotemBarFlyoutIcon" .. i .. "RingTrack", "OVERLAY") + ringTrack:SetTexture(RING_TEXTURE) + local rtc = allRingCoords[RING_TRACK_CELL] + ringTrack:SetTexCoord(rtc.l, rtc.r, rtc.t, rtc.b) + ringTrack:SetPoint("CENTER", ico, "CENTER", 0, 0) + ringTrack:SetWidth(RING_TEX_SIZE) + ringTrack:SetHeight(RING_TEX_SIZE) + ico.ringTrack = ringTrack + + -- Native cooldown swipe over the flyout icon, so the player can + -- see which alternative totems are on cooldown while choosing. + -- Same "Model" (NOT "Cooldown") frame-type caveat as the element + -- buttons: vanilla 1.12 has no dedicated Cooldown widget type - + -- CooldownFrameTemplate is a Model template here (see the element + -- button's cd note above). Driven once at flyout-open by + -- ShowFlyout (the widget animates itself from start+duration), so + -- no per-frame refresh for this transient popup. + local cd = CreateFrame("Model", "TotemBarFlyoutIcon" .. i .. "Cooldown", ico, "CooldownFrameTemplate") + cd:SetAllPoints(ico.icon) + cd:SetFrameLevel(ico:GetFrameLevel()) + ico.cd = cd + + -- Round hover/pushed states (Rev 3), same FX sheet as the bar + -- buttons; flyout icons are BUTTON_SIZE, sized like the bar's ring. + ApplyRoundClickStates(ico, RING_TEX_SIZE) + ico:RegisterForClicks("LeftButtonUp", "RightButtonUp") + + -- Left-click: cast once for the element the flyout is currently + -- showing. recordCast() drives THAT element's timer to the + -- actually-cast totem, without touching the slot's chosen + -- default. Right-click: make this totem the slot's new chosen + -- default and re-populate the flyout in place (so the + -- newly-chosen totem drops out of the "others" list and the + -- previously-chosen one appears), keeping the flyout open. + ico:SetScript("OnClick", function() + if not (this.totemName and flyoutElement) then + return + end + if arg1 == "RightButton" then + local element = flyoutElement + local owner = flyoutOwnerButton + TotemBarDB.chosen[element] = this.totemName + RefreshButton(element) + if owner then + ShowFlyout(owner, element) + end + else + CastSpellByName(this.totemName) + TotemBar.recordCast(flyoutElement, this.totemName) + -- Immediate cooldown feedback: don't wait for + -- SPELL_UPDATE_COOLDOWN. Refreshes the bar button's swipe + -- (in case the cast totem is also this element's chosen + -- default) and the flyout icon's own swipe. + RefreshCooldown(flyoutElement) + RefreshFlyoutCooldowns() + end + end) + + ico:SetScript("OnEnter", function() + if this.totemName then + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + SetSpellTooltip(GameTooltip, this.totemName) + GameTooltip:AddLine("Left-click: cast / Right-click: set default", 1, 1, 1) + GameTooltip:Show() + end + end) + ico:SetScript("OnLeave", function() + GameTooltip:Hide() + end) + + ico:Hide() + registerBindOverlay(ico, function() + if ico.totemName then + return "TOTEMBAR_TOTEM_" .. TotemBar.bindingSuffix(ico.totemName) + end + return nil + end) + flyoutIcons[i] = ico + end + + -- Anchors every pooled icon per BUTTON_GAP now that the whole pool + -- exists (flyoutFrame isn't assigned yet at this point in the function, + -- hence passing `f` explicitly - see layoutFlyoutIcons's own comment). + layoutFlyoutIcons(f) + + -- OnUpdate only fires while the frame is shown, so this hide-check + -- naturally stops running once the flyout is hidden. + f:SetScript("OnUpdate", OnFlyoutUpdate) + + flyoutFrame = f + return f +end + +-- Populates and shows the flyout above `button` for `element`, listing +-- every known totem of that element EXCEPT the currently-chosen default. +-- Shows nothing if there are no "others". +ShowFlyout = function(button, element) + local f = EnsureFlyoutFrame() + + local db = TotemBarDB + local spellNames = TotemBar.scanSpellbook() + local known = TotemBar.knownTotems(spellNames, element) + local chosen = db and db.chosen and db.chosen[element] + + local count = 0 + for i = 1, table.getn(known) do + local totemName = known[i] + if totemName ~= chosen and count < MAX_FLYOUT_ICONS then + count = count + 1 + local ico = flyoutIcons[count] + ico.totemName = totemName + local idx = FindSpellIndexByName(totemName) + local texture = idx and GetSpellTexture(idx, BOOKTYPE_SPELL) + ico.icon:SetTexture(texture or EMPTY_ICON) + -- Drive the swipe once here at flyout-open so the player can + -- see which alternative totems are on cooldown; the widget + -- animates itself from start+duration, no per-tick refresh. + if idx then + local start, duration, enable = GetSpellCooldown(idx, BOOKTYPE_SPELL) + CooldownFrame_SetTimer(ico.cd, start, duration, enable) + else + CooldownFrame_SetTimer(ico.cd, 0, 0, 0) + end + ico:Show() + end + end + + for j = count + 1, MAX_FLYOUT_ICONS do + flyoutIcons[j]:Hide() + flyoutIcons[j].totemName = nil + -- Clear the swipe on unused pool icons so a stale cooldown from a + -- previous open doesn't linger if this slot is reused later. + CooldownFrame_SetTimer(flyoutIcons[j].cd, 0, 0, 0) + end + + if count == 0 then + -- No other known totems for this element; don't show an empty box. + flyoutElement = nil + flyoutOwnerButton = nil + f:Hide() + return + end + + flyoutElement = element + flyoutOwnerButton = button + flyoutElapsed = 0 + + f:SetHeight(count * (BUTTON_SIZE + BUTTON_GAP) - BUTTON_GAP + FLYOUT_PAD * 2) + f:ClearAllPoints() + f:SetPoint("BOTTOM", button, "TOP", 0, FLYOUT_GAP) + f:Show() + -- Freshly-populated flyout icons need their overlays re-driven right + -- away (rather than waiting for the next UPDATE_BINDINGS/toggle) so + -- keys show immediately while bind mode is already on. + if TotemBar.refreshBindOverlays then + TotemBar.refreshBindOverlays() + end +end + +-- Re-drives the cooldown swipe on every CURRENTLY-SHOWN pooled flyout +-- icon, re-resolving each one's spellbook index fresh (so a just-cast +-- totem's swipe reflects the cooldown that cast just started). No-op if +-- the flyout isn't open. Doesn't touch icon texture/totemName/layout - +-- only the swipe - so it's safe to call from anywhere without disturbing +-- ShowFlyout's own population pass (which sets the initial swipe state +-- itself, including clearing unused pool icons; left as-is here). +RefreshFlyoutCooldowns = function() + if not (flyoutFrame and flyoutFrame:IsShown()) then + return + end + for i = 1, MAX_FLYOUT_ICONS do + local ico = flyoutIcons[i] + if ico:IsShown() and ico.totemName then + local idx = FindSpellIndexByName(ico.totemName) + if idx then + local start, duration, enable = GetSpellCooldown(idx, BOOKTYPE_SPELL) + CooldownFrame_SetTimer(ico.cd, start, duration, enable) + else + CooldownFrame_SetTimer(ico.cd, 0, 0, 0) + end + end + end +end + +HideFlyout = function() + if flyoutFrame then + flyoutFrame:Hide() + end + flyoutElement = nil + flyoutOwnerButton = nil +end + +-- Throttled mouse-leave check: hide the flyout once the cursor is over +-- NEITHER the owning element button NOR the flyout itself. This keeps +-- it open while the mouse travels from the button up onto the flyout. +-- No per-frame allocation - just the accumulator and two MouseIsOver +-- geometry checks, gated to run ~every FLYOUT_HIDE_INTERVAL seconds. +OnFlyoutUpdate = function() + flyoutElapsed = flyoutElapsed + arg1 + if flyoutElapsed < FLYOUT_HIDE_INTERVAL then + return + end + flyoutElapsed = 0 + if flyoutOwnerButton and (MouseIsOver(flyoutFrame) or MouseIsOver(flyoutOwnerButton)) then + return + end + HideFlyout() +end + +-- Applies the floating round-icon frame to one button: permanent +-- decorative-frame texture (ringTrack) + duration-arc texture/tint +-- (ringFill, where present) + wave tint (pulseWave, where present), and +-- blanks the button's own square backdrop/border - the frame ring now IS +-- the visual frame, always (no ring-option gate: even with the duration +-- arc hidden, the permanent ring frame stays visible and the button must +-- never show a black box behind it). Used for element buttons (which have +-- ringFill/pulseWave) and the Recall/DropSet buttons (which don't, guarded +-- below) alike. +local function ApplyRoundFrame(btn) + btn.ringTrack:SetTexture(RING_TEXTURE) + local trc = allRingCoords[RING_TRACK_CELL] + btn.ringTrack:SetTexCoord(trc.l, trc.r, trc.t, trc.b) + + if btn.ringFill then + btn.ringFill:SetTexture(RING_TEXTURE) + -- No element-color assignment here anymore: the duration ring is + -- now a time-remaining traffic light (green->yellow->orange->red), + -- recolored every tick in UpdateTimerDisplays via TotemBar.timeColor. + btn.ringLastIdx = nil -- force a fill-frame re-apply on the next tick + end + + if btn.pulseArc then + local c = ELEMENT_COLORS[btn.element] + if c then + btn.pulseArc:SetVertexColor(c[1], c[2], c[3], 0.95) + end + end + + if btn.pulseWave then + local c = ELEMENT_COLORS[btn.element] + if c then + btn.pulseWave:SetVertexColor(c[1], c[2], c[3]) + end + end + + if btn.glow then + local c = ELEMENT_COLORS[btn.element] + if c then + btn.glow:SetVertexColor(c[1], c[2], c[3]) + end + end + + btn:SetBackdropColor(0, 0, 0, 0) + btn:SetBackdropBorderColor(0, 0, 0, 0) +end + +-- The timer text hangs below the button (the pulse arc and ripple wave are +-- both centered ON the button, so neither competes for this anchor). +local function ApplyTimerAnchor(btn) + btn.timerText:ClearAllPoints() + btn.timerText:SetPoint("TOP", btn, "BOTTOM", 0, -1) +end + +-- Deterministic-stacking child frame for regions that must render above +-- ALL of a button's own regions regardless of draw layer (2026-07-16, +-- HIGHLIGHT-hover regression fix). Background: f3644c5 moved +-- ringFill/pulseWave/pulseArc (and the Recall button's autoIndicator) to +-- the HIGHLIGHT layer to beat ringTrack's OVERLAY layer - but 1.12 only +-- renders a Button's HIGHLIGHT draw layer while the mouse is hovering it +-- (in-game confirmed: every fill ring/indicator went invisible except +-- under the cursor). Layers were never going to work for "always-on-top, +-- always visible" content on a Button. The fix used here instead (the same +-- pattern used elsewhere for row-highlight panel frames): WoW's cross-frame +-- draw order is primarily (strata, +-- frame level) - a CHILD FRAME at a higher FrameLevel than its parent +-- draws every one of its own regions above ALL of the parent's regions, +-- no matter which of the 5 draw layers either side uses. So a small, +-- mouse-transparent child frame pinned over the button's own bounds, +-- one level higher, gives deterministic top-of-stack rendering without +-- spending any of the parent's own 5-layer budget. +local function CreateRingOverlay(btn, name) + local overlay = CreateFrame("Frame", name, btn) + overlay:EnableMouse(false) -- pure visual layer, must not steal btn's hover/click + overlay:SetAllPoints(btn) -- deckungsgleich with btn; CENTER anchors below resolve identically + overlay:SetFrameLevel(btn:GetFrameLevel() + 1) + return overlay +end + +CreateElementButton = function(element, index) + local name = "TotemBarButton" .. element + local btn = CreateFrame("Button", name, TotemBarFrame) + btn:SetWidth(BUTTON_SIZE) + btn:SetHeight(BUTTON_SIZE) + btn:SetPoint("LEFT", TotemBarFrame, "LEFT", (index - 1) * (BUTTON_SIZE + BUTTON_GAP) + BUTTON_GAP, 0) + + -- Backdrop template kept (needed for SetBackdropColor/BorderColor + -- below) but floating: ApplyRoundFrame (called later in this function) + -- blanks both to fully transparent - the decorative ring frame is the + -- only visible frame around the icon now. + btn:SetBackdrop({ + bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + tile = true, tileSize = 16, edgeSize = 12, + insets = { left = 3, right = 3, top = 3, bottom = 3 }, + }) + + local icon = btn:CreateTexture(name .. "Icon", "ARTWORK") + -- Inset a few px from the button's own edges (the floating decorative + -- ring frame, drawn OVERLAY on top, covers this margin and the icon's + -- square corners - see ApplyRoundFrame/the texture generator's + -- geometry rationale). + icon:SetPoint("TOPLEFT", btn, "TOPLEFT", 3, -3) + icon:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -3, 3) + btn.icon = icon + -- Set ahead of SetElementIcon below (which reads btn.element to pick + -- the empty-slot glyph cell); reassigned harmlessly again further + -- down alongside the rest of the button's setup, same repeat-write + -- pattern as the element-tint lookup ahead of ApplyRoundFrame below. + btn.element = element + -- SetElementIcon sets both texture and texcoord (SetTexCoord persists + -- across SetTexture, so both the real-icon crop and the sheet-glyph + -- coords always get (re)applied together - see its own comment). + SetElementIcon(btn, GetElementIcon(element)) + + -- Native action-button-style radial cooldown swipe (the same + -- CooldownFrameTemplate widget the default action bars use), + -- covering the icon area. This is IN ADDITION to the OmniCC-style + -- duration timer text below; RefreshCooldown() drives it from + -- events, see there for why it's not driven off the per-tick timer. + -- + -- Frame type is "Model", NOT "Cooldown": vanilla 1.12 has no + -- dedicated Cooldown widget type (that was added in TBC/2.0) - + -- CooldownFrameTemplate is a Model-type template in 1.12 FrameXML + -- that renders Interface\Cooldown\UI-Cooldown-Indicator.mdx (same + -- approach pfUI's own libtotem/action bars use). CreateFrame with + -- type "Cooldown" would error on this client. + local cd = CreateFrame("Model", name .. "Cooldown", btn, "CooldownFrameTemplate") + cd:SetAllPoints(icon) + -- Match the button's own frame level instead of the default child + -- bump (parent level + 1): WoW's cross-frame draw order is primarily + -- (strata, level) - at the default +1 level the ENTIRE swipe frame + -- would draw above ALL of btn's own regions regardless of draw + -- layer, including the OVERLAY timer text below. At the SAME level, + -- ordering falls back to per-region draw layer, letting the OVERLAY + -- timer text render on top of the swipe as intended. Still flagged + -- for an in-game visual check (see file header note). + cd:SetFrameLevel(btn:GetFrameLevel()) + btn.cd = cd + + -- OmniCC-style remaining-duration text, anchored BELOW the button + -- (centered under the icon) so it doesn't overlap the native + -- cooldown swipe (btn.cd) rendered on top of the icon. Parented to + -- btn (a Button, like the element buttons themselves), not to the + -- bar's backdrop Frame - plain 1.12 Frames don't clip child regions + -- (no SetClipsChildren in this client), so text hanging below the + -- bar's backdrop still renders fully; flagged for an in-game visual + -- check regardless (see file header note). Hidden by default; + -- UpdateTimerDisplays() shows/updates it. + -- + -- Left parented to btn (NOT moved to ringOverlay), 2026-07-16 + -- HIGHLIGHT-hover regression fix: geometry check (btn height 36, + -- ring RING_TEX_SIZE 48 centered on btn -> ring overhangs 6px past + -- every edge) puts ring:GetBottom() at btn:GetBottom()-6, while this + -- FontString's SetPoint("TOP", btn, "BOTTOM", 0, -1) puts its OWN top + -- at only btn:GetBottom()-1 - i.e. a ~5px vertical band where the two + -- regions' bounding boxes technically overlap, directly under the + -- button's center. In practice that band is the ring's transparent + -- annulus hole (the band's opaque pixels hug its outer 48px edge, not + -- its center) plus the font's own ascender padding above the glyphs, + -- so no visible ink collision - stays on btn/OVERLAY, unchanged. + local timerText = btn:CreateFontString(name .. "Timer", "OVERLAY") + timerText:SetFont("Fonts\\FRIZQT__.TTF", 14, "OUTLINE") + timerText:SetPoint("TOP", btn, "BOTTOM", 0, -1) + timerText:SetJustifyH("CENTER") + timerText:SetText("") + timerText:Hide() + btn.timerText = timerText + btn.timerVisible = false -- cached shown-state, avoid redundant Show/Hide + btn.timerLastText = nil -- cached last string, avoid redundant SetText + btn.timerLastLow = nil -- cached last <=5s tint state, avoid redundant SetTextColor + btn.tintRed = false -- cached out-of-range tint state, avoid redundant SetVertexColor + + -- Set here (ahead of the later btn.element = element below) because + -- ApplyRoundFrame, called at the end of this block, needs it for the + -- element-tint color lookup; the later assignment is then a harmless + -- no-op repeat of the same value. + btn.element = element + + -- Grounding shadow ellipse (Rev 4 UI chrome). BACKGROUND layer - the + -- anticipation glow below used to also be BACKGROUND on the (wrong) + -- assumption that same-layer draw order follows creation order in this + -- client; the 2026-07-16 ring-occlusion investigation live-confirmed + -- that's false (ringTrack was randomly hiding ringFill despite being + -- created first) - 1.12 discards SetDrawLayer's sublevel argument, so + -- same-MAIN-layer siblings really do have undefined relative order. + -- glow now sits on its own BORDER layer instead (below), which is the + -- real fix: no assumption needed when the two don't share a layer. + local shadow = btn:CreateTexture(name .. "Shadow", "BACKGROUND") + shadow:SetTexture(UI_TEXTURE) + local sc = uiCoords[UI_SHADOW_CELL] + shadow:SetTexCoord(sc.l, sc.r, sc.t, sc.b) + shadow:SetPoint("CENTER", btn, "CENTER", 0, -15) + shadow:SetWidth(58) + shadow:SetHeight(20) + -- Softened after preview review: full-strength plate read boxy on light + -- ground; flatter + more transparent grounds without drawing attention. + shadow:SetAlpha(0.7) + btn.shadow = shadow + + -- Pulse UI Rev 2: floating round frame (permanent decorative band + + -- duration-arc fill) + ripple wave. Sized to RING_TEX_SIZE (48px) + -- centered on the 36px button, NOT SetAllPoints - the ring extends + -- past the button's own edges (see the geometry rationale in the + -- texture generator). + -- + -- Frame-level stacking (2026-07-16, HIGHLIGHT-hover regression fix - + -- see CreateRingOverlay's own comment for the full story): ringTrack, + -- ringFill, pulseWave and pulseArc are all centered on the same point + -- at the same RING_TEX_SIZE, i.e. all four spatially overlap. A prior + -- fix moved the three dynamic ones to HIGHLIGHT to beat ringTrack's + -- OVERLAY - but 1.12 only paints a Button's HIGHLIGHT layer on hover, + -- so that made them invisible almost always. ringTrack is the one + -- PERMANENT member of this quartet (always shown, see ringTrack:Show() + -- below) so it stays right where it was: parented to btn, OVERLAY + -- layer. ringFill/pulseWave/pulseArc move to a dedicated child frame + -- (ringOverlay) one FrameLevel above btn, which puts all three above + -- ringTrack unconditionally, regardless of draw layer. Within that + -- child frame the three still need to not collide with EACH OTHER + -- (1.12 discards SetDrawLayer's sublevel argument, so same-main-layer + -- siblings have undefined relative order) - given 3 items and a full + -- 5-layer budget to itself now, each gets its own layer: + -- ringFill = ARTWORK (base: the colored duration-remaining ring) + -- pulseWave = OVERLAY (the brief per-pulse ripple must read above + -- the fill it pulses over) + -- pulseArc = HIGHLIGHT (the thin white countdown-to-next-pulse + -- readout; per its own file comment it must + -- "read distinctly" from ringFill, so it gets + -- the topmost layer to stay legible even + -- while a wave is mid-flash) + -- All three are still spatially anchored to the SAME center point as + -- before (now via ringOverlay, which is deckungsgleich with btn - + -- SetAllPoints - so a CENTER anchor resolves to the identical pixel + -- as anchoring to btn directly). + local ringTrack = btn:CreateTexture(name .. "RingTrack", "OVERLAY") + ringTrack:SetPoint("CENTER", btn, "CENTER", 0, 0) + ringTrack:SetWidth(RING_TEX_SIZE) + ringTrack:SetHeight(RING_TEX_SIZE) + ringTrack:Show() -- the frame is permanent, unlike ringFill/pulseWave below + btn.ringTrack = ringTrack + + local ringOverlay = CreateRingOverlay(btn, name .. "RingOverlay") + btn.ringOverlay = ringOverlay + + local ringFill = ringOverlay:CreateTexture(name .. "RingFill", "ARTWORK") + ringFill:SetPoint("CENTER", ringOverlay, "CENTER", 0, 0) + ringFill:SetWidth(RING_TEX_SIZE) + ringFill:SetHeight(RING_TEX_SIZE) + ringFill:Hide() + btn.ringFill = ringFill + btn.ringLastIdx = nil -- cached flipbook frame, avoid redundant SetTexCoord + btn.ringVisible = false -- cached shown-state, avoid redundant Show/Hide + + -- Ripple wave: expanding, fading ring in the element color, one wave + -- per pulse (replaces the old pulse progress bar). Animated every + -- frame by UpdateWaves below (allocation-free), inputs refreshed by + -- the 0.1s timer tick in UpdateTimerDisplays. + local pulseWave = ringOverlay:CreateTexture(name .. "PulseWave", "OVERLAY") + pulseWave:SetTexture(RING_TEXTURE) + local wc = allRingCoords[RING_WAVE_CELL] + pulseWave:SetTexCoord(wc.l, wc.r, wc.t, wc.b) + pulseWave:SetPoint("CENTER", ringOverlay, "CENTER", 0, 0) + pulseWave:SetWidth(RING_TEX_SIZE) + pulseWave:SetHeight(RING_TEX_SIZE) + pulseWave:Hide() + btn.pulseWave = pulseWave + btn.waveVisible = false + -- Per-frame wave animation inputs, refreshed by the 0.1s tick; nil + -- interval means "no wave for this slot right now". + btn.waveInterval = nil + btn.waveOrigin = nil -- phase origin (pulseAnchor or start) + btn.waveOneshotAt = nil -- detonation time for oneshot waves + + -- Circular pulse countdown: element-colored inner arc on the band, + -- fills clockwise toward the next pulse and resets (user feedback: the + -- wave alone doesn't show WHEN; a circular bar fits the round slots). + -- Colored below by ApplyRoundFrame (called at the end of this function) + -- via ELEMENT_COLORS - no fixed color set here. + local pulseArc = ringOverlay:CreateTexture(name .. "PulseArc", "HIGHLIGHT") + pulseArc:SetTexture(PULSEARC_TEXTURE) + pulseArc:SetPoint("CENTER", ringOverlay, "CENTER", 0, 0) + pulseArc:SetWidth(RING_TEX_SIZE) + pulseArc:SetHeight(RING_TEX_SIZE) + pulseArc:Hide() + btn.pulseArc = pulseArc + btn.pulseArcLastIdx = nil + btn.pulseVisible = false + + -- Anticipation glow: annular halo just outside the ring band, fading in + -- over the last stretch before a pulse. BORDER layer (moved from + -- BACKGROUND, 2026-07-16 - see the shadow comment above): stays below + -- ARTWORK (icon) as intended, but no longer shares a layer with shadow. + local glow = btn:CreateTexture(name .. "Glow", "BORDER") + glow:SetTexture(FX_TEXTURE) + local gc = fxCoords[FX_GLOW_CELL] + glow:SetTexCoord(gc.l, gc.r, gc.t, gc.b) + glow:SetPoint("CENTER", btn, "CENTER", 0, 0) + glow:SetWidth(GLOW_TEX_SIZE) + glow:SetHeight(GLOW_TEX_SIZE) + glow:Hide() + btn.glow = glow + btn.glowVisible = false + btn.spawnAt = nil -- one-shot spawn ripple origin (set by the tick) + btn.lastStartSeen = nil -- cast-change detector + btn.waveOneshotDelay = nil -- arming window length for oneshot glow + + ApplyRoundFrame(btn) + ApplyTimerAnchor(btn) + + -- No normal texture: UI-Quickslot2's bevel bleeds through + -- transparent icon art (see backdrop above). Round hover/pushed FX + -- states (Rev 3) only appear on click/hover, so they don't bleed. + ApplyRoundClickStates(btn, RING_TEX_SIZE) + + btn:RegisterForClicks("LeftButtonUp", "RightButtonUp") + btn.element = element + + -- Left-click casts the slot's chosen totem. Right-click clears the + -- slot (sets no default); picking a new default happens via the + -- hover flyout's right-click instead (see EnsureFlyoutFrame above). + btn:SetScript("OnClick", function() + local clickedElement = this.element + if arg1 == "RightButton" then + TotemBarDB.chosen[clickedElement] = nil + RefreshButton(clickedElement) + else + local db = TotemBarDB + local totemName = db and db.chosen and db.chosen[clickedElement] + if totemName then + CastSpellByName(totemName) + TotemBar.recordCast(clickedElement, totemName) + else + ChatOut:AddMessage("TotemBar: no totem chosen for " .. clickedElement .. " (hover for known totems, right-click one to set it as default)") + end + end + end) + + btn:SetScript("OnEnter", function() + local db = TotemBarDB + local totemName = db and db.chosen and db.chosen[this.element] + GameTooltip:SetOwner(this, "ANCHOR_TOP") + if totemName then + SetSpellTooltip(GameTooltip, totemName) + GameTooltip:AddLine("Left-click: cast / Right-click: clear", 1, 1, 1) + else + GameTooltip:SetText(this.element .. " (empty)") + GameTooltip:AddLine("Hover for known totems, right-click one to set default", 1, 1, 1) + end + GameTooltip:Show() + -- Pop the "cast one of the others" flyout above this button. + -- The flyout hides itself via its own throttled mouse-leave + -- check (OnFlyoutUpdate), so no OnLeave handling is needed here. + ShowFlyout(this, this.element) + end) + btn:SetScript("OnLeave", function() + GameTooltip:Hide() + end) + + elementButtons[element] = btn + registerBindOverlay(btn, function() return "TOTEMBAR_CAST_" .. string.upper(element) end) + return btn +end + +-- Refreshes the Recall button's small "A" auto-recall indicator to +-- match TotemBarDB.autoRecall: shown (greenish) when on, hidden when +-- off. Called once at button creation and again after every right-click +-- toggle (see CreateRecallButton below). +RefreshRecallIndicator = function() + if not recallButton or not recallButton.autoIndicator then + return + end + if TotemBarDB and TotemBarDB.autoRecall then + recallButton.autoIndicator:SetTextColor(0.3, 1, 0.3) + recallButton.autoIndicator:Show() + else + recallButton.autoIndicator:Hide() + end +end + +-- Public alias so options.lua can refresh the "A" auto-recall indicator +-- after the auto-recall checkbox is toggled. +TotemBar.RefreshRecallIndicator = RefreshRecallIndicator + +-- Pulses the Recall button's icon alpha while anyOutOfRange is true (set +-- by UpdateTimerDisplays) - a "go recall + redeploy" visual prompt. +-- Time-based (GetTime()), so the pulse stays smooth regardless of frame +-- rate; no table/string allocation, just float math + SetAlpha. Once +-- anyOutOfRange goes false, resets to full alpha exactly once (cached +-- via iconPulsing) instead of calling SetAlpha every single frame. +OnRecallUpdate = function() + if anyOutOfRange then + this.icon:SetAlpha(0.35 + 0.65 * math.abs(math.sin(GetTime() * 3))) + this.iconPulsing = true + elseif this.iconPulsing then + this.icon:SetAlpha(1) + this.iconPulsing = false + end +end + +-- Totemic Recall: left-click casts it immediately (no dropdown, no +-- per-element timer - recall has no duration of its own, it just clears +-- totems). Right-click instead toggles TotemBarDB.autoRecall, the flag +-- that decides whether TotemBar.recallAndCastAll() (the Totems macro's +-- entry point, core/cast.lua) prepends a recall before redeploying. A +-- small "A" FontString overlay reflects the current flag state. +CreateRecallButton = function(index) + local name = "TotemBarButtonRecall" + local btn = CreateFrame("Button", name, TotemBarFrame) + btn:SetWidth(BUTTON_SIZE) + btn:SetHeight(BUTTON_SIZE) + btn:SetPoint("LEFT", TotemBarFrame, "LEFT", (index - 1) * (BUTTON_SIZE + BUTTON_GAP) + BUTTON_GAP, 0) + + -- Backdrop template kept (needed for SetBackdropColor/BorderColor + -- below) but floating: ApplyRoundFrame blanks both to fully transparent + -- - the decorative ring frame (below) is the only visible frame now. + btn:SetBackdrop({ + bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + tile = true, tileSize = 16, edgeSize = 12, + insets = { left = 3, right = 3, top = 3, bottom = 3 }, + }) + + local icon = btn:CreateTexture(name .. "Icon", "ARTWORK") + icon:SetPoint("TOPLEFT", btn, "TOPLEFT", 3, -3) + icon:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -3, 3) + icon:SetTexture(GetRecallIcon()) + icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) + btn.icon = icon + btn.iconPulsing = false -- cached: whether the icon's alpha is currently != 1 (avoids redundant SetAlpha calls once the pulse stops) + + -- Auto-recall indicator: small "A" in the icon's top-left corner. Sits + -- inside the area ringTrack (below) is documented to cover ("covers + -- the icon's square corners" - see the element button's icon comment) + -- - i.e. a REAL, code-confirmed spatial collision with ringTrack, not + -- a borderline one. A same-layer HIGHLIGHT promotion (2026-07-16 + -- ring-occlusion follow-up) tried to win that collision the same way + -- ringFill's did, and broke exactly the same way once confirmed + -- in-game: 1.12 only paints a Button's HIGHLIGHT layer on hover, so + -- the "A" went invisible except under the cursor. Fixed the same way + -- as the element buttons' ring stack (see CreateRingOverlay): its own + -- small child frame one FrameLevel above btn, so it renders above + -- ringTrack deterministically regardless of layer. Doesn't share + -- CreateElementButton's ringOverlay (different button, different + -- frame tree) - a second, button-local overlay, OVERLAY layer is + -- fine since it's the only region on it (no sibling to collide with). + -- RefreshRecallIndicator() (called below, and after every toggle) + -- shows/hides and colors it based on TotemBarDB.autoRecall. + local autoOverlay = CreateRingOverlay(btn, name .. "AutoOverlay") + btn.autoOverlay = autoOverlay + local autoIndicator = autoOverlay:CreateFontString(name .. "Auto", "OVERLAY") + autoIndicator:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE") + autoIndicator:SetPoint("TOPLEFT", icon, "TOPLEFT", 1, -1) + autoIndicator:SetJustifyH("LEFT") + autoIndicator:SetText("A") + autoIndicator:Hide() + btn.autoIndicator = autoIndicator + + -- Grounding shadow ellipse (Rev 4 UI chrome). No glow on this button, + -- so creation order relative to other regions doesn't matter here. + local shadow = btn:CreateTexture(name .. "Shadow", "BACKGROUND") + shadow:SetTexture(UI_TEXTURE) + local sc = uiCoords[UI_SHADOW_CELL] + shadow:SetTexCoord(sc.l, sc.r, sc.t, sc.b) + shadow:SetPoint("CENTER", btn, "CENTER", 0, -15) + shadow:SetWidth(58) + shadow:SetHeight(20) + -- Softened after preview review: full-strength plate read boxy on light + -- ground; flatter + more transparent grounds without drawing attention. + shadow:SetAlpha(0.7) + btn.shadow = shadow + + -- Same floating round frame as the element buttons (permanent + -- decorative band, no fill/tint - no ringFill/pulseWave on this + -- button, guarded inside ApplyRoundFrame). + local ringTrack = btn:CreateTexture(name .. "RingTrack", "OVERLAY") + ringTrack:SetPoint("CENTER", btn, "CENTER", 0, 0) + ringTrack:SetWidth(RING_TEX_SIZE) + ringTrack:SetHeight(RING_TEX_SIZE) + btn.ringTrack = ringTrack + ApplyRoundFrame(btn) + + -- No normal texture (UI-Quickslot2 bevel bleeds through transparent + -- icon art). Round hover/pushed FX states (Rev 3) only show on + -- click/hover, no bleed. + ApplyRoundClickStates(btn, RING_TEX_SIZE) + + btn:RegisterForClicks("LeftButtonUp", "RightButtonUp") + + -- "Go recall + redeploy" visual prompt: pulses the icon's alpha + -- while anyOutOfRange is true (set by UpdateTimerDisplays). Only + -- touches btn.icon's alpha, never btn.autoIndicator, so the "A" + -- auto-recall flag text stays steady/visible throughout. + btn:SetScript("OnUpdate", OnRecallUpdate) + + btn:SetScript("OnClick", function() + if arg1 == "RightButton" then + TotemBarDB.autoRecall = not TotemBarDB.autoRecall + if TotemBarDB.autoRecall then + ChatOut:AddMessage("TotemBar: auto-recall before setting ON") + else + ChatOut:AddMessage("TotemBar: auto-recall before setting OFF") + end + RefreshRecallIndicator() + elseif TotemBar.confidentNoneOut and TotemBar.confidentNoneOut() then + -- Confidently nothing out (tracked this session, all expired): don't waste + -- Totemic Recall's 6s cooldown on a no-op cast. After a /reload the tracking is + -- empty and 1.12 can't re-detect a pre-reload totem, so confidentNoneOut fails + -- open there and the recall goes through the else branch below (see core/cast.lua). + ChatOut:AddMessage("TotemBar: no totems out - not recalling (saves the 6s cooldown).") + else + -- Snapshot for the refund learner runs AFTER the cast: activeTotems + -- is still populated here (clearActiveTotems runs last). + CastSpellByName(RECALL_SPELL_NAME) + if TotemBar.snapshotRecallCost then TotemBar.snapshotRecallCost() end + -- Totemic Recall drops every active totem at once; clear our + -- own-tracking timers so the icons' countdowns disappear too + -- (GetTotemInfo, if present, will also reflect this). + TotemBar.clearActiveTotems() + end + end) + + btn:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_TOP") + GameTooltip:SetText(RECALL_SPELL_NAME) + GameTooltip:AddLine("Left-click: recall now", 1, 1, 1) + local state = "OFF" + if TotemBarDB and TotemBarDB.autoRecall then + state = "ON" + end + GameTooltip:AddLine("Auto Recall Toggle (right-click): " .. state, 1, 1, 1) + local activeCost = TotemBar.sumActiveCost(TotemBar.activeTotems, TotemBar.TOTEM_ELEMENTS, GetTime(), TotemBar.getTotemManaCost, TotemBar.remaining) + local pct = (TotemBarDB and TotemBarDB.recallRefundPct) or 0.25 + local refund = TotemBar.refundAmount(pct, activeCost) + if refund > 0 then + GameTooltip:AddLine("Refund: ~" .. refund .. " mana", 0.6, 0.6, 1) + end + GameTooltip:Show() + end) + btn:SetScript("OnLeave", function() + GameTooltip:Hide() + end) + + recallButton = btn + RefreshRecallIndicator() + registerBindOverlay(btn, function() return "TOTEMBAR_RECALL" end) + + return btn +end + +-- The "drop set" button: one click casts all four chosen totems +-- (TotemBar.recallAndCastAll, with its 2s double-press guard). Placed after +-- the Recall button. +CreateDropSetButton = function(index) + local name = "TotemBarButtonDropSet" + local btn = CreateFrame("Button", name, TotemBarFrame) + btn:SetWidth(BUTTON_SIZE) + btn:SetHeight(BUTTON_SIZE) + btn:SetPoint("LEFT", TotemBarFrame, "LEFT", (index - 1) * (BUTTON_SIZE + BUTTON_GAP) + BUTTON_GAP, 0) + btn:SetBackdrop({ + bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + tile = true, tileSize = 16, edgeSize = 12, + insets = { left = 3, right = 3, top = 3, bottom = 3 }, + }) + + local icon = btn:CreateTexture(name .. "Icon", "ARTWORK") + icon:SetPoint("TOPLEFT", btn, "TOPLEFT", 3, -3) + icon:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -3, 3) + icon:SetTexture(ICONS_TEXTURE) + local dc = iconCoords[ICON_DROPSET_CELL] + icon:SetTexCoord(dc.l, dc.r, dc.t, dc.b) + btn.icon = icon + + -- Grounding shadow ellipse (Rev 4 UI chrome). No glow on this button, + -- so creation order relative to other regions doesn't matter here. + local shadow = btn:CreateTexture(name .. "Shadow", "BACKGROUND") + shadow:SetTexture(UI_TEXTURE) + local sc = uiCoords[UI_SHADOW_CELL] + shadow:SetTexCoord(sc.l, sc.r, sc.t, sc.b) + shadow:SetPoint("CENTER", btn, "CENTER", 0, -15) + shadow:SetWidth(58) + shadow:SetHeight(20) + -- Softened after preview review: full-strength plate read boxy on light + -- ground; flatter + more transparent grounds without drawing attention. + shadow:SetAlpha(0.7) + btn.shadow = shadow + + -- Same floating round frame as the element buttons (permanent + -- decorative band, no fill/tint - no ringFill/pulseWave on this + -- button, guarded inside ApplyRoundFrame). + local ringTrack = btn:CreateTexture(name .. "RingTrack", "OVERLAY") + ringTrack:SetPoint("CENTER", btn, "CENTER", 0, 0) + ringTrack:SetWidth(RING_TEX_SIZE) + ringTrack:SetHeight(RING_TEX_SIZE) + btn.ringTrack = ringTrack + ApplyRoundFrame(btn) + + -- Round hover/pushed FX states (Rev 3), same sheet as the other + -- bar buttons. + ApplyRoundClickStates(btn, RING_TEX_SIZE) + btn:RegisterForClicks("LeftButtonUp") + btn:SetScript("OnClick", function() + TotemBar.recallAndCastAll() + end) + btn:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_TOP") + GameTooltip:SetText("Drop all totems") + GameTooltip:AddLine("Left-click: cast your whole set", 1, 1, 1) + local cost = TotemBar.sumChosenCost(TotemBarDB.chosen, TotemBar.TOTEM_ELEMENTS, TotemBar.getTotemManaCost) + if cost and cost > 0 then + GameTooltip:AddLine("Mana: " .. cost, 0.6, 0.6, 1) + end + GameTooltip:Show() + end) + btn:SetScript("OnLeave", function() GameTooltip:Hide() end) + + registerBindOverlay(btn, function() return "TOTEMBAR_DROPSET" end) + dropSetButton = btn + return btn +end + +-- Refreshes every element button's remaining-duration timer text. +-- HYBRID source: prefers pfUI libtotem's GetTotemInfo(slot) when +-- present and reporting the slot active (also catches totems cast +-- outside TotemBar); otherwise falls back to TotemBar's own +-- cast-tracking (TotemBar.activeTotems). Called at most ~5x/sec by +-- OnBarUpdate below, never per-frame. +UpdateTimerDisplays = function() + local now = GetTime() + local hasGTI = (type(GetTotemInfo) == "function") + local elements = TotemBar.TOTEM_ELEMENTS + local activeTotems = TotemBar.activeTotems + local outOfRangeFound = false -- OR-accumulator across this pass; written to anyOutOfRange at the end + + for i = 1, table.getn(elements) do + local element = elements[i] + local btn = elementButtons[element] + if btn then + -- Own-tracking: compute remaining, evicting the record the + -- moment it expires (per-element table, no growth). + local ownRecord = activeTotems[element] + local ownRemaining = nil + if ownRecord then + ownRemaining = TotemBar.remaining(ownRecord.start, ownRecord.duration, now) + if not ownRemaining or ownRemaining <= 0 then + activeTotems[element] = nil + ownRemaining = nil + ownRecord = nil -- expired: not "active" for the range-tint check below either + end + end + + -- Spawn ripple: a NEW own cast (start changed) fires one ripple. + -- The freshness check keeps restored/old records from replaying. + -- Gated on showPulseWaves (the ripple's own toggle), not + -- showPulseBars. + if TotemBarDB.showPulseWaves then + local startNow = ownRecord and ownRecord.start + if startNow and startNow ~= btn.lastStartSeen then + btn.lastStartSeen = startNow + if now - startNow < 1.5 then + btn.spawnAt = startNow + end + elseif not ownRecord then + btn.lastStartSeen = nil + end + end + + -- pfUI libtotem, when present: Fire=1, Earth=2, Water=3, + -- Air=4 - i.e. exactly TotemBar.TOTEM_ELEMENTS' own order. + local gtiActive, gtiRemaining, gtiName, gtiStart, gtiDuration + if hasGTI then + local active, tname, start, duration = GetTotemInfo(i) + gtiActive = active + gtiName = tname + gtiStart = start + gtiDuration = duration + if start and duration then + gtiRemaining = TotemBar.remaining(start, duration, now) + end + end + + local remainingVal = TotemBar.resolveRemaining(gtiActive, gtiRemaining, ownRemaining) + + if remainingVal and TotemBarDB.showTimerText then + if not btn.timerVisible then + btn.timerText:Show() + btn.timerVisible = true + end + local text = TotemBar.formatRemaining(remainingVal) + if text ~= btn.timerLastText then + btn.timerText:SetText(text) + btn.timerLastText = text + end + local isLow = remainingVal <= 5 + if isLow ~= btn.timerLastLow then + if isLow then + btn.timerText:SetTextColor(1, 0.15, 0.15, 1) + else + btn.timerText:SetTextColor(1, 1, 1, 1) + end + btn.timerLastLow = isLow + end + elseif btn.timerVisible then + btn.timerText:Hide() + btn.timerVisible = false + btn.timerLastText = nil + btn.timerLastLow = nil + end + + -- Duration ring: flipbook frame from remaining/total, tinted as + -- a time-remaining traffic light (green->yellow->orange->red - + -- user feedback: element color didn't convey urgency). Gated on + -- showDurationRing for the ARC only (ringFill) - ringTrack (the + -- permanent decorative frame band) is never hidden here. + if TotemBarDB.showDurationRing and remainingVal then + local totalDur = TotemBar.resolveDuration(gtiActive, gtiRemaining, gtiDuration, + ownRemaining, ownRecord and ownRecord.duration) + local idx = TotemBar.ringFrameIndex(remainingVal, totalDur, RING_FRAMES) + if idx ~= btn.ringLastIdx then + local rc = allRingCoords[idx + 1] + btn.ringFill:SetTexCoord(rc.l, rc.r, rc.t, rc.b) + btn.ringLastIdx = idx + end + local tr, tg, tb + if totalDur and totalDur > 0 then + tr, tg, tb = TotemBar.timeColor(remainingVal / totalDur) + else + tr, tg, tb = 1, 1, 1 + end + btn.ringFill:SetVertexColor(tr, tg, tb) + if not btn.ringVisible then + btn.ringFill:Show() + btn.ringVisible = true + end + elseif btn.ringVisible then + btn.ringFill:Hide() + btn.ringVisible = false + btn.ringLastIdx = nil + end + + -- Ripple wave + pulse arc inputs: only for totems with + -- PULSE_DATA. Phase origin: own record start (anchored by real + -- gain events where available) or libtotem's start for casts + -- made outside TotemBar. Both the wave's animation AND the + -- pulse arc's flipbook frame are driven every FRAME in + -- UpdateWaves below (moved off this 0.1s tick - a 10Hz flipbook + -- update read as choppy, see rev7 report) - this block just + -- refreshes what they should animate toward. showPulseWaves and + -- showPulseBars are independent display toggles, but the arc + -- reads the SAME waveInterval/waveOrigin/waveOneshotAt/ + -- waveOneshotDelay fields as the wave, so they're populated + -- whenever EITHER toggle is on. + local waveSet = false + if remainingVal then + local totemName = (ownRecord and ownRecord.totemName) or gtiName + local pd = totemName and TotemBar.PULSE_DATA[totemName] + if pd then + local startAt = (ownRecord and ownRecord.start) or gtiStart + if startAt then + if TotemBarDB.showPulseWaves or TotemBarDB.showPulseBars then + if pd.ptype == "oneshot" then + btn.waveInterval = nil + btn.waveOneshotAt = startAt + pd.delay + btn.waveOrigin = nil + btn.waveOneshotDelay = pd.delay + else + btn.waveInterval = pd.interval + btn.waveOrigin = (ownRecord and ownRecord.pulseAnchor) or startAt + btn.waveOneshotAt = nil + btn.waveOneshotDelay = nil + end + waveSet = true + end + end + end + end + if not waveSet then + btn.waveInterval = nil + btn.waveOneshotAt = nil + btn.waveOrigin = nil + btn.waveOneshotDelay = nil + if btn.waveVisible then + btn.pulseWave:Hide() + btn.waveVisible = false + end + end + -- Pulse arc hide-on-expiry: UpdateWaves (every frame) recomputes + -- arcRatio from the fields above and will already hide it within + -- the next frame once waveInterval/waveOneshotAt go nil, but that + -- can be up to one frame stale right after this tick - not worth + -- a duplicate hide-path here (see rev7 report). + + -- Out-of-range tint: buff-presence based. A totem's party + -- buff uses the SAME icon texture as the totem spell itself + -- (verified in-game), so "am I in range?" == "do I have a + -- buff whose texture matches this totem's icon?". + -- + -- ACTIVE requires an own-tracking record (ownRecord, already + -- nil'd above once its stored duration expires) AND, when + -- pfUI's libtotem is present, GetTotemInfo(i) agreeing the + -- slot is still active. That second check is what keeps a + -- totem someone/something DESTROYED (burned, killed) before + -- its timer ran out from flashing red - once GTI says the + -- slot is gone, we just go back to normal, not red. + local rangeActive = (ownRecord ~= nil) + if rangeActive and hasGTI and not gtiActive then + rangeActive = false + end + + if not rangeActive then + if btn.tintRed then + btn.icon:SetVertexColor(1, 1, 1) + btn.tintRed = false + end + else + local hasBuff = TotemBar.hasBuffWithIcon(ownRecord.icon) + if hasBuff then + -- In range: remember it (self-learning - marks this + -- as a buff totem so a later drop-off can be told + -- apart from a totem that simply never grants one). + ownRecord.everHadBuff = true + if btn.tintRed then + btn.icon:SetVertexColor(1, 1, 1) + btn.tintRed = false + end + elseif ownRecord.everHadBuff then + -- Had the buff earlier from this cast, don't have it + -- now: wandered out of the totem's range. + if not btn.tintRed then + btn.icon:SetVertexColor(1, 0.35, 0.35) + btn.tintRed = true + end + else + -- Either a non-buff totem (Searing/Magma/Grounding/ + -- etc. never grant a matching buff, so everHadBuff + -- stays false forever -> never red) or we simply + -- haven't been in range yet since this cast. + if btn.tintRed then + btn.icon:SetVertexColor(1, 1, 1) + btn.tintRed = false + end + end + end + + if btn.tintRed then + outOfRangeFound = true + end + end + end + + anyOutOfRange = outOfRangeFound +end + +-- Re-applies every Pulse-UI setting to the live buttons (options panel +-- setters call this; cheap, not on any hot path). +function TotemBar.RefreshPulseUI() + local elements = TotemBar.TOTEM_ELEMENTS + for i = 1, table.getn(elements) do + local btn = elementButtons[elements[i]] + if btn then + ApplyRoundFrame(btn) + ApplyTimerAnchor(btn) + if not TotemBarDB.showDurationRing and btn.ringVisible then + btn.ringFill:Hide() + btn.ringVisible = false + btn.ringLastIdx = nil + end + if not TotemBarDB.showPulseWaves then + btn.waveInterval = nil + btn.waveOneshotAt = nil + btn.waveOrigin = nil + btn.waveOneshotDelay = nil + btn.spawnAt = nil + if btn.waveVisible then + btn.pulseWave:Hide() + btn.waveVisible = false + end + if btn.glow then + btn.glow:Hide() + btn.glowVisible = false + end + end + if not TotemBarDB.showPulseBars and btn.pulseVisible then + btn.pulseArc:Hide() + btn.pulseVisible = false + btn.pulseArcLastIdx = nil + end + if not TotemBarDB.showTimerText and btn.timerVisible then + btn.timerText:Hide() + btn.timerVisible = false + btn.timerLastText = nil + btn.timerLastLow = nil + end + end + end + + -- Recall/DropSet share ApplyRoundFrame with the element buttons + -- (harmless re-apply of their static ring-frame texture; kept for + -- symmetry with the element-button refresh above). Flyout icons don't + -- go through ApplyRoundFrame (see EnsureFlyoutFrame) but their ring + -- frame texture is likewise static now, set once at creation - no + -- per-refresh work needed for them. + if recallButton then + ApplyRoundFrame(recallButton) + end + if dropSetButton then + ApplyRoundFrame(dropSetButton) + end +end + +-- Dev aid, one line of live WoW-object state for a single ring texture +-- (ringFill, ringTrack, the icon, or timerText), called by +-- TotemBar.DumpRingRenderState below. Every getter is pcall-wrapped +-- individually ("?" on failure/absence) - this must never itself error, +-- not even on a stale/nil texture object. +-- GetTexCoord's return arity is UNCONFIRMED on this 1.12 client (4-value +-- left/right/top/bottom vs the 8-value corner form retail uses) - its +-- result is captured via a table constructor (`{ pcall(...) }`) instead +-- of assuming a fixed number of return values, so the dump itself is the +-- in-game measurement that settles the question. +-- rect=[...]/layer= added alongside (2026-07-16, occlusion follow-up): the +-- screen-space GetLeft/Right/Top/Bottom quad and GetDrawLayer, so a stale +-- anchor (ringFill not actually over the button) or a same-layer sibling +-- collision (1.12 discards SetDrawLayer sublevels - same main layer means +-- undefined draw order between siblings) show up directly in the dump +-- instead of needing to be inferred from shown/alpha/texCoord alone. +local function DumpRingTextureLine(element, label, tex) + if not tex then + return " " .. element .. " " .. label .. ": (nil texture object)\n" + end + local shownOk, shown = pcall(tex.IsShown, tex) + local texOk, texPath = pcall(tex.GetTexture, tex) + local alphaOk, alpha = pcall(tex.GetAlpha, tex) + local wOk, w = pcall(tex.GetWidth, tex) + local hOk, h = pcall(tex.GetHeight, tex) + local leftOk, left = pcall(tex.GetLeft, tex) + local rightOk, right = pcall(tex.GetRight, tex) + local topOk, top = pcall(tex.GetTop, tex) + local bottomOk, bottom = pcall(tex.GetBottom, tex) + local layerOk, layer = pcall(tex.GetDrawLayer, tex) + + local parentShown, parentAlpha = "?", "?" + local parentOk, parent = pcall(tex.GetParent, tex) + if parentOk and parent then + local pShownOk, pShown = pcall(parent.IsShown, parent) + local pAlphaOk, pAlpha = pcall(parent.GetAlpha, parent) + if pShownOk then parentShown = tostring(pShown) end + if pAlphaOk then parentAlpha = tostring(pAlpha) end + end + + local coordParts = { pcall(tex.GetTexCoord, tex) } + local coordStr = "?" + if coordParts[1] then + local n = table.getn(coordParts) + local s = "" + for ci = 2, n do + if s ~= "" then s = s .. "," end + s = s .. tostring(coordParts[ci]) + end + coordStr = "[" .. s .. "]" + end + + return " " .. element .. " " .. label .. ": shown=" .. (shownOk and tostring(shown) or "?") + .. " texture='" .. (texOk and tostring(texPath) or "?") .. "'" + .. " alpha=" .. (alphaOk and tostring(alpha) or "?") + .. " texCoord=" .. coordStr + .. " w=" .. (wOk and tostring(w) or "?") + .. " h=" .. (hOk and tostring(h) or "?") + .. " parentShown=" .. parentShown + .. " parentAlpha=" .. parentAlpha + .. " rect=[" .. (leftOk and tostring(left) or "?") .. "," .. (topOk and tostring(top) or "?") + .. "," .. (rightOk and tostring(right) or "?") .. "," .. (bottomOk and tostring(bottom) or "?") .. "]" + .. " layer=" .. (layerOk and tostring(layer) or "?") .. "\n" +end + +-- Dev aid, one line per region returned by a button's own :GetRegions() +-- (2026-07-16, occlusion follow-up). GetRegions() is Blizzard's own +-- z-order-relevant enumeration of the regions directly parented to a +-- frame - within a shared main draw layer (see the sublevel-discard note +-- above) it's the best available proxy for actual sibling paint order, so +-- walking it WITH the index is how we tell "ringTrack drawn 2nd, after +-- ringFill" apart from "ringTrack drawn 1st, before ringFill" from outside +-- the client. Deliberately only ever called on a single small element +-- button (~8 regions), never on a big container frame - a wide frame can +-- carry hundreds of regions and walking all of them was the cause of an +-- earlier crash, hence the hard cap below regardless of how many the +-- client reports. +local REGION_WALK_CAP = 24 +local function DumpRegionLine(idx, region) + if not region then + return " [" .. idx .. "] (nil region)\n" + end + local typeOk, objType = pcall(region.GetObjectType, region) + local nameOk, name = pcall(region.GetName, region) + local layerOk, layer = pcall(region.GetDrawLayer, region) + local shownOk, shown = pcall(region.IsShown, region) + local leftOk, left = pcall(region.GetLeft, region) + local topOk, top = pcall(region.GetTop, region) + + -- Texture and FontString expose different "what is this" getters + -- (GetTexture vs GetText); try both, pcall-guarded, and use whichever + -- succeeds - a region of the other kind simply fails the wrong one. + local content = "?" + local texOk, texPath = pcall(region.GetTexture, region) + if texOk and texPath ~= nil then + content = "texture='" .. tostring(texPath) .. "'" + else + local textOk, text = pcall(region.GetText, region) + if textOk and text ~= nil then + content = "text='" .. tostring(text) .. "'" + end + end + + return " [" .. idx .. "] name=" .. (nameOk and tostring(name) or "?") + .. " type=" .. (typeOk and tostring(objType) or "?") + .. " layer=" .. (layerOk and tostring(layer) or "?") + .. " shown=" .. (shownOk and tostring(shown) or "?") + .. " " .. content + .. " left=" .. (leftOk and tostring(left) or "?") + .. " top=" .. (topOk and tostring(top) or "?") .. "\n" +end + +-- Dev aid, called from TotemBar.DumpTimerState (core/cast.lua)'s /tb tdump +-- so the export stays a single file/single command. Purpose: the pure +-- ring math (resolveRemaining/resolveDuration/ringFrameIndex) was already +-- proven correct offline for the Searing-ring report (text shows, no +-- ring), so this section captures what DumpTimerState's own-tracking/GTI +-- dump can't: the RENDER side. Per element button: (a) which button +-- instance is bound to the element, (b) the cached ring state this same +-- render path reads/writes (btn.ringVisible/btn.ringLastIdx - the exact +-- fields UpdateTimerDisplays above uses, not a guess), (c) a same-tick +-- recompute of resolveDuration/ringFrameIndex/timeColor with the SAME +-- inputs the render path would use right now, (d) the countdown +-- FontString's own live state as a control group (it's the one the player +-- actually sees), and (e) the LIVE WoW state of both ring textures (ringFill, the +-- gated arc in question, and ringTrack, the always-on decorative band - +-- if ringTrack is ALSO dark then the whole assembly, not just the fill, +-- is hidden/mispositioned upstream of this addon's own Show/Hide calls). +function TotemBar.DumpRingRenderState() + local now = GetTime() + local hasGTI = (type(GetTotemInfo) == "function") + local elements = TotemBar.TOTEM_ELEMENTS + local activeTotems = TotemBar.activeTotems + local out = "\n-- render state (per-element ring + text, live WoW objects) --\n" + + for i = 1, table.getn(elements) do + local element = elements[i] + local btn = elementButtons[element] + if not btn then + out = out .. element .. ": (no button)\n" + else + local btnName = "?" + local okName, nameVal = pcall(btn.GetName, btn) + if okName and nameVal then + btnName = nameVal + end + + -- Same recompute inputs/precedence as UpdateTimerDisplays + -- above, same tick (i.e. what the render path would compute + -- if it ran again right now). + local ownRecord = activeTotems[element] + local ownRemaining = nil + if ownRecord then + ownRemaining = TotemBar.remaining(ownRecord.start, ownRecord.duration, now) + end + local gtiActive, gtiRemaining, gtiDuration + if hasGTI then + local ok, active, tname, start, duration = pcall(GetTotemInfo, i) + if ok then + gtiActive, gtiDuration = active, duration + if start and duration then + gtiRemaining = TotemBar.remaining(start, duration, now) + end + end + end + local remainingVal = TotemBar.resolveRemaining(gtiActive, gtiRemaining, ownRemaining) + local totalDur = TotemBar.resolveDuration(gtiActive, gtiRemaining, gtiDuration, + ownRemaining, ownRecord and ownRecord.duration) + local idxStr = "n/a" + if remainingVal then + idxStr = tostring(TotemBar.ringFrameIndex(remainingVal, totalDur, RING_FRAMES)) + end + local colorStr = "n/a" + if totalDur and totalDur > 0 and remainingVal then + local okC, cr, cg, cb = pcall(TotemBar.timeColor, remainingVal / totalDur) + if okC then + colorStr = "(" .. tostring(cr) .. "," .. tostring(cg) .. "," .. tostring(cb) .. ")" + end + end + + out = out .. element .. ": btn=" .. btnName + .. " ringVisible=" .. tostring(btn.ringVisible) + .. " ringLastIdx=" .. tostring(btn.ringLastIdx) + .. " | recompute: remainingVal=" .. tostring(remainingVal) + .. " totalDur=" .. tostring(totalDur) + .. " ringIdx=" .. idxStr + .. " timeColor=" .. colorStr .. "\n" + + -- Control group: the countdown text the player actually sees, so a + -- mismatch (text shown, ring both cached+live hidden) pins the + -- bug to the ring specifically, not a shared render gate. + -- Guarded on btn.timerText itself (not just pcall on its + -- methods): indexing .IsShown/.GetText on a nil timerText + -- would error while pcall's OWN arguments are being evaluated, + -- i.e. outside pcall's protection - shouldn't happen (created + -- unconditionally in CreateElementButton) but this dump must + -- never be the thing that errors. + local tShown, tText = "?", "?" + local tLeft, tTop, tRight, tBottom, tLayer = "?", "?", "?", "?", "?" + if btn.timerText then + local tShownOk, tShownVal = pcall(btn.timerText.IsShown, btn.timerText) + local tTextOk, tTextVal = pcall(btn.timerText.GetText, btn.timerText) + local tLeftOk, tLeftVal = pcall(btn.timerText.GetLeft, btn.timerText) + local tRightOk, tRightVal = pcall(btn.timerText.GetRight, btn.timerText) + local tTopOk, tTopVal = pcall(btn.timerText.GetTop, btn.timerText) + local tBottomOk, tBottomVal = pcall(btn.timerText.GetBottom, btn.timerText) + local tLayerOk, tLayerVal = pcall(btn.timerText.GetDrawLayer, btn.timerText) + if tShownOk then tShown = tostring(tShownVal) end + if tTextOk then tText = tostring(tTextVal) end + if tLeftOk then tLeft = tostring(tLeftVal) end + if tRightOk then tRight = tostring(tRightVal) end + if tTopOk then tTop = tostring(tTopVal) end + if tBottomOk then tBottom = tostring(tBottomVal) end + if tLayerOk then tLayer = tostring(tLayerVal) end + end + out = out .. " " .. element .. " timerText: shown=" .. tShown + .. " text='" .. tText .. "'" + .. " rect=[" .. tLeft .. "," .. tTop .. "," .. tRight .. "," .. tBottom .. "]" + .. " layer=" .. tLayer .. "\n" + + out = out .. DumpRingTextureLine(element, "ringFill", btn.ringFill) + out = out .. DumpRingTextureLine(element, "ringTrack", btn.ringTrack) + out = out .. DumpRingTextureLine(element, "icon", btn.icon) + + -- Region walk (2026-07-16, occlusion follow-up): btn is a + -- small element button (icon/shadow/ringTrack/glow/timerText, + -- ~5 regions now that ringFill/pulseWave/pulseArc moved off + -- btn - see below) - safe to enumerate in full, unlike a big + -- container frame. Index order is GetRegions()'s own return + -- order, our best proxy for actual same-layer sibling paint + -- order (see DumpRegionLine above). + local regionParts = { pcall(btn.GetRegions, btn) } + if regionParts[1] then + local total = table.getn(regionParts) - 1 + local capped = total + if capped > REGION_WALK_CAP then capped = REGION_WALK_CAP end + out = out .. " " .. element .. " regions (count=" .. total .. ", showing " .. capped .. "):\n" + for ri = 1, capped do + out = out .. DumpRegionLine(ri, regionParts[ri + 1]) + end + else + out = out .. " " .. element .. " regions: (GetRegions failed)\n" + end + + -- Second region walk (2026-07-16, ring-overlay fix): ringFill/ + -- pulseWave/pulseArc now live on btn.ringOverlay, a separate + -- higher-FrameLevel child frame (see CreateRingOverlay) - they + -- no longer show up in btn:GetRegions() above at all, so the + -- dump would silently go blind on exactly the textures this + -- tool exists to inspect without this second walk. Same small + -- frame (~3 regions), same cap. + if btn.ringOverlay then + local overlayParts = { pcall(btn.ringOverlay.GetRegions, btn.ringOverlay) } + if overlayParts[1] then + local oTotal = table.getn(overlayParts) - 1 + local oCapped = oTotal + if oCapped > REGION_WALK_CAP then oCapped = REGION_WALK_CAP end + out = out .. " " .. element .. " ringOverlay regions (count=" .. oTotal .. ", showing " .. oCapped .. "):\n" + for ri = 1, oCapped do + out = out .. DumpRegionLine(ri, overlayParts[ri + 1]) + end + else + out = out .. " " .. element .. " ringOverlay regions: (GetRegions failed)\n" + end + else + out = out .. " " .. element .. " ringOverlay: (nil)\n" + end + end + end + + return out +end + +-- Ripple animation + pulse-arc flipbook: expanding/fading ring in the +-- element color (one wave per pulse) and the circular countdown arc, both +-- driven every FRAME (pure float math + Set calls, no allocation, same +-- class as OnRecallUpdate - the arc joined the wave here in rev7, was a +-- 0.1s-tick flipbook update before and read as choppy). Inputs are +-- refreshed by the 0.1s tick in UpdateTimerDisplays above. Wave/glow are +-- gated on showPulseWaves, the arc on showPulseBars - independent toggles +-- that happen to share the same waveInterval/waveOrigin/waveOneshotAt/ +-- waveOneshotDelay fields (see the tick's comment). +UpdateWaves = function() + local now = GetTime() + local elements = TotemBar.TOTEM_ELEMENTS + for i = 1, table.getn(elements) do + local btn = elementButtons[elements[i]] + if btn then + if TotemBarDB.showPulseWaves then + local frac = nil + if btn.spawnAt then + frac = TotemBar.oneshotWaveFrac(btn.spawnAt, 0, now, SPAWN_WAVE_DURATION) + if not frac then + btn.spawnAt = nil + end + end + if not frac then + if btn.waveInterval then + local ratio = TotemBar.pulseRatio(btn.waveOrigin, nil, btn.waveInterval, now) + frac = TotemBar.waveFrac(ratio, btn.waveInterval, WAVE_DURATION) + elseif btn.waveOneshotAt then + frac = TotemBar.oneshotWaveFrac(btn.waveOneshotAt, 0, now, WAVE_DURATION) + end + end + if frac then + local size = RING_TEX_SIZE * (1 + (WAVE_MAX_SCALE - 1) * frac) + btn.pulseWave:SetWidth(size) + btn.pulseWave:SetHeight(size) + btn.pulseWave:SetAlpha(WAVE_BASE_ALPHA * (1 - frac)) + if not btn.waveVisible then + btn.pulseWave:Show() + btn.waveVisible = true + end + elseif btn.waveVisible then + btn.pulseWave:Hide() + btn.waveVisible = false + end + + -- Anticipation glow: ramps up as the next pulse (or the + -- oneshot detonation) approaches; hidden otherwise. Tied to + -- the wave's own toggle (showPulseWaves), unchanged. + local ga = nil + if btn.waveInterval then + local gratio = TotemBar.pulseRatio(btn.waveOrigin, nil, btn.waveInterval, now) + ga = TotemBar.glowRamp(gratio, GLOW_RAMP_START) + elseif btn.waveOneshotAt and btn.waveOneshotDelay and now < btn.waveOneshotAt then + ga = TotemBar.glowRamp(1 - ((btn.waveOneshotAt - now) / btn.waveOneshotDelay), GLOW_RAMP_START) + end + if ga then + btn.glow:SetAlpha(GLOW_MAX_ALPHA * ga) + if not btn.glowVisible then + btn.glow:Show() + btn.glowVisible = true + end + elseif btn.glowVisible then + btn.glow:Hide() + btn.glowVisible = false + end + elseif btn.waveVisible or btn.glowVisible then + -- Toggled off (RefreshPulseUI normally already clears these + -- immediately; this is the per-frame safety net). + if btn.waveVisible then + btn.pulseWave:Hide() + btn.waveVisible = false + end + if btn.glowVisible then + btn.glow:Hide() + btn.glowVisible = false + end + end + + -- Pulse arc: continuous countdown readout toward the next + -- pulse/detonation (own toggle: showPulseBars, independent of + -- the wave/glow above). A duplicate pulseRatio()/oneshotRatio() + -- call vs. the wave block above is cheap and keeps this block + -- self-contained (see rev7 report). + local arcRatio = nil + if TotemBarDB.showPulseBars then + if btn.waveInterval then + arcRatio = TotemBar.pulseRatio(btn.waveOrigin, nil, btn.waveInterval, now) + elseif btn.waveOneshotAt and btn.waveOneshotDelay then + arcRatio = TotemBar.oneshotRatio(btn.waveOneshotAt - btn.waveOneshotDelay, btn.waveOneshotDelay, now) + if arcRatio and arcRatio >= 1 then arcRatio = nil end + end + end + if arcRatio then + local idx = math.floor(arcRatio * (RING_FRAMES - 1) + 0.5) + if idx ~= btn.pulseArcLastIdx then + local rc = allRingCoords[idx + 1] + btn.pulseArc:SetTexCoord(rc.l, rc.r, rc.t, rc.b) + btn.pulseArcLastIdx = idx + end + if not btn.pulseVisible then + btn.pulseArc:Show() + btn.pulseVisible = true + end + elseif btn.pulseVisible then + btn.pulseArc:Hide() + btn.pulseVisible = false + btn.pulseArcLastIdx = nil + end + end + end +end + +OnBarUpdate = function() + UpdateWaves() + timerElapsed = timerElapsed + arg1 + if timerElapsed < TIMER_UPDATE_INTERVAL then + return + end + timerElapsed = 0 + UpdateTimerDisplays() +end + +OnDragStart = function() + if not TotemBarDB.locked then + this:StartMoving() + end +end + +OnDragStop = function() + this:StopMovingOrSizing() + local point, _, relPoint, x, y = this:GetPoint() + TotemBarDB.point = point + TotemBarDB.relPoint = relPoint + TotemBarDB.x = x + TotemBarDB.y = y +end + +-- (Re)applies the current BUTTON_GAP to the pending-assignment panel: frame +-- width (4 icons + gaps + the fixed accept-button margin) and each icon's +-- horizontal anchor - both baked in once when the panel is first built, so +-- (like layoutFlyoutIcons above) this must be called again from +-- TotemBar.SetButtonGap when the spacing slider changes live, not just at +-- build time. `frame` lets the build-time call pass the not-yet-assigned +-- local `f` directly; later calls omit it and fall back to the assignFrame +-- upvalue (guarded: a no-op if the panel has never been built yet). +local function layoutAssignIcons(frame) + local f = frame or assignFrame + if not f then + return + end + f:SetWidth(4 * (BUTTON_SIZE + BUTTON_GAP) + BUTTON_GAP + 40) + for i = 1, 4 do + local ico = f.icons and f.icons[i] + if ico then + ico:ClearAllPoints() + ico:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", + BUTTON_GAP + (i - 1) * (BUTTON_SIZE + BUTTON_GAP), 6) + end + end +end + +-- Lazily builds the pending-suggestion panel: a heading label, a row of up +-- to 4 element-ordered totem icons, an Accept button, and a close "X". +-- Built once, reused; event-driven show/hide (no OnUpdate, no per-frame +-- allocation). Anchored above the bar. +EnsureAssignFrame = function() + if assignFrame then + return assignFrame + end + + -- Parent to the bar so the pending panel inherits the bar's scale + -- (UI-size slider), like the flyout. Still DIALOG strata below. Width + -- is set below by layoutAssignIcons (once f.icons exists), not here. + local f = CreateFrame("Frame", "TotemBarAssignFrame", TotemBarFrame) + f:SetFrameStrata("DIALOG") + f:SetHeight(BUTTON_SIZE + 34) + -- Rev 4 UI chrome: shared panel skin (bespoke border/bg) instead of the + -- generic tooltip textures. The bg texture carries the color now, so + -- the tint just needs to be near-opaque white. + f:SetBackdrop(PANEL_BACKDROP) + f:SetBackdropColor(1, 1, 1, 0.97) + f:SetClampedToScreen(true) + f:ClearAllPoints() + f:SetPoint("BOTTOM", TotemBarFrame, "TOP", 0, 6) + + local heading = f:CreateFontString("TotemBarAssignHeading", "OVERLAY") + heading:SetFont("Fonts\\FRIZQT__.TTF", 11, "OUTLINE") + heading:SetPoint("TOP", f, "TOP", 0, -6) + heading:SetText("Assigned set") + f.heading = heading + + f.icons = {} + for i = 1, 4 do + local ico = f:CreateTexture("TotemBarAssignIcon" .. i, "ARTWORK") + ico:SetWidth(BUTTON_SIZE) + ico:SetHeight(BUTTON_SIZE) + -- Anchor applied below via layoutAssignIcons (once the whole row + -- exists), not here - the same helper re-applies it live when + -- BUTTON_GAP changes. + ico:SetTexCoord(0.08, 0.92, 0.08, 0.92) + f.icons[i] = ico + end + + -- Sets the frame width and every icon's anchor per BUTTON_GAP now that + -- the whole row exists (assignFrame isn't assigned yet at this point in + -- the function, hence passing `f` explicitly - see layoutAssignIcons's + -- own comment). + layoutAssignIcons(f) + + local accept = CreateFrame("Button", "TotemBarAssignAccept", f, "UIPanelButtonTemplate") + accept:SetWidth(28) + accept:SetHeight(BUTTON_SIZE) + accept:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -6, 6) + accept:SetText("OK") + accept:SetScript("OnClick", function() + TotemBar.ApplyPending() + end) + accept:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:SetText("Apply assigned set") + GameTooltip:AddLine("Sets these as your chosen totems (no cast).", 1, 1, 1) + GameTooltip:Show() + end) + accept:SetScript("OnLeave", function() GameTooltip:Hide() end) + f.accept = accept + + local close = CreateFrame("Button", "TotemBarAssignClose", f, "UIPanelCloseButton") + close:SetWidth(22) + close:SetHeight(22) + close:SetPoint("TOPRIGHT", f, "TOPRIGHT", -2, -2) + close:SetScript("OnClick", function() + TotemBar.ClearAssignment() + end) + f.close = close + + f:Hide() + assignFrame = f + return f +end + +-- Populates and shows the panel from TotemBar.pending. Unknown totems are +-- greyed (desaturated + dimmed). Called by ReceiveAssignment via the hook. +TotemBar.ShowAssignPanel = function() + local p = TotemBar.pending + if not p then + return + end + local f = EnsureAssignFrame() + f.heading:SetText(p.label or "Assigned set") + + local elements = TotemBar.TOTEM_ELEMENTS + for i = 1, 4 do + local element = elements[i] + local name = element and p.set[element] + local ico = f.icons[i] + if name then + local tex, known = ResolveTotemIcon(name) + ico:SetTexture(tex) + if known then + ico:SetVertexColor(1, 1, 1) + ico:SetAlpha(1) + else + ico:SetVertexColor(0.5, 0.5, 0.5) + ico:SetAlpha(0.5) + end + ico:Show() + else + ico:Hide() + end + end + f:Show() +end + +-- Hides the panel (called by ClearAssignment / ApplyPending via the hook). +TotemBar.HideAssignPanel = function() + if assignFrame then + assignFrame:Hide() + end +end + +-- Repositions the bar buttons (up to six) into the grid configured by +-- TotemBarDB.barLayout and resizes the bar frame to match. Buttons are +-- ordered: the four element buttons (TOTEM_ELEMENTS order), then Recall, +-- then DropSet. The DropSet button is optional (TotemBarDB.showDropSetButton, +-- options panel "Show drop-all button"): when off it is left out of the grid +-- AND hidden, which drops the count to 5 - it is always the LAST slot in +-- every layout table, so slots 1..5 stay exactly where they were and the +-- 2x2 element block never breaks. Called once at the end of BuildUI (after +-- every button exists), from options.lua's layout cycle button, and from +-- that checkbox's setter. No-op before BuildUI has run (TotemBarFrame +-- doesn't exist yet). +TotemBar.ApplyBarLayout = function() + if not TotemBarFrame then + return + end + + -- nil-safe read: an old SavedVariables file (or a call before + -- ensureDefaults) means "show", matching the default. + local showDrop = TotemBarDB.showDropSetButton + if showDrop == nil then + showDrop = true + end + + local buttons = {} + local elements = TotemBar.TOTEM_ELEMENTS + for i = 1, table.getn(elements) do + tinsert(buttons, elementButtons[elements[i]]) + end + tinsert(buttons, recallButton) + if showDrop then + tinsert(buttons, dropSetButton) + end + + -- The button frame itself always exists (BuildUI creates it + -- unconditionally, so the toggle needs no UI rebuild and the bind + -- overlay stays registered) - only its visibility follows the option. + -- The TOTEMBAR_DROPSET keybinding keeps working either way: it is bound + -- to the cast function, not to this frame. + if dropSetButton then + if showDrop then + dropSetButton:Show() + else + dropSetButton:Hide() + end + end + + local count = table.getn(buttons) + local layout = TotemBarDB.barLayout + -- Explicit per-button {col, row} slots (core/optionslogic.lua): the 2x3 + -- layout keeps the four elements as a 2x2 block in the first two columns + -- with Recall/DropSet as the third column, which no row-major fill order + -- produces - hence a positions table per layout instead of index math. + local positions = TotemBar.BAR_LAYOUT_POSITIONS[layout] or TotemBar.BAR_LAYOUT_POSITIONS["1x6"] + local cols = BAR_LAYOUT_COLS[layout] or 6 + local extra = 0 + + for i = 1, count do + local btn = buttons[i] + local pos = positions[i] + if btn and pos then + btn:ClearAllPoints() + btn:SetPoint("TOPLEFT", TotemBarFrame, "TOPLEFT", + BUTTON_GAP + pos[1] * (BUTTON_SIZE + BUTTON_GAP), + -(BUTTON_GAP + pos[2] * (BUTTON_SIZE + BUTTON_GAP + extra))) + end + end + + local width, height = TotemBar.barDimensions(count, cols, BUTTON_SIZE, BUTTON_GAP, extra) + TotemBarFrame:SetWidth(width) + TotemBarFrame:SetHeight(height) +end + +function TotemBar.BuildUI() + if TotemBarFrame then + return + end + + local numElements = table.getn(TotemBar.TOTEM_ELEMENTS) + + -- Placeholder size: TotemBar.ApplyBarLayout() (called below, once every + -- button exists) positions each button per the configured grid and + -- resizes the frame for real - this initial size is never seen on screen. + local frame = CreateFrame("Frame", "TotemBarFrame", UIParent) + frame:SetWidth(BUTTON_SIZE + BUTTON_GAP * 2) + frame:SetHeight(BUTTON_SIZE + BUTTON_GAP * 2) + frame:SetBackdrop({ + bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + tile = true, tileSize = 16, edgeSize = 16, + insets = { left = 4, right = 4, top = 4, bottom = 4 }, + }) + -- Floating icons: the bar's own backdrop is fully transparent (an + -- invisible drag/hit-test area only - size/anchors unchanged). + frame:SetBackdropColor(0, 0, 0, 0) + frame:SetBackdropBorderColor(0, 0, 0, 0) + frame:SetScale(TotemBarDB.scale or 1.0) + + frame:SetMovable(true) + frame:EnableMouse(true) + frame:RegisterForDrag("LeftButton") + frame:SetScript("OnDragStart", OnDragStart) + frame:SetScript("OnDragStop", OnDragStop) + + frame:ClearAllPoints() + frame:SetPoint(TotemBarDB.point, UIParent, TotemBarDB.relPoint, TotemBarDB.x, TotemBarDB.y) + + for i = 1, numElements do + local element = TotemBar.TOTEM_ELEMENTS[i] + CreateElementButton(element, i) + RefreshCooldown(element) -- initial swipe state for whatever's already chosen + end + CreateRecallButton(numElements + 1) + CreateDropSetButton(numElements + 2) + + -- Positions every button per TotemBarDB.barLayout and gives the frame + -- its real size (see ApplyBarLayout above and the placeholder-size + -- note on the CreateFrame call). + TotemBar.ApplyBarLayout() + + frame:SetScript("OnUpdate", OnBarUpdate) + + frame:Show() + if TotemBarDB.hidden then + frame:Hide() + end + + -- Show any already-bound key labels right after login (overlays are + -- always visible now, not gated on bind mode - see refreshBindOverlays). + TotemBar.refreshBindOverlays() +end + +-- Scales the bar while keeping its TOP-LEFT corner visually fixed, so the +-- bar grows/shrinks toward the bottom-right instead of drifting away from +-- where the player put it. SetScale scales the anchor offset too, so a naive +-- SetScale moves the frame; here we capture the top-left's ABSOLUTE screen +-- position (local coord * effective scale = pixels), apply the new scale, +-- then re-anchor TOPLEFT so those pixels are preserved. Persists the new +-- scale + anchor so the next login reproduces it. +function TotemBar.SetBarScale(newScale) + local f = TotemBarFrame + if not f then + return + end + if not newScale or newScale <= 0 then + newScale = 1 + end + local before = f:GetEffectiveScale() + local left = f:GetLeft() + local top = f:GetTop() + f:SetScale(newScale) + if left and top and before then + local leftPx = left * before + local topPx = top * before + local after = f:GetEffectiveScale() + f:ClearAllPoints() + f:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", leftPx / after, topPx / after) + end + if TotemBarDB then + TotemBarDB.scale = newScale + local p, _, rp, x, y = f:GetPoint() + if p then + TotemBarDB.point = p + TotemBarDB.relPoint = rp + TotemBarDB.x = x + TotemBarDB.y = y + end + end +end + +-- Live-applies a new button gap (px, range 10-30): reassigns the shared +-- BUTTON_GAP upvalue (every closure that reads it - element/flyout/assign- +-- panel positioning, ApplyBarLayout - sees the new value on its next read, +-- no re-declaration needed) and persists it. Two icon pools bake their +-- BUTTON_GAP-dependent anchors in once at build time instead of recomputing +-- them on every read (unlike ApplyBarLayout, which already re-reads +-- BUTTON_GAP fresh every call and just needs re-running): the flyout icon +-- pool and the pending-assignment panel, both re-laid-out here via their +-- own helpers (layoutFlyoutIcons/layoutAssignIcons above), each a no-op if +-- that particular frame was never built yet. Driven by the options panel's +-- "Button spacing" slider. +function TotemBar.SetButtonGap(newGap) + if not newGap then + newGap = TotemBar.DEFAULT_BUTTON_GAP or 10 + end + newGap = TotemBar.clampValue(newGap, 10, 30) + BUTTON_GAP = newGap + if TotemBarDB then + TotemBarDB.buttonGap = newGap + end + layoutFlyoutIcons() + layoutAssignIcons() + if TotemBar.ApplyBarLayout then + TotemBar.ApplyBarLayout() + end +end + +-- Shows/hides the bar and persists the choice (TotemBarDB.hidden). Driven +-- by the options panel's "Show bar" checkbox, the minimap right-click, and +-- the bare /tb command. +function TotemBar.ToggleBar() + if not TotemBarFrame then + return + end + if TotemBarFrame:IsShown() then + TotemBarFrame:Hide() + TotemBarDB.hidden = true + else + TotemBarFrame:Show() + TotemBarDB.hidden = false + end +end + +-- Resets the bar's saved anchor to centered defaults and re-anchors it. +-- Does NOT change scale. Driven by the options panel's "Reset position". +function TotemBar.ResetPosition() + TotemBarDB.point = "CENTER" + TotemBarDB.relPoint = "CENTER" + TotemBarDB.x = 0 + TotemBarDB.y = 0 + if TotemBarFrame then + TotemBarFrame:ClearAllPoints() + TotemBarFrame:SetPoint("CENTER", UIParent, "CENTER", 0, 0) + end +end + +-- "/tb scan" dev-aid: one-shot print of every spellbook entry that looks +-- like a totem, so the static element map in core/totemdata.lua can be +-- checked against the live TurtleWoW client. Also writes the list to +-- \imports\totembar_scan.txt via SuperWoW's ExportFile (when +-- present) so it can be read off-client without pasting from chat. +function TotemBar.PrintScan() + local names = TotemBar.scanSpellbook() + local count = 0 + local dump = "" + ChatOut:AddMessage("TotemBar: scanning spellbook for totems...") + for i = 1, table.getn(names) do + if string.find(names[i], "Totem", 1, true) then + ChatOut:AddMessage(" " .. names[i]) + dump = dump .. names[i] .. "\n" + count = count + 1 + end + end + ChatOut:AddMessage("TotemBar: " .. count .. " totem spell(s) found.") + -- ExportFile appends .txt itself; pass the name WITHOUT extension. + if ExportFile then + ExportFile("totembar_scan", "totems=" .. count .. "\n" .. dump) + end +end + +local function HandleSlashCommand(msg) + local cmd = string.lower(msg or "") + if cmd == "" then + TotemBar.ToggleBar() + elseif cmd == "lock" then + TotemBarDB.locked = not TotemBarDB.locked + if TotemBarDB.locked then + ChatOut:AddMessage("TotemBar: bar locked.") + else + ChatOut:AddMessage("TotemBar: bar unlocked (drag to move).") + end + elseif cmd == "scan" then + TotemBar.PrintScan() + elseif cmd == "assign" then + -- Dev aid: inject a sample assignment to exercise the pending panel + -- + accept/decline flow end-to-end without any transport. + local sample = { + Fire = "Searing Totem", + Earth = "Strength of Earth Totem", + Water = "Mana Spring Totem", + Air = "Grace of Air Totem", + } + local ok, reason = TotemBar.ReceiveAssignment(sample, "TEST assignment") + if ok then + ChatOut:AddMessage("TotemBar: injected TEST assignment (click OK on the panel to apply).") + else + ChatOut:AddMessage("TotemBar: assign failed - " .. tostring(reason)) + end + elseif cmd == "options" or cmd == "opt" then + TotemBar.ToggleOptions() + elseif cmd == "bind" then + if TotemBar.ToggleBindMode then TotemBar.ToggleBindMode() end + elseif cmd == "manadump" then + if TotemBar.dumpManaScan then TotemBar.dumpManaScan() end + elseif cmd == "tdump" then + if TotemBar.DumpTimerState then TotemBar.DumpTimerState() end + elseif string.find(cmd, "^pulsecal") then + local _, _, sub = string.find(cmd, "^pulsecal%s*(%a*)") + if TotemBar.PulseCal then TotemBar.PulseCal(sub or "") end + else + ChatOut:AddMessage("TotemBar: unknown command '" .. msg .. "'. Usage: /tb, /tb lock, /tb scan, /tb assign, /tb options, /tb bind, /tb manadump, /tb tdump, /tb pulsecal") + end +end + +-- SPELL_UPDATE_COOLDOWN fires whenever any spell's cooldown starts or +-- ends (the standard signal the default action bars key off of) - this +-- is the event-driven trigger for RefreshCooldown, kept separate from +-- the throttled per-tick timer OnUpdate (see RefreshCooldown's comment +-- for why: SetTimer-on-every-tick would restart the swipe animation). +local eventFrame = CreateFrame("Frame", "TotemBarEventFrame", UIParent) +eventFrame:RegisterEvent("ADDON_LOADED") +eventFrame:RegisterEvent("SPELL_UPDATE_COOLDOWN") +eventFrame:RegisterEvent("SPELLS_CHANGED") +eventFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS") +eventFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_CREATURE_BUFFS") +eventFrame:SetScript("OnEvent", function() + if event == "ADDON_LOADED" and arg1 == "TotemBar" then + TotemBar.ensureDefaults() + -- BUTTON_GAP was initialized above (file scope, load time) to the + -- file-load default, before TotemBarDB was even readable - it must + -- be overwritten by the SAVED value now, BEFORE BuildUI runs, or + -- the very first layout bakes in the default instead of whatever + -- the player set via the options panel's "Button spacing" slider. + BUTTON_GAP = TotemBar.clampValue(TotemBarDB.buttonGap or TotemBar.DEFAULT_BUTTON_GAP or 10, 10, 30) + TotemBar.BuildUI() + eventFrame:UnregisterEvent("ADDON_LOADED") + elseif event == "SPELLS_CHANGED" then + -- BuildUI resolves each slot's icon off the live spellbook at + -- ADDON_LOADED time, but the spellbook (GetSpellName/ + -- GetSpellTexture) isn't reliably populated that early after a + -- login/reload - so a SAVED totem's icon lookup falls through to + -- the sheet's empty-slot glyph even though its name is stored + -- fine. SPELLS_CHANGED fires once the spellbook is ready (and + -- whenever it later changes), so re-resolve every icon here. + local elements = TotemBar.TOTEM_ELEMENTS + for i = 1, table.getn(elements) do + RefreshButton(elements[i]) + end + if recallButton and recallButton.icon then + -- Re-set BOTH texture and crop texcoord: SetTexCoord persists + -- across SetTexture, so a stale non-crop coord state must not + -- survive a texture re-resolve. + recallButton.icon:SetTexture(GetRecallIcon()) + recallButton.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) + end + elseif event == "SPELL_UPDATE_COOLDOWN" then + local elements = TotemBar.TOTEM_ELEMENTS + for i = 1, table.getn(elements) do + RefreshCooldown(elements[i]) + end + RefreshFlyoutCooldowns() + elseif event == "CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS" + or event == "CHAT_MSG_SPELL_PERIODIC_CREATURE_BUFFS" then + -- Re-anchor pulse phase on REAL observed periodic gains (spec 5.5). + local totemName = TotemBar.parseSelfGain(arg1) + if totemName then + local element = TotemBar.elementOf(totemName) + local rec = element and TotemBar.activeTotems[element] + if rec and rec.totemName == totemName then + rec.pulseAnchor = GetTime() + end + end + end +end) + +SLASH_TOTEMBAR1 = "/tb" +SlashCmdList = SlashCmdList or {} +SlashCmdList["TOTEMBAR"] = HandleSlashCommand