Files
moon-well-ui/data/addon/ForeverUI/SpellBook.lua
T
Claude 7d3ea1ac89 ForeverUI : grimoire utilisable en combat, mémoire des extraits, création d'un enchantement
Sans aucun groupe (guerrier, voleur, prêtre, chaman, chevalier de la mort,
bas niveaux), le menu volant n'avait aucun fils protégé : sa poignée était
refusée en combat (« Invalid frame handle »), et chaque onglet, page,
réglage ou fermeture du grimoire levait une erreur. Il est désormais
explicitement protégé (SecureFrameTemplate).

En combat, l'ouverture du grimoire masquait depuis l'addon SpellButton1..12,
que le client vient de réafficher et qui sont protégés : action bloquée à
chaque ouverture. Ils ne sont plus que rendus transparents en combat. Le
bouton de taille, sécurisé, était ancré au bouton de fermeture du client,
qui devenait protégé alors que son niveau est reposé à chaque ouverture :
il s'ancre au livre, au même endroit.

L'environnement restreint garde chaque extrait exécuté, compilé, toute la
session (clés chaînes jamais collectées) : les données publiées, plusieurs
centaines de kilo-octets, ne partent plus que si elles changent, l'état
dans un petit extrait à part. UNIT_PET des autres membres du groupe ne
recalcule plus le grimoire.

