diff --git a/Constants.lua b/Constants.lua index 7b88d36..bb739e2 100644 --- a/Constants.lua +++ b/Constants.lua @@ -8,7 +8,7 @@ local Constants = {} addon.Constants = Constants Constants.TITLEBAR_HEIGHT = 30 -Constants.TABLE_ROW_HEIGHT = 24 +Constants.TABLE_ROW_HEIGHT = 30 Constants.TABLE_HEADER_HEIGHT = 32 Constants.TABLE_CELL_PADDING = 8 Constants.MAX_WINDOW_HEIGHT = 500 diff --git a/Core.lua b/Core.lua index 9a92a93..a684f4d 100644 --- a/Core.lua +++ b/Core.lua @@ -180,6 +180,26 @@ function Core:OnEnable() self:Render() end ) + self:RegisterBucketEvent( + { "PROFESSION_EQUIPMENT_CHANGED" }, + 2, + function() + -- Note: ClearProgressCache() is intentionally omitted -- profGearScore + -- is not stored in progressCache, so clearing it would be a no-op here. + Data:ScanProfessionEquipment() + self:Render() + end + ) + self:RegisterBucketEvent( + { "GET_ITEM_INFO_RECEIVED" }, + 1, + function() + if Data:NeedsProfessionEquipmentRescan() then + Data:ScanProfessionEquipment() + self:Render() + end + end + ) Data:ScanAll() self:Render() diff --git a/Data.lua b/Data.lua index 6cf85f3..9907a2b 100644 --- a/Data.lua +++ b/Data.lua @@ -494,6 +494,28 @@ function Data:DeleteCharacter(characterOrGUID) self:ClearProgressCache(true) end +local PROF_GEAR_ILVL_TO_RANK = { + [2] = { [180]=1, [186]=2, [192]=3, [199]=4, [206]=5 }, -- Uncommon (green) + [3] = { [206]=1, [212]=2, [218]=3, [225]=4, [232]=5 }, -- Rare (blue) + [4] = { [232]=1, [238]=2, [244]=3, [251]=4, [258]=5 }, -- Epic (purple) +} + +local PROF_GEAR_TIER_OFFSET = { [2]=0, [3]=5, [4]=10 } + +--- Returns score (1-15) and craftingRank (1-5), or 0, 0 if unrecognised. +local function ComputeSlotScore(itemQuality, itemLevel) + local rankTable = PROF_GEAR_ILVL_TO_RANK[itemQuality] + if not rankTable then return 0, 0 end + local rank = rankTable[itemLevel] + if not rank then return 0, 0 end + return PROF_GEAR_TIER_OFFSET[itemQuality] + rank, rank +end + +local PROF_SLOT_GROUPS = { + { prefix = "PROF0", slots = {"TOOLSLOT", "GEAR0SLOT", "GEAR1SLOT"} }, + { prefix = "PROF1", slots = {"TOOLSLOT", "GEAR0SLOT", "GEAR1SLOT"} }, +} + ---Update the current character. function Data:ScanAll() self:ScanCharacterInfo() @@ -502,6 +524,108 @@ function Data:ScanAll() self:ScanQuests() self:ScanProfessions() self:ScanCalendar() + self:ScanProfessionEquipment() +end + +--- Returns true if any profession has unscanned or pending gear slots. +function Data:NeedsProfessionEquipmentRescan() + local character = self:GetCharacter() + if not character then return false end + for _, cp in ipairs(character.professions or {}) do + if cp.equipment == nil then return true end + for i = 1, 3 do + if cp.equipment[i] and cp.equipment[i].pending then return true end + end + end + return false +end + +--- Scan profession gear slots for the current character and store results in AceDB. +function Data:ScanProfessionEquipment() + if self:IsInChatMessagingLockdown() then return end + if InCombatLockdown and InCombatLockdown() then return end + local character = self:GetCharacter() + if not character then return end + + local professionIndex1, professionIndex2 = GetProfessions() + local professionIndexes = { professionIndex1, professionIndex2 } + + for _, group in ipairs(PROF_SLOT_GROUPS) do + -- Determine which profession owns this slot group by matching the tool slot item's + -- subType (e.g. "Enchanting") against GetProfessionInfo name (position 1). + -- GetProfessions() ordering does not reliably map to PROF0/PROF1 slot ordering. + local toolSlotID = GetInventorySlotInfo(group.prefix .. "TOOLSLOT") + local toolLink = GetInventoryItemLink("player", toolSlotID) + local toolSubType = toolLink and select(7, C_Item.GetItemInfo(toolLink)) + + local skillLineID + for _, professionIndex in ipairs(professionIndexes) do + if professionIndex then + local profName, _, _, _, _, _, slID = GetProfessionInfo(professionIndex) + if profName and toolSubType and profName == toolSubType then + skillLineID = slID + break + end + end + end + + -- If skillLineID is still nil (no tool equipped or tool data uncached), skip this + -- slot group entirely. No fallback to index-based mapping to avoid writing gear + -- to the wrong profession row. + + if skillLineID then + -- Multiple characterProfessions can share the same skillLineID (one per expansion). + -- The same physical gear slots apply to all of them, so write equipment to all matches. + local matchingProfessions = Utils:TableFilter(character.professions, function(cp) + local variant = self:GetSkillLineVariantByID(cp.skillLineVariantID) + return variant and variant.skillLineID == skillLineID + end) + + if #matchingProfessions > 0 then + local equipment = {} + local totalScore = 0 + + for slotIndex, slotSuffix in ipairs(group.slots) do + local slotName = group.prefix .. slotSuffix + local slotID = GetInventorySlotInfo(slotName) + local itemLink = GetInventoryItemLink("player", slotID) + + if itemLink then + -- C_Item.GetItemInfo return order (confirmed in-game): + -- 1=name, 2=link, 3=quality, 4=itemLevel, 5=requiredLevel, + -- 6=type, 7=subType, 8=stackCount, 9=equipLoc, 10=icon, ... + local name, _, quality, itemLevel, _, _, _, _, _, icon, _, _, _, _, itemExpansionID = + C_Item.GetItemInfo(itemLink) + if name then + local score, rank = ComputeSlotScore(quality, itemLevel) + equipment[slotIndex] = { + itemLink = itemLink, + itemQuality = quality, + itemLevel = itemLevel, + iconFileID = icon, + score = score, + craftingRank = rank, + itemExpansionID = itemExpansionID, + } + totalScore = totalScore + score + else + -- Item data not yet cached; store sentinel so GET_ITEM_INFO_RECEIVED + -- can distinguish this from a genuinely empty slot. + equipment[slotIndex] = { itemLink = itemLink, pending = true } + end + end + -- nil entry in equipment[slotIndex] = scanned, slot genuinely empty + end + + -- Write the same gear data to all expansion rows for this profession + Utils:TableForEach(matchingProfessions, function(cp) + cp.equipment = equipment + cp.profGearScore = totalScore + end) + end + -- if no matches: profession not in DB yet; equipment stays nil (unscanned) + end + end end --- Scan currencies for a character. diff --git a/Main.lua b/Main.lua index a87d826..a51c70a 100644 --- a/Main.lua +++ b/Main.lua @@ -44,6 +44,73 @@ function Main:ToggleWindow() self:Render() end +local EMPTY_SLOT_TEXTURE = 4760248 -- confirmed in-game via GetInventorySlotInfo [2] +local GEAR_ICON_SIZE = 24 + +local function SlotBelongsToExpansion(slot, expansionID) + return slot.itemExpansionID == expansionID +end + +local function GearCellIcons(characterProfession, skillLineVariantID) + local function onLeave() GameTooltip:Hide() end + + if characterProfession.equipment == nil then + -- Never scanned: 3 dimmed empty slot icons with no overlay + local onEnter = function(frame) + GameTooltip:SetOwner(frame, "ANCHOR_RIGHT") + GameTooltip:SetText("Not yet scanned", 1, 1, 1) + GameTooltip:AddLine("Log in on this character to scan their gear.", nil, nil, nil, true) + GameTooltip:Show() + end + return { + { iconFileID = EMPTY_SLOT_TEXTURE, unscanned = true, size = GEAR_ICON_SIZE, onEnter = onEnter, onLeave = onLeave }, + { iconFileID = EMPTY_SLOT_TEXTURE, unscanned = true, size = GEAR_ICON_SIZE, onEnter = onEnter, onLeave = onLeave }, + { iconFileID = EMPTY_SLOT_TEXTURE, unscanned = true, size = GEAR_ICON_SIZE, onEnter = onEnter, onLeave = onLeave }, + } + end + + local variant = Data:GetSkillLineVariantByID(skillLineVariantID) + local expansionID = variant and variant.expansionID + + local icons = {} + for i = 1, 3 do + local slot = characterProfession.equipment[i] + local slotBelongs = slot and not slot.pending and expansionID and SlotBelongsToExpansion(slot, expansionID) + if slotBelongs then + -- Item equipped and belongs to this expansion: full brightness icon + quality star overlay + local itemLink = slot.itemLink + local qualityColor = ITEM_QUALITY_COLORS[slot.itemQuality] + icons[i] = { + iconFileID = slot.iconFileID, + overlayAtlas = slot.craftingRank > 0 and ("Professions-ChatIcon-Quality-Tier" .. slot.craftingRank) or nil, + borderColor = qualityColor and {r = qualityColor.r, g = qualityColor.g, b = qualityColor.b}, + size = GEAR_ICON_SIZE, + onEnter = function(frame) + GameTooltip:SetOwner(frame, "ANCHOR_RIGHT") + GameTooltip:SetHyperlink(itemLink) + GameTooltip:Show() + end, + onLeave = onLeave, + } + else + -- Scanned empty or wrong expansion gear; pending = item data still loading + local isPending = slot and slot.pending + icons[i] = { + iconFileID = EMPTY_SLOT_TEXTURE, + size = GEAR_ICON_SIZE, + onEnter = function(frame) + GameTooltip:SetOwner(frame, "ANCHOR_RIGHT") + GameTooltip:SetText(isPending and "Loading..." or "Empty", 1, 1, 1) + GameTooltip:Show() + end, + onLeave = onLeave, + } + end + end + return icons +end + + function Main:Render() local selectedExpansions = Data.db.global.main.selectedExpansions or {} local expansions = Data:GetExpansions() @@ -1039,6 +1106,39 @@ function Main:GetTableColumns(unfiltered) end, }, }, + { + id = "gear", + headerText = "Gear", + width = 90, + align = "CENTER", + toggleHidden = true, + onEnter = function(cellFrame) + GameTooltip:SetOwner(cellFrame, "ANCHOR_RIGHT") + GameTooltip:SetText("Gear", 1, 1, 1) + GameTooltip:AddLine("Profession tool and accessories.", nil, nil, nil, true) + GameTooltip:AddLine(" ") + GameTooltip:AddLine("Sorted by gear score (0-45): each slot scores 1-5 for Uncommon, 6-10 for Rare, and 11-15 for Epic, based on item rank.", nil, nil, nil, true) + GameTooltip:Show() + end, + onLeave = function() GameTooltip:Hide() end, + renderCell = function(data) + return { icons = GearCellIcons(data.characterProfession, data.skillLineVariantID) } + end, + sorting = { + enabled = true, + compare = function(a, b) + local scoreA = a.data.characterProfession.profGearScore + local scoreB = b.data.characterProfession.profGearScore + if scoreA == nil and scoreB == nil then + return (a.data.character.lastUpdate or 0) < (b.data.character.lastUpdate or 0) + end + if scoreA == nil then return true end + if scoreB == nil then return false end + if scoreA ~= scoreB then return scoreA < scoreB end + return (a.data.character.lastUpdate or 0) < (b.data.character.lastUpdate or 0) + end, + }, + }, } -- Category Progress diff --git a/Table.lua b/Table.lua index 8921ff4..de551d3 100644 --- a/Table.lua +++ b/Table.lua @@ -353,11 +353,84 @@ function Table:CreateFrame(config) if isHeaderRow and tableFrame.config.sorting and tableFrame.config.sorting.enabled then columnFrame:RegisterForClicks("LeftButtonUp", "RightButtonUp") end - columnFrame.text:SetWordWrap(false) - columnFrame.text:SetJustifyH(columnTextAlign) - columnFrame.text:SetPoint("TOPLEFT", columnFrame, "TOPLEFT", tableFrame.config.cells.padding, -tableFrame.config.cells.padding) - columnFrame.text:SetPoint("BOTTOMRIGHT", columnFrame, "BOTTOMRIGHT", -tableFrame.config.cells.padding, tableFrame.config.cells.padding) - columnFrame.text:SetText(column.text) + if column.icons then + -- Icon cell: hide the FontString, show/create icon child frames + columnFrame.text:Hide() + columnFrame.iconFrames = columnFrame.iconFrames or {} + + for i, iconData in ipairs(column.icons) do + local iconFrame = columnFrame.iconFrames[i] + if not iconFrame then + iconFrame = CreateFrame("Frame", nil, columnFrame) + iconFrame.texture = iconFrame:CreateTexture(nil, "ARTWORK") + iconFrame.texture:SetPoint("TOPLEFT", iconFrame, "TOPLEFT", 1, -1) + iconFrame.texture:SetPoint("BOTTOMRIGHT", iconFrame, "BOTTOMRIGHT", -1, 1) + iconFrame.border = iconFrame:CreateTexture(nil, "BACKGROUND") + iconFrame.border:SetAllPoints() + iconFrame.overlay = iconFrame:CreateTexture(nil, "OVERLAY") + columnFrame.iconFrames[i] = iconFrame + end + + local iconSize = iconData.size or 18 + iconFrame:SetSize(iconSize, iconSize) + iconFrame.texture:SetTexture(iconData.iconFileID) + if iconData.unscanned then + iconFrame.texture:SetVertexColor(0.4, 0.4, 0.4, 1) + else + iconFrame.texture:SetVertexColor(1, 1, 1, 1) + end + + if iconData.borderColor then + local bc = iconData.borderColor + iconFrame.border:SetColorTexture(bc.r, bc.g, bc.b, 1) + iconFrame.border:Show() + else + iconFrame.border:Hide() + end + + iconFrame.overlay:SetSize(10, 10) + iconFrame.overlay:ClearAllPoints() + iconFrame.overlay:SetPoint("TOPLEFT", iconFrame, "TOPLEFT", -2, 2) + if iconData.overlayAtlas then + iconFrame.overlay:SetAtlas(iconData.overlayAtlas) + iconFrame.overlay:Show() + else + iconFrame.overlay:Hide() + end + + iconFrame:ClearAllPoints() + iconFrame:SetPoint("LEFT", columnFrame, "LEFT", + tableFrame.config.cells.padding + (i - 1) * (iconSize + 2), 0) + if iconData.onEnter or iconData.onLeave then + iconFrame:EnableMouse(true) + iconFrame:SetScript("OnEnter", iconData.onEnter or nil) + iconFrame:SetScript("OnLeave", iconData.onLeave or nil) + iconFrame:SetScript("OnMouseUp", function() columnFrame:onClickHandler(columnFrame) end) + else + iconFrame:EnableMouse(false) + iconFrame:SetScript("OnEnter", nil) + iconFrame:SetScript("OnLeave", nil) + iconFrame:SetScript("OnMouseUp", nil) + end + iconFrame:Show() + end + + -- Hide leftover icon frames from a previous wider render + for i = #column.icons + 1, #columnFrame.iconFrames do + columnFrame.iconFrames[i]:Hide() + end + else + -- Text cell: show the FontString, hide any icon child frames + columnFrame.text:Show() + columnFrame.text:SetWordWrap(false) + columnFrame.text:SetJustifyH(columnTextAlign) + columnFrame.text:SetPoint("TOPLEFT", columnFrame, "TOPLEFT", tableFrame.config.cells.padding, -tableFrame.config.cells.padding) + columnFrame.text:SetPoint("BOTTOMRIGHT", columnFrame, "BOTTOMRIGHT", -tableFrame.config.cells.padding, tableFrame.config.cells.padding) + columnFrame.text:SetText(column.text) + if columnFrame.iconFrames then + for _, f in ipairs(columnFrame.iconFrames) do f:Hide() end + end + end columnFrame:Show() if column.backgroundColor then diff --git a/Types.lua b/Types.lua index 9593ece..5e138ef 100644 --- a/Types.lua +++ b/Types.lua @@ -120,6 +120,16 @@ ---@field id integer ---@field completed boolean +---@class WK_ProfessionGearSlot +---@field itemLink string -- Full WoW item link +---@field itemQuality integer -- 2=Uncommon, 3=Rare, 4=Epic +---@field itemLevel integer -- Item level +---@field iconFileID integer -- Texture fileID for the item icon +---@field score integer -- 0-15 computed score for this slot +---@field craftingRank integer -- 1-5 rank within tier (stored to avoid recalculation at render) +---@field itemExpansionID integer? -- Expansion that owns this item (position 15 from C_Item.GetItemInfo) +---@field pending boolean? -- true = item data not yet in cache; slot will be rescanned on GET_ITEM_INFO_RECEIVED + ---@class WK_CharacterProfession ---@field enabled boolean ---@field skillLineVariantID integer @@ -129,6 +139,8 @@ ---@field knowledgeMaxLevel integer ---@field knowledgeUnspent integer ---@field specializations WK_CharacterProfessionSpecialization[] +---@field equipment WK_ProfessionGearSlot[]? -- nil=never scanned; index 1=tool, 2=acc1, 3=acc2; nil entry=slot empty +---@field profGearScore integer? -- 0-45 sum of slot scores; nil if never scanned ---@class WK_CharacterProfessionSpecialization ---@field rootNodeID integer @@ -291,12 +303,22 @@ ---@field objective WK_Objective? ---@field progress WK_ObjectiveProgress? +---@class WK_TableDataCellIcon +---@field iconFileID integer -- Main icon texture fileID +---@field overlayAtlas string? -- Atlas name for top-left corner overlay (e.g. quality star) +---@field borderColor {r: number, g: number, b: number}? -- Border color (e.g. item quality color) +---@field unscanned boolean? -- true = never scanned; dims the icon to distinguish from scanned-empty +---@field size integer? -- Icon size in pixels (default 18) +---@field onEnter function? -- Called on MouseEnter; use to show a tooltip +---@field onLeave function? -- Called on MouseLeave; use to hide the tooltip + ---@class WK_TableCell ---@field text string? ---@field backgroundColor {r: number, g: number, b: number, a: number}? ---@field onEnter function? ---@field onLeave function? ---@field onClick function? +---@field icons WK_TableDataCellIcon[]? -- If present, renders icon frames instead of text ---@class WK_TableSortConfig ---@field enabled boolean