Files
moon-well-ui/data/addon/ForeverUI/ProfessionsBook.lua
T
Adrestie bce0d7ea0a ForeverUI : déplacer la fenêtre de métier ne fait plus ramer
Pendant le glisser, les onglets latéraux étaient replacés à chaque image
avec leur strate, leur niveau, leur hauteur et leur affichage ; seuls ce
qui change est désormais réappliqué (la position, pendant un glisser).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:06:26 +02:00

1162 lines
41 KiB
Lua

-- Camelot's professions book (ProfessionsFrame.BookPage): the overview page opened by the
-- professions micro-button (BottomBar.lua), and the side tabs of the professions window.
-- 3.3.5 has no GetProfessions: professions come from the skill lines, their spells from the
-- spellbook. Casting is protected, so spell buttons are secure and the book closes in combat.
local ForeverUI = ForeverUI or {}
_G.ForeverUI = ForeverUI
local Tpl = ForeverUI.Templates
local L = ForeverUI.L
local PB = {}
ForeverUI.ProfessionsBook = PB
local SEP = string.char(92)
local FONT = "Fonts" .. SEP .. "FRIZQT__.TTF"
local NUMBER_FONT = "Fonts" .. SEP .. "ARIALN.TTF"
local BUTTONS = "Interface" .. SEP .. "Buttons" .. SEP
local BOOK_ICON = "Interface" .. SEP .. "ForeverUI" .. SEP .. "icons" .. SEP .. "inv_sidetab_professions_c60-rond"
local N = {
window = { 673, 594 },
portrait = { side = 48, x = 1, y = 1.5 },
main = { 664, 142, x = 5, y = -41, gap = 5 },
secondary = { 225, 275, gap = 4, step = -6 },
-- rowLines: most primary rows visible; gutter: room for the scroll bar (8 wide, 17 arrows);
-- trim: art edge kept when a card shrinks; online: gap between spells laid in a line
rowLines = 3,
gutter = 18,
trim = 20,
online = 5,
name = { 20, -24 },
absent = 485,
primarySpells = { x = 15, single = 46, top = 60, down = 10 },
secondarySpells = { x = 20, y = 25 },
spell = { side = 40, mask = 3, text = { 100, 5, 7 }, sub = { 95, 28, -1 } },
primaryRank = { 441, x = -40, offset = -7 },
secondaryRank = { 190, y = -47, offset = -5 },
rank = { h = 18, background = 23, slice = 30, filled = { 441, 18, 2, -3 }, mask = 1, flare = { 53, 16 }, text = -3 },
unlearnButton = { 20, 1, -4 },
secondaryName = -25,
secondaryText = { 175, 5, -13 },
levels = { content = 1, zone = 1, rowLines = 2, columns = 10, closeButton = 22 },
}
-- NORMAL_FONT_COLOR; PASSIVE_SPELL_FONT_COLOR (camelot GlobalColor 0xffc4a300)
local COLORS = {
normal = { 1, 0.82, 0 },
passive = { 0.7686, 0.6392, 0 },
}
-- UIPanelWindows["TradeSkillFrame"] (3.3.5 Blizzard_TradeSkillUI.lua) plus whileDead;
-- width: camelot's professionsFrameWidthOverride (tabs included), also given to TradeSkillFrame
local PANEL_WIDTH = 750
local PANEL = { area = "left", pushable = 3, whileDead = 1, width = PANEL_WIDTH }
-- name: a spell named like the profession (Spell.dbc, SkillLine.dbc), read in the client
-- language; strip: Skillbar_Fill_Flipbook_<strip>; secondary: slot among the three bottom
-- cards; tab: side tab icon (tabicons/), only for professions with a crafting page
local PROFESSIONS = {
alchemy = { name = 2259, strip = "alchemy_c60", tab = "trade_alchemy" },
blacksmithing = { name = 2018, strip = "blacksmithing", tab = "trade_blacksmithing" },
enchanting = { name = 7411, strip = "enchanting_c60", tab = "trade_engraving" },
engineering = { name = 4036, strip = "engineering", tab = "trade_engineering" },
herbalism = { name = 9134, strip = "herbalism" },
inscription = { name = 45357, strip = "inscription", tab = "inv_inscription_tradeskill01" },
jewelcrafting = { name = 25229, strip = "jewelcrafting", tab = "inv_misc_gem_01" },
leatherworking = { name = 2108, strip = "leatherworking", tab = "trade_leatherworking" },
mining = { name = 2575, strip = "mining", tab = "trade_mining" },
skinning = { name = 8613, strip = "skinning_c60" },
tailoring = { name = 3908, strip = "tailoring", tab = "trade_tailoring" },
cooking = { name = 2550, strip = "cooking", secondary = 1, absent = L.PROFESSIONSBOOK_COOKING_MISSING,
tab = "inv_misc_food_15" },
fishing = { name = 7620, strip = "fishing", secondary = 2, absent = L.PROFESSIONSBOOK_FISHING_MISSING },
firstaid = { name = 3273, strip = "firstaid_c60", secondary = 3, absent = L.PROFESSIONSBOOK_FIRST_AID_MISSING,
tab = "spell_holy_sealofsacrifice" },
}
local SECONDARY_PROFESSIONS = { "cooking", "fishing", "firstaid" }
-- Profession spells by group, each group the ranks of one spell (SkillLineAbility.dbc, no
-- recipes or hidden spells), in card order; the first group opens the crafting page.
local SPELLS = {
alchemy = { { 2259, 3101, 3464, 11611, 28596, 51304 } },
blacksmithing = { { 2018, 3100, 3538, 9785, 29844, 51300 } },
enchanting = { { 7411, 7412, 7413, 13920, 28029, 51313 }, { 13262 } },
engineering = { { 4036, 4037, 4038, 12656, 30350, 51306 } },
herbalism = { { 2383, 8387 }, { 55428, 55480, 55500, 55501, 55502, 55503 } },
inscription = { { 45357, 45358, 45359, 45360, 45361, 45363 }, { 51005 } },
jewelcrafting = { { 25229, 25230, 28894, 28895, 28897, 51311 }, { 31252 } },
leatherworking = { { 2108, 3104, 3811, 10662, 32549, 51302 } },
mining = { { 2656 }, { 2580, 8388 } },
skinning = { { 8613, 8617, 8618, 10768, 32678, 50305 } },
tailoring = { { 3908, 3909, 3910, 12180, 26790, 51309 } },
cooking = { { 2550, 3102, 3413, 18260, 33359, 51296 }, { 818 } },
fishing = { { 62734, 7620, 7731, 7732, 18248, 33095, 51294 }, { 43308 } },
firstaid = { { 3273, 3274, 7924, 10846, 27028, 45542 } },
}
local function place(r, ...)
r:ClearAllPoints()
r:SetPoint(...)
end
-- size: true to also apply the atlas size
local function atlas(t, name, size)
return ForeverUI.SetAtlas(t, name, not size)
end
local truthy = Tpl.Truthy
-- camelot fonts missing from 3.3.5 (same objects as TradeSkill.lua)
local function font(name, path, size, outline, shadow, r, g, b)
local p = _G[name] or CreateFont(name)
p:SetFont(path, size, outline or "")
if shadow then
p:SetShadowOffset(1, -1)
p:SetShadowColor(0, 0, 0, 1)
else
p:SetShadowOffset(0, 0)
p:SetShadowColor(0, 0, 0, 0)
end
p:SetTextColor(r or 1, g or 1, b or 1)
return p
end
local FONTS = {
small2 = font("ForeverUIFontHighlightSmall2", FONT, 11), -- GameFontHighlightSmall2
sub = font("ForeverUIFontNewSubSpell", FONT, 10, nil, true, 0.82, 0.7, 0.54), -- NewSubSpellFont
rank = font("ForeverUIFontNumber12Outline", NUMBER_FONT, 12, "OUTLINE"), -- Number12FontOutline
}
-- ---------- Reading
-- Reading expands collapsed skill headers, then collapses them again. Both fire
-- SKILL_LINES_CHANGED, so the book ignores that event for a moment after its own read.
local silence = 0
-- expands every collapsed skill header; returns the set of their names
local function expandHeaders()
local collapsedSet = {}
local i = 1
while i <= (GetNumSkillLines() or 0) do
local name, header, expanded = GetSkillLineInfo(i)
if truthy(header) and not truthy(expanded) then
collapsedSet[name] = true
ExpandSkillHeader(i)
end
i = i + 1
end
return collapsedSet
end
-- profession name in the client language -> profession key
local function professionNames()
local byName = {}
for key, m in pairs(PROFESSIONS) do
local name = GetSpellInfo(m.name)
if name then byName[name] = key end
end
return byName
end
-- the player's professions: primaries as a list (two in camelot, more on a private server),
-- secondaries by key
function PB.Read()
local byName = professionNames()
local collapsedSet = expandHeaders()
local primaries, secondaries = {}, {}
for i = 1, GetNumSkillLines() or 0 do
local name, header, _, rank, _, bonus, maxValue, abandon = GetSkillLineInfo(i)
local key = not truthy(header) and byName[name]
if key then
local m = { key = key, name = name, rank = rank or 0, maxValue = maxValue or 0, bonus = bonus or 0, abandon = truthy(abandon) }
if PROFESSIONS[key].secondary then
secondaries[key] = m
else
primaries[#primaries + 1] = m
end
end
end
if next(collapsedSet) then
for i = GetNumSkillLines() or 0, 1, -1 do
local name, header, expanded = GetSkillLineInfo(i)
if truthy(header) and truthy(expanded) and collapsedSet[name] then
CollapseSkillHeader(i)
end
end
silence = GetTime() + 0.5
end
return primaries, secondaries
end
-- spellbook: spell id -> slot
local function readSpellbook()
local cells = {}
for tab = 1, GetNumSpellTabs() or 0 do
local _, _, offset, count = GetSpellTabInfo(tab)
offset = offset or 0
for slot = offset + 1, offset + (count or 0) do
local link = GetSpellLink(slot, BOOKTYPE_SPELL)
local id = link and tonumber(string.match(link, "spell:(%d+)"))
if id and not cells[id] then cells[id] = slot end
end
end
return cells
end
-- slots of a profession's spells: one per group, the highest known rank;
-- maxValue: most slots returned
local function spellsOf(key, cells, maxValue)
local list = {}
for _, group in ipairs(SPELLS[key]) do
for k = #group, 1, -1 do
local slot = cells[group[k]]
if slot then
list[#list + 1] = slot
break
end
end
if #list >= maxValue then break end
end
return list
end
-- skill line index for AbandonSkill; expands the headers if needed (they stay expanded
-- while the confirmation box is open)
local function indexOf(name)
for attempt = 1, 2 do
for i = 1, GetNumSkillLines() or 0 do
local n, header = GetSkillLineInfo(i)
if n == name and not truthy(header) then return i end
end
if attempt == 1 then expandHeaders() end
end
end
-- ---------- Rank bar
-- three-slice atlas element: both ends at their size, the middle stretched;
-- tip: end width; layer: draw layer
local function threeSlice(host, name, width, height, tip, layer)
local e = ForeverUI.AtlasEntry(name)
if not e then return end
local du = (e[3] - e[2]) * tip / e[6]
local pieces = {
{ e[2], e[2] + du, 0, tip },
{ e[2] + du, e[3] - du, tip, width - 2 * tip },
{ e[3] - du, e[3], width - tip, tip },
}
for _, m in ipairs(pieces) do
local t = host:CreateTexture(nil, layer)
t:SetTexture(e[1])
t:SetTexCoord(m[1], m[2], e[4], e[5])
t:SetWidth(m[4])
t:SetHeight(height)
t:SetPoint("TOPLEFT", host, "TOPLEFT", m[3], 0)
end
end
-- ProfessionsRankBarTemplate. offset: added to the fill width (-7 primary, -5 secondary)
local function createRank(map, width, offset)
local R = N.rank
local r = CreateFrame("Frame", nil, map)
r:SetWidth(width)
r:SetHeight(R.h)
r.fillWidth, r.offset = width, offset
local background = r:CreateTexture(nil, "BACKGROUND")
atlas(background, "profession-progressbar-bg")
background:SetWidth(width)
background:SetHeight(R.background)
background:SetPoint("TOPLEFT", r, "TOPLEFT", 0, 0)
local filled = r:CreateTexture(nil, "BORDER")
filled:SetHeight(R.filled[2])
filled:SetPoint("TOPLEFT", r, "TOPLEFT", R.filled[3] + R.mask, R.filled[4])
local flare = r:CreateTexture(nil, "ARTWORK")
flare:SetWidth(R.flare[1])
flare:SetHeight(R.flare[2])
flare:SetBlendMode("ADD")
threeSlice(r, "profession-progressbar-frame", width, R.background, R.slice, "OVERLAY")
local textFrame = CreateFrame("Frame", nil, r)
textFrame:SetHeight(R.h)
textFrame:SetPoint("LEFT", r, "LEFT", 0, R.text)
textFrame:SetPoint("RIGHT", r, "RIGHT", 0, R.text)
textFrame:SetFrameLevel(r:GetFrameLevel() + 1)
local text = textFrame:CreateFontString(nil, "ARTWORK")
text:SetFontObject(FONTS.rank)
text:SetPoint("CENTER", textFrame, "CENTER", 0, 0)
r.background, r.filled, r.flare, r.text = background, filled, flare, text
return r
end
-- ProfessionsRankBarMixin:Update without animation: text, profession strip (else
-- DefaultBlue) shown over width x ratio + offset, flare at the end
local function updateRank(r, m)
local R = N.rank
if m.bonus > 0 then
r.text:SetFormattedText(L.TRADESKILL_NAME_RANK_MODIFIER, m.name, m.rank, m.bonus, m.maxValue)
else
r.text:SetFormattedText(L.TRADESKILL_NAME_RANK, m.name, m.rank, m.maxValue)
end
local strip = PROFESSIONS[m.key].strip
local e = ForeverUI.AtlasEntry("skillbar_fill_flipbook_" .. strip) or ForeverUI.AtlasEntry("skillbar_fill_flipbook_defaultblue")
local flare = ForeverUI.AtlasEntry("skillbar_flare_" .. strip)
local part = m.maxValue > 0 and math.min(m.rank / m.maxValue, 1) or 0
local found = math.min(R.filled[1] - R.mask, r.fillWidth * part + r.offset)
if e and found >= 1 then
r.filled:SetTexture(e[1])
local du = (e[3] - e[2]) / R.filled[1]
r.filled:SetTexCoord(e[2] + du * R.mask, e[2] + du * (R.mask + found), e[4], e[5])
r.filled:SetWidth(found)
r.filled:Show()
else
r.filled:Hide()
end
-- flare masked as in camelot: its right part, at the visible width (see TradeSkill.lua)
if flare and found >= 1 then
local l = math.min(R.flare[1], found)
local du = (flare[3] - flare[2]) / R.flare[1]
r.flare:SetTexture(flare[1])
r.flare:SetTexCoord(flare[3] - du * l, flare[3], flare[4], flare[5])
r.flare:SetWidth(l)
place(r.flare, "RIGHT", r, "TOPLEFT", R.filled[3] + R.mask + found, R.filled[4] - R.filled[2] / 2)
r.flare:SetAlpha(m.maxValue > 0 and m.rank >= m.maxValue and 0 or 1)
r.flare:Show()
else
r.flare:Hide()
end
end
-- ---------- Spells
local spellCount = 0
-- ProfessionButtonTemplate as a SECURE button (type "spell"): casting is protected
local function createSpell(map)
spellCount = spellCount + 1
local S = N.spell
local name = "ForeverUIProfessionsBookSpell" .. spellCount
local b = CreateFrame("Button", name, map, "SecureActionButtonTemplate")
b:SetWidth(S.side)
b:SetHeight(S.side)
b:RegisterForClicks("LeftButtonUp", "RightButtonUp")
b:RegisterForDrag("LeftButton")
b:SetAttribute("type", "spell")
-- a modified click links the spell into chat instead of casting it
b:SetAttribute("shift-type1", "link")
b:SetAttribute("shift-type2", "link")
local icon = b:CreateTexture(nil, "BORDER")
icon:SetPoint("TOPLEFT", b, "TOPLEFT", S.mask, -S.mask)
icon:SetPoint("BOTTOMRIGHT", b, "BOTTOMRIGHT", -S.mask, S.mask)
local edge = S.mask / S.side
icon:SetTexCoord(edge, 1 - edge, edge, 1 - edge)
local frame = b:CreateTexture(nil, "OVERLAY")
atlas(frame, "profession-square-frame", true)
frame:SetPoint("CENTER", icon, "CENTER", 0, 0)
b:SetPushedTexture(BUTTONS .. "UI-Quickslot-Depress")
b:SetHighlightTexture(BUTTONS .. "ButtonHilight-Square")
b:GetHighlightTexture():SetBlendMode("ADD")
local cooldown = CreateFrame("Cooldown", name .. "Cooldown", b, "CooldownFrameTemplate")
cooldown:SetAllPoints(b)
local text = b:CreateFontString(nil, "BORDER", "GameFontNormal")
text:SetWidth(S.text[1])
text:SetJustifyH("LEFT")
text:SetPoint("LEFT", b, "RIGHT", S.text[2], S.text[3])
local sub = b:CreateFontString(nil, "BORDER")
sub:SetFontObject(FONTS.sub)
sub:SetWidth(S.sub[1])
sub:SetHeight(S.sub[2])
sub:SetJustifyH("LEFT")
sub:SetJustifyV("TOP")
sub:SetPoint("TOPLEFT", text, "BOTTOMLEFT", 0, S.sub[3])
b.icon, b.frame, b.cooldown, b.text, b.sub = icon, frame, cooldown, text, sub
b:SetScript("OnEnter", function(self)
if not self.slot then return end
GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
GameTooltip:SetSpell(self.slot, BOOKTYPE_SPELL)
GameTooltip:Show()
end)
b:SetScript("OnLeave", function()
GameTooltip:Hide()
end)
b:SetScript("OnDragStart", function(self)
if self.slot then PickupSpell(self.slot, BOOKTYPE_SPELL) end
end)
-- link: the trade skill link if any, else the spell link
b:SetScript("PostClick", function(self)
if self.slot and IsModifiedClick("CHATLINK") then
local link, tradeSkillLink = GetSpellLink(self.slot, BOOKTYPE_SPELL)
if tradeSkillLink or link then ChatEdit_InsertLink(tradeSkillLink or link) end
end
end)
b:Hide()
return b
end
local function updateCooldown(b)
local start, duration, active = GetSpellCooldown(b.slot, BOOKTYPE_SPELL)
CooldownFrame_SetTimer(b.cooldown, start or 0, duration or 0, active or 0)
if truthy(active) then
b.icon:SetVertexColor(1, 1, 1)
else
b.icon:SetVertexColor(0.4, 0.4, 0.4)
end
end
-- UpdateButton: icon, name, rank, cooldown; the spell to cast is "Name(Rank)",
-- which SecureActionButton passes to CastSpellByName
local function fillSpell(b, slot)
b.slot = slot
local name, rank = GetSpellName(slot, BOOKTYPE_SPELL)
local passive = IsPassiveSpell(slot, BOOKTYPE_SPELL)
b:GetHighlightTexture():SetTexture(BUTTONS .. (passive and "UI-PassiveHighlight" or "ButtonHilight-Square"))
local c = passive and COLORS.passive or COLORS.normal
b.text:SetTextColor(c[1], c[2], c[3])
b.text:SetText(name)
b.sub:SetText(rank or "")
b.icon:SetTexture(GetSpellTexture(slot, BOOKTYPE_SPELL))
updateCooldown(b)
if rank and rank ~= "" then
b:SetAttribute("spell", name .. "(" .. rank .. ")")
else
b:SetAttribute("spell", name)
end
end
-- ---------- Unlearn
function PB.Unlearn(name)
local i = indexOf(name)
if not i then return end
local box = StaticPopup_Show("UNLEARN_SKILL", name)
if box then box.data = i end
end
local function createUnlearn(map, rank)
local O = N.unlearnButton
local b = CreateFrame("Button", nil, map)
b:SetWidth(O[1])
b:SetHeight(O[1])
b:SetPoint("LEFT", rank, "RIGHT", O[2], O[3])
local icon = b:CreateTexture(nil, "ARTWORK")
atlas(icon, "profession-button-red-crossmark", true)
icon:SetPoint("CENTER", b, "CENTER", 0, 0)
local pressed = b:CreateTexture(nil, "OVERLAY")
atlas(pressed, "profession-button-red-crossmark-pressed", true)
pressed:SetPoint("CENTER", b, "CENTER", 0, 0)
pressed:Hide()
b.icon, b.pressed = icon, pressed
b:SetScript("OnEnter", function(self)
GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
GameTooltip:SetText(UNLEARN_SKILL_TOOLTIP)
GameTooltip:Show()
end)
b:SetScript("OnLeave", function()
GameTooltip:Hide()
end)
b:SetScript("OnMouseDown", function(self) self.pressed:Show() end)
b:SetScript("OnMouseUp", function(self) self.pressed:Hide() end)
b:SetScript("OnClick", function()
if map.profession then PB.Unlearn(map.profession.name) end
end)
return b
end
-- ---------- Cards
-- Card background. At its art size: one piece. Narrower (row beside the scroll bar) or
-- shorter (column under three rows): the art is not squashed. Its left edge (row) or top
-- edge (column) is kept over N.trim, the rest aligns on the opposite edge, and the strip
-- between them is cropped.
local function placeBackground(c, name)
local e = ForeverUI.AtlasEntry(name)
if not e then return false end
c.backgroundAtlas = name
local W, H, B = e[6], e[7], N.trim
local l, h = c:GetWidth(), c:GetHeight()
local du, dv = (e[3] - e[2]) / W, (e[5] - e[4]) / H
local background, edge = c.background, c.backgroundEdge
background:SetTexture(e[1])
edge:SetTexture(e[1])
background:ClearAllPoints()
if l < W then
edge:ClearAllPoints()
edge:SetTexCoord(e[2], e[2] + du * B, e[4], e[5])
edge:SetWidth(B)
edge:SetPoint("TOPLEFT", c, "TOPLEFT", 0, 0)
edge:SetPoint("BOTTOMLEFT", c, "BOTTOMLEFT", 0, 0)
background:SetTexCoord(e[3] - du * (l - B), e[3], e[4], e[5])
background:SetPoint("TOPLEFT", c, "TOPLEFT", B, 0)
background:SetPoint("BOTTOMRIGHT", c, "BOTTOMRIGHT", 0, 0)
edge:Show()
elseif h < H then
edge:ClearAllPoints()
edge:SetTexCoord(e[2], e[3], e[4], e[4] + dv * B)
edge:SetHeight(B)
edge:SetPoint("TOPLEFT", c, "TOPLEFT", 0, 0)
edge:SetPoint("TOPRIGHT", c, "TOPRIGHT", 0, 0)
background:SetTexCoord(e[2], e[3], e[5] - dv * (h - B), e[5])
background:SetPoint("TOPLEFT", c, "TOPLEFT", 0, -B)
background:SetPoint("BOTTOMRIGHT", c, "BOTTOMRIGHT", 0, 0)
edge:Show()
else
-- the hidden edge keeps its last anchors
background:SetTexCoord(e[2], e[3], e[4], e[5])
background:SetAllPoints(c)
edge:Hide()
end
return true
end
local function createBackground(c)
c.background = c:CreateTexture(nil, "BACKGROUND")
c.backgroundEdge = c:CreateTexture(nil, "BACKGROUND")
c.backgroundEdge:Hide()
end
local function createPrimary(parent, n)
local P = N.main
local c = CreateFrame("Frame", nil, parent)
c:SetWidth(P[1])
c:SetHeight(P[2])
c.main = true
createBackground(c)
c.name = c:CreateFontString(nil, "OVERLAY", "GameFontNormal")
c.name:SetJustifyH("LEFT")
c.name:SetPoint("TOPLEFT", c, "TOPLEFT", N.name[1], N.name[2])
c.absentTitle = c:CreateFontString(nil, "OVERLAY", "GameFontNormal")
c.absentTitle:SetJustifyH("LEFT")
c.absentTitle:SetPoint("TOPLEFT", c, "TOPLEFT", N.name[1], N.name[2])
if n == 1 then
c.absentTitle:SetText(L.PROFESSIONSBOOK_FIRST_PROFESSION)
else
c.absentTitle:SetText(L.PROFESSIONSBOOK_SECOND_PROFESSION)
end
c.absentText = c:CreateFontString(nil, "OVERLAY")
c.absentText:SetFontObject(FONTS.small2)
c.absentText:SetWidth(N.absent)
c.absentText:SetJustifyH("LEFT")
c.absentText:SetPoint("CENTER", c, "CENTER", 0, 0)
c.absentText:SetText(L.PROFESSIONSBOOK_MISSING_PROFESSION)
local PR = N.primaryRank
c.rank = createRank(c, PR[1], PR.offset)
c.rank:SetPoint("RIGHT", c, "RIGHT", PR.x, 0)
c.unlearnButton = createUnlearn(c, c.rank)
c.spells = { createSpell(c), createSpell(c) }
return c
end
-- columns are drawn IN FRONT of the rows zone: their level is set before their children
-- are created
local function createSecondary(content, key)
local S = N.secondary
local c = CreateFrame("Frame", nil, content)
c:SetFrameLevel(content:GetFrameLevel() + N.levels.columns)
c:SetWidth(S[1])
c:SetHeight(S[2])
c.key = key
createBackground(c)
placeBackground(c, "profession-overview-card-generic-" .. key)
c.name = c:CreateFontString(nil, "OVERLAY", "GameFontNormal")
c.name:SetJustifyH("LEFT")
c.name:SetPoint("TOP", c, "TOP", 0, N.secondaryName)
c.absentTitle = c:CreateFontString(nil, "OVERLAY", "GameFontNormal")
c.absentTitle:SetJustifyH("LEFT")
c.absentTitle:SetPoint("TOP", c, "TOP", 0, N.secondaryName)
c.absentTitle:SetText(GetSpellInfo(PROFESSIONS[key].name) or "")
local T = N.secondaryText
c.absentText = c:CreateFontString(nil, "OVERLAY")
c.absentText:SetFontObject(FONTS.small2)
c.absentText:SetWidth(T[1])
c.absentText:SetJustifyH("LEFT")
c.absentText:SetJustifyV("TOP")
c.absentText:SetPoint("TOP", c.absentTitle, "BOTTOM", T[2], T[3])
c.absentText:SetText(PROFESSIONS[key].absent)
local RS = N.secondaryRank
c.rank = createRank(c, RS[1], RS.offset)
c.rank:SetPoint("TOP", c, "TOP", 0, RS.y)
c.spells = {}
for k = 1, 4 do
c.spells[k] = createSpell(c)
end
return c
end
-- column spells: stacked from the bottom (camelot); in a line, icons only, when the column
-- has lost a row's height
local function placeSpellColumn(c)
local SS = N.secondarySpells
for k, b in ipairs(c.spells) do
if k == 1 then
place(b, "BOTTOMLEFT", c, "BOTTOMLEFT", SS.x, SS.y)
elseif c.compact then
place(b, "LEFT", c.spells[k - 1], "RIGHT", N.online, 0)
else
place(b, "BOTTOM", c.spells[k - 1], "TOP", 0, 0)
end
Tpl.SetShown(b.text, not c.compact)
Tpl.SetShown(b.sub, not c.compact)
end
end
-- FormatProfession. c: card; m: profession or nil; cells: spellbook slots by spell id
local function formatProfession(c, m, cells)
if c.main then
placeBackground(c, "profession-overview-card")
end
c.profession = m
if not m then
c.absentTitle:Show()
c.absentText:Show()
for _, b in ipairs(c.spells) do
b.slot = nil
b:Hide()
end
c.rank:Hide()
c.name:SetText("")
if c.unlearnButton then c.unlearnButton:Hide() end
return
end
c.absentTitle:Hide()
c.absentText:Hide()
c.name:SetText(m.name)
if c.main then
placeBackground(c, "profession-overview-card-" .. m.key)
Tpl.SetShown(c.unlearnButton, m.abandon)
end
updateRank(c.rank, m)
c.rank:Show()
local slots = spellsOf(m.key, cells, #c.spells)
for k, b in ipairs(c.spells) do
if slots[k] then
fillSpell(b, slots[k])
b:Show()
else
b.slot = nil
b:Hide()
end
end
if c.main then
local PS = N.primarySpells
if #slots == 1 then
place(c.spells[1], "BOTTOMLEFT", c, "BOTTOMLEFT", PS.x, PS.single)
else
place(c.spells[1], "BOTTOMLEFT", c, "BOTTOMLEFT", PS.x, PS.top)
place(c.spells[2], "BOTTOMLEFT", c, "BOTTOMLEFT", PS.x, PS.down)
end
end
end
-- Rows outside the zone are HIDDEN, not just scrolled away: their spells are secure buttons
-- that no click may reach under the columns or above the window. Scrolling moves one whole
-- row at a time.
local function showRowLines(f)
local visibleCount = math.min(f.count, N.rowLines)
local d = PB.offset or 0
for k, c in ipairs(f.primaries) do
Tpl.SetShown(c, k <= f.count and k > d and k <= d + visibleCount)
end
end
-- Layout by row count: at least two (camelot); at most three visible, the columns then
-- losing a row's height; beyond that, the scroll bar and its gutter.
local function layout(f, count)
local P, S = N.main, N.secondary
local step = P[2] - P.gap
local visibleCount = math.min(count, N.rowLines)
local hasBar = count > N.rowLines
local width = P[1] - (hasBar and N.gutter or 0)
for k = #f.primaries + 1, count do
local c = createPrimary(f.child, k)
c:SetPoint("TOPLEFT", f.primaries[k - 1], "BOTTOMLEFT", 0, P.gap)
f.primaries[k] = c
end
for _, c in ipairs(f.primaries) do
c:SetWidth(width)
end
f.count = count
f.zone:SetWidth(width)
f.zone:SetHeight(visibleCount * step + P.gap)
f.child:SetWidth(width)
f.child:SetHeight(count * step + P.gap)
local compact = visibleCount > 2
for _, c in ipairs(f.secondaries) do
c.compact = compact
c:SetHeight(S[2] - (compact and step or 0))
placeBackground(c, c.backgroundAtlas)
placeSpellColumn(c)
end
PB.offset = math.max(0, math.min(PB.offset or 0, count - visibleCount))
f.bar:Configure(count, visibleCount, PB.offset)
f.zone:SetVerticalScroll(PB.offset * step)
showRowLines(f)
end
-- ProfessionsBookFrameMixin:Update. Out of combat only: the spell buttons are protected
-- (and the book is closed in combat).
function PB.Update()
local f = PB.book
if not f or not f:IsShown() or InCombatLockdown() then return end
local primaries, secondaries = PB.Read()
local cells = readSpellbook()
layout(f, math.max(2, #primaries))
for i, c in ipairs(f.primaries) do
if i <= f.count then formatProfession(c, primaries[i], cells) end
end
for _, c in ipairs(f.secondaries) do
formatProfession(c, secondaries[c.key], cells)
end
end
local function updateCooldowns()
local f = PB.book
for _, list in ipairs({ f.primaries, f.secondaries }) do
for _, c in ipairs(list) do
for _, b in ipairs(c.spells) do
if b.slot then updateCooldown(b) end
end
end
end
end
-- ---------- Moving
-- The window moves by its title banner. The book and the crafting page share one saved
-- position (top-center relative to UIParent's). The panel system re-anchors them on every
-- show, so the position is re-applied after it (OnShow, UpdateUIPanelPositions). The tabs
-- follow during the drag. The protected book only moves out of combat.
local POSITION_KEY = "professions"
local function positions()
ForeverUIDB = ForeverUIDB or {}
ForeverUIDB.positions = ForeverUIDB.positions or {}
return ForeverUIDB.positions
end
-- top-center of a frame relative to UIParent's (nil until the frame is placed)
local function topCenter(frame)
local cx, ux = frame:GetCenter(), UIParent:GetCenter()
local top, uiTop = frame:GetTop(), UIParent:GetTop()
if not cx or not ux or not top or not uiTop then return end
return cx - ux, top - uiTop
end
local function isLocked(f)
return f == PB.book and InCombatLockdown()
end
function PB.Reposition(f)
local p = positions()[POSITION_KEY]
if not p or not f or not f:IsShown() or isLocked(f) then return end
f:ClearAllPoints()
f:SetPoint("TOP", UIParent, "TOP", p.x, p.y)
end
local function makeMovable(f, handle)
f:SetMovable(true)
f:SetClampedToScreen(true)
handle:EnableMouse(true)
handle:RegisterForDrag("LeftButton")
handle:SetScript("OnDragStart", function()
if isLocked(f) then return end
f:StartMoving()
handle:SetScript("OnUpdate", function() PB.PlaceTabs() end)
end)
handle:SetScript("OnDragStop", function()
handle:SetScript("OnUpdate", nil)
if isLocked(f) then return end
f:StopMovingOrSizing()
local x, y = topCenter(f)
if x then
f:ClearAllPoints()
f:SetPoint("TOP", UIParent, "TOP", x, y)
-- we save the position ourselves: the client must not save it too
if f.SetUserPlaced then f:SetUserPlaced(false) end
positions()[POSITION_KEY] = { x = x, y = y }
end
PB.PlaceTabs()
end)
end
-- ---------- Window
function PB.IsOpen()
return (PB.book and PB.book:IsShown()) or (TradeSkillFrame and TradeSkillFrame:IsShown()) or false
end
local function updateMicroButton()
if ForeverUI.UpdateProfessionsMicro then
ForeverUI.UpdateProfessionsMicro(PB.IsOpen())
end
end
-- a page opened or closed: update the micro-button and the tabs
local function refresh()
updateMicroButton()
if PB.UpdateTabs then PB.UpdateTabs() end
end
local OPEN_EVENTS = { "SKILL_LINES_CHANGED", "SPELLS_CHANGED", "SPELL_UPDATE_COOLDOWN" }
function PB.Build()
if PB.book then return PB.book end
local f = CreateFrame("Frame", "ForeverUIProfessionsBook", UIParent)
f:SetWidth(N.window[1])
f:SetHeight(N.window[2])
f:SetPoint("TOPLEFT", UIParent, "TOPLEFT", 0, -104)
f:SetToplevel(true)
f:EnableMouse(true)
f:Hide()
-- panel declared by attributes (GetUIPanelWindowInfo reads them before UIPanelWindows),
-- set before the secure buttons are created
for k, v in pairs(PANEL) do
f:SetAttribute("UIPanelLayout-" .. k, v)
end
f:SetAttribute("UIPanelLayout-defined", true)
f:SetAttribute("UIPanelLayout-enabled", true)
local skin = Tpl.PortraitWindow(f, {
portrait = BOOK_ICON, portraitSide = N.portrait.side, portraitX = N.portrait.x, portraitY = N.portrait.y,
title = string.format(TRADE_SKILL_TITLE, TRADE_SKILLS),
})
-- the portrait is baked round (tools/bake_portrait.py; SetPortraitToTexture leaves it
-- square): use it whole
skin.portrait:SetTexCoord(0, 1, 0, 1)
-- OverrideArt: Profession-Background-Overview instead of the rock, no stripes
if skin.rock.SetHorizTile then
skin.rock:SetHorizTile(false)
skin.rock:SetVertTile(false)
end
atlas(skin.rock, "profession-background-overview", true)
skin.stripes:Hide()
f.skin = skin
makeMovable(f, skin.banner)
local content = CreateFrame("Frame", "ForeverUIProfessionsBookContent", f)
content:SetAllPoints(f)
content:SetFrameLevel(f:GetFrameLevel() + N.levels.content)
f.content = content
-- rows zone: anything past its three rows is clipped
local P, S, NV = N.main, N.secondary, N.levels
local zone = CreateFrame("ScrollFrame", "ForeverUIProfessionsBookScroll", content)
zone:SetFrameLevel(content:GetFrameLevel() + NV.zone)
zone:SetPoint("TOPLEFT", content, "TOPLEFT", P.x, P.y)
local child = CreateFrame("Frame", "ForeverUIProfessionsBookRows", zone)
child:SetFrameLevel(content:GetFrameLevel() + NV.rowLines)
child:SetWidth(P[1])
child:SetHeight(1)
zone:SetScrollChild(child)
f.zone, f.child = zone, child
-- MinimalScrollBar against the zone; one wheel step scrolls one row
local bar = ForeverUI.CreateScrollBar("ForeverUIProfessionsBookScrollBar", content, zone)
bar:SetFrameLevel(content:GetFrameLevel() + NV.zone)
bar.onScroll = function(step)
PB.offset = step
zone:SetVerticalScroll(step * (P[2] - P.gap))
showRowLines(f)
end
bar:Hide()
f.bar = bar
zone:EnableMouseWheel(true)
zone:SetScript("OnMouseWheel", function(_, direction)
if bar:IsShown() then bar:MoveTo(bar.offset - direction) end
end)
-- cards
f.primaries = { createPrimary(child, 1), createPrimary(child, 2) }
f.primaries[1]:SetPoint("TOPLEFT", child, "TOPLEFT", 0, 0)
f.primaries[2]:SetPoint("TOPLEFT", f.primaries[1], "BOTTOMLEFT", 0, P.gap)
f.secondaries = {}
for k, key in ipairs(SECONDARY_PROFESSIONS) do
local c = createSecondary(content, key)
if k == 1 then
c:SetPoint("TOPLEFT", zone, "BOTTOMLEFT", 0, S.gap)
else
c:SetPoint("TOPLEFT", f.secondaries[k - 1], "TOPRIGHT", S.step, 0)
end
f.secondaries[k] = c
end
layout(f, 2)
-- close button (UIPanelCloseButton: HideParentPanel)
local closeButton = CreateFrame("Button", "ForeverUIProfessionsBookCloseButton", f, "UIPanelCloseButton")
Tpl.CloseButton(closeButton, f)
closeButton:SetFrameLevel(f:GetFrameLevel() + N.levels.closeButton)
f.closeButton = closeButton
f:SetScript("OnShow", function(self)
PB.Reposition(self)
for _, ev in ipairs(OPEN_EVENTS) do self:RegisterEvent(ev) end
PB.Update()
PlaySound("igSpellBookOpen")
refresh()
end)
f:SetScript("OnHide", function(self)
for _, ev in ipairs(OPEN_EVENTS) do self:UnregisterEvent(ev) end
StaticPopup_Hide("UNLEARN_SKILL")
PlaySound("igAbilityClose")
refresh()
end)
f:SetScript("OnEvent", function(_, ev)
if ev == "SPELL_UPDATE_COOLDOWN" then
updateCooldowns()
elseif ev ~= "SKILL_LINES_CHANGED" or GetTime() >= silence then
PB.Update()
end
end)
PB.book = f
return f
end
-- ---------- Side tabs
-- Side tabs (camelot ProfessionsLargeRightTabMixin, LargeSideTabButtonTemplate) right of the
-- shown page, book or crafting: overview, then primaries, first aid, fishing, cooking (only
-- those with a crafting page). A profession tab casts a spell: it is secure and its parent
-- becomes protected, so the tabs live in their own UIParent child, never a child of or
-- anchored to TradeSkillFrame; they follow it on UpdateUIPanelPositions and hide in combat.
-- Icon at (-3, 0) like the other side tabs (camelot: -4). Too many tabs for the window
-- height shrink together so the last one ends at the window bottom.
local TAB = { side = 55, y = -60, gap = -2, icon = 50, iconX = -3, crop = 0.03125 }
local TAB_ICONS = "Interface" .. SEP .. "ForeverUI" .. SEP .. "tabicons" .. SEP
local SECONDARY_TABS = { "firstaid", "fishing", "cooking" }
-- LargeSideTabButtonTemplate; secure: casts a spell (SecureActionButtonTemplate)
local function createTab(parent, name, secure)
local O = TAB
local b = CreateFrame("Button", name, parent, secure and "SecureActionButtonTemplate" or nil)
b:SetWidth(O.side)
b:SetHeight(O.side)
b:RegisterForClicks("LeftButtonUp")
local background = b:CreateTexture(nil, "BACKGROUND")
atlas(background, "common-sidetab")
background:SetAllPoints(b)
local icon = b:CreateTexture(nil, "ARTWORK")
icon:SetWidth(O.icon)
icon:SetHeight(O.icon)
icon:SetPoint("CENTER", b, "CENTER", O.iconX, 0)
icon:SetTexCoord(O.crop, 1 - O.crop, O.crop, 1 - O.crop)
b.iconX = O.iconX
local selected = b:CreateTexture(nil, "OVERLAY")
atlas(selected, "common-sidetab-selected")
selected:SetAllPoints(b)
selected:Hide()
local hover = b:CreateTexture(nil, "HIGHLIGHT")
atlas(hover, "common-sidetab-hover")
hover:SetAllPoints(b)
b.icon, b.selected = icon, selected
b:SetScript("OnEnter", function(self)
if not self.tooltip then return end
GameTooltip:SetOwner(self, "ANCHOR_RIGHT", -4, -4)
GameTooltip:SetText(self.tooltip)
GameTooltip:Show()
end)
b:SetScript("OnLeave", function() GameTooltip:Hide() end)
b:SetScript("OnMouseDown", function(self, button)
if button == "LeftButton" then place(self.icon, "CENTER", self, "CENTER", self.iconX + 1, -1) end
end)
b:SetScript("OnMouseUp", function(self, button)
if button == "LeftButton" then
place(self.icon, "CENTER", self, "CENTER", self.iconX, 0)
PlaySound("igCharacterInfoTab")
end
end)
return b
end
-- SelectBookPage: close the crafting page (the 3.3.5 trade skill session ends with it),
-- then open the book
function PB.OpenOverview()
if InCombatLockdown() or (PB.book and PB.book:IsShown()) then return end
if TradeSkillFrame and TradeSkillFrame:IsShown() then HideUIPanel(TradeSkillFrame) end
ShowUIPanel(PB.Build())
end
local function createTabs()
local c = CreateFrame("Frame", "ForeverUIProfessionsTabs", UIParent)
c:SetWidth(TAB.side)
c:SetHeight(1)
c:Hide()
local overview = createTab(c, "ForeverUIProfessionsTab0")
overview.icon:SetTexture(TAB_ICONS .. "inv_sidetab_professions_c60")
overview.tooltip = TRADE_SKILLS
overview:SetPoint("TOPLEFT", c, "TOPLEFT", 0, TAB.y)
overview:SetScript("OnClick", PB.OpenOverview)
c.overview, c.professions, c.count = overview, {}, 0
PB.tabs = c
return c
end
local function professionTab(c, k)
if not c.professions[k] then
local b = createTab(c, "ForeverUIProfessionsTab" .. k, true)
b:SetPoint("TOPLEFT", k == 1 and c.overview or c.professions[k - 1], "BOTTOMLEFT", 0, TAB.gap)
c.professions[k] = b
end
return c.professions[k]
end
-- spellbook slot of the spell that opens a profession's crafting page (first group)
local function opener(key, cells)
local group = SPELLS[key][1]
for k = #group, 1, -1 do
if cells[group[k]] then return cells[group[k]] end
end
end
local function shownWindow()
if PB.book and PB.book:IsShown() then return PB.book end
if TradeSkillFrame and TradeSkillFrame:IsShown() then return TradeSkillFrame end
end
-- tab scale from their count and the window height; recomputed only when either changes
-- (PlaceTabs runs every frame during a drag)
local function resize(c, height)
local O = TAB
local n = c.count + 1
local full = n * O.side - (n - 1) * O.gap
local position = height + O.y
local e = (position > 0 and full > position) and position / full or 1
if e == c.scale and #c.professions == c.sizedCount then return e end
c.scale, c.sizedCount = e, #c.professions
local list = { c.overview }
for k = 1, #c.professions do list[k + 1] = c.professions[k] end
for k, b in ipairs(list) do
b:SetWidth(O.side * e)
b:SetHeight(O.side * e)
b.iconX = O.iconX * e
b.icon:SetWidth(O.icon * e)
b.icon:SetHeight(O.icon * e)
place(b.icon, "CENTER", b, "CENTER", b.iconX, 0)
if k > 1 then place(b, "TOPLEFT", list[k - 1], "BOTTOMLEFT", 0, O.gap * e) end
end
return e
end
-- right of the shown window, in its strata, under its metal border
function PB.PlaceTabs()
local c = PB.tabs
if not c or InCombatLockdown() then return end
local f = shownWindow()
local right, top = f and f:GetRight(), f and f:GetTop()
if not right or not top then
c:Hide()
return
end
local e = resize(c, f:GetHeight() or 0)
c:ClearAllPoints()
c:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", right, top)
-- this runs every frame during a drag, where only the position moves: height, strata,
-- level and visibility are set only when they change
local height = -TAB.y + (c.count + 1) * (TAB.side - TAB.gap) * e
if math.abs((c:GetHeight() or 0) - height) > 0.01 then c:SetHeight(height) end
local strata, level = f:GetFrameStrata(), f:GetFrameLevel() + 1
if c:GetFrameStrata() ~= strata then c:SetFrameStrata(strata) end
if c:GetFrameLevel() ~= level then c:SetFrameLevel(level) end
if not c:IsShown() then c:Show() end
end
-- RefreshRightTabs and RightTabSelected. Out of combat only (secure buttons).
function PB.UpdateTabs()
if InCombatLockdown() then return end
local f = shownWindow()
if not f then
if PB.tabs then PB.tabs:Hide() end
return
end
local c = PB.tabs or createTabs()
local primaries, secondaries = PB.Read()
local cells = readSpellbook()
local list = {}
local function add(m)
local slot = m and PROFESSIONS[m.key].tab and opener(m.key, cells)
if slot then list[#list + 1] = { m = m, slot = slot } end
end
for _, m in ipairs(primaries) do add(m) end
for _, key in ipairs(SECONDARY_TABS) do add(secondaries[key]) end
-- the open profession: the crafting page's, unless it is a linked one
local isOpen
if f == TradeSkillFrame and not truthy(IsTradeSkillLinked()) then
isOpen = professionNames()[GetTradeSkillLine() or ""]
end
Tpl.SetShown(c.overview.selected, f == PB.book)
for k, e in ipairs(list) do
local b = professionTab(c, k)
local name, rank = GetSpellName(e.slot, BOOKTYPE_SPELL)
local selected = e.m.key == isOpen
b.key = e.m.key
b.icon:SetTexture(TAB_ICONS .. PROFESSIONS[e.m.key].tab)
b.tooltip = e.m.name
Tpl.SetShown(b.selected, selected)
if selected then
b:SetAttribute("type", nil)
else
b:SetAttribute("type", "spell")
end
if rank and rank ~= "" then
b:SetAttribute("spell", name .. "(" .. rank .. ")")
else
b:SetAttribute("spell", name)
end
b:Show()
end
for k = #list + 1, #c.professions do c.professions[k]:Hide() end
c.count = #list
PB.PlaceTabs()
end
-- ToggleProfessionsBook: close the book or the open crafting page, else open the book
-- (out of combat only)
function PB.Toggle()
if PB.book and PB.book:IsShown() then
HideUIPanel(PB.book)
elseif TradeSkillFrame and TradeSkillFrame:IsShown() then
HideUIPanel(TradeSkillFrame)
elseif InCombatLockdown() then
UIErrorsFrame:AddMessage(ERR_NOT_IN_COMBAT, 1.0, 0.1, 0.1, 1.0)
else
ShowUIPanel(PB.Build())
end
end
-- The crafting page replaces the book and moves like it. Entering combat closes the book
-- and hides the tabs (PLAYER_REGEN_DISABLED fires BEFORE the combat lockdown); leaving
-- combat shows them again. The micro-button and tabs follow the crafting page, the tabs
-- also follow panel placement; the crafting page keeps room for the tabs (camelot width).
local watcher = CreateFrame("Frame")
PB.watcher = watcher
local function hookTradeSkill()
if TradeSkillFrame and not watcher.hooked then
watcher.hooked = true
TradeSkillFrame:SetAttribute("UIPanelLayout-width", PANEL_WIDTH)
if TradeSkillFrame.foreverSkin then
makeMovable(TradeSkillFrame, TradeSkillFrame.foreverSkin.banner)
end
TradeSkillFrame:HookScript("OnShow", function()
PB.Reposition(TradeSkillFrame)
refresh()
end)
TradeSkillFrame:HookScript("OnHide", refresh)
end
end
for _, ev in ipairs({ "ADDON_LOADED", "TRADE_SKILL_SHOW", "PLAYER_REGEN_DISABLED", "PLAYER_REGEN_ENABLED",
"SKILL_LINES_CHANGED", "SPELLS_CHANGED", "DISPLAY_SIZE_CHANGED", "UI_SCALE_CHANGED" }) do
watcher:RegisterEvent(ev)
end
watcher:SetScript("OnEvent", function(_, ev, name)
if ev == "ADDON_LOADED" then
if name == "Blizzard_TradeSkillUI" then hookTradeSkill() end
elseif ev == "PLAYER_REGEN_DISABLED" then
if PB.book and PB.book:IsShown() then HideUIPanel(PB.book) end
if PB.tabs then PB.tabs:Hide() end
elseif ev == "TRADE_SKILL_SHOW" then
if PB.book and PB.book:IsShown() then HideUIPanel(PB.book) end
PB.UpdateTabs()
elseif ev ~= "SKILL_LINES_CHANGED" or GetTime() >= silence then
PB.UpdateTabs()
end
end)
hookTradeSkill()
-- after the panel system: re-apply the saved position, then place the tabs
if hooksecurefunc then
hooksecurefunc("UpdateUIPanelPositions", function()
PB.Reposition(PB.book)
if TradeSkillFrame then PB.Reposition(TradeSkillFrame) end
PB.PlaceTabs()
end)
end