Métiers : un enchantement n'a pas de compteur, et le compteur caché gardait
le nombre de la recette précédente (0 si elle n'était pas faisable) ; la
création en lance un, comme le champ caché de 3.3.5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxRZNHkq453dyUMcWsyUf1
2026-10-01 23:17:58 +00:00

1771 lines
68 KiB
Lua

-- ForeverUI: camelot's spellbook (blizzard_playerspells), drawn inside WotLK's SpellBookFrame.
-- SpellBookFrame keeps its open/close paths (ToggleSpellBook, P key, micro button) and its close
-- button, re-skinned, so closing never goes through our code. Its art and its twelve spell
-- buttons are hidden.
--
-- Casting is protected, and WotLK's buttons cast from their book's internal state, so each cell
-- is a secure button (SecureActionButtonTemplate, type "spell"). Out of combat, every category,
-- page and group is computed and published to a secure controller
-- (SecureHandlerAttributeTemplate). Tabs, arrows, wheel, groups, settings and resize only send
-- it requests; its "show" snippet places the cells, then CallMethod runs ordinary code for
-- textures and texts, which the client allows in combat. The same path runs out of combat.
-- Only the computation waits for the end of combat (a spell learned or a pet summoned in it).
--
-- Differences with camelot:
-- * 3.3.5 has no texture masks: square icons keep their corners; passive icons are rounded
-- by SetPortraitToTexture.
-- * 3.3.5 has no upcoming spells: nothing grayed or "available at level n".
-- * pet spells cast by name; right click toggles autocast (/petautocasttoggle).
-- * WotLK's panel keeps its "left" UI panel area; the book sits where camelot puts a
-- "center" panel, without pushing the others.
ForeverUI = ForeverUI or {}
local S = {}
ForeverUI.SpellBook = S
-- Layout values from blizzard_playerspells: PlayerSpellsFrame, SpellBookFrame,
-- CategoryTabSystem, PagedSpellsFrame (PagedCondensedVerticalGrid), PagingControls.
local G = {
width = 1618, compactWidth = 809, height = 720, top = -116,
-- margins of the fit to the screen (blizzard_playerspellsregistration: checkFitExtraWidth,
-- checkFitExtraHeight)
fitExtraW = 200, fitExtraH = 140,
bookW = 1612, reducedBookW = 806, bookH = 702, bookBottom = 4,
tabsX = 70, tabsY = -26, tabW = 44, tabH = 32, tabGap = 1,
tabIconW = 34, tabIconH = 33,
pagesTop = -50, viewW = 680, viewH = 590, view1X = 85, viewY = -45, view2X = -50,
columns = 3, gapX = 15, gapY = 10, headerH = 51, spellH = 60, space = 20,
pagerX = -75, pagerY = 40, pagerGap = 8, arrowSide = 32,
buttonSide = 40, iconSide = 36, textX = 50,
cellsPerView = 24,
closeX = -2, closeY = 1, redSide = 24,
portraitX = -5, portraitY = 7, portraitSide = 62,
titleX1 = 58, titleX2 = -24, titleY = -1, titleH = 20,
settingsX = -30, settingsY = -27, settingsW = 15, settingsH = 16,
-- flyout (SpellBookItemButtonMixin, FlyoutButtonTemplate, SpellFlyout): opens to the RIGHT,
-- offset -4, 42 high; arrow 15 x 6, 4 from the edge closed, 2 open; small buttons of 30,
-- first at 9, gap 4, 9 after the last; border tinted 0.7
flyoutOffset = -4, flyoutH = 42, arrowW = 15, arrowH = 6,
arrowClosed = 4, arrowOpen = 2, smallSide = 30, flyoutStart = 9,
flyoutGap = 4, flyoutEnd = 9, flyoutTint = 0.7,
smallFrame = 35, smallStateW = 31.6, smallStateH = 30.9,
}
G.cellW = (G.viewW - G.gapX * (G.columns - 1)) / G.columns
-- metal corners: PortraitFrameTemplate as camelot adjusts it (top-right and bottom-right
-- x - 2, bottom y = -8)
local METAL = {
{ key = "topLeft", name = "ui-frame-portraitmetal-cornertopleft", point = "TOPLEFT", x = -13, y = 16 },
{ key = "topRight", name = "ui-frame-metal-cornertopright", point = "TOPRIGHT", x = 2, y = 16 },
{ key = "bottomLeft", name = "ui-frame-metal-cornerbottomleft", point = "BOTTOMLEFT", x = -13, y = -8 },
{ key = "bottomRight", name = "ui-frame-metal-cornerbottomright", point = "BOTTOMRIGHT", x = 2, y = -8 },
}
local INK = { 0.18, 0.106, 0.059 } -- SPELLBOOK_FONT_COLOR
local FONT = "Fonts\\FRIZQT__.TTF"
local L = ForeverUI.L
local TEXT = {
title = SPELLBOOK,
page = L.SPELLBOOK_PAGE, -- PAGE_NUMBER_WITH_MAX
passive = SPELL_PASSIVE,
pet = PET,
-- settings: camelot's SPELLBOOK_FILTER_PASSIVES and SHOW_ALL_SPELL_RANKS
hidePassives = L.SPELLBOOK_HIDE_PASSIVES,
allRanks = SHOW_ALL_SPELL_RANKS,
flyouts = L.SPELLBOOK_USE_FLYOUTS,
passivesDisabled = L.SPELLBOOK_HIDE_PASSIVES_DISABLED, -- SPELLBOOK_SEARCH_HIDE_PASSIVES_DISABLED
}
-- Settings menu, made of secure buttons. It copies the client list as DropDown.lua skins it
-- (3.3.5 UIDropDownMenu.lua, UIDropDownMenuTemplates.xml): DIALOG strata, TOPLEFT on the
-- arrow's BOTTOMLEFT; rows of 20 (ForeverUI's UIDROPDOWNMENU_BUTTON_HEIGHT) at
-- x = 5 + 12 - 6 (checkbox, MENU mode) and y = -15 - (n - 1) x 20; list n x 20 + 2 x 15 high
-- and longest text + 40 + 25 wide, rows 25 narrower; text 20 from the edge; checkbox
-- common-dropdown-ticksquare 12, yellow check 15 x 14 at (2, 1); background
-- common-dropdown-bg-c60 nine-slice (18; 9, 6, 9, 12) at 0.925. It closes 2 s after the
-- mouse leaves (UIDROPDOWNMENU_SHOW_TIME), or by the arrow.
local MENU = {
rowH = 20, edge = 15, rowX = 11, textX = 20, extraWidth = 40, margin = 25,
checkbox = 12, checkMarkW = 15, checkMarkH = 14, checkMarkX = 2, checkMarkY = 1,
backgroundCorner = 18, backgroundMargins = { 9, 6, 9, 12 }, backgroundAlpha = 0.925, pending = 2,
}
-- settings and their bit in the settings combination (reg); flyouts is checked when its bit
-- is ABSENT (the bit means "no groups")
local SETTINGS = {
{ bit = 1, text = TEXT.hidePassives },
{ bit = 2, text = TEXT.flyouts, inverse = true },
{ bit = 4, text = TEXT.allRanks },
}
local SEP = string.char(92)
local ROCK = "interface" .. SEP .. "ForeverUI" .. SEP .. "framegeneral" .. SEP .. "ui-background-rock"
local ARROWS = "Interface" .. SEP .. "Buttons" .. SEP .. "UI-SpellbookIcon-"
local SQUARE_HOVER = "Interface" .. SEP .. "Buttons" .. SEP .. "UI-Common-MouseHilight"
local CLASS_ICON = "Interface" .. SEP .. "ForeverUI" .. SEP .. "icons" .. SEP .. "classicon_"
-- portrait: the icon of camelot's General line (inv_misc_book_09), with its round mask baked
-- in (tools/bake_masks.py)
local PORTRAIT = "Interface" .. SEP .. "ForeverUI" .. SEP .. "spellbook" .. SEP .. "portrait"
local function settings()
ForeverUIDB = ForeverUIDB or {}
ForeverUIDB.spellbook = ForeverUIDB.spellbook or {}
return ForeverUIDB.spellbook
end
local function font(fs, size, c)
fs:SetFont(FONT, size)
c = c or INK
fs:SetTextColor(c[1], c[2], c[3])
end
-- Hide a WotLK region or frame for good: texture, alpha, visibility and mouse. WotLK's code
-- shows again what is only hidden.
local function suppress(f)
if not f then return end
if f.SetTexture then f:SetTexture(nil) end
f:SetAlpha(0)
-- WotLK's SpellButton1..12 are protected (SpellButtonTemplate inherits SecureFrameTemplate)
-- and SpellBookFrame_Update shows them again: in combat they only turn transparent (their
-- mouse is already off since the calls made out of combat)
if InCombatLockdown() and f.IsProtected and f:IsProtected() then return end
if f.EnableMouse then f:EnableMouse(false) end
f:Hide()
end
-- ---------------------------------------------------------------- Data
-- camelot's categories: one per spell tab (General, then one per talent tree), plus the pet.
-- General shows the class icon.
local function categories()
local list = {}
for i = 1, GetNumSpellTabs() do
local name, icon, offset, count = GetSpellTabInfo(i)
if name then
if i == 1 then
local _, className = UnitClass("player")
icon = CLASS_ICON .. string.lower(className or "warrior")
end
table.insert(list, { name = name, icon = icon, offset = offset, count = count, book = BOOKTYPE_SPELL })
end
end
local numPetSpells = HasPetSpells()
if numPetSpells and numPetSpells > 0 then
table.insert(list, {
name = TEXT.pet, icon = GetPetIcon and GetPetIcon() or nil,
offset = 0, count = numPetSpells, book = BOOKTYPE_PET, pet = true,
})
end
return list
end
S.categories = categories
S.G = G
S.font = font
-- Spell options: passives = hide passives, flyouts = no flyout groups, ranks = all ranks.
-- Defaults: the saved settings and the ShowAllSpellRanks CVar.
local function currentOptions()
return { passives = settings().hidePassives and true or false,
flyouts = settings().noFlyouts and true or false,
ranks = GetCVar("ShowAllSpellRanks") == "1" }
end
-- Spells of a category. Without all ranks, a spell keeps only its highest rank (the last of
-- the line). Hide passives (camelot's spellBookHidePassives, missing in 3.3.5, is kept in
-- ForeverUIDB) removes passives. opts: optional, defaults to currentOptions()
local function spellsOf(cat, opts)
opts = opts or currentOptions()
local spells, last = {}, {}
local allRanks = opts.ranks
local noPassives = opts.passives
for slot = cat.offset + 1, cat.offset + cat.count do
local name, rank = GetSpellName(slot, cat.book)
local passive = name and IsPassiveSpell(slot, cat.book) and true or false
if name and not (noPassives and passive) then
local spell = { slot = slot, book = cat.book, name = name, rank = rank or "", passive = passive,
icon = GetSpellTexture(slot, cat.book), pet = cat.pet }
if not allRanks and last[name] then
spells[last[name]] = spell
else
table.insert(spells, spell)
last[name] = #spells
end
end
end
return spells
end
S.spellsOf = spellsOf
-- ------------------------------------------------------------ Flyouts
-- "Group Similar Spells on Flyouts" (SPELLBOOK_USE_FLYOUTS). camelot's server puts a flyout
-- entry in the book and removes its spells (IsSpellBookItemLooseFlyoutMember). 3.3.5 has
-- neither, so the groups are camelot's, from SpellFlyout.db2 and SpellFlyoutItem.db2 (name,
-- description, icon, spells in slot order), minus the two empty ones (263, 265) and the two
-- with only camelot spells (252 Spell Resistances, 257 Weapon Proficiencies). Each camelot
-- list is followed by the class spells TBC and WotLK add to that family (Spell.dbc,
-- SkillLineAbility.dbc).
-- A spell joins a group BY NAME (GetSpellInfo(id)), so the ranks 3.3.5 adds follow the
-- original one. camelot-only ids (above one million) do not exist here and are ignored.
local FLYOUTS = {
-- Portals and teleports: one group per faction (SpellFlyout race mask). After camelot's
-- list, the TBC and WotLK cities: Exodar / Silvermoon, Shattrath (one spell per faction),
-- Theramore / Stonard, Dalaran (the same spell for both).
{ id = 248, name = L.SPELLBOOK_FLYOUT_PORTAL, icon = "spell_arcane_portalstormwind", faction = "Alliance",
text = L.SPELLBOOK_FLYOUT_PORTAL_DESC,
spells = { 11419, 11416, 10059, 32266, 33691, 49360, 53142 } },
{ id = 249, name = L.SPELLBOOK_FLYOUT_PORTAL, icon = "spell_arcane_portalorgrimmar", faction = "Horde",
text = L.SPELLBOOK_FLYOUT_PORTAL_DESC,
spells = { 11417, 11420, 11418, 32267, 35717, 49361, 53142 } },
{ id = 250, name = L.SPELLBOOK_FLYOUT_TELEPORT, icon = "spell_arcane_teleportstormwind", faction = "Alliance",
text = L.SPELLBOOK_FLYOUT_TELEPORT_DESC,
spells = { 3565, 3562, 3561, 1297659, 32271, 33690, 49359, 53140 } },
{ id = 251, name = L.SPELLBOOK_FLYOUT_TELEPORT, icon = "spell_arcane_teleportorgrimmar", faction = "Horde",
text = L.SPELLBOOK_FLYOUT_TELEPORT_DESC,
spells = { 3567, 3566, 3563, 1297659, 32272, 35715, 49358, 53140 } },
{ id = 253, name = L.SPELLBOOK_FLYOUT_ASPECT, icon = "ability_hunter_aspectmastery",
text = L.SPELLBOOK_FLYOUT_ASPECT_DESC,
spells = { 13163, 13165, 14318, 14319, 14320, 14321, 14322, 25296, 5118, 13161,
1299445, 1299446, 1299447, 13159, 20043, 20190,
34074, 61846 } }, -- + Viper, Dragonhawk
{ id = 259, name = L.SPELLBOOK_FLYOUT_SUMMON_DEMON, icon = "spell_shadow_summonimp",
text = L.SPELLBOOK_FLYOUT_SUMMON_DEMON_DESC,
spells = { 688, 697, 712, 713, 691,
30146 } }, -- + Felguard
{ id = 261, name = L.SPELLBOOK_FLYOUT_IMP_SPELLS, icon = "spell_fire_firebolt",
text = L.SPELLBOOK_FLYOUT_IMP_SPELLS_DESC,
spells = { 20801, 6307, 2949, 4511 } },
{ id = 262, name = L.SPELLBOOK_FLYOUT_SHAPESHIFT, icon = "ability_racial_bearform",
text = L.SPELLBOOK_FLYOUT_SHAPESHIFT_DESC,
spells = { 5487, 9634, 1066, 768, 783, 24858,
33943, 40120, 33891 } }, -- + Flight, Swift Flight, Tree of Life
{ id = 264, name = L.SPELLBOOK_FLYOUT_BLESSINGS, icon = "spell_magic_magearmor",
text = L.SPELLBOOK_FLYOUT_BLESSINGS_DESC,
-- 1038 (camelot's Blessing of Salvation) is Hand of Salvation here: it goes with the other
-- Hands, in Utility Blessings
spells = { 20217, 25291, 19838, 19837, 19836, 19835, 19834, 19740, 25290,
19854, 19853, 19852, 19850, 19742, 19979, 19978, 19977,
20911 } }, -- + Sanctuary
{ id = 267, name = L.SPELLBOOK_FLYOUT_PET_UTILITIES, icon = "ability_hunter_beasttaming",
text = L.SPELLBOOK_FLYOUT_PET_UTILITIES_DESC,
spells = { 883, 982, 6991, 2641, 1515, 1462, 5149 } },
{ id = 268, name = L.SPELLBOOK_FLYOUT_TRACKING, icon = "ability_tracking",
text = L.SPELLBOOK_FLYOUT_TRACKING_DESC,
spells = { 1494, 19878, 19879, 19880, 19882, 19885, 19883, 19884 } },
{ id = 269, name = L.SPELLBOOK_FLYOUT_STANCES, icon = "ability_warrior_offensivestance",
text = L.SPELLBOOK_FLYOUT_STANCES_DESC,
spells = { 2457, 71, 2458 } },
{ id = 270, name = AURAS, icon = "spell_holy_devotionaura",
text = L.SPELLBOOK_FLYOUT_AURAS_DESC,
spells = { 10301, 10300, 10299, 10298, 7294, 10293, 10292, 1032, 10291, 643,
10290, 465, 19746, 20218, 19900, 19899, 19891, 19898, 19897, 19888,
19896, 19895, 19876,
32223 } }, -- + Crusader Aura
{ id = 272, name = L.SPELLBOOK_FLYOUT_GREATER_BLESSINGS, icon = "spell_holy_greaterblessingofkings",
text = L.SPELLBOOK_FLYOUT_GREATER_BLESSINGS_DESC,
spells = { 25895, 25898, 25916, 25782, 25918, 25894, 25890,
25899 } }, -- + Greater Sanctuary
{ id = 273, name = L.SPELLBOOK_FLYOUT_UTILITY_BLESSINGS, icon = "spell_holy_sealofvalor",
text = L.SPELLBOOK_FLYOUT_UTILITY_BLESSINGS_DESC,
spells = { 10278, 5599, 1022, 1044, 20729, 6940,
1038 } }, -- + Hand of Salvation
-- Two groups camelot does not have: paladin seals and judgements. No id, description or
-- icon: the group uses its first spell's icon. Spell ids from Spell.dbc and
-- SkillLineAbility.dbc.
{ name = L.SPELLBOOK_FLYOUT_SEALS,
spells = { 21084, 20164, 20165, 20166, 20375, 31801, 53736 } },
{ name = L.SPELLBOOK_FLYOUT_JUDGEMENTS,
spells = { 20271, 53408, 53407 } },
}
S.FLYOUTS = FLYOUTS
-- Group of a spell name: { flyout, order of the name in the group }. byName is built once;
-- a name can be in two groups (Dalaran, in both factions'), the player's faction wins.
local byName
local function flyoutOf(name)
if not byName then
byName = {}
for _, v in ipairs(FLYOUTS) do
local order, seen = 0, {}
for _, id in ipairs(v.spells) do
local n = GetSpellInfo(id)
if n and not seen[n] then
seen[n] = true
order = order + 1
byName[n] = byName[n] or {}
table.insert(byName[n], { flyout = v, order = order })
end
end
end
end
local faction = UnitFactionGroup("player")
for _, m in ipairs(byName[name] or {}) do
if not m.flyout.faction or m.flyout.faction == faction then
return m
end
end
end
-- Groups of ONE category: a group only joins spells of the same tab (warrior stances, each
-- in its own tree, stay alone; mage portals and teleports, all in one tab, are grouped).
-- It needs at least two DIFFERENT spells: two ranks of one spell are not a group. Its spells
-- follow the group order.
local function groupsOf(spells)
local groups = {}
for _, spell in ipairs(spells) do
local m = flyoutOf(spell.name)
if m then
local g = groups[m.flyout]
if not g then
g = { members = {}, names = {}, distinctCount = 0 }
groups[m.flyout] = g
end
spell.order = m.order
table.insert(g.members, spell)
if not g.names[spell.name] then
g.names[spell.name] = true
g.distinctCount = g.distinctCount + 1
end
end
end
for v, g in pairs(groups) do
if g.distinctCount < 2 then
groups[v] = nil
else
table.sort(g.members, function(a, b)
if a.order ~= b.order then return a.order < b.order end
return a.slot < b.slot
end)
end
end
return groups
end
-- What a category shows: its spells and, when grouping, each group in place of its first
-- spell, its members removed. Pet spells are never grouped.
-- cats: categories(); index: category index; opts: see currentOptions
local function displayedList(cats, index, opts)
opts = opts or currentOptions()
local cat = cats[index]
if not cat then return {} end
local spells = spellsOf(cat, opts)
if cat.pet or opts.flyouts then
return spells
end
local groups = groupsOf(spells)
local list, placed = {}, {}
for _, spell in ipairs(spells) do
local m = flyoutOf(spell.name)
local g = m and groups[m.flyout]
if not g then
table.insert(list, spell)
elseif not placed[m.flyout] then
placed[m.flyout] = true
local v = m.flyout
local icon = v.icon and ("Interface" .. SEP .. "Icons" .. SEP .. v.icon) or g.members[1].icon
table.insert(list, { flyout = v, name = v.name, rank = "", passive = false,
icon = icon, members = g.members })
end
end
return list
end
S.displayedList = displayedList
S.currentOptions = currentOptions
-- ------------------------------------------------------------ Layout
-- PagedCondensedVerticalGrid: the header takes a row (again at the top of each following
-- view), then spells fill the columns one by one over ceil(remaining / 3) rows in the space
-- left; a full view opens another, and the row count restarts from the remaining spells.
-- Several sections (search results): each keeps its own rows; a section starting on a used
-- view gets camelot's spacer (spacerSize 20 + yPadding 10), and moves to the next view if its
-- header and one row do not fit.
-- groups: list of { title, spells }. Returns the views, each a list of
-- { header or sort, column, y }.
local function paginateGroups(groups)
local views = {}
local view, busy
local step = G.spellH + G.gapY
local function newView(title)
view = {}
table.insert(views, view)
busy = 0
if title then
table.insert(view, { header = title, column = 1, y = 0 })
busy = G.headerH + G.gapY
end
end
for _, g in ipairs(groups) do
local title, spells = g.title, g.spells
if not view then
newView(title)
elseif busy + G.space + G.gapY + G.headerH + G.gapY + step > G.viewH then
newView(title)
else
busy = busy + G.space + G.gapY
if title then
table.insert(view, { header = title, column = 1, y = busy })
busy = busy + G.headerH + G.gapY
end
end
local i = 1
while i <= #spells do
local available = math.floor((G.viewH - busy) / step)
if available < 1 then
newView(title)
available = math.floor((G.viewH - busy) / step)
end
local remaining = #spells - i + 1
local rowLines = math.min(math.ceil(remaining / G.columns), available)
for column = 1, G.columns do
for r = 1, rowLines do
if i <= #spells then
table.insert(view, { spell = spells[i], column = column, y = busy + (r - 1) * step })
i = i + 1
end
end
end
busy = busy + rowLines * step
if i <= #spells then
newView(title)
end
end
end
if not view then newView(nil) end
return views
end
S.paginateGroups = paginateGroups
local function paginate(title, spells)
return paginateGroups({ { title = title, spells = spells } })
end
S.paginate = paginate
-- --------------------------------------------------------------- Cells
local function atlas(t, name, keep)
return ForeverUI.SetAtlas(t, name, keep)
end
-- Create spell cell n: a holder frame (background, texts) with the secure icon button
-- inside.
local function createCheckbox(view, n)
local c = CreateFrame("Frame", nil, view)
c:SetWidth(G.cellW)
c:SetHeight(G.spellH)
local background = c:CreateTexture(nil, "BACKGROUND")
atlas(background, "spellbook-item-backplate")
background:SetPoint("CENTER", c, "CENTER", 5, -5)
background:SetAlpha(0.25)
c.background = background
local b = CreateFrame("Button", "ForeverUISpellBookButton" .. n, c, "SecureActionButtonTemplate")
b:SetWidth(G.buttonSide)
b:SetHeight(G.buttonSide)
b:SetPoint("LEFT", c, "LEFT", 0, 0)
b:RegisterForClicks("AnyUp")
b:RegisterForDrag("LeftButton")
-- cell index: the group snippet passes it to ForeverUIFlyout
b:SetID(n)
-- a modified click links the spell in chat instead of casting it
b:SetAttribute("shift-type1", "link")
local icon = b:CreateTexture(nil, "ARTWORK")
icon:SetWidth(G.iconSide)
icon:SetHeight(G.iconSide)
icon:SetPoint("CENTER", b, "CENTER", 0, 0)
local shadow = b:CreateTexture(nil, "ARTWORK")
atlas(shadow, "spellbook-item-iconframe-shadow", true)
shadow:SetPoint("TOPLEFT", b, "TOPLEFT", -12, 3)
shadow:SetPoint("BOTTOMRIGHT", b, "BOTTOMRIGHT", 2, -8)
local frame = b:CreateTexture(nil, "OVERLAY")
local hover = b:CreateTexture(nil, "OVERLAY")
hover:SetAllPoints(b)
hover:SetBlendMode("ADD")
hover:SetAlpha(0.35)
hover:Hide()
local auto = b:CreateTexture(nil, "OVERLAY")
atlas(auto, "spellbook-item-petautocast-corners", true)
auto:SetAllPoints(icon)
auto:Hide()
local cooldown = CreateFrame("Cooldown", "ForeverUISpellBookButton" .. n .. "Cooldown", b, "CooldownFrameTemplate")
cooldown:SetPoint("TOPLEFT", icon, "TOPLEFT", 2, -2)
cooldown:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", -2, 2)
-- flyout arrow (FlyoutButtonTemplate, Arrow)
local arrow = b:CreateTexture(nil, "OVERLAY")
arrow:Hide()
b.icon, b.shadow, b.frame, b.hover, b.auto, b.cooldown = icon, shadow, frame, hover, auto, cooldown
b.arrow = arrow
local name = c:CreateFontString(nil, "ARTWORK")
font(name, 16)
name:SetJustifyH("LEFT")
name:SetWidth(G.cellW - G.textX)
local sub = c:CreateFontString(nil, "ARTWORK")
font(sub, 12)
sub:SetJustifyH("LEFT")
sub:SetWidth(G.cellW - G.textX)
c.name, c.sub = name, sub
b:SetScript("OnEnter", function(self)
c.background:SetAlpha(1)
self.hover:Show()
self.isHovered = true
local spell = c.spell
if spell and spell.flyout then
-- group: its name and description (SpellFlyout.db2)
GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
GameTooltip:SetText(spell.name, 1, 1, 1)
if spell.flyout.text then GameTooltip:AddLine(spell.flyout.text, nil, nil, nil, 1) end
GameTooltip:Show()
S.placeArrow(self)
elseif spell then
GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
GameTooltip:SetSpell(spell.slot, spell.book)
GameTooltip:Show()
end
end)
b:SetScript("OnLeave", function(self)
self.isHovered, self.pressed = nil, nil
c.background:SetAlpha(0.25)
self.hover:Hide()
GameTooltip:Hide()
if c.spell and c.spell.flyout then S.placeArrow(self) end
end)
b:SetScript("OnMouseDown", function(self)
self.pressed = true
self.hover:SetAlpha(0.65)
if c.spell and c.spell.flyout then S.placeArrow(self) end
end)
b:SetScript("OnMouseUp", function(self)
self.pressed = nil
self.hover:SetAlpha(0.35)
if c.spell and c.spell.flyout then S.placeArrow(self) end
end)
b:SetScript("OnDragStart", function(self)
local spell = c.spell
-- a group cannot go on an action bar: 3.3.5 has no flyouts there
if spell and not spell.flyout then PickupSpell(spell.slot, spell.book) end
self.pressed = nil
end)
-- SpellButton_OnModifiedClick: the spell link. A group opens through the secure snippet
-- wrapped around OnClick (OPEN).
b:SetScript("PostClick", function(self, mouse)
local spell = c.spell
if spell and not spell.flyout and IsModifiedClick("CHATLINK") then
local link = GetSpellLink(spell.slot, spell.book)
if link then ChatEdit_InsertLink(link) end
end
end)
c.button = b
c:Hide()
return c
end
-- UpdateVisuals: square or round frame, texts, cooldown, autocast. Nothing protected:
-- called in combat by ForeverUIVisuals.
local function fillCell(c, spell)
c.spell = spell
local b = c.button
if spell.passive then
SetPortraitToTexture(b.icon, spell.icon)
b.icon:SetTexCoord(0, 1, 0, 1)
atlas(b.frame, "talents-node-circle-gray", true)
b.frame:ClearAllPoints()
b.frame:SetAllPoints(b)
atlas(b.hover, "spellbook-item-iconframe-passive-hover", true)
-- a passive has only its round icon, without an active spell's square shadow
b.shadow:Hide()
else
b.shadow:Show()
b.icon:SetTexture(spell.icon)
b.icon:SetTexCoord(0, 1, 0, 1)
atlas(b.frame, "spellbook-item-iconframe-c60", true)
b.frame:ClearAllPoints()
b.frame:SetPoint("TOPLEFT", b, "TOPLEFT", -11, 1)
b.frame:SetPoint("BOTTOMRIGHT", b, "BOTTOMRIGHT", 1, -7)
atlas(b.hover, "spellbook-item-iconframe-hover", true)
end
c.name:SetText(spell.name)
local subtitle = spell.rank
if subtitle == "" and spell.passive then subtitle = TEXT.passive end
c.sub:SetText(subtitle)
-- text block centered on the cell at (50, -1)
local nameH = c.name:GetHeight() or 0
local subH = (subtitle ~= "" and (c.sub:GetHeight() or 0)) or 0
local total = nameH + (subH > 0 and (2 + subH) or 0)
c.name:ClearAllPoints()
c.name:SetPoint("TOPLEFT", c, "LEFT", G.textX, total / 2 - 1)
c.sub:ClearAllPoints()
c.sub:SetPoint("TOPLEFT", c.name, "BOTTOMLEFT", 0, -2)
if subtitle == "" then c.sub:Hide() else c.sub:Show() end
if spell.flyout then
b.arrow:Show()
S.placeArrow(b)
else
b.arrow:Hide()
end
S.updateState(c)
end
-- "Name(Rank N)": what SecureActionButton passes to CastSpellByName
function S.spellText(spell)
local text = spell.name
if spell.rank ~= "" and not spell.pet then text = text .. "(" .. spell.rank .. ")" end
return text
end
-- cooldown and autocast, without touching the attributes
function S.updateState(c)
local spell = c.spell
if not spell then return end
local b = c.button
if spell.flyout then
if CooldownFrame_SetTimer then CooldownFrame_SetTimer(b.cooldown, 0, 0, 0) end
b.auto:Hide()
return
end
local start, duration, active = GetSpellCooldown(spell.slot, spell.book)
if CooldownFrame_SetTimer and start then
CooldownFrame_SetTimer(b.cooldown, start, duration, active)
end
if spell.pet then
local allowed, isLit = GetSpellAutocast(spell.slot, spell.book)
if allowed and isLit then b.auto:Show() else b.auto:Hide() end
else
b.auto:Hide()
end
end
-- ------------------------------------------------------------ Flyout menu
-- SetClampedTextureRotation at 90 or 270 on an atlas entry: the eight-argument SetTexCoord
-- (SetRotation would lose the atlas rectangle), width and height swapped.
local function rotateAtlas(t, name, degrees, width, height)
local e = ForeverUI.AtlasEntry(name)
if not e then return end
t:SetTexture(e[1])
local u1, u2, v1, v2 = e[2], e[3], e[4], e[5]
if degrees == 90 then
t:SetTexCoord(u1, v2, u2, v2, u1, v1, u2, v1)
else
t:SetTexCoord(u2, v1, u1, v1, u2, v2, u1, v2)
end
t:SetWidth(height)
t:SetHeight(width)
end
-- FlyoutButtonMixin: normal, hover or pressed arrow; pointing right (90) when closed,
-- flipped (270) when open, 4 or 2 from the right edge
function S.placeArrow(b)
local isOpen = S.openFlyout == b
local name = "ui-hud-actionbar-flyout"
if b.pressed then
name = name .. "-down"
elseif b.isHovered then
name = name .. "-mouseover"
end
rotateAtlas(b.arrow, name, isOpen and 270 or 90, G.arrowW, G.arrowH)
b.arrow:ClearAllPoints()
b.arrow:SetPoint("RIGHT", b, "RIGHT", isOpen and G.arrowOpen or G.arrowClosed, 0)
end
-- SpellFlyoutPopupButtonTemplate: the small action button (30; frame and pushed 35,
-- centered; hover 31.6 x 30.9), secure to cast. n: button index
local function createSmallButton(flyout, n)
local b = CreateFrame("Button", "ForeverUISpellFlyoutButton" .. n, flyout, "SecureActionButtonTemplate")
b:SetWidth(G.smallSide)
b:SetHeight(G.smallSide)
b:RegisterForClicks("AnyUp")
b:RegisterForDrag("LeftButton")
b:SetAttribute("type1", "spell")
b:SetAttribute("shift-type1", "link")
-- its position never changes: the secure snippet only shows it
b:SetPoint("LEFT", flyout, "LEFT", G.flyoutStart + (n - 1) * (G.smallSide + G.flyoutGap), 0)
b:Hide()
local icon = b:CreateTexture(nil, "BORDER")
icon:SetAllPoints(b)
b.icon = icon
local function state(t, name, l, h, add)
ForeverUI.SetAtlas(t, name, true)
t:SetWidth(l)
t:SetHeight(h)
t:ClearAllPoints()
t:SetPoint("CENTER", b, "CENTER", 0, 0)
if add then t:SetBlendMode("ADD") end
end
local e = ForeverUI.AtlasEntry("ui-hud-actionbar-iconframe")
b:SetNormalTexture(e and e[1] or "")
state(b:GetNormalTexture(), "ui-hud-actionbar-iconframe", G.smallFrame, G.smallFrame)
b:SetPushedTexture(e and e[1] or "")
state(b:GetPushedTexture(), "ui-hud-actionbar-iconframe-down", G.smallFrame, G.smallFrame)
b:SetHighlightTexture(e and e[1] or "")
state(b:GetHighlightTexture(), "ui-hud-actionbar-iconframe-mouseover", G.smallStateW, G.smallStateH)
-- CHECKED state: the hover art in ADD, shown while the spell is current
local checkMark = b:CreateTexture(nil, "OVERLAY")
state(checkMark, "ui-hud-actionbar-iconframe-mouseover", G.smallStateW, G.smallStateH, true)
checkMark:Hide()
b.checkMark = checkMark
b.cooldown = CreateFrame("Cooldown", "ForeverUISpellFlyoutButton" .. n .. "Cooldown", b, "CooldownFrameTemplate")
b.cooldown:SetAllPoints(icon)
b:SetScript("OnEnter", function(self)
if not self.spell then return end
GameTooltip:SetOwner(self, "ANCHOR_RIGHT", 4, 4)
GameTooltip:SetSpell(self.spell.slot, self.spell.book)
GameTooltip:Show()
end)
b:SetScript("OnLeave", function() GameTooltip:Hide() end)
b:SetScript("OnDragStart", function(self)
if self.spell then PickupSpell(self.spell.slot, self.spell.book) end
end)
-- SpellFlyoutPopupButtonMixin:OnClick: the link here; the cast and closing the menu go
-- through the secure snippet (SMALL_PRE, SMALL_POST)
b:SetScript("PostClick", function(self)
if self.spell and IsModifiedClick("CHATLINK") then
local link = GetSpellLink(self.spell.slot, self.spell.book)
if link then ChatEdit_InsertLink(link) end
end
end)
return b
end
local function updateSmallButton(b)
local spell = b.spell
if not spell then return end
local start, duration, active = GetSpellCooldown(spell.slot, spell.book)
if CooldownFrame_SetTimer and start then
CooldownFrame_SetTimer(b.cooldown, start, duration, active)
end
if IsCurrentSpell and IsCurrentSpell(spell.slot, spell.book) then b.checkMark:Show() else b.checkMark:Hide() end
end
-- FlyoutPopupTemplate, opened to the right: start cap (FlyoutBottom) on the left, middle
-- (FlyoutMidLeft), end cap (FlyoutButton) on the right; both caps rotated by 90
local function buildFlyout(book)
-- Explicitly protected: DISPLAY and ON_HIDE hide it in combat, and the handle of a plain
-- frame only works there through protected children, which a book without groups never
-- creates ("Invalid frame handle", RestrictedFrames.lua GetHandleFrame)
local flyout = CreateFrame("Frame", "ForeverUISpellFlyout", book, "SecureFrameTemplate")
flyout:SetFrameStrata("DIALOG")
flyout:EnableMouse(true)
flyout:SetHeight(G.flyoutH)
flyout:Hide()
local start = flyout:CreateTexture(nil, "BACKGROUND")
rotateAtlas(start, "ui-hud-actionbar-iconframe-flyoutbottom", 90, 47, 5)
start:SetHeight(G.flyoutH)
start:SetPoint("LEFT", flyout, "LEFT", 0, 0)
local finish = flyout:CreateTexture(nil, "BACKGROUND")
rotateAtlas(finish, "ui-hud-actionbar-iconframe-flyoutbutton", 90, 47, 29)
finish:SetHeight(G.flyoutH)
finish:SetPoint("RIGHT", flyout, "RIGHT", 0, 0)
local middle = flyout:CreateTexture(nil, "BACKGROUND")
atlas(middle, "_ui-hud-actionbar-iconframe-flyoutmidleft", true)
middle:SetHeight(G.flyoutH)
middle:SetPoint("LEFT", start, "RIGHT", 0, 0)
middle:SetPoint("RIGHT", finish, "LEFT", 0, 0)
for _, t in ipairs({ start, middle, finish }) do
t:SetVertexColor(G.flyoutTint, G.flyoutTint, G.flyoutTint)
end
flyout.buttons = {}
-- hidden by anything (page, book, category): the arrow follows
flyout:SetScript("OnHide", function()
local b = S.openFlyout
S.openFlyout = nil
if b then S.placeArrow(b) end
end)
S.flyout = flyout
return flyout
end
-- ForeverUIFlyout, called by a group's secure snippet (CallMethod): textures of the small
-- buttons and the arrow. Nothing protected. k: cell index; fk: group index in S.flyoutData
function S.flyoutOpenedBy(k, fk)
local old = S.openFlyout
local c = S.cells and S.cells[k]
local members = S.flyoutData and S.flyoutData[fk]
if not c or not members then return end
S.openFlyout = c.button
if old and old ~= c.button then S.placeArrow(old) end
for i, p in ipairs(S.flyout.buttons) do
p.spell = members[i]
if p.spell then
p.icon:SetTexture(p.spell.icon)
updateSmallButton(p)
end
end
S.placeArrow(c.button)
end
-- category header (SpellBookHeaderTemplate)
local function createHeader(view)
local h = CreateFrame("Frame", nil, view)
h:SetWidth(G.viewW)
h:SetHeight(G.headerH)
local background = h:CreateTexture(nil, "BACKGROUND")
atlas(background, "spellbook-list-backplate", true)
background:SetWidth(416)
background:SetHeight(106)
background:SetPoint("LEFT", h, "LEFT", -85, 10)
background:SetAlpha(0.65)
local text = h:CreateFontString(nil, "ARTWORK")
font(text, 24)
text:SetJustifyH("LEFT")
text:SetPoint("TOPLEFT", h, "TOPLEFT", -8, 0)
text:SetPoint("BOTTOMRIGHT", h, "BOTTOMRIGHT", -60, 0)
local line = h:CreateTexture(nil, "ARTWORK")
atlas(line, "spellbook-divider", true)
line:SetHeight(11)
line:SetPoint("BOTTOMLEFT", h, "BOTTOMLEFT", -32, 0)
line:SetPoint("BOTTOMRIGHT", h, "BOTTOMRIGHT", -60, 0)
h.text = text
h:Hide()
return h
end
-- ------------------------------------------------------------ Frame
-- The close button is not a child of the book, so its level is set again on each show,
-- above the metal (+20) and the title (+21).
function S.raiseCloseButton()
local close, book = SpellBookCloseButton, S.book
if not close or not book then return end
close:SetFrameStrata(book:GetFrameStrata())
close:SetFrameLevel(book:GetFrameLevel() + 22)
end
-- Red title bar button. normal, pressed: atlas names; template: frame template
local function redButton(parent, normal, pressed, template)
local b = CreateFrame("Button", nil, parent, template)
b:SetWidth(G.redSide)
b:SetHeight(G.redSide)
local e = ForeverUI.AtlasEntry(normal)
b:SetNormalTexture(e and e[1] or "")
atlas(b:GetNormalTexture(), normal, true)
b:SetPushedTexture(e and e[1] or "")
atlas(b:GetPushedTexture(), pressed, true)
b:SetHighlightTexture(e and e[1] or "")
atlas(b:GetHighlightTexture(), "redbutton-highlight", true)
b:GetHighlightTexture():SetBlendMode("ADD")
return b
end
-- Window chrome of the book: rock, metal, portrait, title, close and resize buttons.
local function buildFrame(book)
-- rock background and metal (PortraitFrameTemplate)
local rock = book:CreateTexture(nil, "BACKGROUND")
rock:SetTexture(ROCK, true)
if rock.SetHorizTile then rock:SetHorizTile(true) rock:SetVertTile(true) end
rock:SetPoint("TOPLEFT", book, "TOPLEFT", 2, -21)
rock:SetPoint("BOTTOMRIGHT", book, "BOTTOMRIGHT", -2, 2)
local metal = CreateFrame("Frame", nil, book)
metal:SetAllPoints(book)
metal:SetFrameLevel(book:GetFrameLevel() + 20)
local p = {}
for _, corner in ipairs(METAL) do
local t = metal:CreateTexture(nil, "OVERLAY")
atlas(t, corner.name)
t:SetPoint(corner.point, metal, corner.point, corner.x, corner.y)
p[corner.key] = t
end
local function edge(name, a1, c1, r1, a2, c2, r2)
local t = metal:CreateTexture(nil, "OVERLAY")
atlas(t, name)
t:SetPoint(a1, c1, r1)
t:SetPoint(a2, c2, r2)
end
edge("_ui-frame-metal-edgetop", "TOPLEFT", p.topLeft, "TOPRIGHT", "TOPRIGHT", p.topRight, "TOPLEFT")
edge("_ui-frame-metal-edgebottom", "BOTTOMLEFT", p.bottomLeft, "BOTTOMRIGHT", "BOTTOMRIGHT", p.bottomRight, "BOTTOMLEFT")
edge("!ui-frame-metal-edgeleft", "TOPLEFT", p.topLeft, "BOTTOMLEFT", "BOTTOMLEFT", p.bottomLeft, "TOPLEFT")
edge("!ui-frame-metal-edgeright", "TOPRIGHT", p.topRight, "BOTTOMRIGHT", "BOTTOMRIGHT", p.bottomRight, "TOPRIGHT")
-- round portrait (PortraitContainer, below the metal)
local portraitFrame = CreateFrame("Frame", nil, book)
portraitFrame:SetAllPoints(book)
portraitFrame:SetFrameLevel(book:GetFrameLevel() + 19)
local portrait = portraitFrame:CreateTexture(nil, "OVERLAY")
portrait:SetWidth(G.portraitSide)
portrait:SetHeight(G.portraitSide)
portrait:SetPoint("TOPLEFT", book, "TOPLEFT", G.portraitX, G.portraitY)
portrait:SetTexture(PORTRAIT)
book.portrait = portrait
-- title (TitleContainer, above the metal)
local banner = CreateFrame("Frame", nil, book)
banner:SetFrameLevel(book:GetFrameLevel() + 21)
banner:SetHeight(G.titleH)
banner:SetPoint("TOPLEFT", book, "TOPLEFT", G.titleX1, G.titleY)
banner:SetPoint("TOPRIGHT", book, "TOPRIGHT", G.titleX2, G.titleY)
local title = banner:CreateFontString(nil, "OVERLAY", "GameFontNormal")
title:SetPoint("TOP", banner, "TOP", 0, -5)
title:SetText(TEXT.title)
book.title = title
book.banner = banner
-- close button: WotLK's, re-skinned. It stays a child of SpellBookFrame: its OnClick hides
-- ITS PARENT (HideUIPanel), so closing never goes through our code.
local close = SpellBookCloseButton
if close then
S.raiseCloseButton()
close:SetWidth(G.redSide)
close:SetHeight(G.redSide)
close:SetHitRectInsets(0, 0, 0, 0)
for _, state in ipairs({ { "Normal", "redbutton-exit" }, { "Pushed", "redbutton-exit-pressed" },
{ "Highlight", "redbutton-highlight" } }) do
local e = ForeverUI.AtlasEntry(state[2])
local t = close["Get" .. state[1] .. "Texture"](close)
if not t and e then
close["Set" .. state[1] .. "Texture"](close, e[1])
t = close["Get" .. state[1] .. "Texture"](close)
end
if t then
atlas(t, state[2], true)
t:ClearAllPoints()
t:SetAllPoints(close)
end
end
close:ClearAllPoints()
close:SetPoint("TOPRIGHT", book, "TOPRIGHT", G.closeX, G.closeY)
end
-- maximize / minimize (MaximizeMinimizeButtonFrameTemplate), on the left. It tells the
-- controller to switch mode (SecureHandlerClickTemplate), in combat too.
local size = redButton(book, "redbutton-condense", "redbutton-condense-pressed", "SecureHandlerClickTemplate")
size:SetFrameLevel(book:GetFrameLevel() + 22)
-- Left of the close button, anchored to the book: a secure button anchored to the client's
-- close button would make it protected, and S.raiseCloseButton sets its level in combat too
size:SetPoint("TOPRIGHT", book, "TOPRIGHT", G.closeX - G.redSide, G.closeY)
size:SetAttribute("_onclick", [==[ self:GetFrameRef("ctrl"):SetAttribute("size", 1) ]==])
size:HookScript("OnClick", function() PlaySound("igMainMenuOptionCheckBoxOn") end)
book.size = size
end
local function build()
if S.book or not SpellBookFrame then
return S.book
end
-- WotLK's panel no longer catches the mouse: it is empty
SpellBookFrame:EnableMouse(false)
local book = CreateFrame("Frame", "ForeverUISpellBookFrame", SpellBookFrame)
book:SetPoint("TOP", UIParent, "TOP", 0, G.top)
book:SetHeight(G.height)
book:SetWidth(G.width)
book:EnableMouse(true)
-- NO SetToplevel here: a book raising itself would cover the close button, its sibling.
-- SpellBookFrame is toplevel and raises both.
S.book = book
buildFrame(book)
-- open book: two pages, or one when reduced
local pages = CreateFrame("Frame", "ForeverUISpellBookPages", book)
pages:SetPoint("BOTTOMLEFT", book, "BOTTOMLEFT", 0, G.bookBottom)
pages:SetHeight(G.bookH)
pages:SetWidth(G.bookW)
local left = pages:CreateTexture(nil, "BACKGROUND")
atlas(left, "spellbook-page-left-c60-2x", true)
left:SetPoint("TOPLEFT", pages, "TOPLEFT", 0, 0)
left:SetPoint("BOTTOMRIGHT", pages, "BOTTOM", 0, 0)
local right = pages:CreateTexture(nil, "BACKGROUND")
atlas(right, "spellbook-page-right-c60-2x", true)
right:SetPoint("TOPLEFT", pages, "TOP", 0, 0)
right:SetPoint("BOTTOMRIGHT", pages, "BOTTOMRIGHT", 0, 0)
local singlePage = pages:CreateTexture(nil, "BACKGROUND")
atlas(singlePage, "spellbook-page-right-c60-2x", true)
singlePage:SetAllPoints(pages)
singlePage:Hide()
pages.left, pages.right, pages.singlePage = left, right, singlePage
S.pages = pages
-- category tabs
local tabs = CreateFrame("Frame", nil, pages)
tabs:SetPoint("TOPLEFT", pages, "TOPLEFT", G.tabsX, G.tabsY)
tabs:SetWidth(1)
tabs:SetHeight(G.tabH)
tabs.buttons = {}
S.tabs = tabs
-- the two views; the content turns pages with the mouse wheel through a secure snippet
-- (SecureHandlerMouseWheelTemplate)
local content = CreateFrame("Frame", "ForeverUISpellBookContent", pages, "SecureHandlerMouseWheelTemplate")
content:SetPoint("TOPLEFT", pages, "TOPLEFT", 0, G.pagesTop)
content:SetPoint("BOTTOMRIGHT", pages, "BOTTOMRIGHT", 0, 0)
S.content = content
S.views = {}
S.cells = {}
for v = 1, 2 do
local view = CreateFrame("Frame", "ForeverUISpellBookView" .. v, content)
view:SetWidth(G.viewW)
view:SetHeight(G.viewH)
if v == 1 then
view:SetPoint("TOPLEFT", content, "TOPLEFT", G.view1X, G.viewY)
else
view:SetPoint("TOPRIGHT", content, "TOPRIGHT", G.view2X, G.viewY)
end
view.cells = {}
for n = 1, G.cellsPerView do
local k = (v - 1) * G.cellsPerView + n
view.cells[n] = createCheckbox(view, k)
S.cells[k] = view.cells[n]
end
view.headers = { createHeader(view) }
view.header = view.headers[1]
S.views[v] = view
end
-- paging: "Page n/m", previous, next (right to left); a click tells the controller to
-- turn (SecureHandlerClickTemplate)
local nextPage = CreateFrame("Button", "ForeverUISpellBookNextPage", content, "SecureHandlerClickTemplate")
local prev = CreateFrame("Button", "ForeverUISpellBookPrevPage", content, "SecureHandlerClickTemplate")
for _, f in ipairs({ { nextPage, "NextPage", 1 }, { prev, "PrevPage", -1 } }) do
local b = f[1]
b:SetWidth(G.arrowSide)
b:SetHeight(G.arrowSide)
b:SetNormalTexture(ARROWS .. f[2] .. "-Up")
b:SetPushedTexture(ARROWS .. f[2] .. "-Down")
b:SetDisabledTexture(ARROWS .. f[2] .. "-Disabled")
b:SetHighlightTexture(SQUARE_HOVER)
b:GetHighlightTexture():SetBlendMode("ADD")
b:SetAttribute("_onclick", ([[ self:GetFrameRef("ctrl"):SetAttribute("rotate", %d) ]]):format(f[3]))
b:HookScript("OnClick", function() PlaySound("igAbiliityPageTurn") end)
end
nextPage:SetPoint("BOTTOMRIGHT", content, "BOTTOMRIGHT", G.pagerX, G.pagerY)
prev:SetPoint("RIGHT", nextPage, "LEFT", -G.pagerGap, 0)
local text = content:CreateFontString(nil, "ARTWORK")
font(text, 14)
text:SetPoint("RIGHT", prev, "LEFT", -G.pagerGap, 0)
S.pager = { nextPage = nextPage, prev = prev, text = text }
content:EnableMouseWheel(true)
content:SetAttribute("_onmousewheel", [[ self:GetFrameRef("ctrl"):SetAttribute("rotate", -delta) ]])
-- settings (SpellBookSettingsDropdown): TOPRIGHT (-30, -27) of the book
S.createSettings(pages)
buildFlyout(book)
S.createController(book)
S.suppressWotLK()
S.applySize()
return book
end
S.build = build
-- ------------------------------------------------------------ Settings
-- UIPanelArrowDropdownButtonTemplate: 15 x 16, arrow common-dropdown-a-button at atlas
-- size, centered; on hover the same in ADD at 0.4; pressed, the icon moves by (1, -1). The
-- menu opens TOPLEFT on BOTTOMLEFT (DropdownButton). Entries (camelot's
-- SetupSettingsDropdown): hide passives; group on flyouts (kept in ForeverUIDB, 3.3.5 has no
-- spellBookHideFlyouts; on by default); all ranks, for every class (camelot hides it for
-- rogues and warriors). A click toggles and keeps the menu open.
function S.createSettings(pages)
local b = CreateFrame("Button", "ForeverUISpellBookSettingsButton", pages, "SecureHandlerClickTemplate")
b:SetWidth(G.settingsW)
b:SetHeight(G.settingsH)
b:SetPoint("TOPRIGHT", pages, "TOPRIGHT", G.settingsX, G.settingsY)
local icon = b:CreateTexture(nil, "ARTWORK")
atlas(icon, "common-dropdown-a-button")
icon:SetPoint("CENTER", b, "CENTER", 0, 0)
local hover = b:CreateTexture(nil, "HIGHLIGHT")
atlas(hover, "common-dropdown-a-button")
hover:SetPoint("CENTER", icon, "CENTER", 0, 0)
hover:SetBlendMode("ADD")
hover:SetAlpha(0.4)
b.icon = icon
b:SetScript("OnMouseDown", function() icon:SetPoint("CENTER", b, "CENTER", 1, -1) end)
b:SetScript("OnMouseUp", function() icon:SetPoint("CENTER", b, "CENTER", 0, 0) end)
-- dropdown list
local list = CreateFrame("Frame", "ForeverUISpellBookSettingsList", pages)
list:SetFrameStrata("DIALOG")
list:EnableMouse(true)
list:SetPoint("TOPLEFT", b, "BOTTOMLEFT", 0, 0)
list:Hide()
local slices = ForeverUI.CreateNineSlice(list, "common-dropdown-bg-c60", MENU.backgroundCorner, MENU.backgroundMargins, "BACKGROUND")
for _, t in ipairs(slices or {}) do t:SetAlpha(MENU.backgroundAlpha) end
list.rows = {}
local longest = 0
for i, r in ipairs(SETTINGS) do
local l = CreateFrame("Button", "ForeverUISpellBookSettingsEntry" .. i, list, "SecureHandlerClickTemplate")
l:SetID(r.bit)
l:SetHeight(MENU.rowH)
l:SetPoint("TOPLEFT", list, "TOPLEFT", MENU.rowX, -MENU.edge - (i - 1) * MENU.rowH)
l:SetHighlightTexture("Interface" .. SEP .. "QuestFrame" .. SEP .. "UI-QuestTitleHighlight")
l:GetHighlightTexture():SetBlendMode("ADD")
local text = l:CreateFontString(nil, "ARTWORK", "GameFontHighlightLeft")
text:SetPoint("LEFT", l, "LEFT", MENU.textX, 0)
text:SetText(r.text)
longest = math.max(longest, text:GetStringWidth() or 0)
local checkbox = l:CreateTexture(nil, "BORDER")
ForeverUI.SetAtlas(checkbox, "common-dropdown-ticksquare", true)
checkbox:SetWidth(MENU.checkbox)
checkbox:SetHeight(MENU.checkbox)
checkbox:SetPoint("LEFT", l, "LEFT", 0, 0)
local checkMark = l:CreateTexture(nil, "ARTWORK")
ForeverUI.SetAtlas(checkMark, "common-dropdown-icon-checkmark-yellow", true)
checkMark:SetWidth(MENU.checkMarkW)
checkMark:SetHeight(MENU.checkMarkH)
checkMark:SetPoint("CENTER", checkbox, "CENTER", MENU.checkMarkX, MENU.checkMarkY)
checkMark:Hide()
l.text, l.checkMark, l.setting = text, checkMark, r
-- UIDropDownMenuButton_OnClick, keepShownOnClick: the list stays open; the setting goes
-- through the controller, in combat too
l:SetAttribute("_onclick", [==[ self:GetFrameRef("ctrl"):SetAttribute("setting", self:GetID()) ]==])
l:HookScript("OnClick", function() PlaySound("UChatScrollButton") end)
-- SetupHidePassivesCheckbox: disabled during a search, with a tooltip (the other two have
-- none)
if r.bit == 1 then
l:SetScript("OnEnter", function(self)
if not S.settingsGrayed then return end
GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
GameTooltip:SetText(TEXT.passivesDisabled, 1, 1, 1, 1, 1)
GameTooltip:Show()
end)
l:SetScript("OnLeave", function() GameTooltip:Hide() end)
end
list.rows[i] = l
end
local width = longest + MENU.extraWidth + MENU.margin
list:SetWidth(width)
list:SetHeight(#SETTINGS * MENU.rowH + 2 * MENU.edge)
for _, l in ipairs(list.rows) do l:SetWidth(width - MENU.margin) end
-- the arrow opens or closes the list, in combat too; the list hides itself 2 s after the
-- mouse leaves it
b:SetFrameRef("list", list)
b:SetAttribute("_onclick", ([==[
local l = self:GetFrameRef("list")
if l:IsShown() then
l:Hide()
else
l:Show()
l:RegisterAutoHide(%d)
l:AddToAutoHide(self)
end
]==]):format(MENU.pending))
S.settings, S.settingsList = b, list
return b
end
-- In combat too: grayed during a search (the secure snippet disables the rows; this sets
-- the text color). grayed: true during a search
function S.graySettings(grayed)
if not S.settingsList then return end
S.settingsGrayed = grayed
for _, l in ipairs(S.settingsList.rows) do
-- "Show all spell ranks" stays enabled during a search (camelot disables it)
if grayed and l.setting.bit ~= 4 then
l.text:SetTextColor(0.5, 0.5, 0.5)
else
l.text:SetTextColor(1, 1, 1)
end
end
end
-- In combat too: check marks follow the settings combination (textures only). reg: settings
-- bits
function S.checkSettings(reg)
if not S.settingsList then return end
for _, l in ipairs(S.settingsList.rows) do
local active = math.floor(reg / l.setting.bit) % 2 == 1
if l.setting.inverse then active = not active end
if active then l.checkMark:Show() else l.checkMark:Hide() end
end
end
-- Hide WotLK's regions and frames in SpellBookFrame, except the book and the close button.
function S.suppressWotLK()
for _, region in ipairs({ SpellBookFrame:GetRegions() }) do
suppress(region)
end
for _, child in ipairs({ SpellBookFrame:GetChildren() }) do
if child ~= S.book and child ~= SpellBookCloseButton then
suppress(child)
end
end
end
-- Two pages or one: page and button textures. Widths and the right view are set by the
-- secure "place" snippet; this follows, in combat too. reduced: one page; nil reads the
-- saved setting
function S.applySize(reduced)
local book, pages = S.book, S.pages
if not book then return end
if reduced == nil then reduced = settings().reduced end
if S.onResize then S.onResize(reduced) end
if reduced then
pages.left:Hide() pages.right:Hide() pages.singlePage:Show()
atlas(book.size:GetNormalTexture(), "redbutton-expand", true)
atlas(book.size:GetPushedTexture(), "redbutton-expand-pressed", true)
else
pages.left:Show() pages.right:Show() pages.singlePage:Hide()
atlas(book.size:GetNormalTexture(), "redbutton-condense", true)
atlas(book.size:GetPushedTexture(), "redbutton-condense-pressed", true)
end
end
-- ------------------------------------------------------------ Tabs
-- A tab tells the controller "category n" (SecureHandlerClickTemplate); tabs are created
-- out of combat only.
local function createTab(parent, n)
local b = CreateFrame("Button", "ForeverUISpellBookTab" .. n, parent, "SecureHandlerClickTemplate")
b:SetID(n)
b:SetFrameRef("ctrl", S.ctrl)
b:SetAttribute("_onclick", [[ self:GetFrameRef("ctrl"):SetAttribute("category", self:GetID()) ]])
S.ctrl:SetFrameRef("o" .. n, b)
S.ctrl:Execute(("TABS[%d] = self:GetFrameRef(\"o%d\")"):format(n, n))
b:SetWidth(G.tabW)
b:SetHeight(G.tabH)
local icon = b:CreateTexture(nil, "ARTWORK")
icon:SetWidth(G.tabIconW)
icon:SetHeight(G.tabIconH)
icon:SetPoint("CENTER", b, "CENTER", -1, -1)
-- frame ABOVE the icon (camelot: ARTWORK sublevel 1): a child frame
local hovered = CreateFrame("Frame", nil, b)
hovered:SetAllPoints(b)
hovered:SetFrameLevel(b:GetFrameLevel() + 1)
local frame = hovered:CreateTexture(nil, "ARTWORK")
atlas(frame, "spellbook-tab-frame-c60")
frame:SetPoint("BOTTOM", b, "BOTTOM", 0, 1)
local active = hovered:CreateTexture(nil, "ARTWORK")
atlas(active, "spellbook-tab-frame-glow-c60")
active:SetPoint("BOTTOM", b, "BOTTOM", 0, 1)
local glow = hovered:CreateTexture(nil, "OVERLAY")
atlas(glow, "spellbook-tab-frame-glow-gradient-c60")
glow:SetPoint("BOTTOM", b, "BOTTOM", 0, 0)
b.icon, b.frame, b.active, b.glow = icon, frame, active, glow
b:HookScript("OnClick", function() PlaySound("igSpellBookOpen") end)
b:SetScript("OnEnter", function(self)
GameTooltip:SetOwner(self, "ANCHOR_RIGHT", -12, -6)
GameTooltip:SetText(self.name)
GameTooltip:Show()
end)
b:SetScript("OnLeave", function() GameTooltip:Hide() end)
return b
end
-- out of combat: one tab per category, in place
local function layoutTabs(cats)
local tabs = S.tabs
for i, cat in ipairs(cats) do
local b = tabs.buttons[i]
if not b then
b = createTab(tabs, i)
tabs.buttons[i] = b
end
b.index, b.name = i, cat.name
b.icon:SetTexture(cat.icon)
b:ClearAllPoints()
b:SetPoint("TOPLEFT", tabs, "TOPLEFT", (i - 1) * (G.tabW + G.tabGap), 0)
b:Show()
end
for i = #cats + 1, #tabs.buttons do
tabs.buttons[i]:Hide()
end
end
-- in combat too: the selected tab lights up (textures only; enabling is done by the secure
-- snippet)
local function highlightTabs(selectedItem)
for i, b in ipairs(S.tabs.buttons) do
-- 3.3.5 has no SetShown
if i == selectedItem then
b.frame:Hide() b.active:Show() b.glow:Show()
else
b.frame:Show() b.active:Hide() b.glow:Hide()
end
end
end
-- ------------------------------------------------------------ Filling
S.state = { category = 1, page = 1 }
-- Requests to the controller, out of combat only (tabs, arrows and wheel are secure and work
-- in combat).
function S.choose(index)
if InCombatLockdown() or not S.ctrl then return end
S.ctrl:SetAttribute("category", index)
end
function S.rotate(direction)
if InCombatLockdown() or not S.ctrl then return end
S.ctrl:SetAttribute("rotate", direction)
end
-- The controller. Its restricted environment holds the published data (D: cells of each
-- page, NUM_PAGES: pages of each category, V: spells of each group), the state (SETTINGS,
-- MODE, CAT, PAGE, OPEN_OWNER) and frame handles. Restricted code (RestrictedExecution.lua,
-- RestrictedFrames.lua) allows no braces and no "function" keyword, hence newtable;
-- SetPoint only targets an explicitly protected frame or "$parent".
local DISPLAY = [==[
local list = D[SETTINGS .. ":" .. MODE .. ":" .. CAT .. ":" .. PAGE]
FLYOUT:Hide()
OPEN_OWNER = nil
for k = 1, NUM_CELLS do
CELLS[k]:Hide()
BUTTONS[k]:SetAttribute("flyout", nil)
end
if list then
for i = 1, #list do
local e = list[i]
local c = CELLS[e[1]]
local b = BUTTONS[e[1]]
c:ClearAllPoints()
c:SetPoint("TOPLEFT", "$parent", "TOPLEFT", e[2], e[3])
if e[4] == "" then b:SetAttribute("type1", nil) else b:SetAttribute("type1", e[4]) end
if e[5] == "" then b:SetAttribute("spell", nil) else b:SetAttribute("spell", e[5]) end
if e[6] == "" then
b:SetAttribute("type2", nil)
b:SetAttribute("macrotext2", nil)
else
b:SetAttribute("type2", e[6])
b:SetAttribute("macrotext2", e[7])
end
if e[8] > 0 then b:SetAttribute("flyout", e[8]) end
c:Show()
end
end
for i = 1, NUM_TABS do
if i == CAT then TABS[i]:Disable() else TABS[i]:Enable() end
end
for i = 1, NUM_ENTRIES do
if CAT == 0 and ENTRIES[i]:GetID() ~= 4 then ENTRIES[i]:Disable() else ENTRIES[i]:Enable() end
end
if PAGE > 1 then PREV:Enable() else PREV:Disable() end
if PAGE < NUM_PAGES[SETTINGS .. ":" .. MODE .. ":" .. CAT] then NEXT:Enable() else NEXT:Disable() end
control:CallMethod("ForeverUIVisuals", CAT, PAGE, MODE, SETTINGS)
]==]
local ON_ATTRIBUTE_CHANGED = [==[
if name == "category" and value then
self:SetAttribute("category", nil)
if (value >= 1 and value <= NUM_TABS) or (value == 0 and NUM_PAGES[SETTINGS .. ":" .. MODE .. ":0"]) then
CAT = value
PAGE = 1
control:RunAttribute("show")
end
elseif name == "rotate" and value then
self:SetAttribute("rotate", nil)
local p = PAGE + value
if p >= 1 and p <= NUM_PAGES[SETTINGS .. ":" .. MODE .. ":" .. CAT] then
PAGE = p
control:RunAttribute("show")
end
elseif name == "size" and value then
self:SetAttribute("size", nil)
if MODE == 2 then
MODE = 1
PAGE = PAGE * 2 - 1
else
MODE = 2
PAGE = ceil(PAGE / 2)
end
if PAGE > NUM_PAGES[SETTINGS .. ":" .. MODE .. ":" .. CAT] then PAGE = NUM_PAGES[SETTINGS .. ":" .. MODE .. ":" .. CAT] end
control:RunAttribute("place")
control:RunAttribute("show")
elseif name == "clear" and value then
self:SetAttribute("clear", nil)
if CAT == 0 then
CAT = 1
PAGE = 1
control:RunAttribute("show")
end
elseif name == "setting" and value then
self:SetAttribute("setting", nil)
if floor(SETTINGS / value) % 2 == 1 then SETTINGS = SETTINGS - value else SETTINGS = SETTINGS + value end
if PAGE > NUM_PAGES[SETTINGS .. ":" .. MODE .. ":" .. CAT] then PAGE = NUM_PAGES[SETTINGS .. ":" .. MODE .. ":" .. CAT] end
control:RunAttribute("show")
end
]==]
-- MaximizeMinimizeButtonFrameTemplate: 1618 / 809 wide, two pages or one (right view
-- hidden). %d: widths from G.
local LAYOUT = [==[
if MODE == 1 then
SPELL_BOOK:SetWidth(%d)
PAGES:SetWidth(%d)
VIEW2:Hide()
else
SPELL_BOOK:SetWidth(%d)
PAGES:SetWidth(%d)
VIEW2:Show()
end
]==]
-- SpellFlyoutMixin:Toggle, wrapped around each cell's OnClick: the same group closes the
-- menu, another moves it. A modifier does not open it (link). %d: base width, width per
-- button and x offset, from G.
local OPEN = [==[
local v = self:GetAttribute("flyout")
if v and not IsModifierKeyDown() then
if OPEN_OWNER == self and FLYOUT:IsShown() then
FLYOUT:Hide()
OPEN_OWNER = nil
else
local list = V[v]
local n = #list
for i = 1, NUM_SMALL do
if i <= n then
SMALL[i]:SetAttribute("spell", list[i])
SMALL[i]:Show()
else
SMALL[i]:Hide()
end
end
FLYOUT:SetWidth(%d + n * %d)
FLYOUT:ClearAllPoints()
FLYOUT:SetPoint("LEFT", self, "RIGHT", %d, 0)
FLYOUT:Show()
OPEN_OWNER = self
control:CallMethod("ForeverUIFlyout", self:GetID(), v)
end
end
]==]
-- a small button: the spell is cast, then the menu closes (except for a link)
local SMALL_PRE = [==[ if not IsModifiedClick("CHATLINK") then return nil, "close" end ]==]
local SMALL_POST = [==[ FLYOUT:Hide() OPEN_OWNER = nil ]==]
-- the book hides: the menu too (FlyoutButtonMixin:OnHide)
local ON_HIDE = [==[ FLYOUT:Hide() OPEN_OWNER = nil ]==]
-- Create the secure controller and give it the handles of the cells, buttons, flyout, pages,
-- page arrows and settings rows.
function S.createController(book)
local ctrl = CreateFrame("Frame", "ForeverUISpellBookControl", book, "SecureHandlerAttributeTemplate")
S.ctrl = ctrl
for k, c in ipairs(S.cells) do
ctrl:SetFrameRef("c" .. k, c)
ctrl:SetFrameRef("b" .. k, c.button)
ctrl:WrapScript(c.button, "OnClick", OPEN:format(G.flyoutStart + G.flyoutEnd - G.flyoutGap,
G.smallSide + G.flyoutGap, G.flyoutOffset))
end
ctrl:SetFrameRef("flyout", S.flyout)
ctrl:SetFrameRef("book", book)
ctrl:SetFrameRef("pages", S.pages)
ctrl:SetFrameRef("view2", S.views[2])
book.size:SetFrameRef("ctrl", ctrl)
for i, l in ipairs(S.settingsList.rows) do
l:SetFrameRef("ctrl", ctrl)
ctrl:SetFrameRef("e" .. i, l)
end
ctrl:SetFrameRef("prevPage", S.pager.prev)
ctrl:SetFrameRef("nextPage", S.pager.nextPage)
for _, b in ipairs({ S.pager.prev, S.pager.nextPage, S.content }) do
b:SetFrameRef("ctrl", ctrl)
end
ctrl:Execute(([==[
NUM_CELLS = %d
CELLS = newtable()
BUTTONS = newtable()
for k = 1, NUM_CELLS do
CELLS[k] = self:GetFrameRef("c" .. k)
BUTTONS[k] = self:GetFrameRef("b" .. k)
end
FLYOUT = self:GetFrameRef("flyout")
SPELL_BOOK = self:GetFrameRef("book")
PAGES = self:GetFrameRef("pages")
VIEW2 = self:GetFrameRef("view2")
MODE = 2
SETTINGS = 0
PREV = self:GetFrameRef("prevPage")
NEXT = self:GetFrameRef("nextPage")
TABS = newtable()
SMALL = newtable()
ENTRIES = newtable()
NUM_ENTRIES = %d
for i = 1, NUM_ENTRIES do ENTRIES[i] = self:GetFrameRef("e" .. i) end
NUM_SMALL = 0
D = newtable()
NUM_PAGES = newtable()
V = newtable()
NUM_TABS = 0
CAT = 1
PAGE = 1
]==]):format(#S.cells, #S.settingsList.rows))
ctrl:SetAttribute("show", DISPLAY)
ctrl:SetAttribute("place", LAYOUT:format(G.compactWidth, G.reducedBookW, G.width, G.bookW))
ctrl:SetAttribute("_onattributechanged", ON_ATTRIBUTE_CHANGED)
ctrl:WrapScript(S.content, "OnHide", ON_HIDE)
-- called by CallMethod: ordinary code, allowed in combat
ctrl.ForeverUIVisuals = function(_, cat, page, mode, reg) S.updateVisuals(cat, page, mode, reg) end
ctrl.ForeverUIFlyout = function(_, k, fk) S.flyoutOpenedBy(k, fk) end
return ctrl
end
-- Small flyout buttons: as many as the largest group, created out of combat, each known to
-- the controller. n: number needed
local function ensureSmallButtons(n)
local flyout, ctrl = S.flyout, S.ctrl
for i = #flyout.buttons + 1, n do
local p = createSmallButton(flyout, i)
flyout.buttons[i] = p
ctrl:SetFrameRef("p" .. i, p)
ctrl:WrapScript(p, "OnClick", SMALL_PRE, SMALL_POST)
ctrl:Execute(("SMALL[%d] = self:GetFrameRef(\"p%d\") NUM_SMALL = %d"):format(i, i, i))
end
end
-- quoted string for a secure snippet
local function q(text)
return string.format("%q", text or "")
end
-- Out of combat: lay out each category, publish it to the controller, then show the current
-- state.
function S.update()
local book = S.book
if not book then return end
if InCombatLockdown() then
S.queued = true
return
end
S.queued, S.dirty = nil, nil
local cats = categories()
local e = S.state
if e.category > #cats then e.category = 1 end
-- Search (SpellBookSearch.lua): its results form one more category, 0, without a tab. With
-- no result the search ends (DisplayFullSearchResults -> ClearActiveSearchState).
local search = ForeverUI.SpellBookSearch
if search then search.invalidate() end
local searchGroups = {}
if search and search.active() then
-- one variant per settings set: search follows Hide Passives and Show all spell ranks, and
-- never groups (camelot shows passives and all ranks there, and groups)
local empty = true
for r = 0, 7 do
local g = search.groups(cats, { passives = r % 2 == 1, flyouts = true,
ranks = math.floor(r / 4) % 2 == 1 })
searchGroups[r] = g
if #g > 0 then empty = false end
end
if empty then
search.quit()
searchGroups = {}
end
end
if e.category == 0 and not searchGroups[0] then e.category = 1 end
layoutTabs(cats)
local mode = settings().reduced and 1 or 2
local current = currentOptions()
local reg = (current.passives and 1 or 0) + (current.flyouts and 2 or 0) + (current.ranks and 4 or 0)
-- Everything is published: the eight settings sets (reg, three bits), each in one page (1)
-- or two (2), so settings and resize work in combat. In a set, the views are the same for
-- both modes; only their grouping into pages changes.
S.pageData, S.np, S.flyoutData = {}, {}, {}
local code = { "wipe(D) wipe(NUM_PAGES) wipe(V)" }
local largest = 0
for r = 0, 7 do
local opts = { passives = r % 2 == 1, flyouts = math.floor(r / 2) % 2 == 1, ranks = math.floor(r / 4) % 2 == 1 }
S.pageData[r], S.np[r] = { {}, {} }, { {}, {} }
-- category 0: search results for this settings set
local g = searchGroups[r]
for ci = (g and 0 or 1), #cats do
local cat = cats[ci]
local views
if ci == 0 then
views = paginateGroups(g)
else
views = paginate(cat.name, displayedList(cats, ci, opts))
end
for perPage = 1, 2 do
local nPages = math.max(1, math.ceil(#views / perPage))
S.np[r][perPage][ci] = nPages
S.pageData[r][perPage][ci] = {}
table.insert(code, ('NUM_PAGES["%d:%d:%d"] = %d'):format(r, perPage, ci, nPages))
for page = 1, nPages do
local data = { headers = {}, cells = {} }
S.pageData[r][perPage][ci][page] = data
local entries = {}
for slot = 1, perPage do
local n = 0
for _, el in ipairs(views[(page - 1) * perPage + slot] or {}) do
if el.header then
data.headers[slot] = data.headers[slot] or {}
table.insert(data.headers[slot], el)
else
n = n + 1
local k = (slot - 1) * G.cellsPerView + n
local spell = el.spell
data.cells[k] = spell
-- a group is published once, for both modes
if spell.flyout and not spell.fk then
table.insert(S.flyoutData, spell.members)
spell.fk = #S.flyoutData
local texts = {}
for _, m in ipairs(spell.members) do table.insert(texts, q(S.spellText(m))) end
table.insert(code, ("V[%d] = newtable(%s)"):format(spell.fk, table.concat(texts, ", ")))
largest = math.max(largest, #spell.members)
end
local castable = not (spell.passive or spell.flyout)
table.insert(entries, ("newtable(%d, %.14g, %d, %s, %s, %s, %s, %d)"):format(k,
(el.column - 1) * (G.cellW + G.gapX), -el.y,
q(castable and "spell" or ""), q(castable and S.spellText(spell) or ""),
q(spell.pet and "macro" or ""), q(spell.pet and ("/petautocasttoggle " .. spell.name) or ""),
spell.fk or 0))
end
end
end
table.insert(code, ('D["%d:%d:%d:%d"] = newtable(%s)'):format(r, perPage, ci, page, table.concat(entries, ", ")))
end
end
end
end
ensureSmallButtons(largest)
if e.page > S.np[reg][mode][e.category] then e.page = S.np[reg][mode][e.category] end
-- RestrictedExecution keeps every snippet body it ran, compiled, for the whole session (its
-- factory is weak-keyed, but Lua 5.1 never collects string keys): the published data, a few
-- hundred kilobytes, is sent only when it changes, and the state in a small snippet of its own.
local body = table.concat(code, "\n")
if body ~= S.publishedBody then
S.ctrl:Execute(body)
S.publishedBody = body
end
-- Run "place" on EACH computation: the layout follows the setting even when it arrives
-- later (saved variables load after the file, so after the book is built).
S.ctrl:Execute(("NUM_TABS = %d CAT = %d PAGE = %d MODE = %d SETTINGS = %d control:RunAttribute(\"place\") control:RunAttribute(\"show\")")
:format(#cats, e.category, e.page, mode, reg))
end
-- ForeverUIVisuals: textures and texts for what the secure snippet just placed. Nothing
-- protected: this keeps the book readable in combat.
-- cat: category (0 = search); mode: 1 or 2 pages; reg: settings bits
function S.updateVisuals(cat, page, mode, reg)
local e = S.state
e.category, e.page = cat, page
mode, reg = mode or 2, reg or 0
-- the chosen mode and settings are saved, even when changed in combat (SetCVar is not
-- protected)
settings().reduced = (mode == 1) or nil
settings().hidePassives = (reg % 2 == 1) or nil
settings().noFlyouts = (math.floor(reg / 2) % 2 == 1) or nil
local ranks = math.floor(reg / 4) % 2 == 1
if ranks ~= (GetCVar("ShowAllSpellRanks") == "1") then
SetCVar("ShowAllSpellRanks", ranks and "1" or "0")
end
S.reg = reg
S.checkSettings(reg)
S.applySize(mode == 1)
e.pages = S.np and S.np[reg][mode][cat] or 1
highlightTabs(cat)
local data = S.pageData and S.pageData[reg][mode][cat] and S.pageData[reg][mode][cat][page]
-- headers: one per section of the view (plain frames, created as needed, allowed in
-- combat)
for slot, view in ipairs(S.views) do
local list = data and data.headers[slot] or {}
for i, el in ipairs(list) do
local h = view.headers[i]
if not h then
h = createHeader(view)
view.headers[i] = h
end
h.text:SetText(el.header)
h:ClearAllPoints()
h:SetPoint("TOPLEFT", view, "TOPLEFT", 0, -el.y)
h:Show()
end
for i = #list + 1, #view.headers do view.headers[i]:Hide() end
end
-- settings are grayed during a search (camelot disables them)
S.graySettings(cat == 0)
if S.onVisuals then S.onVisuals(cat) end
for k, c in ipairs(S.cells) do
local spell = data and data.cells[k]
c.spell = spell
if spell then fillCell(c, spell) end
end
S.pager.text:SetText(string.format(TEXT.page, e.page, e.pages))
end
-- state of the shown cells: cooldowns, autocast
function S.updateStates()
if not S.views then return end
if S.flyout and S.flyout:IsShown() then
for _, p in ipairs(S.flyout.buttons) do
if p:IsShown() then updateSmallButton(p) end
end
end
for _, view in ipairs(S.views) do
for _, c in ipairs(view.cells) do
if c:IsShown() and c.spell then S.updateState(c) end
end
end
end
-- ------------------------------------------------------------ Assembly
build()
if S.book then
-- movable by its title; in front when shown or clicked
ForeverUI.WindowStack.makeMovable(S.book, S.book.banner, "spellbook")
ForeverUI.WindowStack.fitToScreen("spellbook", SpellBookFrame, S.book, 0, G.top, G.fitExtraW, G.fitExtraH)
ForeverUI.WindowStack.register("spellbook", SpellBookFrame, function()
return { S.book, _G.ForeverUISpellFlyout, _G.ForeverUISpellBookSettingsList,
_G.ForeverUISpellBookSearchPreview }
end)
-- on each show: the requested book (spells or pet) and its content
SpellBookFrame:HookScript("OnShow", function()
S.suppressWotLK()
S.raiseCloseButton()
-- in combat the book opens as last published
if InCombatLockdown() then return end
if SpellBookFrame.bookType == BOOKTYPE_PET then
local cats = categories()
for i, cat in ipairs(cats) do
if cat.pet then S.state.category = i S.state.page = 1 end
end
elseif S.state.category and categories()[S.state.category] and categories()[S.state.category].pet then
S.state.category = 1
S.state.page = 1
end
S.update()
end)
if hooksecurefunc and SpellBookFrame_Update then
hooksecurefunc("SpellBookFrame_Update", S.suppressWotLK)
end
-- The book must be published BEFORE combat, shown or not, since it can open in combat. A
-- change marks it and the next frame recomputes it (SPELLS_CHANGED comes in bursts);
-- PLAYER_REGEN_DISABLED, the last moment before lockdown, catches what missed its frame.
local watcher = CreateFrame("Frame")
for _, ev in ipairs({ "PLAYER_ENTERING_WORLD", "SPELLS_CHANGED", "LEARNED_SPELL_IN_TAB", "PET_BAR_UPDATE",
"UNIT_PET", "SPELL_UPDATE_COOLDOWN", "CURRENT_SPELL_CAST_CHANGED", "PLAYER_REGEN_DISABLED",
"PLAYER_REGEN_ENABLED", "CVAR_UPDATE" }) do
watcher:RegisterEvent(ev)
end
local function onNextFrame()
watcher:SetScript("OnUpdate", nil)
if S.dirty and not InCombatLockdown() then S.update() end
end
function S.mark()
S.dirty = true
watcher:SetScript("OnUpdate", onNextFrame)
end
watcher:SetScript("OnEvent", function(_, ev, unit)
-- UNIT_PET fires for every party and raid member; only the player's pet changes the book
if ev == "UNIT_PET" and unit ~= "player" then return end
if ev == "SPELL_UPDATE_COOLDOWN" or ev == "CURRENT_SPELL_CAST_CHANGED" then
S.updateStates()
return
end
if ev == "PET_BAR_UPDATE" then S.updateStates() end
if ev == "PLAYER_REGEN_DISABLED" then
if S.dirty then S.update() end
return
end
if ev == "PLAYER_REGEN_ENABLED" then
if S.dirty or S.queued then S.update() end
return
end
S.mark()
end)
end
-- Print the book's size, mode and state to the chat.
ForeverUI.SpellBookDebug = function()
local prefix = "|cff66ccffForeverUI|r "
if not S.book then
DEFAULT_CHAT_FRAME:AddMessage(prefix .. L.SPELLBOOK_DEBUG_NOT_BUILT)
return
end
local cats = categories()
local e = S.state
local names = {}
for _, c in ipairs(cats) do table.insert(names, c.name .. " (" .. c.count .. ")") end
DEFAULT_CHAT_FRAME:AddMessage(prefix .. string.format(
L.SPELLBOOK_DEBUG_STATE,
S.book:GetWidth(), S.book:GetHeight(), settings().reduced and L.SPELLBOOK_DEBUG_ONE_PAGE or L.SPELLBOOK_DEBUG_TWO_PAGES,
e.category, #cats, e.page, e.pages or 1, table.concat(names, ", ")))
end