From 7734edb50c0d36b9820481b6002ea6bd24e92f5c Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Fri, 6 Mar 2026 18:01:54 +0100 Subject: [PATCH 01/59] Add build comparison tab --- src/Classes/CompareEntry.lua | 340 ++++++++++ src/Classes/CompareTab.lua | 1160 ++++++++++++++++++++++++++++++++++ src/Classes/ImportTab.lua | 13 +- src/Modules/Build.lua | 8 + 4 files changed, 1519 insertions(+), 2 deletions(-) create mode 100644 src/Classes/CompareEntry.lua create mode 100644 src/Classes/CompareTab.lua diff --git a/src/Classes/CompareEntry.lua b/src/Classes/CompareEntry.lua new file mode 100644 index 0000000000..9db65d9413 --- /dev/null +++ b/src/Classes/CompareEntry.lua @@ -0,0 +1,340 @@ +-- Path of Building +-- +-- Module: Compare Entry +-- Lightweight Build wrapper for comparison. Loads XML, creates tabs, and runs calculations +-- without setting up the full UI chrome of the primary build. +-- +local t_insert = table.insert +local m_min = math.min +local m_max = math.max + +local CompareEntryClass = newClass("CompareEntry", "ControlHost", function(self, xmlText, label) + self.ControlHost() + + self.label = label or "Comparison Build" + self.buildName = label or "Comparison Build" + self.xmlText = xmlText + + -- Default build properties (mirrors Build.lua:Init lines 72-82) + self.viewMode = "TREE" + self.characterLevel = m_min(m_max(main.defaultCharLevel or 1, 1), 100) + self.targetVersion = liveTargetVersion + self.bandit = "None" + self.pantheonMajorGod = "None" + self.pantheonMinorGod = "None" + self.characterLevelAutoMode = main.defaultCharLevel == 1 or main.defaultCharLevel == nil + self.mainSocketGroup = 1 + + self.spectreList = {} + self.timelessData = { + jewelType = {}, conquerorType = {}, + devotionVariant1 = 1, devotionVariant2 = 1, + jewelSocket = {}, fallbackWeightMode = {}, + searchList = "", searchListFallback = "", + searchResults = {}, sharedResults = {} + } + + -- Shared data (read-only references) + self.latestTree = main.tree[latestTreeVersion] + self.data = data + + -- Flags + self.modFlag = false + self.buildFlag = false + self.outputRevision = 1 + + -- Display stats (same as primary build uses) + self.displayStats, self.minionDisplayStats, self.extraSaveStats = LoadModule("Modules/BuildDisplayStats") + + -- Load from XML + if xmlText then + self:LoadFromXML(xmlText) + end +end) + +function CompareEntryClass:LoadFromXML(xmlText) + -- Parse the XML (same pattern as Build.lua:LoadDB, line 1834) + local dbXML, errMsg = common.xml.ParseXML(xmlText) + if errMsg then + ConPrintf("CompareEntry: Error parsing XML: %s", errMsg) + return true + end + if not dbXML or not dbXML[1] or dbXML[1].elem ~= "PathOfBuilding" then + ConPrintf("CompareEntry: 'PathOfBuilding' root element missing") + return true + end + + -- Load Build section first (same pattern as Build.lua:LoadDB, line 1848) + for _, node in ipairs(dbXML[1]) do + if type(node) == "table" and node.elem == "Build" then + self:LoadBuildSection(node) + break + end + end + + -- Check for import link + for _, node in ipairs(dbXML[1]) do + if type(node) == "table" and node.elem == "Import" then + if node.attrib.importLink then + self.importLink = node.attrib.importLink + end + break + end + end + + -- Store XML sections for tab loading + self.xmlSectionList = {} + for _, node in ipairs(dbXML[1]) do + if type(node) == "table" then + t_insert(self.xmlSectionList, node) + end + end + + -- Version check + if self.targetVersion ~= liveTargetVersion then + self.targetVersion = liveTargetVersion + end + + -- Create tabs (same pattern as Build.lua lines 579-590) + -- PartyTab is replaced with a stub providing an empty enemyModList and actor + -- (CalcPerform.lua:1088 accesses build.partyTab.actor for party member buffs) + local partyActor = { Aura = {}, Curse = {}, Warcry = {}, Link = {}, modDB = new("ModDB"), output = {} } + partyActor.modDB.actor = partyActor + self.partyTab = { enemyModList = new("ModList"), actor = partyActor } + self.configTab = new("ConfigTab", self) + self.itemsTab = new("ItemsTab", self) + self.treeTab = new("TreeTab", self) + self.skillsTab = new("SkillsTab", self) + self.calcsTab = new("CalcsTab", self) + + -- Set up savers table (same pattern as Build.lua lines 593-606) + self.savers = { + ["Config"] = self.configTab, + ["Tree"] = self.treeTab, + ["TreeView"] = self.treeTab.viewer, + ["Items"] = self.itemsTab, + ["Skills"] = self.skillsTab, + ["Calcs"] = self.calcsTab, + } + self.legacyLoaders = { + ["Spec"] = self.treeTab, + } + + -- Special rebuild to properly initialise boss placeholders + self.configTab:BuildModList() + + -- Load legacy bandit and pantheon choices from build section + for _, control in ipairs({ "bandit", "pantheonMajorGod", "pantheonMinorGod" }) do + self.configTab.input[control] = self[control] + end + + -- Load XML sections into tabs (same pattern as Build.lua lines 620-647) + -- Defer passive trees until after items are loaded (jewel socket issue) + local deferredPassiveTrees = {} + for _, node in ipairs(self.xmlSectionList) do + local saver = self.savers[node.elem] or self.legacyLoaders[node.elem] + if saver then + if saver == self.treeTab then + t_insert(deferredPassiveTrees, node) + else + saver:Load(node, "CompareEntry") + end + end + end + for _, node in ipairs(deferredPassiveTrees) do + self.treeTab:Load(node, "CompareEntry") + end + for _, saver in pairs(self.savers) do + if saver.PostLoad then + saver:PostLoad() + end + end + + if next(self.configTab.input) == nil then + if self.configTab.ImportCalcSettings then + self.configTab:ImportCalcSettings() + end + end + + -- Build calculation output tables (same pattern as Build.lua lines 654-657) + self.calcsTab:BuildOutput() + self.buildFlag = false +end + +-- Load build section attributes (same pattern as Build.lua:Load, line 927) +function CompareEntryClass:LoadBuildSection(xml) + self.targetVersion = xml.attrib.targetVersion or legacyTargetVersion + if xml.attrib.viewMode then + self.viewMode = xml.attrib.viewMode + end + self.characterLevel = tonumber(xml.attrib.level) or 1 + self.characterLevelAutoMode = xml.attrib.characterLevelAutoMode == "true" + for _, diff in pairs({ "bandit", "pantheonMajorGod", "pantheonMinorGod" }) do + self[diff] = xml.attrib[diff] or "None" + end + self.mainSocketGroup = tonumber(xml.attrib.mainSkillIndex) or tonumber(xml.attrib.mainSocketGroup) or 1 + wipeTable(self.spectreList) + for _, child in ipairs(xml) do + if child.elem == "Spectre" then + if child.attrib.id and data.minions[child.attrib.id] then + t_insert(self.spectreList, child.attrib.id) + end + elseif child.elem == "TimelessData" then + self.timelessData.jewelType = { id = tonumber(child.attrib.jewelTypeId) } + self.timelessData.conquerorType = { id = tonumber(child.attrib.conquerorTypeId) } + self.timelessData.devotionVariant1 = tonumber(child.attrib.devotionVariant1) or 1 + self.timelessData.devotionVariant2 = tonumber(child.attrib.devotionVariant2) or 1 + self.timelessData.jewelSocket = { id = tonumber(child.attrib.jewelSocketId) } + self.timelessData.fallbackWeightMode = { idx = tonumber(child.attrib.fallbackWeightModeIdx) } + self.timelessData.socketFilter = child.attrib.socketFilter == "true" + self.timelessData.socketFilterDistance = tonumber(child.attrib.socketFilterDistance) or 0 + self.timelessData.searchList = child.attrib.searchList + self.timelessData.searchListFallback = child.attrib.searchListFallback + end + end +end + +function CompareEntryClass:GetOutput() + return self.calcsTab.mainOutput +end + +function CompareEntryClass:GetSpec() + return self.spec +end + +function CompareEntryClass:Rebuild() + wipeGlobalCache() + self.outputRevision = self.outputRevision + 1 + self.calcsTab:BuildOutput() + self.buildFlag = false +end + +function CompareEntryClass:SetActiveSpec(index) + if self.treeTab and self.treeTab.SetActiveSpec then + self.treeTab:SetActiveSpec(index) + self:Rebuild() + end +end + +function CompareEntryClass:SetActiveItemSet(id) + if self.itemsTab and self.itemsTab.SetActiveItemSet then + self.itemsTab:SetActiveItemSet(id) + self:Rebuild() + end +end + +function CompareEntryClass:SetActiveSkillSet(id) + if self.skillsTab and self.skillsTab.SetActiveSkillSet then + self.skillsTab:SetActiveSkillSet(id) + self:Rebuild() + end +end + +-- Stub methods that the build interface may call +function CompareEntryClass:RefreshStatList() + -- No sidebar to refresh in comparison entry +end + +function CompareEntryClass:RefreshSkillSelectControls() + -- No skill select controls in comparison entry +end + +function CompareEntryClass:UpdateClassDropdowns() + -- No class dropdowns in comparison entry +end + +function CompareEntryClass:SyncLoadouts() + -- No loadout syncing in comparison entry +end + +function CompareEntryClass:OpenSpectreLibrary() + -- No spectre library in comparison entry +end + +function CompareEntryClass:AddStatComparesToTooltip(tooltip, baseOutput, compareOutput, header, nodeCount) + -- Reuse the stat comparison logic + local count = 0 + if self.calcsTab and self.calcsTab.mainEnv and self.calcsTab.mainEnv.player and self.calcsTab.mainEnv.player.mainSkill then + if self.calcsTab.mainEnv.player.mainSkill.minion and baseOutput.Minion and compareOutput.Minion then + count = count + self:CompareStatList(tooltip, self.minionDisplayStats, self.calcsTab.mainEnv.minion, baseOutput.Minion, compareOutput.Minion, header.."\n^7Minion:", nodeCount) + if count > 0 then + header = "^7Player:" + else + header = header.."\n^7Player:" + end + end + count = count + self:CompareStatList(tooltip, self.displayStats, self.calcsTab.mainEnv.player, baseOutput, compareOutput, header, nodeCount) + end + return count +end + +-- Stat comparison (mirrors Build.lua:CompareStatList, line 1733) +function CompareEntryClass:CompareStatList(tooltip, statList, actor, baseOutput, compareOutput, header, nodeCount) + local s_format = string.format + local count = 0 + if not actor or not actor.mainSkill then + return 0 + end + for _, statData in ipairs(statList) do + if statData.stat and not statData.childStat and statData.stat ~= "SkillDPS" then + local flagMatch = true + if statData.flag then + if type(statData.flag) == "string" then + flagMatch = actor.mainSkill.skillFlags[statData.flag] + elseif type(statData.flag) == "table" then + for _, flag in ipairs(statData.flag) do + if not actor.mainSkill.skillFlags[flag] then + flagMatch = false + break + end + end + end + end + if statData.notFlag then + if type(statData.notFlag) == "string" then + if actor.mainSkill.skillFlags[statData.notFlag] then + flagMatch = false + end + elseif type(statData.notFlag) == "table" then + for _, flag in ipairs(statData.notFlag) do + if actor.mainSkill.skillFlags[flag] then + flagMatch = false + break + end + end + end + end + if flagMatch then + local statVal1 = compareOutput[statData.stat] or 0 + local statVal2 = baseOutput[statData.stat] or 0 + local diff = statVal1 - statVal2 + if statData.stat == "FullDPS" and not compareOutput[statData.stat] then + diff = 0 + end + if (diff > 0.001 or diff < -0.001) and (not statData.condFunc or statData.condFunc(statVal1, compareOutput) or statData.condFunc(statVal2, baseOutput)) then + if count == 0 then + tooltip:AddLine(14, header) + end + local color = ((statData.lowerIsBetter and diff < 0) or (not statData.lowerIsBetter and diff > 0)) and colorCodes.POSITIVE or colorCodes.NEGATIVE + local val = diff * ((statData.pc or statData.mod) and 100 or 1) + local valStr = s_format("%+"..statData.fmt, val) + local number, suffix = valStr:match("^([%+%-]?%d+%.%d+)(%D*)$") + if number then + valStr = number:gsub("0+$", ""):gsub("%.$", "") .. suffix + end + valStr = formatNumSep(valStr) + local line = s_format("%s%s %s", color, valStr, statData.label) + if statData.compPercent and statVal1 ~= 0 and statVal2 ~= 0 then + local pc = statVal1 / statVal2 * 100 - 100 + line = line .. s_format(" (%+.1f%%)", pc) + end + tooltip:AddLine(14, line) + count = count + 1 + end + end + end + end + return count +end + +return CompareEntryClass diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua new file mode 100644 index 0000000000..a23e396dba --- /dev/null +++ b/src/Classes/CompareTab.lua @@ -0,0 +1,1160 @@ +-- Path of Building +-- +-- Module: Compare Tab +-- Manages build comparison state and renders the comparison screen. +-- +local t_insert = table.insert +local t_remove = table.remove +local m_min = math.min +local m_max = math.max +local m_floor = math.floor +local s_format = string.format + +local CompareTabClass = newClass("CompareTab", "ControlHost", "Control", function(self, primaryBuild) + self.ControlHost() + self.Control() + + self.primaryBuild = primaryBuild + + -- Comparison entries (indexed 1..N for future 3+ build support) + self.compareEntries = {} + self.activeCompareIndex = 0 + + -- Sub-view mode + self.compareViewMode = "SUMMARY" + + -- Scroll offset for scrollable views + self.scrollY = 0 + + -- Tree layout cache (set in Draw, used by DrawTree) + self.treeLayout = nil + + -- Track when tree search fields need syncing with viewer state + self.treeSearchNeedsSync = true + + -- Controls for the comparison screen + self:InitControls() +end) + +function CompareTabClass:InitControls() + -- Sub-tab buttons + local subTabs = { "Summary", "Tree", "Skills", "Items", "Calcs", "Config" } + local subTabModes = { "SUMMARY", "TREE", "SKILLS", "ITEMS", "CALCS", "CONFIG" } + + self.controls.subTabAnchor = new("Control", nil, {0, 0, 0, 20}) + for i, tabName in ipairs(subTabs) do + local mode = subTabModes[i] + local prevName = i > 1 and ("subTab" .. subTabs[i-1]) or "subTabAnchor" + local anchor = i == 1 + and {"TOPLEFT", self.controls.subTabAnchor, "TOPLEFT"} + or {"LEFT", self.controls[prevName], "RIGHT"} + self.controls["subTab" .. tabName] = new("ButtonControl", anchor, {i == 1 and 0 or 4, 0, 72, 20}, tabName, function() + self.compareViewMode = mode + self.scrollY = 0 + if mode == "TREE" then + self.treeSearchNeedsSync = true + end + end) + self.controls["subTab" .. tabName].locked = function() + return self.compareViewMode == mode + end + end + + -- Build B selector dropdown + self.controls.compareBuildLabel = new("LabelControl", {"TOPLEFT", self.controls.subTabAnchor, "TOPLEFT"}, {0, -48, 0, 16}, "^7Compare with:") + self.controls.compareBuildSelect = new("DropDownControl", {"LEFT", self.controls.compareBuildLabel, "RIGHT"}, {4, 0, 250, 20}, {}, function(index, value) + if index and index > 0 and index <= #self.compareEntries then + self.activeCompareIndex = index + self.treeSearchNeedsSync = true + end + end) + self.controls.compareBuildSelect.enabled = function() + return #self.compareEntries > 0 + end + + -- Import button (opens import popup) + self.controls.importBtn = new("ButtonControl", {"LEFT", self.controls.compareBuildSelect, "RIGHT"}, {8, 0, 100, 20}, "Import...", function() + self:OpenImportPopup() + end) + + -- Re-import current build button + self.controls.reimportBtn = new("ButtonControl", {"LEFT", self.controls.importBtn, "RIGHT"}, {4, 0, 120, 20}, "Re-import Current", function() + self:ReimportPrimary() + end) + self.controls.reimportBtn.tooltipFunc = function(tooltip) + tooltip:Clear() + local importTab = self.primaryBuild.importTab + if importTab and importTab.charImportMode == "SELECTCHAR" then + local charSelect = importTab.controls.charSelect + local charData = charSelect and charSelect.list and charSelect.list[charSelect.selIndex] + if charData and charData.char then + tooltip:AddLine(16, "Re-import character from the game server:") + tooltip:AddLine(14, "^7" .. charData.char.name .. " (" .. charData.char.class .. ", " .. charData.char.league .. ")") + else + tooltip:AddLine(16, "Re-import the currently selected character.") + end + tooltip:AddLine(14, "^7Refreshes passive tree, jewels, items, and skills.") + else + tooltip:AddLine(16, "^7No character selected.") + tooltip:AddLine(14, "^7Go to Import/Export Build tab and select a character first.") + end + end + + -- Remove comparison build button + self.controls.removeBtn = new("ButtonControl", {"LEFT", self.controls.reimportBtn, "RIGHT"}, {4, 0, 70, 20}, "Remove", function() + if self.activeCompareIndex > 0 and self.activeCompareIndex <= #self.compareEntries then + self:RemoveBuild(self.activeCompareIndex) + end + end) + self.controls.removeBtn.enabled = function() + return #self.compareEntries > 0 + end + + -- ============================================================ + -- Comparison build set selectors (row between build selector and sub-tabs) + -- ============================================================ + local setsEnabled = function() + return #self.compareEntries > 0 + end + + self.controls.compareSetsLabel = new("LabelControl", {"TOPLEFT", self.controls.subTabAnchor, "TOPLEFT"}, {0, -22, 0, 16}, "^7Sets:") + self.controls.compareSetsLabel.shown = setsEnabled + + -- Tree spec selector for comparison build + self.controls.compareSpecLabel = new("LabelControl", {"LEFT", self.controls.compareSetsLabel, "RIGHT"}, {4, 0, 0, 16}, "^7Tree set:") + self.controls.compareSpecLabel.shown = setsEnabled + self.controls.compareSpecSelect = new("DropDownControl", {"LEFT", self.controls.compareSpecLabel, "RIGHT"}, {2, 0, 150, 20}, {}, function(index, value) + local entry = self:GetActiveCompare() + if entry and entry.treeTab and entry.treeTab.specList[index] then + entry:SetActiveSpec(index) + -- Restore primary build's window title (SetActiveSpec changes it) + if self.primaryBuild.spec then + self.primaryBuild.spec:SetWindowTitleWithBuildClass() + end + end + end) + self.controls.compareSpecSelect.enabled = setsEnabled + self.controls.compareSpecSelect.maxDroppedWidth = 500 + self.controls.compareSpecSelect.enableDroppedWidth = true + + -- Skill set selector for comparison build + self.controls.compareSkillSetLabel = new("LabelControl", {"LEFT", self.controls.compareSpecSelect, "RIGHT"}, {8, 0, 0, 16}, "^7Skill set:") + self.controls.compareSkillSetLabel.shown = setsEnabled + self.controls.compareSkillSetSelect = new("DropDownControl", {"LEFT", self.controls.compareSkillSetLabel, "RIGHT"}, {2, 0, 150, 20}, {}, function(index, value) + local entry = self:GetActiveCompare() + if entry and entry.skillsTab and entry.skillsTab.skillSetOrderList[index] then + entry:SetActiveSkillSet(entry.skillsTab.skillSetOrderList[index]) + end + end) + self.controls.compareSkillSetSelect.enabled = setsEnabled + + -- Item set selector for comparison build + self.controls.compareItemSetLabel = new("LabelControl", {"LEFT", self.controls.compareSkillSetSelect, "RIGHT"}, {8, 0, 0, 16}, "^7Item set:") + self.controls.compareItemSetLabel.shown = setsEnabled + self.controls.compareItemSetSelect = new("DropDownControl", {"LEFT", self.controls.compareItemSetLabel, "RIGHT"}, {2, 0, 150, 20}, {}, function(index, value) + local entry = self:GetActiveCompare() + if entry and entry.itemsTab and entry.itemsTab.itemSetOrderList[index] then + entry:SetActiveItemSet(entry.itemsTab.itemSetOrderList[index]) + end + end) + self.controls.compareItemSetSelect.enabled = setsEnabled + + -- ============================================================ + -- Tree footer controls (visible only in TREE view mode with a comparison loaded) + -- ============================================================ + local treeFooterShown = function() + return self.compareViewMode == "TREE" and self:GetActiveCompare() ~= nil + end + + -- Build version dropdown list (shared between left and right) + self.treeVersionDropdownList = {} + for _, num in ipairs(treeVersionList) do + t_insert(self.treeVersionDropdownList, { + label = treeVersions[num].display, + value = num + }) + end + + -- Footer anchor controls (positioned dynamically in Draw) + self.controls.leftFooterAnchor = new("Control", nil, {0, 0, 0, 20}) + self.controls.leftFooterAnchor.shown = treeFooterShown + self.controls.rightFooterAnchor = new("Control", nil, {0, 0, 0, 20}) + self.controls.rightFooterAnchor.shown = treeFooterShown + + -- Left side (primary build) footer controls + self.controls.leftSpecSelect = new("DropDownControl", {"LEFT", self.controls.leftFooterAnchor, "LEFT"}, {0, 0, 180, 20}, {}, function(index, value) + if self.primaryBuild.treeTab and self.primaryBuild.treeTab.specList[index] then + self.primaryBuild.modFlag = true + self.primaryBuild.treeTab:SetActiveSpec(index) + end + end) + self.controls.leftSpecSelect.shown = treeFooterShown + self.controls.leftSpecSelect.maxDroppedWidth = 500 + self.controls.leftSpecSelect.enableDroppedWidth = true + + self.controls.leftVersionSelect = new("DropDownControl", {"LEFT", self.controls.leftSpecSelect, "RIGHT"}, {4, 0, 100, 20}, self.treeVersionDropdownList, function(index, selected) + if selected and selected.value and self.primaryBuild.spec and selected.value ~= self.primaryBuild.spec.treeVersion then + self.primaryBuild.treeTab:OpenVersionConvertPopup(selected.value, true) + end + end) + self.controls.leftVersionSelect.shown = treeFooterShown + + self.controls.leftTreeSearch = new("EditControl", {"TOPLEFT", self.controls.leftFooterAnchor, "TOPLEFT"}, {0, 24, 200, 20}, "", "Search", "%c", 100, function(buf) + if self.primaryBuild.treeTab and self.primaryBuild.treeTab.viewer then + self.primaryBuild.treeTab.viewer.searchStr = buf + end + end, nil, nil, true) + self.controls.leftTreeSearch.shown = treeFooterShown + + -- Right side (compare build) footer controls + self.controls.rightSpecSelect = new("DropDownControl", {"LEFT", self.controls.rightFooterAnchor, "LEFT"}, {0, 0, 180, 20}, {}, function(index, value) + local entry = self:GetActiveCompare() + if entry and entry.treeTab and entry.treeTab.specList[index] then + entry:SetActiveSpec(index) + -- Restore primary build's window title (compare entry's SetActiveSpec changes it) + if self.primaryBuild.spec then + self.primaryBuild.spec:SetWindowTitleWithBuildClass() + end + end + end) + self.controls.rightSpecSelect.shown = treeFooterShown + self.controls.rightSpecSelect.maxDroppedWidth = 500 + self.controls.rightSpecSelect.enableDroppedWidth = true + + self.controls.rightVersionSelect = new("DropDownControl", {"LEFT", self.controls.rightSpecSelect, "RIGHT"}, {4, 0, 100, 20}, self.treeVersionDropdownList, function(index, selected) + local entry = self:GetActiveCompare() + if entry and selected and selected.value and entry.spec then + if selected.value ~= entry.spec.treeVersion then + entry.treeTab:OpenVersionConvertPopup(selected.value, true) + end + end + end) + self.controls.rightVersionSelect.shown = treeFooterShown + + self.controls.rightTreeSearch = new("EditControl", {"TOPLEFT", self.controls.rightFooterAnchor, "TOPLEFT"}, {0, 24, 200, 20}, "", "Search", "%c", 100, function(buf) + local entry = self:GetActiveCompare() + if entry and entry.treeTab and entry.treeTab.viewer then + entry.treeTab.viewer.searchStr = buf + end + end, nil, nil, true) + self.controls.rightTreeSearch.shown = treeFooterShown +end + +-- Import a comparison build from XML text +function CompareTabClass:ImportBuild(xmlText, label) + local entry = new("CompareEntry", xmlText, label) + if entry and entry.calcsTab and entry.calcsTab.mainOutput then + t_insert(self.compareEntries, entry) + self.activeCompareIndex = #self.compareEntries + self:UpdateBuildSelector() + return true + end + return false +end + +-- Import a comparison build from a build code (base64-encoded) +function CompareTabClass:ImportFromCode(code) + local xmlText = Inflate(common.base64.decode(code:gsub("-","+"):gsub("_","/"))) + if not xmlText then + return false + end + return self:ImportBuild(xmlText, "Imported build") +end + +-- Remove a comparison build +function CompareTabClass:RemoveBuild(index) + if index >= 1 and index <= #self.compareEntries then + t_remove(self.compareEntries, index) + if self.activeCompareIndex > #self.compareEntries then + self.activeCompareIndex = #self.compareEntries + end + if self.activeCompareIndex == 0 and #self.compareEntries > 0 then + self.activeCompareIndex = 1 + end + self:UpdateBuildSelector() + end +end + +-- Re-import primary build using character import (same as Import/Export tab) +function CompareTabClass:ReimportPrimary() + local importTab = self.primaryBuild.importTab + if not importTab then + main:OpenMessagePopup("Re-import", "Import tab not available.") + return + end + if importTab.charImportMode ~= "SELECTCHAR" then + main:OpenMessagePopup("Re-import", "No character selected.\nGo to the Import/Export Build tab, enter your account name,\nand select a character first.") + return + end + -- Set clear checkboxes to true (delete existing jewels, skills, equipment) + importTab.controls.charImportTreeClearJewels.state = true + importTab.controls.charImportItemsClearSkills.state = true + importTab.controls.charImportItemsClearItems.state = true + -- Trigger both async imports (passive tree + items/skills) + importTab:DownloadPassiveTree() + importTab:DownloadItems() +end + +-- Update the build selector dropdown +function CompareTabClass:UpdateBuildSelector() + local list = {} + for i, entry in ipairs(self.compareEntries) do + t_insert(list, entry.label or ("Build " .. i)) + end + self.controls.compareBuildSelect.list = list + if self.activeCompareIndex > 0 and self.activeCompareIndex <= #list then + self.controls.compareBuildSelect.selIndex = self.activeCompareIndex + end +end + +-- Get the active comparison entry +function CompareTabClass:GetActiveCompare() + if self.activeCompareIndex > 0 and self.activeCompareIndex <= #self.compareEntries then + return self.compareEntries[self.activeCompareIndex] + end + return nil +end + +-- Open the import popup for adding a comparison build +function CompareTabClass:OpenImportPopup() + local controls = {} + -- Use a local variable for state text so it doesn't go into the controls table + -- (PopupDialog iterates all controls table entries and expects them to be control objects) + local stateText = "" + controls.label = new("LabelControl", nil, {0, 20, 0, 16}, "^7Paste a build code or URL to import as comparison:") + controls.input = new("EditControl", nil, {0, 50, 450, 20}, "", nil, nil, nil, nil, nil, nil, true) + controls.input.enterFunc = function() + if controls.input.buf and controls.input.buf ~= "" then + controls.go.onClick() + end + end + controls.state = new("LabelControl", {"TOPLEFT", controls.input, "BOTTOMLEFT"}, {0, 4, 0, 16}) + controls.state.label = function() + return stateText or "" + end + controls.go = new("ButtonControl", nil, {-45, 100, 80, 20}, "Import", function() + local buf = controls.input.buf + if not buf or buf == "" then + return + end + + -- Check if it's a URL + for _, site in ipairs(buildSites.websiteList) do + if buf:match(site.matchURL) then + stateText = colorCodes.WARNING .. "Downloading..." + buildSites.DownloadBuild(buf, site, function(isSuccess, codeData) + if isSuccess then + local xmlText = Inflate(common.base64.decode(codeData:gsub("-","+"):gsub("_","/"))) + if xmlText then + self:ImportBuild(xmlText, "Imported from " .. site.label) + main:ClosePopup() + else + stateText = colorCodes.NEGATIVE .. "Failed to decode build data" + end + else + stateText = colorCodes.NEGATIVE .. tostring(codeData) + end + end) + return + end + end + + -- Try as a build code + local xmlText = Inflate(common.base64.decode(buf:gsub("-","+"):gsub("_","/"))) + if xmlText then + self:ImportBuild(xmlText, "Imported build") + main:ClosePopup() + else + stateText = colorCodes.NEGATIVE .. "Invalid build code" + end + end) + controls.cancel = new("ButtonControl", nil, {45, 100, 80, 20}, "Cancel", function() + main:ClosePopup() + end) + main:OpenPopup(500, 130, "Import Comparison Build", controls, "go", "input", "cancel") +end + +-- ============================================================ +-- DRAW - Main render method +-- ============================================================ +function CompareTabClass:Draw(viewPort, inputEvents) + local controlBarHeight = 74 + + -- Position top-bar controls + self.controls.subTabAnchor.x = viewPort.x + 4 + self.controls.subTabAnchor.y = viewPort.y + 52 + + self.controls.compareBuildLabel.x = function() + return 0 + end + + local contentVP = { + x = viewPort.x, + y = viewPort.y + controlBarHeight, + width = viewPort.width, + height = viewPort.height - controlBarHeight, + } + + -- Get active comparison early (needed for footer positioning before ProcessControlsInput) + local compareEntry = self:GetActiveCompare() + + -- Rebuild compare entry if its buildFlag is set (e.g. after version convert or spec change) + if compareEntry and compareEntry.buildFlag then + compareEntry:Rebuild() + end + + -- Pre-draw tree footer backgrounds and position footer controls + -- (must happen before ProcessControlsInput so controls render on top of backgrounds) + self.treeLayout = nil + if self.compareViewMode == "TREE" and compareEntry then + local halfWidth = m_floor(contentVP.width / 2) - 2 + local footerHeight = 50 + local footerY = contentVP.y + contentVP.height - footerHeight + local rightAbsX = contentVP.x + halfWidth + 4 + local specWidth = m_min(m_floor(halfWidth * 0.55), 200) + + -- Store layout for DrawTree + self.treeLayout = { + halfWidth = halfWidth, + footerHeight = footerHeight, + footerY = footerY, + rightAbsX = rightAbsX, + } + + -- Draw footer backgrounds + SetDrawColor(0.05, 0.05, 0.05) + DrawImage(nil, contentVP.x, footerY, halfWidth, footerHeight) + DrawImage(nil, rightAbsX, footerY, halfWidth, footerHeight) + SetDrawColor(0.85, 0.85, 0.85) + DrawImage(nil, contentVP.x, footerY, halfWidth, 2) + DrawImage(nil, rightAbsX, footerY, halfWidth, 2) + + -- Position left footer controls + self.controls.leftFooterAnchor.x = contentVP.x + 4 + self.controls.leftFooterAnchor.y = footerY + 4 + self.controls.leftSpecSelect.width = specWidth + self.controls.leftTreeSearch.width = halfWidth - 8 + + -- Position right footer controls + self.controls.rightFooterAnchor.x = rightAbsX + 4 + self.controls.rightFooterAnchor.y = footerY + 4 + self.controls.rightSpecSelect.width = specWidth + self.controls.rightTreeSearch.width = halfWidth - 8 + + -- Update spec dropdown lists + if self.primaryBuild.treeTab then + self.controls.leftSpecSelect.list = self.primaryBuild.treeTab:GetSpecList() + self.controls.leftSpecSelect.selIndex = self.primaryBuild.treeTab.activeSpec + end + if compareEntry.treeTab then + self.controls.rightSpecSelect.list = compareEntry.treeTab:GetSpecList() + self.controls.rightSpecSelect.selIndex = compareEntry.treeTab.activeSpec + end + + -- Update version dropdown selection to match current spec + if self.primaryBuild.spec then + for i, ver in ipairs(self.treeVersionDropdownList) do + if ver.value == self.primaryBuild.spec.treeVersion then + self.controls.leftVersionSelect.selIndex = i + break + end + end + end + if compareEntry.spec then + for i, ver in ipairs(self.treeVersionDropdownList) do + if ver.value == compareEntry.spec.treeVersion then + self.controls.rightVersionSelect.selIndex = i + break + end + end + end + + -- Sync search fields when entering tree mode or changing compare entry + if self.treeSearchNeedsSync then + self.treeSearchNeedsSync = false + if self.primaryBuild.treeTab and self.primaryBuild.treeTab.viewer then + self.controls.leftTreeSearch:SetText(self.primaryBuild.treeTab.viewer.searchStr or "") + end + if compareEntry.treeTab and compareEntry.treeTab.viewer then + self.controls.rightTreeSearch:SetText(compareEntry.treeTab.viewer.searchStr or "") + end + end + end + + -- Update comparison build set selectors + if compareEntry then + -- Tree spec list (reuse GetSpecList from TreeTab) + if compareEntry.treeTab then + self.controls.compareSpecSelect.list = compareEntry.treeTab:GetSpecList() + self.controls.compareSpecSelect.selIndex = compareEntry.treeTab.activeSpec + end + -- Skill set list (pattern from SkillsTab:Draw lines 527-535) + if compareEntry.skillsTab then + local skillList = {} + for index, skillSetId in ipairs(compareEntry.skillsTab.skillSetOrderList) do + local skillSet = compareEntry.skillsTab.skillSets[skillSetId] + t_insert(skillList, skillSet.title or "Default") + if skillSetId == compareEntry.skillsTab.activeSkillSetId then + self.controls.compareSkillSetSelect.selIndex = index + end + end + self.controls.compareSkillSetSelect:SetList(skillList) + end + -- Item set list (pattern from ItemsTab:Draw lines 1293-1301) + if compareEntry.itemsTab then + local itemList = {} + for index, itemSetId in ipairs(compareEntry.itemsTab.itemSetOrderList) do + local itemSet = compareEntry.itemsTab.itemSets[itemSetId] + t_insert(itemList, itemSet.title or "Default") + if itemSetId == compareEntry.itemsTab.activeItemSetId then + self.controls.compareItemSetSelect.selIndex = index + end + end + self.controls.compareItemSetSelect:SetList(itemList) + end + end + + -- Handle scroll events for scrollable views + local cursorX, cursorY = GetCursorPos() + local mouseInContent = cursorX >= contentVP.x and cursorX < contentVP.x + contentVP.width + and cursorY >= contentVP.y and cursorY < contentVP.y + contentVP.height + + for id, event in ipairs(inputEvents) do + if event.type == "KeyDown" and mouseInContent then + if event.key == "WHEELUP" and self.compareViewMode ~= "TREE" then + self.scrollY = m_max(self.scrollY - 40, 0) + inputEvents[id] = nil + elseif event.key == "WHEELDOWN" and self.compareViewMode ~= "TREE" then + self.scrollY = self.scrollY + 40 + inputEvents[id] = nil + end + end + end + + -- Process input events for our controls (including footer controls) + self:ProcessControlsInput(inputEvents, viewPort) + + -- Draw controls (footer controls render on top of pre-drawn backgrounds) + self:DrawControls(viewPort) + + if not compareEntry then + -- No comparison build loaded - show instructions + SetDrawColor(1, 1, 1) + DrawString(contentVP.x + contentVP.width / 2, contentVP.y + 40, "CENTER", 20, "VAR", + "^7No comparison build loaded.") + DrawString(contentVP.x + contentVP.width / 2, contentVP.y + 70, "CENTER", 16, "VAR", + "^7Click " .. colorCodes.POSITIVE .. "Import..." .. "^7 above to import a build to compare against,") + DrawString(contentVP.x + contentVP.width / 2, contentVP.y + 90, "CENTER", 16, "VAR", + "^7or use the " .. colorCodes.POSITIVE .. "Import/Export Build" .. "^7 tab with \"Import as comparison\" mode.") + return + end + + -- Dispatch to sub-view + if self.compareViewMode == "SUMMARY" then + self:DrawSummary(contentVP, compareEntry) + elseif self.compareViewMode == "TREE" then + self:DrawTree(contentVP, inputEvents, compareEntry) + elseif self.compareViewMode == "ITEMS" then + self:DrawItems(contentVP, compareEntry) + elseif self.compareViewMode == "SKILLS" then + self:DrawSkills(contentVP, compareEntry) + elseif self.compareViewMode == "CALCS" then + self:DrawCalcs(contentVP, compareEntry) + elseif self.compareViewMode == "CONFIG" then + self:DrawConfig(contentVP, compareEntry) + end +end + +-- ============================================================ +-- SUMMARY VIEW +-- ============================================================ +function CompareTabClass:DrawSummary(vp, compareEntry) + local primaryOutput = self.primaryBuild.calcsTab.mainOutput + local compareOutput = compareEntry:GetOutput() + if not primaryOutput or not compareOutput then + return + end + + local lineHeight = 18 + local headerHeight = 22 + local colWidth = m_floor(vp.width / 2) + + SetViewport(vp.x, vp.y, vp.width, vp.height) + local drawY = 4 - self.scrollY + + -- Headers + SetDrawColor(1, 1, 1) + DrawString(10, drawY, "LEFT", headerHeight, "VAR", colorCodes.POSITIVE .. "Your Build: ^7" .. (self.primaryBuild.buildName or "Current")) + DrawString(colWidth + 10, drawY, "LEFT", headerHeight, "VAR", colorCodes.WARNING .. "Compare: ^7" .. (compareEntry.label or "Comparison")) + drawY = drawY + headerHeight + 4 + + -- Separator + SetDrawColor(0.5, 0.5, 0.5) + DrawImage(nil, 4, drawY, vp.width - 8, 2) + drawY = drawY + 6 + + -- Progress section + drawY = self:DrawProgressSection(drawY, colWidth, vp, compareEntry) + drawY = drawY + 4 + + -- Separator + SetDrawColor(0.5, 0.5, 0.5) + DrawImage(nil, 4, drawY, vp.width - 8, 2) + drawY = drawY + 6 + + -- Stat comparison + local displayStats = self.primaryBuild.displayStats + local primaryEnv = self.primaryBuild.calcsTab.mainEnv + local compareEnv = compareEntry.calcsTab.mainEnv + + -- Section: Offence + SetDrawColor(1, 1, 1) + DrawString(10, drawY, "LEFT", headerHeight, "VAR", colorCodes.OFFENCE .. "Offence") + drawY = drawY + headerHeight + 2 + + drawY = self:DrawStatSection(drawY, colWidth, vp, displayStats, primaryOutput, compareOutput, primaryEnv, compareEnv, {"attack", "spell", "dot"}) + + -- Section: Defence + drawY = drawY + 6 + SetDrawColor(0.5, 0.5, 0.5) + DrawImage(nil, 4, drawY, vp.width - 8, 1) + drawY = drawY + 4 + SetDrawColor(1, 1, 1) + DrawString(10, drawY, "LEFT", headerHeight, "VAR", colorCodes.DEFENCE .. "Defence") + drawY = drawY + headerHeight + 2 + + drawY = self:DrawStatSection(drawY, colWidth, vp, displayStats, primaryOutput, compareOutput, primaryEnv, compareEnv, nil) + + SetViewport() +end + +function CompareTabClass:DrawProgressSection(drawY, colWidth, vp, compareEntry) + local lineHeight = 16 + + -- Count matching passive nodes + local primaryNodes = self.primaryBuild.spec and self.primaryBuild.spec.allocNodes or {} + local compareNodes = compareEntry.spec and compareEntry.spec.allocNodes or {} + local primaryCount = 0 + local compareCount = 0 + local matchCount = 0 + for nodeId, _ in pairs(primaryNodes) do + if type(nodeId) == "number" and nodeId < 65536 then -- Exclude special nodes + primaryCount = primaryCount + 1 + if compareNodes[nodeId] then + matchCount = matchCount + 1 + end + end + end + for nodeId, _ in pairs(compareNodes) do + if type(nodeId) == "number" and nodeId < 65536 then + compareCount = compareCount + 1 + end + end + + -- Count matching items + local primaryItemCount = 0 + local compareItemCount = 0 + local matchingItemCount = 0 + if self.primaryBuild.itemsTab and compareEntry.itemsTab then + local baseSlots = { "Weapon 1", "Weapon 2", "Helmet", "Body Armour", "Gloves", "Boots", "Amulet", "Ring 1", "Ring 2", "Belt" } + for _, slotName in ipairs(baseSlots) do + local pSlot = self.primaryBuild.itemsTab.slots[slotName] + local cSlot = compareEntry.itemsTab.slots[slotName] + local pItem = pSlot and self.primaryBuild.itemsTab.items[pSlot.selItemId] + local cItem = cSlot and compareEntry.itemsTab.items[cSlot.selItemId] + if pItem then primaryItemCount = primaryItemCount + 1 end + if cItem then compareItemCount = compareItemCount + 1 end + if pItem and cItem and pItem.name == cItem.name then + matchingItemCount = matchingItemCount + 1 + end + end + end + + -- Count matching gems + local primaryGemCount = 0 + local compareGemCount = 0 + local matchingGemCount = 0 + if self.primaryBuild.skillsTab and compareEntry.skillsTab then + local pGems = {} + for _, group in ipairs(self.primaryBuild.skillsTab.socketGroupList) do + for _, gem in ipairs(group.gemList) do + if gem.grantedEffect then + pGems[gem.grantedEffect.name] = true + primaryGemCount = primaryGemCount + 1 + end + end + end + for _, group in ipairs(compareEntry.skillsTab.socketGroupList) do + for _, gem in ipairs(group.gemList) do + if gem.grantedEffect then + compareGemCount = compareGemCount + 1 + if pGems[gem.grantedEffect.name] then + matchingGemCount = matchingGemCount + 1 + end + end + end + end + end + + SetDrawColor(1, 1, 1) + DrawString(10, drawY, "LEFT", 18, "VAR", "^7Progress toward comparison build:") + drawY = drawY + 22 + + -- Nodes progress + local nodePercent = compareCount > 0 and m_floor(matchCount / compareCount * 100) or 0 + local nodeColor = nodePercent >= 90 and colorCodes.POSITIVE or nodePercent >= 50 and colorCodes.WARNING or colorCodes.NEGATIVE + DrawString(20, drawY, "LEFT", lineHeight, "VAR", + s_format("^7Passive Nodes: %s%d^7/%d matched (%s%d%%^7) - You: %d, Target: %d", nodeColor, matchCount, compareCount, nodeColor, nodePercent, primaryCount, compareCount)) + drawY = drawY + lineHeight + 2 + + -- Items progress + local itemPercent = compareItemCount > 0 and m_floor(matchingItemCount / compareItemCount * 100) or 0 + local itemColor = itemPercent >= 90 and colorCodes.POSITIVE or itemPercent >= 50 and colorCodes.WARNING or colorCodes.NEGATIVE + DrawString(20, drawY, "LEFT", lineHeight, "VAR", + s_format("^7Items: %s%d^7/%d matching (%s%d%%^7)", itemColor, matchingItemCount, compareItemCount, itemColor, itemPercent)) + drawY = drawY + lineHeight + 2 + + -- Gems progress + local gemPercent = compareGemCount > 0 and m_floor(matchingGemCount / compareGemCount * 100) or 0 + local gemColor = gemPercent >= 90 and colorCodes.POSITIVE or gemPercent >= 50 and colorCodes.WARNING or colorCodes.NEGATIVE + DrawString(20, drawY, "LEFT", lineHeight, "VAR", + s_format("^7Gems: %s%d^7/%d matching (%s%d%%^7)", gemColor, matchingGemCount, compareGemCount, gemColor, gemPercent)) + drawY = drawY + lineHeight + 2 + + return drawY +end + +function CompareTabClass:DrawStatSection(drawY, colWidth, vp, displayStats, primaryOutput, compareOutput, primaryEnv, compareEnv, flagFilter) + local lineHeight = 16 + + for _, statData in ipairs(displayStats) do + if statData.stat then + local primaryVal = primaryOutput[statData.stat] or 0 + local compareVal = compareOutput[statData.stat] or 0 + + -- Skip table-type stat values (some outputs are breakdowns, not numbers) + if type(primaryVal) == "table" or type(compareVal) == "table" then + primaryVal = 0 + compareVal = 0 + end + + -- Skip zero-value stats + if primaryVal ~= 0 or compareVal ~= 0 then + -- Check if stat has a condition function that filters it + if not statData.condFunc or statData.condFunc(primaryVal, primaryOutput) or statData.condFunc(compareVal, compareOutput) then + -- Format values + local fmt = statData.fmt or "d" + local multiplier = (statData.pc or statData.mod) and 100 or 1 + local primaryStr = s_format("%"..fmt, primaryVal * multiplier) + local compareStr = s_format("%"..fmt, compareVal * multiplier) + primaryStr = formatNumSep(primaryStr) + compareStr = formatNumSep(compareStr) + + -- Determine diff color + local diff = compareVal - primaryVal + local diffStr = "" + local diffColor = "^7" + if diff > 0.001 or diff < -0.001 then + local isBetter = (statData.lowerIsBetter and diff < 0) or (not statData.lowerIsBetter and diff > 0) + diffColor = isBetter and colorCodes.POSITIVE or colorCodes.NEGATIVE + local diffVal = diff * multiplier + diffStr = s_format("%+"..fmt, diffVal) + diffStr = formatNumSep(diffStr) + end + + -- Draw stat row + DrawString(20, drawY, "LEFT", lineHeight, "VAR", "^7" .. (statData.label or statData.stat) .. ":") + DrawString(colWidth - 10, drawY, "RIGHT", lineHeight, "VAR", "^7" .. primaryStr) + DrawString(colWidth + colWidth - 10, drawY, "RIGHT", lineHeight, "VAR", diffColor .. compareStr) + if diffStr ~= "" then + DrawString(colWidth + colWidth + 10, drawY, "LEFT", lineHeight, "VAR", diffColor .. "(" .. diffStr .. ")") + end + drawY = drawY + lineHeight + 1 + end + end + end + end + return drawY +end + +-- ============================================================ +-- TREE VIEW (side-by-side) +-- ============================================================ +function CompareTabClass:DrawTree(vp, inputEvents, compareEntry) + local layout = self.treeLayout + if not layout then return end + + local halfWidth = layout.halfWidth + local footerHeight = layout.footerHeight + local labelHeight = 20 + + -- Labels (drawn in absolute screen coords before any viewport changes) + SetDrawColor(1, 1, 1) + DrawString(vp.x + halfWidth / 2, vp.y + 2, "CENTER", 16, "VAR", colorCodes.POSITIVE .. "Your Build" .. "^7 (" .. (self.primaryBuild.buildName or "Current") .. ")") + DrawString(vp.x + halfWidth + 4 + halfWidth / 2, vp.y + 2, "CENTER", 16, "VAR", colorCodes.WARNING .. "Compare" .. "^7 (" .. (compareEntry.label or "Comparison") .. ")") + + -- Divider (full height including footer) + SetDrawColor(0.5, 0.5, 0.5) + DrawImage(nil, vp.x + halfWidth, vp.y + labelHeight, 4, vp.height - labelHeight) + + -- Route input events to the panel containing the mouse + local origGetCursorPos = GetCursorPos + local mouseX, mouseY = origGetCursorPos() + local leftHasInput = mouseX < (vp.x + halfWidth + 2) + + local treeHeight = vp.height - labelHeight - footerHeight + + -- Left tree: SetViewport clips drawing; patch GetCursorPos so mouse coords + -- are viewport-relative (matching the {x=0,y=0} viewport passed to the tree) + local leftAbsX = vp.x + local leftAbsY = vp.y + labelHeight + if self.primaryBuild.treeTab and self.primaryBuild.treeTab.viewer then + SetViewport(leftAbsX, leftAbsY, halfWidth, treeHeight) + SetDrawLayer(nil, 0) -- Reset draw layer so background renders behind connectors + GetCursorPos = function() + local x, y = origGetCursorPos() + return x - leftAbsX, y - leftAbsY + end + local leftTreeVP = { x = 0, y = 0, width = halfWidth, height = treeHeight } + self.primaryBuild.treeTab.viewer:Draw(self.primaryBuild, leftTreeVP, leftHasInput and inputEvents or {}) + SetViewport() + end + + -- Right tree: same approach - SetViewport for clipping, patched cursor + local rightAbsX = vp.x + halfWidth + 4 + local rightAbsY = vp.y + labelHeight + if compareEntry.treeTab and compareEntry.treeTab.viewer then + SetViewport(rightAbsX, rightAbsY, halfWidth, treeHeight) + SetDrawLayer(nil, 0) -- Reset draw layer so background renders behind connectors + GetCursorPos = function() + local x, y = origGetCursorPos() + return x - rightAbsX, y - rightAbsY + end + local rightTreeVP = { x = 0, y = 0, width = halfWidth, height = treeHeight } + compareEntry.treeTab.viewer:Draw(compareEntry, rightTreeVP, leftHasInput and {} or inputEvents) + SetViewport() + end + + -- Restore original GetCursorPos + GetCursorPos = origGetCursorPos + + -- Footer backgrounds and controls are drawn by Draw() before this method + -- (so that controls render on top of the background rectangles) +end + +-- ============================================================ +-- ITEMS VIEW +-- ============================================================ +function CompareTabClass:DrawItems(vp, compareEntry) + local baseSlots = { "Weapon 1", "Weapon 2", "Helmet", "Body Armour", "Gloves", "Boots", "Amulet", "Ring 1", "Ring 2", "Belt" } + local lineHeight = 20 + local slotHeight = 46 + local colWidth = m_floor(vp.width / 2) + + SetViewport(vp.x, vp.y, vp.width, vp.height) + local drawY = 4 - self.scrollY + + -- Headers + SetDrawColor(1, 1, 1) + DrawString(10, drawY, "LEFT", 18, "VAR", colorCodes.POSITIVE .. "Your Build") + DrawString(colWidth + 10, drawY, "LEFT", 18, "VAR", colorCodes.WARNING .. "Compare Build") + drawY = drawY + 24 + + for _, slotName in ipairs(baseSlots) do + -- Separator + SetDrawColor(0.3, 0.3, 0.3) + DrawImage(nil, 4, drawY, vp.width - 8, 1) + drawY = drawY + 2 + + -- Slot label + SetDrawColor(1, 1, 1) + DrawString(10, drawY, "LEFT", 16, "VAR", "^7" .. slotName .. ":") + + -- Get items from both builds + local pSlot = self.primaryBuild.itemsTab and self.primaryBuild.itemsTab.slots and self.primaryBuild.itemsTab.slots[slotName] + local cSlot = compareEntry.itemsTab and compareEntry.itemsTab.slots and compareEntry.itemsTab.slots[slotName] + local pItem = pSlot and self.primaryBuild.itemsTab.items and self.primaryBuild.itemsTab.items[pSlot.selItemId] + local cItem = cSlot and compareEntry.itemsTab and compareEntry.itemsTab.items and compareEntry.itemsTab.items[cSlot.selItemId] + + local pName = pItem and pItem.name or "(empty)" + local cName = cItem and cItem.name or "(empty)" + + -- Color code by rarity + local pColor = "^7" + if pItem then + if pItem.rarity == "UNIQUE" then pColor = colorCodes.UNIQUE + elseif pItem.rarity == "RARE" then pColor = colorCodes.RARE + elseif pItem.rarity == "MAGIC" then pColor = colorCodes.MAGIC + else pColor = colorCodes.NORMAL end + end + local cColor = "^7" + if cItem then + if cItem.rarity == "UNIQUE" then cColor = colorCodes.UNIQUE + elseif cItem.rarity == "RARE" then cColor = colorCodes.RARE + elseif cItem.rarity == "MAGIC" then cColor = colorCodes.MAGIC + else cColor = colorCodes.NORMAL end + end + + drawY = drawY + 18 + + -- Draw item names + DrawString(20, drawY, "LEFT", 16, "VAR", pColor .. pName) + DrawString(colWidth + 20, drawY, "LEFT", 16, "VAR", cColor .. cName) + + -- Show diff indicator + local isSame = pItem and cItem and pItem.name == cItem.name + local diffLabel = "" + if not pItem and not cItem then + diffLabel = "^8(both empty)" + elseif isSame then + diffLabel = colorCodes.POSITIVE .. "(match)" + elseif not pItem then + diffLabel = colorCodes.NEGATIVE .. "(missing)" + elseif not cItem then + diffLabel = colorCodes.TIP .. "(extra)" + else + diffLabel = colorCodes.WARNING .. "(different)" + end + DrawString(colWidth - 10, drawY, "RIGHT", 14, "VAR", diffLabel) + + drawY = drawY + 20 + end + + SetViewport() +end + +-- ============================================================ +-- SKILLS VIEW +-- ============================================================ +function CompareTabClass:DrawSkills(vp, compareEntry) + local lineHeight = 18 + local colWidth = m_floor(vp.width / 2) + + SetViewport(vp.x, vp.y, vp.width, vp.height) + local drawY = 4 - self.scrollY + + -- Headers + SetDrawColor(1, 1, 1) + DrawString(10, drawY, "LEFT", 18, "VAR", colorCodes.POSITIVE .. "Your Build - Socket Groups") + DrawString(colWidth + 10, drawY, "LEFT", 18, "VAR", colorCodes.WARNING .. "Compare Build - Socket Groups") + drawY = drawY + 24 + + -- Get socket groups from both builds + local pGroups = self.primaryBuild.skillsTab and self.primaryBuild.skillsTab.socketGroupList or {} + local cGroups = compareEntry.skillsTab and compareEntry.skillsTab.socketGroupList or {} + + -- Draw primary build groups + local maxGroups = m_max(#pGroups, #cGroups) + for i = 1, maxGroups do + SetDrawColor(0.3, 0.3, 0.3) + DrawImage(nil, 4, drawY, vp.width - 8, 1) + drawY = drawY + 2 + + -- Primary group + local pGroup = pGroups[i] + if pGroup then + local groupLabel = pGroup.displayLabel or pGroup.label or ("Group " .. i) + if pGroup.slot then + groupLabel = groupLabel .. " (" .. pGroup.slot .. ")" + end + DrawString(10, drawY, "LEFT", 16, "VAR", "^7" .. groupLabel) + local gemY = drawY + lineHeight + for _, gem in ipairs(pGroup.gemList or {}) do + local gemName = gem.grantedEffect and gem.grantedEffect.name or gem.nameSpec or "?" + local levelStr = gem.level and (" Lv" .. gem.level) or "" + local qualStr = gem.quality and gem.quality > 0 and ("/" .. gem.quality .. "q") or "" + DrawString(20, gemY, "LEFT", 14, "VAR", colorCodes.GEM .. gemName .. "^7" .. levelStr .. qualStr) + gemY = gemY + 16 + end + end + + -- Compare group + local cGroup = cGroups[i] + if cGroup then + local groupLabel = cGroup.displayLabel or cGroup.label or ("Group " .. i) + if cGroup.slot then + groupLabel = groupLabel .. " (" .. cGroup.slot .. ")" + end + DrawString(colWidth + 10, drawY, "LEFT", 16, "VAR", "^7" .. groupLabel) + local gemY = drawY + lineHeight + for _, gem in ipairs(cGroup.gemList or {}) do + local gemName = gem.grantedEffect and gem.grantedEffect.name or gem.nameSpec or "?" + local levelStr = gem.level and (" Lv" .. gem.level) or "" + local qualStr = gem.quality and gem.quality > 0 and ("/" .. gem.quality .. "q") or "" + DrawString(colWidth + 20, gemY, "LEFT", 14, "VAR", colorCodes.GEM .. gemName .. "^7" .. levelStr .. qualStr) + gemY = gemY + 16 + end + end + + -- Calculate height for this row + local pGemCount = pGroup and #(pGroup.gemList or {}) or 0 + local cGemCount = cGroup and #(cGroup.gemList or {}) or 0 + local rowGems = m_max(pGemCount, cGemCount) + drawY = drawY + lineHeight + rowGems * 16 + 6 + end + + SetViewport() +end + +-- ============================================================ +-- CALCS VIEW +-- ============================================================ +function CompareTabClass:DrawCalcs(vp, compareEntry) + local primaryOutput = self.primaryBuild.calcsTab.mainOutput + local compareOutput = compareEntry:GetOutput() + if not primaryOutput or not compareOutput then + return + end + + local lineHeight = 16 + local headerHeight = 20 + local displayStats = self.primaryBuild.displayStats + + SetViewport(vp.x, vp.y, vp.width, vp.height) + local drawY = 4 - self.scrollY + + -- Column headers + local col1 = 10 -- Stat name + local col2 = 300 -- Your Build value + local col3 = 450 -- Compare Build value + local col4 = 600 -- Difference + + SetDrawColor(1, 1, 1) + DrawString(col1, drawY, "LEFT", headerHeight, "VAR", "^7Stat") + DrawString(col2, drawY, "LEFT", headerHeight, "VAR", colorCodes.POSITIVE .. "Your Build") + DrawString(col3, drawY, "LEFT", headerHeight, "VAR", colorCodes.WARNING .. "Compare") + DrawString(col4, drawY, "LEFT", headerHeight, "VAR", "^7Difference") + drawY = drawY + headerHeight + 4 + + SetDrawColor(0.5, 0.5, 0.5) + DrawImage(nil, 4, drawY, vp.width - 8, 2) + drawY = drawY + 6 + + for _, statData in ipairs(displayStats) do + if statData.stat then + local primaryVal = primaryOutput[statData.stat] or 0 + local compareVal = compareOutput[statData.stat] or 0 + + -- Skip table-type stat values (some outputs are breakdowns, not numbers) + if type(primaryVal) == "table" or type(compareVal) == "table" then + primaryVal = 0 + compareVal = 0 + end + + if primaryVal ~= 0 or compareVal ~= 0 then + if not statData.condFunc or statData.condFunc(primaryVal, primaryOutput) or statData.condFunc(compareVal, compareOutput) then + local fmt = statData.fmt or "d" + local multiplier = (statData.pc or statData.mod) and 100 or 1 + + local primaryStr = s_format("%"..fmt, primaryVal * multiplier) + local compareStr = s_format("%"..fmt, compareVal * multiplier) + primaryStr = formatNumSep(primaryStr) + compareStr = formatNumSep(compareStr) + + local diff = compareVal - primaryVal + local diffStr = "" + local diffColor = "^7" + if diff > 0.001 or diff < -0.001 then + local isBetter = (statData.lowerIsBetter and diff < 0) or (not statData.lowerIsBetter and diff > 0) + diffColor = isBetter and colorCodes.POSITIVE or colorCodes.NEGATIVE + diffStr = s_format("%+"..fmt, diff * multiplier) + diffStr = formatNumSep(diffStr) + if statData.compPercent and primaryVal ~= 0 then + local pc = compareVal / primaryVal * 100 - 100 + diffStr = diffStr .. s_format(" (%+.1f%%)", pc) + end + end + + DrawString(col1, drawY, "LEFT", lineHeight, "VAR", "^7" .. (statData.label or statData.stat)) + DrawString(col2, drawY, "LEFT", lineHeight, "VAR", "^7" .. primaryStr) + DrawString(col3, drawY, "LEFT", lineHeight, "VAR", diffColor .. compareStr) + if diffStr ~= "" then + DrawString(col4, drawY, "LEFT", lineHeight, "VAR", diffColor .. diffStr) + end + drawY = drawY + lineHeight + 1 + end + end + end + end + + SetViewport() +end + +-- ============================================================ +-- CONFIG VIEW +-- ============================================================ +function CompareTabClass:DrawConfig(vp, compareEntry) + local lineHeight = 18 + local headerHeight = 20 + + SetViewport(vp.x, vp.y, vp.width, vp.height) + local drawY = 4 - self.scrollY + + -- Headers + local col1 = 10 + local col2 = 300 + local col3 = 500 + + SetDrawColor(1, 1, 1) + DrawString(col1, drawY, "LEFT", headerHeight, "VAR", "^7Configuration Option") + DrawString(col2, drawY, "LEFT", headerHeight, "VAR", colorCodes.POSITIVE .. "Your Build") + DrawString(col3, drawY, "LEFT", headerHeight, "VAR", colorCodes.WARNING .. "Compare Build") + drawY = drawY + headerHeight + 4 + + SetDrawColor(0.5, 0.5, 0.5) + DrawImage(nil, 4, drawY, vp.width - 8, 2) + drawY = drawY + 6 + + -- Compare config inputs + local pInput = self.primaryBuild.configTab.input or {} + local cInput = compareEntry.configTab.input or {} + + -- Collect all unique keys + local allKeys = {} + local keySet = {} + for k, _ in pairs(pInput) do + if not keySet[k] then + t_insert(allKeys, k) + keySet[k] = true + end + end + for k, _ in pairs(cInput) do + if not keySet[k] then + t_insert(allKeys, k) + keySet[k] = true + end + end + table.sort(allKeys) + + local diffCount = 0 + for _, key in ipairs(allKeys) do + local pVal = pInput[key] + local cVal = cInput[key] + + -- Only show differences + if tostring(pVal or "") ~= tostring(cVal or "") then + local pStr = pVal ~= nil and tostring(pVal) or "^8(not set)" + local cStr = cVal ~= nil and tostring(cVal) or "^8(not set)" + + -- Format boolean values + if pVal == true then pStr = colorCodes.POSITIVE .. "Yes" + elseif pVal == false then pStr = colorCodes.NEGATIVE .. "No" end + if cVal == true then cStr = colorCodes.POSITIVE .. "Yes" + elseif cVal == false then cStr = colorCodes.NEGATIVE .. "No" end + + DrawString(col1, drawY, "LEFT", lineHeight, "VAR", "^7" .. key) + DrawString(col2, drawY, "LEFT", lineHeight, "VAR", "^7" .. pStr) + DrawString(col3, drawY, "LEFT", lineHeight, "VAR", "^7" .. cStr) + drawY = drawY + lineHeight + 1 + diffCount = diffCount + 1 + end + end + + if diffCount == 0 then + DrawString(10, drawY, "LEFT", lineHeight, "VAR", colorCodes.POSITIVE .. "No configuration differences found.") + end + + SetViewport() +end + +return CompareTabClass diff --git a/src/Classes/ImportTab.lua b/src/Classes/ImportTab.lua index 3cc8b80097..e339366f9e 100644 --- a/src/Classes/ImportTab.lua +++ b/src/Classes/ImportTab.lua @@ -314,6 +314,15 @@ You can get this from your web browser's cookies while logged into the Path of E self.build:Init(self.build.dbFileName, self.build.buildName, self.importCodeXML, false, self.importCodeSite and self.controls.importCodeIn.buf or nil) self.build.viewMode = "TREE" end) + elseif self.controls.importCodeMode.selIndex == 3 then + -- Import as comparison build + if self.build.compareTab then + if self.build.compareTab:ImportBuild(self.importCodeXML, "Imported comparison") then + self.build.viewMode = "COMPARE" + else + main:OpenMessagePopup("Import Error", "Failed to import build for comparison.") + end + end else self.build:Shutdown() self.build:Init(false, "Imported build", self.importCodeXML, false, self.importCodeSite and self.controls.importCodeIn.buf or nil) @@ -331,9 +340,9 @@ You can get this from your web browser's cookies while logged into the Path of E self.controls.importCodeState.label = function() return self.importCodeDetail or "" end - self.controls.importCodeMode = new("DropDownControl", {"TOPLEFT",self.controls.importCodeIn,"BOTTOMLEFT"}, {0, 4, 160, 20}, { "Import to this build", "Import to a new build" }) + self.controls.importCodeMode = new("DropDownControl", {"TOPLEFT",self.controls.importCodeIn,"BOTTOMLEFT"}, {0, 4, 200, 20}, { "Import to this build", "Import to a new build", "Import as comparison" }) self.controls.importCodeMode.enabled = function() - return self.build.dbFileName and self.importCodeValid + return (self.build.dbFileName or self.controls.importCodeMode.selIndex == 3) and self.importCodeValid end self.controls.importCodeGo = new("ButtonControl", {"LEFT",self.controls.importCodeMode,"RIGHT"}, {8, 0, 160, 20}, "Import", function() if self.importCodeSite and not self.importCodeXML then diff --git a/src/Modules/Build.lua b/src/Modules/Build.lua index 6f8849ab7c..aa5987ae10 100644 --- a/src/Modules/Build.lua +++ b/src/Modules/Build.lua @@ -434,6 +434,10 @@ function buildMode:Init(dbFileName, buildName, buildXML, convertBuild, importLin self.viewMode = "PARTY" end) self.controls.modeParty.locked = function() return self.viewMode == "PARTY" end + self.controls.modeCompare = new("ButtonControl", {"LEFT",self.controls.modeParty,"RIGHT"}, {4, 0, 72, 20}, "Compare", function() + self.viewMode = "COMPARE" + end) + self.controls.modeCompare.locked = function() return self.viewMode == "COMPARE" end -- Skills self.controls.mainSkillLabel = new("LabelControl", {"TOPLEFT",self.anchorSideBar,"TOPLEFT"}, {0, 80, 300, 16}, "^7Main Skill:") self.controls.mainSocketGroup = new("DropDownControl", {"TOPLEFT",self.controls.mainSkillLabel,"BOTTOMLEFT"}, {0, 2, 300, 18}, nil, function(index, value) @@ -568,6 +572,7 @@ function buildMode:Init(dbFileName, buildName, buildXML, convertBuild, importLin self.treeTab = new("TreeTab", self) self.skillsTab = new("SkillsTab", self) self.calcsTab = new("CalcsTab", self) + self.compareTab = new("CompareTab", self) -- Load sections from the build file self.savers = { @@ -1143,6 +1148,8 @@ function buildMode:OnFrame(inputEvents) self.itemsTab:Draw(tabViewPort, inputEvents) elseif self.viewMode == "CALCS" then self.calcsTab:Draw(tabViewPort, inputEvents) + elseif self.viewMode == "COMPARE" then + self.compareTab:Draw(tabViewPort, inputEvents) end self.unsaved = self.modFlag or self.notesTab.modFlag or self.partyTab.modFlag or self.configTab.modFlag or self.treeTab.modFlag or self.treeTab.searchFlag or self.spec.modFlag or self.skillsTab.modFlag or self.itemsTab.modFlag or self.calcsTab.modFlag @@ -1162,6 +1169,7 @@ function buildMode:OnFrame(inputEvents) SetDrawColor(0.85, 0.85, 0.85) DrawImage(nil, sideBarWidth - 4, 32, 4, main.screenH - 32) + self:DrawControls(main.viewPort) end From f1e5217f20db4c200b7ce964f1d48d55b08c20de Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Sat, 7 Mar 2026 15:08:23 +0100 Subject: [PATCH 02/59] color code summary, add name for imported build, add item hover tooltips --- src/Classes/CompareEntry.lua | 161 ++++++++++++- src/Classes/CompareTab.lua | 441 ++++++++++++++++++++++++++++------- 2 files changed, 515 insertions(+), 87 deletions(-) diff --git a/src/Classes/CompareEntry.lua b/src/Classes/CompareEntry.lua index 9db65d9413..135f869085 100644 --- a/src/Classes/CompareEntry.lua +++ b/src/Classes/CompareEntry.lua @@ -5,6 +5,7 @@ -- without setting up the full UI chrome of the primary build. -- local t_insert = table.insert +local s_format = string.format local m_min = math.min local m_max = math.max @@ -235,8 +236,125 @@ function CompareEntryClass:RefreshStatList() -- No sidebar to refresh in comparison entry end -function CompareEntryClass:RefreshSkillSelectControls() - -- No skill select controls in comparison entry +function CompareEntryClass:SetMainSocketGroup(index) + self.mainSocketGroup = index + self.modFlag = true + self.buildFlag = true +end + +function CompareEntryClass:RefreshSkillSelectControls(controls, mainGroup, suffix) + -- Populate skill select controls (adapted from Build.lua:RefreshSkillSelectControls, lines 1444-1542) + if not controls or not controls.mainSocketGroup then return end + controls.mainSocketGroup.selIndex = mainGroup + wipeTable(controls.mainSocketGroup.list) + for i, socketGroup in pairs(self.skillsTab.socketGroupList) do + controls.mainSocketGroup.list[i] = { val = i, label = socketGroup.displayLabel } + end + controls.mainSocketGroup:CheckDroppedWidth(true) + if #controls.mainSocketGroup.list == 0 then + controls.mainSocketGroup.list[1] = { val = 1, label = "" } + controls.mainSkill.shown = false + controls.mainSkillPart.shown = false + controls.mainSkillMineCount.shown = false + controls.mainSkillStageCount.shown = false + controls.mainSkillMinion.shown = false + controls.mainSkillMinionSkill.shown = false + else + local mainSocketGroup = self.skillsTab.socketGroupList[mainGroup] + if not mainSocketGroup then + mainSocketGroup = self.skillsTab.socketGroupList[1] + mainGroup = 1 + end + local displaySkillList = mainSocketGroup["displaySkillList"..suffix] + if not displaySkillList then + controls.mainSkill.shown = false + controls.mainSkillPart.shown = false + controls.mainSkillMineCount.shown = false + controls.mainSkillStageCount.shown = false + controls.mainSkillMinion.shown = false + controls.mainSkillMinionSkill.shown = false + return + end + local mainActiveSkill = mainSocketGroup["mainActiveSkill"..suffix] or 1 + wipeTable(controls.mainSkill.list) + for i, activeSkill in ipairs(displaySkillList) do + local explodeSource = activeSkill.activeEffect.srcInstance.explodeSource + local explodeSourceName = explodeSource and (explodeSource.name or explodeSource.dn) + local colourCoded = explodeSourceName and ("From "..colorCodes[explodeSource.rarity or "NORMAL"]..explodeSourceName) + t_insert(controls.mainSkill.list, { val = i, label = colourCoded or activeSkill.activeEffect.grantedEffect.name }) + end + controls.mainSkill.enabled = #displaySkillList > 1 + controls.mainSkill.selIndex = mainActiveSkill + controls.mainSkill.shown = true + controls.mainSkillPart.shown = false + controls.mainSkillMineCount.shown = false + controls.mainSkillStageCount.shown = false + controls.mainSkillMinion.shown = false + controls.mainSkillMinionSkill.shown = false + if displaySkillList[1] then + local activeSkill = displaySkillList[mainActiveSkill] + if not activeSkill then + activeSkill = displaySkillList[1] + end + local activeEffect = activeSkill.activeEffect + if activeEffect then + if activeEffect.grantedEffect.parts and #activeEffect.grantedEffect.parts > 1 then + controls.mainSkillPart.shown = true + wipeTable(controls.mainSkillPart.list) + for i, part in ipairs(activeEffect.grantedEffect.parts) do + t_insert(controls.mainSkillPart.list, { val = i, label = part.name }) + end + controls.mainSkillPart.selIndex = activeEffect.srcInstance["skillPart"..suffix] or 1 + if activeEffect.grantedEffect.parts[controls.mainSkillPart.selIndex] and activeEffect.grantedEffect.parts[controls.mainSkillPart.selIndex].stages then + controls.mainSkillStageCount.shown = true + controls.mainSkillStageCount.buf = tostring(activeEffect.srcInstance["skillStageCount"..suffix] or activeEffect.grantedEffect.parts[controls.mainSkillPart.selIndex].stagesMin or 1) + end + end + if activeSkill.skillFlags and activeSkill.skillFlags.mine then + controls.mainSkillMineCount.shown = true + controls.mainSkillMineCount.buf = tostring(activeEffect.srcInstance["skillMineCount"..suffix] or "") + end + if activeSkill.skillFlags and activeSkill.skillFlags.multiStage and not (activeEffect.grantedEffect.parts and #activeEffect.grantedEffect.parts > 1) then + controls.mainSkillStageCount.shown = true + controls.mainSkillStageCount.buf = tostring(activeEffect.srcInstance["skillStageCount"..suffix] or activeSkill.skillData.stagesMin or 1) + end + if activeSkill.skillFlags and not activeSkill.skillFlags.disable and (activeEffect.grantedEffect.minionList or (activeSkill.minionList and activeSkill.minionList[1])) then + wipeTable(controls.mainSkillMinion.list) + if activeEffect.grantedEffect.minionHasItemSet then + for _, itemSetId in ipairs(self.itemsTab.itemSetOrderList) do + local itemSet = self.itemsTab.itemSets[itemSetId] + t_insert(controls.mainSkillMinion.list, { + label = itemSet.title or "Default Item Set", + itemSetId = itemSetId, + }) + end + controls.mainSkillMinion:SelByValue(activeEffect.srcInstance["skillMinionItemSet"..suffix] or 1, "itemSetId") + else + for _, minionId in ipairs(activeSkill.minionList) do + t_insert(controls.mainSkillMinion.list, { + label = self.data.minions[minionId] and self.data.minions[minionId].name or minionId, + minionId = minionId, + }) + end + controls.mainSkillMinion:SelByValue(activeEffect.srcInstance["skillMinion"..suffix] or (controls.mainSkillMinion.list[1] and controls.mainSkillMinion.list[1].minionId), "minionId") + end + controls.mainSkillMinion.enabled = #controls.mainSkillMinion.list > 1 + controls.mainSkillMinion.shown = true + wipeTable(controls.mainSkillMinionSkill.list) + if activeSkill.minion then + for _, minionSkill in ipairs(activeSkill.minion.activeSkillList) do + t_insert(controls.mainSkillMinionSkill.list, minionSkill.activeEffect.grantedEffect.name) + end + controls.mainSkillMinionSkill.selIndex = activeEffect.srcInstance["skillMinionSkill"..suffix] or 1 + controls.mainSkillMinionSkill.shown = true + controls.mainSkillMinionSkill.enabled = #controls.mainSkillMinionSkill.list > 1 + else + t_insert(controls.mainSkillMinion.list, "") + end + end + end + end + end end function CompareEntryClass:UpdateClassDropdowns() @@ -337,4 +455,43 @@ function CompareEntryClass:CompareStatList(tooltip, statList, actor, baseOutput, return count end +-- Add requirements to tooltip +do + local req = { } + function CompareEntryClass:AddRequirementsToTooltip(tooltip, level, str, dex, int, strBase, dexBase, intBase) + if level and level > 0 then + t_insert(req, s_format("^x7F7F7FLevel %s%d", main:StatColor(level, nil, self.characterLevel), level)) + end + if self.calcsTab.mainEnv.modDB:Flag(nil, "OmniscienceRequirements") then + local omniSatisfy = self.calcsTab.mainEnv.modDB:Sum("INC", nil, "OmniAttributeRequirements") + local highestAttribute = 0 + for i, stat in ipairs({str, dex, int}) do + if((stat or 0) > highestAttribute) then + highestAttribute = stat + end + end + local omni = math.floor(highestAttribute * (100/omniSatisfy)) + if omni and (omni > 0 or omni > self.calcsTab.mainOutput.Omni) then + t_insert(req, s_format("%s%d ^x7F7F7FOmni", main:StatColor(omni, 0, self.calcsTab.mainOutput.Omni), omni)) + end + else + if str and (str > 14 or str > self.calcsTab.mainOutput.Str) then + t_insert(req, s_format("%s%d ^x7F7F7FStr", main:StatColor(str, strBase, self.calcsTab.mainOutput.Str), str)) + end + if dex and (dex > 14 or dex > self.calcsTab.mainOutput.Dex) then + t_insert(req, s_format("%s%d ^x7F7F7FDex", main:StatColor(dex, dexBase, self.calcsTab.mainOutput.Dex), dex)) + end + if int and (int > 14 or int > self.calcsTab.mainOutput.Int) then + t_insert(req, s_format("%s%d ^x7F7F7FInt", main:StatColor(int, intBase, self.calcsTab.mainOutput.Int), int)) + end + end + if req[1] then + local fontSizeBig = main.showFlavourText and 18 or 16 + tooltip:AddLine(fontSizeBig, "^x7F7F7FRequires "..table.concat(req, "^x7F7F7F, "), "FONTIN SC") + tooltip:AddSeparator(10) + end + wipeTable(req) + end +end + return CompareEntryClass diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index a23e396dba..9f8113ffa5 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -10,6 +10,31 @@ local m_max = math.max local m_floor = math.floor local s_format = string.format +-- Flag matching for stat filtering (same logic as Build.lua lines 33-57) +local function matchFlags(reqFlags, notFlags, flags) + if type(reqFlags) == "string" then + reqFlags = { reqFlags } + end + if reqFlags then + for _, flag in ipairs(reqFlags) do + if not flags[flag] then + return + end + end + end + if type(notFlags) == "string" then + notFlags = { notFlags } + end + if notFlags then + for _, flag in ipairs(notFlags) do + if flags[flag] then + return + end + end + end + return true +end + local CompareTabClass = newClass("CompareTab", "ControlHost", "Control", function(self, primaryBuild) self.ControlHost() self.Control() @@ -32,6 +57,9 @@ local CompareTabClass = newClass("CompareTab", "ControlHost", "Control", functio -- Track when tree search fields need syncing with viewer state self.treeSearchNeedsSync = true + -- Tooltip for item hover in Items view + self.itemTooltip = new("Tooltip") + -- Controls for the comparison screen self:InitControls() end) @@ -61,7 +89,7 @@ function CompareTabClass:InitControls() end -- Build B selector dropdown - self.controls.compareBuildLabel = new("LabelControl", {"TOPLEFT", self.controls.subTabAnchor, "TOPLEFT"}, {0, -48, 0, 16}, "^7Compare with:") + self.controls.compareBuildLabel = new("LabelControl", {"TOPLEFT", self.controls.subTabAnchor, "TOPLEFT"}, {0, -70, 0, 16}, "^7Compare with:") self.controls.compareBuildSelect = new("DropDownControl", {"LEFT", self.controls.compareBuildLabel, "RIGHT"}, {4, 0, 250, 20}, {}, function(index, value) if index and index > 0 and index <= #self.compareEntries then self.activeCompareIndex = index @@ -117,7 +145,7 @@ function CompareTabClass:InitControls() return #self.compareEntries > 0 end - self.controls.compareSetsLabel = new("LabelControl", {"TOPLEFT", self.controls.subTabAnchor, "TOPLEFT"}, {0, -22, 0, 16}, "^7Sets:") + self.controls.compareSetsLabel = new("LabelControl", {"TOPLEFT", self.controls.subTabAnchor, "TOPLEFT"}, {0, -44, 0, 16}, "^7Sets:") self.controls.compareSetsLabel.shown = setsEnabled -- Tree spec selector for comparison build @@ -159,6 +187,138 @@ function CompareTabClass:InitControls() end) self.controls.compareItemSetSelect.enabled = setsEnabled + -- ============================================================ + -- Comparison build main skill selector (row between sets and sub-tabs) + -- ============================================================ + self.controls.cmpSkillLabel = new("LabelControl", {"TOPLEFT", self.controls.subTabAnchor, "TOPLEFT"}, {0, -22, 0, 16}, "^7Skill:") + self.controls.cmpSkillLabel.shown = setsEnabled + + -- Socket group dropdown + self.controls.cmpSocketGroup = new("DropDownControl", {"LEFT", self.controls.cmpSkillLabel, "RIGHT"}, {2, 0, 200, 20}, {}, function(index, value) + local entry = self:GetActiveCompare() + if entry then + entry:SetMainSocketGroup(index) + end + end) + self.controls.cmpSocketGroup.shown = setsEnabled + self.controls.cmpSocketGroup.maxDroppedWidth = 500 + self.controls.cmpSocketGroup.enableDroppedWidth = true + + -- Active skill within group + self.controls.cmpMainSkill = new("DropDownControl", {"LEFT", self.controls.cmpSocketGroup, "RIGHT"}, {2, 0, 150, 20}, {}, function(index, value) + local entry = self:GetActiveCompare() + if entry then + local mainSocketGroup = entry.skillsTab.socketGroupList[entry.mainSocketGroup] + if mainSocketGroup then + mainSocketGroup.mainActiveSkill = index + entry.modFlag = true + entry.buildFlag = true + end + end + end) + self.controls.cmpMainSkill.shown = false + + -- Skill part (multi-part skills) + self.controls.cmpSkillPart = new("DropDownControl", {"LEFT", self.controls.cmpMainSkill, "RIGHT"}, {2, 0, 100, 20}, {}, function(index, value) + local entry = self:GetActiveCompare() + if entry then + local mainSocketGroup = entry.skillsTab.socketGroupList[entry.mainSocketGroup] + if mainSocketGroup then + local displaySkillList = mainSocketGroup.displaySkillList + local activeSkill = displaySkillList and displaySkillList[mainSocketGroup.mainActiveSkill or 1] + if activeSkill and activeSkill.activeEffect then + activeSkill.activeEffect.srcInstance.skillPart = index + entry.modFlag = true + entry.buildFlag = true + end + end + end + end) + self.controls.cmpSkillPart.shown = false + + -- Stage count + self.controls.cmpStageCountLabel = new("LabelControl", {"LEFT", self.controls.cmpSkillPart, "RIGHT"}, {4, 0, 0, 16}, "^7Stages:") + self.controls.cmpStageCountLabel.shown = function() return self.controls.cmpStageCount.shown end + self.controls.cmpStageCount = new("EditControl", {"LEFT", self.controls.cmpStageCountLabel, "RIGHT"}, {2, 0, 52, 20}, "", nil, "%D", 5, function(buf) + local entry = self:GetActiveCompare() + if entry then + local mainSocketGroup = entry.skillsTab.socketGroupList[entry.mainSocketGroup] + if mainSocketGroup then + local displaySkillList = mainSocketGroup.displaySkillList + local activeSkill = displaySkillList and displaySkillList[mainSocketGroup.mainActiveSkill or 1] + if activeSkill and activeSkill.activeEffect then + activeSkill.activeEffect.srcInstance.skillStageCount = tonumber(buf) + entry.modFlag = true + entry.buildFlag = true + end + end + end + end) + self.controls.cmpStageCount.shown = false + + -- Mine count + self.controls.cmpMineCountLabel = new("LabelControl", {"LEFT", self.controls.cmpStageCount, "RIGHT"}, {4, 0, 0, 16}, "^7Mines:") + self.controls.cmpMineCountLabel.shown = function() return self.controls.cmpMineCount.shown end + self.controls.cmpMineCount = new("EditControl", {"LEFT", self.controls.cmpMineCountLabel, "RIGHT"}, {2, 0, 52, 20}, "", nil, "%D", 5, function(buf) + local entry = self:GetActiveCompare() + if entry then + local mainSocketGroup = entry.skillsTab.socketGroupList[entry.mainSocketGroup] + if mainSocketGroup then + local displaySkillList = mainSocketGroup.displaySkillList + local activeSkill = displaySkillList and displaySkillList[mainSocketGroup.mainActiveSkill or 1] + if activeSkill and activeSkill.activeEffect then + activeSkill.activeEffect.srcInstance.skillMineCount = tonumber(buf) + entry.modFlag = true + entry.buildFlag = true + end + end + end + end) + self.controls.cmpMineCount.shown = false + + -- Minion selector + self.controls.cmpMinion = new("DropDownControl", {"LEFT", self.controls.cmpMineCount, "RIGHT"}, {4, 0, 140, 20}, {}, function(index, value) + local entry = self:GetActiveCompare() + if entry then + local mainSocketGroup = entry.skillsTab.socketGroupList[entry.mainSocketGroup] + if mainSocketGroup then + local displaySkillList = mainSocketGroup.displaySkillList + local activeSkill = displaySkillList and displaySkillList[mainSocketGroup.mainActiveSkill or 1] + if activeSkill and activeSkill.activeEffect then + local selected = self.controls.cmpMinion.list[index] + if selected then + if selected.itemSetId then + activeSkill.activeEffect.srcInstance.skillMinionItemSet = selected.itemSetId + elseif selected.minionId then + activeSkill.activeEffect.srcInstance.skillMinion = selected.minionId + end + entry.modFlag = true + entry.buildFlag = true + end + end + end + end + end) + self.controls.cmpMinion.shown = false + + -- Minion skill selector + self.controls.cmpMinionSkill = new("DropDownControl", {"LEFT", self.controls.cmpMinion, "RIGHT"}, {2, 0, 140, 20}, {}, function(index, value) + local entry = self:GetActiveCompare() + if entry then + local mainSocketGroup = entry.skillsTab.socketGroupList[entry.mainSocketGroup] + if mainSocketGroup then + local displaySkillList = mainSocketGroup.displaySkillList + local activeSkill = displaySkillList and displaySkillList[mainSocketGroup.mainActiveSkill or 1] + if activeSkill and activeSkill.activeEffect then + activeSkill.activeEffect.srcInstance.skillMinionSkill = index + entry.modFlag = true + entry.buildFlag = true + end + end + end + end) + self.controls.cmpMinionSkill.shown = false + -- ============================================================ -- Tree footer controls (visible only in TREE view mode with a comparison loaded) -- ============================================================ @@ -328,15 +488,18 @@ function CompareTabClass:OpenImportPopup() controls.go.onClick() end end - controls.state = new("LabelControl", {"TOPLEFT", controls.input, "BOTTOMLEFT"}, {0, 4, 0, 16}) + controls.nameLabel = new("LabelControl", nil, {-175, 80, 0, 16}, "^7Name:") + controls.name = new("EditControl", nil, {40, 80, 300, 20}, "", "Name (optional)", nil, 100, nil) + controls.state = new("LabelControl", {"TOPLEFT", controls.name, "BOTTOMLEFT"}, {0, 4, 0, 16}) controls.state.label = function() return stateText or "" end - controls.go = new("ButtonControl", nil, {-45, 100, 80, 20}, "Import", function() + controls.go = new("ButtonControl", nil, {-45, 130, 80, 20}, "Import", function() local buf = controls.input.buf if not buf or buf == "" then return end + local customName = controls.name.buf ~= "" and controls.name.buf or nil -- Check if it's a URL for _, site in ipairs(buildSites.websiteList) do @@ -346,7 +509,7 @@ function CompareTabClass:OpenImportPopup() if isSuccess then local xmlText = Inflate(common.base64.decode(codeData:gsub("-","+"):gsub("_","/"))) if xmlText then - self:ImportBuild(xmlText, "Imported from " .. site.label) + self:ImportBuild(xmlText, customName or ("Imported from " .. site.label)) main:ClosePopup() else stateText = colorCodes.NEGATIVE .. "Failed to decode build data" @@ -362,27 +525,27 @@ function CompareTabClass:OpenImportPopup() -- Try as a build code local xmlText = Inflate(common.base64.decode(buf:gsub("-","+"):gsub("_","/"))) if xmlText then - self:ImportBuild(xmlText, "Imported build") + self:ImportBuild(xmlText, customName or "Imported build") main:ClosePopup() else stateText = colorCodes.NEGATIVE .. "Invalid build code" end end) - controls.cancel = new("ButtonControl", nil, {45, 100, 80, 20}, "Cancel", function() + controls.cancel = new("ButtonControl", nil, {45, 130, 80, 20}, "Cancel", function() main:ClosePopup() end) - main:OpenPopup(500, 130, "Import Comparison Build", controls, "go", "input", "cancel") + main:OpenPopup(500, 160, "Import Comparison Build", controls, "go", "input", "cancel") end -- ============================================================ -- DRAW - Main render method -- ============================================================ function CompareTabClass:Draw(viewPort, inputEvents) - local controlBarHeight = 74 + local controlBarHeight = 96 -- Position top-bar controls self.controls.subTabAnchor.x = viewPort.x + 4 - self.controls.subTabAnchor.y = viewPort.y + 52 + self.controls.subTabAnchor.y = viewPort.y + 74 self.controls.compareBuildLabel.x = function() return 0 @@ -512,6 +675,19 @@ function CompareTabClass:Draw(viewPort, inputEvents) end self.controls.compareItemSetSelect:SetList(itemList) end + + -- Refresh comparison build skill selector controls + local cmpControls = { + mainSocketGroup = self.controls.cmpSocketGroup, + mainSkill = self.controls.cmpMainSkill, + mainSkillPart = self.controls.cmpSkillPart, + mainSkillStageCount = self.controls.cmpStageCount, + mainSkillMineCount = self.controls.cmpMineCount, + mainSkillMinion = self.controls.cmpMinion, + mainSkillMinionLibrary = { shown = false }, + mainSkillMinionSkill = self.controls.cmpMinionSkill, + } + compareEntry:RefreshSkillSelectControls(cmpControls, compareEntry.mainSocketGroup, "") end -- Handle scroll events for scrollable views @@ -539,13 +715,15 @@ function CompareTabClass:Draw(viewPort, inputEvents) if not compareEntry then -- No comparison build loaded - show instructions + SetViewport(contentVP.x, contentVP.y, contentVP.width, contentVP.height) SetDrawColor(1, 1, 1) - DrawString(contentVP.x + contentVP.width / 2, contentVP.y + 40, "CENTER", 20, "VAR", + DrawString(0, 40, "CENTER", 20, "VAR", "^7No comparison build loaded.") - DrawString(contentVP.x + contentVP.width / 2, contentVP.y + 70, "CENTER", 16, "VAR", + DrawString(0, 70, "CENTER", 16, "VAR", "^7Click " .. colorCodes.POSITIVE .. "Import..." .. "^7 above to import a build to compare against,") - DrawString(contentVP.x + contentVP.width / 2, contentVP.y + 90, "CENTER", 16, "VAR", + DrawString(0, 90, "CENTER", 16, "VAR", "^7or use the " .. colorCodes.POSITIVE .. "Import/Export Build" .. "^7 tab with \"Import as comparison\" mode.") + SetViewport() return end @@ -584,8 +762,8 @@ function CompareTabClass:DrawSummary(vp, compareEntry) -- Headers SetDrawColor(1, 1, 1) - DrawString(10, drawY, "LEFT", headerHeight, "VAR", colorCodes.POSITIVE .. "Your Build: ^7" .. (self.primaryBuild.buildName or "Current")) - DrawString(colWidth + 10, drawY, "LEFT", headerHeight, "VAR", colorCodes.WARNING .. "Compare: ^7" .. (compareEntry.label or "Comparison")) + DrawString(10, drawY, "LEFT", headerHeight, "VAR", colorCodes.POSITIVE .. (self.primaryBuild.buildName or "Your Build")) + DrawString(colWidth + 10, drawY, "LEFT", headerHeight, "VAR", colorCodes.WARNING .. (compareEntry.label or "Compare Build")) drawY = drawY + headerHeight + 4 -- Separator @@ -607,23 +785,7 @@ function CompareTabClass:DrawSummary(vp, compareEntry) local primaryEnv = self.primaryBuild.calcsTab.mainEnv local compareEnv = compareEntry.calcsTab.mainEnv - -- Section: Offence - SetDrawColor(1, 1, 1) - DrawString(10, drawY, "LEFT", headerHeight, "VAR", colorCodes.OFFENCE .. "Offence") - drawY = drawY + headerHeight + 2 - - drawY = self:DrawStatSection(drawY, colWidth, vp, displayStats, primaryOutput, compareOutput, primaryEnv, compareEnv, {"attack", "spell", "dot"}) - - -- Section: Defence - drawY = drawY + 6 - SetDrawColor(0.5, 0.5, 0.5) - DrawImage(nil, 4, drawY, vp.width - 8, 1) - drawY = drawY + 4 - SetDrawColor(1, 1, 1) - DrawString(10, drawY, "LEFT", headerHeight, "VAR", colorCodes.DEFENCE .. "Defence") - drawY = drawY + headerHeight + 2 - - drawY = self:DrawStatSection(drawY, colWidth, vp, displayStats, primaryOutput, compareOutput, primaryEnv, compareEnv, nil) + drawY = self:DrawStatList(drawY, colWidth, vp, displayStats, primaryOutput, compareOutput, primaryEnv, compareEnv) SetViewport() end @@ -724,53 +886,85 @@ function CompareTabClass:DrawProgressSection(drawY, colWidth, vp, compareEntry) return drawY end -function CompareTabClass:DrawStatSection(drawY, colWidth, vp, displayStats, primaryOutput, compareOutput, primaryEnv, compareEnv, flagFilter) +function CompareTabClass:DrawStatList(drawY, colWidth, vp, displayStats, primaryOutput, compareOutput, primaryEnv, compareEnv) local lineHeight = 16 + -- Get skill flags from both builds for stat filtering + local primaryFlags = primaryEnv and primaryEnv.player and primaryEnv.player.mainSkill and primaryEnv.player.mainSkill.skillFlags or {} + local compareFlags = compareEnv and compareEnv.player and compareEnv.player.mainSkill and compareEnv.player.mainSkill.skillFlags or {} + for _, statData in ipairs(displayStats) do - if statData.stat then + if not statData.stat and not statData.label then + -- Empty entry = section spacer (matches sidebar behavior) + drawY = drawY + 6 + elseif statData.stat == "SkillDPS" then + -- Skip: multi-row SkillDPS doesn't fit compare layout + elseif statData.hideStat then + -- Skip: hidden stats + elseif not matchFlags(statData.flag, statData.notFlag, primaryFlags) + and not matchFlags(statData.flag, statData.notFlag, compareFlags) then + -- Skip: stat not relevant to either build's active skill + elseif statData.stat then + -- Normal stat with value local primaryVal = primaryOutput[statData.stat] or 0 local compareVal = compareOutput[statData.stat] or 0 - -- Skip table-type stat values (some outputs are breakdowns, not numbers) + -- Handle childStat (e.g. MainHand.Accuracy) + if statData.childStat then + primaryVal = type(primaryVal) == "table" and primaryVal[statData.childStat] or 0 + compareVal = type(compareVal) == "table" and compareVal[statData.childStat] or 0 + end + + -- Skip table-type stat values if type(primaryVal) == "table" or type(compareVal) == "table" then primaryVal = 0 compareVal = 0 end - -- Skip zero-value stats - if primaryVal ~= 0 or compareVal ~= 0 then - -- Check if stat has a condition function that filters it - if not statData.condFunc or statData.condFunc(primaryVal, primaryOutput) or statData.condFunc(compareVal, compareOutput) then - -- Format values - local fmt = statData.fmt or "d" - local multiplier = (statData.pc or statData.mod) and 100 or 1 - local primaryStr = s_format("%"..fmt, primaryVal * multiplier) - local compareStr = s_format("%"..fmt, compareVal * multiplier) - primaryStr = formatNumSep(primaryStr) - compareStr = formatNumSep(compareStr) - - -- Determine diff color - local diff = compareVal - primaryVal - local diffStr = "" - local diffColor = "^7" - if diff > 0.001 or diff < -0.001 then - local isBetter = (statData.lowerIsBetter and diff < 0) or (not statData.lowerIsBetter and diff > 0) - diffColor = isBetter and colorCodes.POSITIVE or colorCodes.NEGATIVE - local diffVal = diff * multiplier - diffStr = s_format("%+"..fmt, diffVal) - diffStr = formatNumSep(diffStr) - end + -- Skip zero-value stats, check condFunc + if (primaryVal ~= 0 or compareVal ~= 0) and + (not statData.condFunc or statData.condFunc(primaryVal, primaryOutput) or statData.condFunc(compareVal, compareOutput)) then + -- Format values + local fmt = statData.fmt or "d" + local multiplier = (statData.pc or statData.mod) and 100 or 1 + local primaryStr = s_format("%"..fmt, primaryVal * multiplier) + local compareStr = s_format("%"..fmt, compareVal * multiplier) + primaryStr = formatNumSep(primaryStr) + compareStr = formatNumSep(compareStr) + + -- Determine diff color + local diff = compareVal - primaryVal + local diffStr = "" + local diffColor = "^7" + if diff > 0.001 or diff < -0.001 then + local isBetter = (statData.lowerIsBetter and diff < 0) or (not statData.lowerIsBetter and diff > 0) + diffColor = isBetter and colorCodes.POSITIVE or colorCodes.NEGATIVE + local diffVal = diff * multiplier + diffStr = s_format("%+"..fmt, diffVal) + diffStr = formatNumSep(diffStr) + end - -- Draw stat row - DrawString(20, drawY, "LEFT", lineHeight, "VAR", "^7" .. (statData.label or statData.stat) .. ":") - DrawString(colWidth - 10, drawY, "RIGHT", lineHeight, "VAR", "^7" .. primaryStr) - DrawString(colWidth + colWidth - 10, drawY, "RIGHT", lineHeight, "VAR", diffColor .. compareStr) - if diffStr ~= "" then - DrawString(colWidth + colWidth + 10, drawY, "LEFT", lineHeight, "VAR", diffColor .. "(" .. diffStr .. ")") - end - drawY = drawY + lineHeight + 1 + -- Draw stat row with color-coded label (matches sidebar) + local labelColor = statData.color or "^7" + DrawString(20, drawY, "LEFT", lineHeight, "VAR", labelColor .. (statData.label or statData.stat) .. ":") + DrawString(colWidth - 10, drawY, "RIGHT", lineHeight, "VAR", "^7" .. primaryStr) + DrawString(colWidth + colWidth - 10, drawY, "RIGHT", lineHeight, "VAR", diffColor .. compareStr) + if diffStr ~= "" then + DrawString(colWidth + colWidth + 10, drawY, "LEFT", lineHeight, "VAR", diffColor .. "(" .. diffStr .. ")") end + drawY = drawY + lineHeight + 1 + end + elseif statData.label and statData.condFunc then + -- Label-only stat (e.g. "Chaos Resistance: Immune") + local labelColor = statData.color or "^7" + if statData.condFunc(primaryOutput) or statData.condFunc(compareOutput) then + local valStr = statData.val or "" + local primaryShown = statData.condFunc(primaryOutput) + local compareShown = statData.condFunc(compareOutput) + DrawString(20, drawY, "LEFT", lineHeight, "VAR", labelColor .. statData.label .. ":") + DrawString(colWidth - 10, drawY, "RIGHT", lineHeight, "VAR", "^7" .. (primaryShown and valStr or "-")) + DrawString(colWidth + colWidth - 10, drawY, "RIGHT", lineHeight, "VAR", "^7" .. (compareShown and valStr or "-")) + drawY = drawY + lineHeight + 1 end end end @@ -790,8 +984,8 @@ function CompareTabClass:DrawTree(vp, inputEvents, compareEntry) -- Labels (drawn in absolute screen coords before any viewport changes) SetDrawColor(1, 1, 1) - DrawString(vp.x + halfWidth / 2, vp.y + 2, "CENTER", 16, "VAR", colorCodes.POSITIVE .. "Your Build" .. "^7 (" .. (self.primaryBuild.buildName or "Current") .. ")") - DrawString(vp.x + halfWidth + 4 + halfWidth / 2, vp.y + 2, "CENTER", 16, "VAR", colorCodes.WARNING .. "Compare" .. "^7 (" .. (compareEntry.label or "Comparison") .. ")") + DrawString(vp.x + halfWidth / 2, vp.y + 2, "CENTER", 16, "VAR", colorCodes.POSITIVE .. (self.primaryBuild.buildName or "Your Build")) + DrawString(vp.x + halfWidth + 4 + halfWidth / 2, vp.y + 2, "CENTER", 16, "VAR", colorCodes.WARNING .. (compareEntry.label or "Compare Build")) -- Divider (full height including footer) SetDrawColor(0.5, 0.5, 0.5) @@ -854,10 +1048,19 @@ function CompareTabClass:DrawItems(vp, compareEntry) SetViewport(vp.x, vp.y, vp.width, vp.height) local drawY = 4 - self.scrollY + -- Get cursor position relative to viewport for hover detection + local cursorX, cursorY = GetCursorPos() + cursorX = cursorX - vp.x + cursorY = cursorY - vp.y + local hoverItem = nil + local hoverX, hoverY = 0, 0 + local hoverW, hoverH = 0, 0 + local hoverItemsTab = nil + -- Headers SetDrawColor(1, 1, 1) - DrawString(10, drawY, "LEFT", 18, "VAR", colorCodes.POSITIVE .. "Your Build") - DrawString(colWidth + 10, drawY, "LEFT", 18, "VAR", colorCodes.WARNING .. "Compare Build") + DrawString(10, drawY, "LEFT", 18, "VAR", colorCodes.POSITIVE .. (self.primaryBuild.buildName or "Your Build")) + DrawString(colWidth + 10, drawY, "LEFT", 18, "VAR", colorCodes.WARNING .. (compareEntry.label or "Compare Build")) drawY = drawY + 24 for _, slotName in ipairs(baseSlots) do @@ -901,6 +1104,28 @@ function CompareTabClass:DrawItems(vp, compareEntry) DrawString(20, drawY, "LEFT", 16, "VAR", pColor .. pName) DrawString(colWidth + 20, drawY, "LEFT", 16, "VAR", cColor .. cName) + -- Check hover on primary item (left column) + if pItem and cursorX >= 10 and cursorX < colWidth + and cursorY >= drawY and cursorY < drawY + 18 then + hoverItem = pItem + hoverX = 20 + hoverY = drawY + hoverW = colWidth - 30 + hoverH = 18 + hoverItemsTab = self.primaryBuild.itemsTab + end + + -- Check hover on compare item (right column) + if cItem and cursorX >= colWidth and cursorX < vp.width + and cursorY >= drawY and cursorY < drawY + 18 then + hoverItem = cItem + hoverX = colWidth + 20 + hoverY = drawY + hoverW = colWidth - 30 + hoverH = 18 + hoverItemsTab = compareEntry.itemsTab + end + -- Show diff indicator local isSame = pItem and cItem and pItem.name == cItem.name local diffLabel = "" @@ -920,6 +1145,15 @@ function CompareTabClass:DrawItems(vp, compareEntry) drawY = drawY + 20 end + -- Draw item tooltip on hover (on top of everything) + if hoverItem and hoverItemsTab then + self.itemTooltip:Clear() + hoverItemsTab:AddItemTooltip(self.itemTooltip, hoverItem, nil) + SetDrawLayer(nil, 100) + self.itemTooltip:Draw(hoverX, hoverY, hoverW, hoverH, vp) + SetDrawLayer(nil, 0) + end + SetViewport() end @@ -935,25 +1169,62 @@ function CompareTabClass:DrawSkills(vp, compareEntry) -- Headers SetDrawColor(1, 1, 1) - DrawString(10, drawY, "LEFT", 18, "VAR", colorCodes.POSITIVE .. "Your Build - Socket Groups") - DrawString(colWidth + 10, drawY, "LEFT", 18, "VAR", colorCodes.WARNING .. "Compare Build - Socket Groups") + DrawString(10, drawY, "LEFT", 18, "VAR", colorCodes.POSITIVE .. (self.primaryBuild.buildName or "Your Build") .. " - Socket Groups") + DrawString(colWidth + 10, drawY, "LEFT", 18, "VAR", colorCodes.WARNING .. (compareEntry.label or "Compare Build") .. " - Socket Groups") drawY = drawY + 24 -- Get socket groups from both builds local pGroups = self.primaryBuild.skillsTab and self.primaryBuild.skillsTab.socketGroupList or {} local cGroups = compareEntry.skillsTab and compareEntry.skillsTab.socketGroupList or {} - -- Draw primary build groups - local maxGroups = m_max(#pGroups, #cGroups) - for i = 1, maxGroups do + -- Helper: get the main (non-support) skill name from a socket group + local function getMainSkillName(group) + for _, gem in ipairs(group.gemList or {}) do + if gem.grantedEffect and not gem.grantedEffect.support then + return gem.grantedEffect.name + end + end + return group.displayLabel or group.label + end + + -- Build lookup: main skill name → compare group index + local cNameToIndex = {} + for i, group in ipairs(cGroups) do + local name = getMainSkillName(group) + if name and not cNameToIndex[name] then + cNameToIndex[name] = i + end + end + + -- Match primary groups to compare groups by main skill name + local renderPairs = {} + local cMatched = {} + for i, group in ipairs(pGroups) do + local name = getMainSkillName(group) + if name and cNameToIndex[name] and not cMatched[cNameToIndex[name]] then + t_insert(renderPairs, { pIdx = i, cIdx = cNameToIndex[name] }) + cMatched[cNameToIndex[name]] = true + else + t_insert(renderPairs, { pIdx = i, cIdx = nil }) + end + end + -- Add unmatched compare groups + for i = 1, #cGroups do + if not cMatched[i] then + t_insert(renderPairs, { pIdx = nil, cIdx = i }) + end + end + + -- Draw matched pairs + for _, pair in ipairs(renderPairs) do SetDrawColor(0.3, 0.3, 0.3) DrawImage(nil, 4, drawY, vp.width - 8, 1) drawY = drawY + 2 - -- Primary group - local pGroup = pGroups[i] + -- Primary group (left side) + local pGroup = pair.pIdx and pGroups[pair.pIdx] if pGroup then - local groupLabel = pGroup.displayLabel or pGroup.label or ("Group " .. i) + local groupLabel = pGroup.displayLabel or pGroup.label or ("Group " .. pair.pIdx) if pGroup.slot then groupLabel = groupLabel .. " (" .. pGroup.slot .. ")" end @@ -968,10 +1239,10 @@ function CompareTabClass:DrawSkills(vp, compareEntry) end end - -- Compare group - local cGroup = cGroups[i] + -- Compare group (right side) + local cGroup = pair.cIdx and cGroups[pair.cIdx] if cGroup then - local groupLabel = cGroup.displayLabel or cGroup.label or ("Group " .. i) + local groupLabel = cGroup.displayLabel or cGroup.label or ("Group " .. pair.cIdx) if cGroup.slot then groupLabel = groupLabel .. " (" .. cGroup.slot .. ")" end @@ -1021,8 +1292,8 @@ function CompareTabClass:DrawCalcs(vp, compareEntry) SetDrawColor(1, 1, 1) DrawString(col1, drawY, "LEFT", headerHeight, "VAR", "^7Stat") - DrawString(col2, drawY, "LEFT", headerHeight, "VAR", colorCodes.POSITIVE .. "Your Build") - DrawString(col3, drawY, "LEFT", headerHeight, "VAR", colorCodes.WARNING .. "Compare") + DrawString(col2, drawY, "LEFT", headerHeight, "VAR", colorCodes.POSITIVE .. (self.primaryBuild.buildName or "Your Build")) + DrawString(col3, drawY, "LEFT", headerHeight, "VAR", colorCodes.WARNING .. (compareEntry.label or "Compare Build")) DrawString(col4, drawY, "LEFT", headerHeight, "VAR", "^7Difference") drawY = drawY + headerHeight + 4 @@ -1097,8 +1368,8 @@ function CompareTabClass:DrawConfig(vp, compareEntry) SetDrawColor(1, 1, 1) DrawString(col1, drawY, "LEFT", headerHeight, "VAR", "^7Configuration Option") - DrawString(col2, drawY, "LEFT", headerHeight, "VAR", colorCodes.POSITIVE .. "Your Build") - DrawString(col3, drawY, "LEFT", headerHeight, "VAR", colorCodes.WARNING .. "Compare Build") + DrawString(col2, drawY, "LEFT", headerHeight, "VAR", colorCodes.POSITIVE .. (self.primaryBuild.buildName or "Your Build")) + DrawString(col3, drawY, "LEFT", headerHeight, "VAR", colorCodes.WARNING .. (compareEntry.label or "Compare Build")) drawY = drawY + headerHeight + 4 SetDrawColor(0.5, 0.5, 0.5) From c1c7c8e0bdb2adbc1b6b2dccfa9252fe9070cc56 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Tue, 10 Mar 2026 09:54:43 +0100 Subject: [PATCH 03/59] update calcs and config tabs to behave like their regular counterparts --- src/Classes/CompareTab.lua | 780 +++++++++++++++++++++++++++++-------- 1 file changed, 628 insertions(+), 152 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 9f8113ffa5..6a6ac54a7b 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -60,6 +60,14 @@ local CompareTabClass = newClass("CompareTab", "ControlHost", "Control", functio -- Tooltip for item hover in Items view self.itemTooltip = new("Tooltip") + -- Interactive config controls state + self.configControls = {} -- { var -> { control, varData } } + self.configControlList = {} -- ordered list for layout + self.configNeedsRebuild = true -- trigger initial build + self.configCompareId = nil -- track which compare entry controls were built for + self.configToggle = false -- show all / hide ineligible toggle + self.configDisplayList = {} -- computed display order (headers + rows) + -- Controls for the comparison screen self:InitControls() end) @@ -398,6 +406,262 @@ function CompareTabClass:InitControls() end end, nil, nil, true) self.controls.rightTreeSearch.shown = treeFooterShown + + -- Config view: "Copy Config from Compare Build" button + self.controls.copyConfigBtn = new("ButtonControl", nil, {0, 0, 240, 20}, + "Copy Config from Compare Build", + function() self:CopyCompareConfig() end) + self.controls.copyConfigBtn.shown = function() + return self.compareViewMode == "CONFIG" and self:GetActiveCompare() ~= nil + end + + -- Config view: "Show All / Hide Ineligible" toggle button + self.controls.configToggleBtn = new("ButtonControl", nil, {0, 0, 240, 20}, + function() + return self.configToggle and "Hide Ineligible Configurations" or "Show All Configurations" + end, + function() + self.configToggle = not self.configToggle + end) + self.controls.configToggleBtn.shown = function() + return self.compareViewMode == "CONFIG" and self:GetActiveCompare() ~= nil + end +end + +-- Get a short display name from a build name (strips "AccountName - " prefix) +function CompareTabClass:GetShortBuildName(fullName) + if not fullName then return "Your Build" end + local dashPos = fullName:find(" %- ") + if dashPos then + return fullName:sub(dashPos + 3) + end + return fullName +end + +-- Format a numeric value with separator and rounding +function CompareTabClass:FormatVal(val, p) + return formatNumSep(tostring(round(val, p))) +end + +-- Resolve format strings against an actor's output/modDB +-- Handles: {output:Key}, {p:output:Key}, {p:mod:indices} +function CompareTabClass:FormatStr(str, actor, colData) + if not actor then return "" end + str = str:gsub("{output:([%a%.:]+)}", function(c) + local ns, var = c:match("^(%a+)%.(%a+)$") + if ns then + return actor.output[ns] and actor.output[ns][var] or "" + else + return actor.output[c] or "" + end + end) + str = str:gsub("{(%d+):output:([%a%.:]+)}", function(p, c) + local ns, var = c:match("^(%a+)%.(%a+)$") + if ns then + return self:FormatVal(actor.output[ns] and actor.output[ns][var] or 0, tonumber(p)) + else + return self:FormatVal(actor.output[c] or 0, tonumber(p)) + end + end) + str = str:gsub("{(%d+):mod:([%d,]+)}", function(p, n) + local numList = { } + for num in n:gmatch("%d+") do + t_insert(numList, tonumber(num)) + end + if not colData[numList[1]] or not colData[numList[1]].modType then + return "?" + end + local modType = colData[numList[1]].modType + local modTotal = modType == "MORE" and 1 or 0 + for _, num in ipairs(numList) do + local sectionData = colData[num] + if not sectionData then break end + local modCfg = (sectionData.cfg and actor.mainSkill and actor.mainSkill[sectionData.cfg.."Cfg"]) or { } + if sectionData.modSource then + modCfg.source = sectionData.modSource + end + if sectionData.actor then + modCfg.actor = sectionData.actor + end + local modVal + local modStore = (sectionData.enemy and actor.enemy and actor.enemy.modDB) or (sectionData.cfg and actor.mainSkill and actor.mainSkill.skillModList) or actor.modDB + if not modStore then break end + if type(sectionData.modName) == "table" then + modVal = modStore:Combine(sectionData.modType, modCfg, unpack(sectionData.modName)) + else + modVal = modStore:Combine(sectionData.modType, modCfg, sectionData.modName) + end + if modType == "MORE" then + modTotal = modTotal * modVal + else + modTotal = modTotal + modVal + end + end + if modType == "MORE" then + modTotal = (modTotal - 1) * 100 + end + return self:FormatVal(modTotal, tonumber(p)) + end) + return str +end + +-- Check visibility flags for a section/row against an actor +function CompareTabClass:CheckCalcFlag(obj, actor) + if not actor or not actor.mainSkill then return true end + local skillFlags = actor.mainSkill.skillFlags or {} + if obj.flag and not skillFlags[obj.flag] then + return false + end + if obj.flagList then + for _, flag in ipairs(obj.flagList) do + if not skillFlags[flag] then + return false + end + end + end + if obj.playerFlag and not skillFlags[obj.playerFlag] then + return false + end + if obj.notFlag and skillFlags[obj.notFlag] then + return false + end + if obj.notFlagList then + for _, flag in ipairs(obj.notFlagList) do + if skillFlags[flag] then + return false + end + end + end + if obj.haveOutput then + local ns, var = obj.haveOutput:match("^(%a+)%.(%a+)$") + if ns then + if not actor.output[ns] or not actor.output[ns][var] or actor.output[ns][var] == 0 then + return false + end + elseif not actor.output[obj.haveOutput] or actor.output[obj.haveOutput] == 0 then + return false + end + end + return true +end + +-- Format a config value for read-only display +function CompareTabClass:FormatConfigValue(varData, val) + if val == nil then return "^8(not set)" end + if varData.type == "check" then + return val and (colorCodes.POSITIVE .. "Yes") or (colorCodes.NEGATIVE .. "No") + elseif varData.type == "list" and varData.list then + for _, item in ipairs(varData.list) do + if item.val == val then + return item.label or tostring(val) + end + end + return tostring(val) + else + return tostring(val) + end +end + +-- Rebuild interactive config controls for all config options +function CompareTabClass:RebuildConfigControls(compareEntry) + -- Remove old config controls + for var, _ in pairs(self.configControls) do + self.controls["cfg_" .. var] = nil + end + self.configControls = {} + self.configControlList = {} + + if not compareEntry then return end + + local configOptions = LoadModule("Modules/ConfigOptions") + local pInput = self.primaryBuild.configTab.input or {} + local primaryBuild = self.primaryBuild + + for _, varData in ipairs(configOptions) do + if varData.var and varData.type ~= "text" then + local pVal = pInput[varData.var] + local control + if varData.type == "check" then + control = new("CheckBoxControl", nil, {0, 0, 18}, nil, function(state) + primaryBuild.configTab.input[varData.var] = state + primaryBuild.configTab:UpdateControls() + primaryBuild.configTab:BuildModList() + primaryBuild.buildFlag = true + end) + control.state = pVal or false + elseif varData.type == "count" or varData.type == "integer" + or varData.type == "countAllowZero" or varData.type == "float" then + local filter = (varData.type == "integer" and "^%-%d") + or (varData.type == "float" and "^%d.") or "%D" + control = new("EditControl", nil, {0, 0, 90, 18}, + tostring(pVal or ""), nil, filter, 7, + function(buf) + primaryBuild.configTab.input[varData.var] = tonumber(buf) + primaryBuild.configTab:UpdateControls() + primaryBuild.configTab:BuildModList() + primaryBuild.buildFlag = true + end) + elseif varData.type == "list" and varData.list then + control = new("DropDownControl", nil, {0, 0, 150, 18}, + varData.list, function(index, value) + primaryBuild.configTab.input[varData.var] = value.val + primaryBuild.configTab:UpdateControls() + primaryBuild.configTab:BuildModList() + primaryBuild.buildFlag = true + end) + control:SelByValue(pVal or (varData.list[1] and varData.list[1].val), "val") + end + + if control then + control.shown = function() return false end -- hidden until positioned + self.controls["cfg_" .. varData.var] = control + + -- Determine eligibility category (matches ConfigTab's isShowAllConfig logic) + local isHardConditional = varData.ifOption or varData.ifSkill + or varData.ifSkillData or varData.ifSkillFlag or varData.legacy + local isKeywordExcluded = false + if varData.label then + local labelLower = varData.label:lower() + for _, kw in ipairs({"recently", "in the last", "in the past", "in last", "in past", "pvp"}) do + if labelLower:find(kw) then + isKeywordExcluded = true + break + end + end + end + local hasAnyCondition = varData.ifCond or varData.ifOption or varData.ifSkill + or varData.ifSkillFlag or varData.ifSkillData or varData.ifSkillList + or varData.ifNode or varData.ifMod or varData.ifMult + or varData.ifEnemyStat or varData.ifEnemyCond or varData.legacy + + local ctrlInfo = { + control = control, + varData = varData, + visible = false, + -- Always shown in "All Configurations" (no conditions at all) + alwaysShow = not hasAnyCondition and not isKeywordExcluded, + -- Shown in "All Configurations" when toggle is ON (simple conditions only) + showWithToggle = not isHardConditional and not isKeywordExcluded, + } + self.configControls[varData.var] = ctrlInfo + t_insert(self.configControlList, ctrlInfo) + end + end + end +end + +-- Copy all config settings from compare build to primary build +function CompareTabClass:CopyCompareConfig() + local compareEntry = self:GetActiveCompare() + if not compareEntry then return end + local cInput = compareEntry.configTab.input + for k, v in pairs(cInput) do + self.primaryBuild.configTab.input[k] = v + end + self.primaryBuild.configTab:UpdateControls() + self.primaryBuild.configTab:BuildModList() + self.primaryBuild.buildFlag = true + self.configNeedsRebuild = true end -- Import a comparison build from XML text @@ -644,6 +908,112 @@ function CompareTabClass:Draw(viewPort, inputEvents) end end + -- Position config controls when in CONFIG view + if self.compareViewMode == "CONFIG" and compareEntry then + -- Rebuild controls if compare entry changed or config was modified + if self.configCompareId ~= self.activeCompareIndex or self.configNeedsRebuild then + self:RebuildConfigControls(compareEntry) + self.configCompareId = self.activeCompareIndex + self.configNeedsRebuild = false + end + + -- Sync control values with current primary input (in case changed from normal Config tab) + local pInput = self.primaryBuild.configTab.input or {} + for var, ctrlInfo in pairs(self.configControls) do + local ctrl = ctrlInfo.control + local varData = ctrlInfo.varData + local pVal = pInput[var] + if varData.type == "check" then + ctrl.state = pVal or false + elseif varData.type == "count" or varData.type == "integer" + or varData.type == "countAllowZero" or varData.type == "float" then + ctrl:SetText(tostring(pVal or "")) + elseif varData.type == "list" then + ctrl:SelByValue(pVal or (varData.list[1] and varData.list[1].val), "val") + end + end + + -- Position buttons at top of config view (above column headers) + self.controls.copyConfigBtn.x = contentVP.x + 10 + self.controls.copyConfigBtn.y = contentVP.y + 4 + self.controls.configToggleBtn.x = contentVP.x + 260 + self.controls.configToggleBtn.y = contentVP.y + 4 + + -- Build display list: Differences section first, then All Configurations + local cInput = compareEntry.configTab.input or {} + local displayList = {} + local rowHeight = 22 + local sectionHeaderHeight = 24 + + -- Collect differences + local diffs = {} + for _, ctrlInfo in ipairs(self.configControlList) do + local pVal = pInput[ctrlInfo.varData.var] + local cVal = cInput[ctrlInfo.varData.var] + if tostring(pVal or "") ~= tostring(cVal or "") then + t_insert(diffs, ctrlInfo) + end + end + + -- Differences section + if #diffs > 0 then + t_insert(displayList, { type = "header", text = "Differences (" .. #diffs .. ")" }) + for _, ctrlInfo in ipairs(diffs) do + t_insert(displayList, { type = "row", ctrlInfo = ctrlInfo }) + end + end + + -- Collect eligible non-diff options for "All Configurations" section + local configs = {} + for _, ctrlInfo in ipairs(self.configControlList) do + local pVal = pInput[ctrlInfo.varData.var] + local cVal = cInput[ctrlInfo.varData.var] + -- Only include non-diff options + if tostring(pVal or "") == tostring(cVal or "") then + if ctrlInfo.alwaysShow or (self.configToggle and ctrlInfo.showWithToggle) then + t_insert(configs, ctrlInfo) + end + end + end + + if #configs > 0 then + t_insert(displayList, { type = "header", text = "All Configurations" }) + for _, ctrlInfo in ipairs(configs) do + t_insert(displayList, { type = "row", ctrlInfo = ctrlInfo }) + end + end + + self.configDisplayList = displayList + + -- First, hide ALL config controls (will selectively show visible ones) + for _, ctrlInfo in ipairs(self.configControlList) do + ctrlInfo.control.shown = function() return false end + end + + -- Position visible controls at absolute coords matching DrawConfig layout + local col2AbsX = contentVP.x + 300 + local fixedHeaderHeight = 58 -- buttons + column headers + separator (not scrollable) + local scrollTopAbs = contentVP.y + fixedHeaderHeight -- top of scrollable area + local startY = fixedHeaderHeight -- content starts after fixed header + local currentY = startY + for _, item in ipairs(displayList) do + if item.type == "header" then + currentY = currentY + sectionHeaderHeight + elseif item.type == "row" then + local absY = contentVP.y + currentY - self.scrollY + item.ctrlInfo.control.x = col2AbsX + item.ctrlInfo.control.y = absY + local cy = currentY -- capture for closure + item.ctrlInfo.control.shown = function() + local ay = contentVP.y + cy - self.scrollY + return ay >= scrollTopAbs - 20 and ay < contentVP.y + contentVP.height + and self.compareViewMode == "CONFIG" and self:GetActiveCompare() ~= nil + end + currentY = currentY + rowHeight + end + end + end + -- Update comparison build set selectors if compareEntry then -- Tree spec list (reuse GetSpecList from TreeTab) @@ -755,15 +1125,22 @@ function CompareTabClass:DrawSummary(vp, compareEntry) local lineHeight = 18 local headerHeight = 22 - local colWidth = m_floor(vp.width / 2) + + -- Column positions + local col1 = 10 -- Stat name + local col2 = 300 -- Primary value + local col3 = 450 -- Compare value + local col4 = 600 -- Difference SetViewport(vp.x, vp.y, vp.width, vp.height) local drawY = 4 - self.scrollY -- Headers SetDrawColor(1, 1, 1) - DrawString(10, drawY, "LEFT", headerHeight, "VAR", colorCodes.POSITIVE .. (self.primaryBuild.buildName or "Your Build")) - DrawString(colWidth + 10, drawY, "LEFT", headerHeight, "VAR", colorCodes.WARNING .. (compareEntry.label or "Compare Build")) + DrawString(col1, drawY, "LEFT", headerHeight, "VAR", "^7Stat") + DrawString(col2, drawY, "LEFT", headerHeight, "VAR", colorCodes.POSITIVE .. self:GetShortBuildName(self.primaryBuild.buildName)) + DrawString(col3, drawY, "LEFT", headerHeight, "VAR", colorCodes.WARNING .. (compareEntry.label or "Compare Build")) + DrawString(col4, drawY, "LEFT", headerHeight, "VAR", "^7Difference") drawY = drawY + headerHeight + 4 -- Separator @@ -771,21 +1148,12 @@ function CompareTabClass:DrawSummary(vp, compareEntry) DrawImage(nil, 4, drawY, vp.width - 8, 2) drawY = drawY + 6 - -- Progress section - drawY = self:DrawProgressSection(drawY, colWidth, vp, compareEntry) - drawY = drawY + 4 - - -- Separator - SetDrawColor(0.5, 0.5, 0.5) - DrawImage(nil, 4, drawY, vp.width - 8, 2) - drawY = drawY + 6 - -- Stat comparison local displayStats = self.primaryBuild.displayStats local primaryEnv = self.primaryBuild.calcsTab.mainEnv local compareEnv = compareEntry.calcsTab.mainEnv - drawY = self:DrawStatList(drawY, colWidth, vp, displayStats, primaryOutput, compareOutput, primaryEnv, compareEnv) + drawY = self:DrawStatList(drawY, vp, displayStats, primaryOutput, compareOutput, primaryEnv, compareEnv, col1, col2, col3, col4) SetViewport() end @@ -886,7 +1254,7 @@ function CompareTabClass:DrawProgressSection(drawY, colWidth, vp, compareEntry) return drawY end -function CompareTabClass:DrawStatList(drawY, colWidth, vp, displayStats, primaryOutput, compareOutput, primaryEnv, compareEnv) +function CompareTabClass:DrawStatList(drawY, vp, displayStats, primaryOutput, compareOutput, primaryEnv, compareEnv, col1, col2, col3, col4) local lineHeight = 16 -- Get skill flags from both builds for stat filtering @@ -932,7 +1300,7 @@ function CompareTabClass:DrawStatList(drawY, colWidth, vp, displayStats, primary primaryStr = formatNumSep(primaryStr) compareStr = formatNumSep(compareStr) - -- Determine diff color + -- Determine diff color and string local diff = compareVal - primaryVal local diffStr = "" local diffColor = "^7" @@ -942,15 +1310,20 @@ function CompareTabClass:DrawStatList(drawY, colWidth, vp, displayStats, primary local diffVal = diff * multiplier diffStr = s_format("%+"..fmt, diffVal) diffStr = formatNumSep(diffStr) + -- Add percentage if primary value is non-zero + if primaryVal ~= 0 then + local pc = compareVal / primaryVal * 100 - 100 + diffStr = diffStr .. s_format(" (%+.1f%%)", pc) + end end - -- Draw stat row with color-coded label (matches sidebar) + -- Draw stat row local labelColor = statData.color or "^7" - DrawString(20, drawY, "LEFT", lineHeight, "VAR", labelColor .. (statData.label or statData.stat) .. ":") - DrawString(colWidth - 10, drawY, "RIGHT", lineHeight, "VAR", "^7" .. primaryStr) - DrawString(colWidth + colWidth - 10, drawY, "RIGHT", lineHeight, "VAR", diffColor .. compareStr) + DrawString(col1, drawY, "LEFT", lineHeight, "VAR", labelColor .. (statData.label or statData.stat)) + DrawString(col2, drawY, "LEFT", lineHeight, "VAR", "^7" .. primaryStr) + DrawString(col3, drawY, "LEFT", lineHeight, "VAR", diffColor .. compareStr) if diffStr ~= "" then - DrawString(colWidth + colWidth + 10, drawY, "LEFT", lineHeight, "VAR", diffColor .. "(" .. diffStr .. ")") + DrawString(col4, drawY, "LEFT", lineHeight, "VAR", diffColor .. diffStr) end drawY = drawY + lineHeight + 1 end @@ -961,9 +1334,9 @@ function CompareTabClass:DrawStatList(drawY, colWidth, vp, displayStats, primary local valStr = statData.val or "" local primaryShown = statData.condFunc(primaryOutput) local compareShown = statData.condFunc(compareOutput) - DrawString(20, drawY, "LEFT", lineHeight, "VAR", labelColor .. statData.label .. ":") - DrawString(colWidth - 10, drawY, "RIGHT", lineHeight, "VAR", "^7" .. (primaryShown and valStr or "-")) - DrawString(colWidth + colWidth - 10, drawY, "RIGHT", lineHeight, "VAR", "^7" .. (compareShown and valStr or "-")) + DrawString(col1, drawY, "LEFT", lineHeight, "VAR", labelColor .. statData.label) + DrawString(col2, drawY, "LEFT", lineHeight, "VAR", "^7" .. (primaryShown and valStr or "-")) + DrawString(col3, drawY, "LEFT", lineHeight, "VAR", "^7" .. (compareShown and valStr or "-")) drawY = drawY + lineHeight + 1 end end @@ -982,11 +1355,6 @@ function CompareTabClass:DrawTree(vp, inputEvents, compareEntry) local footerHeight = layout.footerHeight local labelHeight = 20 - -- Labels (drawn in absolute screen coords before any viewport changes) - SetDrawColor(1, 1, 1) - DrawString(vp.x + halfWidth / 2, vp.y + 2, "CENTER", 16, "VAR", colorCodes.POSITIVE .. (self.primaryBuild.buildName or "Your Build")) - DrawString(vp.x + halfWidth + 4 + halfWidth / 2, vp.y + 2, "CENTER", 16, "VAR", colorCodes.WARNING .. (compareEntry.label or "Compare Build")) - -- Divider (full height including footer) SetDrawColor(0.5, 0.5, 0.5) DrawImage(nil, vp.x + halfWidth, vp.y + labelHeight, 4, vp.height - labelHeight) @@ -1059,7 +1427,7 @@ function CompareTabClass:DrawItems(vp, compareEntry) -- Headers SetDrawColor(1, 1, 1) - DrawString(10, drawY, "LEFT", 18, "VAR", colorCodes.POSITIVE .. (self.primaryBuild.buildName or "Your Build")) + DrawString(10, drawY, "LEFT", 18, "VAR", colorCodes.POSITIVE .. self:GetShortBuildName(self.primaryBuild.buildName)) DrawString(colWidth + 10, drawY, "LEFT", 18, "VAR", colorCodes.WARNING .. (compareEntry.label or "Compare Build")) drawY = drawY + 24 @@ -1169,7 +1537,7 @@ function CompareTabClass:DrawSkills(vp, compareEntry) -- Headers SetDrawColor(1, 1, 1) - DrawString(10, drawY, "LEFT", 18, "VAR", colorCodes.POSITIVE .. (self.primaryBuild.buildName or "Your Build") .. " - Socket Groups") + DrawString(10, drawY, "LEFT", 18, "VAR", colorCodes.POSITIVE .. self:GetShortBuildName(self.primaryBuild.buildName) .. " - Socket Groups") DrawString(colWidth + 10, drawY, "LEFT", 18, "VAR", colorCodes.WARNING .. (compareEntry.label or "Compare Build") .. " - Socket Groups") drawY = drawY + 24 @@ -1232,9 +1600,10 @@ function CompareTabClass:DrawSkills(vp, compareEntry) local gemY = drawY + lineHeight for _, gem in ipairs(pGroup.gemList or {}) do local gemName = gem.grantedEffect and gem.grantedEffect.name or gem.nameSpec or "?" + local gemColor = gem.color or colorCodes.GEM local levelStr = gem.level and (" Lv" .. gem.level) or "" local qualStr = gem.quality and gem.quality > 0 and ("/" .. gem.quality .. "q") or "" - DrawString(20, gemY, "LEFT", 14, "VAR", colorCodes.GEM .. gemName .. "^7" .. levelStr .. qualStr) + DrawString(20, gemY, "LEFT", 14, "VAR", gemColor .. gemName .. "^7" .. levelStr .. qualStr) gemY = gemY + 16 end end @@ -1250,9 +1619,10 @@ function CompareTabClass:DrawSkills(vp, compareEntry) local gemY = drawY + lineHeight for _, gem in ipairs(cGroup.gemList or {}) do local gemName = gem.grantedEffect and gem.grantedEffect.name or gem.nameSpec or "?" + local gemColor = gem.color or colorCodes.GEM local levelStr = gem.level and (" Lv" .. gem.level) or "" local qualStr = gem.quality and gem.quality > 0 and ("/" .. gem.quality .. "q") or "" - DrawString(colWidth + 20, gemY, "LEFT", 14, "VAR", colorCodes.GEM .. gemName .. "^7" .. levelStr .. qualStr) + DrawString(colWidth + 20, gemY, "LEFT", 14, "VAR", gemColor .. gemName .. "^7" .. levelStr .. qualStr) gemY = gemY + 16 end end @@ -1268,81 +1638,188 @@ function CompareTabClass:DrawSkills(vp, compareEntry) end -- ============================================================ --- CALCS VIEW +-- CALCS VIEW (card-based sections with comparison) -- ============================================================ function CompareTabClass:DrawCalcs(vp, compareEntry) - local primaryOutput = self.primaryBuild.calcsTab.mainOutput - local compareOutput = compareEntry:GetOutput() - if not primaryOutput or not compareOutput then - return + -- Get actors from both builds (use mainEnv, not calcsEnv, so skill dropdown is respected) + local primaryEnv = self.primaryBuild.calcsTab.mainEnv + local compareEnv = compareEntry.calcsTab and compareEntry.calcsTab.mainEnv + if not primaryEnv or not compareEnv then return end + local primaryActor = primaryEnv.player + local compareActor = compareEnv.player + if not primaryActor or not compareActor then return end + + -- Load section definitions (cached) + if not self.calcSections then + self.calcSections = LoadModule("Modules/CalcSections") end - local lineHeight = 16 - local headerHeight = 20 - local displayStats = self.primaryBuild.displayStats + -- Card dimensions + -- Layout: [2px border | 130px label | 2px gap | 2px sep | valW | 2px sep | valW | 2px border] + local cardWidth = m_min(400, vp.width - 16) + local labelWidth = 132 + local sepW = 2 + local valColWidth = m_floor((cardWidth - 140) / 2) + local valCol1X = labelWidth + sepW * 2 + local valCol2X = valCol1X + valColWidth + sepW + + -- Layout parameters + local maxCol = m_max(1, m_floor(vp.width / (cardWidth + 8))) + local baseX = 4 + local headerBarHeight = 24 + local baseY = headerBarHeight + + -- Pre-compute section visibility and heights + local sections = {} + for _, secDef in ipairs(self.calcSections) do + local secWidth, id, group, colour, subSections = secDef[1], secDef[2], secDef[3], secDef[4], secDef[5] + local secData = subSections[1].data + -- Check section-level flags against primary actor + if self:CheckCalcFlag(secData, primaryActor) then + local subSecInfo = {} + local sectionHasRows = false + for _, subSec in ipairs(subSections) do + local rows = {} + for _, rowData in ipairs(subSec.data) do + -- Only include rows with a label and a first column with a format string + if rowData.label and rowData[1] and rowData[1].format then + if self:CheckCalcFlag(rowData, primaryActor) or self:CheckCalcFlag(rowData, compareActor) then + t_insert(rows, rowData) + end + end + end + if #rows > 0 then + t_insert(subSecInfo, { label = subSec.label, rows = rows, data = subSec.data }) + sectionHasRows = true + end + end + if sectionHasRows then + -- Calculate card height + local height = 2 + for _, si in ipairs(subSecInfo) do + height = height + 22 + #si.rows * 18 + if #si.rows > 0 then + height = height + 2 + end + end + t_insert(sections, { + id = id, group = group, colour = colour, + subSecs = subSecInfo, + height = height, + }) + end + end + end - SetViewport(vp.x, vp.y, vp.width, vp.height) - local drawY = 4 - self.scrollY + -- Layout: place sections into shortest column + local colY = {} + local maxY = baseY + for _, sec in ipairs(sections) do + local col = 1 + local minY = colY[1] or baseY + for c = 2, maxCol do + if (colY[c] or baseY) < minY then + col = c + minY = colY[c] or baseY + end + end + sec.drawX = baseX + (cardWidth + 8) * (col - 1) + sec.drawY = colY[col] or baseY + colY[col] = sec.drawY + sec.height + 8 + maxY = m_max(maxY, colY[col]) + end - -- Column headers - local col1 = 10 -- Stat name - local col2 = 300 -- Your Build value - local col3 = 450 -- Compare Build value - local col4 = 600 -- Difference + -- Set viewport for scroll clipping + SetViewport(vp.x, vp.y, vp.width, vp.height) + -- Draw header bar with build names + local headerY = 4 - self.scrollY SetDrawColor(1, 1, 1) - DrawString(col1, drawY, "LEFT", headerHeight, "VAR", "^7Stat") - DrawString(col2, drawY, "LEFT", headerHeight, "VAR", colorCodes.POSITIVE .. (self.primaryBuild.buildName or "Your Build")) - DrawString(col3, drawY, "LEFT", headerHeight, "VAR", colorCodes.WARNING .. (compareEntry.label or "Compare Build")) - DrawString(col4, drawY, "LEFT", headerHeight, "VAR", "^7Difference") - drawY = drawY + headerHeight + 4 - + DrawString(baseX + valCol1X, headerY, "LEFT", 14, "VAR", + colorCodes.POSITIVE .. self:GetShortBuildName(self.primaryBuild.buildName)) + DrawString(baseX + valCol2X, headerY, "LEFT", 14, "VAR", + colorCodes.WARNING .. (compareEntry.label or "Compare Build")) SetDrawColor(0.5, 0.5, 0.5) - DrawImage(nil, 4, drawY, vp.width - 8, 2) - drawY = drawY + 6 - - for _, statData in ipairs(displayStats) do - if statData.stat then - local primaryVal = primaryOutput[statData.stat] or 0 - local compareVal = compareOutput[statData.stat] or 0 - - -- Skip table-type stat values (some outputs are breakdowns, not numbers) - if type(primaryVal) == "table" or type(compareVal) == "table" then - primaryVal = 0 - compareVal = 0 - end - - if primaryVal ~= 0 or compareVal ~= 0 then - if not statData.condFunc or statData.condFunc(primaryVal, primaryOutput) or statData.condFunc(compareVal, compareOutput) then - local fmt = statData.fmt or "d" - local multiplier = (statData.pc or statData.mod) and 100 or 1 - - local primaryStr = s_format("%"..fmt, primaryVal * multiplier) - local compareStr = s_format("%"..fmt, compareVal * multiplier) - primaryStr = formatNumSep(primaryStr) - compareStr = formatNumSep(compareStr) - - local diff = compareVal - primaryVal - local diffStr = "" - local diffColor = "^7" - if diff > 0.001 or diff < -0.001 then - local isBetter = (statData.lowerIsBetter and diff < 0) or (not statData.lowerIsBetter and diff > 0) - diffColor = isBetter and colorCodes.POSITIVE or colorCodes.NEGATIVE - diffStr = s_format("%+"..fmt, diff * multiplier) - diffStr = formatNumSep(diffStr) - if statData.compPercent and primaryVal ~= 0 then - local pc = compareVal / primaryVal * 100 - 100 - diffStr = diffStr .. s_format(" (%+.1f%%)", pc) + DrawImage(nil, 4, headerY + 16, vp.width - 8, 1) + + -- Draw section cards + for _, sec in ipairs(sections) do + local x = sec.drawX + local y = sec.drawY - self.scrollY + + -- Skip if entirely off-screen + if y + sec.height >= 0 and y < vp.height then + -- Draw border + SetDrawLayer(nil, -10) + SetDrawColor(sec.colour) + DrawImage(nil, x, y, cardWidth, sec.height) + -- Draw background + SetDrawColor(0.10, 0.10, 0.10) + DrawImage(nil, x + 2, y + 2, cardWidth - 4, sec.height - 4) + SetDrawLayer(nil, 0) + + local lineY = y + for _, subSec in ipairs(sec.subSecs) do + -- Separator above header + SetDrawColor(sec.colour) + DrawImage(nil, x + 2, lineY, cardWidth - 4, 2) + -- Header text + DrawString(x + 3, lineY + 3, "LEFT", 16, "VAR BOLD", "^7" .. subSec.label .. ":") + -- Show extra info (e.g. "4521/5000 | 3800/4200") + if subSec.data and subSec.data.extra then + local extraTextW = DrawStringWidth(16, "VAR BOLD", subSec.label .. ":") + local extraX = x + 3 + extraTextW + 8 + local ok1, pExtra = pcall(self.FormatStr, self, subSec.data.extra, primaryActor) + local ok2, cExtra = pcall(self.FormatStr, self, subSec.data.extra, compareActor) + if ok1 and ok2 then + DrawString(extraX, lineY + 3, "LEFT", 16, "VAR", + colorCodes.POSITIVE .. pExtra .. " ^8| " .. colorCodes.WARNING .. cExtra) + end + end + -- Separator below header + SetDrawColor(sec.colour) + DrawImage(nil, x + 2, lineY + 20, cardWidth - 4, 2) + lineY = lineY + 22 + + -- Draw rows + for _, rowData in ipairs(subSec.rows) do + local colData = rowData[1] + local textSize = rowData.textSize or 14 + + -- Label background and text + SetDrawColor(rowData.bgCol or "^0") + DrawImage(nil, x + 2, lineY, labelWidth - 2, 18) + local textColor = rowData.color or "^7" + DrawString(x + labelWidth, lineY + 1, "RIGHT_X", 16, "VAR", textColor .. rowData.label .. "^7:") + + -- Primary value column + SetDrawColor(sec.colour) + DrawImage(nil, x + valCol1X - sepW, lineY, sepW, 18) + SetDrawColor(rowData.bgCol or "^0") + DrawImage(nil, x + valCol1X, lineY, valColWidth, 18) + if colData and colData.format then + local ok, str = pcall(self.FormatStr, self, colData.format, primaryActor, colData) + if ok and str then + DrawString(x + valCol1X + 2, lineY + 9 - textSize / 2, "LEFT", textSize, "VAR", "^7" .. str) end end - DrawString(col1, drawY, "LEFT", lineHeight, "VAR", "^7" .. (statData.label or statData.stat)) - DrawString(col2, drawY, "LEFT", lineHeight, "VAR", "^7" .. primaryStr) - DrawString(col3, drawY, "LEFT", lineHeight, "VAR", diffColor .. compareStr) - if diffStr ~= "" then - DrawString(col4, drawY, "LEFT", lineHeight, "VAR", diffColor .. diffStr) + -- Compare value column + SetDrawColor(sec.colour) + DrawImage(nil, x + valCol2X - sepW, lineY, sepW, 18) + SetDrawColor(rowData.bgCol or "^0") + DrawImage(nil, x + valCol2X, lineY, valColWidth, 18) + if colData and colData.format then + local ok, str = pcall(self.FormatStr, self, colData.format, compareActor, colData) + if ok and str then + DrawString(x + valCol2X + 2, lineY + 9 - textSize / 2, "LEFT", textSize, "VAR", "^7" .. str) + end end - drawY = drawY + lineHeight + 1 + + lineY = lineY + 18 + end + if #subSec.rows > 0 then + lineY = lineY + 2 end end end @@ -1355,74 +1832,73 @@ end -- CONFIG VIEW -- ============================================================ function CompareTabClass:DrawConfig(vp, compareEntry) - local lineHeight = 18 - local headerHeight = 20 - - SetViewport(vp.x, vp.y, vp.width, vp.height) - local drawY = 4 - self.scrollY + local rowHeight = 22 + local sectionHeaderHeight = 24 + local columnHeaderHeight = 20 + local fixedHeaderHeight = 58 -- buttons + column headers + separator (not scrollable) - -- Headers + -- Column positions (viewport-relative) local col1 = 10 - local col2 = 300 - local col3 = 500 - + local col2 = 300 -- primary value (interactive controls drawn by ControlHost) + local col3 = 500 -- compare value (read-only) + + -- Fixed header area: buttons at top, then column headers + separator + SetViewport(vp.x, vp.y, vp.width, fixedHeaderHeight) + -- Buttons (Copy Config + Toggle) are drawn by ControlHost at y=4 + -- Column headers below buttons + local colHeaderY = 28 SetDrawColor(1, 1, 1) - DrawString(col1, drawY, "LEFT", headerHeight, "VAR", "^7Configuration Option") - DrawString(col2, drawY, "LEFT", headerHeight, "VAR", colorCodes.POSITIVE .. (self.primaryBuild.buildName or "Your Build")) - DrawString(col3, drawY, "LEFT", headerHeight, "VAR", colorCodes.WARNING .. (compareEntry.label or "Compare Build")) - drawY = drawY + headerHeight + 4 - + DrawString(col1, colHeaderY, "LEFT", columnHeaderHeight, "VAR", "^7Configuration Option") + DrawString(col2, colHeaderY, "LEFT", columnHeaderHeight, "VAR", + colorCodes.POSITIVE .. self:GetShortBuildName(self.primaryBuild.buildName)) + DrawString(col3, colHeaderY, "LEFT", columnHeaderHeight, "VAR", + colorCodes.WARNING .. (compareEntry.label or "Compare Build")) SetDrawColor(0.5, 0.5, 0.5) - DrawImage(nil, 4, drawY, vp.width - 8, 2) - drawY = drawY + 6 - - -- Compare config inputs - local pInput = self.primaryBuild.configTab.input or {} - local cInput = compareEntry.configTab.input or {} + DrawImage(nil, 4, colHeaderY + columnHeaderHeight + 4, vp.width - 8, 2) - -- Collect all unique keys - local allKeys = {} - local keySet = {} - for k, _ in pairs(pInput) do - if not keySet[k] then - t_insert(allKeys, k) - keySet[k] = true - end - end - for k, _ in pairs(cInput) do - if not keySet[k] then - t_insert(allKeys, k) - keySet[k] = true - end + -- Scrollable content area (clipped below fixed header so content can't bleed through buttons) + local scrollH = vp.height - fixedHeaderHeight + if scrollH <= 0 then + SetViewport() + return end - table.sort(allKeys) - - local diffCount = 0 - for _, key in ipairs(allKeys) do - local pVal = pInput[key] - local cVal = cInput[key] + SetViewport(vp.x, vp.y + fixedHeaderHeight, vp.width, scrollH) - -- Only show differences - if tostring(pVal or "") ~= tostring(cVal or "") then - local pStr = pVal ~= nil and tostring(pVal) or "^8(not set)" - local cStr = cVal ~= nil and tostring(cVal) or "^8(not set)" - - -- Format boolean values - if pVal == true then pStr = colorCodes.POSITIVE .. "Yes" - elseif pVal == false then pStr = colorCodes.NEGATIVE .. "No" end - if cVal == true then cStr = colorCodes.POSITIVE .. "Yes" - elseif cVal == false then cStr = colorCodes.NEGATIVE .. "No" end - - DrawString(col1, drawY, "LEFT", lineHeight, "VAR", "^7" .. key) - DrawString(col2, drawY, "LEFT", lineHeight, "VAR", "^7" .. pStr) - DrawString(col3, drawY, "LEFT", lineHeight, "VAR", "^7" .. cStr) - drawY = drawY + lineHeight + 1 - diffCount = diffCount + 1 + local cInput = compareEntry.configTab.input or {} + local currentY = 0 -- relative to scrollable viewport + + -- Draw from the computed display list (built in Draw()) + for _, item in ipairs(self.configDisplayList) do + if item.type == "header" then + local headerY = currentY - self.scrollY + if headerY + sectionHeaderHeight >= 0 and headerY < scrollH then + -- Section header text + SetDrawColor(1, 1, 1) + DrawString(col1, headerY + 4, "LEFT", 16, "VAR BOLD", "^7" .. item.text) + -- Thin separator below header + SetDrawColor(0.4, 0.4, 0.4) + DrawImage(nil, col1, headerY + sectionHeaderHeight - 2, vp.width - col1 * 2, 1) + end + currentY = currentY + sectionHeaderHeight + elseif item.type == "row" then + local rowY = currentY - self.scrollY + if rowY + rowHeight >= 0 and rowY < scrollH then + local varData = item.ctrlInfo.varData + -- Label (col1) + SetDrawColor(1, 1, 1) + DrawString(col1, rowY + 2, "LEFT", 16, "VAR", "^7" .. (varData.label or varData.var)) + -- Compare value (col3, read-only) + local cVal = cInput[varData.var] + local cStr = self:FormatConfigValue(varData, cVal) + DrawString(col3, rowY + 2, "LEFT", 16, "VAR", "^7" .. cStr) + end + currentY = currentY + rowHeight end end - if diffCount == 0 then - DrawString(10, drawY, "LEFT", lineHeight, "VAR", colorCodes.POSITIVE .. "No configuration differences found.") + if #self.configDisplayList == 0 then + DrawString(10, -self.scrollY, "LEFT", 16, "VAR", + colorCodes.POSITIVE .. "No configuration options to display.") end SetViewport() From 8939a5648ec4cc2796dfe4587a6a104efa76fa51 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Tue, 10 Mar 2026 16:28:18 +0100 Subject: [PATCH 04/59] add overlay option to the passive tree comparison --- src/Classes/CompareTab.lua | 281 ++++++++++++++++++++++++++++--------- 1 file changed, 216 insertions(+), 65 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 6a6ac54a7b..9effd00772 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -57,6 +57,9 @@ local CompareTabClass = newClass("CompareTab", "ControlHost", "Control", functio -- Track when tree search fields need syncing with viewer state self.treeSearchNeedsSync = true + -- Tree overlay mode (false = side-by-side, true = overlay with green/red/blue nodes) + self.treeOverlayMode = false + -- Tooltip for item hover in Items view self.itemTooltip = new("Tooltip") @@ -85,6 +88,11 @@ function CompareTabClass:InitControls() and {"TOPLEFT", self.controls.subTabAnchor, "TOPLEFT"} or {"LEFT", self.controls[prevName], "RIGHT"} self.controls["subTab" .. tabName] = new("ButtonControl", anchor, {i == 1 and 0 or 4, 0, 72, 20}, tabName, function() + -- Clear tree overlay compareSpec when leaving TREE mode + if self.compareViewMode == "TREE" and self.treeOverlayMode + and self.primaryBuild.treeTab and self.primaryBuild.treeTab.viewer then + self.primaryBuild.treeTab.viewer.compareSpec = nil + end self.compareViewMode = mode self.scrollY = 0 if mode == "TREE" then @@ -333,6 +341,9 @@ function CompareTabClass:InitControls() local treeFooterShown = function() return self.compareViewMode == "TREE" and self:GetActiveCompare() ~= nil end + local treeSideBySideShown = function() + return self.compareViewMode == "TREE" and self:GetActiveCompare() ~= nil and not self.treeOverlayMode + end -- Build version dropdown list (shared between left and right) self.treeVersionDropdownList = {} @@ -343,14 +354,34 @@ function CompareTabClass:InitControls() }) end - -- Footer anchor controls (positioned dynamically in Draw) + -- Overlay toggle checkbox (positioned dynamically in Draw) + self.controls.treeOverlayCheck = new("CheckBoxControl", nil, {0, 0, 20}, "Overlay comparison", function(state) + self.treeOverlayMode = state + self.treeSearchNeedsSync = true + if not state and self.primaryBuild.treeTab and self.primaryBuild.treeTab.viewer then + self.primaryBuild.treeTab.viewer.compareSpec = nil + end + end) + self.controls.treeOverlayCheck.shown = treeFooterShown + + -- Overlay-mode search (single search for primary viewer) + self.controls.overlayTreeSearch = new("EditControl", nil, {0, 0, 300, 20}, "", "Search", "%c", 100, function(buf) + if self.primaryBuild.treeTab and self.primaryBuild.treeTab.viewer then + self.primaryBuild.treeTab.viewer.searchStr = buf + end + end, nil, nil, true) + self.controls.overlayTreeSearch.shown = function() + return self.compareViewMode == "TREE" and self:GetActiveCompare() ~= nil and self.treeOverlayMode + end + + -- Footer anchor controls (positioned dynamically in Draw, side-by-side only) self.controls.leftFooterAnchor = new("Control", nil, {0, 0, 0, 20}) - self.controls.leftFooterAnchor.shown = treeFooterShown + self.controls.leftFooterAnchor.shown = treeSideBySideShown self.controls.rightFooterAnchor = new("Control", nil, {0, 0, 0, 20}) - self.controls.rightFooterAnchor.shown = treeFooterShown + self.controls.rightFooterAnchor.shown = treeSideBySideShown - -- Left side (primary build) footer controls - self.controls.leftSpecSelect = new("DropDownControl", {"LEFT", self.controls.leftFooterAnchor, "LEFT"}, {0, 0, 180, 20}, {}, function(index, value) + -- Left side (primary build) spec/version controls (header, both modes) + self.controls.leftSpecSelect = new("DropDownControl", nil, {0, 0, 180, 20}, {}, function(index, value) if self.primaryBuild.treeTab and self.primaryBuild.treeTab.specList[index] then self.primaryBuild.modFlag = true self.primaryBuild.treeTab:SetActiveSpec(index) @@ -367,15 +398,16 @@ function CompareTabClass:InitControls() end) self.controls.leftVersionSelect.shown = treeFooterShown - self.controls.leftTreeSearch = new("EditControl", {"TOPLEFT", self.controls.leftFooterAnchor, "TOPLEFT"}, {0, 24, 200, 20}, "", "Search", "%c", 100, function(buf) + -- Left search (footer, side-by-side only) + self.controls.leftTreeSearch = new("EditControl", {"TOPLEFT", self.controls.leftFooterAnchor, "TOPLEFT"}, {0, 0, 200, 20}, "", "Search", "%c", 100, function(buf) if self.primaryBuild.treeTab and self.primaryBuild.treeTab.viewer then self.primaryBuild.treeTab.viewer.searchStr = buf end end, nil, nil, true) - self.controls.leftTreeSearch.shown = treeFooterShown + self.controls.leftTreeSearch.shown = treeSideBySideShown - -- Right side (compare build) footer controls - self.controls.rightSpecSelect = new("DropDownControl", {"LEFT", self.controls.rightFooterAnchor, "LEFT"}, {0, 0, 180, 20}, {}, function(index, value) + -- Right side (compare build) spec/version controls (header, both modes) + self.controls.rightSpecSelect = new("DropDownControl", nil, {0, 0, 180, 20}, {}, function(index, value) local entry = self:GetActiveCompare() if entry and entry.treeTab and entry.treeTab.specList[index] then entry:SetActiveSpec(index) @@ -399,13 +431,14 @@ function CompareTabClass:InitControls() end) self.controls.rightVersionSelect.shown = treeFooterShown - self.controls.rightTreeSearch = new("EditControl", {"TOPLEFT", self.controls.rightFooterAnchor, "TOPLEFT"}, {0, 24, 200, 20}, "", "Search", "%c", 100, function(buf) + -- Right search (footer, side-by-side only) + self.controls.rightTreeSearch = new("EditControl", {"TOPLEFT", self.controls.rightFooterAnchor, "TOPLEFT"}, {0, 0, 200, 20}, "", "Search", "%c", 100, function(buf) local entry = self:GetActiveCompare() if entry and entry.treeTab and entry.treeTab.viewer then entry.treeTab.viewer.searchStr = buf end end, nil, nil, true) - self.controls.rightTreeSearch.shown = treeFooterShown + self.controls.rightTreeSearch.shown = treeSideBySideShown -- Config view: "Copy Config from Compare Build" button self.controls.copyConfigBtn = new("ButtonControl", nil, {0, 0, 240, 20}, @@ -834,41 +867,104 @@ function CompareTabClass:Draw(viewPort, inputEvents) -- (must happen before ProcessControlsInput so controls render on top of backgrounds) self.treeLayout = nil if self.compareViewMode == "TREE" and compareEntry then - local halfWidth = m_floor(contentVP.width / 2) - 2 - local footerHeight = 50 + local headerHeight = 50 -- spec/version selectors + overlay checkbox + separator + local footerHeight = 30 -- search field(s) local footerY = contentVP.y + contentVP.height - footerHeight - local rightAbsX = contentVP.x + halfWidth + 4 - local specWidth = m_min(m_floor(halfWidth * 0.55), 200) - - -- Store layout for DrawTree - self.treeLayout = { - halfWidth = halfWidth, - footerHeight = footerHeight, - footerY = footerY, - rightAbsX = rightAbsX, - } - -- Draw footer backgrounds - SetDrawColor(0.05, 0.05, 0.05) - DrawImage(nil, contentVP.x, footerY, halfWidth, footerHeight) - DrawImage(nil, rightAbsX, footerY, halfWidth, footerHeight) - SetDrawColor(0.85, 0.85, 0.85) - DrawImage(nil, contentVP.x, footerY, halfWidth, 2) - DrawImage(nil, rightAbsX, footerY, halfWidth, 2) - - -- Position left footer controls - self.controls.leftFooterAnchor.x = contentVP.x + 4 - self.controls.leftFooterAnchor.y = footerY + 4 - self.controls.leftSpecSelect.width = specWidth - self.controls.leftTreeSearch.width = halfWidth - 8 - - -- Position right footer controls - self.controls.rightFooterAnchor.x = rightAbsX + 4 - self.controls.rightFooterAnchor.y = footerY + 4 - self.controls.rightSpecSelect.width = specWidth - self.controls.rightTreeSearch.width = halfWidth - 8 - - -- Update spec dropdown lists + if self.treeOverlayMode then + -- ========== OVERLAY MODE LAYOUT ========== + local specWidth = m_min(m_floor(contentVP.width * 0.25), 200) + + self.treeLayout = { + overlay = true, + headerHeight = headerHeight, + footerHeight = footerHeight, + footerY = footerY, + } + + -- Header background + separator + SetDrawColor(0.05, 0.05, 0.05) + DrawImage(nil, contentVP.x, contentVP.y, contentVP.width, headerHeight) + SetDrawColor(0.85, 0.85, 0.85) + DrawImage(nil, contentVP.x, contentVP.y + headerHeight - 2, contentVP.width, 2) + + -- Footer background + SetDrawColor(0.05, 0.05, 0.05) + DrawImage(nil, contentVP.x, footerY, contentVP.width, footerHeight) + SetDrawColor(0.85, 0.85, 0.85) + DrawImage(nil, contentVP.x, footerY, contentVP.width, 2) + + -- Position spec/version in header row 1 + self.controls.leftSpecSelect.x = contentVP.x + 4 + self.controls.leftSpecSelect.y = contentVP.y + 4 + self.controls.leftSpecSelect.width = specWidth + + local rightSpecX = contentVP.x + m_floor(contentVP.width / 2) + 4 + self.controls.rightSpecSelect.x = rightSpecX + self.controls.rightSpecSelect.y = contentVP.y + 4 + self.controls.rightSpecSelect.width = specWidth + + -- Overlay checkbox in header row 2 (label draws LEFT of checkbox, needs ~140px clearance) + self.controls.treeOverlayCheck.x = contentVP.x + 155 + self.controls.treeOverlayCheck.y = contentVP.y + 28 + + -- Overlay search in footer (full width) + self.controls.overlayTreeSearch.x = contentVP.x + 4 + self.controls.overlayTreeSearch.y = footerY + 4 + self.controls.overlayTreeSearch.width = contentVP.width - 8 + else + -- ========== SIDE-BY-SIDE MODE LAYOUT ========== + local halfWidth = m_floor(contentVP.width / 2) - 2 + local rightAbsX = contentVP.x + halfWidth + 4 + local specWidth = m_min(m_floor(halfWidth * 0.55), 200) + + self.treeLayout = { + overlay = false, + halfWidth = halfWidth, + headerHeight = headerHeight, + footerHeight = footerHeight, + footerY = footerY, + rightAbsX = rightAbsX, + } + + -- Header background + separator + SetDrawColor(0.05, 0.05, 0.05) + DrawImage(nil, contentVP.x, contentVP.y, contentVP.width, headerHeight) + SetDrawColor(0.85, 0.85, 0.85) + DrawImage(nil, contentVP.x, contentVP.y + headerHeight - 2, contentVP.width, 2) + + -- Footer backgrounds (two halves) + SetDrawColor(0.05, 0.05, 0.05) + DrawImage(nil, contentVP.x, footerY, halfWidth, footerHeight) + DrawImage(nil, rightAbsX, footerY, halfWidth, footerHeight) + SetDrawColor(0.85, 0.85, 0.85) + DrawImage(nil, contentVP.x, footerY, halfWidth, 2) + DrawImage(nil, rightAbsX, footerY, halfWidth, 2) + + -- Position spec/version in header row 1 + self.controls.leftSpecSelect.x = contentVP.x + 4 + self.controls.leftSpecSelect.y = contentVP.y + 4 + self.controls.leftSpecSelect.width = specWidth + + self.controls.rightSpecSelect.x = contentVP.x + m_floor(contentVP.width / 2) + 4 + self.controls.rightSpecSelect.y = contentVP.y + 4 + self.controls.rightSpecSelect.width = specWidth + + -- Overlay checkbox in header row 2 (label draws LEFT of checkbox, needs ~140px clearance) + self.controls.treeOverlayCheck.x = contentVP.x + 155 + self.controls.treeOverlayCheck.y = contentVP.y + 28 + + -- Position footer search fields + self.controls.leftFooterAnchor.x = contentVP.x + 4 + self.controls.leftFooterAnchor.y = footerY + 4 + self.controls.leftTreeSearch.width = halfWidth - 8 + + self.controls.rightFooterAnchor.x = rightAbsX + 4 + self.controls.rightFooterAnchor.y = footerY + 4 + self.controls.rightTreeSearch.width = halfWidth - 8 + end + + -- (Common) Update spec dropdown lists if self.primaryBuild.treeTab then self.controls.leftSpecSelect.list = self.primaryBuild.treeTab:GetSpecList() self.controls.leftSpecSelect.selIndex = self.primaryBuild.treeTab.activeSpec @@ -878,7 +974,7 @@ function CompareTabClass:Draw(viewPort, inputEvents) self.controls.rightSpecSelect.selIndex = compareEntry.treeTab.activeSpec end - -- Update version dropdown selection to match current spec + -- (Common) Update version dropdown selection to match current spec if self.primaryBuild.spec then for i, ver in ipairs(self.treeVersionDropdownList) do if ver.value == self.primaryBuild.spec.treeVersion then @@ -896,11 +992,12 @@ function CompareTabClass:Draw(viewPort, inputEvents) end end - -- Sync search fields when entering tree mode or changing compare entry + -- (Common) Sync search fields when entering tree mode or changing compare entry if self.treeSearchNeedsSync then self.treeSearchNeedsSync = false if self.primaryBuild.treeTab and self.primaryBuild.treeTab.viewer then self.controls.leftTreeSearch:SetText(self.primaryBuild.treeTab.viewer.searchStr or "") + self.controls.overlayTreeSearch:SetText(self.primaryBuild.treeTab.viewer.searchStr or "") end if compareEntry.treeTab and compareEntry.treeTab.viewer then self.controls.rightTreeSearch:SetText(compareEntry.treeTab.viewer.searchStr or "") @@ -1080,9 +1177,37 @@ function CompareTabClass:Draw(viewPort, inputEvents) -- Process input events for our controls (including footer controls) self:ProcessControlsInput(inputEvents, viewPort) - -- Draw controls (footer controls render on top of pre-drawn backgrounds) + -- Draw TREE view BEFORE controls so header dropdowns render on top of the tree + if self.compareViewMode == "TREE" and compareEntry then + self:DrawTree(contentVP, inputEvents, compareEntry) + + -- Elevate to main draw layer 1 (matching TreeTab pattern) so controls + -- render above all tree sublayers (tree uses sublayers up to 100) + SetDrawLayer(1) + + -- Redraw header + footer backgrounds at this higher layer to cover any + -- tree artifacts that bled into those regions via high sublayers + local layout = self.treeLayout + if layout then + SetDrawColor(0.05, 0.05, 0.05) + DrawImage(nil, contentVP.x, contentVP.y, contentVP.width, layout.headerHeight) + SetDrawColor(0.85, 0.85, 0.85) + DrawImage(nil, contentVP.x, contentVP.y + layout.headerHeight - 2, contentVP.width, 2) + SetDrawColor(0.05, 0.05, 0.05) + DrawImage(nil, contentVP.x, layout.footerY, contentVP.width, layout.footerHeight) + SetDrawColor(0.85, 0.85, 0.85) + DrawImage(nil, contentVP.x, layout.footerY, contentVP.width, 2) + end + end + + -- Draw controls (at main layer 1 when in TREE mode, above all tree content) self:DrawControls(viewPort) + -- Reset to default draw layer after controls + if self.compareViewMode == "TREE" and compareEntry then + SetDrawLayer(0) + end + if not compareEntry then -- No comparison build loaded - show instructions SetViewport(contentVP.x, contentVP.y, contentVP.width, contentVP.height) @@ -1097,11 +1222,9 @@ function CompareTabClass:Draw(viewPort, inputEvents) return end - -- Dispatch to sub-view + -- Dispatch to sub-view (TREE already drawn above) if self.compareViewMode == "SUMMARY" then self:DrawSummary(contentVP, compareEntry) - elseif self.compareViewMode == "TREE" then - self:DrawTree(contentVP, inputEvents, compareEntry) elseif self.compareViewMode == "ITEMS" then self:DrawItems(contentVP, compareEntry) elseif self.compareViewMode == "SKILLS" then @@ -1345,34 +1468,65 @@ function CompareTabClass:DrawStatList(drawY, vp, displayStats, primaryOutput, co end -- ============================================================ --- TREE VIEW (side-by-side) +-- TREE VIEW (overlay + side-by-side) -- ============================================================ function CompareTabClass:DrawTree(vp, inputEvents, compareEntry) local layout = self.treeLayout if not layout then return end - local halfWidth = layout.halfWidth + local headerHeight = layout.headerHeight local footerHeight = layout.footerHeight - local labelHeight = 20 + local origGetCursorPos = GetCursorPos + + if layout.overlay then + -- ========== OVERLAY MODE ========== + -- Uses the primary build's viewer with compareSpec set to the compare entry's spec. + -- PassiveTreeView automatically renders green (added), red (removed), blue (mastery differs). + local treeAbsX = vp.x + local treeAbsY = vp.y + headerHeight + local treeHeight = vp.height - headerHeight - footerHeight + + if self.primaryBuild.treeTab and self.primaryBuild.treeTab.viewer then + -- Set compareSpec to enable overlay coloring + self.primaryBuild.treeTab.viewer.compareSpec = compareEntry.spec + + SetViewport(treeAbsX, treeAbsY, vp.width, treeHeight) + SetDrawLayer(nil, 0) + GetCursorPos = function() + local x, y = origGetCursorPos() + return x - treeAbsX, y - treeAbsY + end + local treeVP = { x = 0, y = 0, width = vp.width, height = treeHeight } + self.primaryBuild.treeTab.viewer:Draw(self.primaryBuild, treeVP, inputEvents) + SetViewport() + + -- Clear compareSpec so it doesn't affect the normal Tree tab + self.primaryBuild.treeTab.viewer.compareSpec = nil + end + + GetCursorPos = origGetCursorPos + return + end + + -- ========== SIDE-BY-SIDE MODE ========== + local halfWidth = layout.halfWidth + local treeHeight = vp.height - headerHeight - footerHeight - -- Divider (full height including footer) + -- Divider (from header bottom to viewport bottom) SetDrawColor(0.5, 0.5, 0.5) - DrawImage(nil, vp.x + halfWidth, vp.y + labelHeight, 4, vp.height - labelHeight) + DrawImage(nil, vp.x + halfWidth, vp.y + headerHeight, 4, vp.height - headerHeight) -- Route input events to the panel containing the mouse - local origGetCursorPos = GetCursorPos local mouseX, mouseY = origGetCursorPos() local leftHasInput = mouseX < (vp.x + halfWidth + 2) - local treeHeight = vp.height - labelHeight - footerHeight - -- Left tree: SetViewport clips drawing; patch GetCursorPos so mouse coords -- are viewport-relative (matching the {x=0,y=0} viewport passed to the tree) local leftAbsX = vp.x - local leftAbsY = vp.y + labelHeight + local leftAbsY = vp.y + headerHeight if self.primaryBuild.treeTab and self.primaryBuild.treeTab.viewer then SetViewport(leftAbsX, leftAbsY, halfWidth, treeHeight) - SetDrawLayer(nil, 0) -- Reset draw layer so background renders behind connectors + SetDrawLayer(nil, 0) GetCursorPos = function() local x, y = origGetCursorPos() return x - leftAbsX, y - leftAbsY @@ -1384,10 +1538,10 @@ function CompareTabClass:DrawTree(vp, inputEvents, compareEntry) -- Right tree: same approach - SetViewport for clipping, patched cursor local rightAbsX = vp.x + halfWidth + 4 - local rightAbsY = vp.y + labelHeight + local rightAbsY = vp.y + headerHeight if compareEntry.treeTab and compareEntry.treeTab.viewer then SetViewport(rightAbsX, rightAbsY, halfWidth, treeHeight) - SetDrawLayer(nil, 0) -- Reset draw layer so background renders behind connectors + SetDrawLayer(nil, 0) GetCursorPos = function() local x, y = origGetCursorPos() return x - rightAbsX, y - rightAbsY @@ -1399,9 +1553,6 @@ function CompareTabClass:DrawTree(vp, inputEvents, compareEntry) -- Restore original GetCursorPos GetCursorPos = origGetCursorPos - - -- Footer backgrounds and controls are drawn by Draw() before this method - -- (so that controls render on top of the background rectangles) end -- ============================================================ From ad9da2ae12dbb290fa3e938a3d27dfdef8422b85 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Tue, 17 Mar 2026 10:03:31 +0100 Subject: [PATCH 05/59] pair skill groups using Jaccard similarity --- src/Classes/CompareTab.lua | 76 +++++++++++++++++++++++++++++--------- 1 file changed, 58 insertions(+), 18 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 9effd00772..5685d2d8d4 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -1696,34 +1696,74 @@ function CompareTabClass:DrawSkills(vp, compareEntry) local pGroups = self.primaryBuild.skillsTab and self.primaryBuild.skillsTab.socketGroupList or {} local cGroups = compareEntry.skillsTab and compareEntry.skillsTab.socketGroupList or {} - -- Helper: get the main (non-support) skill name from a socket group - local function getMainSkillName(group) + -- Helper: get the set of gem names in a socket group + local function getGemNameSet(group) + local set = {} for _, gem in ipairs(group.gemList or {}) do - if gem.grantedEffect and not gem.grantedEffect.support then - return gem.grantedEffect.name + local name = gem.grantedEffect and gem.grantedEffect.name or gem.nameSpec + if name then + set[name] = true end end - return group.displayLabel or group.label + return set end - -- Build lookup: main skill name → compare group index - local cNameToIndex = {} + -- Helper: compute Jaccard similarity between two gem name sets + local function groupSimilarity(setA, setB) + local intersection = 0 + local union = 0 + local allKeys = {} + for k in pairs(setA) do allKeys[k] = true end + for k in pairs(setB) do allKeys[k] = true end + for k in pairs(allKeys) do + union = union + 1 + if setA[k] and setB[k] then + intersection = intersection + 1 + end + end + if union == 0 then return 0 end + return intersection / union + end + + -- Build gem name sets for all groups + local pSets = {} + for i, group in ipairs(pGroups) do + pSets[i] = getGemNameSet(group) + end + local cSets = {} for i, group in ipairs(cGroups) do - local name = getMainSkillName(group) - if name and not cNameToIndex[name] then - cNameToIndex[name] = i + cSets[i] = getGemNameSet(group) + end + + -- Compute all pairwise similarity scores + local scorePairs = {} + for pi = 1, #pGroups do + for ci = 1, #cGroups do + local score = groupSimilarity(pSets[pi], cSets[ci]) + if score > 0 then + t_insert(scorePairs, { pIdx = pi, cIdx = ci, score = score }) + end end end - -- Match primary groups to compare groups by main skill name - local renderPairs = {} + -- Sort by similarity descending (best matches first) + table.sort(scorePairs, function(a, b) return a.score > b.score end) + + -- Greedy matching: assign best pairs first, each group used at most once + local pMatched = {} local cMatched = {} - for i, group in ipairs(pGroups) do - local name = getMainSkillName(group) - if name and cNameToIndex[name] and not cMatched[cNameToIndex[name]] then - t_insert(renderPairs, { pIdx = i, cIdx = cNameToIndex[name] }) - cMatched[cNameToIndex[name]] = true - else + local renderPairs = {} + for _, sp in ipairs(scorePairs) do + if not pMatched[sp.pIdx] and not cMatched[sp.cIdx] then + t_insert(renderPairs, { pIdx = sp.pIdx, cIdx = sp.cIdx }) + pMatched[sp.pIdx] = true + cMatched[sp.cIdx] = true + end + end + + -- Add unmatched primary groups + for i = 1, #pGroups do + if not pMatched[i] then t_insert(renderPairs, { pIdx = i, cIdx = nil }) end end From 849e269d5a679f1c77c276a2d080398a3e9ef860 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Tue, 17 Mar 2026 12:34:34 +0100 Subject: [PATCH 06/59] add tooltip to calculation comparisons --- src/Classes/CompareTab.lua | 378 +++++++++++++++++++++++++++++++++++-- 1 file changed, 367 insertions(+), 11 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 5685d2d8d4..d94bd6be6e 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -63,6 +63,9 @@ local CompareTabClass = newClass("CompareTab", "ControlHost", "Control", functio -- Tooltip for item hover in Items view self.itemTooltip = new("Tooltip") + -- Tooltip for calcs hover breakdown + self.calcsTooltip = new("Tooltip") + -- Interactive config controls state self.configControls = {} -- { var -> { control, varData } } self.configControlList = {} -- ordered list for layout @@ -1828,6 +1831,319 @@ function CompareTabClass:DrawSkills(vp, compareEntry) SetViewport() end +-- ============================================================ +-- CALCS TOOLTIP HELPERS +-- ============================================================ + +-- Format a modifier value with its type for display +function CompareTabClass:FormatCalcModValue(value, modType) + if modType == "BASE" then + return s_format("%+g base", value) + elseif modType == "INC" then + if value >= 0 then + return value .. "% increased" + else + return (-value) .. "% reduced" + end + elseif modType == "MORE" then + if value >= 0 then + return value .. "% more" + else + return (-value) .. "% less" + end + elseif modType == "OVERRIDE" then + return "Override: " .. tostring(value) + elseif modType == "FLAG" then + return value and "True" or "False" + else + return tostring(value) + end +end + +-- Format CamelCase mod name to spaced words +function CompareTabClass:FormatCalcModName(modName) + return modName:gsub("([%l%d]:?)(%u)", "%1 %2"):gsub("(%l)(%d)", "%1 %2") +end + +-- Resolve a modifier's source to a human-readable name +function CompareTabClass:ResolveSourceName(mod, build) + if not mod.source then return "" end + local sourceType = mod.source:match("[^:]+") or "" + if sourceType == "Item" then + local itemId = mod.source:match("Item:(%d+):.+") + local item = build.itemsTab and build.itemsTab.items[tonumber(itemId)] + if item then + return colorCodes[item.rarity] .. item.name + end + elseif sourceType == "Tree" then + local nodeId = mod.source:match("Tree:(%d+)") + if nodeId then + local nodeIdNum = tonumber(nodeId) + local node = (build.spec and build.spec.nodes[nodeIdNum]) + or (build.spec and build.spec.tree and build.spec.tree.nodes[nodeIdNum]) + or (build.latestTree and build.latestTree.nodes[nodeIdNum]) + if node then + return node.dn or node.name or "" + end + end + elseif sourceType == "Skill" then + local skillId = mod.source:match("Skill:(.+)") + if skillId and build.data and build.data.skills[skillId] then + return build.data.skills[skillId].name + end + elseif sourceType == "Pantheon" then + return mod.source:match("Pantheon:(.+)") or "" + elseif sourceType == "Spectre" then + return mod.source:match("Spectre:(.+)") or "" + end + return "" +end + +-- Get the modDB and config for a sectionData entry and actor +function CompareTabClass:GetModStoreAndCfg(sectionData, actor) + local cfg = {} + if sectionData.cfg and actor.mainSkill and actor.mainSkill[sectionData.cfg .. "Cfg"] then + cfg = copyTable(actor.mainSkill[sectionData.cfg .. "Cfg"], true) + end + cfg.source = sectionData.modSource + cfg.actor = sectionData.actor + + local modStore + if sectionData.enemy and actor.enemy then + modStore = actor.enemy.modDB + elseif sectionData.cfg and actor.mainSkill then + modStore = actor.mainSkill.skillModList + else + modStore = actor.modDB + end + return modStore, cfg +end + +-- Tabulate modifiers for a sectionData entry and actor +function CompareTabClass:TabulateMods(sectionData, actor) + local modStore, cfg = self:GetModStoreAndCfg(sectionData, actor) + if not modStore then return {} end + + local rowList + if type(sectionData.modName) == "table" then + rowList = modStore:Tabulate(sectionData.modType, cfg, unpack(sectionData.modName)) + else + rowList = modStore:Tabulate(sectionData.modType, cfg, sectionData.modName) + end + return rowList or {} +end + +-- Build a unique key for a modifier row to match between builds +function CompareTabClass:ModRowKey(row) + local src = row.mod.source or "" + local name = row.mod.name or "" + local mtype = row.mod.type or "" + -- Normalize Item sources by stripping the build-specific numeric ID + -- "Item:5:Body Armour" -> "Item:Body Armour" so same items match across builds + local normalizedSrc = src:gsub("^(Item):%d+:", "%1:") + return normalizedSrc .. "|" .. name .. "|" .. mtype +end + +-- Format a single modifier row as a tooltip line +function CompareTabClass:FormatModRow(row, sectionData, build) + local displayValue + if not sectionData.modType then + displayValue = self:FormatCalcModValue(row.value, row.mod.type) + else + displayValue = formatRound(row.value, 2) + end + + local sourceType = row.mod.source and row.mod.source:match("[^:]+") or "?" + local sourceName = self:ResolveSourceName(row.mod, build) + local modName = "" + if type(sectionData.modName) == "table" then + modName = " " .. self:FormatCalcModName(row.mod.name) + end + + return displayValue, sourceType, sourceName, modName +end + +-- Get breakdown text lines for a build's actor +function CompareTabClass:GetBreakdownLines(sectionData, build) + if not sectionData.breakdown then return nil end + local calcsActor = build.calcsTab and build.calcsTab.calcsEnv and build.calcsTab.calcsEnv.player + if not calcsActor or not calcsActor.breakdown then return nil end + + local breakdown + local ns, name = sectionData.breakdown:match("^(%a+)%.(%a+)$") + if ns then + breakdown = calcsActor.breakdown[ns] and calcsActor.breakdown[ns][name] + else + breakdown = calcsActor.breakdown[sectionData.breakdown] + end + + if not breakdown or #breakdown == 0 then return nil end + + local lines = {} + for _, line in ipairs(breakdown) do + if type(line) == "string" then + t_insert(lines, line) + end + end + return #lines > 0 and lines or nil +end + +-- Draw the calcs hover tooltip showing breakdown for both builds with common/unique grouping +function CompareTabClass:DrawCalcsTooltip(colData, rowLabel, rowX, rowY, rowW, rowH, vp, compareEntry) + local tooltip = self.calcsTooltip + if tooltip:CheckForUpdate(colData, rowLabel) then + -- Get calcsEnv actors (these have breakdown data populated) + local primaryCalcsActor = self.primaryBuild.calcsTab and self.primaryBuild.calcsTab.calcsEnv + and self.primaryBuild.calcsTab.calcsEnv.player + local compareCalcsActor = compareEntry.calcsTab and compareEntry.calcsTab.calcsEnv + and compareEntry.calcsTab.calcsEnv.player + + local primaryActor = primaryCalcsActor or (self.primaryBuild.calcsTab.mainEnv and self.primaryBuild.calcsTab.mainEnv.player) + local compareActor = compareCalcsActor or (compareEntry.calcsTab.mainEnv and compareEntry.calcsTab.mainEnv.player) + + if not primaryActor and not compareActor then + return + end + + local primaryLabel = self:GetShortBuildName(self.primaryBuild.buildName) + local compareLabel = compareEntry.label or "Compare Build" + + -- Tooltip header + tooltip:AddLine(16, "^7" .. (rowLabel or "")) + tooltip:AddSeparator(10) + + -- Process each sectionData entry in colData + for _, sectionData in ipairs(colData) do + -- Show breakdown formulas per build (these are always build-specific) + if sectionData.breakdown then + local primaryLines = self:GetBreakdownLines(sectionData, self.primaryBuild) + local compareLines = self:GetBreakdownLines(sectionData, compareEntry) + + if primaryLines then + tooltip:AddLine(14, colorCodes.POSITIVE .. primaryLabel .. ":") + for _, line in ipairs(primaryLines) do + tooltip:AddLine(14, "^7 " .. line) + end + end + if compareLines then + tooltip:AddLine(14, colorCodes.WARNING .. compareLabel .. ":") + for _, line in ipairs(compareLines) do + tooltip:AddLine(14, "^7 " .. line) + end + end + if primaryLines or compareLines then + tooltip:AddSeparator(10) + end + end + + -- Show modifier sources split into common / primary-only / compare-only + if sectionData.modName then + local pRows = primaryActor and self:TabulateMods(sectionData, primaryActor) or {} + local cRows = compareActor and self:TabulateMods(sectionData, compareActor) or {} + + if #pRows > 0 or #cRows > 0 then + -- Build lookup of compare rows by key + local cByKey = {} + for _, row in ipairs(cRows) do + local key = self:ModRowKey(row) + cByKey[key] = row + end + + -- Classify into common, primary-only, compare-only + local common = {} -- { { pRow, cRow }, ... } + local pOnly = {} + local cMatched = {} -- keys that were matched + + for _, pRow in ipairs(pRows) do + local key = self:ModRowKey(pRow) + if cByKey[key] then + t_insert(common, { pRow, cByKey[key] }) + cMatched[key] = true + else + t_insert(pOnly, pRow) + end + end + + local cOnly = {} + for _, cRow in ipairs(cRows) do + local key = self:ModRowKey(cRow) + if not cMatched[key] then + t_insert(cOnly, cRow) + end + end + + -- Sub-section header (e.g., "Sources", "Increased Life Regeneration Rate") + local sectionLabel = sectionData.label or "Player modifiers" + tooltip:AddLine(14, "^7" .. sectionLabel .. ":") + + -- Common modifiers + if #common > 0 then + -- Sort by primary value descending + table.sort(common, function(a, b) + if type(a[1].value) == "number" and type(b[1].value) == "number" then + return a[1].value > b[1].value + end + return false + end) + tooltip:AddLine(12, "^x808080 Common:") + for _, pair in ipairs(common) do + local pVal, sourceType, sourceName, modName = self:FormatModRow(pair[1], sectionData, self.primaryBuild) + local cVal = self:FormatModRow(pair[2], sectionData, compareEntry) + local valStr + if pVal == cVal then + valStr = s_format("^7%-10s", pVal) + else + valStr = colorCodes.POSITIVE .. s_format("%-5s", pVal) .. "^7/" .. colorCodes.WARNING .. s_format("%-5s", cVal) + end + local line = s_format(" %s ^7%-6s ^7%s%s", valStr, sourceType, sourceName, modName) + tooltip:AddLine(12, line) + end + end + + -- Primary-only modifiers + if #pOnly > 0 then + table.sort(pOnly, function(a, b) + if type(a.value) == "number" and type(b.value) == "number" then + return a.value > b.value + end + return false + end) + tooltip:AddLine(12, colorCodes.POSITIVE .. " " .. primaryLabel .. " only:") + for _, row in ipairs(pOnly) do + local displayValue, sourceType, sourceName, modName = self:FormatModRow(row, sectionData, self.primaryBuild) + local line = s_format(" ^7%-10s ^7%-6s ^7%s%s", displayValue, sourceType, sourceName, modName) + tooltip:AddLine(12, line) + end + end + + -- Compare-only modifiers + if #cOnly > 0 then + table.sort(cOnly, function(a, b) + if type(a.value) == "number" and type(b.value) == "number" then + return a.value > b.value + end + return false + end) + tooltip:AddLine(12, colorCodes.WARNING .. " " .. compareLabel .. " only:") + for _, row in ipairs(cOnly) do + local displayValue, sourceType, sourceName, modName = self:FormatModRow(row, sectionData, compareEntry) + local line = s_format(" ^7%-10s ^7%-6s ^7%s%s", displayValue, sourceType, sourceName, modName) + tooltip:AddLine(12, line) + end + end + + -- Separator between sub-sections + tooltip:AddSeparator(6) + end + end + end + end + + SetDrawLayer(nil, 100) + tooltip:Draw(rowX, rowY, rowW, rowH, vp) + SetDrawLayer(nil, 0) +end + -- ============================================================ -- CALCS VIEW (card-based sections with comparison) -- ============================================================ @@ -1923,6 +2239,14 @@ function CompareTabClass:DrawCalcs(vp, compareEntry) -- Set viewport for scroll clipping SetViewport(vp.x, vp.y, vp.width, vp.height) + -- Cursor position relative to viewport (for hover detection) + local cursorX, cursorY = GetCursorPos() + local vpCursorX = cursorX - vp.x + local vpCursorY = cursorY - vp.y + local hoverColData = nil + local hoverRowLabel = nil + local hoverRowX, hoverRowY, hoverRowW, hoverRowH = 0, 0, 0, 0 + -- Draw header bar with build names local headerY = 4 - self.scrollY SetDrawColor(1, 1, 1) @@ -1977,17 +2301,41 @@ function CompareTabClass:DrawCalcs(vp, compareEntry) local colData = rowData[1] local textSize = rowData.textSize or 14 + -- Hover highlight + local isHovered = vpCursorX >= x and vpCursorX < x + cardWidth + and vpCursorY >= lineY and vpCursorY < lineY + 18 + and vpCursorY >= 0 and vpCursorY < vp.height + local rowHovered = isHovered and colData + if rowHovered then + -- Draw green border around hovered row (matching normal CalcsTab style) + SetDrawColor(0.25, 1, 0.25) + DrawImage(nil, x + 2, lineY, cardWidth - 4, 18) + SetDrawColor(rowData.bgCol or "^0") + DrawImage(nil, x + 3, lineY + 1, cardWidth - 6, 16) + hoverColData = colData + hoverRowLabel = rowData.label + hoverRowX = x + hoverRowY = lineY + hoverRowW = cardWidth + hoverRowH = 18 + end + -- Label background and text - SetDrawColor(rowData.bgCol or "^0") - DrawImage(nil, x + 2, lineY, labelWidth - 2, 18) + local bgCol = rowData.bgCol or "^0" + if not rowHovered then + SetDrawColor(bgCol) + DrawImage(nil, x + 2, lineY, labelWidth - 2, 18) + end local textColor = rowData.color or "^7" DrawString(x + labelWidth, lineY + 1, "RIGHT_X", 16, "VAR", textColor .. rowData.label .. "^7:") -- Primary value column - SetDrawColor(sec.colour) - DrawImage(nil, x + valCol1X - sepW, lineY, sepW, 18) - SetDrawColor(rowData.bgCol or "^0") - DrawImage(nil, x + valCol1X, lineY, valColWidth, 18) + if not rowHovered then + SetDrawColor(sec.colour) + DrawImage(nil, x + valCol1X - sepW, lineY, sepW, 18) + SetDrawColor(bgCol) + DrawImage(nil, x + valCol1X, lineY, valColWidth, 18) + end if colData and colData.format then local ok, str = pcall(self.FormatStr, self, colData.format, primaryActor, colData) if ok and str then @@ -1996,10 +2344,12 @@ function CompareTabClass:DrawCalcs(vp, compareEntry) end -- Compare value column - SetDrawColor(sec.colour) - DrawImage(nil, x + valCol2X - sepW, lineY, sepW, 18) - SetDrawColor(rowData.bgCol or "^0") - DrawImage(nil, x + valCol2X, lineY, valColWidth, 18) + if not rowHovered then + SetDrawColor(sec.colour) + DrawImage(nil, x + valCol2X - sepW, lineY, sepW, 18) + SetDrawColor(bgCol) + DrawImage(nil, x + valCol2X, lineY, valColWidth, 18) + end if colData and colData.format then local ok, str = pcall(self.FormatStr, self, colData.format, compareActor, colData) if ok and str then @@ -2016,7 +2366,13 @@ function CompareTabClass:DrawCalcs(vp, compareEntry) end end - SetViewport() + -- Draw hover tooltip for calcs breakdown (reset viewport first so tooltip can extend beyond) + if hoverColData then + SetViewport() + self:DrawCalcsTooltip(hoverColData, hoverRowLabel, hoverRowX + vp.x, hoverRowY + vp.y, hoverRowW, hoverRowH, vp, compareEntry) + else + SetViewport() + end end -- ============================================================ From e1d0c3ba3c52574a0a10237f4352b0e2ac99de39 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Tue, 17 Mar 2026 13:28:24 +0100 Subject: [PATCH 07/59] make tree overlay default comparison --- src/Classes/CompareTab.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index d94bd6be6e..0f2e77e19d 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -58,7 +58,7 @@ local CompareTabClass = newClass("CompareTab", "ControlHost", "Control", functio self.treeSearchNeedsSync = true -- Tree overlay mode (false = side-by-side, true = overlay with green/red/blue nodes) - self.treeOverlayMode = false + self.treeOverlayMode = true -- Tooltip for item hover in Items view self.itemTooltip = new("Tooltip") @@ -364,7 +364,7 @@ function CompareTabClass:InitControls() if not state and self.primaryBuild.treeTab and self.primaryBuild.treeTab.viewer then self.primaryBuild.treeTab.viewer.compareSpec = nil end - end) + end, nil, true) self.controls.treeOverlayCheck.shown = treeFooterShown -- Overlay-mode search (single search for primary viewer) From 88f465a17043c6cd7d5f91f8d0f2cf10274c5c7d Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Tue, 17 Mar 2026 15:18:56 +0100 Subject: [PATCH 08/59] Remove use of 'Jackard' in comment to fix spellcheck issue --- src/Classes/CompareTab.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 0f2e77e19d..0d5fa3bace 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -1711,7 +1711,7 @@ function CompareTabClass:DrawSkills(vp, compareEntry) return set end - -- Helper: compute Jaccard similarity between two gem name sets + -- Helper: compute similarity between two gem name sets local function groupSimilarity(setA, setB) local intersection = 0 local union = 0 From 58e9884ffb519838a0434c249980157116e9ab11 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Wed, 18 Mar 2026 21:55:35 +0100 Subject: [PATCH 09/59] add an expanded mode for item comparison --- src/Classes/CompareTab.lua | 422 +++++++++++++++++++++++++++++-------- 1 file changed, 337 insertions(+), 85 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 0d5fa3bace..af570ef455 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -63,6 +63,9 @@ local CompareTabClass = newClass("CompareTab", "ControlHost", "Control", functio -- Tooltip for item hover in Items view self.itemTooltip = new("Tooltip") + -- Items expanded mode (false = compact names only, true = full item details inline) + self.itemsExpandedMode = false + -- Tooltip for calcs hover breakdown self.calcsTooltip = new("Tooltip") @@ -377,6 +380,15 @@ function CompareTabClass:InitControls() return self.compareViewMode == "TREE" and self:GetActiveCompare() ~= nil and self.treeOverlayMode end + -- Items expanded mode toggle (positioned dynamically in Draw) + self.controls.itemsExpandedCheck = new("CheckBoxControl", nil, {0, 0, 20}, "Expanded mode", function(state) + self.itemsExpandedMode = state + self.scrollY = 0 + end) + self.controls.itemsExpandedCheck.shown = function() + return self.compareViewMode == "ITEMS" and self:GetActiveCompare() ~= nil + end + -- Footer anchor controls (positioned dynamically in Draw, side-by-side only) self.controls.leftFooterAnchor = new("Control", nil, {0, 0, 0, 20}) self.controls.leftFooterAnchor.shown = treeSideBySideShown @@ -870,7 +882,7 @@ function CompareTabClass:Draw(viewPort, inputEvents) -- (must happen before ProcessControlsInput so controls render on top of backgrounds) self.treeLayout = nil if self.compareViewMode == "TREE" and compareEntry then - local headerHeight = 50 -- spec/version selectors + overlay checkbox + separator + local headerHeight = 58 -- spec/version selectors + overlay checkbox + separator + padding local footerHeight = 30 -- search field(s) local footerY = contentVP.y + contentVP.height - footerHeight @@ -899,17 +911,17 @@ function CompareTabClass:Draw(viewPort, inputEvents) -- Position spec/version in header row 1 self.controls.leftSpecSelect.x = contentVP.x + 4 - self.controls.leftSpecSelect.y = contentVP.y + 4 + self.controls.leftSpecSelect.y = contentVP.y + 8 self.controls.leftSpecSelect.width = specWidth local rightSpecX = contentVP.x + m_floor(contentVP.width / 2) + 4 self.controls.rightSpecSelect.x = rightSpecX - self.controls.rightSpecSelect.y = contentVP.y + 4 + self.controls.rightSpecSelect.y = contentVP.y + 8 self.controls.rightSpecSelect.width = specWidth -- Overlay checkbox in header row 2 (label draws LEFT of checkbox, needs ~140px clearance) self.controls.treeOverlayCheck.x = contentVP.x + 155 - self.controls.treeOverlayCheck.y = contentVP.y + 28 + self.controls.treeOverlayCheck.y = contentVP.y + 34 -- Overlay search in footer (full width) self.controls.overlayTreeSearch.x = contentVP.x + 4 @@ -946,16 +958,16 @@ function CompareTabClass:Draw(viewPort, inputEvents) -- Position spec/version in header row 1 self.controls.leftSpecSelect.x = contentVP.x + 4 - self.controls.leftSpecSelect.y = contentVP.y + 4 + self.controls.leftSpecSelect.y = contentVP.y + 8 self.controls.leftSpecSelect.width = specWidth self.controls.rightSpecSelect.x = contentVP.x + m_floor(contentVP.width / 2) + 4 - self.controls.rightSpecSelect.y = contentVP.y + 4 + self.controls.rightSpecSelect.y = contentVP.y + 8 self.controls.rightSpecSelect.width = specWidth -- Overlay checkbox in header row 2 (label draws LEFT of checkbox, needs ~140px clearance) self.controls.treeOverlayCheck.x = contentVP.x + 155 - self.controls.treeOverlayCheck.y = contentVP.y + 28 + self.controls.treeOverlayCheck.y = contentVP.y + 34 -- Position footer search fields self.controls.leftFooterAnchor.x = contentVP.x + 4 @@ -1035,9 +1047,9 @@ function CompareTabClass:Draw(viewPort, inputEvents) -- Position buttons at top of config view (above column headers) self.controls.copyConfigBtn.x = contentVP.x + 10 - self.controls.copyConfigBtn.y = contentVP.y + 4 + self.controls.copyConfigBtn.y = contentVP.y + 8 self.controls.configToggleBtn.x = contentVP.x + 260 - self.controls.configToggleBtn.y = contentVP.y + 4 + self.controls.configToggleBtn.y = contentVP.y + 8 -- Build display list: Differences section first, then All Configurations local cInput = compareEntry.configTab.input or {} @@ -1225,6 +1237,13 @@ function CompareTabClass:Draw(viewPort, inputEvents) return end + -- Position items expanded mode checkbox (inside content area, top-left) + -- Label draws to the left of the checkbox, so offset x by labelWidth to keep it visible + if self.compareViewMode == "ITEMS" then + self.controls.itemsExpandedCheck.x = contentVP.x + 10 + self.controls.itemsExpandedCheck.labelWidth + self.controls.itemsExpandedCheck.y = contentVP.y + 8 + end + -- Dispatch to sub-view (TREE already drawn above) if self.compareViewMode == "SUMMARY" then self:DrawSummary(contentVP, compareEntry) @@ -1312,7 +1331,7 @@ function CompareTabClass:DrawProgressSection(drawY, colWidth, vp, compareEntry) local compareItemCount = 0 local matchingItemCount = 0 if self.primaryBuild.itemsTab and compareEntry.itemsTab then - local baseSlots = { "Weapon 1", "Weapon 2", "Helmet", "Body Armour", "Gloves", "Boots", "Amulet", "Ring 1", "Ring 2", "Belt" } + local baseSlots = { "Weapon 1", "Weapon 2", "Helmet", "Body Armour", "Gloves", "Boots", "Amulet", "Ring 1", "Ring 2", "Belt", "Flask 1", "Flask 2", "Flask 3", "Flask 4", "Flask 5" } for _, slotName in ipairs(baseSlots) do local pSlot = self.primaryBuild.itemsTab.slots[slotName] local cSlot = compareEntry.itemsTab.slots[slotName] @@ -1561,13 +1580,218 @@ end -- ============================================================ -- ITEMS VIEW -- ============================================================ + +-- Helper: get rarity color code for an item +local function getRarityColor(item) + if not item then return "^7" end + if item.rarity == "UNIQUE" then return colorCodes.UNIQUE + elseif item.rarity == "RARE" then return colorCodes.RARE + elseif item.rarity == "MAGIC" then return colorCodes.MAGIC + else return colorCodes.NORMAL end +end + +-- Helper: normalize a mod line by replacing numbers with "#" for template matching +local function modLineTemplate(line) + -- Replace decimal numbers first (e.g. "1.5"), then integers + return line:gsub("[%d]+%.?[%d]*", "#") +end + +-- Helper: extract the first number from a mod line for value comparison +local function modLineValue(line) + return tonumber(line:match("[%d]+%.?[%d]*")) or 0 +end + +-- Helper: build a mod comparison map from an item. +-- Returns a table keyed by template string → { line = original text, value = first number } +local function buildModMap(item) + local modMap = {} + if not item then return modMap end + for _, modList in ipairs{item.enchantModLines or {}, item.scourgeModLines or {}, item.implicitModLines or {}, item.explicitModLines or {}, item.crucibleModLines or {}} do + for _, modLine in ipairs(modList) do + if item:CheckModLineVariant(modLine) then + local formatted = itemLib.formatModLine(modLine) + if formatted then + local tmpl = modLineTemplate(modLine.line) + modMap[tmpl] = { line = modLine.line, value = modLineValue(modLine.line) } + end + end + end + end + return modMap +end + +-- Draw a single item's full details at (x, startY) within colWidth. +-- otherModMap: optional table from buildModMap() of the other item for diff highlighting. +-- Returns the total height consumed. +function CompareTabClass:DrawItemExpanded(item, x, startY, colWidth, otherModMap) + local lineHeight = 16 + local fontSize = 14 + local drawY = startY + + if not item then + DrawString(x, drawY, "LEFT", fontSize, "VAR", "^8(empty)") + return lineHeight + end + + -- Item name + local rarityColor = getRarityColor(item) + DrawString(x, drawY, "LEFT", 16, "VAR", rarityColor .. item.name) + drawY = drawY + 18 + + -- Base type label + local base = item.base + if base then + if base.weapon then + local weaponData = item.weaponData and item.weaponData[1] + if weaponData then + if weaponData.PhysicalDPS then + DrawString(x, drawY, "LEFT", fontSize, "VAR", s_format("^x7F7F7FPhys DPS: " .. colorCodes.MAGIC .. "%.1f", weaponData.PhysicalDPS)) + drawY = drawY + lineHeight + end + if weaponData.ElementalDPS then + DrawString(x, drawY, "LEFT", fontSize, "VAR", s_format("^x7F7F7FEle DPS: " .. colorCodes.MAGIC .. "%.1f", weaponData.ElementalDPS)) + drawY = drawY + lineHeight + end + if weaponData.ChaosDPS then + DrawString(x, drawY, "LEFT", fontSize, "VAR", s_format("^x7F7F7FChaos DPS: " .. colorCodes.MAGIC .. "%.1f", weaponData.ChaosDPS)) + drawY = drawY + lineHeight + end + if weaponData.TotalDPS then + DrawString(x, drawY, "LEFT", fontSize, "VAR", s_format("^x7F7F7FTotal DPS: " .. colorCodes.MAGIC .. "%.1f", weaponData.TotalDPS)) + drawY = drawY + lineHeight + end + DrawString(x, drawY, "LEFT", fontSize, "VAR", s_format("^x7F7F7FCrit: " .. colorCodes.MAGIC .. "%.2f%%", weaponData.CritChance)) + drawY = drawY + lineHeight + DrawString(x, drawY, "LEFT", fontSize, "VAR", s_format("^x7F7F7FAPS: " .. colorCodes.MAGIC .. "%.2f", weaponData.AttackRate)) + drawY = drawY + lineHeight + end + elseif base.armour then + local armourData = item.armourData + if armourData then + if armourData.Armour and armourData.Armour > 0 then + DrawString(x, drawY, "LEFT", fontSize, "VAR", s_format("^x7F7F7FArmour: " .. colorCodes.MAGIC .. "%d", armourData.Armour)) + drawY = drawY + lineHeight + end + if armourData.Evasion and armourData.Evasion > 0 then + DrawString(x, drawY, "LEFT", fontSize, "VAR", s_format("^x7F7F7FEvasion: " .. colorCodes.MAGIC .. "%d", armourData.Evasion)) + drawY = drawY + lineHeight + end + if armourData.EnergyShield and armourData.EnergyShield > 0 then + DrawString(x, drawY, "LEFT", fontSize, "VAR", s_format("^x7F7F7FES: " .. colorCodes.MAGIC .. "%d", armourData.EnergyShield)) + drawY = drawY + lineHeight + end + if armourData.Ward and armourData.Ward > 0 then + DrawString(x, drawY, "LEFT", fontSize, "VAR", s_format("^x7F7F7FWard: " .. colorCodes.MAGIC .. "%d", armourData.Ward)) + drawY = drawY + lineHeight + end + if armourData.BlockChance and armourData.BlockChance > 0 then + DrawString(x, drawY, "LEFT", fontSize, "VAR", s_format("^x7F7F7FBlock: " .. colorCodes.MAGIC .. "%d%%", armourData.BlockChance)) + drawY = drawY + lineHeight + end + end + elseif base.flask then + local flaskData = item.flaskData + if flaskData then + if flaskData.lifeTotal then + DrawString(x, drawY, "LEFT", fontSize, "VAR", s_format("^x7F7F7FLife: " .. colorCodes.MAGIC .. "%d ^x7F7F7F(%.1fs)", flaskData.lifeTotal, flaskData.duration or 0)) + drawY = drawY + lineHeight + end + if flaskData.manaTotal then + DrawString(x, drawY, "LEFT", fontSize, "VAR", s_format("^x7F7F7FMana: " .. colorCodes.MAGIC .. "%d ^x7F7F7F(%.1fs)", flaskData.manaTotal, flaskData.duration or 0)) + drawY = drawY + lineHeight + end + if not flaskData.lifeTotal and not flaskData.manaTotal and flaskData.duration then + DrawString(x, drawY, "LEFT", fontSize, "VAR", s_format("^x7F7F7FDuration: " .. colorCodes.MAGIC .. "%.2fs", flaskData.duration)) + drawY = drawY + lineHeight + end + if flaskData.chargesUsed and flaskData.chargesMax then + DrawString(x, drawY, "LEFT", fontSize, "VAR", s_format("^x7F7F7FCharges: " .. colorCodes.MAGIC .. "%d/%d", flaskData.chargesUsed, flaskData.chargesMax)) + drawY = drawY + lineHeight + end + -- Flask buff mods + if item.buffModLines then + for _, modLine in pairs(item.buffModLines) do + local color = modLine.extra and colorCodes.UNSUPPORTED or colorCodes.MAGIC + DrawString(x, drawY, "LEFT", fontSize, "VAR", color .. modLine.line) + drawY = drawY + lineHeight + end + end + end + end + + -- Quality (if not shown in type-specific section) + if item.quality and item.quality > 0 and not base.weapon and not base.armour and not base.flask then + DrawString(x, drawY, "LEFT", fontSize, "VAR", s_format("^x7F7F7FQuality: " .. colorCodes.MAGIC .. "+%d%%", item.quality)) + drawY = drawY + lineHeight + end + end + + -- Separator before mods + if drawY > startY + 18 then + drawY = drawY + 2 + end + + -- Mod lines with diff highlighting + for _, modListData in ipairs{item.enchantModLines or {}, item.scourgeModLines or {}, item.implicitModLines or {}, item.explicitModLines or {}, item.crucibleModLines or {}} do + local drewAny = false + for _, modLine in ipairs(modListData) do + if item:CheckModLineVariant(modLine) then + local formatted = itemLib.formatModLine(modLine) + if formatted then + if otherModMap then + local tmpl = modLineTemplate(modLine.line) + local otherEntry = otherModMap[tmpl] + if not otherEntry then + -- Mod exists only on this side + formatted = colorCodes.POSITIVE .. "+ " .. formatted + elseif otherEntry.line ~= modLine.line then + -- Same mod template but different values + local myVal = modLineValue(modLine.line) + local otherVal = otherEntry.value + if myVal > otherVal then + formatted = colorCodes.POSITIVE .. "> " .. formatted + elseif myVal < otherVal then + formatted = colorCodes.NEGATIVE .. "< " .. formatted + end + -- If equal after rounding, no indicator needed + end + -- If exact match (same line text), no indicator — it's identical + end + DrawString(x, drawY, "LEFT", fontSize, "VAR", formatted) + drawY = drawY + lineHeight + drewAny = true + end + end + end + if drewAny then + drawY = drawY + 2 -- small gap between mod sections + end + end + + -- Corrupted/Split/Mirrored + if item.corrupted then + DrawString(x, drawY, "LEFT", fontSize, "VAR", colorCodes.NEGATIVE .. "Corrupted") + drawY = drawY + lineHeight + end + if item.split then + DrawString(x, drawY, "LEFT", fontSize, "VAR", colorCodes.NEGATIVE .. "Split") + drawY = drawY + lineHeight + end + if item.mirrored then + DrawString(x, drawY, "LEFT", fontSize, "VAR", colorCodes.NEGATIVE .. "Mirrored") + drawY = drawY + lineHeight + end + + return drawY - startY +end + function CompareTabClass:DrawItems(vp, compareEntry) - local baseSlots = { "Weapon 1", "Weapon 2", "Helmet", "Body Armour", "Gloves", "Boots", "Amulet", "Ring 1", "Ring 2", "Belt" } + local baseSlots = { "Weapon 1", "Weapon 2", "Helmet", "Body Armour", "Gloves", "Boots", "Amulet", "Ring 1", "Ring 2", "Belt", "Flask 1", "Flask 2", "Flask 3", "Flask 4", "Flask 5" } local lineHeight = 20 - local slotHeight = 46 local colWidth = m_floor(vp.width / 2) - SetViewport(vp.x, vp.y, vp.width, vp.height) + local checkboxOffset = 36 -- space for the expanded mode checkbox plus padding + SetViewport(vp.x, vp.y + checkboxOffset, vp.width, vp.height - checkboxOffset) local drawY = 4 - self.scrollY -- Get cursor position relative to viewport for hover detection @@ -1591,83 +1815,111 @@ function CompareTabClass:DrawItems(vp, compareEntry) DrawImage(nil, 4, drawY, vp.width - 8, 1) drawY = drawY + 2 - -- Slot label - SetDrawColor(1, 1, 1) - DrawString(10, drawY, "LEFT", 16, "VAR", "^7" .. slotName .. ":") - -- Get items from both builds local pSlot = self.primaryBuild.itemsTab and self.primaryBuild.itemsTab.slots and self.primaryBuild.itemsTab.slots[slotName] local cSlot = compareEntry.itemsTab and compareEntry.itemsTab.slots and compareEntry.itemsTab.slots[slotName] local pItem = pSlot and self.primaryBuild.itemsTab.items and self.primaryBuild.itemsTab.items[pSlot.selItemId] local cItem = cSlot and compareEntry.itemsTab and compareEntry.itemsTab.items and compareEntry.itemsTab.items[cSlot.selItemId] - local pName = pItem and pItem.name or "(empty)" - local cName = cItem and cItem.name or "(empty)" - - -- Color code by rarity - local pColor = "^7" - if pItem then - if pItem.rarity == "UNIQUE" then pColor = colorCodes.UNIQUE - elseif pItem.rarity == "RARE" then pColor = colorCodes.RARE - elseif pItem.rarity == "MAGIC" then pColor = colorCodes.MAGIC - else pColor = colorCodes.NORMAL end - end - local cColor = "^7" - if cItem then - if cItem.rarity == "UNIQUE" then cColor = colorCodes.UNIQUE - elseif cItem.rarity == "RARE" then cColor = colorCodes.RARE - elseif cItem.rarity == "MAGIC" then cColor = colorCodes.MAGIC - else cColor = colorCodes.NORMAL end - end - - drawY = drawY + 18 - - -- Draw item names - DrawString(20, drawY, "LEFT", 16, "VAR", pColor .. pName) - DrawString(colWidth + 20, drawY, "LEFT", 16, "VAR", cColor .. cName) - - -- Check hover on primary item (left column) - if pItem and cursorX >= 10 and cursorX < colWidth - and cursorY >= drawY and cursorY < drawY + 18 then - hoverItem = pItem - hoverX = 20 - hoverY = drawY - hoverW = colWidth - 30 - hoverH = 18 - hoverItemsTab = self.primaryBuild.itemsTab - end - - -- Check hover on compare item (right column) - if cItem and cursorX >= colWidth and cursorX < vp.width - and cursorY >= drawY and cursorY < drawY + 18 then - hoverItem = cItem - hoverX = colWidth + 20 - hoverY = drawY - hoverW = colWidth - 30 - hoverH = 18 - hoverItemsTab = compareEntry.itemsTab - end - - -- Show diff indicator - local isSame = pItem and cItem and pItem.name == cItem.name - local diffLabel = "" - if not pItem and not cItem then - diffLabel = "^8(both empty)" - elseif isSame then - diffLabel = colorCodes.POSITIVE .. "(match)" - elseif not pItem then - diffLabel = colorCodes.NEGATIVE .. "(missing)" - elseif not cItem then - diffLabel = colorCodes.TIP .. "(extra)" + if self.itemsExpandedMode then + -- === EXPANDED MODE === + -- Slot label + SetDrawColor(1, 1, 1) + DrawString(10, drawY, "LEFT", 16, "VAR", "^7" .. slotName .. ":") + + -- Diff indicator next to slot label + local isSame = pItem and cItem and pItem.name == cItem.name + local diffLabel = "" + if not pItem and not cItem then + diffLabel = "^8(both empty)" + elseif isSame then + diffLabel = colorCodes.POSITIVE .. "(match)" + elseif not pItem then + diffLabel = colorCodes.NEGATIVE .. "(missing)" + elseif not cItem then + diffLabel = colorCodes.TIP .. "(extra)" + else + diffLabel = colorCodes.WARNING .. "(different)" + end + DrawString(colWidth - 10, drawY, "RIGHT", 14, "VAR", diffLabel) + drawY = drawY + 20 + + -- Build mod maps for diff highlighting + local pModMap = buildModMap(pItem) + local cModMap = buildModMap(cItem) + + -- Draw both items expanded side by side + local itemStartY = drawY + local leftHeight = self:DrawItemExpanded(pItem, 20, drawY, colWidth - 30, cModMap) + local rightHeight = self:DrawItemExpanded(cItem, colWidth + 20, drawY, colWidth - 30, pModMap) + + -- Vertical separator between columns + SetDrawColor(0.25, 0.25, 0.25) + local maxH = m_max(leftHeight, rightHeight) + DrawImage(nil, colWidth, itemStartY, 1, maxH) + + drawY = drawY + maxH + 6 else - diffLabel = colorCodes.WARNING .. "(different)" - end - DrawString(colWidth - 10, drawY, "RIGHT", 14, "VAR", diffLabel) + -- === COMPACT MODE (existing behavior) === + -- Slot label + SetDrawColor(1, 1, 1) + DrawString(10, drawY, "LEFT", 16, "VAR", "^7" .. slotName .. ":") + + local pName = pItem and pItem.name or "(empty)" + local cName = cItem and cItem.name or "(empty)" + + local pColor = getRarityColor(pItem) + local cColor = getRarityColor(cItem) + + drawY = drawY + 18 + + -- Draw item names + DrawString(20, drawY, "LEFT", 16, "VAR", pColor .. pName) + DrawString(colWidth + 20, drawY, "LEFT", 16, "VAR", cColor .. cName) + + -- Check hover on primary item (left column) + if pItem and cursorX >= 10 and cursorX < colWidth + and cursorY >= drawY and cursorY < drawY + 18 then + hoverItem = pItem + hoverX = 20 + hoverY = drawY + hoverW = colWidth - 30 + hoverH = 18 + hoverItemsTab = self.primaryBuild.itemsTab + end + + -- Check hover on compare item (right column) + if cItem and cursorX >= colWidth and cursorX < vp.width + and cursorY >= drawY and cursorY < drawY + 18 then + hoverItem = cItem + hoverX = colWidth + 20 + hoverY = drawY + hoverW = colWidth - 30 + hoverH = 18 + hoverItemsTab = compareEntry.itemsTab + end + + -- Show diff indicator + local isSame = pItem and cItem and pItem.name == cItem.name + local diffLabel = "" + if not pItem and not cItem then + diffLabel = "^8(both empty)" + elseif isSame then + diffLabel = colorCodes.POSITIVE .. "(match)" + elseif not pItem then + diffLabel = colorCodes.NEGATIVE .. "(missing)" + elseif not cItem then + diffLabel = colorCodes.TIP .. "(extra)" + else + diffLabel = colorCodes.WARNING .. "(different)" + end + DrawString(colWidth - 10, drawY, "RIGHT", 14, "VAR", diffLabel) - drawY = drawY + 20 + drawY = drawY + 20 + end end - -- Draw item tooltip on hover (on top of everything) + -- Draw item tooltip on hover (compact mode only, on top of everything) if hoverItem and hoverItemsTab then self.itemTooltip:Clear() hoverItemsTab:AddItemTooltip(self.itemTooltip, hoverItem, nil) @@ -1691,8 +1943,8 @@ function CompareTabClass:DrawSkills(vp, compareEntry) -- Headers SetDrawColor(1, 1, 1) - DrawString(10, drawY, "LEFT", 18, "VAR", colorCodes.POSITIVE .. self:GetShortBuildName(self.primaryBuild.buildName) .. " - Socket Groups") - DrawString(colWidth + 10, drawY, "LEFT", 18, "VAR", colorCodes.WARNING .. (compareEntry.label or "Compare Build") .. " - Socket Groups") + DrawString(10, drawY, "LEFT", 18, "VAR", colorCodes.POSITIVE .. self:GetShortBuildName(self.primaryBuild.buildName)) + DrawString(colWidth + 10, drawY, "LEFT", 18, "VAR", colorCodes.WARNING .. (compareEntry.label or "Compare Build")) drawY = drawY + 24 -- Get socket groups from both builds @@ -2382,7 +2634,7 @@ function CompareTabClass:DrawConfig(vp, compareEntry) local rowHeight = 22 local sectionHeaderHeight = 24 local columnHeaderHeight = 20 - local fixedHeaderHeight = 58 -- buttons + column headers + separator (not scrollable) + local fixedHeaderHeight = 66 -- buttons + column headers + separator (not scrollable) -- Column positions (viewport-relative) local col1 = 10 @@ -2391,9 +2643,9 @@ function CompareTabClass:DrawConfig(vp, compareEntry) -- Fixed header area: buttons at top, then column headers + separator SetViewport(vp.x, vp.y, vp.width, fixedHeaderHeight) - -- Buttons (Copy Config + Toggle) are drawn by ControlHost at y=4 + -- Buttons (Copy Config + Toggle) are drawn by ControlHost at y=8 -- Column headers below buttons - local colHeaderY = 28 + local colHeaderY = 36 SetDrawColor(1, 1, 1) DrawString(col1, colHeaderY, "LEFT", columnHeaderHeight, "VAR", "^7Configuration Option") DrawString(col2, colHeaderY, "LEFT", columnHeaderHeight, "VAR", From 7a43accabed960d865b5746af491caf446f2e965 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Wed, 18 Mar 2026 22:28:30 +0100 Subject: [PATCH 10/59] add buttons to copy and copy+use the compared builds tree or items --- src/Classes/CompareTab.lua | 186 ++++++++++++++++++++++++++++++++++++- 1 file changed, 182 insertions(+), 4 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index af570ef455..c95501c021 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -197,7 +197,6 @@ function CompareTabClass:InitControls() end end) self.controls.compareSkillSetSelect.enabled = setsEnabled - -- Item set selector for comparison build self.controls.compareItemSetLabel = new("LabelControl", {"LEFT", self.controls.compareSkillSetSelect, "RIGHT"}, {8, 0, 0, 16}, "^7Item set:") self.controls.compareItemSetLabel.shown = setsEnabled @@ -446,6 +445,22 @@ function CompareTabClass:InitControls() end) self.controls.rightVersionSelect.shown = treeFooterShown + -- Copy compared tree to primary build + self.controls.copySpecBtn = new("ButtonControl", {"LEFT", self.controls.rightVersionSelect, "RIGHT"}, {4, 0, 66, 20}, "Copy tree", function() + self:CopyCompareSpecToPrimary(false) + end) + self.controls.copySpecBtn.shown = treeFooterShown + self.controls.copySpecBtn.enabled = function() + local entry = self:GetActiveCompare() + return entry and entry.treeTab and entry.treeTab.specList[entry.treeTab.activeSpec] ~= nil + end + + self.controls.copySpecUseBtn = new("ButtonControl", {"LEFT", self.controls.copySpecBtn, "RIGHT"}, {2, 0, 90, 20}, "Copy and use", function() + self:CopyCompareSpecToPrimary(true) + end) + self.controls.copySpecUseBtn.shown = treeFooterShown + self.controls.copySpecUseBtn.enabled = self.controls.copySpecBtn.enabled + -- Right search (footer, side-by-side only) self.controls.rightTreeSearch = new("EditControl", {"TOPLEFT", self.controls.rightFooterAnchor, "TOPLEFT"}, {0, 0, 200, 20}, "", "Search", "%c", 100, function(buf) local entry = self:GetActiveCompare() @@ -787,6 +802,69 @@ function CompareTabClass:GetActiveCompare() return nil end +-- Copy the compared build's currently selected tree spec into the primary build +function CompareTabClass:CopyCompareSpecToPrimary(andUse) + local entry = self:GetActiveCompare() + if not entry or not entry.treeTab then return end + local sourceSpec = entry.treeTab.specList[entry.treeTab.activeSpec] + if not sourceSpec then return end + + local primaryTreeTab = self.primaryBuild.treeTab + + -- Create new spec from source (same pattern as PassiveSpecListControl Copy) + -- Note: we don't copy jewels because they reference item IDs in the compared + -- build's itemsTab which don't exist in the primary build + local newSpec = new("PassiveSpec", self.primaryBuild, sourceSpec.treeVersion) + newSpec.title = (sourceSpec.title or "Default") .. " (Compared)" + newSpec:RestoreUndoState(sourceSpec:CreateUndoState()) + newSpec:BuildClusterJewelGraphs() + + -- Add to primary build's spec list + t_insert(primaryTreeTab.specList, newSpec) + + if andUse then + primaryTreeTab:SetActiveSpec(#primaryTreeTab.specList) + -- Restore primary build's window title + if self.primaryBuild.spec then + self.primaryBuild.spec:SetWindowTitleWithBuildClass() + end + end + + -- Update items tab passive tree dropdown (same pattern as PassiveSpecListControl) + local itemsSpecSelect = self.primaryBuild.itemsTab.controls.specSelect + local newSpecList = {} + for i = 1, #primaryTreeTab.specList do + newSpecList[i] = primaryTreeTab.specList[i].title or "Default" + end + itemsSpecSelect:SetList(newSpecList) + itemsSpecSelect.selIndex = primaryTreeTab.activeSpec + + self.primaryBuild.buildFlag = true +end + +-- Copy a compared build's item into the primary build +function CompareTabClass:CopyCompareItemToPrimary(slotName, compareEntry, andUse) + local cSlot = compareEntry.itemsTab and compareEntry.itemsTab.slots and compareEntry.itemsTab.slots[slotName] + local cItem = cSlot and compareEntry.itemsTab.items and compareEntry.itemsTab.items[cSlot.selItemId] + if not cItem or not cItem.raw then return end + + local newItem = new("Item", cItem.raw) + newItem:NormaliseQuality() + local pItemsTab = self.primaryBuild.itemsTab + pItemsTab:AddItem(newItem, true) -- true = noAutoEquip + + if andUse then + local pSlot = pItemsTab.slots[slotName] + if pSlot then + pSlot:SetSelItemId(newItem.id) + end + end + + pItemsTab:PopulateSlots() + pItemsTab:AddUndoState() + self.primaryBuild.buildFlag = true +end + -- Open the import popup for adding a comparison build function CompareTabClass:OpenImportPopup() local controls = {} @@ -1248,7 +1326,7 @@ function CompareTabClass:Draw(viewPort, inputEvents) if self.compareViewMode == "SUMMARY" then self:DrawSummary(contentVP, compareEntry) elseif self.compareViewMode == "ITEMS" then - self:DrawItems(contentVP, compareEntry) + self:DrawItems(contentVP, compareEntry, inputEvents) elseif self.compareViewMode == "SKILLS" then self:DrawSkills(contentVP, compareEntry) elseif self.compareViewMode == "CALCS" then @@ -1785,7 +1863,7 @@ function CompareTabClass:DrawItemExpanded(item, x, startY, colWidth, otherModMap return drawY - startY end -function CompareTabClass:DrawItems(vp, compareEntry) +function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) local baseSlots = { "Weapon 1", "Weapon 2", "Helmet", "Body Armour", "Gloves", "Boots", "Amulet", "Ring 1", "Ring 2", "Belt", "Flask 1", "Flask 2", "Flask 3", "Flask 4", "Flask 5" } local lineHeight = 20 local colWidth = m_floor(vp.width / 2) @@ -1797,12 +1875,16 @@ function CompareTabClass:DrawItems(vp, compareEntry) -- Get cursor position relative to viewport for hover detection local cursorX, cursorY = GetCursorPos() cursorX = cursorX - vp.x - cursorY = cursorY - vp.y + cursorY = cursorY - (vp.y + checkboxOffset) local hoverItem = nil local hoverX, hoverY = 0, 0 local hoverW, hoverH = 0, 0 local hoverItemsTab = nil + -- Track item copy button clicks + local clickedCopySlot = nil + local clickedCopyUseSlot = nil + -- Headers SetDrawColor(1, 1, 1) DrawString(10, drawY, "LEFT", 18, "VAR", colorCodes.POSITIVE .. self:GetShortBuildName(self.primaryBuild.buildName)) @@ -1842,6 +1924,51 @@ function CompareTabClass:DrawItems(vp, compareEntry) diffLabel = colorCodes.WARNING .. "(different)" end DrawString(colWidth - 10, drawY, "RIGHT", 14, "VAR", diffLabel) + + -- Copy buttons for compare item (expanded mode) + if cItem then + local btnW = 60 + local btnH = 18 + local btn2X = vp.width - btnW - 8 + local btn1X = btn2X - btnW - 4 + local btnY = drawY + 1 + + -- "Copy" button + local b1Hover = cursorX >= btn1X and cursorX < btn1X + btnW + and cursorY >= btnY and cursorY < btnY + btnH + SetDrawColor(b1Hover and 0.5 or 0.35, b1Hover and 0.5 or 0.35, b1Hover and 0.5 or 0.35) + DrawImage(nil, btn1X, btnY, btnW, btnH) + SetDrawColor(0.1, 0.1, 0.1) + DrawImage(nil, btn1X + 1, btnY + 1, btnW - 2, btnH - 2) + SetDrawColor(1, 1, 1) + DrawString(btn1X + btnW / 2, btnY + 1, "CENTER_X", 14, "VAR", "^7Copy") + + -- "Copy+Use" button + local b2Hover = cursorX >= btn2X and cursorX < btn2X + btnW + and cursorY >= btnY and cursorY < btnY + btnH + SetDrawColor(b2Hover and 0.5 or 0.35, b2Hover and 0.5 or 0.35, b2Hover and 0.5 or 0.35) + DrawImage(nil, btn2X, btnY, btnW, btnH) + SetDrawColor(0.1, 0.1, 0.1) + DrawImage(nil, btn2X + 1, btnY + 1, btnW - 2, btnH - 2) + SetDrawColor(1, 1, 1) + DrawString(btn2X + btnW / 2, btnY + 1, "CENTER_X", 14, "VAR", "^7Copy+Use") + + -- Click detection + if inputEvents then + for id, event in ipairs(inputEvents) do + if event.type == "KeyUp" and event.key == "LEFTBUTTON" then + if b1Hover then + clickedCopySlot = slotName + inputEvents[id] = nil + elseif b2Hover then + clickedCopyUseSlot = slotName + inputEvents[id] = nil + end + end + end + end + end + drawY = drawY + 20 -- Build mod maps for diff highlighting @@ -1899,6 +2026,50 @@ function CompareTabClass:DrawItems(vp, compareEntry) hoverItemsTab = compareEntry.itemsTab end + -- Copy buttons for compare item (compact mode) + if cItem then + local btnW = 60 + local btnH = 18 + local btn2X = vp.width - btnW - 8 + local btn1X = btn2X - btnW - 4 + local btnY = drawY + + -- "Copy" button + local b1Hover = cursorX >= btn1X and cursorX < btn1X + btnW + and cursorY >= btnY and cursorY < btnY + btnH + SetDrawColor(b1Hover and 0.5 or 0.35, b1Hover and 0.5 or 0.35, b1Hover and 0.5 or 0.35) + DrawImage(nil, btn1X, btnY, btnW, btnH) + SetDrawColor(0.1, 0.1, 0.1) + DrawImage(nil, btn1X + 1, btnY + 1, btnW - 2, btnH - 2) + SetDrawColor(1, 1, 1) + DrawString(btn1X + btnW / 2, btnY + 1, "CENTER_X", 14, "VAR", "^7Copy") + + -- "Copy+Use" button + local b2Hover = cursorX >= btn2X and cursorX < btn2X + btnW + and cursorY >= btnY and cursorY < btnY + btnH + SetDrawColor(b2Hover and 0.5 or 0.35, b2Hover and 0.5 or 0.35, b2Hover and 0.5 or 0.35) + DrawImage(nil, btn2X, btnY, btnW, btnH) + SetDrawColor(0.1, 0.1, 0.1) + DrawImage(nil, btn2X + 1, btnY + 1, btnW - 2, btnH - 2) + SetDrawColor(1, 1, 1) + DrawString(btn2X + btnW / 2, btnY + 1, "CENTER_X", 14, "VAR", "^7Copy+Use") + + -- Click detection + if inputEvents then + for id, event in ipairs(inputEvents) do + if event.type == "KeyUp" and event.key == "LEFTBUTTON" then + if b1Hover then + clickedCopySlot = slotName + inputEvents[id] = nil + elseif b2Hover then + clickedCopyUseSlot = slotName + inputEvents[id] = nil + end + end + end + end + end + -- Show diff indicator local isSame = pItem and cItem and pItem.name == cItem.name local diffLabel = "" @@ -1919,6 +2090,13 @@ function CompareTabClass:DrawItems(vp, compareEntry) end end + -- Process item copy button clicks + if clickedCopySlot then + self:CopyCompareItemToPrimary(clickedCopySlot, compareEntry, false) + elseif clickedCopyUseSlot then + self:CopyCompareItemToPrimary(clickedCopyUseSlot, compareEntry, true) + end + -- Draw item tooltip on hover (compact mode only, on top of everything) if hoverItem and hoverItemsTab then self.itemTooltip:Clear() From 5dc8c2297bac28adf8f255876bdc1505c4ad8912 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Thu, 19 Mar 2026 10:04:33 +0100 Subject: [PATCH 11/59] improve ui/ux of compared item eg fixing overlapping texts --- src/Classes/CompareTab.lua | 86 ++++++++++++++++++++++++-------------- 1 file changed, 54 insertions(+), 32 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index c95501c021..fc2b49d9b2 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -105,6 +105,9 @@ function CompareTabClass:InitControls() self.treeSearchNeedsSync = true end end) + self.controls["subTab" .. tabName].shown = function() + return #self.compareEntries > 0 + end self.controls["subTab" .. tabName].locked = function() return self.compareViewMode == mode end @@ -1182,7 +1185,7 @@ function CompareTabClass:Draw(viewPort, inputEvents) -- Position visible controls at absolute coords matching DrawConfig layout local col2AbsX = contentVP.x + 300 - local fixedHeaderHeight = 58 -- buttons + column headers + separator (not scrollable) + local fixedHeaderHeight = 66 -- buttons + column headers + separator (not scrollable) local scrollTopAbs = contentVP.y + fixedHeaderHeight -- top of scrollable area local startY = fixedHeaderHeight -- content starts after fixed header local currentY = startY @@ -1308,9 +1311,7 @@ function CompareTabClass:Draw(viewPort, inputEvents) DrawString(0, 40, "CENTER", 20, "VAR", "^7No comparison build loaded.") DrawString(0, 70, "CENTER", 16, "VAR", - "^7Click " .. colorCodes.POSITIVE .. "Import..." .. "^7 above to import a build to compare against,") - DrawString(0, 90, "CENTER", 16, "VAR", - "^7or use the " .. colorCodes.POSITIVE .. "Import/Export Build" .. "^7 tab with \"Import as comparison\" mode.") + "^7Click " .. colorCodes.POSITIVE .. "Import..." .. "^7 above to import a build to compare against.") SetViewport() return end @@ -1992,40 +1993,77 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) SetDrawColor(1, 1, 1) DrawString(10, drawY, "LEFT", 16, "VAR", "^7" .. slotName .. ":") + -- Diff indicator on slot label line + local isSame = pItem and cItem and pItem.name == cItem.name + local diffLabel = "" + if not pItem and not cItem then + diffLabel = "^8(both empty)" + elseif isSame then + diffLabel = colorCodes.POSITIVE .. "(match)" + elseif not pItem then + diffLabel = colorCodes.NEGATIVE .. "(missing)" + elseif not cItem then + diffLabel = colorCodes.TIP .. "(extra)" + else + diffLabel = colorCodes.WARNING .. "(different)" + end + DrawString(colWidth - 10, drawY, "RIGHT", 14, "VAR", diffLabel) + local pName = pItem and pItem.name or "(empty)" local cName = cItem and cItem.name or "(empty)" local pColor = getRarityColor(pItem) local cColor = getRarityColor(cItem) - drawY = drawY + 18 + -- Measure text widths for precise hover detection + local pTextW = pItem and DrawStringWidth(16, "VAR", pColor .. pName) or 0 + local cTextW = cItem and DrawStringWidth(16, "VAR", cColor .. cName) or 0 - -- Draw item names - DrawString(20, drawY, "LEFT", 16, "VAR", pColor .. pName) - DrawString(colWidth + 20, drawY, "LEFT", 16, "VAR", cColor .. cName) + drawY = drawY + 18 - -- Check hover on primary item (left column) - if pItem and cursorX >= 10 and cursorX < colWidth - and cursorY >= drawY and cursorY < drawY + 18 then + -- Check hover on primary item (left column, text bounds only) + local pHover = pItem and cursorX >= 18 and cursorX < 22 + pTextW + and cursorY >= drawY and cursorY < drawY + 18 + if pHover then hoverItem = pItem hoverX = 20 hoverY = drawY - hoverW = colWidth - 30 + hoverW = pTextW + 4 hoverH = 18 hoverItemsTab = self.primaryBuild.itemsTab end - -- Check hover on compare item (right column) - if cItem and cursorX >= colWidth and cursorX < vp.width - and cursorY >= drawY and cursorY < drawY + 18 then + -- Check hover on compare item (right column, text bounds only) + local cHover = cItem and cursorX >= colWidth + 18 and cursorX < colWidth + 22 + cTextW + and cursorY >= drawY and cursorY < drawY + 18 + if cHover then hoverItem = cItem hoverX = colWidth + 20 hoverY = drawY - hoverW = colWidth - 30 + hoverW = cTextW + 4 hoverH = 18 hoverItemsTab = compareEntry.itemsTab end + -- Draw hover border around text (matching ButtonControl style) + if pHover then + SetDrawColor(0.5, 0.5, 0.5) + DrawImage(nil, 18, drawY - 1, pTextW + 4, 20) + SetDrawColor(0, 0, 0) + DrawImage(nil, 19, drawY, pTextW + 2, 18) + end + if cHover then + SetDrawColor(0.5, 0.5, 0.5) + DrawImage(nil, colWidth + 18, drawY - 1, cTextW + 4, 20) + SetDrawColor(0, 0, 0) + DrawImage(nil, colWidth + 19, drawY, cTextW + 2, 18) + end + + -- Draw item names + SetDrawColor(1, 1, 1) + DrawString(20, drawY, "LEFT", 16, "VAR", pColor .. pName) + DrawString(colWidth + 20, drawY, "LEFT", 16, "VAR", cColor .. cName) + -- Copy buttons for compare item (compact mode) if cItem then local btnW = 60 @@ -2070,22 +2108,6 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) end end - -- Show diff indicator - local isSame = pItem and cItem and pItem.name == cItem.name - local diffLabel = "" - if not pItem and not cItem then - diffLabel = "^8(both empty)" - elseif isSame then - diffLabel = colorCodes.POSITIVE .. "(match)" - elseif not pItem then - diffLabel = colorCodes.NEGATIVE .. "(missing)" - elseif not cItem then - diffLabel = colorCodes.TIP .. "(extra)" - else - diffLabel = colorCodes.WARNING .. "(different)" - end - DrawString(colWidth - 10, drawY, "RIGHT", 14, "VAR", diffLabel) - drawY = drawY + 20 end end From 4f22e9996c2c8eacdecefce34a32115125890406 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Fri, 20 Mar 2026 09:30:39 +0100 Subject: [PATCH 12/59] split up draw into dedicated methods and add layout constants --- src/Classes/CompareEntry.lua | 217 ++++----- src/Classes/CompareTab.lua | 870 ++++++++++++++++++----------------- 2 files changed, 558 insertions(+), 529 deletions(-) diff --git a/src/Classes/CompareEntry.lua b/src/Classes/CompareEntry.lua index 135f869085..71d20de621 100644 --- a/src/Classes/CompareEntry.lua +++ b/src/Classes/CompareEntry.lua @@ -16,7 +16,7 @@ local CompareEntryClass = newClass("CompareEntry", "ControlHost", function(self, self.buildName = label or "Comparison Build" self.xmlText = xmlText - -- Default build properties (mirrors Build.lua:Init lines 72-82) + -- Default build properties self.viewMode = "TREE" self.characterLevel = m_min(m_max(main.defaultCharLevel or 1, 1), 100) self.targetVersion = liveTargetVersion @@ -54,7 +54,7 @@ local CompareEntryClass = newClass("CompareEntry", "ControlHost", function(self, end) function CompareEntryClass:LoadFromXML(xmlText) - -- Parse the XML (same pattern as Build.lua:LoadDB, line 1834) + -- Parse the XML local dbXML, errMsg = common.xml.ParseXML(xmlText) if errMsg then ConPrintf("CompareEntry: Error parsing XML: %s", errMsg) @@ -65,7 +65,7 @@ function CompareEntryClass:LoadFromXML(xmlText) return true end - -- Load Build section first (same pattern as Build.lua:LoadDB, line 1848) + -- Load Build section first for _, node in ipairs(dbXML[1]) do if type(node) == "table" and node.elem == "Build" then self:LoadBuildSection(node) @@ -96,7 +96,7 @@ function CompareEntryClass:LoadFromXML(xmlText) self.targetVersion = liveTargetVersion end - -- Create tabs (same pattern as Build.lua lines 579-590) + -- Create tabs -- PartyTab is replaced with a stub providing an empty enemyModList and actor -- (CalcPerform.lua:1088 accesses build.partyTab.actor for party member buffs) local partyActor = { Aura = {}, Curse = {}, Warcry = {}, Link = {}, modDB = new("ModDB"), output = {} } @@ -108,7 +108,7 @@ function CompareEntryClass:LoadFromXML(xmlText) self.skillsTab = new("SkillsTab", self) self.calcsTab = new("CalcsTab", self) - -- Set up savers table (same pattern as Build.lua lines 593-606) + -- Set up savers table self.savers = { ["Config"] = self.configTab, ["Tree"] = self.treeTab, @@ -129,7 +129,7 @@ function CompareEntryClass:LoadFromXML(xmlText) self.configTab.input[control] = self[control] end - -- Load XML sections into tabs (same pattern as Build.lua lines 620-647) + -- Load XML sections into tabs -- Defer passive trees until after items are loaded (jewel socket issue) local deferredPassiveTrees = {} for _, node in ipairs(self.xmlSectionList) do @@ -157,12 +157,12 @@ function CompareEntryClass:LoadFromXML(xmlText) end end - -- Build calculation output tables (same pattern as Build.lua lines 654-657) + -- Build calculation output tables self.calcsTab:BuildOutput() self.buildFlag = false end --- Load build section attributes (same pattern as Build.lua:Load, line 927) +-- Load build section attributes function CompareEntryClass:LoadBuildSection(xml) self.targetVersion = xml.attrib.targetVersion or legacyTargetVersion if xml.attrib.viewMode then @@ -243,7 +243,7 @@ function CompareEntryClass:SetMainSocketGroup(index) end function CompareEntryClass:RefreshSkillSelectControls(controls, mainGroup, suffix) - -- Populate skill select controls (adapted from Build.lua:RefreshSkillSelectControls, lines 1444-1542) + -- Populate skill select controls if not controls or not controls.mainSocketGroup then return end controls.mainSocketGroup.selIndex = mainGroup wipeTable(controls.mainSocketGroup.list) @@ -251,109 +251,120 @@ function CompareEntryClass:RefreshSkillSelectControls(controls, mainGroup, suffi controls.mainSocketGroup.list[i] = { val = i, label = socketGroup.displayLabel } end controls.mainSocketGroup:CheckDroppedWidth(true) - if #controls.mainSocketGroup.list == 0 then - controls.mainSocketGroup.list[1] = { val = 1, label = "" } + + -- Helper: hide all skill detail controls + local function hideAllSkillControls() controls.mainSkill.shown = false controls.mainSkillPart.shown = false controls.mainSkillMineCount.shown = false controls.mainSkillStageCount.shown = false controls.mainSkillMinion.shown = false controls.mainSkillMinionSkill.shown = false - else - local mainSocketGroup = self.skillsTab.socketGroupList[mainGroup] - if not mainSocketGroup then - mainSocketGroup = self.skillsTab.socketGroupList[1] - mainGroup = 1 + end + + if #controls.mainSocketGroup.list == 0 then + controls.mainSocketGroup.list[1] = { val = 1, label = "" } + hideAllSkillControls() + return + end + + local mainSocketGroup = self.skillsTab.socketGroupList[mainGroup] + if not mainSocketGroup then + mainSocketGroup = self.skillsTab.socketGroupList[1] + mainGroup = 1 + end + local displaySkillList = mainSocketGroup["displaySkillList"..suffix] + if not displaySkillList then + hideAllSkillControls() + return + end + + -- Populate main skill dropdown + local mainActiveSkill = mainSocketGroup["mainActiveSkill"..suffix] or 1 + wipeTable(controls.mainSkill.list) + for i, activeSkill in ipairs(displaySkillList) do + local explodeSource = activeSkill.activeEffect.srcInstance.explodeSource + local explodeSourceName = explodeSource and (explodeSource.name or explodeSource.dn) + local colourCoded = explodeSourceName and ("From "..colorCodes[explodeSource.rarity or "NORMAL"]..explodeSourceName) + t_insert(controls.mainSkill.list, { val = i, label = colourCoded or activeSkill.activeEffect.grantedEffect.name }) + end + controls.mainSkill.enabled = #displaySkillList > 1 + controls.mainSkill.selIndex = mainActiveSkill + controls.mainSkill.shown = true + hideAllSkillControls() + controls.mainSkill.shown = true -- restore after hideAll + + local activeSkill = displaySkillList[mainActiveSkill] or displaySkillList[1] + if not activeSkill then return end + local activeEffect = activeSkill.activeEffect + if not activeEffect then return end + + -- Skill parts + if activeEffect.grantedEffect.parts and #activeEffect.grantedEffect.parts > 1 then + controls.mainSkillPart.shown = true + wipeTable(controls.mainSkillPart.list) + for i, part in ipairs(activeEffect.grantedEffect.parts) do + t_insert(controls.mainSkillPart.list, { val = i, label = part.name }) + end + controls.mainSkillPart.selIndex = activeEffect.srcInstance["skillPart"..suffix] or 1 + local selectedPart = activeEffect.grantedEffect.parts[controls.mainSkillPart.selIndex] + if selectedPart and selectedPart.stages then + controls.mainSkillStageCount.shown = true + controls.mainSkillStageCount.buf = tostring(activeEffect.srcInstance["skillStageCount"..suffix] or selectedPart.stagesMin or 1) end - local displaySkillList = mainSocketGroup["displaySkillList"..suffix] - if not displaySkillList then - controls.mainSkill.shown = false - controls.mainSkillPart.shown = false - controls.mainSkillMineCount.shown = false - controls.mainSkillStageCount.shown = false - controls.mainSkillMinion.shown = false - controls.mainSkillMinionSkill.shown = false - return + end + + -- Mine count + if activeSkill.skillFlags and activeSkill.skillFlags.mine then + controls.mainSkillMineCount.shown = true + controls.mainSkillMineCount.buf = tostring(activeEffect.srcInstance["skillMineCount"..suffix] or "") + end + + -- Stage count (for multi-stage skills without parts) + if activeSkill.skillFlags and activeSkill.skillFlags.multiStage and not (activeEffect.grantedEffect.parts and #activeEffect.grantedEffect.parts > 1) then + controls.mainSkillStageCount.shown = true + controls.mainSkillStageCount.buf = tostring(activeEffect.srcInstance["skillStageCount"..suffix] or activeSkill.skillData.stagesMin or 1) + end + + -- Minion controls + if activeSkill.skillFlags and not activeSkill.skillFlags.disable and (activeEffect.grantedEffect.minionList or (activeSkill.minionList and activeSkill.minionList[1])) then + self:RefreshMinionControls(controls, activeSkill, activeEffect, suffix) + end +end + +function CompareEntryClass:RefreshMinionControls(controls, activeSkill, activeEffect, suffix) + wipeTable(controls.mainSkillMinion.list) + if activeEffect.grantedEffect.minionHasItemSet then + for _, itemSetId in ipairs(self.itemsTab.itemSetOrderList) do + local itemSet = self.itemsTab.itemSets[itemSetId] + t_insert(controls.mainSkillMinion.list, { + label = itemSet.title or "Default Item Set", + itemSetId = itemSetId, + }) end - local mainActiveSkill = mainSocketGroup["mainActiveSkill"..suffix] or 1 - wipeTable(controls.mainSkill.list) - for i, activeSkill in ipairs(displaySkillList) do - local explodeSource = activeSkill.activeEffect.srcInstance.explodeSource - local explodeSourceName = explodeSource and (explodeSource.name or explodeSource.dn) - local colourCoded = explodeSourceName and ("From "..colorCodes[explodeSource.rarity or "NORMAL"]..explodeSourceName) - t_insert(controls.mainSkill.list, { val = i, label = colourCoded or activeSkill.activeEffect.grantedEffect.name }) + controls.mainSkillMinion:SelByValue(activeEffect.srcInstance["skillMinionItemSet"..suffix] or 1, "itemSetId") + else + for _, minionId in ipairs(activeSkill.minionList) do + t_insert(controls.mainSkillMinion.list, { + label = self.data.minions[minionId] and self.data.minions[minionId].name or minionId, + minionId = minionId, + }) end - controls.mainSkill.enabled = #displaySkillList > 1 - controls.mainSkill.selIndex = mainActiveSkill - controls.mainSkill.shown = true - controls.mainSkillPart.shown = false - controls.mainSkillMineCount.shown = false - controls.mainSkillStageCount.shown = false - controls.mainSkillMinion.shown = false - controls.mainSkillMinionSkill.shown = false - if displaySkillList[1] then - local activeSkill = displaySkillList[mainActiveSkill] - if not activeSkill then - activeSkill = displaySkillList[1] - end - local activeEffect = activeSkill.activeEffect - if activeEffect then - if activeEffect.grantedEffect.parts and #activeEffect.grantedEffect.parts > 1 then - controls.mainSkillPart.shown = true - wipeTable(controls.mainSkillPart.list) - for i, part in ipairs(activeEffect.grantedEffect.parts) do - t_insert(controls.mainSkillPart.list, { val = i, label = part.name }) - end - controls.mainSkillPart.selIndex = activeEffect.srcInstance["skillPart"..suffix] or 1 - if activeEffect.grantedEffect.parts[controls.mainSkillPart.selIndex] and activeEffect.grantedEffect.parts[controls.mainSkillPart.selIndex].stages then - controls.mainSkillStageCount.shown = true - controls.mainSkillStageCount.buf = tostring(activeEffect.srcInstance["skillStageCount"..suffix] or activeEffect.grantedEffect.parts[controls.mainSkillPart.selIndex].stagesMin or 1) - end - end - if activeSkill.skillFlags and activeSkill.skillFlags.mine then - controls.mainSkillMineCount.shown = true - controls.mainSkillMineCount.buf = tostring(activeEffect.srcInstance["skillMineCount"..suffix] or "") - end - if activeSkill.skillFlags and activeSkill.skillFlags.multiStage and not (activeEffect.grantedEffect.parts and #activeEffect.grantedEffect.parts > 1) then - controls.mainSkillStageCount.shown = true - controls.mainSkillStageCount.buf = tostring(activeEffect.srcInstance["skillStageCount"..suffix] or activeSkill.skillData.stagesMin or 1) - end - if activeSkill.skillFlags and not activeSkill.skillFlags.disable and (activeEffect.grantedEffect.minionList or (activeSkill.minionList and activeSkill.minionList[1])) then - wipeTable(controls.mainSkillMinion.list) - if activeEffect.grantedEffect.minionHasItemSet then - for _, itemSetId in ipairs(self.itemsTab.itemSetOrderList) do - local itemSet = self.itemsTab.itemSets[itemSetId] - t_insert(controls.mainSkillMinion.list, { - label = itemSet.title or "Default Item Set", - itemSetId = itemSetId, - }) - end - controls.mainSkillMinion:SelByValue(activeEffect.srcInstance["skillMinionItemSet"..suffix] or 1, "itemSetId") - else - for _, minionId in ipairs(activeSkill.minionList) do - t_insert(controls.mainSkillMinion.list, { - label = self.data.minions[minionId] and self.data.minions[minionId].name or minionId, - minionId = minionId, - }) - end - controls.mainSkillMinion:SelByValue(activeEffect.srcInstance["skillMinion"..suffix] or (controls.mainSkillMinion.list[1] and controls.mainSkillMinion.list[1].minionId), "minionId") - end - controls.mainSkillMinion.enabled = #controls.mainSkillMinion.list > 1 - controls.mainSkillMinion.shown = true - wipeTable(controls.mainSkillMinionSkill.list) - if activeSkill.minion then - for _, minionSkill in ipairs(activeSkill.minion.activeSkillList) do - t_insert(controls.mainSkillMinionSkill.list, minionSkill.activeEffect.grantedEffect.name) - end - controls.mainSkillMinionSkill.selIndex = activeEffect.srcInstance["skillMinionSkill"..suffix] or 1 - controls.mainSkillMinionSkill.shown = true - controls.mainSkillMinionSkill.enabled = #controls.mainSkillMinionSkill.list > 1 - else - t_insert(controls.mainSkillMinion.list, "") - end - end - end + controls.mainSkillMinion:SelByValue(activeEffect.srcInstance["skillMinion"..suffix] or (controls.mainSkillMinion.list[1] and controls.mainSkillMinion.list[1].minionId), "minionId") + end + controls.mainSkillMinion.enabled = #controls.mainSkillMinion.list > 1 + controls.mainSkillMinion.shown = true + + wipeTable(controls.mainSkillMinionSkill.list) + if activeSkill.minion then + for _, minionSkill in ipairs(activeSkill.minion.activeSkillList) do + t_insert(controls.mainSkillMinionSkill.list, minionSkill.activeEffect.grantedEffect.name) end + controls.mainSkillMinionSkill.selIndex = activeEffect.srcInstance["skillMinionSkill"..suffix] or 1 + controls.mainSkillMinionSkill.shown = true + controls.mainSkillMinionSkill.enabled = #controls.mainSkillMinionSkill.list > 1 + else + t_insert(controls.mainSkillMinion.list, "") end end @@ -386,7 +397,7 @@ function CompareEntryClass:AddStatComparesToTooltip(tooltip, baseOutput, compare return count end --- Stat comparison (mirrors Build.lua:CompareStatList, line 1733) +-- Stat comparison function CompareEntryClass:CompareStatList(tooltip, statList, actor, baseOutput, compareOutput, header, nodeCount) local s_format = string.format local count = 0 diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index fc2b49d9b2..8368d66e98 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -10,7 +10,44 @@ local m_max = math.max local m_floor = math.floor local s_format = string.format --- Flag matching for stat filtering (same logic as Build.lua lines 33-57) +-- Layout constants (shared across Draw, DrawConfig, DrawItems, DrawCalcs, etc.) +local LAYOUT = { + -- Main tab control bar + controlBarHeight = 96, + + -- Tree view header/footer + treeHeaderHeight = 58, + treeFooterHeight = 30, + treeOverlayCheckX = 155, + + -- Summary view columns + summaryCol1 = 10, + summaryCol2 = 300, + summaryCol3 = 450, + summaryCol4 = 600, + + -- Items view + itemsCheckboxOffset = 36, + itemsCopyBtnW = 60, + itemsCopyBtnH = 18, + + -- Calcs view + calcsMaxCardWidth = 400, + calcsLabelWidth = 132, + calcsSepW = 2, + calcsHeaderBarHeight = 24, + + -- Config view (shared between Draw() layout and DrawConfig()) + configRowHeight = 22, + configSectionHeaderHeight = 24, + configColumnHeaderHeight = 20, + configFixedHeaderHeight = 66, + configCol1 = 10, + configCol2 = 300, + configCol3 = 500, +} + +-- Flag matching for stat filtering local function matchFlags(reqFlags, notFlags, flags) if type(reqFlags) == "string" then reqFlags = { reqFlags } @@ -77,6 +114,10 @@ local CompareTabClass = newClass("CompareTab", "ControlHost", "Control", functio self.configToggle = false -- show all / hide ineligible toggle self.configDisplayList = {} -- computed display order (headers + rows) + -- Pre-load static module data + self.configOptions = LoadModule("Modules/ConfigOptions") + self.calcSections = LoadModule("Modules/CalcSections") + -- Controls for the comparison screen self:InitControls() end) @@ -639,7 +680,7 @@ function CompareTabClass:RebuildConfigControls(compareEntry) if not compareEntry then return end - local configOptions = LoadModule("Modules/ConfigOptions") + local configOptions = self.configOptions local pInput = self.primaryBuild.configTab.input or {} local primaryBuild = self.primaryBuild @@ -934,8 +975,6 @@ end -- DRAW - Main render method -- ============================================================ function CompareTabClass:Draw(viewPort, inputEvents) - local controlBarHeight = 96 - -- Position top-bar controls self.controls.subTabAnchor.x = viewPort.x + 4 self.controls.subTabAnchor.y = viewPort.y + 74 @@ -946,9 +985,9 @@ function CompareTabClass:Draw(viewPort, inputEvents) local contentVP = { x = viewPort.x, - y = viewPort.y + controlBarHeight, + y = viewPort.y + LAYOUT.controlBarHeight, width = viewPort.width, - height = viewPort.height - controlBarHeight, + height = viewPort.height - LAYOUT.controlBarHeight, } -- Get active comparison early (needed for footer positioning before ProcessControlsInput) @@ -959,316 +998,14 @@ function CompareTabClass:Draw(viewPort, inputEvents) compareEntry:Rebuild() end - -- Pre-draw tree footer backgrounds and position footer controls + -- Layout: position controls and draw backgrounds for current view mode -- (must happen before ProcessControlsInput so controls render on top of backgrounds) - self.treeLayout = nil - if self.compareViewMode == "TREE" and compareEntry then - local headerHeight = 58 -- spec/version selectors + overlay checkbox + separator + padding - local footerHeight = 30 -- search field(s) - local footerY = contentVP.y + contentVP.height - footerHeight - - if self.treeOverlayMode then - -- ========== OVERLAY MODE LAYOUT ========== - local specWidth = m_min(m_floor(contentVP.width * 0.25), 200) - - self.treeLayout = { - overlay = true, - headerHeight = headerHeight, - footerHeight = footerHeight, - footerY = footerY, - } - - -- Header background + separator - SetDrawColor(0.05, 0.05, 0.05) - DrawImage(nil, contentVP.x, contentVP.y, contentVP.width, headerHeight) - SetDrawColor(0.85, 0.85, 0.85) - DrawImage(nil, contentVP.x, contentVP.y + headerHeight - 2, contentVP.width, 2) - - -- Footer background - SetDrawColor(0.05, 0.05, 0.05) - DrawImage(nil, contentVP.x, footerY, contentVP.width, footerHeight) - SetDrawColor(0.85, 0.85, 0.85) - DrawImage(nil, contentVP.x, footerY, contentVP.width, 2) - - -- Position spec/version in header row 1 - self.controls.leftSpecSelect.x = contentVP.x + 4 - self.controls.leftSpecSelect.y = contentVP.y + 8 - self.controls.leftSpecSelect.width = specWidth - - local rightSpecX = contentVP.x + m_floor(contentVP.width / 2) + 4 - self.controls.rightSpecSelect.x = rightSpecX - self.controls.rightSpecSelect.y = contentVP.y + 8 - self.controls.rightSpecSelect.width = specWidth - - -- Overlay checkbox in header row 2 (label draws LEFT of checkbox, needs ~140px clearance) - self.controls.treeOverlayCheck.x = contentVP.x + 155 - self.controls.treeOverlayCheck.y = contentVP.y + 34 - - -- Overlay search in footer (full width) - self.controls.overlayTreeSearch.x = contentVP.x + 4 - self.controls.overlayTreeSearch.y = footerY + 4 - self.controls.overlayTreeSearch.width = contentVP.width - 8 - else - -- ========== SIDE-BY-SIDE MODE LAYOUT ========== - local halfWidth = m_floor(contentVP.width / 2) - 2 - local rightAbsX = contentVP.x + halfWidth + 4 - local specWidth = m_min(m_floor(halfWidth * 0.55), 200) - - self.treeLayout = { - overlay = false, - halfWidth = halfWidth, - headerHeight = headerHeight, - footerHeight = footerHeight, - footerY = footerY, - rightAbsX = rightAbsX, - } - - -- Header background + separator - SetDrawColor(0.05, 0.05, 0.05) - DrawImage(nil, contentVP.x, contentVP.y, contentVP.width, headerHeight) - SetDrawColor(0.85, 0.85, 0.85) - DrawImage(nil, contentVP.x, contentVP.y + headerHeight - 2, contentVP.width, 2) - - -- Footer backgrounds (two halves) - SetDrawColor(0.05, 0.05, 0.05) - DrawImage(nil, contentVP.x, footerY, halfWidth, footerHeight) - DrawImage(nil, rightAbsX, footerY, halfWidth, footerHeight) - SetDrawColor(0.85, 0.85, 0.85) - DrawImage(nil, contentVP.x, footerY, halfWidth, 2) - DrawImage(nil, rightAbsX, footerY, halfWidth, 2) - - -- Position spec/version in header row 1 - self.controls.leftSpecSelect.x = contentVP.x + 4 - self.controls.leftSpecSelect.y = contentVP.y + 8 - self.controls.leftSpecSelect.width = specWidth - - self.controls.rightSpecSelect.x = contentVP.x + m_floor(contentVP.width / 2) + 4 - self.controls.rightSpecSelect.y = contentVP.y + 8 - self.controls.rightSpecSelect.width = specWidth - - -- Overlay checkbox in header row 2 (label draws LEFT of checkbox, needs ~140px clearance) - self.controls.treeOverlayCheck.x = contentVP.x + 155 - self.controls.treeOverlayCheck.y = contentVP.y + 34 - - -- Position footer search fields - self.controls.leftFooterAnchor.x = contentVP.x + 4 - self.controls.leftFooterAnchor.y = footerY + 4 - self.controls.leftTreeSearch.width = halfWidth - 8 - - self.controls.rightFooterAnchor.x = rightAbsX + 4 - self.controls.rightFooterAnchor.y = footerY + 4 - self.controls.rightTreeSearch.width = halfWidth - 8 - end - - -- (Common) Update spec dropdown lists - if self.primaryBuild.treeTab then - self.controls.leftSpecSelect.list = self.primaryBuild.treeTab:GetSpecList() - self.controls.leftSpecSelect.selIndex = self.primaryBuild.treeTab.activeSpec - end - if compareEntry.treeTab then - self.controls.rightSpecSelect.list = compareEntry.treeTab:GetSpecList() - self.controls.rightSpecSelect.selIndex = compareEntry.treeTab.activeSpec - end - - -- (Common) Update version dropdown selection to match current spec - if self.primaryBuild.spec then - for i, ver in ipairs(self.treeVersionDropdownList) do - if ver.value == self.primaryBuild.spec.treeVersion then - self.controls.leftVersionSelect.selIndex = i - break - end - end - end - if compareEntry.spec then - for i, ver in ipairs(self.treeVersionDropdownList) do - if ver.value == compareEntry.spec.treeVersion then - self.controls.rightVersionSelect.selIndex = i - break - end - end - end - - -- (Common) Sync search fields when entering tree mode or changing compare entry - if self.treeSearchNeedsSync then - self.treeSearchNeedsSync = false - if self.primaryBuild.treeTab and self.primaryBuild.treeTab.viewer then - self.controls.leftTreeSearch:SetText(self.primaryBuild.treeTab.viewer.searchStr or "") - self.controls.overlayTreeSearch:SetText(self.primaryBuild.treeTab.viewer.searchStr or "") - end - if compareEntry.treeTab and compareEntry.treeTab.viewer then - self.controls.rightTreeSearch:SetText(compareEntry.treeTab.viewer.searchStr or "") - end - end - end - - -- Position config controls when in CONFIG view - if self.compareViewMode == "CONFIG" and compareEntry then - -- Rebuild controls if compare entry changed or config was modified - if self.configCompareId ~= self.activeCompareIndex or self.configNeedsRebuild then - self:RebuildConfigControls(compareEntry) - self.configCompareId = self.activeCompareIndex - self.configNeedsRebuild = false - end - - -- Sync control values with current primary input (in case changed from normal Config tab) - local pInput = self.primaryBuild.configTab.input or {} - for var, ctrlInfo in pairs(self.configControls) do - local ctrl = ctrlInfo.control - local varData = ctrlInfo.varData - local pVal = pInput[var] - if varData.type == "check" then - ctrl.state = pVal or false - elseif varData.type == "count" or varData.type == "integer" - or varData.type == "countAllowZero" or varData.type == "float" then - ctrl:SetText(tostring(pVal or "")) - elseif varData.type == "list" then - ctrl:SelByValue(pVal or (varData.list[1] and varData.list[1].val), "val") - end - end - - -- Position buttons at top of config view (above column headers) - self.controls.copyConfigBtn.x = contentVP.x + 10 - self.controls.copyConfigBtn.y = contentVP.y + 8 - self.controls.configToggleBtn.x = contentVP.x + 260 - self.controls.configToggleBtn.y = contentVP.y + 8 - - -- Build display list: Differences section first, then All Configurations - local cInput = compareEntry.configTab.input or {} - local displayList = {} - local rowHeight = 22 - local sectionHeaderHeight = 24 - - -- Collect differences - local diffs = {} - for _, ctrlInfo in ipairs(self.configControlList) do - local pVal = pInput[ctrlInfo.varData.var] - local cVal = cInput[ctrlInfo.varData.var] - if tostring(pVal or "") ~= tostring(cVal or "") then - t_insert(diffs, ctrlInfo) - end - end - - -- Differences section - if #diffs > 0 then - t_insert(displayList, { type = "header", text = "Differences (" .. #diffs .. ")" }) - for _, ctrlInfo in ipairs(diffs) do - t_insert(displayList, { type = "row", ctrlInfo = ctrlInfo }) - end - end - - -- Collect eligible non-diff options for "All Configurations" section - local configs = {} - for _, ctrlInfo in ipairs(self.configControlList) do - local pVal = pInput[ctrlInfo.varData.var] - local cVal = cInput[ctrlInfo.varData.var] - -- Only include non-diff options - if tostring(pVal or "") == tostring(cVal or "") then - if ctrlInfo.alwaysShow or (self.configToggle and ctrlInfo.showWithToggle) then - t_insert(configs, ctrlInfo) - end - end - end - - if #configs > 0 then - t_insert(displayList, { type = "header", text = "All Configurations" }) - for _, ctrlInfo in ipairs(configs) do - t_insert(displayList, { type = "row", ctrlInfo = ctrlInfo }) - end - end - - self.configDisplayList = displayList - - -- First, hide ALL config controls (will selectively show visible ones) - for _, ctrlInfo in ipairs(self.configControlList) do - ctrlInfo.control.shown = function() return false end - end - - -- Position visible controls at absolute coords matching DrawConfig layout - local col2AbsX = contentVP.x + 300 - local fixedHeaderHeight = 66 -- buttons + column headers + separator (not scrollable) - local scrollTopAbs = contentVP.y + fixedHeaderHeight -- top of scrollable area - local startY = fixedHeaderHeight -- content starts after fixed header - local currentY = startY - for _, item in ipairs(displayList) do - if item.type == "header" then - currentY = currentY + sectionHeaderHeight - elseif item.type == "row" then - local absY = contentVP.y + currentY - self.scrollY - item.ctrlInfo.control.x = col2AbsX - item.ctrlInfo.control.y = absY - local cy = currentY -- capture for closure - item.ctrlInfo.control.shown = function() - local ay = contentVP.y + cy - self.scrollY - return ay >= scrollTopAbs - 20 and ay < contentVP.y + contentVP.height - and self.compareViewMode == "CONFIG" and self:GetActiveCompare() ~= nil - end - currentY = currentY + rowHeight - end - end - end - - -- Update comparison build set selectors + self:LayoutTreeView(contentVP, compareEntry) + self:LayoutConfigView(contentVP, compareEntry) if compareEntry then - -- Tree spec list (reuse GetSpecList from TreeTab) - if compareEntry.treeTab then - self.controls.compareSpecSelect.list = compareEntry.treeTab:GetSpecList() - self.controls.compareSpecSelect.selIndex = compareEntry.treeTab.activeSpec - end - -- Skill set list (pattern from SkillsTab:Draw lines 527-535) - if compareEntry.skillsTab then - local skillList = {} - for index, skillSetId in ipairs(compareEntry.skillsTab.skillSetOrderList) do - local skillSet = compareEntry.skillsTab.skillSets[skillSetId] - t_insert(skillList, skillSet.title or "Default") - if skillSetId == compareEntry.skillsTab.activeSkillSetId then - self.controls.compareSkillSetSelect.selIndex = index - end - end - self.controls.compareSkillSetSelect:SetList(skillList) - end - -- Item set list (pattern from ItemsTab:Draw lines 1293-1301) - if compareEntry.itemsTab then - local itemList = {} - for index, itemSetId in ipairs(compareEntry.itemsTab.itemSetOrderList) do - local itemSet = compareEntry.itemsTab.itemSets[itemSetId] - t_insert(itemList, itemSet.title or "Default") - if itemSetId == compareEntry.itemsTab.activeItemSetId then - self.controls.compareItemSetSelect.selIndex = index - end - end - self.controls.compareItemSetSelect:SetList(itemList) - end - - -- Refresh comparison build skill selector controls - local cmpControls = { - mainSocketGroup = self.controls.cmpSocketGroup, - mainSkill = self.controls.cmpMainSkill, - mainSkillPart = self.controls.cmpSkillPart, - mainSkillStageCount = self.controls.cmpStageCount, - mainSkillMineCount = self.controls.cmpMineCount, - mainSkillMinion = self.controls.cmpMinion, - mainSkillMinionLibrary = { shown = false }, - mainSkillMinionSkill = self.controls.cmpMinionSkill, - } - compareEntry:RefreshSkillSelectControls(cmpControls, compareEntry.mainSocketGroup, "") - end - - -- Handle scroll events for scrollable views - local cursorX, cursorY = GetCursorPos() - local mouseInContent = cursorX >= contentVP.x and cursorX < contentVP.x + contentVP.width - and cursorY >= contentVP.y and cursorY < contentVP.y + contentVP.height - - for id, event in ipairs(inputEvents) do - if event.type == "KeyDown" and mouseInContent then - if event.key == "WHEELUP" and self.compareViewMode ~= "TREE" then - self.scrollY = m_max(self.scrollY - 40, 0) - inputEvents[id] = nil - elseif event.key == "WHEELDOWN" and self.compareViewMode ~= "TREE" then - self.scrollY = self.scrollY + 40 - inputEvents[id] = nil - end - end + self:UpdateSetSelectors(compareEntry) end + self:HandleScrollInput(contentVP, inputEvents) -- Process input events for our controls (including footer controls) self:ProcessControlsInput(inputEvents, viewPort) @@ -1337,6 +1074,327 @@ function CompareTabClass:Draw(viewPort, inputEvents) end end +-- ============================================================ +-- DRAW HELPERS +-- ============================================================ + +-- Pre-draw tree header/footer backgrounds and position tree controls. +-- Must run before ProcessControlsInput so controls render on top of backgrounds. +function CompareTabClass:LayoutTreeView(contentVP, compareEntry) + self.treeLayout = nil + if self.compareViewMode ~= "TREE" or not compareEntry then return end + + local headerHeight = LAYOUT.treeHeaderHeight + local footerHeight = LAYOUT.treeFooterHeight + local footerY = contentVP.y + contentVP.height - footerHeight + + if self.treeOverlayMode then + -- ========== OVERLAY MODE LAYOUT ========== + local specWidth = m_min(m_floor(contentVP.width * 0.25), 200) + + self.treeLayout = { + overlay = true, + headerHeight = headerHeight, + footerHeight = footerHeight, + footerY = footerY, + } + + -- Header background + separator + SetDrawColor(0.05, 0.05, 0.05) + DrawImage(nil, contentVP.x, contentVP.y, contentVP.width, headerHeight) + SetDrawColor(0.85, 0.85, 0.85) + DrawImage(nil, contentVP.x, contentVP.y + headerHeight - 2, contentVP.width, 2) + + -- Footer background + SetDrawColor(0.05, 0.05, 0.05) + DrawImage(nil, contentVP.x, footerY, contentVP.width, footerHeight) + SetDrawColor(0.85, 0.85, 0.85) + DrawImage(nil, contentVP.x, footerY, contentVP.width, 2) + + -- Position spec/version in header row 1 + self.controls.leftSpecSelect.x = contentVP.x + 4 + self.controls.leftSpecSelect.y = contentVP.y + 8 + self.controls.leftSpecSelect.width = specWidth + + local rightSpecX = contentVP.x + m_floor(contentVP.width / 2) + 4 + self.controls.rightSpecSelect.x = rightSpecX + self.controls.rightSpecSelect.y = contentVP.y + 8 + self.controls.rightSpecSelect.width = specWidth + + -- Overlay checkbox in header row 2 + self.controls.treeOverlayCheck.x = contentVP.x + LAYOUT.treeOverlayCheckX + self.controls.treeOverlayCheck.y = contentVP.y + 34 + + -- Overlay search in footer (full width) + self.controls.overlayTreeSearch.x = contentVP.x + 4 + self.controls.overlayTreeSearch.y = footerY + 4 + self.controls.overlayTreeSearch.width = contentVP.width - 8 + else + -- ========== SIDE-BY-SIDE MODE LAYOUT ========== + local halfWidth = m_floor(contentVP.width / 2) - 2 + local rightAbsX = contentVP.x + halfWidth + 4 + local specWidth = m_min(m_floor(halfWidth * 0.55), 200) + + self.treeLayout = { + overlay = false, + halfWidth = halfWidth, + headerHeight = headerHeight, + footerHeight = footerHeight, + footerY = footerY, + rightAbsX = rightAbsX, + } + + -- Header background + separator + SetDrawColor(0.05, 0.05, 0.05) + DrawImage(nil, contentVP.x, contentVP.y, contentVP.width, headerHeight) + SetDrawColor(0.85, 0.85, 0.85) + DrawImage(nil, contentVP.x, contentVP.y + headerHeight - 2, contentVP.width, 2) + + -- Footer backgrounds (two halves) + SetDrawColor(0.05, 0.05, 0.05) + DrawImage(nil, contentVP.x, footerY, halfWidth, footerHeight) + DrawImage(nil, rightAbsX, footerY, halfWidth, footerHeight) + SetDrawColor(0.85, 0.85, 0.85) + DrawImage(nil, contentVP.x, footerY, halfWidth, 2) + DrawImage(nil, rightAbsX, footerY, halfWidth, 2) + + -- Position spec/version in header row 1 + self.controls.leftSpecSelect.x = contentVP.x + 4 + self.controls.leftSpecSelect.y = contentVP.y + 8 + self.controls.leftSpecSelect.width = specWidth + + self.controls.rightSpecSelect.x = contentVP.x + m_floor(contentVP.width / 2) + 4 + self.controls.rightSpecSelect.y = contentVP.y + 8 + self.controls.rightSpecSelect.width = specWidth + + -- Overlay checkbox in header row 2 + self.controls.treeOverlayCheck.x = contentVP.x + LAYOUT.treeOverlayCheckX + self.controls.treeOverlayCheck.y = contentVP.y + 34 + + -- Position footer search fields + self.controls.leftFooterAnchor.x = contentVP.x + 4 + self.controls.leftFooterAnchor.y = footerY + 4 + self.controls.leftTreeSearch.width = halfWidth - 8 + + self.controls.rightFooterAnchor.x = rightAbsX + 4 + self.controls.rightFooterAnchor.y = footerY + 4 + self.controls.rightTreeSearch.width = halfWidth - 8 + end + + -- (Common) Update spec dropdown lists + if self.primaryBuild.treeTab then + self.controls.leftSpecSelect.list = self.primaryBuild.treeTab:GetSpecList() + self.controls.leftSpecSelect.selIndex = self.primaryBuild.treeTab.activeSpec + end + if compareEntry.treeTab then + self.controls.rightSpecSelect.list = compareEntry.treeTab:GetSpecList() + self.controls.rightSpecSelect.selIndex = compareEntry.treeTab.activeSpec + end + + -- (Common) Update version dropdown selection to match current spec + if self.primaryBuild.spec then + for i, ver in ipairs(self.treeVersionDropdownList) do + if ver.value == self.primaryBuild.spec.treeVersion then + self.controls.leftVersionSelect.selIndex = i + break + end + end + end + if compareEntry.spec then + for i, ver in ipairs(self.treeVersionDropdownList) do + if ver.value == compareEntry.spec.treeVersion then + self.controls.rightVersionSelect.selIndex = i + break + end + end + end + + -- (Common) Sync search fields when entering tree mode or changing compare entry + if self.treeSearchNeedsSync then + self.treeSearchNeedsSync = false + if self.primaryBuild.treeTab and self.primaryBuild.treeTab.viewer then + self.controls.leftTreeSearch:SetText(self.primaryBuild.treeTab.viewer.searchStr or "") + self.controls.overlayTreeSearch:SetText(self.primaryBuild.treeTab.viewer.searchStr or "") + end + if compareEntry.treeTab and compareEntry.treeTab.viewer then + self.controls.rightTreeSearch:SetText(compareEntry.treeTab.viewer.searchStr or "") + end + end +end + +-- Position config controls and build display list when in CONFIG view. +function CompareTabClass:LayoutConfigView(contentVP, compareEntry) + if self.compareViewMode ~= "CONFIG" or not compareEntry then return end + + -- Rebuild controls if compare entry changed or config was modified + if self.configCompareId ~= self.activeCompareIndex or self.configNeedsRebuild then + self:RebuildConfigControls(compareEntry) + self.configCompareId = self.activeCompareIndex + self.configNeedsRebuild = false + end + + -- Sync control values with current primary input (in case changed from normal Config tab) + local pInput = self.primaryBuild.configTab.input or {} + for var, ctrlInfo in pairs(self.configControls) do + local ctrl = ctrlInfo.control + local varData = ctrlInfo.varData + local pVal = pInput[var] + if varData.type == "check" then + ctrl.state = pVal or false + elseif varData.type == "count" or varData.type == "integer" + or varData.type == "countAllowZero" or varData.type == "float" then + ctrl:SetText(tostring(pVal or "")) + elseif varData.type == "list" then + ctrl:SelByValue(pVal or (varData.list[1] and varData.list[1].val), "val") + end + end + + -- Position buttons at top of config view (above column headers) + self.controls.copyConfigBtn.x = contentVP.x + 10 + self.controls.copyConfigBtn.y = contentVP.y + 8 + self.controls.configToggleBtn.x = contentVP.x + 260 + self.controls.configToggleBtn.y = contentVP.y + 8 + + -- Build display list: Differences section first, then All Configurations + local cInput = compareEntry.configTab.input or {} + local displayList = {} + local rowHeight = LAYOUT.configRowHeight + local sectionHeaderHeight = LAYOUT.configSectionHeaderHeight + + -- Collect differences + local diffs = {} + for _, ctrlInfo in ipairs(self.configControlList) do + local pVal = pInput[ctrlInfo.varData.var] + local cVal = cInput[ctrlInfo.varData.var] + if tostring(pVal or "") ~= tostring(cVal or "") then + t_insert(diffs, ctrlInfo) + end + end + + -- Differences section + if #diffs > 0 then + t_insert(displayList, { type = "header", text = "Differences (" .. #diffs .. ")" }) + for _, ctrlInfo in ipairs(diffs) do + t_insert(displayList, { type = "row", ctrlInfo = ctrlInfo }) + end + end + + -- Collect eligible non-diff options for "All Configurations" section + local configs = {} + for _, ctrlInfo in ipairs(self.configControlList) do + local pVal = pInput[ctrlInfo.varData.var] + local cVal = cInput[ctrlInfo.varData.var] + -- Only include non-diff options + if tostring(pVal or "") == tostring(cVal or "") then + if ctrlInfo.alwaysShow or (self.configToggle and ctrlInfo.showWithToggle) then + t_insert(configs, ctrlInfo) + end + end + end + + if #configs > 0 then + t_insert(displayList, { type = "header", text = "All Configurations" }) + for _, ctrlInfo in ipairs(configs) do + t_insert(displayList, { type = "row", ctrlInfo = ctrlInfo }) + end + end + + self.configDisplayList = displayList + + -- First, hide ALL config controls (will selectively show visible ones) + for _, ctrlInfo in ipairs(self.configControlList) do + ctrlInfo.control.shown = function() return false end + end + + -- Position visible controls at absolute coords matching DrawConfig layout + local col2AbsX = contentVP.x + LAYOUT.configCol2 + local fixedHeaderHeight = LAYOUT.configFixedHeaderHeight + local scrollTopAbs = contentVP.y + fixedHeaderHeight -- top of scrollable area + local startY = fixedHeaderHeight -- content starts after fixed header + local currentY = startY + for _, item in ipairs(displayList) do + if item.type == "header" then + currentY = currentY + sectionHeaderHeight + elseif item.type == "row" then + local absY = contentVP.y + currentY - self.scrollY + item.ctrlInfo.control.x = col2AbsX + item.ctrlInfo.control.y = absY + local cy = currentY -- capture for closure + item.ctrlInfo.control.shown = function() + local ay = contentVP.y + cy - self.scrollY + return ay >= scrollTopAbs - 20 and ay < contentVP.y + contentVP.height + and self.compareViewMode == "CONFIG" and self:GetActiveCompare() ~= nil + end + currentY = currentY + rowHeight + end + end +end + +-- Update comparison build set selectors (spec, skill set, item set, skill controls). +function CompareTabClass:UpdateSetSelectors(compareEntry) + -- Tree spec list (reuse GetSpecList from TreeTab) + if compareEntry.treeTab then + self.controls.compareSpecSelect.list = compareEntry.treeTab:GetSpecList() + self.controls.compareSpecSelect.selIndex = compareEntry.treeTab.activeSpec + end + -- Skill set list + if compareEntry.skillsTab then + local skillList = {} + for index, skillSetId in ipairs(compareEntry.skillsTab.skillSetOrderList) do + local skillSet = compareEntry.skillsTab.skillSets[skillSetId] + t_insert(skillList, skillSet.title or "Default") + if skillSetId == compareEntry.skillsTab.activeSkillSetId then + self.controls.compareSkillSetSelect.selIndex = index + end + end + self.controls.compareSkillSetSelect:SetList(skillList) + end + -- Item set list + if compareEntry.itemsTab then + local itemList = {} + for index, itemSetId in ipairs(compareEntry.itemsTab.itemSetOrderList) do + local itemSet = compareEntry.itemsTab.itemSets[itemSetId] + t_insert(itemList, itemSet.title or "Default") + if itemSetId == compareEntry.itemsTab.activeItemSetId then + self.controls.compareItemSetSelect.selIndex = index + end + end + self.controls.compareItemSetSelect:SetList(itemList) + end + + -- Refresh comparison build skill selector controls + local cmpControls = { + mainSocketGroup = self.controls.cmpSocketGroup, + mainSkill = self.controls.cmpMainSkill, + mainSkillPart = self.controls.cmpSkillPart, + mainSkillStageCount = self.controls.cmpStageCount, + mainSkillMineCount = self.controls.cmpMineCount, + mainSkillMinion = self.controls.cmpMinion, + mainSkillMinionLibrary = { shown = false }, + mainSkillMinionSkill = self.controls.cmpMinionSkill, + } + compareEntry:RefreshSkillSelectControls(cmpControls, compareEntry.mainSocketGroup, "") +end + +-- Handle scroll events for scrollable views. +function CompareTabClass:HandleScrollInput(contentVP, inputEvents) + local cursorX, cursorY = GetCursorPos() + local mouseInContent = cursorX >= contentVP.x and cursorX < contentVP.x + contentVP.width + and cursorY >= contentVP.y and cursorY < contentVP.y + contentVP.height + + for id, event in ipairs(inputEvents) do + if event.type == "KeyDown" and mouseInContent then + if event.key == "WHEELUP" and self.compareViewMode ~= "TREE" then + self.scrollY = m_max(self.scrollY - 40, 0) + inputEvents[id] = nil + elseif event.key == "WHEELDOWN" and self.compareViewMode ~= "TREE" then + self.scrollY = self.scrollY + 40 + inputEvents[id] = nil + end + end + end +end + -- ============================================================ -- SUMMARY VIEW -- ============================================================ @@ -1351,10 +1409,10 @@ function CompareTabClass:DrawSummary(vp, compareEntry) local headerHeight = 22 -- Column positions - local col1 = 10 -- Stat name - local col2 = 300 -- Primary value - local col3 = 450 -- Compare value - local col4 = 600 -- Difference + local col1 = LAYOUT.summaryCol1 + local col2 = LAYOUT.summaryCol2 + local col3 = LAYOUT.summaryCol3 + local col4 = LAYOUT.summaryCol4 SetViewport(vp.x, vp.y, vp.width, vp.height) local drawY = 4 - self.scrollY @@ -1699,6 +1757,53 @@ local function buildModMap(item) return modMap end +-- Helper: get diff label string for an item slot comparison +local function getSlotDiffLabel(pItem, cItem) + if not pItem and not cItem then + return "^8(both empty)" + end + if pItem and cItem and pItem.name == cItem.name then + return colorCodes.POSITIVE .. "(match)" + elseif not pItem then + return colorCodes.NEGATIVE .. "(missing)" + elseif not cItem then + return colorCodes.TIP .. "(extra)" + else + return colorCodes.WARNING .. "(different)" + end +end + +-- Helper: draw Copy and Copy+Use buttons at the given position. +-- Returns copyHovered, copyUseHovered booleans. +local function drawCopyButtons(cursorX, cursorY, vpWidth, btnY) + local btnW = LAYOUT.itemsCopyBtnW + local btnH = LAYOUT.itemsCopyBtnH + local btn2X = vpWidth - btnW - 8 + local btn1X = btn2X - btnW - 4 + + -- "Copy" button + local b1Hover = cursorX >= btn1X and cursorX < btn1X + btnW + and cursorY >= btnY and cursorY < btnY + btnH + SetDrawColor(b1Hover and 0.5 or 0.35, b1Hover and 0.5 or 0.35, b1Hover and 0.5 or 0.35) + DrawImage(nil, btn1X, btnY, btnW, btnH) + SetDrawColor(0.1, 0.1, 0.1) + DrawImage(nil, btn1X + 1, btnY + 1, btnW - 2, btnH - 2) + SetDrawColor(1, 1, 1) + DrawString(btn1X + btnW / 2, btnY + 1, "CENTER_X", 14, "VAR", "^7Copy") + + -- "Copy+Use" button + local b2Hover = cursorX >= btn2X and cursorX < btn2X + btnW + and cursorY >= btnY and cursorY < btnY + btnH + SetDrawColor(b2Hover and 0.5 or 0.35, b2Hover and 0.5 or 0.35, b2Hover and 0.5 or 0.35) + DrawImage(nil, btn2X, btnY, btnW, btnH) + SetDrawColor(0.1, 0.1, 0.1) + DrawImage(nil, btn2X + 1, btnY + 1, btnW - 2, btnH - 2) + SetDrawColor(1, 1, 1) + DrawString(btn2X + btnW / 2, btnY + 1, "CENTER_X", 14, "VAR", "^7Copy+Use") + + return b1Hover, b2Hover +end + -- Draw a single item's full details at (x, startY) within colWidth. -- otherModMap: optional table from buildModMap() of the other item for diff highlighting. -- Returns the total height consumed. @@ -1869,7 +1974,7 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) local lineHeight = 20 local colWidth = m_floor(vp.width / 2) - local checkboxOffset = 36 -- space for the expanded mode checkbox plus padding + local checkboxOffset = LAYOUT.itemsCheckboxOffset SetViewport(vp.x, vp.y + checkboxOffset, vp.width, vp.height - checkboxOffset) local drawY = 4 - self.scrollY @@ -1906,55 +2011,14 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) if self.itemsExpandedMode then -- === EXPANDED MODE === - -- Slot label + -- Slot label + diff indicator SetDrawColor(1, 1, 1) DrawString(10, drawY, "LEFT", 16, "VAR", "^7" .. slotName .. ":") + DrawString(colWidth - 10, drawY, "RIGHT", 14, "VAR", getSlotDiffLabel(pItem, cItem)) - -- Diff indicator next to slot label - local isSame = pItem and cItem and pItem.name == cItem.name - local diffLabel = "" - if not pItem and not cItem then - diffLabel = "^8(both empty)" - elseif isSame then - diffLabel = colorCodes.POSITIVE .. "(match)" - elseif not pItem then - diffLabel = colorCodes.NEGATIVE .. "(missing)" - elseif not cItem then - diffLabel = colorCodes.TIP .. "(extra)" - else - diffLabel = colorCodes.WARNING .. "(different)" - end - DrawString(colWidth - 10, drawY, "RIGHT", 14, "VAR", diffLabel) - - -- Copy buttons for compare item (expanded mode) + -- Copy buttons for compare item if cItem then - local btnW = 60 - local btnH = 18 - local btn2X = vp.width - btnW - 8 - local btn1X = btn2X - btnW - 4 - local btnY = drawY + 1 - - -- "Copy" button - local b1Hover = cursorX >= btn1X and cursorX < btn1X + btnW - and cursorY >= btnY and cursorY < btnY + btnH - SetDrawColor(b1Hover and 0.5 or 0.35, b1Hover and 0.5 or 0.35, b1Hover and 0.5 or 0.35) - DrawImage(nil, btn1X, btnY, btnW, btnH) - SetDrawColor(0.1, 0.1, 0.1) - DrawImage(nil, btn1X + 1, btnY + 1, btnW - 2, btnH - 2) - SetDrawColor(1, 1, 1) - DrawString(btn1X + btnW / 2, btnY + 1, "CENTER_X", 14, "VAR", "^7Copy") - - -- "Copy+Use" button - local b2Hover = cursorX >= btn2X and cursorX < btn2X + btnW - and cursorY >= btnY and cursorY < btnY + btnH - SetDrawColor(b2Hover and 0.5 or 0.35, b2Hover and 0.5 or 0.35, b2Hover and 0.5 or 0.35) - DrawImage(nil, btn2X, btnY, btnW, btnH) - SetDrawColor(0.1, 0.1, 0.1) - DrawImage(nil, btn2X + 1, btnY + 1, btnW - 2, btnH - 2) - SetDrawColor(1, 1, 1) - DrawString(btn2X + btnW / 2, btnY + 1, "CENTER_X", 14, "VAR", "^7Copy+Use") - - -- Click detection + local b1Hover, b2Hover = drawCopyButtons(cursorX, cursorY, vp.width, drawY + 1) if inputEvents then for id, event in ipairs(inputEvents) do if event.type == "KeyUp" and event.key == "LEFTBUTTON" then @@ -1988,26 +2052,11 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) drawY = drawY + maxH + 6 else - -- === COMPACT MODE (existing behavior) === - -- Slot label + -- === COMPACT MODE === + -- Slot label + diff indicator SetDrawColor(1, 1, 1) DrawString(10, drawY, "LEFT", 16, "VAR", "^7" .. slotName .. ":") - - -- Diff indicator on slot label line - local isSame = pItem and cItem and pItem.name == cItem.name - local diffLabel = "" - if not pItem and not cItem then - diffLabel = "^8(both empty)" - elseif isSame then - diffLabel = colorCodes.POSITIVE .. "(match)" - elseif not pItem then - diffLabel = colorCodes.NEGATIVE .. "(missing)" - elseif not cItem then - diffLabel = colorCodes.TIP .. "(extra)" - else - diffLabel = colorCodes.WARNING .. "(different)" - end - DrawString(colWidth - 10, drawY, "RIGHT", 14, "VAR", diffLabel) + DrawString(colWidth - 10, drawY, "RIGHT", 14, "VAR", getSlotDiffLabel(pItem, cItem)) local pName = pItem and pItem.name or "(empty)" local cName = cItem and cItem.name or "(empty)" @@ -2064,35 +2113,9 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) DrawString(20, drawY, "LEFT", 16, "VAR", pColor .. pName) DrawString(colWidth + 20, drawY, "LEFT", 16, "VAR", cColor .. cName) - -- Copy buttons for compare item (compact mode) + -- Copy buttons for compare item if cItem then - local btnW = 60 - local btnH = 18 - local btn2X = vp.width - btnW - 8 - local btn1X = btn2X - btnW - 4 - local btnY = drawY - - -- "Copy" button - local b1Hover = cursorX >= btn1X and cursorX < btn1X + btnW - and cursorY >= btnY and cursorY < btnY + btnH - SetDrawColor(b1Hover and 0.5 or 0.35, b1Hover and 0.5 or 0.35, b1Hover and 0.5 or 0.35) - DrawImage(nil, btn1X, btnY, btnW, btnH) - SetDrawColor(0.1, 0.1, 0.1) - DrawImage(nil, btn1X + 1, btnY + 1, btnW - 2, btnH - 2) - SetDrawColor(1, 1, 1) - DrawString(btn1X + btnW / 2, btnY + 1, "CENTER_X", 14, "VAR", "^7Copy") - - -- "Copy+Use" button - local b2Hover = cursorX >= btn2X and cursorX < btn2X + btnW - and cursorY >= btnY and cursorY < btnY + btnH - SetDrawColor(b2Hover and 0.5 or 0.35, b2Hover and 0.5 or 0.35, b2Hover and 0.5 or 0.35) - DrawImage(nil, btn2X, btnY, btnW, btnH) - SetDrawColor(0.1, 0.1, 0.1) - DrawImage(nil, btn2X + 1, btnY + 1, btnW - 2, btnH - 2) - SetDrawColor(1, 1, 1) - DrawString(btn2X + btnW / 2, btnY + 1, "CENTER_X", 14, "VAR", "^7Copy+Use") - - -- Click detection + local b1Hover, b2Hover = drawCopyButtons(cursorX, cursorY, vp.width, drawY) if inputEvents then for id, event in ipairs(inputEvents) do if event.type == "KeyUp" and event.key == "LEFTBUTTON" then @@ -2163,7 +2186,7 @@ function CompareTabClass:DrawSkills(vp, compareEntry) return set end - -- Helper: compute similarity between two gem name sets + -- Helper: compute Jaccard similarity between two gem name sets local function groupSimilarity(setA, setB) local intersection = 0 local union = 0 @@ -2608,16 +2631,11 @@ function CompareTabClass:DrawCalcs(vp, compareEntry) local compareActor = compareEnv.player if not primaryActor or not compareActor then return end - -- Load section definitions (cached) - if not self.calcSections then - self.calcSections = LoadModule("Modules/CalcSections") - end - -- Card dimensions -- Layout: [2px border | 130px label | 2px gap | 2px sep | valW | 2px sep | valW | 2px border] - local cardWidth = m_min(400, vp.width - 16) - local labelWidth = 132 - local sepW = 2 + local cardWidth = m_min(LAYOUT.calcsMaxCardWidth, vp.width - 16) + local labelWidth = LAYOUT.calcsLabelWidth + local sepW = LAYOUT.calcsSepW local valColWidth = m_floor((cardWidth - 140) / 2) local valCol1X = labelWidth + sepW * 2 local valCol2X = valCol1X + valColWidth + sepW @@ -2625,7 +2643,7 @@ function CompareTabClass:DrawCalcs(vp, compareEntry) -- Layout parameters local maxCol = m_max(1, m_floor(vp.width / (cardWidth + 8))) local baseX = 4 - local headerBarHeight = 24 + local headerBarHeight = LAYOUT.calcsHeaderBarHeight local baseY = headerBarHeight -- Pre-compute section visibility and heights @@ -2831,15 +2849,15 @@ end -- CONFIG VIEW -- ============================================================ function CompareTabClass:DrawConfig(vp, compareEntry) - local rowHeight = 22 - local sectionHeaderHeight = 24 - local columnHeaderHeight = 20 - local fixedHeaderHeight = 66 -- buttons + column headers + separator (not scrollable) + local rowHeight = LAYOUT.configRowHeight + local sectionHeaderHeight = LAYOUT.configSectionHeaderHeight + local columnHeaderHeight = LAYOUT.configColumnHeaderHeight + local fixedHeaderHeight = LAYOUT.configFixedHeaderHeight -- Column positions (viewport-relative) - local col1 = 10 - local col2 = 300 -- primary value (interactive controls drawn by ControlHost) - local col3 = 500 -- compare value (read-only) + local col1 = LAYOUT.configCol1 + local col2 = LAYOUT.configCol2 + local col3 = LAYOUT.configCol3 -- Fixed header area: buttons at top, then column headers + separator SetViewport(vp.x, vp.y, vp.width, fixedHeaderHeight) From 84f494976b7504d9d139625a13b52ef2ead37f27 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Sat, 21 Mar 2026 08:57:38 +0100 Subject: [PATCH 13/59] include power report feature in summary --- src/Classes/ComparePowerReportListControl.lua | 124 ++++ src/Classes/CompareTab.lua | 568 +++++++++++++++--- 2 files changed, 603 insertions(+), 89 deletions(-) create mode 100644 src/Classes/ComparePowerReportListControl.lua diff --git a/src/Classes/ComparePowerReportListControl.lua b/src/Classes/ComparePowerReportListControl.lua new file mode 100644 index 0000000000..30b61c2b31 --- /dev/null +++ b/src/Classes/ComparePowerReportListControl.lua @@ -0,0 +1,124 @@ +-- Path of Building +-- +-- Class: Compare Power Report List +-- List control for the compare power report in the Summary tab. +-- + +local t_insert = table.insert +local t_sort = table.sort + +local ComparePowerReportListClass = newClass("ComparePowerReportListControl", "ListControl", function(self, anchor, rect) + self.ListControl(anchor, rect, 18, "VERTICAL", false) + + local width = rect[3] + self.impactColumn = { width = width * 0.22, label = "", sortable = true } + self.colList = { + { width = width * 0.10, label = "Category", sortable = true }, + { width = width * 0.44, label = "Name" }, + self.impactColumn, + { width = width * 0.08, label = "Points", sortable = true }, + { width = width * 0.16, label = "Per Point", sortable = true }, + } + self.colLabels = true + self.showRowSeparators = true + self.statusText = "Select a metric above to generate the power report." +end) + +function ComparePowerReportListClass:SetReport(stat, report) + self.impactColumn.label = stat and stat.label or "" + self.reportData = report or {} + + if stat and stat.stat then + if report and #report > 0 then + self.statusText = nil + else + self.statusText = "No differences found." + end + else + self.statusText = "Select a metric above to generate the power report." + end + + self:ReList() + self:ReSort(3) +end + +function ComparePowerReportListClass:SetProgress(progress) + if progress < 100 then + self.statusText = "Calculating... " .. progress .. "%" + self.list = {} + end +end + +function ComparePowerReportListClass:Draw(viewPort, noTooltip) + self.ListControl.Draw(self, viewPort, noTooltip) + -- Draw status text below column headers when the list is empty + if #self.list == 0 and self.statusText then + local x, y = self:GetPos() + local width, height = self:GetSize() + -- Column headers are 18px tall, plus 2px border = start at y+20 + SetViewport(x + 2, y + 20, width - 20, height - 22) + SetDrawColor(1, 1, 1) + DrawString(4, 4, "LEFT", 14, "VAR", self.statusText) + SetViewport() + end +end + +function ComparePowerReportListClass:ReSort(colIndex) + local compare = function(a, b) return a > b end + + if colIndex == 1 then + t_sort(self.list, function(a, b) + if a.category == b.category then + return compare(math.abs(a.impact), math.abs(b.impact)) + end + return a.category < b.category + end) + elseif colIndex == 3 then + t_sort(self.list, function(a, b) + return compare(a.impact, b.impact) + end) + elseif colIndex == 4 then + t_sort(self.list, function(a, b) + local aDist = a.pathDist or 99999 + local bDist = b.pathDist or 99999 + if aDist == bDist then + return compare(math.abs(a.impact), math.abs(b.impact)) + end + return aDist < bDist + end) + elseif colIndex == 5 then + t_sort(self.list, function(a, b) + local aVal = a.perPoint or -99999 + local bVal = b.perPoint or -99999 + return compare(aVal, bVal) + end) + end +end + +function ComparePowerReportListClass:ReList() + self.list = {} + if not self.reportData then + return + end + for _, entry in ipairs(self.reportData) do + t_insert(self.list, entry) + end +end + +function ComparePowerReportListClass:GetRowValue(column, index, entry) + if column == 1 then + return (entry.categoryColor or "^7") .. entry.category + elseif column == 2 then + return (entry.nameColor or "^7") .. entry.name + elseif column == 3 then + return entry.combinedImpactStr or entry.impactStr or "0" + elseif column == 4 then + if entry.pathDist then + return tostring(entry.pathDist) + end + return "" + elseif column == 5 then + return entry.perPointStr or "" + end + return "" +end diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 8368d66e98..e97793ab85 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -37,6 +37,9 @@ local LAYOUT = { calcsSepW = 2, calcsHeaderBarHeight = 24, + -- Power report section (inside Summary view) + powerReportLeft = 10, + -- Config view (shared between Draw() layout and DrawConfig()) configRowHeight = 22, configSectionHeaderHeight = 24, @@ -114,9 +117,19 @@ local CompareTabClass = newClass("CompareTab", "ControlHost", "Control", functio self.configToggle = false -- show all / hide ineligible toggle self.configDisplayList = {} -- computed display order (headers + rows) + -- Compare power report state + self.comparePowerStat = nil -- selected data.powerStatList entry + self.comparePowerCategories = { treeNodes = true, items = true, gems = true } + self.comparePowerResults = nil -- sorted list of result entries + self.comparePowerCoroutine = nil -- active coroutine + self.comparePowerProgress = 0 -- 0-100 + self.comparePowerDirty = false -- flag to restart calculation + self.comparePowerCompareId = nil -- track which compare entry was calculated + -- Pre-load static module data self.configOptions = LoadModule("Modules/ConfigOptions") self.calcSections = LoadModule("Modules/CalcSections") + self.calcs = LoadModule("Modules/Calcs") -- Controls for the comparison screen self:InitControls() @@ -533,6 +546,60 @@ function CompareTabClass:InitControls() self.controls.configToggleBtn.shown = function() return self.compareViewMode == "CONFIG" and self:GetActiveCompare() ~= nil end + + -- ============================================================ + -- Compare Power Report controls (Summary view) + -- ============================================================ + local powerReportShown = function() + return self.compareViewMode == "SUMMARY" and #self.compareEntries > 0 + end + + -- Metric dropdown + local powerStatList = { { label = "-- Select Metric --", stat = nil } } + for _, entry in ipairs(data.powerStatList) do + if entry.stat and not entry.ignoreForNodes then + t_insert(powerStatList, entry) + end + end + self.controls.comparePowerStatSelect = new("DropDownControl", nil, {0, 0, 200, 20}, powerStatList, function(index, value) + if value and value.stat and value ~= self.comparePowerStat then + self.comparePowerStat = value + self.comparePowerDirty = true + elseif value and not value.stat then + self.comparePowerStat = nil + self.comparePowerResults = nil + self.comparePowerCoroutine = nil + self.comparePowerListSynced = false + end + end) + self.controls.comparePowerStatSelect.shown = powerReportShown + self.controls.comparePowerStatSelect.tooltipText = "Select a metric to calculate power report" + + -- Category checkboxes + self.controls.comparePowerTreeCheck = new("CheckBoxControl", nil, {0, 0, 18}, "Tree:", function(state) + self.comparePowerCategories.treeNodes = state + self.comparePowerDirty = true + end, "Include passive tree nodes from compared build") + self.controls.comparePowerTreeCheck.shown = powerReportShown + self.controls.comparePowerTreeCheck.state = true + + self.controls.comparePowerItemsCheck = new("CheckBoxControl", nil, {0, 0, 18}, "Items:", function(state) + self.comparePowerCategories.items = state + self.comparePowerDirty = true + end, "Include items from compared build") + self.controls.comparePowerItemsCheck.shown = powerReportShown + self.controls.comparePowerItemsCheck.state = true + + self.controls.comparePowerGemsCheck = new("CheckBoxControl", nil, {0, 0, 18}, "Gems:", function(state) + self.comparePowerCategories.gems = state + self.comparePowerDirty = true + end, "Include skill gem groups from compared build") + self.controls.comparePowerGemsCheck.shown = powerReportShown + self.controls.comparePowerGemsCheck.state = true + + -- Power report list control (static height, own scrollbar) + self.controls.comparePowerReportList = new("ComparePowerReportListControl", nil, {0, 0, 750, 250}) + self.controls.comparePowerReportList.shown = powerReportShown end -- Get a short display name from a build name (strips "AccountName - " prefix) @@ -1395,6 +1462,348 @@ function CompareTabClass:HandleScrollInput(contentVP, inputEvents) end end +-- ============================================================ +-- COMPARE POWER REPORT +-- ============================================================ + +-- Calculate the stat difference for a given power stat selection +-- output: result from calcFunc (with the change applied) +-- calcBase: baseline output (without the change) +-- Returns positive value if the change improves the stat +function CompareTabClass:CalculatePowerStat(selection, output, calcBase) + local withChange = output + local baseline = calcBase + if baseline.Minion and not selection.stat == "FullDPS" then + withChange = withChange.Minion + baseline = baseline.Minion + end + local withValue = withChange[selection.stat] or 0 + local baseValue = baseline[selection.stat] or 0 + if selection.transform then + withValue = selection.transform(withValue) + baseValue = selection.transform(baseValue) + end + return withValue - baseValue +end + +-- Build a signature string for a socket group (sorted gem names) +function CompareTabClass:GetSocketGroupSignature(group) + local names = {} + for _, gem in ipairs(group.gemList or {}) do + local name = gem.grantedEffect and gem.grantedEffect.name or gem.nameSpec + if name then + t_insert(names, name) + end + end + table.sort(names) + return table.concat(names, "+") +end + +-- Get a display label for a socket group (active skills only) +function CompareTabClass:GetSocketGroupLabel(group) + local names = {} + for _, gem in ipairs(group.gemList or {}) do + local isSupport = gem.grantedEffect and gem.grantedEffect.support + if not isSupport then + local name = gem.grantedEffect and gem.grantedEffect.name or gem.nameSpec + if name then + t_insert(names, name) + end + end + end + if #names == 0 then + -- Fallback: show all gem names if no active skills found + for _, gem in ipairs(group.gemList or {}) do + local name = gem.grantedEffect and gem.grantedEffect.name or gem.nameSpec + if name then + t_insert(names, name) + end + end + end + if #names == 0 then + return "(empty group)" + end + return table.concat(names, " + ") +end + +-- Coroutine: calculate power of compared build elements against primary build +function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories) + local results = {} + local useFullDPS = powerStat.stat == "FullDPS" + + -- Get calculator for primary build + local calcFunc, calcBase = self.calcs.getMiscCalculator(self.primaryBuild) + + -- Find display stat for formatting + local displayStat = nil + for _, ds in ipairs(self.primaryBuild.displayStats) do + if ds.stat == powerStat.stat then + displayStat = ds + break + end + end + if not displayStat then + displayStat = { fmt = ".1f" } + end + + local total = 0 + local processed = 0 + local start = GetTime() + + -- Count total work items for progress + if categories.treeNodes then + local compareNodes = compareEntry.spec and compareEntry.spec.allocNodes or {} + local primaryNodes = self.primaryBuild.spec and self.primaryBuild.spec.allocNodes or {} + for nodeId, node in pairs(compareNodes) do + if type(nodeId) == "number" and nodeId < 65536 and not primaryNodes[nodeId] then + local pNode = self.primaryBuild.spec.nodes[nodeId] + if pNode and (pNode.type == "Normal" or pNode.type == "Notable" or pNode.type == "Keystone") and not pNode.ascendancyName then + total = total + 1 + end + end + end + end + if categories.items then + local baseSlots = { "Weapon 1", "Weapon 2", "Helmet", "Body Armour", "Gloves", "Boots", "Amulet", "Ring 1", "Ring 2", "Belt", "Flask 1", "Flask 2", "Flask 3", "Flask 4", "Flask 5" } + for _, slotName in ipairs(baseSlots) do + local cSlot = compareEntry.itemsTab and compareEntry.itemsTab.slots[slotName] + local cItem = cSlot and compareEntry.itemsTab.items[cSlot.selItemId] + if cItem then + total = total + 1 + end + end + end + if categories.gems then + local cGroups = compareEntry.skillsTab and compareEntry.skillsTab.socketGroupList or {} + total = total + #cGroups + end + + if total == 0 then + self.comparePowerResults = results + self.comparePowerProgress = 100 + return + end + + -- Get baseline stat value for percentage calculation + local baseStatValue = calcBase[powerStat.stat] or 0 + if powerStat.transform then + baseStatValue = powerStat.transform(baseStatValue) + end + + -- Helper to format an impact value and compute percentage + local function formatImpact(impact) + local displayVal = impact * ((displayStat.pc or displayStat.mod) and 100 or 1) + local numStr = s_format("%" .. displayStat.fmt, displayVal) + numStr = formatNumSep(numStr) + + -- Determine color + local isPositive = (displayVal > 0 and not displayStat.lowerIsBetter) or (displayVal < 0 and displayStat.lowerIsBetter) + local isNegative = (displayVal < 0 and not displayStat.lowerIsBetter) or (displayVal > 0 and displayStat.lowerIsBetter) + local color = isPositive and colorCodes.POSITIVE or isNegative and colorCodes.NEGATIVE or "^7" + local sign = displayVal > 0 and "+" or "" + local str = color .. sign .. numStr + + -- Compute percentage change + local percent = 0 + if baseStatValue ~= 0 then + percent = (impact / math.abs(baseStatValue)) * 100 + end + + -- Build combined string: "+1,234.5 (+4.3%)" + local combinedStr = str + if percent ~= 0 then + local pctStr = s_format("%+.1f%%", percent) + combinedStr = str .. " ^7(" .. color .. pctStr .. "^7)" + end + + return str, displayVal, combinedStr, percent + end + + -- ========================================== + -- Phase A: Tree Nodes + -- ========================================== + if categories.treeNodes then + local compareNodes = compareEntry.spec and compareEntry.spec.allocNodes or {} + local primaryNodes = self.primaryBuild.spec and self.primaryBuild.spec.allocNodes or {} + local cache = {} + + for nodeId, _ in pairs(compareNodes) do + if type(nodeId) == "number" and nodeId < 65536 and not primaryNodes[nodeId] then + local pNode = self.primaryBuild.spec.nodes[nodeId] + if pNode and (pNode.type == "Normal" or pNode.type == "Notable" or pNode.type == "Keystone") + and not pNode.ascendancyName and pNode.modKey ~= "" then + local output + if cache[pNode.modKey] then + output = cache[pNode.modKey] + else + output = calcFunc({ addNodes = { [pNode] = true } }, useFullDPS) + cache[pNode.modKey] = output + end + local impact = self:CalculatePowerStat(powerStat, output, calcBase) + local pathDist = pNode.pathDist or 0 + if pathDist == 0 then + pathDist = #(pNode.path or {}) + if pathDist == 0 then pathDist = 1 end + end + local perPoint = impact / pathDist + local impactStr, impactVal, combinedImpactStr, impactPercent = formatImpact(impact) + local perPointStr = formatImpact(perPoint) + + t_insert(results, { + category = "Tree", + categoryColor = "^7", + nameColor = "^7", + name = pNode.dn, + impact = impactVal, + impactStr = impactStr, + impactPercent = impactPercent, + combinedImpactStr = combinedImpactStr, + pathDist = pathDist, + perPoint = perPoint * ((displayStat.pc or displayStat.mod) and 100 or 1), + perPointStr = perPointStr, + }) + + processed = processed + 1 + if coroutine.running() and GetTime() - start > 100 then + self.comparePowerProgress = m_floor(processed / total * 100) + coroutine.yield() + start = GetTime() + end + end + end + end + end + + -- ========================================== + -- Phase B: Items + -- ========================================== + if categories.items then + local baseSlots = { "Weapon 1", "Weapon 2", "Helmet", "Body Armour", "Gloves", "Boots", "Amulet", "Ring 1", "Ring 2", "Belt", "Flask 1", "Flask 2", "Flask 3", "Flask 4", "Flask 5" } + for _, slotName in ipairs(baseSlots) do + local cSlot = compareEntry.itemsTab and compareEntry.itemsTab.slots[slotName] + local cItem = cSlot and compareEntry.itemsTab.items[cSlot.selItemId] + if cItem and cItem.raw then + local newItem = new("Item", cItem.raw) + newItem:NormaliseQuality() + local output = calcFunc({ repSlotName = slotName, repItem = newItem }, useFullDPS) + local impact = self:CalculatePowerStat(powerStat, output, calcBase) + local impactStr, impactVal, combinedImpactStr, impactPercent = formatImpact(impact) + + -- Get rarity color for item name + local rarityColor = colorCodes[cItem.rarity] or colorCodes.NORMAL + + t_insert(results, { + category = "Item", + categoryColor = colorCodes.NORMAL, + nameColor = rarityColor, + name = (cItem.name or "Unknown") .. ", " .. slotName, + impact = impactVal, + impactStr = impactStr, + impactPercent = impactPercent, + combinedImpactStr = combinedImpactStr, + pathDist = nil, + perPoint = nil, + perPointStr = nil, + }) + end + processed = processed + 1 + if coroutine.running() and GetTime() - start > 100 then + self.comparePowerProgress = m_floor(processed / total * 100) + coroutine.yield() + start = GetTime() + end + end + end + + -- ========================================== + -- Phase C: Skill Gems (socket groups) + -- ========================================== + if categories.gems then + local cGroups = compareEntry.skillsTab and compareEntry.skillsTab.socketGroupList or {} + local pGroups = self.primaryBuild.skillsTab and self.primaryBuild.skillsTab.socketGroupList or {} + + -- Build signature set for primary groups + local pSignatures = {} + for _, group in ipairs(pGroups) do + pSignatures[self:GetSocketGroupSignature(group)] = true + end + + for _, cGroup in ipairs(cGroups) do + local sig = self:GetSocketGroupSignature(cGroup) + if sig ~= "" and not pSignatures[sig] then + -- Temporarily add this socket group to primary build and recalculate + t_insert(pGroups, cGroup) + self.primaryBuild.buildFlag = true + + -- Get a fresh calculator with the added group + local gemCalcFunc, gemCalcBase = self.calcs.getMiscCalculator(self.primaryBuild) + local impact = self:CalculatePowerStat(powerStat, gemCalcBase, calcBase) + + -- Remove the temporarily added group + t_remove(pGroups) + self.primaryBuild.buildFlag = true + + local impactStr, impactVal, combinedImpactStr, impactPercent = formatImpact(impact) + local label = self:GetSocketGroupLabel(cGroup) + + t_insert(results, { + category = "Gem", + categoryColor = colorCodes.GEM, + nameColor = colorCodes.GEM, + name = label, + impact = impactVal, + impactStr = impactStr, + impactPercent = impactPercent, + combinedImpactStr = combinedImpactStr, + pathDist = nil, + perPoint = nil, + perPointStr = nil, + }) + end + processed = processed + 1 + if coroutine.running() and GetTime() - start > 100 then + self.comparePowerProgress = m_floor(processed / total * 100) + coroutine.yield() + start = GetTime() + end + end + end + + self.comparePowerResults = results + self.comparePowerProgress = 100 +end + +-- Drive the compare power report coroutine +function CompareTabClass:RunComparePowerReport(compareEntry) + -- Invalidate if compare entry changed + if self.comparePowerCompareId ~= compareEntry then + self.comparePowerCompareId = compareEntry + self.comparePowerDirty = true + end + + -- Start new calculation if dirty + if self.comparePowerDirty and self.comparePowerStat then + self.comparePowerDirty = false + self.comparePowerResults = nil + self.comparePowerProgress = 0 + self.comparePowerListSynced = false + self.comparePowerCoroutine = coroutine.create(function() + self:ComparePowerBuilder(compareEntry, self.comparePowerStat, self.comparePowerCategories) + end) + end + + -- Resume coroutine + if self.comparePowerCoroutine then + local res, errMsg = coroutine.resume(self.comparePowerCoroutine) + if launch and launch.devMode and not res then + error(errMsg) + end + if coroutine.status(self.comparePowerCoroutine) == "dead" then + self.comparePowerCoroutine = nil + end + end +end + -- ============================================================ -- SUMMARY VIEW -- ============================================================ @@ -1437,105 +1846,86 @@ function CompareTabClass:DrawSummary(vp, compareEntry) drawY = self:DrawStatList(drawY, vp, displayStats, primaryOutput, compareOutput, primaryEnv, compareEnv, col1, col2, col3, col4) - SetViewport() -end + -- ======================================== + -- Compare Power Report section + -- ======================================== + drawY = drawY + 16 -function CompareTabClass:DrawProgressSection(drawY, colWidth, vp, compareEntry) - local lineHeight = 16 + -- Separator + SetDrawColor(0.5, 0.5, 0.5) + DrawImage(nil, 4, drawY, vp.width - 8, 2) + drawY = drawY + 8 - -- Count matching passive nodes - local primaryNodes = self.primaryBuild.spec and self.primaryBuild.spec.allocNodes or {} - local compareNodes = compareEntry.spec and compareEntry.spec.allocNodes or {} - local primaryCount = 0 - local compareCount = 0 - local matchCount = 0 - for nodeId, _ in pairs(primaryNodes) do - if type(nodeId) == "number" and nodeId < 65536 then -- Exclude special nodes - primaryCount = primaryCount + 1 - if compareNodes[nodeId] then - matchCount = matchCount + 1 - end - end - end - for nodeId, _ in pairs(compareNodes) do - if type(nodeId) == "number" and nodeId < 65536 then - compareCount = compareCount + 1 - end - end + -- Header + SetDrawColor(1, 1, 1) + DrawString(LAYOUT.powerReportLeft, drawY, "LEFT", 20, "VAR", "^7Compare Power Report") + drawY = drawY + 24 - -- Count matching items - local primaryItemCount = 0 - local compareItemCount = 0 - local matchingItemCount = 0 - if self.primaryBuild.itemsTab and compareEntry.itemsTab then - local baseSlots = { "Weapon 1", "Weapon 2", "Helmet", "Body Armour", "Gloves", "Boots", "Amulet", "Ring 1", "Ring 2", "Belt", "Flask 1", "Flask 2", "Flask 3", "Flask 4", "Flask 5" } - for _, slotName in ipairs(baseSlots) do - local pSlot = self.primaryBuild.itemsTab.slots[slotName] - local cSlot = compareEntry.itemsTab.slots[slotName] - local pItem = pSlot and self.primaryBuild.itemsTab.items[pSlot.selItemId] - local cItem = cSlot and compareEntry.itemsTab.items[cSlot.selItemId] - if pItem then primaryItemCount = primaryItemCount + 1 end - if cItem then compareItemCount = compareItemCount + 1 end - if pItem and cItem and pItem.name == cItem.name then - matchingItemCount = matchingItemCount + 1 - end - end + -- Run the coroutine driver (advances calculation each frame) + self:RunComparePowerReport(compareEntry) + + -- Position controls dynamically based on drawY + -- The controls need absolute screen positions (vp.x/vp.y offset + viewport-local drawY) + -- drawY already includes the scroll offset (starts at 4 - self.scrollY) + local controlY = vp.y + drawY + local ctrlBaseX = vp.x + LAYOUT.powerReportLeft + + -- Metric dropdown + self.controls.comparePowerStatSelect.x = ctrlBaseX + 60 + self.controls.comparePowerStatSelect.y = controlY + + -- Label for dropdown + DrawString(LAYOUT.powerReportLeft, drawY, "LEFT", 16, "VAR", "^7Metric:") + + -- Category checkboxes (positioned to the right of dropdown) + local checkX = ctrlBaseX + 280 + self.controls.comparePowerTreeCheck.x = checkX + self.controls.comparePowerTreeCheck.labelWidth + self.controls.comparePowerTreeCheck.y = controlY + checkX = checkX + self.controls.comparePowerTreeCheck.labelWidth + 26 + + self.controls.comparePowerItemsCheck.x = checkX + self.controls.comparePowerItemsCheck.labelWidth + self.controls.comparePowerItemsCheck.y = controlY + checkX = checkX + self.controls.comparePowerItemsCheck.labelWidth + 26 + + self.controls.comparePowerGemsCheck.x = checkX + self.controls.comparePowerGemsCheck.labelWidth + self.controls.comparePowerGemsCheck.y = controlY + + drawY = drawY + 28 + + -- Update the list control with current data (only when changed) + local listControl = self.controls.comparePowerReportList + if self.comparePowerCoroutine then + listControl:SetProgress(self.comparePowerProgress) + self.comparePowerListSynced = false + elseif self.comparePowerResults and not self.comparePowerListSynced then + listControl:SetReport(self.comparePowerStat, self.comparePowerResults) + self.comparePowerListSynced = true + elseif not self.comparePowerStat and not self.comparePowerListSynced then + listControl:SetReport(nil, nil) + self.comparePowerListSynced = true end - -- Count matching gems - local primaryGemCount = 0 - local compareGemCount = 0 - local matchingGemCount = 0 - if self.primaryBuild.skillsTab and compareEntry.skillsTab then - local pGems = {} - for _, group in ipairs(self.primaryBuild.skillsTab.socketGroupList) do - for _, gem in ipairs(group.gemList) do - if gem.grantedEffect then - pGems[gem.grantedEffect.name] = true - primaryGemCount = primaryGemCount + 1 - end - end - end - for _, group in ipairs(compareEntry.skillsTab.socketGroupList) do - for _, gem in ipairs(group.gemList) do - if gem.grantedEffect then - compareGemCount = compareGemCount + 1 - if pGems[gem.grantedEffect.name] then - matchingGemCount = matchingGemCount + 1 - end - end - end - end + -- Update the impact column label to match the selected stat + if self.comparePowerStat then + listControl.impactColumn.label = self.comparePowerStat.label or "" end - SetDrawColor(1, 1, 1) - DrawString(10, drawY, "LEFT", 18, "VAR", "^7Progress toward comparison build:") - drawY = drawY + 22 - - -- Nodes progress - local nodePercent = compareCount > 0 and m_floor(matchCount / compareCount * 100) or 0 - local nodeColor = nodePercent >= 90 and colorCodes.POSITIVE or nodePercent >= 50 and colorCodes.WARNING or colorCodes.NEGATIVE - DrawString(20, drawY, "LEFT", lineHeight, "VAR", - s_format("^7Passive Nodes: %s%d^7/%d matched (%s%d%%^7) - You: %d, Target: %d", nodeColor, matchCount, compareCount, nodeColor, nodePercent, primaryCount, compareCount)) - drawY = drawY + lineHeight + 2 - - -- Items progress - local itemPercent = compareItemCount > 0 and m_floor(matchingItemCount / compareItemCount * 100) or 0 - local itemColor = itemPercent >= 90 and colorCodes.POSITIVE or itemPercent >= 50 and colorCodes.WARNING or colorCodes.NEGATIVE - DrawString(20, drawY, "LEFT", lineHeight, "VAR", - s_format("^7Items: %s%d^7/%d matching (%s%d%%^7)", itemColor, matchingItemCount, compareItemCount, itemColor, itemPercent)) - drawY = drawY + lineHeight + 2 - - -- Gems progress - local gemPercent = compareGemCount > 0 and m_floor(matchingGemCount / compareGemCount * 100) or 0 - local gemColor = gemPercent >= 90 and colorCodes.POSITIVE or gemPercent >= 50 and colorCodes.WARNING or colorCodes.NEGATIVE - DrawString(20, drawY, "LEFT", lineHeight, "VAR", - s_format("^7Gems: %s%d^7/%d matching (%s%d%%^7)", gemColor, matchingGemCount, compareGemCount, gemColor, gemPercent)) - drawY = drawY + lineHeight + 2 + -- Position the list control (absolute screen coordinates). + -- The list has a fixed height and its own internal scrollbar for rows. + -- Width matches the table columns (750) plus scrollbar (20px border/scroll area). + local listHeight = 250 + local listWidth = 770 + listControl.x = vp.x + LAYOUT.powerReportLeft + listControl.y = vp.y + drawY + listControl.width = listWidth + listControl.height = listHeight - return drawY + drawY = drawY + listHeight + 20 -- bottom padding + + SetViewport() end + function CompareTabClass:DrawStatList(drawY, vp, displayStats, primaryOutput, compareOutput, primaryEnv, compareEnv, col1, col2, col3, col4) local lineHeight = 16 From 2f42df2f6054b945c9778976c24e8e731c2e1b97 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Sun, 22 Mar 2026 01:35:33 +0100 Subject: [PATCH 14/59] add support for jewels --- src/Classes/CompareTab.lua | 397 +++++++++++++++++++++++++++++++- src/Classes/PassiveTreeView.lua | 284 ++++++++++++++++++++++- 2 files changed, 672 insertions(+), 9 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index e97793ab85..7f902fd40c 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -953,6 +953,74 @@ function CompareTabClass:CopyCompareSpecToPrimary(andUse) self.primaryBuild.buildFlag = true end +-- Build a list of jewel comparison entries between the primary and compare builds. +-- Returns a sorted list of { label, nodeId, pItem, cItem, pSlotName, cSlotName } records. +function CompareTabClass:GetJewelComparisonSlots(compareEntry) + local pSpec = self.primaryBuild.spec + local cSpec = compareEntry.spec + if not pSpec or not cSpec then return {} end + + -- Collect union of all socket nodeIds that have a jewel equipped in either build + local nodeIds = {} + if pSpec.jewels then + for nodeId, itemId in pairs(pSpec.jewels) do + if itemId and itemId > 0 then + nodeIds[nodeId] = true + end + end + end + if cSpec.jewels then + for nodeId, itemId in pairs(cSpec.jewels) do + if itemId and itemId > 0 then + nodeIds[nodeId] = true + end + end + end + + local result = {} + for nodeId in pairs(nodeIds) do + local pItemId = pSpec.jewels and pSpec.jewels[nodeId] + local cItemId = cSpec.jewels and cSpec.jewels[nodeId] + local pItem = pItemId and self.primaryBuild.itemsTab.items[pItemId] + local cItem = cItemId and compareEntry.itemsTab.items[cItemId] + + -- Skip if neither build actually has a jewel here + if pItem or cItem then + local slotName = "Jewel "..nodeId + -- Derive a friendly label from the primary build's socket control if available + local label = slotName + local pSocket = self.primaryBuild.itemsTab.sockets and self.primaryBuild.itemsTab.sockets[nodeId] + if pSocket and pSocket.label then + label = pSocket.label + else + local cSocket = compareEntry.itemsTab.sockets and compareEntry.itemsTab.sockets[nodeId] + if cSocket and cSocket.label then + label = cSocket.label + end + end + + -- Check if the socket node is allocated in each build's current tree + local pNodeAllocated = pSpec.allocNodes and pSpec.allocNodes[nodeId] and true or false + local cNodeAllocated = cSpec.allocNodes and cSpec.allocNodes[nodeId] and true or false + + t_insert(result, { + label = label, + nodeId = nodeId, + pItem = pItem, + cItem = cItem, + pSlotName = slotName, + cSlotName = slotName, + pNodeAllocated = pNodeAllocated, + cNodeAllocated = cNodeAllocated, + }) + end + end + + -- Sort by nodeId for stable ordering + table.sort(result, function(a, b) return a.nodeId < b.nodeId end) + return result +end + -- Copy a compared build's item into the primary build function CompareTabClass:CopyCompareItemToPrimary(slotName, compareEntry, andUse) local cSlot = compareEntry.itemsTab and compareEntry.itemsTab.slots and compareEntry.itemsTab.slots[slotName] @@ -1572,6 +1640,13 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories total = total + 1 end end + -- Count jewels for progress tracking + local jewelSlots = self:GetJewelComparisonSlots(compareEntry) + for _, jEntry in ipairs(jewelSlots) do + if jEntry.cItem then + total = total + 1 + end + end end if categories.gems then local cGroups = compareEntry.skillsTab and compareEntry.skillsTab.socketGroupList or {} @@ -1620,7 +1695,7 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories end -- ========================================== - -- Phase A: Tree Nodes + -- Tree Nodes -- ========================================== if categories.treeNodes then local compareNodes = compareEntry.spec and compareEntry.spec.allocNodes or {} @@ -1675,7 +1750,7 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories end -- ========================================== - -- Phase B: Items + -- Items -- ========================================== if categories.items then local baseSlots = { "Weapon 1", "Weapon 2", "Helmet", "Body Armour", "Gloves", "Boots", "Amulet", "Ring 1", "Ring 2", "Belt", "Flask 1", "Flask 2", "Flask 3", "Flask 4", "Flask 5" } @@ -1716,7 +1791,89 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories end -- ========================================== - -- Phase C: Skill Gems (socket groups) + -- Jewels (included as items) + -- ========================================== + if categories.items then + -- Build list of jewel socket info in the primary build for fallback testing + -- Each entry has { slotName, nodeId, node, allocated } + local pSpec = self.primaryBuild.spec + local primaryJewelSockets = {} + for _, slot in ipairs(self.primaryBuild.itemsTab.orderedSlots) do + if slot.nodeId then + local node = pSpec.nodes[slot.nodeId] + local allocated = pSpec.allocNodes and pSpec.allocNodes[slot.nodeId] and true or false + if node then + t_insert(primaryJewelSockets, { + slotName = slot.slotName, + nodeId = slot.nodeId, + node = node, + allocated = allocated, + }) + end + end + end + + local jewelSlots = self:GetJewelComparisonSlots(compareEntry) + for _, jEntry in ipairs(jewelSlots) do + if jEntry.cItem and jEntry.cItem.raw then + local newItem = new("Item", jEntry.cItem.raw) + newItem:NormaliseQuality() + + local bestImpactVal = nil + local bestSlotLabel = jEntry.label + + if jEntry.pNodeAllocated then + -- Socket is allocated in primary build, test directly in that socket + local output = calcFunc({ repSlotName = jEntry.cSlotName, repItem = newItem }, useFullDPS) + bestImpactVal = self:CalculatePowerStat(powerStat, output, calcBase) + else + -- Socket is NOT allocated in primary build; try the jewel in every + -- jewel socket on the primary build's tree, temporarily allocating + -- unallocated sockets via addNodes so CalcSetup doesn't skip them + for _, socketInfo in ipairs(primaryJewelSockets) do + local override = { repSlotName = socketInfo.slotName, repItem = newItem } + if not socketInfo.allocated then + override.addNodes = { [socketInfo.node] = true } + end + local output = calcFunc(override, useFullDPS) + local impact = self:CalculatePowerStat(powerStat, output, calcBase) + if bestImpactVal == nil or impact > bestImpactVal then + bestImpactVal = impact + bestSlotLabel = jEntry.label .. " (best socket)" + end + end + end + + if bestImpactVal ~= nil then + local impactStr, impactVal, combinedImpactStr, impactPercent = formatImpact(bestImpactVal) + local rarityColor = colorCodes[jEntry.cItem.rarity] or colorCodes.NORMAL + + t_insert(results, { + category = "Item", + categoryColor = colorCodes.NORMAL, + nameColor = rarityColor, + name = (jEntry.cItem.name or "Unknown") .. ", " .. bestSlotLabel, + impact = impactVal, + impactStr = impactStr, + impactPercent = impactPercent, + combinedImpactStr = combinedImpactStr, + pathDist = nil, + perPoint = nil, + perPointStr = nil, + }) + end + end + processed = processed + 1 + if coroutine.running() and GetTime() - start > 100 then + self.comparePowerProgress = m_floor(processed / total * 100) + coroutine.yield() + start = GetTime() + end + end + end + + -- ========================================== + -- Skill Gems (socket groups) -- ========================================== if categories.gems then local cGroups = compareEntry.skillsTab and compareEntry.skillsTab.socketGroupList or {} @@ -2191,7 +2348,7 @@ local function drawCopyButtons(cursorX, cursorY, vpWidth, btnY) SetDrawColor(1, 1, 1) DrawString(btn2X + btnW / 2, btnY + 1, "CENTER_X", 14, "VAR", "^7Copy+Use") - return b1Hover, b2Hover + return b1Hover, b2Hover, btn2X, btnY, btnW, btnH end -- Draw a single item's full details at (x, startY) within colWidth. @@ -2381,6 +2538,12 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) local clickedCopySlot = nil local clickedCopyUseSlot = nil + -- Track Copy+Use button hover for stat comparison tooltip + local hoverCopyUseItem = nil + local hoverCopyUseSlotName = nil + local hoverCopyUseBtnX, hoverCopyUseBtnY = 0, 0 + local hoverCopyUseBtnW, hoverCopyUseBtnH = 0, 0 + -- Headers SetDrawColor(1, 1, 1) DrawString(10, drawY, "LEFT", 18, "VAR", colorCodes.POSITIVE .. self:GetShortBuildName(self.primaryBuild.buildName)) @@ -2408,7 +2571,13 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) -- Copy buttons for compare item if cItem then - local b1Hover, b2Hover = drawCopyButtons(cursorX, cursorY, vp.width, drawY + 1) + local b1Hover, b2Hover, b2X, b2Y, b2W, b2H = drawCopyButtons(cursorX, cursorY, vp.width, drawY + 1) + if b2Hover then + hoverCopyUseItem = cItem + hoverCopyUseSlotName = slotName + hoverCopyUseBtnX, hoverCopyUseBtnY = b2X, b2Y + hoverCopyUseBtnW, hoverCopyUseBtnH = b2W, b2H + end if inputEvents then for id, event in ipairs(inputEvents) do if event.type == "KeyUp" and event.key == "LEFTBUTTON" then @@ -2505,7 +2674,13 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) -- Copy buttons for compare item if cItem then - local b1Hover, b2Hover = drawCopyButtons(cursorX, cursorY, vp.width, drawY) + local b1Hover, b2Hover, b2X, b2Y, b2W, b2H = drawCopyButtons(cursorX, cursorY, vp.width, drawY) + if b2Hover then + hoverCopyUseItem = cItem + hoverCopyUseSlotName = slotName + hoverCopyUseBtnX, hoverCopyUseBtnY = b2X, b2Y + hoverCopyUseBtnW, hoverCopyUseBtnH = b2W, b2H + end if inputEvents then for id, event in ipairs(inputEvents) do if event.type == "KeyUp" and event.key == "LEFTBUTTON" then @@ -2525,6 +2700,170 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) end end + -- === JEWELS SECTION === + local jewelSlots = self:GetJewelComparisonSlots(compareEntry) + if #jewelSlots > 0 then + -- Section header + drawY = drawY + 4 + SetDrawColor(0.5, 0.5, 0.5) + DrawImage(nil, 4, drawY, vp.width - 8, 1) + drawY = drawY + 4 + SetDrawColor(1, 1, 1) + DrawString(10, drawY, "LEFT", 16, "VAR", "^7-- Jewels --") + drawY = drawY + 20 + + for jIdx, jEntry in ipairs(jewelSlots) do + local pItem = jEntry.pItem + local cItem = jEntry.cItem + + -- Separator (skip before first jewel since section header already has one) + if jIdx > 1 then + SetDrawColor(0.3, 0.3, 0.3) + DrawImage(nil, 4, drawY, vp.width - 8, 1) + drawY = drawY + 2 + end + + -- Tree allocation warning text + local pWarn = (pItem and not jEntry.pNodeAllocated) and colorCodes.WARNING .. " (tree missing allocated node)" or "" + local cWarn = (cItem and not jEntry.cNodeAllocated) and colorCodes.WARNING .. " (tree missing allocated node)" or "" + + if self.itemsExpandedMode then + -- === EXPANDED MODE === + SetDrawColor(1, 1, 1) + DrawString(10, drawY, "LEFT", 16, "VAR", "^7" .. jEntry.label .. ":" .. pWarn) + DrawString(colWidth - 10, drawY, "RIGHT", 14, "VAR", getSlotDiffLabel(pItem, cItem)) + + -- Copy buttons for compare jewel + if cItem then + local b1Hover, b2Hover, b2X, b2Y, b2W, b2H = drawCopyButtons(cursorX, cursorY, vp.width, drawY + 1) + if b2Hover then + hoverCopyUseItem = cItem + hoverCopyUseSlotName = jEntry.pSlotName + hoverCopyUseBtnX, hoverCopyUseBtnY = b2X, b2Y + hoverCopyUseBtnW, hoverCopyUseBtnH = b2W, b2H + end + if inputEvents then + for id, event in ipairs(inputEvents) do + if event.type == "KeyUp" and event.key == "LEFTBUTTON" then + if b1Hover then + clickedCopySlot = jEntry.cSlotName + inputEvents[id] = nil + elseif b2Hover then + clickedCopyUseSlot = jEntry.pSlotName + inputEvents[id] = nil + end + end + end + end + end + + drawY = drawY + 20 + + -- Build mod maps for diff highlighting + local pModMap = buildModMap(pItem) + local cModMap = buildModMap(cItem) + + -- Draw both items expanded side by side + local itemStartY = drawY + local leftHeight = self:DrawItemExpanded(pItem, 20, drawY, colWidth - 30, cModMap) + local rightHeight = self:DrawItemExpanded(cItem, colWidth + 20, drawY, colWidth - 30, pModMap) + + -- Vertical separator between columns + SetDrawColor(0.25, 0.25, 0.25) + local maxH = m_max(leftHeight, rightHeight) + DrawImage(nil, colWidth, itemStartY, 1, maxH) + + drawY = drawY + maxH + 6 + else + -- === COMPACT MODE === + SetDrawColor(1, 1, 1) + DrawString(10, drawY, "LEFT", 16, "VAR", "^7" .. jEntry.label .. ":") + DrawString(colWidth - 10, drawY, "RIGHT", 14, "VAR", getSlotDiffLabel(pItem, cItem)) + + local pName = (pItem and pItem.name or "(empty)") .. pWarn + local cName = (cItem and cItem.name or "(empty)") .. cWarn + + local pColor = getRarityColor(pItem) + local cColor = getRarityColor(cItem) + + -- Measure text widths for precise hover detection + local pTextW = pItem and DrawStringWidth(16, "VAR", pColor .. pName) or 0 + local cTextW = cItem and DrawStringWidth(16, "VAR", cColor .. cName) or 0 + + drawY = drawY + 18 + + -- Check hover on primary jewel (left column) + local pHover = pItem and cursorX >= 18 and cursorX < 22 + pTextW + and cursorY >= drawY and cursorY < drawY + 18 + if pHover then + hoverItem = pItem + hoverX = 20 + hoverY = drawY + hoverW = pTextW + 4 + hoverH = 18 + hoverItemsTab = self.primaryBuild.itemsTab + end + + -- Check hover on compare jewel (right column) + local cHover = cItem and cursorX >= colWidth + 18 and cursorX < colWidth + 22 + cTextW + and cursorY >= drawY and cursorY < drawY + 18 + if cHover then + hoverItem = cItem + hoverX = colWidth + 20 + hoverY = drawY + hoverW = cTextW + 4 + hoverH = 18 + hoverItemsTab = compareEntry.itemsTab + end + + -- Draw hover border + if pHover then + SetDrawColor(0.5, 0.5, 0.5) + DrawImage(nil, 18, drawY - 1, pTextW + 4, 20) + SetDrawColor(0, 0, 0) + DrawImage(nil, 19, drawY, pTextW + 2, 18) + end + if cHover then + SetDrawColor(0.5, 0.5, 0.5) + DrawImage(nil, colWidth + 18, drawY - 1, cTextW + 4, 20) + SetDrawColor(0, 0, 0) + DrawImage(nil, colWidth + 19, drawY, cTextW + 2, 18) + end + + -- Draw jewel names + SetDrawColor(1, 1, 1) + DrawString(20, drawY, "LEFT", 16, "VAR", pColor .. pName) + DrawString(colWidth + 20, drawY, "LEFT", 16, "VAR", cColor .. cName) + + -- Copy buttons for compare jewel + if cItem then + local b1Hover, b2Hover, b2X, b2Y, b2W, b2H = drawCopyButtons(cursorX, cursorY, vp.width, drawY) + if b2Hover then + hoverCopyUseItem = cItem + hoverCopyUseSlotName = jEntry.pSlotName + hoverCopyUseBtnX, hoverCopyUseBtnY = b2X, b2Y + hoverCopyUseBtnW, hoverCopyUseBtnH = b2W, b2H + end + if inputEvents then + for id, event in ipairs(inputEvents) do + if event.type == "KeyUp" and event.key == "LEFTBUTTON" then + if b1Hover then + clickedCopySlot = jEntry.cSlotName + inputEvents[id] = nil + elseif b2Hover then + clickedCopyUseSlot = jEntry.pSlotName + inputEvents[id] = nil + end + end + end + end + end + + drawY = drawY + 20 + end + end + end + -- Process item copy button clicks if clickedCopySlot then self:CopyCompareItemToPrimary(clickedCopySlot, compareEntry, false) @@ -2541,6 +2880,52 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) SetDrawLayer(nil, 0) end + -- Draw stat comparison tooltip when hovering Copy+Use button + if hoverCopyUseItem and hoverCopyUseSlotName and not hoverItem then + self.itemTooltip:Clear() + local calcFunc, calcBase = self.calcs.getMiscCalculator(self.primaryBuild) + if calcFunc then + -- Create a fresh item to evaluate + local newItem = new("Item", hoverCopyUseItem.raw) + newItem:NormaliseQuality() + + -- Determine what's currently in the target slot + local pSlot = self.primaryBuild.itemsTab.slots[hoverCopyUseSlotName] + local selItem = pSlot and self.primaryBuild.itemsTab.items[pSlot.selItemId] + + -- For jewel sockets that aren't allocated, temporarily allocate the node + local override = { repSlotName = hoverCopyUseSlotName, repItem = newItem } + if pSlot and pSlot.nodeId then + local pSpec = self.primaryBuild.spec + if pSpec and pSpec.allocNodes and not pSpec.allocNodes[pSlot.nodeId] then + local node = pSpec.nodes[pSlot.nodeId] + if node then + override.addNodes = { [node] = true } + end + end + end + + local output = calcFunc(override) + local slotLabel = pSlot and pSlot.label or hoverCopyUseSlotName + local header + if selItem then + header = string.format("^7Equipping this item in %s will give you:\n(replacing %s%s^7)", slotLabel, colorCodes[selItem.rarity] or "^7", selItem.name) + else + header = string.format("^7Equipping this item in %s will give you:", slotLabel) + end + local count = self.primaryBuild:AddStatComparesToTooltip(self.itemTooltip, calcBase, output, header) + if count == 0 then + self.itemTooltip:AddLine(14, header) + self.itemTooltip:AddLine(14, "^7No changes.") + end + end + SetDrawLayer(nil, 100) + -- Force tooltip to the left of the button by passing a large width + -- so the right-side placement overflows and the Draw logic flips to left + self.itemTooltip:Draw(hoverCopyUseBtnX, hoverCopyUseBtnY, vp.width, hoverCopyUseBtnH, vp) + SetDrawLayer(nil, 0) + end + SetViewport() end diff --git a/src/Classes/PassiveTreeView.lua b/src/Classes/PassiveTreeView.lua index 9c4ce10dda..4c021c11da 100644 --- a/src/Classes/PassiveTreeView.lua +++ b/src/Classes/PassiveTreeView.lua @@ -97,6 +97,20 @@ function PassiveTreeViewClass:Save(xml) } end +-- Look up the jewel item socketed at a given node ID in a compare spec. +-- Uses itemsTab.sockets (the slot controls) which stay in sync with the active item/tree set. +function PassiveTreeViewClass:GetCompareJewel(nodeId) + if not self.compareSpec then return nil end + local cBuild = self.compareSpec.build + local cItemsTab = cBuild and cBuild.itemsTab + if not cItemsTab or not cItemsTab.sockets then return nil end + local cSocket = cItemsTab.sockets[nodeId] + if cSocket and cSocket.selItemId and cSocket.selItemId > 0 then + return cItemsTab.items[cSocket.selItemId] + end + return nil +end + function PassiveTreeViewClass:Draw(build, viewPort, inputEvents) local spec = build.spec local tree = spec.tree @@ -203,6 +217,7 @@ function PassiveTreeViewClass:Draw(build, viewPort, inputEvents) end local hoverNode + local hoverCompareNode -- Track compare-only node hover separately if mOver then -- Cursor is over the tree, check if it is over a node local curTreeX, curTreeY = screenToTree(cursorX, cursorY) @@ -217,6 +232,20 @@ function PassiveTreeViewClass:Draw(build, viewPort, inputEvents) end end end + -- If not hovering a primary node, check compare-only nodes (e.g. cluster jewel subgraph nodes) + if not hoverNode and self.compareSpec then + for nodeId, cNode in pairs(self.compareSpec.nodes) do + if not spec.nodes[nodeId] and cNode.alloc and cNode.rsq and cNode.x and cNode.y + and cNode.type ~= "ClassStart" and cNode.type ~= "AscendClassStart" then + local vX = curTreeX - cNode.x + local vY = curTreeY - cNode.y + if vX * vX + vY * vY <= cNode.rsq then + hoverCompareNode = cNode + break + end + end + end + end end self.hoverNode = hoverNode @@ -531,6 +560,16 @@ function PassiveTreeViewClass:Draw(build, viewPort, inputEvents) for _, subGraph in pairs(spec.subGraphs) do renderGroup(subGraph.group, true) end + -- Draw group backgrounds for compare-only subgraphs (cluster jewels only in compare build) + if self.compareSpec then + for subGraphId, subGraph in pairs(self.compareSpec.subGraphs) do + if not spec.subGraphs[subGraphId] then + SetDrawColor(0, 1, 0, 0.6) + renderGroup(subGraph.group, true) + SetDrawColor(1, 1, 1) + end + end + end local connectorColor = { 1, 1, 1 } local function setConnectorColor(r, g, b) @@ -602,6 +641,34 @@ function PassiveTreeViewClass:Draw(build, viewPort, inputEvents) renderConnector(connector) end end + -- Draw connectors for compare-only subgraphs (cluster jewels only in compare build) + if self.compareSpec then + for subGraphId, subGraph in pairs(self.compareSpec.subGraphs) do + if not spec.subGraphs[subGraphId] then + for _, connector in pairs(subGraph.connectors) do + local cNode1 = self.compareSpec.nodes[connector.nodeId1] + local cNode2 = self.compareSpec.nodes[connector.nodeId2] + if cNode1 and cNode2 and cNode1.alloc and cNode2.alloc and connector.vert then + local state = "Active" + local vert = connector.vert[state] or connector.vert["Normal"] + if vert then + connector.c = connector.c or {} + connector.c[1], connector.c[2] = treeToScreen(vert[1], vert[2]) + connector.c[3], connector.c[4] = treeToScreen(vert[3], vert[4]) + connector.c[5], connector.c[6] = treeToScreen(vert[5], vert[6]) + connector.c[7], connector.c[8] = treeToScreen(vert[7], vert[8]) + SetDrawColor(0, 1, 0) + local asset = tree.assets[connector.type..state] or tree.assets[connector.type.."Normal"] + if asset then + DrawImageQuad(asset.handle, unpack(connector.c)) + end + end + end + end + end + end + SetDrawColor(1, 1, 1) + end if self.showHeatMap then -- Build the power numbers if needed @@ -792,6 +859,18 @@ function PassiveTreeViewClass:Draw(build, viewPort, inputEvents) elseif node.type == "Mastery" and compareNode.alloc and node.alloc and node.sd ~= compareNode.sd then -- Node is a mastery, both have it allocated, but mastery changed, color it blue SetDrawColor(0, 0, 1) + elseif node.type == "Socket" and compareNode.alloc and node.alloc then + -- Both allocated socket, check if jewels differ + local pJewelId = spec.jewels[nodeId] + local pJewel = pJewelId and build.itemsTab.items[pJewelId] + local cJewel = self:GetCompareJewel(nodeId) + local pName = pJewel and pJewel.name or "" + local cName = cJewel and cJewel.name or "" + if pName ~= cName then + SetDrawColor(0, 0, 1) + else + SetDrawColor(nodeDefaultColor) + end else -- Both have or both have not SetDrawColor(nodeDefaultColor) @@ -820,10 +899,22 @@ function PassiveTreeViewClass:Draw(build, viewPort, inputEvents) elseif node.type == "Mastery" and compareNode.alloc and node.alloc and node.sd ~= compareNode.sd then -- Node is a mastery, both have it allocated, but mastery changed, color it blue SetDrawColor(0, 0, 1) + elseif node.type == "Socket" and compareNode.alloc and node.alloc then + -- Both allocated socket, check if jewels differ + local pJewelId = spec.jewels[nodeId] + local pJewel = pJewelId and build.itemsTab.items[pJewelId] + local cJewel = self:GetCompareJewel(nodeId) + local pName = pJewel and pJewel.name or "" + local cName = cJewel and cJewel.name or "" + if pName ~= cName then + SetDrawColor(0, 0, 1) + else + SetDrawColor(nodeDefaultColor) + end else -- Both have or both have not SetDrawColor(nodeDefaultColor) - end + end else SetDrawColor(nodeDefaultColor) end @@ -919,14 +1010,87 @@ function PassiveTreeViewClass:Draw(build, viewPort, inputEvents) -- Draw tooltip SetDrawLayer(nil, 100) local size = m_floor(node.size * scale) - if self.tooltip:CheckForUpdate(node, self.showStatDifferences, self.tracePath, launch.devModeAlt, build.outputRevision) then + if self.tooltip:CheckForUpdate(node, self.showStatDifferences, self.tracePath, launch.devModeAlt, build.outputRevision, self.compareSpec) then self:AddNodeTooltip(self.tooltip, node, build) end self.tooltip.center = true self.tooltip:Draw(m_floor(scrX - size), m_floor(scrY - size), size * 2, size * 2, viewPort) end end - + + -- Draw compare-only nodes (nodes in compareSpec but not in primary spec, e.g. cluster jewel subgraphs) + if self.compareSpec then + SetDrawLayer(nil, 25) + for nodeId, compareNode in pairs(self.compareSpec.nodes) do + if not spec.nodes[nodeId] and compareNode.alloc and compareNode.x and compareNode.y + and compareNode.type ~= "ClassStart" and compareNode.type ~= "AscendClassStart" then + local scrX, scrY = treeToScreen(compareNode.x, compareNode.y) + -- Draw base artwork with green coloring (compare-only = "added" nodes) + SetDrawColor(0, 1, 0) + local state = "alloc" + local base, overlay + if compareNode.type == "Socket" then + base = tree.assets[compareNode.overlay and compareNode.overlay[state .. (compareNode.expansionJewel and "Alt" or "")] or "JewelSocketActiveBlue"] + -- Look up jewel from compare build to show correct colored socket overlay + local cJewel = self:GetCompareJewel(nodeId) + if cJewel then + if cJewel.baseName == "Crimson Jewel" then + overlay = compareNode.expansionJewel and "JewelSocketActiveRedAlt" or "JewelSocketActiveRed" + elseif cJewel.baseName == "Viridian Jewel" then + overlay = compareNode.expansionJewel and "JewelSocketActiveGreenAlt" or "JewelSocketActiveGreen" + elseif cJewel.baseName == "Cobalt Jewel" then + overlay = compareNode.expansionJewel and "JewelSocketActiveBlueAlt" or "JewelSocketActiveBlue" + elseif cJewel.baseName == "Prismatic Jewel" then + overlay = compareNode.expansionJewel and "JewelSocketActivePrismaticAlt" or "JewelSocketActivePrismatic" + elseif cJewel.base and cJewel.base.subType == "Abyss" then + overlay = compareNode.expansionJewel and "JewelSocketActiveAbyssAlt" or "JewelSocketActiveAbyss" + elseif cJewel.baseName == "Timeless Jewel" then + overlay = compareNode.expansionJewel and "JewelSocketActiveLegionAlt" or "JewelSocketActiveLegion" + elseif cJewel.baseName == "Large Cluster Jewel" then + overlay = "JewelSocketActiveAltPurple" + elseif cJewel.baseName == "Medium Cluster Jewel" then + overlay = "JewelSocketActiveAltBlue" + elseif cJewel.baseName == "Small Cluster Jewel" then + overlay = "JewelSocketActiveAltRed" + end + end + elseif compareNode.type == "Mastery" then + if compareNode.masterySprites and compareNode.masterySprites.activeIcon then + base = compareNode.masterySprites.activeIcon.masteryActiveSelected + elseif compareNode.sprites then + base = compareNode.sprites.mastery + end + else + if compareNode.sprites then + base = compareNode.sprites[compareNode.type:lower() .. "Active"] + end + if compareNode.overlay then + local overlayKey = state .. (compareNode.ascendancyName and "Ascend" or "") .. (compareNode.isBlighted and "Blighted" or "") + overlay = compareNode.overlay[overlayKey] + end + end + if base then + self:DrawAsset(base, scrX, scrY, scale) + end + if overlay then + self:DrawAsset(tree.assets[overlay], scrX, scrY, scale) + end + SetDrawColor(1, 1, 1) + -- Draw tooltip for hovered compare-only node + if compareNode == hoverCompareNode and (compareNode.type ~= "Mastery" or compareNode.masteryEffects) and not IsKeyDown("CTRL") and not main.popups[1] then + SetDrawLayer(nil, 100) + local size = m_floor(compareNode.size * scale) + if self.tooltip:CheckForUpdate(compareNode, false, nil, launch.devModeAlt, build.outputRevision) then + self:AddCompareNodeTooltip(self.tooltip, compareNode, build) + end + self.tooltip.center = true + self.tooltip:Draw(m_floor(scrX - size), m_floor(scrY - size), size * 2, size * 2, viewPort) + SetDrawLayer(nil, 25) + end + end + end + end + -- Draw ring overlays for jewel sockets SetDrawLayer(nil, 25) for nodeId in pairs(tree.sockets) do @@ -1231,6 +1395,19 @@ function PassiveTreeViewClass:AddNodeTooltip(tooltip, node, build) else self:AddNodeName(tooltip, node, build) end + -- Show compare build's jewel info when in overlay compare mode + if self.compareSpec then + local cJewel = self:GetCompareJewel(node.id) + local cAllocated = self.compareSpec.allocNodes and self.compareSpec.allocNodes[node.id] + if cJewel then + tooltip:AddSeparator(14) + tooltip:AddLine(16, colorCodes.WARNING .. "Compared build jewel:") + tooltip:AddLine(16, (cJewel.rarity == "UNIQUE" and colorCodes.UNIQUE or cJewel.rarity == "RARE" and colorCodes.RARE or cJewel.rarity == "MAGIC" and colorCodes.MAGIC or "^7") .. cJewel.name) + elseif cAllocated then + tooltip:AddSeparator(14) + tooltip:AddLine(16, colorCodes.WARNING .. "Compared build: ^7(empty socket)") + end + end tooltip:AddSeparator(14) if socket:IsEnabled() then tooltip:AddLine(14, colorCodes.TIP.."Tip: Right click this socket to go to the items page and choose the jewel for this socket.") @@ -1239,6 +1416,21 @@ function PassiveTreeViewClass:AddNodeTooltip(tooltip, node, build) return end + -- For unallocated sockets, show compare build's jewel if it has one + if node.type == "Socket" and not node.alloc and self.compareSpec then + local cJewel = self:GetCompareJewel(node.id) + local cItemsTab = self.compareSpec.build and self.compareSpec.build.itemsTab + local cAllocated = self.compareSpec.allocNodes and self.compareSpec.allocNodes[node.id] + if cJewel and cAllocated then + -- Show the compare build's jewel tooltip instead of generic socket info + cItemsTab:AddItemTooltip(tooltip, cJewel, { nodeId = node.id }) + tooltip:AddSeparator(14) + tooltip:AddLine(14, colorCodes.DEXTERITY .. "Jewel from compared build") + tooltip:AddLine(14, colorCodes.TIP.."Tip: Hold Shift or Ctrl to hide this tooltip.") + return + end + end + -- Node name self:AddNodeName(tooltip, node, build) tooltip.center = false @@ -1447,3 +1639,89 @@ function PassiveTreeViewClass:AddNodeTooltip(tooltip, node, build) tooltip:AddLine(14, colorCodes.TIP.."Tip: Press Ctrl+C to copy this node's text.") end end + +function PassiveTreeViewClass:AddCompareNodeTooltip(tooltip, node, build) + -- Tooltip for compare-only nodes (nodes only in the compared build, e.g. cluster jewel subgraph nodes) + local fontSizeBig = main.showFlavourText and 18 or 16 + tooltip.center = true + tooltip.maxWidth = 800 + + -- Special case for sockets with jewels + if node.type == "Socket" and node.alloc then + local cJewel = self:GetCompareJewel(node.id) + local cItemsTab = self.compareSpec.build and self.compareSpec.build.itemsTab + if cJewel and cItemsTab then + cItemsTab:AddItemTooltip(tooltip, cJewel, { nodeId = node.id }) + else + self:AddCompareNodeName(tooltip, node) + end + tooltip:AddSeparator(14) + tooltip:AddLine(14, colorCodes.DEXTERITY .. "This node is only in the compared build") + return + end + + -- Node name + self:AddCompareNodeName(tooltip, node) + tooltip.center = false + + -- Node mods + if node.sd and node.sd[1] then + tooltip:AddLine(16, "") + for i, line in ipairs(node.sd) do + if node.mods and node.mods[i] then + if line ~= " " and (node.mods[i].extra or not node.mods[i].list) then + tooltip:AddLine(fontSizeBig, colorCodes.UNSUPPORTED..line, "FONTIN") + else + tooltip:AddLine(fontSizeBig, colorCodes.MAGIC..line, "FONTIN") + end + else + tooltip:AddLine(fontSizeBig, colorCodes.MAGIC..line, "FONTIN") + end + end + end + + -- Reminder text + if node.reminderText then + tooltip:AddSeparator(14) + for _, line in ipairs(node.reminderText) do + tooltip:AddLine(14, "^xA0A080"..line) + end + end + + -- Flavour text + if node.flavourText and main.showFlavourText then + tooltip:AddSeparator(14) + for _, line in ipairs(node.flavourText) do + tooltip:AddLine(fontSizeBig, colorCodes.UNIQUE..line, "FONTIN ITALIC") + end + end + + tooltip:AddSeparator(14) + tooltip:AddLine(14, colorCodes.DEXTERITY .. "This node is only in the compared build") +end + +function PassiveTreeViewClass:AddCompareNodeName(tooltip, node) + tooltip:SetRecipe(node.recipe) + local tooltipMap = { + Normal = "PASSIVE", + Notable = "NOTABLE", + Socket = "JEWEL", + Keystone = "KEYSTONE", + Ascendancy = "ASCENDANCY", + Mastery = "MASTERY", + } + if node.type == "Mastery" then + tooltip.tooltipHeader = node.alloc and "MASTERYALLOC" or "MASTERY" + elseif (node.type == "Notable" or node.type == "Normal") and node.ascendancyName then + tooltip.tooltipHeader = "ASCENDANCY" + else + tooltip.tooltipHeader = tooltipMap[node.type] or "UNKNOWN" + end + local nodeName = node.dn + if main.showFlavourText then + nodeName = "^xF8E6CA" .. node.dn + end + tooltip.center = true + tooltip:AddLine(24, nodeName..(launch.devModeAlt and " ["..node.id.."]" or ""), "FONTIN") + tooltip.center = false +end From 05e02cf6c1705ecf7441816240248aeb90c26e0a Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Sun, 22 Mar 2026 10:29:15 +0100 Subject: [PATCH 15/59] included config options in the comparative power report --- src/Classes/CompareTab.lua | 114 ++++++++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 3 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 7f902fd40c..4d803d66e8 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -119,7 +119,7 @@ local CompareTabClass = newClass("CompareTab", "ControlHost", "Control", functio -- Compare power report state self.comparePowerStat = nil -- selected data.powerStatList entry - self.comparePowerCategories = { treeNodes = true, items = true, gems = true } + self.comparePowerCategories = { treeNodes = true, items = true, gems = true, config = true } self.comparePowerResults = nil -- sorted list of result entries self.comparePowerCoroutine = nil -- active coroutine self.comparePowerProgress = 0 -- 0-100 @@ -597,6 +597,13 @@ function CompareTabClass:InitControls() self.controls.comparePowerGemsCheck.shown = powerReportShown self.controls.comparePowerGemsCheck.state = true + self.controls.comparePowerConfigCheck = new("CheckBoxControl", nil, {0, 0, 18}, "Config:", function(state) + self.comparePowerCategories.config = state + self.comparePowerDirty = true + end, "Include config option differences from compared build") + self.controls.comparePowerConfigCheck.shown = powerReportShown + self.controls.comparePowerConfigCheck.state = true + -- Power report list control (static height, own scrollbar) self.controls.comparePowerReportList = new("ComparePowerReportListControl", nil, {0, 0, 750, 250}) self.controls.comparePowerReportList.shown = powerReportShown @@ -736,6 +743,17 @@ function CompareTabClass:FormatConfigValue(varData, val) end end +-- Normalize config values so that functionally equivalent states compare equal +-- (nil/false for checks, nil/0 for counts/integers/floats) +function CompareTabClass:NormalizeConfigVals(varData, pVal, cVal) + if varData.type == "check" then + return pVal or false, cVal or false + elseif varData.type == "count" or varData.type == "integer" or varData.type == "float" then + return pVal or 0, cVal or 0 + end + return pVal, cVal +end + -- Rebuild interactive config controls for all config options function CompareTabClass:RebuildConfigControls(compareEntry) -- Remove old config controls @@ -1652,6 +1670,18 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories local cGroups = compareEntry.skillsTab and compareEntry.skillsTab.socketGroupList or {} total = total + #cGroups end + if categories.config then + local pInput = self.primaryBuild.configTab.input or {} + local cInput = compareEntry.configTab.input or {} + for _, varData in ipairs(self.configOptions) do + if varData.var and varData.apply and varData.type ~= "text" then + local pVal, cVal = self:NormalizeConfigVals(varData, pInput[varData.var], cInput[varData.var]) + if pVal ~= cVal then + total = total + 1 + end + end + end + end if total == 0 then self.comparePowerResults = results @@ -1769,7 +1799,7 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories t_insert(results, { category = "Item", - categoryColor = colorCodes.NORMAL, + categoryColor = rarityColor, nameColor = rarityColor, name = (cItem.name or "Unknown") .. ", " .. slotName, impact = impactVal, @@ -1850,7 +1880,7 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories t_insert(results, { category = "Item", - categoryColor = colorCodes.NORMAL, + categoryColor = rarityColor, nameColor = rarityColor, name = (jEntry.cItem.name or "Unknown") .. ", " .. bestSlotLabel, impact = impactVal, @@ -1926,6 +1956,80 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories end end + -- ========================================== + -- Config Options + -- ========================================== + if categories.config then + local pInput = self.primaryBuild.configTab.input + local cInput = compareEntry.configTab.input or {} + + local function stripColors(s) + return s:gsub("%^%x", ""):gsub("%^x%x%x%x%x%x%x", "") + end + + for _, varData in ipairs(self.configOptions) do + if varData.var and varData.apply and varData.type ~= "text" then + local pVal = pInput[varData.var] + local cVal = cInput[varData.var] + local pNorm, cNorm = self:NormalizeConfigVals(varData, pVal, cVal) + + if pNorm ~= cNorm then + -- Save original value + local savedVal = pInput[varData.var] + + -- Apply compare build's config value + pInput[varData.var] = cVal + + -- Rebuild the mod list with the new config value + self.primaryBuild.configTab:BuildModList() + self.primaryBuild.buildFlag = true + + -- Get a fresh calculator with the changed config + local cfgCalcFunc, cfgCalcBase = self.calcs.getMiscCalculator(self.primaryBuild) + local impact = self:CalculatePowerStat(powerStat, cfgCalcBase, calcBase) + + -- Restore original value + pInput[varData.var] = savedVal + self.primaryBuild.configTab:BuildModList() + self.primaryBuild.buildFlag = true + + local impactStr, impactVal, combinedImpactStr, impactPercent = formatImpact(impact) + + -- Only include configs with non-zero impact + if impactVal ~= 0 then + -- Build display name with value change description + local displayName = varData.label or varData.var + displayName = displayName:gsub(":$", "") + + local pDisplay = stripColors(self:FormatConfigValue(varData, pVal)) + local cDisplay = stripColors(self:FormatConfigValue(varData, cVal)) + + t_insert(results, { + category = "Config", + categoryColor = colorCodes.FRACTURED, + nameColor = "^7", + name = displayName .. " (" .. pDisplay .. " -> " .. cDisplay .. ")", + impact = impactVal, + impactStr = impactStr, + impactPercent = impactPercent, + combinedImpactStr = combinedImpactStr, + pathDist = nil, + perPoint = nil, + perPointStr = nil, + }) + end + + processed = processed + 1 + if coroutine.running() and GetTime() - start > 100 then + self.comparePowerProgress = m_floor(processed / total * 100) + coroutine.yield() + start = GetTime() + end + end + end + end + end + self.comparePowerResults = results self.comparePowerProgress = 100 end @@ -2046,6 +2150,10 @@ function CompareTabClass:DrawSummary(vp, compareEntry) self.controls.comparePowerGemsCheck.x = checkX + self.controls.comparePowerGemsCheck.labelWidth self.controls.comparePowerGemsCheck.y = controlY + checkX = checkX + self.controls.comparePowerGemsCheck.labelWidth + 26 + + self.controls.comparePowerConfigCheck.x = checkX + self.controls.comparePowerConfigCheck.labelWidth + self.controls.comparePowerConfigCheck.y = controlY drawY = drawY + 28 From a2038e633addee63e9f19c806da246a8f16df739 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Sun, 22 Mar 2026 10:59:25 +0100 Subject: [PATCH 16/59] add indicator on gems in similar gem groups if they are mismatching --- src/Classes/CompareTab.lua | 52 +++++++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 4d803d66e8..38bf01832c 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -3141,6 +3141,11 @@ function CompareTabClass:DrawSkills(vp, compareEntry) DrawImage(nil, 4, drawY, vp.width - 8, 1) drawY = drawY + 2 + local pSet = pair.pIdx and pSets[pair.pIdx] or {} + local cSet = pair.cIdx and cSets[pair.cIdx] or {} + local pFinalGemY = drawY + lineHeight + local cFinalGemY = drawY + lineHeight + -- Primary group (left side) local pGroup = pair.pIdx and pGroups[pair.pIdx] if pGroup then @@ -3155,9 +3160,28 @@ function CompareTabClass:DrawSkills(vp, compareEntry) local gemColor = gem.color or colorCodes.GEM local levelStr = gem.level and (" Lv" .. gem.level) or "" local qualStr = gem.quality and gem.quality > 0 and ("/" .. gem.quality .. "q") or "" - DrawString(20, gemY, "LEFT", 14, "VAR", gemColor .. gemName .. "^7" .. levelStr .. qualStr) + local prefix = "" + if pair.cIdx and not cSet[gemName] then + prefix = colorCodes.POSITIVE .. "+ " + end + DrawString(20, gemY, "LEFT", 14, "VAR", prefix .. gemColor .. gemName .. "^7" .. levelStr .. qualStr) gemY = gemY + 16 end + -- Show gems missing from primary but present in compare + if pair.cIdx then + local missing = {} + for name in pairs(cSet) do + if not pSet[name] then + t_insert(missing, name) + end + end + table.sort(missing) + for _, name in ipairs(missing) do + DrawString(20, gemY, "LEFT", 14, "VAR", colorCodes.NEGATIVE .. "- " .. name .. "^7") + gemY = gemY + 16 + end + end + pFinalGemY = gemY end -- Compare group (right side) @@ -3174,16 +3198,32 @@ function CompareTabClass:DrawSkills(vp, compareEntry) local gemColor = gem.color or colorCodes.GEM local levelStr = gem.level and (" Lv" .. gem.level) or "" local qualStr = gem.quality and gem.quality > 0 and ("/" .. gem.quality .. "q") or "" - DrawString(colWidth + 20, gemY, "LEFT", 14, "VAR", gemColor .. gemName .. "^7" .. levelStr .. qualStr) + local prefix = "" + if pair.pIdx and not pSet[gemName] then + prefix = colorCodes.POSITIVE .. "+ " + end + DrawString(colWidth + 20, gemY, "LEFT", 14, "VAR", prefix .. gemColor .. gemName .. "^7" .. levelStr .. qualStr) gemY = gemY + 16 end + -- Show gems missing from compare but present in primary + if pair.pIdx then + local missing = {} + for name in pairs(pSet) do + if not cSet[name] then + t_insert(missing, name) + end + end + table.sort(missing) + for _, name in ipairs(missing) do + DrawString(colWidth + 20, gemY, "LEFT", 14, "VAR", colorCodes.NEGATIVE .. "- " .. name .. "^7") + gemY = gemY + 16 + end + end + cFinalGemY = gemY end -- Calculate height for this row - local pGemCount = pGroup and #(pGroup.gemList or {}) or 0 - local cGemCount = cGroup and #(cGroup.gemList or {}) or 0 - local rowGems = m_max(pGemCount, cGemCount) - drawY = drawY + lineHeight + rowGems * 16 + 6 + drawY = drawY + m_max(pFinalGemY - drawY, cFinalGemY - drawY) + 6 end SetViewport() From a2344814d9f758d59839b9275ad17789f11b573f Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Mon, 23 Mar 2026 00:19:20 +0100 Subject: [PATCH 17/59] add functionality to buy similar items --- src/Classes/CompareTab.lua | 522 ++++++++++++++++++++++++++++++++++++- 1 file changed, 510 insertions(+), 12 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 38bf01832c..9049132a6d 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -9,6 +9,21 @@ local m_min = math.min local m_max = math.max local m_floor = math.floor local s_format = string.format +local dkjson = require "dkjson" +local queryModsData = LoadModule("Data/QueryMods") + +-- Forward declarations for trade helper functions (defined later in the file) +local findTradeModId +local getTradeCategory +local getTradeCategoryLabel +local modLineValue + +-- Realm display name to API id mapping (used by Buy Similar popup and URL builder) +local REALM_API_IDS = { + ["PC"] = "pc", + ["PS4"] = "sony", + ["Xbox"] = "xbox", +} -- Layout constants (shared across Draw, DrawConfig, DrawItems, DrawCalcs, etc.) local LAYOUT = { @@ -30,6 +45,7 @@ local LAYOUT = { itemsCheckboxOffset = 36, itemsCopyBtnW = 60, itemsCopyBtnH = 18, + itemsBuyBtnW = 60, -- Calcs view calcsMaxCardWidth = 400, @@ -1062,6 +1078,361 @@ function CompareTabClass:CopyCompareItemToPrimary(slotName, compareEntry, andUse self.primaryBuild.buildFlag = true end +-- Helper: create a numeric EditControl without +/- spinner buttons +local function newPlainNumericEdit(anchor, rect, init, prompt, limit) + local ctrl = new("EditControl", anchor, rect, init, prompt, "%D", limit) + -- Remove the +/- spinner buttons that "%D" filter triggers + ctrl.isNumeric = false + if ctrl.controls then + if ctrl.controls.buttonDown then ctrl.controls.buttonDown.shown = false end + if ctrl.controls.buttonUp then ctrl.controls.buttonUp.shown = false end + end + return ctrl +end + +-- Open the Buy Similar popup for a compared item +function CompareTabClass:OpenBuySimilarPopup(item, slotName) + if not item then return end + + local isUnique = item.rarity == "UNIQUE" or item.rarity == "RELIC" + local controls = {} + local rowHeight = 24 + local popupWidth = 550 + local leftMargin = 20 + local minFieldX = popupWidth - 160 + local maxFieldX = popupWidth - 80 + local fieldW = 60 + local fieldH = 20 + local checkboxSize = 20 + + -- Collect mod entries with trade IDs + local modEntries = {} + local modTypeSources = { + { list = item.implicitModLines, type = "implicit" }, + { list = item.enchantModLines, type = "enchant" }, + { list = item.scourgeModLines, type = "explicit" }, + { list = item.explicitModLines, type = "explicit" }, + { list = item.crucibleModLines, type = "explicit" }, + } + for _, source in ipairs(modTypeSources) do + if source.list then + for _, modLine in ipairs(source.list) do + if item:CheckModLineVariant(modLine) then + local formatted = itemLib.formatModLine(modLine) + if formatted then + local tradeId = findTradeModId(modLine.line, source.type) + local value = modLineValue(modLine.line) + t_insert(modEntries, { + line = modLine.line, + formatted = formatted:gsub("%^x%x%x%x%x%x%x", ""):gsub("%^%x", ""), -- strip color codes + tradeId = tradeId, + value = value, + modType = source.type, + }) + end + end + end + end + end + + -- Collect defence stats for non-unique gear items + local defenceEntries = {} + if not isUnique and item.armourData and item.base and item.base.armour then + local defences = { + { key = "Armour", label = "Armour", tradeKey = "ar" }, + { key = "Evasion", label = "Evasion", tradeKey = "ev" }, + { key = "EnergyShield", label = "Energy Shield", tradeKey = "es" }, + { key = "Ward", label = "Ward", tradeKey = "ward" }, + } + for _, def in ipairs(defences) do + local val = item.armourData[def.key] + if val and val > 0 then + t_insert(defenceEntries, { + label = def.label, + value = val, + tradeKey = def.tradeKey, + }) + end + end + end + + -- Build controls + local ctrlY = 25 + + -- Realm and league dropdowns + local tradeQuery = self.primaryBuild.itemsTab and self.primaryBuild.itemsTab.tradeQuery + local tradeQueryRequests = tradeQuery and tradeQuery.tradeQueryRequests + if not tradeQueryRequests then + tradeQueryRequests = new("TradeQueryRequests") + end + + -- Helper to fetch and populate leagues for a given realm API id + local function fetchLeaguesForRealm(realmApiId) + controls.leagueDrop:SetList({"Loading..."}) + controls.leagueDrop.selIndex = 1 + tradeQueryRequests:FetchLeagues(realmApiId, function(leagues, errMsg) + if errMsg then + controls.leagueDrop:SetList({"Standard"}) + return + end + local leagueList = {} + for _, league in ipairs(leagues) do + if league ~= "Standard" and league ~= "Ruthless" and league ~= "Hardcore" and league ~= "Hardcore Ruthless" then + if not (league:find("Hardcore") or league:find("Ruthless")) then + t_insert(leagueList, 1, league) + else + t_insert(leagueList, league) + end + end + end + t_insert(leagueList, "Standard") + t_insert(leagueList, "Hardcore") + t_insert(leagueList, "Ruthless") + t_insert(leagueList, "Hardcore Ruthless") + controls.leagueDrop:SetList(leagueList) + end) + end + + -- Realm dropdown + controls.realmLabel = new("LabelControl", {"TOPLEFT", nil, "TOPLEFT"}, {leftMargin, ctrlY, 0, 16}, "^7Realm:") + controls.realmDrop = new("DropDownControl", {"LEFT", controls.realmLabel, "RIGHT"}, {4, 0, 80, 20}, {"PC", "PS4", "Xbox"}, function(index, value) + local realmApiId = REALM_API_IDS[value] or "pc" + fetchLeaguesForRealm(realmApiId) + end) + + -- League dropdown + controls.leagueLabel = new("LabelControl", {"LEFT", controls.realmDrop, "RIGHT"}, {12, 0, 0, 16}, "^7League:") + controls.leagueDrop = new("DropDownControl", {"LEFT", controls.leagueLabel, "RIGHT"}, {4, 0, 160, 20}, {"Loading..."}, function(index, value) + -- League selection stored in the dropdown itself + end) + controls.leagueDrop.enabled = function() return #controls.leagueDrop.list > 0 and controls.leagueDrop.list[1] ~= "Loading..." end + + -- Fetch initial leagues for default realm + fetchLeaguesForRealm("pc") + ctrlY = ctrlY + rowHeight + 4 + + if isUnique then + -- Unique item name label + controls.nameLabel = new("LabelControl", nil, {0, ctrlY, 0, 16}, "^x" .. (colorCodes[item.rarity] or "FFFFFF"):gsub("%^x","") .. item.name) + ctrlY = ctrlY + rowHeight + else + -- Category label + local categoryLabel = getTradeCategoryLabel(slotName, item) + controls.categoryLabel = new("LabelControl", {"TOPLEFT", nil, "TOPLEFT"}, {leftMargin, ctrlY, 0, 16}, "^7Category: " .. categoryLabel) + ctrlY = ctrlY + rowHeight + + -- Base type checkbox + controls.baseTypeCheck = new("CheckBoxControl", nil, {-popupWidth/2 + leftMargin + checkboxSize/2, ctrlY, checkboxSize}, "", nil, nil) + controls.baseTypeLabel = new("LabelControl", {"LEFT", controls.baseTypeCheck, "RIGHT"}, {4, 0, 0, 16}, "^7Use specific base: " .. (item.baseName or "Unknown")) + ctrlY = ctrlY + rowHeight + + -- Item level + ctrlY = ctrlY + 4 + controls.ilvlLabel = new("LabelControl", {"TOPLEFT", nil, "TOPLEFT"}, {leftMargin, ctrlY, 0, 16}, "^7Item Level:") + controls.ilvlMin = newPlainNumericEdit(nil, {minFieldX - popupWidth/2, ctrlY, fieldW, fieldH}, "", "Min", 4) + controls.ilvlMax = newPlainNumericEdit(nil, {maxFieldX - popupWidth/2, ctrlY, fieldW, fieldH}, "", "Max", 4) + ctrlY = ctrlY + rowHeight + + -- Defence stat rows + for i, def in ipairs(defenceEntries) do + local prefix = "def" .. i + controls[prefix .. "Check"] = new("CheckBoxControl", nil, {-popupWidth/2 + leftMargin + checkboxSize/2, ctrlY, checkboxSize}, "", nil, nil) + controls[prefix .. "Label"] = new("LabelControl", {"LEFT", controls[prefix .. "Check"], "RIGHT"}, {4, 0, 0, 16}, "^7" .. def.label) + controls[prefix .. "Min"] = newPlainNumericEdit(nil, {minFieldX - popupWidth/2, ctrlY, fieldW, fieldH}, tostring(m_floor(def.value)), "Min", 6) + controls[prefix .. "Max"] = newPlainNumericEdit(nil, {maxFieldX - popupWidth/2, ctrlY, fieldW, fieldH}, "", "Max", 6) + ctrlY = ctrlY + rowHeight + end + + -- Separator between defence stats and mods + if #defenceEntries > 0 then + ctrlY = ctrlY + 8 + end + end + + -- Mod rows + for i, entry in ipairs(modEntries) do + local prefix = "mod" .. i + local canSearch = entry.tradeId ~= nil + controls[prefix .. "Check"] = new("CheckBoxControl", nil, {-popupWidth/2 + leftMargin + checkboxSize/2, ctrlY, checkboxSize}, "", nil, nil) + controls[prefix .. "Check"].enabled = function() return canSearch end + -- Truncate long mod text to fit + local displayText = entry.formatted + if #displayText > 45 then + displayText = displayText:sub(1, 42) .. "..." + end + controls[prefix .. "Label"] = new("LabelControl", {"LEFT", controls[prefix .. "Check"], "RIGHT"}, {4, 0, 0, 16}, (canSearch and "^7" or "^8") .. displayText) + controls[prefix .. "Min"] = newPlainNumericEdit(nil, {minFieldX - popupWidth/2, ctrlY, fieldW, fieldH}, entry.value ~= 0 and tostring(m_floor(entry.value)) or "", "Min", 8) + controls[prefix .. "Max"] = newPlainNumericEdit(nil, {maxFieldX - popupWidth/2, ctrlY, fieldW, fieldH}, "", "Max", 8) + if not canSearch then + controls[prefix .. "Min"].enabled = function() return false end + controls[prefix .. "Max"].enabled = function() return false end + end + ctrlY = ctrlY + rowHeight + end + + -- Search button + ctrlY = ctrlY + 8 + controls.search = new("ButtonControl", nil, {0, ctrlY, 100, 20}, "Generate URL", function() + local success, result = pcall(function() + return self:BuildBuySimilarURL(item, slotName, controls, modEntries, defenceEntries, isUnique) + end) + if success and result then + controls.uri:SetText(result, true) + elseif not success then + controls.uri:SetText("Error: " .. tostring(result), true) + else + controls.uri:SetText("Error: could not determine league", true) + end + end) + ctrlY = ctrlY + rowHeight + 4 + + -- URL field + controls.uri = new("EditControl", nil, {-30, ctrlY, popupWidth - 100, fieldH}, "", nil, "^%C\t\n") + controls.uri:SetPlaceholder("Press 'Generate URL' then Ctrl+Click to open") + controls.uri.tooltipFunc = function(tooltip) + tooltip:Clear() + if controls.uri.buf and controls.uri.buf ~= "" then + tooltip:AddLine(16, "^7Ctrl + Click to open in web browser") + end + end + controls.close = new("ButtonControl", nil, {popupWidth/2 - 50, ctrlY, 60, 20}, "Close", function() + main:ClosePopup() + end) + + -- Calculate popup height from final control position + local popupHeight = ctrlY + fieldH + 16 + if popupHeight > 600 then popupHeight = 600 end + + local title = "Buy Similar" + main:OpenPopup(popupWidth, popupHeight, title, controls, "search", nil, "close") +end + +-- Build the trade search URL based on popup selections +function CompareTabClass:BuildBuySimilarURL(item, slotName, controls, modEntries, defenceEntries, isUnique) + -- Determine realm and league from the popup's dropdowns + local realmDisplayValue = controls.realmDrop and controls.realmDrop:GetSelValue() or "PC" + local realm = REALM_API_IDS[realmDisplayValue] or "pc" + local league = controls.leagueDrop and controls.leagueDrop:GetSelValue() + if not league or league == "" or league == "Loading..." then + league = "Standard" + end + local hostName = "https://www.pathofexile.com/" + + -- Build query + local queryTable = { + query = { + status = { option = "online" }, + stats = { + { + type = "and", + filters = {} + } + }, + }, + sort = { price = "asc" } + } + local queryFilters = {} + + if isUnique then + -- Search by unique name + -- Strip "Foulborn" prefix from unique name for trade search + local tradeName = (item.title or item.name):gsub("^Foulborn%s+", "") + queryTable.query.name = tradeName + queryTable.query.type = item.baseName + -- If item is Foulborn, add the foulborn_item filter + if item.foulborn then + queryFilters.misc_filters = queryFilters.misc_filters or { filters = {} } + queryFilters.misc_filters.filters.foulborn_item = { option = "true" } + end + else + -- Category filter + local categoryStr = getTradeCategory(slotName, item) + if categoryStr then + queryFilters.type_filters = { + filters = { + category = { option = categoryStr } + } + } + end + + -- Base type filter + if controls.baseTypeCheck and controls.baseTypeCheck.state then + queryTable.query.type = item.baseName + end + + -- Item level filter + local ilvlMin = controls.ilvlMin and tonumber(controls.ilvlMin.buf) + local ilvlMax = controls.ilvlMax and tonumber(controls.ilvlMax.buf) + if ilvlMin or ilvlMax then + local ilvlFilter = {} + if ilvlMin then ilvlFilter.min = ilvlMin end + if ilvlMax then ilvlFilter.max = ilvlMax end + queryFilters.misc_filters = { + filters = { + ilvl = ilvlFilter + } + } + end + + -- Defence stat filters + local armourFilters = {} + for i, def in ipairs(defenceEntries) do + local prefix = "def" .. i + if controls[prefix .. "Check"] and controls[prefix .. "Check"].state then + local minVal = tonumber(controls[prefix .. "Min"].buf) + local maxVal = tonumber(controls[prefix .. "Max"].buf) + local filter = {} + if minVal then filter.min = minVal end + if maxVal then filter.max = maxVal end + if minVal or maxVal then + armourFilters[def.tradeKey] = filter + end + end + end + if next(armourFilters) then + queryFilters.armour_filters = { + filters = armourFilters + } + end + end + + -- Mod filters + for i, entry in ipairs(modEntries) do + local prefix = "mod" .. i + if entry.tradeId and controls[prefix .. "Check"] and controls[prefix .. "Check"].state then + local minVal = tonumber(controls[prefix .. "Min"].buf) + local maxVal = tonumber(controls[prefix .. "Max"].buf) + local filter = { id = entry.tradeId } + local value = {} + if minVal then value.min = minVal end + if maxVal then value.max = maxVal end + if next(value) then + filter.value = value + end + t_insert(queryTable.query.stats[1].filters, filter) + end + end + + -- Only include filters if we have any + if next(queryFilters) then + queryTable.query.filters = queryFilters + end + + -- Build URL + local queryJson = dkjson.encode(queryTable) + local url = hostName .. "trade/search" + if realm and realm ~= "" and realm ~= "pc" then + url = url .. "/" .. realm + end + local encodedLeague = league:gsub("[^%w%-%.%_%~]", function(c) + return string.format("%%%02X", string.byte(c)) + end):gsub(" ", "+") + url = url .. "/" .. encodedLeague + url = url .. "?q=" .. urlEncode(queryJson) + + return url +end + -- Open the import popup for adding a comparison build function CompareTabClass:OpenImportPopup() local controls = {} @@ -2389,10 +2760,102 @@ local function modLineTemplate(line) end -- Helper: extract the first number from a mod line for value comparison -local function modLineValue(line) +modLineValue = function(line) return tonumber(line:match("[%d]+%.?[%d]*")) or 0 end +-- Helper: lazily build a reverse lookup from QueryMods tradeMod.text → tradeMod.id +local _tradeModLookup = nil +local function getTradeModLookup() + if _tradeModLookup then return _tradeModLookup end + _tradeModLookup = {} + if not queryModsData then return _tradeModLookup end + for _groupName, mods in pairs(queryModsData) do + for _modKey, modData in pairs(mods) do + if type(modData) == "table" and modData.tradeMod then + local tmpl = modData.tradeMod.text + local modType = modData.tradeMod.type or "explicit" + local key = tmpl .. "|" .. modType + _tradeModLookup[key] = modData.tradeMod.id + -- Also store without type for fallback matching + if not _tradeModLookup[tmpl] then + _tradeModLookup[tmpl] = modData.tradeMod.id + end + end + end + end + return _tradeModLookup +end + +-- Helper: find the trade stat ID for a mod line +findTradeModId = function(modLine, modType) + local lookup = getTradeModLookup() + local tmpl = modLineTemplate(modLine) + -- Try exact match with type first + local key = tmpl .. "|" .. modType + if lookup[key] then + return lookup[key] + end + -- Try without leading +/- sign + local stripped = tmpl:gsub("^[%+%-]", "") + key = stripped .. "|" .. modType + if lookup[key] then + return lookup[key] + end + -- Fallback: match by template text only (any type) + if lookup[tmpl] then + return lookup[tmpl] + end + if lookup[stripped] then + return lookup[stripped] + end + return nil +end + +-- Helper: map slot name + item type to trade API category string +getTradeCategory = function(slotName, item) + if not item or not item.base then return nil end + local itemType = item.type or (item.base and item.base.type) + if slotName:find("^Weapon %d") then + if itemType == "Shield" then return "armour.shield" + elseif itemType == "Quiver" then return "armour.quiver" + elseif itemType == "Bow" then return "weapon.bow" + elseif itemType == "Staff" then return "weapon.staff" + elseif itemType == "Two Handed Sword" then return "weapon.twosword" + elseif itemType == "Two Handed Axe" then return "weapon.twoaxe" + elseif itemType == "Two Handed Mace" then return "weapon.twomace" + elseif itemType == "Fishing Rod" then return "weapon.rod" + elseif itemType == "One Handed Sword" then return "weapon.onesword" + elseif itemType == "One Handed Axe" then return "weapon.oneaxe" + elseif itemType == "One Handed Mace" or itemType == "Sceptre" then return "weapon.onemace" + elseif itemType == "Wand" then return "weapon.wand" + elseif itemType == "Dagger" then return "weapon.dagger" + elseif itemType == "Claw" then return "weapon.claw" + elseif itemType and itemType:find("Two Handed") then return "weapon.twomelee" + elseif itemType and itemType:find("One Handed") then return "weapon.one" + else return "weapon" + end + elseif slotName == "Body Armour" then return "armour.chest" + elseif slotName == "Helmet" then return "armour.helmet" + elseif slotName == "Gloves" then return "armour.gloves" + elseif slotName == "Boots" then return "armour.boots" + elseif slotName == "Amulet" then return "accessory.amulet" + elseif slotName == "Ring 1" or slotName == "Ring 2" or slotName == "Ring 3" then return "accessory.ring" + elseif slotName == "Belt" then return "accessory.belt" + elseif slotName:find("Abyssal") then return "jewel.abyss" + elseif slotName:find("Jewel") then return "jewel" + elseif slotName:find("Flask") then return "flask" + else return nil + end +end + +-- Helper: get a display-friendly category name from slot name +getTradeCategoryLabel = function(slotName, item) + if not item or not item.base then return "Item" end + local baseType = item.base.type or item.type + return baseType or "Item" +end + -- Helper: build a mod comparison map from an item. -- Returns a table keyed by template string → { line = original text, value = first number } local function buildModMap(item) @@ -2428,13 +2891,25 @@ local function getSlotDiffLabel(pItem, cItem) end end --- Helper: draw Copy and Copy+Use buttons at the given position. --- Returns copyHovered, copyUseHovered booleans. +-- Helper: draw Copy, Copy+Use, and Buy buttons at the given position. +-- Returns copyHovered, copyUseHovered, buyHovered booleans. local function drawCopyButtons(cursorX, cursorY, vpWidth, btnY) local btnW = LAYOUT.itemsCopyBtnW local btnH = LAYOUT.itemsCopyBtnH + local buyW = LAYOUT.itemsBuyBtnW local btn2X = vpWidth - btnW - 8 local btn1X = btn2X - btnW - 4 + local btn3X = btn1X - buyW - 4 + + -- "Buy" button + local b3Hover = cursorX >= btn3X and cursorX < btn3X + buyW + and cursorY >= btnY and cursorY < btnY + btnH + SetDrawColor(b3Hover and 0.5 or 0.35, b3Hover and 0.5 or 0.35, b3Hover and 0.5 or 0.35) + DrawImage(nil, btn3X, btnY, buyW, btnH) + SetDrawColor(0.1, 0.1, 0.1) + DrawImage(nil, btn3X + 1, btnY + 1, buyW - 2, btnH - 2) + SetDrawColor(1, 1, 1) + DrawString(btn3X + buyW / 2, btnY + 1, "CENTER_X", 14, "VAR", "^7Buy") -- "Copy" button local b1Hover = cursorX >= btn1X and cursorX < btn1X + btnW @@ -2456,7 +2931,7 @@ local function drawCopyButtons(cursorX, cursorY, vpWidth, btnY) SetDrawColor(1, 1, 1) DrawString(btn2X + btnW / 2, btnY + 1, "CENTER_X", 14, "VAR", "^7Copy+Use") - return b1Hover, b2Hover, btn2X, btnY, btnW, btnH + return b1Hover, b2Hover, b3Hover, btn2X, btnY, btnW, btnH end -- Draw a single item's full details at (x, startY) within colWidth. @@ -2645,6 +3120,8 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) -- Track item copy button clicks local clickedCopySlot = nil local clickedCopyUseSlot = nil + local clickedBuySlot = nil + local clickedBuyItem = nil -- Track Copy+Use button hover for stat comparison tooltip local hoverCopyUseItem = nil @@ -2677,9 +3154,9 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) DrawString(10, drawY, "LEFT", 16, "VAR", "^7" .. slotName .. ":") DrawString(colWidth - 10, drawY, "RIGHT", 14, "VAR", getSlotDiffLabel(pItem, cItem)) - -- Copy buttons for compare item + -- Copy/Buy buttons for compare item if cItem then - local b1Hover, b2Hover, b2X, b2Y, b2W, b2H = drawCopyButtons(cursorX, cursorY, vp.width, drawY + 1) + local b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H = drawCopyButtons(cursorX, cursorY, vp.width, drawY + 1) if b2Hover then hoverCopyUseItem = cItem hoverCopyUseSlotName = slotName @@ -2695,6 +3172,10 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) elseif b2Hover then clickedCopyUseSlot = slotName inputEvents[id] = nil + elseif b3Hover then + clickedBuySlot = slotName + clickedBuyItem = cItem + inputEvents[id] = nil end end end @@ -2780,9 +3261,9 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) DrawString(20, drawY, "LEFT", 16, "VAR", pColor .. pName) DrawString(colWidth + 20, drawY, "LEFT", 16, "VAR", cColor .. cName) - -- Copy buttons for compare item + -- Copy/Buy buttons for compare item if cItem then - local b1Hover, b2Hover, b2X, b2Y, b2W, b2H = drawCopyButtons(cursorX, cursorY, vp.width, drawY) + local b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H = drawCopyButtons(cursorX, cursorY, vp.width, drawY) if b2Hover then hoverCopyUseItem = cItem hoverCopyUseSlotName = slotName @@ -2798,6 +3279,10 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) elseif b2Hover then clickedCopyUseSlot = slotName inputEvents[id] = nil + elseif b3Hover then + clickedBuySlot = slotName + clickedBuyItem = cItem + inputEvents[id] = nil end end end @@ -2841,9 +3326,9 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) DrawString(10, drawY, "LEFT", 16, "VAR", "^7" .. jEntry.label .. ":" .. pWarn) DrawString(colWidth - 10, drawY, "RIGHT", 14, "VAR", getSlotDiffLabel(pItem, cItem)) - -- Copy buttons for compare jewel + -- Copy/Buy buttons for compare jewel if cItem then - local b1Hover, b2Hover, b2X, b2Y, b2W, b2H = drawCopyButtons(cursorX, cursorY, vp.width, drawY + 1) + local b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H = drawCopyButtons(cursorX, cursorY, vp.width, drawY + 1) if b2Hover then hoverCopyUseItem = cItem hoverCopyUseSlotName = jEntry.pSlotName @@ -2859,6 +3344,10 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) elseif b2Hover then clickedCopyUseSlot = jEntry.pSlotName inputEvents[id] = nil + elseif b3Hover then + clickedBuySlot = jEntry.pSlotName + clickedBuyItem = cItem + inputEvents[id] = nil end end end @@ -2943,9 +3432,9 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) DrawString(20, drawY, "LEFT", 16, "VAR", pColor .. pName) DrawString(colWidth + 20, drawY, "LEFT", 16, "VAR", cColor .. cName) - -- Copy buttons for compare jewel + -- Copy/Buy buttons for compare jewel if cItem then - local b1Hover, b2Hover, b2X, b2Y, b2W, b2H = drawCopyButtons(cursorX, cursorY, vp.width, drawY) + local b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H = drawCopyButtons(cursorX, cursorY, vp.width, drawY) if b2Hover then hoverCopyUseItem = cItem hoverCopyUseSlotName = jEntry.pSlotName @@ -2961,6 +3450,10 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) elseif b2Hover then clickedCopyUseSlot = jEntry.pSlotName inputEvents[id] = nil + elseif b3Hover then + clickedBuySlot = jEntry.pSlotName + clickedBuyItem = cItem + inputEvents[id] = nil end end end @@ -2979,6 +3472,11 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) self:CopyCompareItemToPrimary(clickedCopyUseSlot, compareEntry, true) end + -- Process buy button click + if clickedBuySlot and clickedBuyItem then + self:OpenBuySimilarPopup(clickedBuyItem, clickedBuySlot) + end + -- Draw item tooltip on hover (compact mode only, on top of everything) if hoverItem and hoverItemsTab then self.itemTooltip:Clear() From 93866aabd69b3bfd1719bed66ec51ece46d4854a Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Tue, 24 Mar 2026 01:26:49 +0100 Subject: [PATCH 18/59] improve UX of items tab --- src/Classes/CompareTab.lua | 498 ++++++++++++++++++++++++------------- 1 file changed, 324 insertions(+), 174 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 9049132a6d..7bd421d07f 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -42,7 +42,7 @@ local LAYOUT = { summaryCol4 = 600, -- Items view - itemsCheckboxOffset = 36, + itemsCheckboxOffset = 60, itemsCopyBtnW = 60, itemsCopyBtnH = 18, itemsBuyBtnW = 60, @@ -432,7 +432,7 @@ function CompareTabClass:InitControls() }) end - -- Overlay toggle checkbox (positioned dynamically in Draw) + -- Overlay toggle checkbox self.controls.treeOverlayCheck = new("CheckBoxControl", nil, {0, 0, 20}, "Overlay comparison", function(state) self.treeOverlayMode = state self.treeSearchNeedsSync = true @@ -452,7 +452,7 @@ function CompareTabClass:InitControls() return self.compareViewMode == "TREE" and self:GetActiveCompare() ~= nil and self.treeOverlayMode end - -- Items expanded mode toggle (positioned dynamically in Draw) + -- Items expanded mode toggle self.controls.itemsExpandedCheck = new("CheckBoxControl", nil, {0, 0, 20}, "Expanded mode", function(state) self.itemsExpandedMode = state self.scrollY = 0 @@ -461,7 +461,65 @@ function CompareTabClass:InitControls() return self.compareViewMode == "ITEMS" and self:GetActiveCompare() ~= nil end - -- Footer anchor controls (positioned dynamically in Draw, side-by-side only) + -- Item set dropdown for primary build + local itemsShown = function() + return self.compareViewMode == "ITEMS" and self:GetActiveCompare() ~= nil + end + self.controls.primaryItemSetLabel = new("LabelControl", nil, {0, 0, 0, 16}, "^7Item set:") + self.controls.primaryItemSetLabel.shown = itemsShown + self.controls.primaryItemSetSelect = new("DropDownControl", nil, {0, 0, 216, 20}, {}, function(index, value) + if self.primaryBuild.itemsTab and self.primaryBuild.itemsTab.itemSetOrderList[index] then + self.primaryBuild.itemsTab:SetActiveItemSet(self.primaryBuild.itemsTab.itemSetOrderList[index]) + self.primaryBuild.itemsTab:AddUndoState() + end + end) + self.controls.primaryItemSetSelect.enabled = itemsShown + self.controls.primaryItemSetSelect.shown = itemsShown + + -- Item set dropdown for compare build + self.controls.compareItemSetLabel2 = new("LabelControl", nil, {0, 0, 0, 16}, "^7Item set:") + self.controls.compareItemSetLabel2.shown = itemsShown + self.controls.compareItemSetSelect2 = new("DropDownControl", nil, {0, 0, 216, 20}, {}, function(index, value) + local entry = self:GetActiveCompare() + if entry and entry.itemsTab and entry.itemsTab.itemSetOrderList[index] then + entry:SetActiveItemSet(entry.itemsTab.itemSetOrderList[index]) + end + end) + self.controls.compareItemSetSelect2.enabled = itemsShown + self.controls.compareItemSetSelect2.shown = itemsShown + + -- Tree set dropdown for primary build + self.controls.primaryTreeSetLabel = new("LabelControl", nil, {0, 0, 0, 16}, "^7Tree set:") + self.controls.primaryTreeSetLabel.shown = itemsShown + self.controls.primaryTreeSetSelect = new("DropDownControl", nil, {0, 0, 216, 20}, {}, function(index, value) + if self.primaryBuild.treeTab and self.primaryBuild.treeTab.specList[index] then + self.primaryBuild.modFlag = true + self.primaryBuild.treeTab:SetActiveSpec(index) + end + end) + self.controls.primaryTreeSetSelect.enabled = itemsShown + self.controls.primaryTreeSetSelect.shown = itemsShown + self.controls.primaryTreeSetSelect.maxDroppedWidth = 500 + self.controls.primaryTreeSetSelect.enableDroppedWidth = true + + -- Tree set dropdown for compare build + self.controls.compareTreeSetLabel = new("LabelControl", nil, {0, 0, 0, 16}, "^7Tree set:") + self.controls.compareTreeSetLabel.shown = itemsShown + self.controls.compareTreeSetSelect = new("DropDownControl", nil, {0, 0, 216, 20}, {}, function(index, value) + local entry = self:GetActiveCompare() + if entry and entry.treeTab and entry.treeTab.specList[index] then + entry:SetActiveSpec(index) + if self.primaryBuild.spec then + self.primaryBuild.spec:SetWindowTitleWithBuildClass() + end + end + end) + self.controls.compareTreeSetSelect.enabled = itemsShown + self.controls.compareTreeSetSelect.shown = itemsShown + self.controls.compareTreeSetSelect.maxDroppedWidth = 500 + self.controls.compareTreeSetSelect.enableDroppedWidth = true + + -- Footer anchor controls (side-by-side only) self.controls.leftFooterAnchor = new("Control", nil, {0, 0, 0, 20}) self.controls.leftFooterAnchor.shown = treeSideBySideShown self.controls.rightFooterAnchor = new("Control", nil, {0, 0, 0, 20}) @@ -1577,11 +1635,56 @@ function CompareTabClass:Draw(viewPort, inputEvents) return end - -- Position items expanded mode checkbox (inside content area, top-left) + -- Position items expanded mode checkbox and item set dropdowns (inside content area, top-left) -- Label draws to the left of the checkbox, so offset x by labelWidth to keep it visible if self.compareViewMode == "ITEMS" then self.controls.itemsExpandedCheck.x = contentVP.x + 10 + self.controls.itemsExpandedCheck.labelWidth self.controls.itemsExpandedCheck.y = contentVP.y + 8 + + local colWidth = m_floor(contentVP.width / 2) + local itemSetLabelW = DrawStringWidth(16, "VAR", "^7Item set:") + 4 + + -- Item set dropdowns + local row1Y = contentVP.y + 34 + + -- Primary build item set dropdown + self.controls.primaryItemSetLabel.x = contentVP.x + 10 + self.controls.primaryItemSetLabel.y = row1Y + 2 + self.controls.primaryItemSetSelect.x = contentVP.x + 10 + itemSetLabelW + self.controls.primaryItemSetSelect.y = row1Y + + -- Compare build item set dropdown + self.controls.compareItemSetLabel2.x = contentVP.x + colWidth + 10 + self.controls.compareItemSetLabel2.y = row1Y + 2 + self.controls.compareItemSetSelect2.x = contentVP.x + colWidth + 10 + itemSetLabelW + self.controls.compareItemSetSelect2.y = row1Y + + -- Populate primary build item set list + if self.primaryBuild.itemsTab and self.primaryBuild.itemsTab.itemSetOrderList then + local itemList = {} + for index, itemSetId in ipairs(self.primaryBuild.itemsTab.itemSetOrderList) do + local itemSet = self.primaryBuild.itemsTab.itemSets[itemSetId] + t_insert(itemList, itemSet.title or "Default") + if itemSetId == self.primaryBuild.itemsTab.activeItemSetId then + self.controls.primaryItemSetSelect.selIndex = index + end + end + self.controls.primaryItemSetSelect:SetList(itemList) + end + + -- Populate compare build item set list + if compareEntry and compareEntry.itemsTab and compareEntry.itemsTab.itemSetOrderList then + local itemList = {} + for index, itemSetId in ipairs(compareEntry.itemsTab.itemSetOrderList) do + local itemSet = compareEntry.itemsTab.itemSets[itemSetId] + t_insert(itemList, itemSet.title or "Default") + if itemSetId == compareEntry.itemsTab.activeItemSetId then + self.controls.compareItemSetSelect2.selIndex = index + end + end + self.controls.compareItemSetSelect2:SetList(itemList) + end + end -- Dispatch to sub-view (TREE already drawn above) @@ -2892,14 +2995,15 @@ local function getSlotDiffLabel(pItem, cItem) end -- Helper: draw Copy, Copy+Use, and Buy buttons at the given position. +-- btnStartX is the left edge where the first button (Buy) should appear. -- Returns copyHovered, copyUseHovered, buyHovered booleans. -local function drawCopyButtons(cursorX, cursorY, vpWidth, btnY) +local function drawCopyButtons(cursorX, cursorY, btnStartX, btnY) local btnW = LAYOUT.itemsCopyBtnW local btnH = LAYOUT.itemsCopyBtnH local buyW = LAYOUT.itemsBuyBtnW - local btn2X = vpWidth - btnW - 8 - local btn1X = btn2X - btnW - 4 - local btn3X = btn1X - buyW - 4 + local btn3X = btnStartX + local btn1X = btn3X + buyW + 4 + local btn2X = btn1X + btnW + 4 -- "Buy" button local b3Hover = cursorX >= btn3X and cursorX < btn3X + buyW @@ -2934,6 +3038,108 @@ local function drawCopyButtons(cursorX, cursorY, vpWidth, btnY) return b1Hover, b2Hover, b3Hover, btn2X, btnY, btnW, btnH end +-- Helper: fit a colored item name within maxW pixels, truncating with "..." if needed. +local function fitItemName(colorCode, name, maxW) + local display = colorCode .. name + if DrawStringWidth(16, "VAR", display) <= maxW then + return display + end + local lo, hi = 0, #name + while lo < hi do + local mid = m_floor((lo + hi + 1) / 2) + if DrawStringWidth(16, "VAR", colorCode .. name:sub(1, mid) .. "...") <= maxW then + lo = mid + else + hi = mid - 1 + end + end + return colorCode .. name:sub(1, lo) .. "..." +end + +-- Helper: draw a single compact-mode item row. +-- Returns: pHover, cHover, b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H, hoverItem, hoverItemsTab +local ITEM_BOX_W = 310 +local ITEM_BOX_H = 20 + +local function drawCompactSlotRow(drawY, slotLabel, pItem, cItem, + colWidth, cursorX, cursorY, maxLabelW, primaryItemsTab, compareItemsTab, pWarn, cWarn) + + local pName = pItem and pItem.name or "(empty)" + local cName = cItem and cItem.name or "(empty)" + if pWarn and pWarn ~= "" then pName = pName .. pWarn end + if cWarn and cWarn ~= "" then cName = cName .. cWarn end + local pColor = getRarityColor(pItem) + local cColor = getRarityColor(cItem) + local diffLabel = getSlotDiffLabel(pItem, cItem) + + -- Layout positions (fixed 310px box width matching regular Items tab) + local labelX = 10 + local pBoxX = labelX + maxLabelW + 4 + local pBoxW = ITEM_BOX_W + + local cBoxX = colWidth + 10 + local cBoxW = ITEM_BOX_W + + -- Diff indicator position + local diffX = pBoxX + pBoxW + 6 + + -- Hover detection + local pHover = pItem and cursorX >= pBoxX and cursorX < pBoxX + pBoxW + and cursorY >= drawY and cursorY < drawY + ITEM_BOX_H + local cHover = cItem and cursorX >= cBoxX and cursorX < cBoxX + cBoxW + and cursorY >= drawY and cursorY < drawY + ITEM_BOX_H + + -- Draw slot label + SetDrawColor(1, 1, 1) + DrawString(labelX, drawY + 2, "LEFT", 16, "VAR", "^7" .. slotLabel .. ":") + + -- Draw primary item box + local pBorderGray = pHover and 0.5 or 0.33 + SetDrawColor(pBorderGray, pBorderGray, pBorderGray) + DrawImage(nil, pBoxX, drawY, pBoxW, ITEM_BOX_H) + SetDrawColor(0.05, 0.05, 0.05) + DrawImage(nil, pBoxX + 1, drawY + 1, pBoxW - 2, ITEM_BOX_H - 2) + SetDrawColor(1, 1, 1) + DrawString(pBoxX + 4, drawY + 2, "LEFT", 16, "VAR", fitItemName(pColor, pName, pBoxW - 8)) + + -- Draw diff indicator (between the two item boxes) + DrawString(diffX, drawY + 3, "LEFT", 14, "VAR", diffLabel) + + -- Draw compare item box + local cBorderGray = cHover and 0.5 or 0.33 + SetDrawColor(cBorderGray, cBorderGray, cBorderGray) + DrawImage(nil, cBoxX, drawY, cBoxW, ITEM_BOX_H) + SetDrawColor(0.05, 0.05, 0.05) + DrawImage(nil, cBoxX + 1, drawY + 1, cBoxW - 2, ITEM_BOX_H - 2) + SetDrawColor(1, 1, 1) + DrawString(cBoxX + 4, drawY + 2, "LEFT", 16, "VAR", fitItemName(cColor, cName, cBoxW - 8)) + + -- Draw buttons + local b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H + if cItem then + local btnStartX = cBoxX + cBoxW + 6 + b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H = + drawCopyButtons(cursorX, cursorY, btnStartX, drawY + 1) + end + + -- Determine hovered item and tooltip anchor position + local hoverItem = nil + local hoverItemsTab = nil + local hoverBoxX, hoverBoxY, hoverBoxW, hoverBoxH = 0, 0, 0, 0 + if pHover then + hoverItem = pItem + hoverItemsTab = primaryItemsTab + hoverBoxX, hoverBoxY, hoverBoxW, hoverBoxH = pBoxX, drawY, pBoxW, ITEM_BOX_H + elseif cHover then + hoverItem = cItem + hoverItemsTab = compareItemsTab + hoverBoxX, hoverBoxY, hoverBoxW, hoverBoxH = cBoxX, drawY, cBoxW, ITEM_BOX_H + end + + return pHover, cHover, b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H, + hoverItem, hoverItemsTab, hoverBoxX, hoverBoxY, hoverBoxW, hoverBoxH +end + -- Draw a single item's full details at (x, startY) within colWidth. -- otherModMap: optional table from buildModMap() of the other item for diff highlighting. -- Returns the total height consumed. @@ -3135,6 +3341,14 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) DrawString(colWidth + 10, drawY, "LEFT", 18, "VAR", colorCodes.WARNING .. (compareEntry.label or "Compare Build")) drawY = drawY + 24 + -- Pre-compute max slot label width for alignment + local maxLabelW = 0 + for _, sn in ipairs(baseSlots) do + local w = DrawStringWidth(16, "VAR", "^7" .. sn .. ":") + if w > maxLabelW then maxLabelW = w end + end + maxLabelW = maxLabelW + 2 + for _, slotName in ipairs(baseSlots) do -- Separator SetDrawColor(0.3, 0.3, 0.3) @@ -3156,7 +3370,7 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) -- Copy/Buy buttons for compare item if cItem then - local b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H = drawCopyButtons(cursorX, cursorY, vp.width, drawY + 1) + local b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H = drawCopyButtons(cursorX, cursorY, vp.width - 196, drawY + 1) if b2Hover then hoverCopyUseItem = cItem hoverCopyUseSlotName = slotName @@ -3200,90 +3414,40 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) drawY = drawY + maxH + 6 else - -- === COMPACT MODE === - -- Slot label + diff indicator - SetDrawColor(1, 1, 1) - DrawString(10, drawY, "LEFT", 16, "VAR", "^7" .. slotName .. ":") - DrawString(colWidth - 10, drawY, "RIGHT", 14, "VAR", getSlotDiffLabel(pItem, cItem)) - - local pName = pItem and pItem.name or "(empty)" - local cName = cItem and cItem.name or "(empty)" - - local pColor = getRarityColor(pItem) - local cColor = getRarityColor(cItem) - - -- Measure text widths for precise hover detection - local pTextW = pItem and DrawStringWidth(16, "VAR", pColor .. pName) or 0 - local cTextW = cItem and DrawStringWidth(16, "VAR", cColor .. cName) or 0 - - drawY = drawY + 18 - - -- Check hover on primary item (left column, text bounds only) - local pHover = pItem and cursorX >= 18 and cursorX < 22 + pTextW - and cursorY >= drawY and cursorY < drawY + 18 - if pHover then - hoverItem = pItem - hoverX = 20 - hoverY = drawY - hoverW = pTextW + 4 - hoverH = 18 - hoverItemsTab = self.primaryBuild.itemsTab + -- === COMPACT MODE (single-line with bordered boxes) === + local pHover, cHover, b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H, + rowHoverItem, rowHoverItemsTab, rowHoverX, rowHoverY, rowHoverW, rowHoverH = + drawCompactSlotRow(drawY, slotName, pItem, cItem, + colWidth, cursorX, cursorY, maxLabelW, + self.primaryBuild.itemsTab, compareEntry.itemsTab) + + if rowHoverItem then + hoverItem = rowHoverItem + hoverItemsTab = rowHoverItemsTab + hoverX, hoverY = rowHoverX, rowHoverY + hoverW, hoverH = rowHoverW, rowHoverH end - -- Check hover on compare item (right column, text bounds only) - local cHover = cItem and cursorX >= colWidth + 18 and cursorX < colWidth + 22 + cTextW - and cursorY >= drawY and cursorY < drawY + 18 - if cHover then - hoverItem = cItem - hoverX = colWidth + 20 - hoverY = drawY - hoverW = cTextW + 4 - hoverH = 18 - hoverItemsTab = compareEntry.itemsTab + if b2Hover and cItem then + hoverCopyUseItem = cItem + hoverCopyUseSlotName = slotName + hoverCopyUseBtnX, hoverCopyUseBtnY = b2X, b2Y + hoverCopyUseBtnW, hoverCopyUseBtnH = b2W, b2H end - -- Draw hover border around text (matching ButtonControl style) - if pHover then - SetDrawColor(0.5, 0.5, 0.5) - DrawImage(nil, 18, drawY - 1, pTextW + 4, 20) - SetDrawColor(0, 0, 0) - DrawImage(nil, 19, drawY, pTextW + 2, 18) - end - if cHover then - SetDrawColor(0.5, 0.5, 0.5) - DrawImage(nil, colWidth + 18, drawY - 1, cTextW + 4, 20) - SetDrawColor(0, 0, 0) - DrawImage(nil, colWidth + 19, drawY, cTextW + 2, 18) - end - - -- Draw item names - SetDrawColor(1, 1, 1) - DrawString(20, drawY, "LEFT", 16, "VAR", pColor .. pName) - DrawString(colWidth + 20, drawY, "LEFT", 16, "VAR", cColor .. cName) - - -- Copy/Buy buttons for compare item - if cItem then - local b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H = drawCopyButtons(cursorX, cursorY, vp.width, drawY) - if b2Hover then - hoverCopyUseItem = cItem - hoverCopyUseSlotName = slotName - hoverCopyUseBtnX, hoverCopyUseBtnY = b2X, b2Y - hoverCopyUseBtnW, hoverCopyUseBtnH = b2W, b2H - end - if inputEvents then - for id, event in ipairs(inputEvents) do - if event.type == "KeyUp" and event.key == "LEFTBUTTON" then - if b1Hover then - clickedCopySlot = slotName - inputEvents[id] = nil - elseif b2Hover then - clickedCopyUseSlot = slotName - inputEvents[id] = nil - elseif b3Hover then - clickedBuySlot = slotName - clickedBuyItem = cItem - inputEvents[id] = nil - end + if cItem and inputEvents then + for id, event in ipairs(inputEvents) do + if event.type == "KeyUp" and event.key == "LEFTBUTTON" then + if b1Hover then + clickedCopySlot = slotName + inputEvents[id] = nil + elseif b2Hover then + clickedCopyUseSlot = slotName + inputEvents[id] = nil + elseif b3Hover then + clickedBuySlot = slotName + clickedBuyItem = cItem + inputEvents[id] = nil end end end @@ -3293,18 +3457,53 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) end end + -- === TREE SET DROPDOWNS === + drawY = drawY + 12 + SetDrawColor(0.5, 0.5, 0.5) + DrawImage(nil, 4, drawY, vp.width - 8, 1) + drawY = drawY + 10 + + -- Convert drawY to absolute screen coords for control positioning + local absY = vp.y + checkboxOffset + drawY + local treeSetLabelW = DrawStringWidth(16, "VAR", "^7Tree set:") + 4 + + self.controls.primaryTreeSetLabel.x = vp.x + 10 + self.controls.primaryTreeSetLabel.y = absY + 2 + self.controls.primaryTreeSetSelect.x = vp.x + 10 + treeSetLabelW + self.controls.primaryTreeSetSelect.y = absY + + self.controls.compareTreeSetLabel.x = vp.x + colWidth + 10 + self.controls.compareTreeSetLabel.y = absY + 2 + self.controls.compareTreeSetSelect.x = vp.x + colWidth + 10 + treeSetLabelW + self.controls.compareTreeSetSelect.y = absY + + -- Populate tree set lists + if self.primaryBuild.treeTab then + self.controls.primaryTreeSetSelect.list = self.primaryBuild.treeTab:GetSpecList() + self.controls.primaryTreeSetSelect.selIndex = self.primaryBuild.treeTab.activeSpec + end + if compareEntry.treeTab then + self.controls.compareTreeSetSelect.list = compareEntry.treeTab:GetSpecList() + self.controls.compareTreeSetSelect.selIndex = compareEntry.treeTab.activeSpec + end + + drawY = drawY + 24 + -- === JEWELS SECTION === local jewelSlots = self:GetJewelComparisonSlots(compareEntry) if #jewelSlots > 0 then -- Section header - drawY = drawY + 4 - SetDrawColor(0.5, 0.5, 0.5) - DrawImage(nil, 4, drawY, vp.width - 8, 1) - drawY = drawY + 4 SetDrawColor(1, 1, 1) DrawString(10, drawY, "LEFT", 16, "VAR", "^7-- Jewels --") drawY = drawY + 20 + -- Pre-compute max jewel label width for alignment + local maxJewelLabelW = maxLabelW + for _, jE in ipairs(jewelSlots) do + local w = DrawStringWidth(16, "VAR", "^7" .. jE.label .. ":") + 2 + if w > maxJewelLabelW then maxJewelLabelW = w end + end + for jIdx, jEntry in ipairs(jewelSlots) do local pItem = jEntry.pItem local cItem = jEntry.cItem @@ -3328,7 +3527,7 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) -- Copy/Buy buttons for compare jewel if cItem then - local b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H = drawCopyButtons(cursorX, cursorY, vp.width, drawY + 1) + local b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H = drawCopyButtons(cursorX, cursorY, vp.width - 196, drawY + 1) if b2Hover then hoverCopyUseItem = cItem hoverCopyUseSlotName = jEntry.pSlotName @@ -3372,89 +3571,40 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) drawY = drawY + maxH + 6 else - -- === COMPACT MODE === - SetDrawColor(1, 1, 1) - DrawString(10, drawY, "LEFT", 16, "VAR", "^7" .. jEntry.label .. ":") - DrawString(colWidth - 10, drawY, "RIGHT", 14, "VAR", getSlotDiffLabel(pItem, cItem)) - - local pName = (pItem and pItem.name or "(empty)") .. pWarn - local cName = (cItem and cItem.name or "(empty)") .. cWarn - - local pColor = getRarityColor(pItem) - local cColor = getRarityColor(cItem) - - -- Measure text widths for precise hover detection - local pTextW = pItem and DrawStringWidth(16, "VAR", pColor .. pName) or 0 - local cTextW = cItem and DrawStringWidth(16, "VAR", cColor .. cName) or 0 - - drawY = drawY + 18 - - -- Check hover on primary jewel (left column) - local pHover = pItem and cursorX >= 18 and cursorX < 22 + pTextW - and cursorY >= drawY and cursorY < drawY + 18 - if pHover then - hoverItem = pItem - hoverX = 20 - hoverY = drawY - hoverW = pTextW + 4 - hoverH = 18 - hoverItemsTab = self.primaryBuild.itemsTab + -- === COMPACT MODE (single-line with bordered boxes) === + local pHover, cHover, b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H, + rowHoverItem, rowHoverItemsTab, rowHoverX, rowHoverY, rowHoverW, rowHoverH = + drawCompactSlotRow(drawY, jEntry.label, pItem, cItem, + colWidth, cursorX, cursorY, maxJewelLabelW, + self.primaryBuild.itemsTab, compareEntry.itemsTab, pWarn, cWarn) + + if rowHoverItem then + hoverItem = rowHoverItem + hoverItemsTab = rowHoverItemsTab + hoverX, hoverY = rowHoverX, rowHoverY + hoverW, hoverH = rowHoverW, rowHoverH end - -- Check hover on compare jewel (right column) - local cHover = cItem and cursorX >= colWidth + 18 and cursorX < colWidth + 22 + cTextW - and cursorY >= drawY and cursorY < drawY + 18 - if cHover then - hoverItem = cItem - hoverX = colWidth + 20 - hoverY = drawY - hoverW = cTextW + 4 - hoverH = 18 - hoverItemsTab = compareEntry.itemsTab - end - - -- Draw hover border - if pHover then - SetDrawColor(0.5, 0.5, 0.5) - DrawImage(nil, 18, drawY - 1, pTextW + 4, 20) - SetDrawColor(0, 0, 0) - DrawImage(nil, 19, drawY, pTextW + 2, 18) - end - if cHover then - SetDrawColor(0.5, 0.5, 0.5) - DrawImage(nil, colWidth + 18, drawY - 1, cTextW + 4, 20) - SetDrawColor(0, 0, 0) - DrawImage(nil, colWidth + 19, drawY, cTextW + 2, 18) + if b2Hover and cItem then + hoverCopyUseItem = cItem + hoverCopyUseSlotName = jEntry.pSlotName + hoverCopyUseBtnX, hoverCopyUseBtnY = b2X, b2Y + hoverCopyUseBtnW, hoverCopyUseBtnH = b2W, b2H end - -- Draw jewel names - SetDrawColor(1, 1, 1) - DrawString(20, drawY, "LEFT", 16, "VAR", pColor .. pName) - DrawString(colWidth + 20, drawY, "LEFT", 16, "VAR", cColor .. cName) - - -- Copy/Buy buttons for compare jewel - if cItem then - local b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H = drawCopyButtons(cursorX, cursorY, vp.width, drawY) - if b2Hover then - hoverCopyUseItem = cItem - hoverCopyUseSlotName = jEntry.pSlotName - hoverCopyUseBtnX, hoverCopyUseBtnY = b2X, b2Y - hoverCopyUseBtnW, hoverCopyUseBtnH = b2W, b2H - end - if inputEvents then - for id, event in ipairs(inputEvents) do - if event.type == "KeyUp" and event.key == "LEFTBUTTON" then - if b1Hover then - clickedCopySlot = jEntry.cSlotName - inputEvents[id] = nil - elseif b2Hover then - clickedCopyUseSlot = jEntry.pSlotName - inputEvents[id] = nil - elseif b3Hover then - clickedBuySlot = jEntry.pSlotName - clickedBuyItem = cItem - inputEvents[id] = nil - end + if cItem and inputEvents then + for id, event in ipairs(inputEvents) do + if event.type == "KeyUp" and event.key == "LEFTBUTTON" then + if b1Hover then + clickedCopySlot = jEntry.cSlotName + inputEvents[id] = nil + elseif b2Hover then + clickedCopyUseSlot = jEntry.pSlotName + inputEvents[id] = nil + elseif b3Hover then + clickedBuySlot = jEntry.pSlotName + clickedBuyItem = cItem + inputEvents[id] = nil end end end From 396e8d6ffa95bfe59e36a3d097710af786119ae7 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Tue, 24 Mar 2026 20:56:00 +0100 Subject: [PATCH 19/59] clean up duplication in PassiveTreeView --- src/Classes/PassiveTreeView.lua | 164 ++++++++++++-------------------- 1 file changed, 60 insertions(+), 104 deletions(-) diff --git a/src/Classes/PassiveTreeView.lua b/src/Classes/PassiveTreeView.lua index 4c021c11da..43bb210062 100644 --- a/src/Classes/PassiveTreeView.lua +++ b/src/Classes/PassiveTreeView.lua @@ -111,6 +111,62 @@ function PassiveTreeViewClass:GetCompareJewel(nodeId) return nil end +-- Returns the overlay asset name for a socketed jewel, or nil if no special overlay applies. +function PassiveTreeViewClass:GetJewelSocketOverlay(jewel, isExpansion) + if jewel.baseName == "Crimson Jewel" then + return isExpansion and "JewelSocketActiveRedAlt" or "JewelSocketActiveRed" + elseif jewel.baseName == "Viridian Jewel" then + return isExpansion and "JewelSocketActiveGreenAlt" or "JewelSocketActiveGreen" + elseif jewel.baseName == "Cobalt Jewel" then + return isExpansion and "JewelSocketActiveBlueAlt" or "JewelSocketActiveBlue" + elseif jewel.baseName == "Prismatic Jewel" then + return isExpansion and "JewelSocketActivePrismaticAlt" or "JewelSocketActivePrismatic" + elseif jewel.base and jewel.base.subType == "Abyss" then + return isExpansion and "JewelSocketActiveAbyssAlt" or "JewelSocketActiveAbyss" + elseif jewel.base and jewel.base.subType == "Charm" then + if jewel.baseName == "Ursine Charm" then + return "CharmSocketActiveStr" + elseif jewel.baseName == "Corvine Charm" then + return "CharmSocketActiveInt" + elseif jewel.baseName == "Lupine Charm" then + return "CharmSocketActiveDex" + end + elseif jewel.baseName == "Timeless Jewel" then + return isExpansion and "JewelSocketActiveLegionAlt" or "JewelSocketActiveLegion" + elseif jewel.baseName == "Large Cluster Jewel" then + return "JewelSocketActiveAltPurple" + elseif jewel.baseName == "Medium Cluster Jewel" then + return "JewelSocketActiveAltBlue" + elseif jewel.baseName == "Small Cluster Jewel" then + return "JewelSocketActiveAltRed" + end +end + +-- Returns the draw color for a node when compare overlay is active. +-- Handles diff coloring for allocated/unallocated, mastery changes, and jewel socket differences. +function PassiveTreeViewClass:GetCompareNodeColor(node, compareNode, spec, build, nodeDefaultColor) + if not compareNode then + return nodeDefaultColor + end + if compareNode.alloc and not node.alloc then + return 0, 1, 0 + elseif not compareNode.alloc and node.alloc then + return 1, 0, 0 + elseif node.type == "Mastery" and compareNode.alloc and node.alloc and node.sd ~= compareNode.sd then + return 0, 0, 1 + elseif node.type == "Socket" and compareNode.alloc and node.alloc then + local pJewelId = spec.jewels[node.id] + local pJewel = pJewelId and build.itemsTab.items[pJewelId] + local cJewel = self:GetCompareJewel(node.id) + local pName = pJewel and pJewel.name or "" + local cName = cJewel and cJewel.name or "" + if pName ~= cName then + return 0, 0, 1 + end + end + return nodeDefaultColor +end + function PassiveTreeViewClass:Draw(build, viewPort, inputEvents) local spec = build.spec local tree = spec.tree @@ -742,33 +798,7 @@ function PassiveTreeViewClass:Draw(build, viewPort, inputEvents) base = tree.assets[(node.name == "Charm Socket" and "Azmeri" or "" ) .. node.overlay[state .. (node.expansionJewel and "Alt" or "")]] local socket, jewel = build.itemsTab:GetSocketAndJewelForNodeID(nodeId) if isAlloc and jewel then - if jewel.baseName == "Crimson Jewel" then - overlay = node.expansionJewel and "JewelSocketActiveRedAlt" or "JewelSocketActiveRed" - elseif jewel.baseName == "Viridian Jewel" then - overlay = node.expansionJewel and "JewelSocketActiveGreenAlt" or "JewelSocketActiveGreen" - elseif jewel.baseName == "Cobalt Jewel" then - overlay = node.expansionJewel and "JewelSocketActiveBlueAlt" or "JewelSocketActiveBlue" - elseif jewel.baseName == "Prismatic Jewel" then - overlay = node.expansionJewel and "JewelSocketActivePrismaticAlt" or "JewelSocketActivePrismatic" - elseif jewel.base.subType == "Abyss" then - overlay = node.expansionJewel and "JewelSocketActiveAbyssAlt" or "JewelSocketActiveAbyss" - elseif jewel.base.subType == "Charm" then - if jewel.baseName == "Ursine Charm" then - overlay = "CharmSocketActiveStr" - elseif jewel.baseName == "Corvine Charm" then - overlay = "CharmSocketActiveInt" - elseif jewel.baseName == "Lupine Charm" then - overlay = "CharmSocketActiveDex" - end - elseif jewel.baseName == "Timeless Jewel" then - overlay = node.expansionJewel and "JewelSocketActiveLegionAlt" or "JewelSocketActiveLegion" - elseif jewel.baseName == "Large Cluster Jewel" then - overlay = "JewelSocketActiveAltPurple" - elseif jewel.baseName == "Medium Cluster Jewel" then - overlay = "JewelSocketActiveAltBlue" - elseif jewel.baseName == "Small Cluster Jewel" then - overlay = "JewelSocketActiveAltRed" - end + overlay = self:GetJewelSocketOverlay(jewel, node.expansionJewel) end elseif node.type == "Mastery" then local override = spec.hashOverrides and spec.hashOverrides[node.id] @@ -849,35 +879,7 @@ function PassiveTreeViewClass:Draw(build, viewPort, inputEvents) end end else - if compareNode then - if compareNode.alloc and not node.alloc then - -- Base has, current has not, color green (take these nodes to match) - SetDrawColor(0, 1, 0) - elseif not compareNode.alloc and node.alloc then - -- Base has not, current has, color red (Remove nodes to match) - SetDrawColor(1, 0, 0) - elseif node.type == "Mastery" and compareNode.alloc and node.alloc and node.sd ~= compareNode.sd then - -- Node is a mastery, both have it allocated, but mastery changed, color it blue - SetDrawColor(0, 0, 1) - elseif node.type == "Socket" and compareNode.alloc and node.alloc then - -- Both allocated socket, check if jewels differ - local pJewelId = spec.jewels[nodeId] - local pJewel = pJewelId and build.itemsTab.items[pJewelId] - local cJewel = self:GetCompareJewel(nodeId) - local pName = pJewel and pJewel.name or "" - local cName = cJewel and cJewel.name or "" - if pName ~= cName then - SetDrawColor(0, 0, 1) - else - SetDrawColor(nodeDefaultColor) - end - else - -- Both have or both have not - SetDrawColor(nodeDefaultColor) - end - else - SetDrawColor(nodeDefaultColor) - end + SetDrawColor(self:GetCompareNodeColor(node, compareNode, spec, build, nodeDefaultColor)) end elseif launch.devModeAlt then -- Debug display @@ -889,35 +891,7 @@ function PassiveTreeViewClass:Draw(build, viewPort, inputEvents) SetDrawColor(0, 0, 0) end else - if compareNode then - if compareNode.alloc and not node.alloc then - -- Base has, current has not, color green (take these nodes to match) - SetDrawColor(0, 1, 0) - elseif not compareNode.alloc and node.alloc then - -- Base has not, current has, color red (Remove nodes to match) - SetDrawColor(1, 0, 0) - elseif node.type == "Mastery" and compareNode.alloc and node.alloc and node.sd ~= compareNode.sd then - -- Node is a mastery, both have it allocated, but mastery changed, color it blue - SetDrawColor(0, 0, 1) - elseif node.type == "Socket" and compareNode.alloc and node.alloc then - -- Both allocated socket, check if jewels differ - local pJewelId = spec.jewels[nodeId] - local pJewel = pJewelId and build.itemsTab.items[pJewelId] - local cJewel = self:GetCompareJewel(nodeId) - local pName = pJewel and pJewel.name or "" - local cName = cJewel and cJewel.name or "" - if pName ~= cName then - SetDrawColor(0, 0, 1) - else - SetDrawColor(nodeDefaultColor) - end - else - -- Both have or both have not - SetDrawColor(nodeDefaultColor) - end - else - SetDrawColor(nodeDefaultColor) - end + SetDrawColor(self:GetCompareNodeColor(node, compareNode, spec, build, nodeDefaultColor)) end -- Draw mastery/tattoo effect artwork @@ -1034,25 +1008,7 @@ function PassiveTreeViewClass:Draw(build, viewPort, inputEvents) -- Look up jewel from compare build to show correct colored socket overlay local cJewel = self:GetCompareJewel(nodeId) if cJewel then - if cJewel.baseName == "Crimson Jewel" then - overlay = compareNode.expansionJewel and "JewelSocketActiveRedAlt" or "JewelSocketActiveRed" - elseif cJewel.baseName == "Viridian Jewel" then - overlay = compareNode.expansionJewel and "JewelSocketActiveGreenAlt" or "JewelSocketActiveGreen" - elseif cJewel.baseName == "Cobalt Jewel" then - overlay = compareNode.expansionJewel and "JewelSocketActiveBlueAlt" or "JewelSocketActiveBlue" - elseif cJewel.baseName == "Prismatic Jewel" then - overlay = compareNode.expansionJewel and "JewelSocketActivePrismaticAlt" or "JewelSocketActivePrismatic" - elseif cJewel.base and cJewel.base.subType == "Abyss" then - overlay = compareNode.expansionJewel and "JewelSocketActiveAbyssAlt" or "JewelSocketActiveAbyss" - elseif cJewel.baseName == "Timeless Jewel" then - overlay = compareNode.expansionJewel and "JewelSocketActiveLegionAlt" or "JewelSocketActiveLegion" - elseif cJewel.baseName == "Large Cluster Jewel" then - overlay = "JewelSocketActiveAltPurple" - elseif cJewel.baseName == "Medium Cluster Jewel" then - overlay = "JewelSocketActiveAltBlue" - elseif cJewel.baseName == "Small Cluster Jewel" then - overlay = "JewelSocketActiveAltRed" - end + overlay = self:GetJewelSocketOverlay(cJewel, compareNode.expansionJewel) end elseif compareNode.type == "Mastery" then if compareNode.masterySprites and compareNode.masterySprites.activeIcon then From b56cbcc7afc148d7fa7e3590150b75f8136e0dc0 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Sun, 29 Mar 2026 00:10:36 +0100 Subject: [PATCH 20/59] expand mod support when buying similar items --- src/Classes/CompareTab.lua | 94 ++++++++++++++++++++++++++++++++++---- 1 file changed, 86 insertions(+), 8 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 7bd421d07f..fe4b96cc42 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -1178,8 +1178,10 @@ function CompareTabClass:OpenBuySimilarPopup(item, slotName) if item:CheckModLineVariant(modLine) then local formatted = itemLib.formatModLine(modLine) if formatted then - local tradeId = findTradeModId(modLine.line, source.type) - local value = modLineValue(modLine.line) + -- Use range-resolved text for matching + local resolvedLine = (modLine.range and itemLib.applyRange(modLine.line, modLine.range, modLine.valueScalar)) or modLine.line + local tradeId = findTradeModId(resolvedLine, source.type) + local value = modLineValue(resolvedLine) t_insert(modEntries, { line = modLine.line, formatted = formatted:gsub("%^x%x%x%x%x%x%x", ""):gsub("%^%x", ""), -- strip color codes @@ -2876,13 +2878,25 @@ local function getTradeModLookup() for _groupName, mods in pairs(queryModsData) do for _modKey, modData in pairs(mods) do if type(modData) == "table" and modData.tradeMod then - local tmpl = modData.tradeMod.text + local text = modData.tradeMod.text local modType = modData.tradeMod.type or "explicit" - local key = tmpl .. "|" .. modType - _tradeModLookup[key] = modData.tradeMod.id - -- Also store without type for fallback matching - if not _tradeModLookup[tmpl] then - _tradeModLookup[tmpl] = modData.tradeMod.id + local id = modData.tradeMod.id + local key = text .. "|" .. modType + _tradeModLookup[key] = id + if not _tradeModLookup[text] then + _tradeModLookup[text] = id + end + -- Also store with template-converted text for mods with literal numbers + -- (e.g. "1 Added Passive Skill is X" → "# Added Passive Skill is X") + local tmpl = modLineTemplate(text) + if tmpl ~= text then + local tmplKey = tmpl .. "|" .. modType + if not _tradeModLookup[tmplKey] then + _tradeModLookup[tmplKey] = id + end + if not _tradeModLookup[tmpl] then + _tradeModLookup[tmpl] = id + end end end end @@ -2890,8 +2904,54 @@ local function getTradeModLookup() return _tradeModLookup end +-- Helper: lazily fetch and cache the trade API stats for comprehensive mod matching +-- Covers mods not in QueryMods.lua (cluster enchants, unique-specific mods, etc.) +local _tradeStatsLookup = nil +local _tradeStatsFetched = false +local function getTradeStatsLookup() + if _tradeStatsFetched then return _tradeStatsLookup end + _tradeStatsFetched = true + local tradeStats = "" + local easy = common.curl.easy() + if not easy then return nil end + easy:setopt_url("https://www.pathofexile.com/api/trade/data/stats") + easy:setopt_useragent("Path of Building/" .. (launch.versionNumber or "")) + easy:setopt_writefunction(function(d) + tradeStats = tradeStats .. d + return true + end) + local ok = easy:perform() + easy:close() + if not ok or tradeStats == "" then return nil end + local parsed = dkjson.decode(tradeStats) + if not parsed or not parsed.result then return nil end + _tradeStatsLookup = {} + for _, category in ipairs(parsed.result) do + local catLabel = category.label + for _, entry in ipairs(category.entries) do + local stripped = entry.text:gsub("[#()0-9%-%+%.]", "") + local key = stripped .. "|" .. catLabel + if not _tradeStatsLookup[key] then + _tradeStatsLookup[key] = entry + end + if not _tradeStatsLookup[stripped] then + _tradeStatsLookup[stripped] = entry + end + end + end + return _tradeStatsLookup +end + +-- Map source types used in OpenBuySimilarPopup to trade API category labels +local sourceTypeToCategory = { + ["implicit"] = "Implicit", + ["explicit"] = "Explicit", + ["enchant"] = "Enchant", +} + -- Helper: find the trade stat ID for a mod line findTradeModId = function(modLine, modType) + -- Try QueryMods-based lookup local lookup = getTradeModLookup() local tmpl = modLineTemplate(modLine) -- Try exact match with type first @@ -2912,6 +2972,24 @@ findTradeModId = function(modLine, modType) if lookup[stripped] then return lookup[stripped] end + + -- Try trade API stats (covers mods not in QueryMods) + local tradeStats = getTradeStatsLookup() + if tradeStats then + local strippedLine = modLine:gsub("[#()0-9%-%+%.]", "") + local category = sourceTypeToCategory[modType] + if category then + local catKey = strippedLine .. "|" .. category + if tradeStats[catKey] then + return tradeStats[catKey].id + end + end + -- Fallback: any category + if tradeStats[strippedLine] then + return tradeStats[strippedLine].id + end + end + return nil end From b7b98d6574b05d727633c74d32981e327ba3a138 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Sun, 29 Mar 2026 01:57:32 +0100 Subject: [PATCH 21/59] add listing type when buying similar items --- src/Classes/CompareTab.lua | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index fe4b96cc42..0d067baf6a 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -25,6 +25,18 @@ local REALM_API_IDS = { ["Xbox"] = "xbox", } +-- Listed status display names and their API option values +local LISTED_STATUS_OPTIONS = { + { label = "Instant Buyout & In Person", apiValue = "available" }, + { label = "Instant Buyout", apiValue = "securable" }, + { label = "In Person (Online)", apiValue = "online" }, + { label = "Any", apiValue = "any" }, +} +local LISTED_STATUS_LABELS = { } +for i, entry in ipairs(LISTED_STATUS_OPTIONS) do + LISTED_STATUS_LABELS[i] = entry.label +end + -- Layout constants (shared across Draw, DrawConfig, DrawItems, DrawCalcs, etc.) local LAYOUT = { -- Main tab control bar @@ -1155,7 +1167,7 @@ function CompareTabClass:OpenBuySimilarPopup(item, slotName) local isUnique = item.rarity == "UNIQUE" or item.rarity == "RELIC" local controls = {} local rowHeight = 24 - local popupWidth = 550 + local popupWidth = 700 local leftMargin = 20 local minFieldX = popupWidth - 160 local maxFieldX = popupWidth - 80 @@ -1267,6 +1279,12 @@ function CompareTabClass:OpenBuySimilarPopup(item, slotName) end) controls.leagueDrop.enabled = function() return #controls.leagueDrop.list > 0 and controls.leagueDrop.list[1] ~= "Loading..." end + -- Listed status dropdown + controls.listedLabel = new("LabelControl", {"LEFT", controls.leagueDrop, "RIGHT"}, {12, 0, 0, 16}, "^7Listed:") + controls.listedDrop = new("DropDownControl", {"LEFT", controls.listedLabel, "RIGHT"}, {4, 0, 180, 20}, LISTED_STATUS_LABELS, function(index, value) + -- Listed status selection stored in the dropdown itself + end) + -- Fetch initial leagues for default realm fetchLeaguesForRealm("pc") ctrlY = ctrlY + rowHeight + 4 @@ -1378,10 +1396,14 @@ function CompareTabClass:BuildBuySimilarURL(item, slotName, controls, modEntries end local hostName = "https://www.pathofexile.com/" + -- Determine listed status from dropdown + local listedIndex = controls.listedDrop and controls.listedDrop.selIndex or 1 + local listedApiValue = LISTED_STATUS_OPTIONS[listedIndex] and LISTED_STATUS_OPTIONS[listedIndex].apiValue or "available" + -- Build query local queryTable = { query = { - status = { option = "online" }, + status = { option = listedApiValue }, stats = { { type = "and", From 0aeb7f2faa06723ae0e5013ddea203c4fa46368a Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Sun, 29 Mar 2026 04:12:22 +0200 Subject: [PATCH 22/59] handle Ring 3 slot --- src/Classes/CompareTab.lua | 57 ++++++++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 0d067baf6a..516ce45c26 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -2149,6 +2149,9 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories end if categories.items then local baseSlots = { "Weapon 1", "Weapon 2", "Helmet", "Body Armour", "Gloves", "Boots", "Amulet", "Ring 1", "Ring 2", "Belt", "Flask 1", "Flask 2", "Flask 3", "Flask 4", "Flask 5" } + if self:ShouldShowRing3(compareEntry) then + t_insert(baseSlots, 10, "Ring 3") + end for _, slotName in ipairs(baseSlots) do local cSlot = compareEntry.itemsTab and compareEntry.itemsTab.slots[slotName] local cItem = cSlot and compareEntry.itemsTab.items[cSlot.selItemId] @@ -2282,6 +2285,9 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories -- ========================================== if categories.items then local baseSlots = { "Weapon 1", "Weapon 2", "Helmet", "Body Armour", "Gloves", "Boots", "Amulet", "Ring 1", "Ring 2", "Belt", "Flask 1", "Flask 2", "Flask 3", "Flask 4", "Flask 5" } + if self:ShouldShowRing3(compareEntry) then + t_insert(baseSlots, 10, "Ring 3") + end for _, slotName in ipairs(baseSlots) do local cSlot = compareEntry.itemsTab and compareEntry.itemsTab.slots[slotName] local cItem = cSlot and compareEntry.itemsTab.items[cSlot.selItemId] @@ -3097,7 +3103,7 @@ end -- Helper: draw Copy, Copy+Use, and Buy buttons at the given position. -- btnStartX is the left edge where the first button (Buy) should appear. -- Returns copyHovered, copyUseHovered, buyHovered booleans. -local function drawCopyButtons(cursorX, cursorY, btnStartX, btnY) +local function drawCopyButtons(cursorX, cursorY, btnStartX, btnY, slotMissing) local btnW = LAYOUT.itemsCopyBtnW local btnH = LAYOUT.itemsCopyBtnH local buyW = LAYOUT.itemsBuyBtnW @@ -3125,15 +3131,23 @@ local function drawCopyButtons(cursorX, cursorY, btnStartX, btnY) SetDrawColor(1, 1, 1) DrawString(btn1X + btnW / 2, btnY + 1, "CENTER_X", 14, "VAR", "^7Copy") - -- "Copy+Use" button - local b2Hover = cursorX >= btn2X and cursorX < btn2X + btnW - and cursorY >= btnY and cursorY < btnY + btnH - SetDrawColor(b2Hover and 0.5 or 0.35, b2Hover and 0.5 or 0.35, b2Hover and 0.5 or 0.35) - DrawImage(nil, btn2X, btnY, btnW, btnH) - SetDrawColor(0.1, 0.1, 0.1) - DrawImage(nil, btn2X + 1, btnY + 1, btnW - 2, btnH - 2) - SetDrawColor(1, 1, 1) - DrawString(btn2X + btnW / 2, btnY + 1, "CENTER_X", 14, "VAR", "^7Copy+Use") + local b2Hover + if slotMissing then + -- Show "Missing slot" label instead of Copy+Use button + SetDrawColor(1, 1, 1) + DrawString(btn2X + btnW / 2, btnY + 1, "CENTER_X", 14, "VAR", "^xBBBBBBMissing slot") + b2Hover = false + else + -- "Copy+Use" button + b2Hover = cursorX >= btn2X and cursorX < btn2X + btnW + and cursorY >= btnY and cursorY < btnY + btnH + SetDrawColor(b2Hover and 0.5 or 0.35, b2Hover and 0.5 or 0.35, b2Hover and 0.5 or 0.35) + DrawImage(nil, btn2X, btnY, btnW, btnH) + SetDrawColor(0.1, 0.1, 0.1) + DrawImage(nil, btn2X + 1, btnY + 1, btnW - 2, btnH - 2) + SetDrawColor(1, 1, 1) + DrawString(btn2X + btnW / 2, btnY + 1, "CENTER_X", 14, "VAR", "^7Copy+Use") + end return b1Hover, b2Hover, b3Hover, btn2X, btnY, btnW, btnH end @@ -3162,7 +3176,7 @@ local ITEM_BOX_W = 310 local ITEM_BOX_H = 20 local function drawCompactSlotRow(drawY, slotLabel, pItem, cItem, - colWidth, cursorX, cursorY, maxLabelW, primaryItemsTab, compareItemsTab, pWarn, cWarn) + colWidth, cursorX, cursorY, maxLabelW, primaryItemsTab, compareItemsTab, pWarn, cWarn, slotMissing) local pName = pItem and pItem.name or "(empty)" local cName = cItem and cItem.name or "(empty)" @@ -3219,7 +3233,7 @@ local function drawCompactSlotRow(drawY, slotLabel, pItem, cItem, if cItem then local btnStartX = cBoxX + cBoxW + 6 b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H = - drawCopyButtons(cursorX, cursorY, btnStartX, drawY + 1) + drawCopyButtons(cursorX, cursorY, btnStartX, drawY + 1, slotMissing) end -- Determine hovered item and tooltip anchor position @@ -3405,8 +3419,21 @@ function CompareTabClass:DrawItemExpanded(item, x, startY, colWidth, otherModMap return drawY - startY end +function CompareTabClass:ShouldShowRing3(compareEntry) + local primaryEnv = self.primaryBuild.calcsTab and self.primaryBuild.calcsTab.mainEnv + local compareEnv = compareEntry.calcsTab and compareEntry.calcsTab.mainEnv + local primaryHas = primaryEnv and primaryEnv.modDB:Flag(nil, "AdditionalRingSlot") + local compareHas = compareEnv and compareEnv.modDB:Flag(nil, "AdditionalRingSlot") + return primaryHas or compareHas +end + function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) local baseSlots = { "Weapon 1", "Weapon 2", "Helmet", "Body Armour", "Gloves", "Boots", "Amulet", "Ring 1", "Ring 2", "Belt", "Flask 1", "Flask 2", "Flask 3", "Flask 4", "Flask 5" } + if self:ShouldShowRing3(compareEntry) then + t_insert(baseSlots, 10, "Ring 3") + end + local primaryEnv = self.primaryBuild.calcsTab and self.primaryBuild.calcsTab.mainEnv + local primaryHasRing3 = primaryEnv and primaryEnv.modDB:Flag(nil, "AdditionalRingSlot") local lineHeight = 20 local colWidth = m_floor(vp.width / 2) @@ -3470,7 +3497,8 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) -- Copy/Buy buttons for compare item if cItem then - local b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H = drawCopyButtons(cursorX, cursorY, vp.width - 196, drawY + 1) + local slotMissing = slotName == "Ring 3" and not primaryHasRing3 + local b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H = drawCopyButtons(cursorX, cursorY, vp.width - 196, drawY + 1, slotMissing) if b2Hover then hoverCopyUseItem = cItem hoverCopyUseSlotName = slotName @@ -3519,7 +3547,8 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) rowHoverItem, rowHoverItemsTab, rowHoverX, rowHoverY, rowHoverW, rowHoverH = drawCompactSlotRow(drawY, slotName, pItem, cItem, colWidth, cursorX, cursorY, maxLabelW, - self.primaryBuild.itemsTab, compareEntry.itemsTab) + self.primaryBuild.itemsTab, compareEntry.itemsTab, nil, nil, + slotName == "Ring 3" and not primaryHasRing3) if rowHoverItem then hoverItem = rowHoverItem From b9b24e9be2a80985e5cf03742f5eb6a2c57eac05 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Mon, 30 Mar 2026 21:19:06 +0200 Subject: [PATCH 23/59] persist compared imported builds through saves --- src/Classes/CompareTab.lua | 62 ++++++++++++++++++++++++++++++++++++++ src/Modules/Build.lua | 1 + 2 files changed, 63 insertions(+) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 516ce45c26..61154c94c8 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -963,6 +963,68 @@ function CompareTabClass:ImportFromCode(code) return self:ImportBuild(xmlText, "Imported build") end +-- Save comparison builds to the build file +function CompareTabClass:Save(xml) + xml.attrib = { + activeCompareIndex = tostring(self.activeCompareIndex), + } + for _, entry in ipairs(self.compareEntries) do + local attrib = { + label = entry.label, + buildCode = common.base64.encode(Deflate(entry.xmlText)):gsub("+","-"):gsub("/","_"), + } + if entry.treeTab then + attrib.activeSpec = tostring(entry.treeTab.activeSpec) + end + if entry.skillsTab then + attrib.activeSkillSetId = tostring(entry.skillsTab.activeSkillSetId) + end + if entry.itemsTab then + attrib.activeItemSetId = tostring(entry.itemsTab.activeItemSetId) + end + t_insert(xml, { + elem = "CompareEntry", + attrib = attrib, + }) + end +end + +-- Load comparison builds from the build file +function CompareTabClass:Load(xml, dbFileName) + local savedIndex = tonumber(xml.attrib and xml.attrib.activeCompareIndex) or 0 + for _, child in ipairs(xml) do + if type(child) == "table" and child.elem == "CompareEntry" then + local code = child.attrib and child.attrib.buildCode + if code then + local xmlText = Inflate(common.base64.decode(code:gsub("-","+"):gsub("_","/"))) + if xmlText then + if self:ImportBuild(xmlText, child.attrib.label or "Comparison Build") then + local entry = self.compareEntries[#self.compareEntries] + local savedSpec = tonumber(child.attrib.activeSpec) + if savedSpec and entry.treeTab and entry.treeTab.specList[savedSpec] then + entry:SetActiveSpec(savedSpec) + end + local savedSkillSet = tonumber(child.attrib.activeSkillSetId) + if savedSkillSet and entry.skillsTab then + entry:SetActiveSkillSet(savedSkillSet) + end + local savedItemSet = tonumber(child.attrib.activeItemSetId) + if savedItemSet and entry.itemsTab then + entry:SetActiveItemSet(savedItemSet) + end + end + end + end + end + end + if #self.compareEntries > 0 then + self.activeCompareIndex = m_max(1, m_min(savedIndex, #self.compareEntries)) + else + self.activeCompareIndex = 0 + end + self:UpdateBuildSelector() +end + -- Remove a comparison build function CompareTabClass:RemoveBuild(index) if index >= 1 and index <= #self.compareEntries then diff --git a/src/Modules/Build.lua b/src/Modules/Build.lua index aa5987ae10..e5fa932bb8 100644 --- a/src/Modules/Build.lua +++ b/src/Modules/Build.lua @@ -585,6 +585,7 @@ function buildMode:Init(dbFileName, buildName, buildXML, convertBuild, importLin ["Skills"] = self.skillsTab, ["Calcs"] = self.calcsTab, ["Import"] = self.importTab, + ["Compare"] = self.compareTab, } self.legacyLoaders = { -- Special loaders for legacy sections ["Spec"] = self.treeTab, From a78c8232f31e6c87833fcf476d3e6bbb6a801863 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Mon, 30 Mar 2026 22:13:05 +0200 Subject: [PATCH 24/59] add hover effects on 'tree' and 'item' list items in the compare power report --- src/Classes/ComparePowerReportListControl.lua | 37 +++++++++++++++++++ src/Classes/CompareTab.lua | 5 +++ 2 files changed, 42 insertions(+) diff --git a/src/Classes/ComparePowerReportListControl.lua b/src/Classes/ComparePowerReportListControl.lua index 30b61c2b31..1df2699180 100644 --- a/src/Classes/ComparePowerReportListControl.lua +++ b/src/Classes/ComparePowerReportListControl.lua @@ -50,6 +50,10 @@ function ComparePowerReportListClass:SetProgress(progress) end function ComparePowerReportListClass:Draw(viewPort, noTooltip) + if self.hoverIndex ~= self.lastTooltipIndex then + self.tooltip.updateParams = nil + end + self.lastTooltipIndex = self.hoverIndex self.ListControl.Draw(self, viewPort, noTooltip) -- Draw status text below column headers when the list is empty if #self.list == 0 and self.statusText then @@ -105,6 +109,39 @@ function ComparePowerReportListClass:ReList() end end +function ComparePowerReportListClass:AddValueTooltip(tooltip, index, entry) + if main.popups[1] then + tooltip:Clear() + return + end + + local build = self.compareTab and self.compareTab.primaryBuild + if not build then + tooltip:Clear() + return + end + + if entry.category == "Tree" and entry.nodeId then + local node = build.spec.nodes[entry.nodeId] + if node then + if tooltip:CheckForUpdate(node, IsKeyDown("SHIFT"), launch.devModeAlt, build.outputRevision) then + local viewer = build.treeTab and build.treeTab.viewer + if viewer then + viewer:AddNodeTooltip(tooltip, node, build) + end + end + else + tooltip:Clear() + end + elseif entry.category == "Item" and entry.itemObj then + if tooltip:CheckForUpdate(entry.itemObj, IsKeyDown("SHIFT"), launch.devModeAlt, build.outputRevision) then + build.itemsTab:AddItemTooltip(tooltip, entry.itemObj) + end + else + tooltip:Clear() + end +end + function ComparePowerReportListClass:GetRowValue(column, index, entry) if column == 1 then return (entry.categoryColor or "^7") .. entry.category diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 61154c94c8..dd44b1c0dc 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -692,6 +692,7 @@ function CompareTabClass:InitControls() -- Power report list control (static height, own scrollbar) self.controls.comparePowerReportList = new("ComparePowerReportListControl", nil, {0, 0, 750, 250}) + self.controls.comparePowerReportList.compareTab = self self.controls.comparePowerReportList.shown = powerReportShown end @@ -2322,6 +2323,7 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories categoryColor = "^7", nameColor = "^7", name = pNode.dn, + nodeId = nodeId, impact = impactVal, impactStr = impactStr, impactPercent = impactPercent, @@ -2368,6 +2370,8 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories categoryColor = rarityColor, nameColor = rarityColor, name = (cItem.name or "Unknown") .. ", " .. slotName, + itemObj = newItem, + slotName = slotName, impact = impactVal, impactStr = impactStr, impactPercent = impactPercent, @@ -2449,6 +2453,7 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories categoryColor = rarityColor, nameColor = rarityColor, name = (jEntry.cItem.name or "Unknown") .. ", " .. bestSlotLabel, + itemObj = newItem, impact = impactVal, impactStr = impactStr, impactPercent = impactPercent, From cfd0871224c804c1694d7bbfeb386911d028ed42 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Fri, 3 Apr 2026 02:54:38 +0200 Subject: [PATCH 25/59] rework interface of configuration comparison --- src/Classes/CompareTab.lua | 498 +++++++++++++++++++++++++------------ 1 file changed, 339 insertions(+), 159 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index dd44b1c0dc..300d7a0602 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -70,12 +70,14 @@ local LAYOUT = { -- Config view (shared between Draw() layout and DrawConfig()) configRowHeight = 22, - configSectionHeaderHeight = 24, configColumnHeaderHeight = 20, - configFixedHeaderHeight = 66, - configCol1 = 10, - configCol2 = 300, - configCol3 = 500, + configFixedHeaderHeight = 92, + configSectionWidth = 560, + configSectionGap = 18, + configSectionInnerPad = 20, + configLabelOffset = 10, + configCol2 = 234, + configCol3 = 400, } -- Flag matching for stat filtering @@ -143,7 +145,9 @@ local CompareTabClass = newClass("CompareTab", "ControlHost", "Control", functio self.configNeedsRebuild = true -- trigger initial build self.configCompareId = nil -- track which compare entry controls were built for self.configToggle = false -- show all / hide ineligible toggle - self.configDisplayList = {} -- computed display order (headers + rows) + self.configSections = {} -- section groups from ConfigOptions + self.configSectionLayout = {} -- computed section layout for drawing + self.configTotalContentHeight = 0 -- Compare power report state self.comparePowerStat = nil -- selected data.powerStatList entry @@ -292,6 +296,22 @@ function CompareTabClass:InitControls() end end) self.controls.compareItemSetSelect.enabled = setsEnabled + -- Config set selector for comparison build + self.controls.compareConfigSetLabel = new("LabelControl", {"LEFT", self.controls.compareItemSetSelect, "RIGHT"}, {8, 0, 0, 16}, "^7Config set:") + self.controls.compareConfigSetLabel.shown = setsEnabled + self.controls.compareConfigSetSelect = new("DropDownControl", {"LEFT", self.controls.compareConfigSetLabel, "RIGHT"}, {2, 0, 150, 20}, {}, function(index, value) + local entry = self:GetActiveCompare() + if entry and entry.configTab then + local setId = entry.configTab.configSetOrderList[index] + if setId then + entry.configTab:SetActiveConfigSet(setId) + entry.buildFlag = true + self.configNeedsRebuild = true + end + end + end) + self.controls.compareConfigSetSelect.enabled = setsEnabled + self.controls.compareConfigSetSelect.enableDroppedWidth = true -- ============================================================ -- Comparison build main skill selector (row between sets and sub-tabs) @@ -633,6 +653,32 @@ function CompareTabClass:InitControls() return self.compareViewMode == "CONFIG" and self:GetActiveCompare() ~= nil end + -- Config view: search bar + self.controls.configSearchEdit = new("EditControl", nil, {0, 0, 200, 20}, "", "Search", "%c", 100, nil, nil, nil, true) + self.controls.configSearchEdit.shown = function() + return self.compareViewMode == "CONFIG" and self:GetActiveCompare() ~= nil + end + + -- Config view: primary build config set dropdown + local configShown = function() + return self.compareViewMode == "CONFIG" and self:GetActiveCompare() ~= nil + end + self.controls.configPrimarySetLabel = new("LabelControl", nil, {0, 0, 0, 16}, "^7Config set:") + self.controls.configPrimarySetLabel.shown = configShown + self.controls.configPrimarySetSelect = new("DropDownControl", nil, {0, 0, 150, 20}, nil, function(index, value) + local configTab = self.primaryBuild.configTab + local setId = configTab.configSetOrderList[index] + if setId then + configTab:SetActiveConfigSet(setId) + self.configNeedsRebuild = true + end + end) + self.controls.configPrimarySetSelect.shown = configShown + self.controls.configPrimarySetSelect.enableDroppedWidth = true + self.controls.configPrimarySetSelect.enabled = function() + return #self.primaryBuild.configTab.configSetOrderList > 1 + end + -- ============================================================ -- Compare Power Report controls (Summary view) -- ============================================================ @@ -841,59 +887,81 @@ function CompareTabClass:NormalizeConfigVals(varData, pVal, cVal) return pVal, cVal end --- Rebuild interactive config controls for all config options +-- Create a single config control for a given varData, writing to the specified input/configTab/build +local function makeConfigControl(varData, inputTable, configTab, buildObj) + local control + local pVal = inputTable[varData.var] + if varData.type == "check" then + control = new("CheckBoxControl", nil, {0, 0, 18}, nil, function(state) + inputTable[varData.var] = state + configTab:UpdateControls() + configTab:BuildModList() + buildObj.buildFlag = true + end) + control.state = pVal or false + elseif varData.type == "count" or varData.type == "integer" + or varData.type == "countAllowZero" or varData.type == "float" then + local filter = (varData.type == "integer" and "^%-%d") + or (varData.type == "float" and "^%d.") or "%D" + control = new("EditControl", nil, {0, 0, 90, 18}, + tostring(pVal or ""), nil, filter, 7, + function(buf) + inputTable[varData.var] = tonumber(buf) + configTab:UpdateControls() + configTab:BuildModList() + buildObj.buildFlag = true + end) + elseif varData.type == "list" and varData.list then + control = new("DropDownControl", nil, {0, 0, 150, 18}, + varData.list, function(index, value) + inputTable[varData.var] = value.val + configTab:UpdateControls() + configTab:BuildModList() + buildObj.buildFlag = true + end) + control:SelByValue(pVal or (varData.list[1] and varData.list[1].val), "val") + end + if control then + control.shown = function() return false end + end + return control +end + +-- Rebuild interactive config controls for all config options (both primary and compare builds) function CompareTabClass:RebuildConfigControls(compareEntry) -- Remove old config controls for var, _ in pairs(self.configControls) do - self.controls["cfg_" .. var] = nil + self.controls["cfg_p_" .. var] = nil + self.controls["cfg_c_" .. var] = nil end self.configControls = {} self.configControlList = {} + self.configSections = {} if not compareEntry then return end local configOptions = self.configOptions local pInput = self.primaryBuild.configTab.input or {} + local cInput = compareEntry.configTab.input or {} local primaryBuild = self.primaryBuild + local currentSection = nil for _, varData in ipairs(configOptions) do - if varData.var and varData.type ~= "text" then - local pVal = pInput[varData.var] - local control - if varData.type == "check" then - control = new("CheckBoxControl", nil, {0, 0, 18}, nil, function(state) - primaryBuild.configTab.input[varData.var] = state - primaryBuild.configTab:UpdateControls() - primaryBuild.configTab:BuildModList() - primaryBuild.buildFlag = true - end) - control.state = pVal or false - elseif varData.type == "count" or varData.type == "integer" - or varData.type == "countAllowZero" or varData.type == "float" then - local filter = (varData.type == "integer" and "^%-%d") - or (varData.type == "float" and "^%d.") or "%D" - control = new("EditControl", nil, {0, 0, 90, 18}, - tostring(pVal or ""), nil, filter, 7, - function(buf) - primaryBuild.configTab.input[varData.var] = tonumber(buf) - primaryBuild.configTab:UpdateControls() - primaryBuild.configTab:BuildModList() - primaryBuild.buildFlag = true - end) - elseif varData.type == "list" and varData.list then - control = new("DropDownControl", nil, {0, 0, 150, 18}, - varData.list, function(index, value) - primaryBuild.configTab.input[varData.var] = value.val - primaryBuild.configTab:UpdateControls() - primaryBuild.configTab:BuildModList() - primaryBuild.buildFlag = true - end) - control:SelByValue(pVal or (varData.list[1] and varData.list[1].val), "val") + if varData.section then + -- Skip "Custom Modifiers" section + if varData.section ~= "Custom Modifiers" then + currentSection = { name = varData.section, col = varData.col, items = {} } + t_insert(self.configSections, currentSection) + else + currentSection = nil end + elseif currentSection and varData.var and varData.type ~= "text" then + local pCtrl = makeConfigControl(varData, pInput, self.primaryBuild.configTab, primaryBuild) + local cCtrl = makeConfigControl(varData, cInput, compareEntry.configTab, compareEntry) - if control then - control.shown = function() return false end -- hidden until positioned - self.controls["cfg_" .. varData.var] = control + if pCtrl and cCtrl then + self.controls["cfg_p_" .. varData.var] = pCtrl + self.controls["cfg_c_" .. varData.var] = cCtrl -- Determine eligibility category (matches ConfigTab's isShowAllConfig logic) local isHardConditional = varData.ifOption or varData.ifSkill @@ -914,16 +982,16 @@ function CompareTabClass:RebuildConfigControls(compareEntry) or varData.ifEnemyStat or varData.ifEnemyCond or varData.legacy local ctrlInfo = { - control = control, + primaryControl = pCtrl, + compareControl = cCtrl, varData = varData, visible = false, - -- Always shown in "All Configurations" (no conditions at all) alwaysShow = not hasAnyCondition and not isKeywordExcluded, - -- Shown in "All Configurations" when toggle is ON (simple conditions only) showWithToggle = not isHardConditional and not isKeywordExcluded, } self.configControls[varData.var] = ctrlInfo t_insert(self.configControlList, ctrlInfo) + t_insert(currentSection.items, ctrlInfo) end end end @@ -983,6 +1051,9 @@ function CompareTabClass:Save(xml) if entry.itemsTab then attrib.activeItemSetId = tostring(entry.itemsTab.activeItemSetId) end + if entry.configTab then + attrib.activeConfigSetId = tostring(entry.configTab.activeConfigSetId) + end t_insert(xml, { elem = "CompareEntry", attrib = attrib, @@ -1013,6 +1084,10 @@ function CompareTabClass:Load(xml, dbFileName) if savedItemSet and entry.itemsTab then entry:SetActiveItemSet(savedItemSet) end + local savedConfigSet = tonumber(child.attrib.activeConfigSetId) + if savedConfigSet and entry.configTab and entry.configTab.configSets[savedConfigSet] then + entry.configTab:SetActiveConfigSet(savedConfigSet) + end end end end @@ -1936,7 +2011,19 @@ function CompareTabClass:LayoutTreeView(contentVP, compareEntry) end end --- Position config controls and build display list when in CONFIG view. +-- Sync a single control's displayed value with the actual input value +local function syncControlValue(ctrl, varData, val) + if varData.type == "check" then + ctrl.state = val or false + elseif varData.type == "count" or varData.type == "integer" + or varData.type == "countAllowZero" or varData.type == "float" then + ctrl:SetText(tostring(val or "")) + elseif varData.type == "list" then + ctrl:SelByValue(val or (varData.list[1] and varData.list[1].val), "val") + end +end + +-- Position config controls and build section-grouped display when in CONFIG view. function CompareTabClass:LayoutConfigView(contentVP, compareEntry) if self.compareViewMode ~= "CONFIG" or not compareEntry then return end @@ -1947,101 +2034,156 @@ function CompareTabClass:LayoutConfigView(contentVP, compareEntry) self.configNeedsRebuild = false end - -- Sync control values with current primary input (in case changed from normal Config tab) + -- Sync control values with current input (in case changed from normal Config tab or externally) local pInput = self.primaryBuild.configTab.input or {} + local cInput = compareEntry.configTab.input or {} for var, ctrlInfo in pairs(self.configControls) do - local ctrl = ctrlInfo.control local varData = ctrlInfo.varData - local pVal = pInput[var] - if varData.type == "check" then - ctrl.state = pVal or false - elseif varData.type == "count" or varData.type == "integer" - or varData.type == "countAllowZero" or varData.type == "float" then - ctrl:SetText(tostring(pVal or "")) - elseif varData.type == "list" then - ctrl:SelByValue(pVal or (varData.list[1] and varData.list[1].val), "val") - end + syncControlValue(ctrlInfo.primaryControl, varData, pInput[var]) + syncControlValue(ctrlInfo.compareControl, varData, cInput[var]) end - -- Position buttons at top of config view (above column headers) + -- Position header controls + local row1Y = contentVP.y + 4 + local row2Y = contentVP.y + 28 self.controls.copyConfigBtn.x = contentVP.x + 10 - self.controls.copyConfigBtn.y = contentVP.y + 8 + self.controls.copyConfigBtn.y = row1Y self.controls.configToggleBtn.x = contentVP.x + 260 - self.controls.configToggleBtn.y = contentVP.y + 8 + self.controls.configToggleBtn.y = row1Y - -- Build display list: Differences section first, then All Configurations - local cInput = compareEntry.configTab.input or {} - local displayList = {} - local rowHeight = LAYOUT.configRowHeight - local sectionHeaderHeight = LAYOUT.configSectionHeaderHeight + self.controls.configSearchEdit.x = contentVP.x + 10 + self.controls.configSearchEdit.y = row2Y - -- Collect differences - local diffs = {} - for _, ctrlInfo in ipairs(self.configControlList) do - local pVal = pInput[ctrlInfo.varData.var] - local cVal = cInput[ctrlInfo.varData.var] - if tostring(pVal or "") ~= tostring(cVal or "") then - t_insert(diffs, ctrlInfo) + -- Update primary config set dropdown list + local pConfigTab = self.primaryBuild.configTab + local pSetList = {} + for index, setId in ipairs(pConfigTab.configSetOrderList) do + local configSet = pConfigTab.configSets[setId] + t_insert(pSetList, configSet and configSet.title or "Default") + if setId == pConfigTab.activeConfigSetId then + self.controls.configPrimarySetSelect.selIndex = index end end + self.controls.configPrimarySetSelect:SetList(pSetList) + self.controls.configPrimarySetLabel.x = contentVP.x + 220 + self.controls.configPrimarySetLabel.y = row2Y + 2 + self.controls.configPrimarySetSelect.x = contentVP.x + 290 + self.controls.configPrimarySetSelect.y = row2Y - -- Differences section - if #diffs > 0 then - t_insert(displayList, { type = "header", text = "Differences (" .. #diffs .. ")" }) - for _, ctrlInfo in ipairs(diffs) do - t_insert(displayList, { type = "row", ctrlInfo = ctrlInfo }) - end - end + -- Build section layout: multi-column grid, mirroring regular ConfigTab + local rowHeight = LAYOUT.configRowHeight + local sectionInnerPad = LAYOUT.configSectionInnerPad + local sectionGap = LAYOUT.configSectionGap + local fixedHeaderHeight = LAYOUT.configFixedHeaderHeight + local sectionWidth = LAYOUT.configSectionWidth + local scrollableH = contentVP.height - fixedHeaderHeight - -- Collect eligible non-diff options for "All Configurations" section - local configs = {} + -- Hide ALL config controls first (selectively shown below) for _, ctrlInfo in ipairs(self.configControlList) do - local pVal = pInput[ctrlInfo.varData.var] - local cVal = cInput[ctrlInfo.varData.var] - -- Only include non-diff options - if tostring(pVal or "") == tostring(cVal or "") then - if ctrlInfo.alwaysShow or (self.configToggle and ctrlInfo.showWithToggle) then - t_insert(configs, ctrlInfo) + ctrlInfo.primaryControl.shown = function() return false end + ctrlInfo.compareControl.shown = function() return false end + end + + -- Search filter: match config labels against search text + local searchStr = self.controls.configSearchEdit.buf:lower():gsub("[%-%.%+%[%]%$%^%%%?%*]", "%%%0") + local hasSearch = searchStr and searchStr:match("%S") + local function searchMatch(varData) + if not hasSearch then return true end + local err, match = PCall(string.matchOrPattern, (varData.label or ""):lower(), searchStr) + return not err and match + end + + -- First pass: compute rows and height for each section + local visibleSections = {} + for _, section in ipairs(self.configSections) do + local diffs = {} + local commons = {} + for _, ctrlInfo in ipairs(section.items) do + if searchMatch(ctrlInfo.varData) then + local pVal, cVal = self:NormalizeConfigVals(ctrlInfo.varData, + pInput[ctrlInfo.varData.var], cInput[ctrlInfo.varData.var]) + local isDiff = tostring(pVal) ~= tostring(cVal) + if isDiff then + t_insert(diffs, ctrlInfo) + elseif ctrlInfo.alwaysShow or (self.configToggle and ctrlInfo.showWithToggle) then + t_insert(commons, ctrlInfo) + end end end - end - if #configs > 0 then - t_insert(displayList, { type = "header", text = "All Configurations" }) - for _, ctrlInfo in ipairs(configs) do - t_insert(displayList, { type = "row", ctrlInfo = ctrlInfo }) + local rows = {} + for _, ci in ipairs(diffs) do t_insert(rows, { ctrlInfo = ci, isDiff = true }) end + for _, ci in ipairs(commons) do t_insert(rows, { ctrlInfo = ci, isDiff = false }) end + + if #rows > 0 then + local sectionHeight = sectionInnerPad + #rows * rowHeight + 8 + t_insert(visibleSections, { + name = section.name, + col = section.col, + rows = rows, + height = sectionHeight, + diffCount = #diffs, + }) end end - self.configDisplayList = displayList + -- Second pass: multi-column placement (same algorithm as ConfigTab) + local maxCol = m_floor((contentVP.width - 10) / sectionWidth) + if maxCol < 1 then maxCol = 1 end + local colY = { 0 } + local maxColY = 0 + local sectionLayout = {} - -- First, hide ALL config controls (will selectively show visible ones) - for _, ctrlInfo in ipairs(self.configControlList) do - ctrlInfo.control.shown = function() return false end - end - - -- Position visible controls at absolute coords matching DrawConfig layout - local col2AbsX = contentVP.x + LAYOUT.configCol2 - local fixedHeaderHeight = LAYOUT.configFixedHeaderHeight - local scrollTopAbs = contentVP.y + fixedHeaderHeight -- top of scrollable area - local startY = fixedHeaderHeight -- content starts after fixed header - local currentY = startY - for _, item in ipairs(displayList) do - if item.type == "header" then - currentY = currentY + sectionHeaderHeight - elseif item.type == "row" then - local absY = contentVP.y + currentY - self.scrollY - item.ctrlInfo.control.x = col2AbsX - item.ctrlInfo.control.y = absY - local cy = currentY -- capture for closure - item.ctrlInfo.control.shown = function() - local ay = contentVP.y + cy - self.scrollY + for _, sec in ipairs(visibleSections) do + local h = sec.height + local col + -- Try preferred column if it fits + if sec.col and (colY[sec.col] or 0) + h + 28 <= scrollableH + and 10 + sec.col * sectionWidth <= contentVP.width then + col = sec.col + else + -- Find shortest column + col = 1 + for c = 2, maxCol do + colY[c] = colY[c] or 0 + if colY[c] < colY[col] then + col = c + end + end + end + colY[col] = colY[col] or 0 + sec.x = 10 + (col - 1) * sectionWidth + sec.y = colY[col] + sectionGap + colY[col] = colY[col] + h + sectionGap + maxColY = m_max(maxColY, colY[col]) + t_insert(sectionLayout, sec) + end + + -- Third pass: position controls at absolute coords + local scrollTopAbs = contentVP.y + fixedHeaderHeight + for _, sec in ipairs(sectionLayout) do + local sectionAbsX = contentVP.x + sec.x + local rowY = sec.y + sectionInnerPad + for _, row in ipairs(sec.rows) do + local ci = row.ctrlInfo + ci.primaryControl.x = sectionAbsX + LAYOUT.configCol2 + ci.primaryControl.y = contentVP.y + fixedHeaderHeight + rowY - self.scrollY + ci.compareControl.x = sectionAbsX + LAYOUT.configCol3 + ci.compareControl.y = contentVP.y + fixedHeaderHeight + rowY - self.scrollY + local capturedRowY = rowY + local shownFn = function() + local ay = contentVP.y + fixedHeaderHeight + capturedRowY - self.scrollY return ay >= scrollTopAbs - 20 and ay < contentVP.y + contentVP.height and self.compareViewMode == "CONFIG" and self:GetActiveCompare() ~= nil end - currentY = currentY + rowHeight + ci.primaryControl.shown = shownFn + ci.compareControl.shown = shownFn + rowY = rowY + rowHeight end end + + self.configSectionLayout = sectionLayout + self.configTotalContentHeight = maxColY + sectionGap end -- Update comparison build set selectors (spec, skill set, item set, skill controls). @@ -2075,6 +2217,18 @@ function CompareTabClass:UpdateSetSelectors(compareEntry) end self.controls.compareItemSetSelect:SetList(itemList) end + -- Config set list + if compareEntry.configTab then + local configList = {} + for index, configSetId in ipairs(compareEntry.configTab.configSetOrderList) do + local configSet = compareEntry.configTab.configSets[configSetId] + t_insert(configList, configSet and configSet.title or "Default") + if configSetId == compareEntry.configTab.activeConfigSetId then + self.controls.compareConfigSetSelect.selIndex = index + end + end + self.controls.compareConfigSetSelect:SetList(configList) + end -- Refresh comparison build skill selector controls local cmpControls = { @@ -2102,7 +2256,14 @@ function CompareTabClass:HandleScrollInput(contentVP, inputEvents) self.scrollY = m_max(self.scrollY - 40, 0) inputEvents[id] = nil elseif event.key == "WHEELDOWN" and self.compareViewMode ~= "TREE" then - self.scrollY = self.scrollY + 40 + local maxScroll = 0 + if self.compareViewMode == "CONFIG" and self.configTotalContentHeight then + local scrollViewH = contentVP.height - LAYOUT.configFixedHeaderHeight + maxScroll = m_max(self.configTotalContentHeight - scrollViewH, 0) + else + maxScroll = 99999 + end + self.scrollY = m_min(self.scrollY + 40, maxScroll) inputEvents[id] = nil end end @@ -4617,30 +4778,27 @@ end -- ============================================================ function CompareTabClass:DrawConfig(vp, compareEntry) local rowHeight = LAYOUT.configRowHeight - local sectionHeaderHeight = LAYOUT.configSectionHeaderHeight local columnHeaderHeight = LAYOUT.configColumnHeaderHeight local fixedHeaderHeight = LAYOUT.configFixedHeaderHeight + local sectionInnerPad = LAYOUT.configSectionInnerPad + local sectionWidth = LAYOUT.configSectionWidth + local labelOffset = LAYOUT.configLabelOffset - -- Column positions (viewport-relative) - local col1 = LAYOUT.configCol1 - local col2 = LAYOUT.configCol2 - local col3 = LAYOUT.configCol3 - - -- Fixed header area: buttons at top, then column headers + separator + -- Fixed header area: row 1 = buttons, row 2 = search/dropdowns, then column headers + separator SetViewport(vp.x, vp.y, vp.width, fixedHeaderHeight) - -- Buttons (Copy Config + Toggle) are drawn by ControlHost at y=8 - -- Column headers below buttons - local colHeaderY = 36 + -- Controls are drawn by ControlHost (positioned in LayoutConfigView) + local colHeaderY = 54 SetDrawColor(1, 1, 1) - DrawString(col1, colHeaderY, "LEFT", columnHeaderHeight, "VAR", "^7Configuration Option") - DrawString(col2, colHeaderY, "LEFT", columnHeaderHeight, "VAR", + -- Column headers aligned with first column's control offsets + local headerBaseX = 10 + DrawString(headerBaseX + LAYOUT.configCol2, colHeaderY, "LEFT", columnHeaderHeight, "VAR", colorCodes.POSITIVE .. self:GetShortBuildName(self.primaryBuild.buildName)) - DrawString(col3, colHeaderY, "LEFT", columnHeaderHeight, "VAR", + DrawString(headerBaseX + LAYOUT.configCol3, colHeaderY, "LEFT", columnHeaderHeight, "VAR", colorCodes.WARNING .. (compareEntry.label or "Compare Build")) SetDrawColor(0.5, 0.5, 0.5) DrawImage(nil, 4, colHeaderY + columnHeaderHeight + 4, vp.width - 8, 2) - -- Scrollable content area (clipped below fixed header so content can't bleed through buttons) + -- Scrollable content area (clipped below fixed header) local scrollH = vp.height - fixedHeaderHeight if scrollH <= 0 then SetViewport() @@ -4648,39 +4806,61 @@ function CompareTabClass:DrawConfig(vp, compareEntry) end SetViewport(vp.x, vp.y + fixedHeaderHeight, vp.width, scrollH) - local cInput = compareEntry.configTab.input or {} - local currentY = 0 -- relative to scrollable viewport - - -- Draw from the computed display list (built in Draw()) - for _, item in ipairs(self.configDisplayList) do - if item.type == "header" then - local headerY = currentY - self.scrollY - if headerY + sectionHeaderHeight >= 0 and headerY < scrollH then - -- Section header text - SetDrawColor(1, 1, 1) - DrawString(col1, headerY + 4, "LEFT", 16, "VAR BOLD", "^7" .. item.text) - -- Thin separator below header - SetDrawColor(0.4, 0.4, 0.4) - DrawImage(nil, col1, headerY + sectionHeaderHeight - 2, vp.width - col1 * 2, 1) + -- Draw section boxes + for _, sec in ipairs(self.configSectionLayout) do + local boxX = sec.x + local boxY = sec.y - self.scrollY + local boxH = sec.height + + -- Skip entirely off-screen sections + if boxY + boxH >= 0 and boxY < scrollH then + -- Draw section box + SetDrawLayer(nil, -10) + SetDrawColor(0.66, 0.66, 0.66) + DrawImage(nil, boxX, boxY, sectionWidth, boxH) + SetDrawColor(0.1, 0.1, 0.1) + DrawImage(nil, boxX + 2, boxY + 2, sectionWidth - 4, boxH - 4) + SetDrawLayer(nil, 0) + + -- Draw section label badge + local labelText = sec.name + if sec.diffCount > 0 then + labelText = labelText .. " (" .. sec.diffCount .. " diff)" end - currentY = currentY + sectionHeaderHeight - elseif item.type == "row" then - local rowY = currentY - self.scrollY - if rowY + rowHeight >= 0 and rowY < scrollH then - local varData = item.ctrlInfo.varData - -- Label (col1) - SetDrawColor(1, 1, 1) - DrawString(col1, rowY + 2, "LEFT", 16, "VAR", "^7" .. (varData.label or varData.var)) - -- Compare value (col3, read-only) - local cVal = cInput[varData.var] - local cStr = self:FormatConfigValue(varData, cVal) - DrawString(col3, rowY + 2, "LEFT", 16, "VAR", "^7" .. cStr) + local labelWidth = DrawStringWidth(14, "VAR", labelText) + SetDrawColor(0.66, 0.66, 0.66) + DrawImage(nil, boxX + 6, boxY - 8, labelWidth + 6, 18) + SetDrawColor(0, 0, 0) + DrawImage(nil, boxX + 7, boxY - 7, labelWidth + 4, 16) + SetDrawColor(1, 1, 1) + DrawString(boxX + 9, boxY - 6, "LEFT", 14, "VAR", labelText) + + -- Draw rows inside section + local rowY = boxY + sectionInnerPad + for _, row in ipairs(sec.rows) do + if rowY + rowHeight >= 0 and rowY < scrollH then + local varData = row.ctrlInfo.varData + -- Subtle highlight for diff rows + if row.isDiff then + SetDrawLayer(nil, -5) + SetDrawColor(0.18, 0.14, 0.08) + DrawImage(nil, boxX + 3, rowY, sectionWidth - 6, rowHeight) + SetDrawLayer(nil, 0) + end + -- Label (reduce font size for long labels, matching ConfigTab behavior) + local labelStr = varData.label or varData.var + local labelSize = DrawStringWidth(14, "VAR", labelStr) > 228 and 12 or 14 + SetDrawColor(1, 1, 1) + DrawString(boxX + labelOffset, rowY + 2, "LEFT", labelSize, "VAR", + "^7" .. labelStr) + -- Controls are drawn by ControlHost (positioned in LayoutConfigView) + end + rowY = rowY + rowHeight end - currentY = currentY + rowHeight end end - if #self.configDisplayList == 0 then + if #self.configSectionLayout == 0 then DrawString(10, -self.scrollY, "LEFT", 16, "VAR", colorCodes.POSITIVE .. "No configuration options to display.") end From f6157b7f7649c4401c423d290ddbac74b31e8a5e Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Fri, 3 Apr 2026 03:33:24 +0200 Subject: [PATCH 26/59] update colors and text alignment of stats summary --- src/Classes/CompareTab.lua | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 300d7a0602..6daeb64081 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -49,8 +49,8 @@ local LAYOUT = { -- Summary view columns summaryCol1 = 10, - summaryCol2 = 300, - summaryCol3 = 450, + summaryCol2Right = 440, + summaryCol3Right = 580, summaryCol4 = 600, -- Items view @@ -2812,8 +2812,8 @@ function CompareTabClass:DrawSummary(vp, compareEntry) -- Column positions local col1 = LAYOUT.summaryCol1 - local col2 = LAYOUT.summaryCol2 - local col3 = LAYOUT.summaryCol3 + local col2R = LAYOUT.summaryCol2Right + local col3R = LAYOUT.summaryCol3Right local col4 = LAYOUT.summaryCol4 SetViewport(vp.x, vp.y, vp.width, vp.height) @@ -2822,8 +2822,9 @@ function CompareTabClass:DrawSummary(vp, compareEntry) -- Headers SetDrawColor(1, 1, 1) DrawString(col1, drawY, "LEFT", headerHeight, "VAR", "^7Stat") - DrawString(col2, drawY, "LEFT", headerHeight, "VAR", colorCodes.POSITIVE .. self:GetShortBuildName(self.primaryBuild.buildName)) - DrawString(col3, drawY, "LEFT", headerHeight, "VAR", colorCodes.WARNING .. (compareEntry.label or "Compare Build")) + DrawString(col2R, drawY, "RIGHT_X", headerHeight, "VAR", colorCodes.POSITIVE .. self:GetShortBuildName(self.primaryBuild.buildName)) + DrawString(col3R, drawY, "RIGHT_X", headerHeight, "VAR", + colorCodes.WARNING .. (compareEntry.label or "Compare Build")) DrawString(col4, drawY, "LEFT", headerHeight, "VAR", "^7Difference") drawY = drawY + headerHeight + 4 @@ -2837,7 +2838,7 @@ function CompareTabClass:DrawSummary(vp, compareEntry) local primaryEnv = self.primaryBuild.calcsTab.mainEnv local compareEnv = compareEntry.calcsTab.mainEnv - drawY = self:DrawStatList(drawY, vp, displayStats, primaryOutput, compareOutput, primaryEnv, compareEnv, col1, col2, col3, col4) + drawY = self:DrawStatList(drawY, displayStats, primaryOutput, compareOutput, primaryEnv, compareEnv, col1, col4, col2R, col3R) -- ======================================== -- Compare Power Report section @@ -2923,7 +2924,7 @@ function CompareTabClass:DrawSummary(vp, compareEntry) end -function CompareTabClass:DrawStatList(drawY, vp, displayStats, primaryOutput, compareOutput, primaryEnv, compareEnv, col1, col2, col3, col4) +function CompareTabClass:DrawStatList(drawY, displayStats, primaryOutput, compareOutput, primaryEnv, compareEnv, col1, col4, col2R, col3R) local lineHeight = 16 -- Get skill flags from both builds for stat filtering @@ -2989,8 +2990,8 @@ function CompareTabClass:DrawStatList(drawY, vp, displayStats, primaryOutput, co -- Draw stat row local labelColor = statData.color or "^7" DrawString(col1, drawY, "LEFT", lineHeight, "VAR", labelColor .. (statData.label or statData.stat)) - DrawString(col2, drawY, "LEFT", lineHeight, "VAR", "^7" .. primaryStr) - DrawString(col3, drawY, "LEFT", lineHeight, "VAR", diffColor .. compareStr) + DrawString(col2R, drawY, "RIGHT_X", lineHeight, "VAR", "^7" .. primaryStr) + DrawString(col3R, drawY, "RIGHT_X", lineHeight, "VAR", colorCodes.SPLITPERSONALITY .. compareStr) if diffStr ~= "" then DrawString(col4, drawY, "LEFT", lineHeight, "VAR", diffColor .. diffStr) end @@ -3004,8 +3005,8 @@ function CompareTabClass:DrawStatList(drawY, vp, displayStats, primaryOutput, co local primaryShown = statData.condFunc(primaryOutput) local compareShown = statData.condFunc(compareOutput) DrawString(col1, drawY, "LEFT", lineHeight, "VAR", labelColor .. statData.label) - DrawString(col2, drawY, "LEFT", lineHeight, "VAR", "^7" .. (primaryShown and valStr or "-")) - DrawString(col3, drawY, "LEFT", lineHeight, "VAR", "^7" .. (compareShown and valStr or "-")) + DrawString(col2R, drawY, "RIGHT_X", lineHeight, "VAR", "^7" .. (primaryShown and valStr or "-")) + DrawString(col3R, drawY, "RIGHT_X", lineHeight, "VAR", colorCodes.WARNING .. (compareShown and valStr or "-")) drawY = drawY + lineHeight + 1 end end From 197ee017ef0c4087dc1eb8ece7826b413a056d44 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Fri, 3 Apr 2026 03:49:42 +0200 Subject: [PATCH 27/59] handle mouse scroll in compare power report list --- src/Classes/CompareTab.lua | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 6daeb64081..35ed055a86 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -2250,8 +2250,11 @@ function CompareTabClass:HandleScrollInput(contentVP, inputEvents) local mouseInContent = cursorX >= contentVP.x and cursorX < contentVP.x + contentVP.width and cursorY >= contentVP.y and cursorY < contentVP.y + contentVP.height + local listControl = self.controls.comparePowerReportList + local mouseOverList = listControl:IsShown() and listControl:IsMouseOver() + for id, event in ipairs(inputEvents) do - if event.type == "KeyDown" and mouseInContent then + if event.type == "KeyDown" and mouseInContent and not mouseOverList then if event.key == "WHEELUP" and self.compareViewMode ~= "TREE" then self.scrollY = m_max(self.scrollY - 40, 0) inputEvents[id] = nil From 24de4254a6048195e2ade61e677cdd6d9c3dbb22 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Fri, 3 Apr 2026 04:06:33 +0200 Subject: [PATCH 28/59] include compared builds in save name --- src/Classes/BuildListControl.lua | 7 +++++++ src/Modules/BuildList.lua | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/src/Classes/BuildListControl.lua b/src/Classes/BuildListControl.lua index 3f42430ce9..22c30f3c08 100644 --- a/src/Classes/BuildListControl.lua +++ b/src/Classes/BuildListControl.lua @@ -175,6 +175,13 @@ function BuildListClass:GetRowValue(column, index, build) label = ">> " .. build.folderName else label = build.buildName or "?" + if build.compareLabels and #build.compareLabels > 0 then + if #build.compareLabels == 1 then + label = label .. " (+" .. build.compareLabels[1] .. ")" + else + label = label .. " (+" .. #build.compareLabels .. " compared builds)" + end + end end if self.cutBuild and self.cutBuild.buildName == build.buildName and self.cutBuild.folderName == build.folderName then return "^xC0B0B0"..label diff --git a/src/Modules/BuildList.lua b/src/Modules/BuildList.lua index f0c1ba68e2..fa32b272dd 100644 --- a/src/Modules/BuildList.lua +++ b/src/Modules/BuildList.lua @@ -220,6 +220,10 @@ function listMode:BuildList() main:OpenCloudErrorPopup(build.fullFileName) return end + build.compareLabels = { } + for label in fileText:gmatch(']-label="([^"]*)"') do + t_insert(build.compareLabels, label) + end fileText = fileText:match("()") if fileText then local xml = common.xml.ParseXML(fileText.."") From f8b308fcc50d5cb2b6a97505c3882c91eb4710b4 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Fri, 3 Apr 2026 04:56:22 +0200 Subject: [PATCH 29/59] handle overlapping build names in summary tab --- src/Classes/CompareTab.lua | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 35ed055a86..a962247d89 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -2813,11 +2813,19 @@ function CompareTabClass:DrawSummary(vp, compareEntry) local lineHeight = 18 local headerHeight = 22 - -- Column positions + -- Column positions (col3R and col4 shift right dynamically to avoid name overlap) local col1 = LAYOUT.summaryCol1 local col2R = LAYOUT.summaryCol2Right - local col3R = LAYOUT.summaryCol3Right - local col4 = LAYOUT.summaryCol4 + + local primaryName = self:GetShortBuildName(self.primaryBuild.buildName) + local compareName = compareEntry.label or "Compare Build" + local primaryNameW = DrawStringWidth(headerHeight, "VAR", primaryName) + local compareNameW = DrawStringWidth(headerHeight, "VAR", compareName) + + local minCol3R = col2R + compareNameW + 16 + local maxCol3R = vp.width - 200 + local col3R = m_min(m_max(LAYOUT.summaryCol3Right, minCol3R), maxCol3R) + local col4 = col3R + 20 SetViewport(vp.x, vp.y, vp.width, vp.height) local drawY = 4 - self.scrollY @@ -2825,9 +2833,9 @@ function CompareTabClass:DrawSummary(vp, compareEntry) -- Headers SetDrawColor(1, 1, 1) DrawString(col1, drawY, "LEFT", headerHeight, "VAR", "^7Stat") - DrawString(col2R, drawY, "RIGHT_X", headerHeight, "VAR", colorCodes.POSITIVE .. self:GetShortBuildName(self.primaryBuild.buildName)) + DrawString(col2R, drawY, "RIGHT_X", headerHeight, "VAR", colorCodes.POSITIVE .. primaryName) DrawString(col3R, drawY, "RIGHT_X", headerHeight, "VAR", - colorCodes.WARNING .. (compareEntry.label or "Compare Build")) + colorCodes.WARNING .. compareName) DrawString(col4, drawY, "LEFT", headerHeight, "VAR", "^7Difference") drawY = drawY + headerHeight + 4 From 03e4f5b06483c93413ecb6401612b0394096975a Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Fri, 3 Apr 2026 15:37:42 +0200 Subject: [PATCH 30/59] skill gem highlighting --- src/Classes/CompareTab.lua | 123 ++++++++++++++++++++++++++++++++++--- 1 file changed, 114 insertions(+), 9 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index a962247d89..98300bebc0 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -4152,7 +4152,104 @@ function CompareTabClass:DrawSkills(vp, compareEntry) end end - -- Draw matched pairs + -- Helper: check if gemA supports gemB (mirrors GemSelectControl:CheckSupporting) + local function checkSupporting(gemA, gemB) + if not gemA.gemData or not gemB.gemData then return false end + return (gemA.gemData.grantedEffect and gemA.gemData.grantedEffect.support + and gemB.gemData.grantedEffect and not gemB.gemData.grantedEffect.support + and gemA.supportEffect and gemA.supportEffect.isSupporting + and gemA.supportEffect.isSupporting[gemB]) + or (gemA.gemData.secondaryGrantedEffect + and gemA.gemData.secondaryGrantedEffect.support + and gemB.gemData.grantedEffect and not gemB.gemData.grantedEffect.support + and gemA.supportEffect and gemA.supportEffect.isSupporting + and gemA.supportEffect.isSupporting[gemB]) + end + + local gemFontSize = 16 + local gemLineHeight = 18 + local gemTextWidth = colWidth - 30 + + -- Position pre-pass: compute gem positions without drawing to enable hover hit-testing + local gemEntries = {} -- { gem, x, y, group } + local preY = 4 - self.scrollY + 24 -- after headers + for _, pair in ipairs(renderPairs) do + preY = preY + 2 -- separator + local pSet = pair.pIdx and pSets[pair.pIdx] or {} + local cSet = pair.cIdx and cSets[pair.cIdx] or {} + local pGemY = preY + lineHeight + local cGemY = preY + lineHeight + + -- Primary group gems + local pGroup = pair.pIdx and pGroups[pair.pIdx] + if pGroup then + for _, gem in ipairs(pGroup.gemList or {}) do + t_insert(gemEntries, { gem = gem, x = 20, y = pGemY, group = pGroup }) + pGemY = pGemY + gemLineHeight + end + if pair.cIdx then + for name in pairs(cSet) do + if not pSet[name] then + pGemY = pGemY + gemLineHeight -- missing gem placeholder + end + end + end + end + + -- Compare group gems + local cGroup = pair.cIdx and cGroups[pair.cIdx] + if cGroup then + for _, gem in ipairs(cGroup.gemList or {}) do + t_insert(gemEntries, { gem = gem, x = colWidth + 20, y = cGemY, group = cGroup }) + cGemY = cGemY + gemLineHeight + end + if pair.pIdx then + for name in pairs(pSet) do + if not cSet[name] then + cGemY = cGemY + gemLineHeight + end + end + end + end + + preY = preY + m_max(pGemY - preY, cGemY - preY) + 6 + end + + -- Hit-test: find hovered gem + local cursorX, cursorY = GetCursorPos() + local localCursorX = cursorX - vp.x + local localCursorY = cursorY - vp.y + local hoveredEntry = nil + if localCursorX >= 0 and localCursorX < vp.width and localCursorY >= 0 and localCursorY < vp.height then + for _, entry in ipairs(gemEntries) do + if localCursorX >= entry.x and localCursorX < entry.x + gemTextWidth + and localCursorY >= entry.y and localCursorY < entry.y + gemLineHeight then + hoveredEntry = entry + break + end + end + end + + -- Build set of highlighted gems based on hover + local highlightSet = {} + if hoveredEntry then + highlightSet[hoveredEntry.gem] = true + for _, entry in ipairs(gemEntries) do + if entry.group == hoveredEntry.group and entry.gem ~= hoveredEntry.gem then + if checkSupporting(hoveredEntry.gem, entry.gem) or checkSupporting(entry.gem, hoveredEntry.gem) then + highlightSet[entry.gem] = true + end + end + end + -- Only keep highlights if there's at least one linked gem (not just the hovered one) + local count = 0 + for _ in pairs(highlightSet) do count = count + 1 end + if count <= 1 then + highlightSet = {} + end + end + + -- Draw pass for _, pair in ipairs(renderPairs) do SetDrawColor(0.3, 0.3, 0.3) DrawImage(nil, 4, drawY, vp.width - 8, 1) @@ -4173,6 +4270,10 @@ function CompareTabClass:DrawSkills(vp, compareEntry) DrawString(10, drawY, "LEFT", 16, "VAR", "^7" .. groupLabel) local gemY = drawY + lineHeight for _, gem in ipairs(pGroup.gemList or {}) do + if highlightSet[gem] then + SetDrawColor(0.33, 1, 0.33, 0.25) + DrawImage(nil, 20, gemY, gemTextWidth, gemLineHeight) + end local gemName = gem.grantedEffect and gem.grantedEffect.name or gem.nameSpec or "?" local gemColor = gem.color or colorCodes.GEM local levelStr = gem.level and (" Lv" .. gem.level) or "" @@ -4181,8 +4282,8 @@ function CompareTabClass:DrawSkills(vp, compareEntry) if pair.cIdx and not cSet[gemName] then prefix = colorCodes.POSITIVE .. "+ " end - DrawString(20, gemY, "LEFT", 14, "VAR", prefix .. gemColor .. gemName .. "^7" .. levelStr .. qualStr) - gemY = gemY + 16 + DrawString(20, gemY, "LEFT", gemFontSize, "VAR", prefix .. gemColor .. gemName .. "^7" .. levelStr .. qualStr) + gemY = gemY + gemLineHeight end -- Show gems missing from primary but present in compare if pair.cIdx then @@ -4194,8 +4295,8 @@ function CompareTabClass:DrawSkills(vp, compareEntry) end table.sort(missing) for _, name in ipairs(missing) do - DrawString(20, gemY, "LEFT", 14, "VAR", colorCodes.NEGATIVE .. "- " .. name .. "^7") - gemY = gemY + 16 + DrawString(20, gemY, "LEFT", gemFontSize, "VAR", colorCodes.NEGATIVE .. "- " .. name .. "^7") + gemY = gemY + gemLineHeight end end pFinalGemY = gemY @@ -4211,6 +4312,10 @@ function CompareTabClass:DrawSkills(vp, compareEntry) DrawString(colWidth + 10, drawY, "LEFT", 16, "VAR", "^7" .. groupLabel) local gemY = drawY + lineHeight for _, gem in ipairs(cGroup.gemList or {}) do + if highlightSet[gem] then + SetDrawColor(0.33, 1, 0.33, 0.25) + DrawImage(nil, colWidth + 20, gemY, gemTextWidth, gemLineHeight) + end local gemName = gem.grantedEffect and gem.grantedEffect.name or gem.nameSpec or "?" local gemColor = gem.color or colorCodes.GEM local levelStr = gem.level and (" Lv" .. gem.level) or "" @@ -4219,8 +4324,8 @@ function CompareTabClass:DrawSkills(vp, compareEntry) if pair.pIdx and not pSet[gemName] then prefix = colorCodes.POSITIVE .. "+ " end - DrawString(colWidth + 20, gemY, "LEFT", 14, "VAR", prefix .. gemColor .. gemName .. "^7" .. levelStr .. qualStr) - gemY = gemY + 16 + DrawString(colWidth + 20, gemY, "LEFT", gemFontSize, "VAR", prefix .. gemColor .. gemName .. "^7" .. levelStr .. qualStr) + gemY = gemY + gemLineHeight end -- Show gems missing from compare but present in primary if pair.pIdx then @@ -4232,8 +4337,8 @@ function CompareTabClass:DrawSkills(vp, compareEntry) end table.sort(missing) for _, name in ipairs(missing) do - DrawString(colWidth + 20, gemY, "LEFT", 14, "VAR", colorCodes.NEGATIVE .. "- " .. name .. "^7") - gemY = gemY + 16 + DrawString(colWidth + 20, gemY, "LEFT", gemFontSize, "VAR", colorCodes.NEGATIVE .. "- " .. name .. "^7") + gemY = gemY + gemLineHeight end end cFinalGemY = gemY From 8e9aeb7e6df6f4bf2c1587b7758e369e4ee7fb74 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Fri, 3 Apr 2026 16:44:18 +0200 Subject: [PATCH 31/59] handle unprotected state mutation in power report --- src/Classes/CompareTab.lua | 112 ++++++++++++++++++++----------------- 1 file changed, 61 insertions(+), 51 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 98300bebc0..d67afbaaed 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -2657,30 +2657,35 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories t_insert(pGroups, cGroup) self.primaryBuild.buildFlag = true - -- Get a fresh calculator with the added group - local gemCalcFunc, gemCalcBase = self.calcs.getMiscCalculator(self.primaryBuild) - local impact = self:CalculatePowerStat(powerStat, gemCalcBase, calcBase) + -- Get a fresh calculator with the added group (pcall to guarantee cleanup) + local ok, gemCalcFunc, gemCalcBase = pcall(self.calcs.getMiscCalculator, self.calcs, self.primaryBuild) - -- Remove the temporarily added group + -- Always remove the temporarily added group t_remove(pGroups) self.primaryBuild.buildFlag = true - local impactStr, impactVal, combinedImpactStr, impactPercent = formatImpact(impact) - local label = self:GetSocketGroupLabel(cGroup) + if not ok then + -- gemCalcFunc contains the error message on failure; skip this group + ConPrintf("Compare power (gem): %s", tostring(gemCalcFunc)) + else + local impact = self:CalculatePowerStat(powerStat, gemCalcBase, calcBase) + local impactStr, impactVal, combinedImpactStr, impactPercent = formatImpact(impact) + local label = self:GetSocketGroupLabel(cGroup) - t_insert(results, { - category = "Gem", - categoryColor = colorCodes.GEM, - nameColor = colorCodes.GEM, - name = label, - impact = impactVal, - impactStr = impactStr, - impactPercent = impactPercent, - combinedImpactStr = combinedImpactStr, - pathDist = nil, - perPoint = nil, - perPointStr = nil, - }) + t_insert(results, { + category = "Gem", + categoryColor = colorCodes.GEM, + nameColor = colorCodes.GEM, + name = label, + impact = impactVal, + impactStr = impactStr, + impactPercent = impactPercent, + combinedImpactStr = combinedImpactStr, + pathDist = nil, + perPoint = nil, + perPointStr = nil, + }) + end end processed = processed + 1 if coroutine.running() and GetTime() - start > 100 then @@ -2715,43 +2720,48 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories -- Apply compare build's config value pInput[varData.var] = cVal - -- Rebuild the mod list with the new config value - self.primaryBuild.configTab:BuildModList() - self.primaryBuild.buildFlag = true - - -- Get a fresh calculator with the changed config - local cfgCalcFunc, cfgCalcBase = self.calcs.getMiscCalculator(self.primaryBuild) - local impact = self:CalculatePowerStat(powerStat, cfgCalcBase, calcBase) + -- Rebuild and calculate (pcall to guarantee restore on error) + local ok, cfgCalcFunc, cfgCalcBase = pcall(function() + self.primaryBuild.configTab:BuildModList() + self.primaryBuild.buildFlag = true + return self.calcs.getMiscCalculator(self.primaryBuild) + end) - -- Restore original value + -- Always restore original value pInput[varData.var] = savedVal self.primaryBuild.configTab:BuildModList() self.primaryBuild.buildFlag = true - local impactStr, impactVal, combinedImpactStr, impactPercent = formatImpact(impact) - - -- Only include configs with non-zero impact - if impactVal ~= 0 then - -- Build display name with value change description - local displayName = varData.label or varData.var - displayName = displayName:gsub(":$", "") - - local pDisplay = stripColors(self:FormatConfigValue(varData, pVal)) - local cDisplay = stripColors(self:FormatConfigValue(varData, cVal)) - - t_insert(results, { - category = "Config", - categoryColor = colorCodes.FRACTURED, - nameColor = "^7", - name = displayName .. " (" .. pDisplay .. " -> " .. cDisplay .. ")", - impact = impactVal, - impactStr = impactStr, - impactPercent = impactPercent, - combinedImpactStr = combinedImpactStr, - pathDist = nil, - perPoint = nil, - perPointStr = nil, - }) + if not ok then + -- cfgCalcFunc contains the error message on failure; skip this config + ConPrintf("Compare power (config): %s", tostring(cfgCalcFunc)) + else + local impact = self:CalculatePowerStat(powerStat, cfgCalcBase, calcBase) + local impactStr, impactVal, combinedImpactStr, impactPercent = formatImpact(impact) + + -- Only include configs with non-zero impact + if impactVal ~= 0 then + -- Build display name with value change description + local displayName = varData.label or varData.var + displayName = displayName:gsub(":$", "") + + local pDisplay = stripColors(self:FormatConfigValue(varData, pVal)) + local cDisplay = stripColors(self:FormatConfigValue(varData, cVal)) + + t_insert(results, { + category = "Config", + categoryColor = colorCodes.FRACTURED, + nameColor = "^7", + name = displayName .. " (" .. pDisplay .. " -> " .. cDisplay .. ")", + impact = impactVal, + impactStr = impactStr, + impactPercent = impactPercent, + combinedImpactStr = combinedImpactStr, + pathDist = nil, + perPoint = nil, + perPointStr = nil, + }) + end end processed = processed + 1 From afc641ddb6b792743d39d89d7d30c25ee1fd264f Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Fri, 3 Apr 2026 21:18:26 +0200 Subject: [PATCH 32/59] handling unsaved changes for compared build --- src/Classes/CompareTab.lua | 22 +++++++++++++++++++++- src/Classes/ImportTab.lua | 1 + src/Modules/Build.lua | 3 ++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index d67afbaaed..933f56bdcc 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -266,6 +266,7 @@ function CompareTabClass:InitControls() local entry = self:GetActiveCompare() if entry and entry.treeTab and entry.treeTab.specList[index] then entry:SetActiveSpec(index) + self.modFlag = true -- Restore primary build's window title (SetActiveSpec changes it) if self.primaryBuild.spec then self.primaryBuild.spec:SetWindowTitleWithBuildClass() @@ -283,6 +284,7 @@ function CompareTabClass:InitControls() local entry = self:GetActiveCompare() if entry and entry.skillsTab and entry.skillsTab.skillSetOrderList[index] then entry:SetActiveSkillSet(entry.skillsTab.skillSetOrderList[index]) + self.modFlag = true end end) self.controls.compareSkillSetSelect.enabled = setsEnabled @@ -293,6 +295,7 @@ function CompareTabClass:InitControls() local entry = self:GetActiveCompare() if entry and entry.itemsTab and entry.itemsTab.itemSetOrderList[index] then entry:SetActiveItemSet(entry.itemsTab.itemSetOrderList[index]) + self.modFlag = true end end) self.controls.compareItemSetSelect.enabled = setsEnabled @@ -306,6 +309,7 @@ function CompareTabClass:InitControls() if setId then entry.configTab:SetActiveConfigSet(setId) entry.buildFlag = true + self.modFlag = true self.configNeedsRebuild = true end end @@ -338,6 +342,7 @@ function CompareTabClass:InitControls() if mainSocketGroup then mainSocketGroup.mainActiveSkill = index entry.modFlag = true + self.modFlag = true entry.buildFlag = true end end @@ -355,6 +360,7 @@ function CompareTabClass:InitControls() if activeSkill and activeSkill.activeEffect then activeSkill.activeEffect.srcInstance.skillPart = index entry.modFlag = true + self.modFlag = true entry.buildFlag = true end end @@ -375,6 +381,7 @@ function CompareTabClass:InitControls() if activeSkill and activeSkill.activeEffect then activeSkill.activeEffect.srcInstance.skillStageCount = tonumber(buf) entry.modFlag = true + self.modFlag = true entry.buildFlag = true end end @@ -395,6 +402,7 @@ function CompareTabClass:InitControls() if activeSkill and activeSkill.activeEffect then activeSkill.activeEffect.srcInstance.skillMineCount = tonumber(buf) entry.modFlag = true + self.modFlag = true entry.buildFlag = true end end @@ -419,6 +427,7 @@ function CompareTabClass:InitControls() activeSkill.activeEffect.srcInstance.skillMinion = selected.minionId end entry.modFlag = true + self.modFlag = true entry.buildFlag = true end end @@ -438,6 +447,7 @@ function CompareTabClass:InitControls() if activeSkill and activeSkill.activeEffect then activeSkill.activeEffect.srcInstance.skillMinionSkill = index entry.modFlag = true + self.modFlag = true entry.buildFlag = true end end @@ -515,6 +525,7 @@ function CompareTabClass:InitControls() local entry = self:GetActiveCompare() if entry and entry.itemsTab and entry.itemsTab.itemSetOrderList[index] then entry:SetActiveItemSet(entry.itemsTab.itemSetOrderList[index]) + self.modFlag = true end end) self.controls.compareItemSetSelect2.enabled = itemsShown @@ -541,6 +552,7 @@ function CompareTabClass:InitControls() local entry = self:GetActiveCompare() if entry and entry.treeTab and entry.treeTab.specList[index] then entry:SetActiveSpec(index) + self.modFlag = true if self.primaryBuild.spec then self.primaryBuild.spec:SetWindowTitleWithBuildClass() end @@ -588,6 +600,7 @@ function CompareTabClass:InitControls() local entry = self:GetActiveCompare() if entry and entry.treeTab and entry.treeTab.specList[index] then entry:SetActiveSpec(index) + self.modFlag = true -- Restore primary build's window title (compare entry's SetActiveSpec changes it) if self.primaryBuild.spec then self.primaryBuild.spec:SetWindowTitleWithBuildClass() @@ -1029,7 +1042,11 @@ function CompareTabClass:ImportFromCode(code) if not xmlText then return false end - return self:ImportBuild(xmlText, "Imported build") + if self:ImportBuild(xmlText, "Imported build") then + self.modFlag = true + return true + end + return false end -- Save comparison builds to the build file @@ -1105,6 +1122,7 @@ end function CompareTabClass:RemoveBuild(index) if index >= 1 and index <= #self.compareEntries then t_remove(self.compareEntries, index) + self.modFlag = true if self.activeCompareIndex > #self.compareEntries then self.activeCompareIndex = #self.compareEntries end @@ -1688,6 +1706,7 @@ function CompareTabClass:OpenImportPopup() local xmlText = Inflate(common.base64.decode(codeData:gsub("-","+"):gsub("_","/"))) if xmlText then self:ImportBuild(xmlText, customName or ("Imported from " .. site.label)) + self.modFlag = true main:ClosePopup() else stateText = colorCodes.NEGATIVE .. "Failed to decode build data" @@ -1704,6 +1723,7 @@ function CompareTabClass:OpenImportPopup() local xmlText = Inflate(common.base64.decode(buf:gsub("-","+"):gsub("_","/"))) if xmlText then self:ImportBuild(xmlText, customName or "Imported build") + self.modFlag = true main:ClosePopup() else stateText = colorCodes.NEGATIVE .. "Invalid build code" diff --git a/src/Classes/ImportTab.lua b/src/Classes/ImportTab.lua index e339366f9e..bab9cc116c 100644 --- a/src/Classes/ImportTab.lua +++ b/src/Classes/ImportTab.lua @@ -318,6 +318,7 @@ You can get this from your web browser's cookies while logged into the Path of E -- Import as comparison build if self.build.compareTab then if self.build.compareTab:ImportBuild(self.importCodeXML, "Imported comparison") then + self.build.compareTab.modFlag = true self.build.viewMode = "COMPARE" else main:OpenMessagePopup("Import Error", "Failed to import build for comparison.") diff --git a/src/Modules/Build.lua b/src/Modules/Build.lua index e5fa932bb8..02d67c72b1 100644 --- a/src/Modules/Build.lua +++ b/src/Modules/Build.lua @@ -1043,6 +1043,7 @@ function buildMode:ResetModFlags() self.skillsTab.modFlag = false self.itemsTab.modFlag = false self.calcsTab.modFlag = false + self.compareTab.modFlag = false end function buildMode:OnFrame(inputEvents) @@ -1153,7 +1154,7 @@ function buildMode:OnFrame(inputEvents) self.compareTab:Draw(tabViewPort, inputEvents) end - self.unsaved = self.modFlag or self.notesTab.modFlag or self.partyTab.modFlag or self.configTab.modFlag or self.treeTab.modFlag or self.treeTab.searchFlag or self.spec.modFlag or self.skillsTab.modFlag or self.itemsTab.modFlag or self.calcsTab.modFlag + self.unsaved = self.modFlag or self.notesTab.modFlag or self.partyTab.modFlag or self.configTab.modFlag or self.treeTab.modFlag or self.treeTab.searchFlag or self.spec.modFlag or self.skillsTab.modFlag or self.itemsTab.modFlag or self.calcsTab.modFlag or self.compareTab.modFlag SetDrawLayer(5) From 97e4ab5f2374d6814141500326be7d860d4c9659 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Fri, 3 Apr 2026 22:26:49 +0200 Subject: [PATCH 33/59] include support gems in compare power report --- src/Classes/CompareTab.lua | 142 ++++++++++++++++++++++++++++++++++--- 1 file changed, 134 insertions(+), 8 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 933f56bdcc..7f42517c85 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -151,7 +151,7 @@ local CompareTabClass = newClass("CompareTab", "ControlHost", "Control", functio -- Compare power report state self.comparePowerStat = nil -- selected data.powerStatList entry - self.comparePowerCategories = { treeNodes = true, items = true, gems = true, config = true } + self.comparePowerCategories = { treeNodes = true, items = true, skillGems = true, supportGems = true, config = true } self.comparePowerResults = nil -- sorted list of result entries self.comparePowerCoroutine = nil -- active coroutine self.comparePowerProgress = 0 -- 0-100 @@ -735,13 +735,20 @@ function CompareTabClass:InitControls() self.controls.comparePowerItemsCheck.shown = powerReportShown self.controls.comparePowerItemsCheck.state = true - self.controls.comparePowerGemsCheck = new("CheckBoxControl", nil, {0, 0, 18}, "Gems:", function(state) - self.comparePowerCategories.gems = state + self.controls.comparePowerGemsCheck = new("CheckBoxControl", nil, {0, 0, 18}, "Skill gems:", function(state) + self.comparePowerCategories.skillGems = state self.comparePowerDirty = true - end, "Include skill gem groups from compared build") + end, "Include skill gem groups unique to compared build") self.controls.comparePowerGemsCheck.shown = powerReportShown self.controls.comparePowerGemsCheck.state = true + self.controls.comparePowerSupportGemsCheck = new("CheckBoxControl", nil, {0, 0, 18}, "Support gems:", function(state) + self.comparePowerCategories.supportGems = state + self.comparePowerDirty = true + end, "Include support gems from compared build's active skill") + self.controls.comparePowerSupportGemsCheck.shown = powerReportShown + self.controls.comparePowerSupportGemsCheck.state = true + self.controls.comparePowerConfigCheck = new("CheckBoxControl", nil, {0, 0, 18}, "Config:", function(state) self.comparePowerCategories.config = state self.comparePowerDirty = true @@ -2317,6 +2324,14 @@ function CompareTabClass:CalculatePowerStat(selection, output, calcBase) return withValue - baseValue end +-- Resolve the granted effect for a gem instance +function CompareTabClass:GetGemGrantedEffect(gem) + if gem.gemData and gem.gemData.grantedEffect then + return gem.gemData.grantedEffect + end + return gem.grantedEffect +end + -- Build a signature string for a socket group (sorted gem names) function CompareTabClass:GetSocketGroupSignature(group) local names = {} @@ -2414,10 +2429,34 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories end end end - if categories.gems then + if categories.skillGems then local cGroups = compareEntry.skillsTab and compareEntry.skillsTab.socketGroupList or {} total = total + #cGroups end + if categories.supportGems then + local cMainGroup = compareEntry.skillsTab and compareEntry.skillsTab.socketGroupList[compareEntry.mainSocketGroup] + local pMainGroup = self.primaryBuild.skillsTab and self.primaryBuild.skillsTab.socketGroupList[self.primaryBuild.mainSocketGroup] + if cMainGroup and pMainGroup then + -- Count support gems in compared build's main group not in primary's main group + local pSupportNames = {} + for _, gem in ipairs(pMainGroup.gemList or {}) do + local ge = self:GetGemGrantedEffect(gem) + if ge and ge.support then + local name = ge.name or gem.nameSpec + if name then pSupportNames[name] = true end + end + end + for _, gem in ipairs(cMainGroup.gemList or {}) do + local ge = self:GetGemGrantedEffect(gem) + if ge and ge.support then + local name = ge.name or gem.nameSpec + if name and not pSupportNames[name] then + total = total + 1 + end + end + end + end + end if categories.config then local pInput = self.primaryBuild.configTab.input or {} local cInput = compareEntry.configTab.input or {} @@ -2660,7 +2699,7 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories -- ========================================== -- Skill Gems (socket groups) -- ========================================== - if categories.gems then + if categories.skillGems then local cGroups = compareEntry.skillsTab and compareEntry.skillsTab.socketGroupList or {} local pGroups = self.primaryBuild.skillsTab and self.primaryBuild.skillsTab.socketGroupList or {} @@ -2678,7 +2717,9 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories self.primaryBuild.buildFlag = true -- Get a fresh calculator with the added group (pcall to guarantee cleanup) - local ok, gemCalcFunc, gemCalcBase = pcall(self.calcs.getMiscCalculator, self.calcs, self.primaryBuild) + local ok, gemCalcFunc, gemCalcBase = pcall(function() + return self.calcs.getMiscCalculator(self.primaryBuild) + end) -- Always remove the temporarily added group t_remove(pGroups) @@ -2693,7 +2734,7 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories local label = self:GetSocketGroupLabel(cGroup) t_insert(results, { - category = "Gem", + category = "Skill gem", categoryColor = colorCodes.GEM, nameColor = colorCodes.GEM, name = label, @@ -2716,6 +2757,87 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories end end + -- ========================================== + -- Support Gems (from compared build's active skill) + -- ========================================== + if categories.supportGems then + local cMainGroup = compareEntry.skillsTab and compareEntry.skillsTab.socketGroupList[compareEntry.mainSocketGroup] + local pMainGroup = self.primaryBuild.skillsTab and self.primaryBuild.skillsTab.socketGroupList[self.primaryBuild.mainSocketGroup] + + if cMainGroup and pMainGroup then + -- Collect support gem names already in primary build's main group + local pSupportNames = {} + for _, gem in ipairs(pMainGroup.gemList or {}) do + local ge = self:GetGemGrantedEffect(gem) + if ge and ge.support then + local name = ge.name or gem.nameSpec + if name then pSupportNames[name] = true end + end + end + + for _, cGem in ipairs(cMainGroup.gemList or {}) do + local cGrantedEffect = self:GetGemGrantedEffect(cGem) + if cGrantedEffect and cGrantedEffect.support then + local name = cGrantedEffect.name or cGem.nameSpec + if name and not pSupportNames[name] then + -- Create a temporary copy of this support gem + local tempGem = { + nameSpec = cGem.nameSpec, + level = cGem.level, + quality = cGem.quality, + qualityId = cGem.qualityId, + enabled = cGem.enabled, + grantedEffect = cGem.grantedEffect, + gemData = cGem.gemData, + count = cGem.count, + enableGlobal1 = cGem.enableGlobal1, + enableGlobal2 = cGem.enableGlobal2, + } + + -- Temporarily add to primary build's main socket group + t_insert(pMainGroup.gemList, tempGem) + self.primaryBuild.buildFlag = true + + local ok, sgCalcFunc, sgCalcBase = pcall(function() + return self.calcs.getMiscCalculator(self.primaryBuild) + end) + + -- Always remove the temporarily added gem + t_remove(pMainGroup.gemList) + self.primaryBuild.buildFlag = true + + if not ok then + ConPrintf("Compare power (support gem): %s", tostring(sgCalcFunc)) + else + local impact = self:CalculatePowerStat(powerStat, sgCalcBase, calcBase) + local impactStr, impactVal, combinedImpactStr, impactPercent = formatImpact(impact) + + t_insert(results, { + category = "Support gem", + categoryColor = colorCodes.GEM, + nameColor = colorCodes.GEM, + name = name, + impact = impactVal, + impactStr = impactStr, + impactPercent = impactPercent, + combinedImpactStr = combinedImpactStr, + pathDist = nil, + perPoint = nil, + perPointStr = nil, + }) + end + processed = processed + 1 + if coroutine.running() and GetTime() - start > 100 then + self.comparePowerProgress = m_floor(processed / total * 100) + coroutine.yield() + start = GetTime() + end + end + end + end + end + end + -- ========================================== -- Config Options -- ========================================== @@ -2926,6 +3048,10 @@ function CompareTabClass:DrawSummary(vp, compareEntry) self.controls.comparePowerGemsCheck.y = controlY checkX = checkX + self.controls.comparePowerGemsCheck.labelWidth + 26 + self.controls.comparePowerSupportGemsCheck.x = checkX + self.controls.comparePowerSupportGemsCheck.labelWidth + self.controls.comparePowerSupportGemsCheck.y = controlY + checkX = checkX + self.controls.comparePowerSupportGemsCheck.labelWidth + 26 + self.controls.comparePowerConfigCheck.x = checkX + self.controls.comparePowerConfigCheck.labelWidth self.controls.comparePowerConfigCheck.y = controlY From 3a604923c154965109c65b00da54d896d0d47b07 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Sat, 4 Apr 2026 00:24:29 +0200 Subject: [PATCH 34/59] add constant for cluster node threshold limit/offset --- src/Classes/CompareTab.lua | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 7f42517c85..287698b7dd 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -37,6 +37,9 @@ for i, entry in ipairs(LISTED_STATUS_OPTIONS) do LISTED_STATUS_LABELS[i] = entry.label end +-- Node IDs below this value are normal passive tree nodes; IDs at or above are cluster jewel nodes +local CLUSTER_NODE_OFFSET = 65536 + -- Layout constants (shared across Draw, DrawConfig, DrawItems, DrawCalcs, etc.) local LAYOUT = { -- Main tab control bar @@ -2401,7 +2404,7 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories local compareNodes = compareEntry.spec and compareEntry.spec.allocNodes or {} local primaryNodes = self.primaryBuild.spec and self.primaryBuild.spec.allocNodes or {} for nodeId, node in pairs(compareNodes) do - if type(nodeId) == "number" and nodeId < 65536 and not primaryNodes[nodeId] then + if type(nodeId) == "number" and nodeId < CLUSTER_NODE_OFFSET and not primaryNodes[nodeId] then local pNode = self.primaryBuild.spec.nodes[nodeId] if pNode and (pNode.type == "Normal" or pNode.type == "Notable" or pNode.type == "Keystone") and not pNode.ascendancyName then total = total + 1 @@ -2520,7 +2523,7 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories local cache = {} for nodeId, _ in pairs(compareNodes) do - if type(nodeId) == "number" and nodeId < 65536 and not primaryNodes[nodeId] then + if type(nodeId) == "number" and nodeId < CLUSTER_NODE_OFFSET and not primaryNodes[nodeId] then local pNode = self.primaryBuild.spec.nodes[nodeId] if pNode and (pNode.type == "Normal" or pNode.type == "Notable" or pNode.type == "Keystone") and not pNode.ascendancyName and pNode.modKey ~= "" then From 39bce7510cd001627dcaa617c1dbf0848ec2b496 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Sat, 4 Apr 2026 12:05:24 +0200 Subject: [PATCH 35/59] create CompareTradeHelpers --- src/Classes/CompareTab.lua | 425 ++-------------------------- src/Classes/CompareTradeHelpers.lua | 392 +++++++++++++++++++++++++ 2 files changed, 414 insertions(+), 403 deletions(-) create mode 100644 src/Classes/CompareTradeHelpers.lua diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 287698b7dd..0149ae23f4 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -10,13 +10,7 @@ local m_max = math.max local m_floor = math.floor local s_format = string.format local dkjson = require "dkjson" -local queryModsData = LoadModule("Data/QueryMods") - --- Forward declarations for trade helper functions (defined later in the file) -local findTradeModId -local getTradeCategory -local getTradeCategoryLabel -local modLineValue +local tradeHelpers = LoadModule("Classes/CompareTradeHelpers") -- Realm display name to API id mapping (used by Buy Similar popup and URL builder) local REALM_API_IDS = { @@ -1358,8 +1352,8 @@ function CompareTabClass:OpenBuySimilarPopup(item, slotName) if formatted then -- Use range-resolved text for matching local resolvedLine = (modLine.range and itemLib.applyRange(modLine.line, modLine.range, modLine.valueScalar)) or modLine.line - local tradeId = findTradeModId(resolvedLine, source.type) - local value = modLineValue(resolvedLine) + local tradeId = tradeHelpers.findTradeModId(resolvedLine, source.type) + local value = tradeHelpers.modLineValue(resolvedLine) t_insert(modEntries, { line = modLine.line, formatted = formatted:gsub("%^x%x%x%x%x%x%x", ""):gsub("%^%x", ""), -- strip color codes @@ -1461,7 +1455,7 @@ function CompareTabClass:OpenBuySimilarPopup(item, slotName) ctrlY = ctrlY + rowHeight else -- Category label - local categoryLabel = getTradeCategoryLabel(slotName, item) + local categoryLabel = tradeHelpers.getTradeCategoryLabel(slotName, item) controls.categoryLabel = new("LabelControl", {"TOPLEFT", nil, "TOPLEFT"}, {leftMargin, ctrlY, 0, 16}, "^7Category: " .. categoryLabel) ctrlY = ctrlY + rowHeight @@ -1594,7 +1588,7 @@ function CompareTabClass:BuildBuySimilarURL(item, slotName, controls, modEntries end else -- Category filter - local categoryStr = getTradeCategory(slotName, item) + local categoryStr = tradeHelpers.getTradeCategory(slotName, item) if categoryStr then queryFilters.type_filters = { filters = { @@ -3276,383 +3270,6 @@ end -- ITEMS VIEW -- ============================================================ --- Helper: get rarity color code for an item -local function getRarityColor(item) - if not item then return "^7" end - if item.rarity == "UNIQUE" then return colorCodes.UNIQUE - elseif item.rarity == "RARE" then return colorCodes.RARE - elseif item.rarity == "MAGIC" then return colorCodes.MAGIC - else return colorCodes.NORMAL end -end - --- Helper: normalize a mod line by replacing numbers with "#" for template matching -local function modLineTemplate(line) - -- Replace decimal numbers first (e.g. "1.5"), then integers - return line:gsub("[%d]+%.?[%d]*", "#") -end - --- Helper: extract the first number from a mod line for value comparison -modLineValue = function(line) - return tonumber(line:match("[%d]+%.?[%d]*")) or 0 -end - --- Helper: lazily build a reverse lookup from QueryMods tradeMod.text → tradeMod.id -local _tradeModLookup = nil -local function getTradeModLookup() - if _tradeModLookup then return _tradeModLookup end - _tradeModLookup = {} - if not queryModsData then return _tradeModLookup end - for _groupName, mods in pairs(queryModsData) do - for _modKey, modData in pairs(mods) do - if type(modData) == "table" and modData.tradeMod then - local text = modData.tradeMod.text - local modType = modData.tradeMod.type or "explicit" - local id = modData.tradeMod.id - local key = text .. "|" .. modType - _tradeModLookup[key] = id - if not _tradeModLookup[text] then - _tradeModLookup[text] = id - end - -- Also store with template-converted text for mods with literal numbers - -- (e.g. "1 Added Passive Skill is X" → "# Added Passive Skill is X") - local tmpl = modLineTemplate(text) - if tmpl ~= text then - local tmplKey = tmpl .. "|" .. modType - if not _tradeModLookup[tmplKey] then - _tradeModLookup[tmplKey] = id - end - if not _tradeModLookup[tmpl] then - _tradeModLookup[tmpl] = id - end - end - end - end - end - return _tradeModLookup -end - --- Helper: lazily fetch and cache the trade API stats for comprehensive mod matching --- Covers mods not in QueryMods.lua (cluster enchants, unique-specific mods, etc.) -local _tradeStatsLookup = nil -local _tradeStatsFetched = false -local function getTradeStatsLookup() - if _tradeStatsFetched then return _tradeStatsLookup end - _tradeStatsFetched = true - local tradeStats = "" - local easy = common.curl.easy() - if not easy then return nil end - easy:setopt_url("https://www.pathofexile.com/api/trade/data/stats") - easy:setopt_useragent("Path of Building/" .. (launch.versionNumber or "")) - easy:setopt_writefunction(function(d) - tradeStats = tradeStats .. d - return true - end) - local ok = easy:perform() - easy:close() - if not ok or tradeStats == "" then return nil end - local parsed = dkjson.decode(tradeStats) - if not parsed or not parsed.result then return nil end - _tradeStatsLookup = {} - for _, category in ipairs(parsed.result) do - local catLabel = category.label - for _, entry in ipairs(category.entries) do - local stripped = entry.text:gsub("[#()0-9%-%+%.]", "") - local key = stripped .. "|" .. catLabel - if not _tradeStatsLookup[key] then - _tradeStatsLookup[key] = entry - end - if not _tradeStatsLookup[stripped] then - _tradeStatsLookup[stripped] = entry - end - end - end - return _tradeStatsLookup -end - --- Map source types used in OpenBuySimilarPopup to trade API category labels -local sourceTypeToCategory = { - ["implicit"] = "Implicit", - ["explicit"] = "Explicit", - ["enchant"] = "Enchant", -} - --- Helper: find the trade stat ID for a mod line -findTradeModId = function(modLine, modType) - -- Try QueryMods-based lookup - local lookup = getTradeModLookup() - local tmpl = modLineTemplate(modLine) - -- Try exact match with type first - local key = tmpl .. "|" .. modType - if lookup[key] then - return lookup[key] - end - -- Try without leading +/- sign - local stripped = tmpl:gsub("^[%+%-]", "") - key = stripped .. "|" .. modType - if lookup[key] then - return lookup[key] - end - -- Fallback: match by template text only (any type) - if lookup[tmpl] then - return lookup[tmpl] - end - if lookup[stripped] then - return lookup[stripped] - end - - -- Try trade API stats (covers mods not in QueryMods) - local tradeStats = getTradeStatsLookup() - if tradeStats then - local strippedLine = modLine:gsub("[#()0-9%-%+%.]", "") - local category = sourceTypeToCategory[modType] - if category then - local catKey = strippedLine .. "|" .. category - if tradeStats[catKey] then - return tradeStats[catKey].id - end - end - -- Fallback: any category - if tradeStats[strippedLine] then - return tradeStats[strippedLine].id - end - end - - return nil -end - --- Helper: map slot name + item type to trade API category string -getTradeCategory = function(slotName, item) - if not item or not item.base then return nil end - local itemType = item.type or (item.base and item.base.type) - if slotName:find("^Weapon %d") then - if itemType == "Shield" then return "armour.shield" - elseif itemType == "Quiver" then return "armour.quiver" - elseif itemType == "Bow" then return "weapon.bow" - elseif itemType == "Staff" then return "weapon.staff" - elseif itemType == "Two Handed Sword" then return "weapon.twosword" - elseif itemType == "Two Handed Axe" then return "weapon.twoaxe" - elseif itemType == "Two Handed Mace" then return "weapon.twomace" - elseif itemType == "Fishing Rod" then return "weapon.rod" - elseif itemType == "One Handed Sword" then return "weapon.onesword" - elseif itemType == "One Handed Axe" then return "weapon.oneaxe" - elseif itemType == "One Handed Mace" or itemType == "Sceptre" then return "weapon.onemace" - elseif itemType == "Wand" then return "weapon.wand" - elseif itemType == "Dagger" then return "weapon.dagger" - elseif itemType == "Claw" then return "weapon.claw" - elseif itemType and itemType:find("Two Handed") then return "weapon.twomelee" - elseif itemType and itemType:find("One Handed") then return "weapon.one" - else return "weapon" - end - elseif slotName == "Body Armour" then return "armour.chest" - elseif slotName == "Helmet" then return "armour.helmet" - elseif slotName == "Gloves" then return "armour.gloves" - elseif slotName == "Boots" then return "armour.boots" - elseif slotName == "Amulet" then return "accessory.amulet" - elseif slotName == "Ring 1" or slotName == "Ring 2" or slotName == "Ring 3" then return "accessory.ring" - elseif slotName == "Belt" then return "accessory.belt" - elseif slotName:find("Abyssal") then return "jewel.abyss" - elseif slotName:find("Jewel") then return "jewel" - elseif slotName:find("Flask") then return "flask" - else return nil - end -end - --- Helper: get a display-friendly category name from slot name -getTradeCategoryLabel = function(slotName, item) - if not item or not item.base then return "Item" end - local baseType = item.base.type or item.type - return baseType or "Item" -end - --- Helper: build a mod comparison map from an item. --- Returns a table keyed by template string → { line = original text, value = first number } -local function buildModMap(item) - local modMap = {} - if not item then return modMap end - for _, modList in ipairs{item.enchantModLines or {}, item.scourgeModLines or {}, item.implicitModLines or {}, item.explicitModLines or {}, item.crucibleModLines or {}} do - for _, modLine in ipairs(modList) do - if item:CheckModLineVariant(modLine) then - local formatted = itemLib.formatModLine(modLine) - if formatted then - local tmpl = modLineTemplate(modLine.line) - modMap[tmpl] = { line = modLine.line, value = modLineValue(modLine.line) } - end - end - end - end - return modMap -end - --- Helper: get diff label string for an item slot comparison -local function getSlotDiffLabel(pItem, cItem) - if not pItem and not cItem then - return "^8(both empty)" - end - if pItem and cItem and pItem.name == cItem.name then - return colorCodes.POSITIVE .. "(match)" - elseif not pItem then - return colorCodes.NEGATIVE .. "(missing)" - elseif not cItem then - return colorCodes.TIP .. "(extra)" - else - return colorCodes.WARNING .. "(different)" - end -end - --- Helper: draw Copy, Copy+Use, and Buy buttons at the given position. --- btnStartX is the left edge where the first button (Buy) should appear. --- Returns copyHovered, copyUseHovered, buyHovered booleans. -local function drawCopyButtons(cursorX, cursorY, btnStartX, btnY, slotMissing) - local btnW = LAYOUT.itemsCopyBtnW - local btnH = LAYOUT.itemsCopyBtnH - local buyW = LAYOUT.itemsBuyBtnW - local btn3X = btnStartX - local btn1X = btn3X + buyW + 4 - local btn2X = btn1X + btnW + 4 - - -- "Buy" button - local b3Hover = cursorX >= btn3X and cursorX < btn3X + buyW - and cursorY >= btnY and cursorY < btnY + btnH - SetDrawColor(b3Hover and 0.5 or 0.35, b3Hover and 0.5 or 0.35, b3Hover and 0.5 or 0.35) - DrawImage(nil, btn3X, btnY, buyW, btnH) - SetDrawColor(0.1, 0.1, 0.1) - DrawImage(nil, btn3X + 1, btnY + 1, buyW - 2, btnH - 2) - SetDrawColor(1, 1, 1) - DrawString(btn3X + buyW / 2, btnY + 1, "CENTER_X", 14, "VAR", "^7Buy") - - -- "Copy" button - local b1Hover = cursorX >= btn1X and cursorX < btn1X + btnW - and cursorY >= btnY and cursorY < btnY + btnH - SetDrawColor(b1Hover and 0.5 or 0.35, b1Hover and 0.5 or 0.35, b1Hover and 0.5 or 0.35) - DrawImage(nil, btn1X, btnY, btnW, btnH) - SetDrawColor(0.1, 0.1, 0.1) - DrawImage(nil, btn1X + 1, btnY + 1, btnW - 2, btnH - 2) - SetDrawColor(1, 1, 1) - DrawString(btn1X + btnW / 2, btnY + 1, "CENTER_X", 14, "VAR", "^7Copy") - - local b2Hover - if slotMissing then - -- Show "Missing slot" label instead of Copy+Use button - SetDrawColor(1, 1, 1) - DrawString(btn2X + btnW / 2, btnY + 1, "CENTER_X", 14, "VAR", "^xBBBBBBMissing slot") - b2Hover = false - else - -- "Copy+Use" button - b2Hover = cursorX >= btn2X and cursorX < btn2X + btnW - and cursorY >= btnY and cursorY < btnY + btnH - SetDrawColor(b2Hover and 0.5 or 0.35, b2Hover and 0.5 or 0.35, b2Hover and 0.5 or 0.35) - DrawImage(nil, btn2X, btnY, btnW, btnH) - SetDrawColor(0.1, 0.1, 0.1) - DrawImage(nil, btn2X + 1, btnY + 1, btnW - 2, btnH - 2) - SetDrawColor(1, 1, 1) - DrawString(btn2X + btnW / 2, btnY + 1, "CENTER_X", 14, "VAR", "^7Copy+Use") - end - - return b1Hover, b2Hover, b3Hover, btn2X, btnY, btnW, btnH -end - --- Helper: fit a colored item name within maxW pixels, truncating with "..." if needed. -local function fitItemName(colorCode, name, maxW) - local display = colorCode .. name - if DrawStringWidth(16, "VAR", display) <= maxW then - return display - end - local lo, hi = 0, #name - while lo < hi do - local mid = m_floor((lo + hi + 1) / 2) - if DrawStringWidth(16, "VAR", colorCode .. name:sub(1, mid) .. "...") <= maxW then - lo = mid - else - hi = mid - 1 - end - end - return colorCode .. name:sub(1, lo) .. "..." -end - --- Helper: draw a single compact-mode item row. --- Returns: pHover, cHover, b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H, hoverItem, hoverItemsTab -local ITEM_BOX_W = 310 -local ITEM_BOX_H = 20 - -local function drawCompactSlotRow(drawY, slotLabel, pItem, cItem, - colWidth, cursorX, cursorY, maxLabelW, primaryItemsTab, compareItemsTab, pWarn, cWarn, slotMissing) - - local pName = pItem and pItem.name or "(empty)" - local cName = cItem and cItem.name or "(empty)" - if pWarn and pWarn ~= "" then pName = pName .. pWarn end - if cWarn and cWarn ~= "" then cName = cName .. cWarn end - local pColor = getRarityColor(pItem) - local cColor = getRarityColor(cItem) - local diffLabel = getSlotDiffLabel(pItem, cItem) - - -- Layout positions (fixed 310px box width matching regular Items tab) - local labelX = 10 - local pBoxX = labelX + maxLabelW + 4 - local pBoxW = ITEM_BOX_W - - local cBoxX = colWidth + 10 - local cBoxW = ITEM_BOX_W - - -- Diff indicator position - local diffX = pBoxX + pBoxW + 6 - - -- Hover detection - local pHover = pItem and cursorX >= pBoxX and cursorX < pBoxX + pBoxW - and cursorY >= drawY and cursorY < drawY + ITEM_BOX_H - local cHover = cItem and cursorX >= cBoxX and cursorX < cBoxX + cBoxW - and cursorY >= drawY and cursorY < drawY + ITEM_BOX_H - - -- Draw slot label - SetDrawColor(1, 1, 1) - DrawString(labelX, drawY + 2, "LEFT", 16, "VAR", "^7" .. slotLabel .. ":") - - -- Draw primary item box - local pBorderGray = pHover and 0.5 or 0.33 - SetDrawColor(pBorderGray, pBorderGray, pBorderGray) - DrawImage(nil, pBoxX, drawY, pBoxW, ITEM_BOX_H) - SetDrawColor(0.05, 0.05, 0.05) - DrawImage(nil, pBoxX + 1, drawY + 1, pBoxW - 2, ITEM_BOX_H - 2) - SetDrawColor(1, 1, 1) - DrawString(pBoxX + 4, drawY + 2, "LEFT", 16, "VAR", fitItemName(pColor, pName, pBoxW - 8)) - - -- Draw diff indicator (between the two item boxes) - DrawString(diffX, drawY + 3, "LEFT", 14, "VAR", diffLabel) - - -- Draw compare item box - local cBorderGray = cHover and 0.5 or 0.33 - SetDrawColor(cBorderGray, cBorderGray, cBorderGray) - DrawImage(nil, cBoxX, drawY, cBoxW, ITEM_BOX_H) - SetDrawColor(0.05, 0.05, 0.05) - DrawImage(nil, cBoxX + 1, drawY + 1, cBoxW - 2, ITEM_BOX_H - 2) - SetDrawColor(1, 1, 1) - DrawString(cBoxX + 4, drawY + 2, "LEFT", 16, "VAR", fitItemName(cColor, cName, cBoxW - 8)) - - -- Draw buttons - local b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H - if cItem then - local btnStartX = cBoxX + cBoxW + 6 - b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H = - drawCopyButtons(cursorX, cursorY, btnStartX, drawY + 1, slotMissing) - end - - -- Determine hovered item and tooltip anchor position - local hoverItem = nil - local hoverItemsTab = nil - local hoverBoxX, hoverBoxY, hoverBoxW, hoverBoxH = 0, 0, 0, 0 - if pHover then - hoverItem = pItem - hoverItemsTab = primaryItemsTab - hoverBoxX, hoverBoxY, hoverBoxW, hoverBoxH = pBoxX, drawY, pBoxW, ITEM_BOX_H - elseif cHover then - hoverItem = cItem - hoverItemsTab = compareItemsTab - hoverBoxX, hoverBoxY, hoverBoxW, hoverBoxH = cBoxX, drawY, cBoxW, ITEM_BOX_H - end - - return pHover, cHover, b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H, - hoverItem, hoverItemsTab, hoverBoxX, hoverBoxY, hoverBoxW, hoverBoxH -end - -- Draw a single item's full details at (x, startY) within colWidth. -- otherModMap: optional table from buildModMap() of the other item for diff highlighting. -- Returns the total height consumed. @@ -3667,7 +3284,7 @@ function CompareTabClass:DrawItemExpanded(item, x, startY, colWidth, otherModMap end -- Item name - local rarityColor = getRarityColor(item) + local rarityColor = tradeHelpers.getRarityColor(item) DrawString(x, drawY, "LEFT", 16, "VAR", rarityColor .. item.name) drawY = drawY + 18 @@ -3772,14 +3389,14 @@ function CompareTabClass:DrawItemExpanded(item, x, startY, colWidth, otherModMap local formatted = itemLib.formatModLine(modLine) if formatted then if otherModMap then - local tmpl = modLineTemplate(modLine.line) + local tmpl = tradeHelpers.modLineTemplate(modLine.line) local otherEntry = otherModMap[tmpl] if not otherEntry then -- Mod exists only on this side formatted = colorCodes.POSITIVE .. "+ " .. formatted elseif otherEntry.line ~= modLine.line then -- Same mod template but different values - local myVal = modLineValue(modLine.line) + local myVal = tradeHelpers.modLineValue(modLine.line) local otherVal = otherEntry.value if myVal > otherVal then formatted = colorCodes.POSITIVE .. "> " .. formatted @@ -3892,12 +3509,12 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) -- Slot label + diff indicator SetDrawColor(1, 1, 1) DrawString(10, drawY, "LEFT", 16, "VAR", "^7" .. slotName .. ":") - DrawString(colWidth - 10, drawY, "RIGHT", 14, "VAR", getSlotDiffLabel(pItem, cItem)) + DrawString(colWidth - 10, drawY, "RIGHT", 14, "VAR", tradeHelpers.getSlotDiffLabel(pItem, cItem)) -- Copy/Buy buttons for compare item if cItem then local slotMissing = slotName == "Ring 3" and not primaryHasRing3 - local b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H = drawCopyButtons(cursorX, cursorY, vp.width - 196, drawY + 1, slotMissing) + local b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H = tradeHelpers.drawCopyButtons(cursorX, cursorY, vp.width - 196, drawY + 1, slotMissing, LAYOUT.itemsCopyBtnW, LAYOUT.itemsCopyBtnH, LAYOUT.itemsBuyBtnW) if b2Hover then hoverCopyUseItem = cItem hoverCopyUseSlotName = slotName @@ -3926,8 +3543,8 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) drawY = drawY + 20 -- Build mod maps for diff highlighting - local pModMap = buildModMap(pItem) - local cModMap = buildModMap(cItem) + local pModMap = tradeHelpers.buildModMap(pItem) + local cModMap = tradeHelpers.buildModMap(cItem) -- Draw both items expanded side by side local itemStartY = drawY @@ -3944,10 +3561,11 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) -- === COMPACT MODE (single-line with bordered boxes) === local pHover, cHover, b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H, rowHoverItem, rowHoverItemsTab, rowHoverX, rowHoverY, rowHoverW, rowHoverH = - drawCompactSlotRow(drawY, slotName, pItem, cItem, + tradeHelpers.drawCompactSlotRow(drawY, slotName, pItem, cItem, colWidth, cursorX, cursorY, maxLabelW, self.primaryBuild.itemsTab, compareEntry.itemsTab, nil, nil, - slotName == "Ring 3" and not primaryHasRing3) + slotName == "Ring 3" and not primaryHasRing3, + LAYOUT.itemsCopyBtnW, LAYOUT.itemsCopyBtnH, LAYOUT.itemsBuyBtnW) if rowHoverItem then hoverItem = rowHoverItem @@ -4051,11 +3669,11 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) -- === EXPANDED MODE === SetDrawColor(1, 1, 1) DrawString(10, drawY, "LEFT", 16, "VAR", "^7" .. jEntry.label .. ":" .. pWarn) - DrawString(colWidth - 10, drawY, "RIGHT", 14, "VAR", getSlotDiffLabel(pItem, cItem)) + DrawString(colWidth - 10, drawY, "RIGHT", 14, "VAR", tradeHelpers.getSlotDiffLabel(pItem, cItem)) -- Copy/Buy buttons for compare jewel if cItem then - local b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H = drawCopyButtons(cursorX, cursorY, vp.width - 196, drawY + 1) + local b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H = tradeHelpers.drawCopyButtons(cursorX, cursorY, vp.width - 196, drawY + 1, nil, LAYOUT.itemsCopyBtnW, LAYOUT.itemsCopyBtnH, LAYOUT.itemsBuyBtnW) if b2Hover then hoverCopyUseItem = cItem hoverCopyUseSlotName = jEntry.pSlotName @@ -4084,8 +3702,8 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) drawY = drawY + 20 -- Build mod maps for diff highlighting - local pModMap = buildModMap(pItem) - local cModMap = buildModMap(cItem) + local pModMap = tradeHelpers.buildModMap(pItem) + local cModMap = tradeHelpers.buildModMap(cItem) -- Draw both items expanded side by side local itemStartY = drawY @@ -4102,9 +3720,10 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) -- === COMPACT MODE (single-line with bordered boxes) === local pHover, cHover, b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H, rowHoverItem, rowHoverItemsTab, rowHoverX, rowHoverY, rowHoverW, rowHoverH = - drawCompactSlotRow(drawY, jEntry.label, pItem, cItem, + tradeHelpers.drawCompactSlotRow(drawY, jEntry.label, pItem, cItem, colWidth, cursorX, cursorY, maxJewelLabelW, - self.primaryBuild.itemsTab, compareEntry.itemsTab, pWarn, cWarn) + self.primaryBuild.itemsTab, compareEntry.itemsTab, pWarn, cWarn, nil, + LAYOUT.itemsCopyBtnW, LAYOUT.itemsCopyBtnH, LAYOUT.itemsBuyBtnW) if rowHoverItem then hoverItem = rowHoverItem diff --git a/src/Classes/CompareTradeHelpers.lua b/src/Classes/CompareTradeHelpers.lua new file mode 100644 index 0000000000..c2ab26e51c --- /dev/null +++ b/src/Classes/CompareTradeHelpers.lua @@ -0,0 +1,392 @@ +-- Path of Building +-- +-- Module: Compare Trade Helpers +-- Stateless trade mod lookup/matching and item display helper functions +-- +local m_floor = math.floor +local dkjson = require "dkjson" +local queryModsData = LoadModule("Data/QueryMods") + +local M = {} + +-- Helper: get rarity color code for an item +function M.getRarityColor(item) + if not item then return "^7" end + if item.rarity == "UNIQUE" then return colorCodes.UNIQUE + elseif item.rarity == "RARE" then return colorCodes.RARE + elseif item.rarity == "MAGIC" then return colorCodes.MAGIC + else return colorCodes.NORMAL end +end + +-- Helper: normalize a mod line by replacing numbers with "#" for template matching +function M.modLineTemplate(line) + -- Replace decimal numbers first (e.g. "1.5"), then integers + return line:gsub("[%d]+%.?[%d]*", "#") +end + +-- Helper: extract the first number from a mod line for value comparison +function M.modLineValue(line) + return tonumber(line:match("[%d]+%.?[%d]*")) or 0 +end + +-- Helper: lazily build a reverse lookup from QueryMods tradeMod.text → tradeMod.id +local _tradeModLookup = nil +local function getTradeModLookup() + if _tradeModLookup then return _tradeModLookup end + _tradeModLookup = {} + if not queryModsData then return _tradeModLookup end + for _groupName, mods in pairs(queryModsData) do + for _modKey, modData in pairs(mods) do + if type(modData) == "table" and modData.tradeMod then + local text = modData.tradeMod.text + local modType = modData.tradeMod.type or "explicit" + local id = modData.tradeMod.id + local key = text .. "|" .. modType + _tradeModLookup[key] = id + if not _tradeModLookup[text] then + _tradeModLookup[text] = id + end + -- Also store with template-converted text for mods with literal numbers + -- (e.g. "1 Added Passive Skill is X" → "# Added Passive Skill is X") + local tmpl = M.modLineTemplate(text) + if tmpl ~= text then + local tmplKey = tmpl .. "|" .. modType + if not _tradeModLookup[tmplKey] then + _tradeModLookup[tmplKey] = id + end + if not _tradeModLookup[tmpl] then + _tradeModLookup[tmpl] = id + end + end + end + end + end + return _tradeModLookup +end + +-- Helper: lazily fetch and cache the trade API stats for comprehensive mod matching +-- Covers mods not in QueryMods.lua (cluster enchants, unique-specific mods, etc.) +local _tradeStatsLookup = nil +local _tradeStatsFetched = false +local function getTradeStatsLookup() + if _tradeStatsFetched then return _tradeStatsLookup end + _tradeStatsFetched = true + local tradeStats = "" + local easy = common.curl.easy() + if not easy then return nil end + easy:setopt_url("https://www.pathofexile.com/api/trade/data/stats") + easy:setopt_useragent("Path of Building/" .. (launch.versionNumber or "")) + easy:setopt_writefunction(function(d) + tradeStats = tradeStats .. d + return true + end) + local ok = easy:perform() + easy:close() + if not ok or tradeStats == "" then return nil end + local parsed = dkjson.decode(tradeStats) + if not parsed or not parsed.result then return nil end + _tradeStatsLookup = {} + for _, category in ipairs(parsed.result) do + local catLabel = category.label + for _, entry in ipairs(category.entries) do + local stripped = entry.text:gsub("[#()0-9%-%+%.]", "") + local key = stripped .. "|" .. catLabel + if not _tradeStatsLookup[key] then + _tradeStatsLookup[key] = entry + end + if not _tradeStatsLookup[stripped] then + _tradeStatsLookup[stripped] = entry + end + end + end + return _tradeStatsLookup +end + +-- Map source types used in OpenBuySimilarPopup to trade API category labels +M.sourceTypeToCategory = { + ["implicit"] = "Implicit", + ["explicit"] = "Explicit", + ["enchant"] = "Enchant", +} + +-- Helper: find the trade stat ID for a mod line +function M.findTradeModId(modLine, modType) + -- Try QueryMods-based lookup + local lookup = getTradeModLookup() + local tmpl = M.modLineTemplate(modLine) + -- Try exact match with type first + local key = tmpl .. "|" .. modType + if lookup[key] then + return lookup[key] + end + -- Try without leading +/- sign + local stripped = tmpl:gsub("^[%+%-]", "") + key = stripped .. "|" .. modType + if lookup[key] then + return lookup[key] + end + -- Fallback: match by template text only (any type) + if lookup[tmpl] then + return lookup[tmpl] + end + if lookup[stripped] then + return lookup[stripped] + end + + -- Try trade API stats (covers mods not in QueryMods) + local tradeStats = getTradeStatsLookup() + if tradeStats then + local strippedLine = modLine:gsub("[#()0-9%-%+%.]", "") + local category = M.sourceTypeToCategory[modType] + if category then + local catKey = strippedLine .. "|" .. category + if tradeStats[catKey] then + return tradeStats[catKey].id + end + end + -- Fallback: any category + if tradeStats[strippedLine] then + return tradeStats[strippedLine].id + end + end + + return nil +end + +-- Helper: map slot name + item type to trade API category string +function M.getTradeCategory(slotName, item) + if not item or not item.base then return nil end + local itemType = item.type or (item.base and item.base.type) + if slotName:find("^Weapon %d") then + if itemType == "Shield" then return "armour.shield" + elseif itemType == "Quiver" then return "armour.quiver" + elseif itemType == "Bow" then return "weapon.bow" + elseif itemType == "Staff" then return "weapon.staff" + elseif itemType == "Two Handed Sword" then return "weapon.twosword" + elseif itemType == "Two Handed Axe" then return "weapon.twoaxe" + elseif itemType == "Two Handed Mace" then return "weapon.twomace" + elseif itemType == "Fishing Rod" then return "weapon.rod" + elseif itemType == "One Handed Sword" then return "weapon.onesword" + elseif itemType == "One Handed Axe" then return "weapon.oneaxe" + elseif itemType == "One Handed Mace" or itemType == "Sceptre" then return "weapon.onemace" + elseif itemType == "Wand" then return "weapon.wand" + elseif itemType == "Dagger" then return "weapon.dagger" + elseif itemType == "Claw" then return "weapon.claw" + elseif itemType and itemType:find("Two Handed") then return "weapon.twomelee" + elseif itemType and itemType:find("One Handed") then return "weapon.one" + else return "weapon" + end + elseif slotName == "Body Armour" then return "armour.chest" + elseif slotName == "Helmet" then return "armour.helmet" + elseif slotName == "Gloves" then return "armour.gloves" + elseif slotName == "Boots" then return "armour.boots" + elseif slotName == "Amulet" then return "accessory.amulet" + elseif slotName == "Ring 1" or slotName == "Ring 2" or slotName == "Ring 3" then return "accessory.ring" + elseif slotName == "Belt" then return "accessory.belt" + elseif slotName:find("Abyssal") then return "jewel.abyss" + elseif slotName:find("Jewel") then return "jewel" + elseif slotName:find("Flask") then return "flask" + else return nil + end +end + +-- Helper: get a display-friendly category name from slot name +function M.getTradeCategoryLabel(slotName, item) + if not item or not item.base then return "Item" end + local baseType = item.base.type or item.type + return baseType or "Item" +end + +-- Helper: build a mod comparison map from an item. +-- Returns a table keyed by template string → { line = original text, value = first number } +function M.buildModMap(item) + local modMap = {} + if not item then return modMap end + for _, modList in ipairs{item.enchantModLines or {}, item.scourgeModLines or {}, item.implicitModLines or {}, item.explicitModLines or {}, item.crucibleModLines or {}} do + for _, modLine in ipairs(modList) do + if item:CheckModLineVariant(modLine) then + local formatted = itemLib.formatModLine(modLine) + if formatted then + local tmpl = M.modLineTemplate(modLine.line) + modMap[tmpl] = { line = modLine.line, value = M.modLineValue(modLine.line) } + end + end + end + end + return modMap +end + +-- Helper: get diff label string for an item slot comparison +function M.getSlotDiffLabel(pItem, cItem) + if not pItem and not cItem then + return "^8(both empty)" + end + if pItem and cItem and pItem.name == cItem.name then + return colorCodes.POSITIVE .. "(match)" + elseif not pItem then + return colorCodes.NEGATIVE .. "(missing)" + elseif not cItem then + return colorCodes.TIP .. "(extra)" + else + return colorCodes.WARNING .. "(different)" + end +end + +-- Helper: draw Copy, Copy+Use, and Buy buttons at the given position. +-- btnStartX is the left edge where the first button (Buy) should appear. +-- copyBtnW, copyBtnH, buyBtnW are button dimensions (passed from LAYOUT by caller). +-- Returns copyHovered, copyUseHovered, buyHovered booleans. +function M.drawCopyButtons(cursorX, cursorY, btnStartX, btnY, slotMissing, copyBtnW, copyBtnH, buyBtnW) + local btnW = copyBtnW + local btnH = copyBtnH + local buyW = buyBtnW + local btn3X = btnStartX + local btn1X = btn3X + buyW + 4 + local btn2X = btn1X + btnW + 4 + + -- "Buy" button + local b3Hover = cursorX >= btn3X and cursorX < btn3X + buyW + and cursorY >= btnY and cursorY < btnY + btnH + SetDrawColor(b3Hover and 0.5 or 0.35, b3Hover and 0.5 or 0.35, b3Hover and 0.5 or 0.35) + DrawImage(nil, btn3X, btnY, buyW, btnH) + SetDrawColor(0.1, 0.1, 0.1) + DrawImage(nil, btn3X + 1, btnY + 1, buyW - 2, btnH - 2) + SetDrawColor(1, 1, 1) + DrawString(btn3X + buyW / 2, btnY + 1, "CENTER_X", 14, "VAR", "^7Buy") + + -- "Copy" button + local b1Hover = cursorX >= btn1X and cursorX < btn1X + btnW + and cursorY >= btnY and cursorY < btnY + btnH + SetDrawColor(b1Hover and 0.5 or 0.35, b1Hover and 0.5 or 0.35, b1Hover and 0.5 or 0.35) + DrawImage(nil, btn1X, btnY, btnW, btnH) + SetDrawColor(0.1, 0.1, 0.1) + DrawImage(nil, btn1X + 1, btnY + 1, btnW - 2, btnH - 2) + SetDrawColor(1, 1, 1) + DrawString(btn1X + btnW / 2, btnY + 1, "CENTER_X", 14, "VAR", "^7Copy") + + local b2Hover + if slotMissing then + -- Show "Missing slot" label instead of Copy+Use button + SetDrawColor(1, 1, 1) + DrawString(btn2X + btnW / 2, btnY + 1, "CENTER_X", 14, "VAR", "^xBBBBBBMissing slot") + b2Hover = false + else + -- "Copy+Use" button + b2Hover = cursorX >= btn2X and cursorX < btn2X + btnW + and cursorY >= btnY and cursorY < btnY + btnH + SetDrawColor(b2Hover and 0.5 or 0.35, b2Hover and 0.5 or 0.35, b2Hover and 0.5 or 0.35) + DrawImage(nil, btn2X, btnY, btnW, btnH) + SetDrawColor(0.1, 0.1, 0.1) + DrawImage(nil, btn2X + 1, btnY + 1, btnW - 2, btnH - 2) + SetDrawColor(1, 1, 1) + DrawString(btn2X + btnW / 2, btnY + 1, "CENTER_X", 14, "VAR", "^7Copy+Use") + end + + return b1Hover, b2Hover, b3Hover, btn2X, btnY, btnW, btnH +end + +-- Helper: fit a colored item name within maxW pixels, truncating with "..." if needed. +local function fitItemName(colorCode, name, maxW) + local display = colorCode .. name + if DrawStringWidth(16, "VAR", display) <= maxW then + return display + end + local lo, hi = 0, #name + while lo < hi do + local mid = m_floor((lo + hi + 1) / 2) + if DrawStringWidth(16, "VAR", colorCode .. name:sub(1, mid) .. "...") <= maxW then + lo = mid + else + hi = mid - 1 + end + end + return colorCode .. name:sub(1, lo) .. "..." +end + +-- Helper: draw a single compact-mode item row. +-- Returns: pHover, cHover, b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H, hoverItem, hoverItemsTab +-- copyBtnW, copyBtnH, buyBtnW are button dimensions (passed from LAYOUT by caller). +local ITEM_BOX_W = 310 +local ITEM_BOX_H = 20 + +function M.drawCompactSlotRow(drawY, slotLabel, pItem, cItem, + colWidth, cursorX, cursorY, maxLabelW, primaryItemsTab, compareItemsTab, pWarn, cWarn, slotMissing, + copyBtnW, copyBtnH, buyBtnW) + + local pName = pItem and pItem.name or "(empty)" + local cName = cItem and cItem.name or "(empty)" + if pWarn and pWarn ~= "" then pName = pName .. pWarn end + if cWarn and cWarn ~= "" then cName = cName .. cWarn end + local pColor = M.getRarityColor(pItem) + local cColor = M.getRarityColor(cItem) + local diffLabel = M.getSlotDiffLabel(pItem, cItem) + + -- Layout positions (fixed 310px box width matching regular Items tab) + local labelX = 10 + local pBoxX = labelX + maxLabelW + 4 + local pBoxW = ITEM_BOX_W + + local cBoxX = colWidth + 10 + local cBoxW = ITEM_BOX_W + + -- Diff indicator position + local diffX = pBoxX + pBoxW + 6 + + -- Hover detection + local pHover = pItem and cursorX >= pBoxX and cursorX < pBoxX + pBoxW + and cursorY >= drawY and cursorY < drawY + ITEM_BOX_H + local cHover = cItem and cursorX >= cBoxX and cursorX < cBoxX + cBoxW + and cursorY >= drawY and cursorY < drawY + ITEM_BOX_H + + -- Draw slot label + SetDrawColor(1, 1, 1) + DrawString(labelX, drawY + 2, "LEFT", 16, "VAR", "^7" .. slotLabel .. ":") + + -- Draw primary item box + local pBorderGray = pHover and 0.5 or 0.33 + SetDrawColor(pBorderGray, pBorderGray, pBorderGray) + DrawImage(nil, pBoxX, drawY, pBoxW, ITEM_BOX_H) + SetDrawColor(0.05, 0.05, 0.05) + DrawImage(nil, pBoxX + 1, drawY + 1, pBoxW - 2, ITEM_BOX_H - 2) + SetDrawColor(1, 1, 1) + DrawString(pBoxX + 4, drawY + 2, "LEFT", 16, "VAR", fitItemName(pColor, pName, pBoxW - 8)) + + -- Draw diff indicator (between the two item boxes) + DrawString(diffX, drawY + 3, "LEFT", 14, "VAR", diffLabel) + + -- Draw compare item box + local cBorderGray = cHover and 0.5 or 0.33 + SetDrawColor(cBorderGray, cBorderGray, cBorderGray) + DrawImage(nil, cBoxX, drawY, cBoxW, ITEM_BOX_H) + SetDrawColor(0.05, 0.05, 0.05) + DrawImage(nil, cBoxX + 1, drawY + 1, cBoxW - 2, ITEM_BOX_H - 2) + SetDrawColor(1, 1, 1) + DrawString(cBoxX + 4, drawY + 2, "LEFT", 16, "VAR", fitItemName(cColor, cName, cBoxW - 8)) + + -- Draw buttons + local b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H + if cItem then + local btnStartX = cBoxX + cBoxW + 6 + b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H = + M.drawCopyButtons(cursorX, cursorY, btnStartX, drawY + 1, slotMissing, copyBtnW, copyBtnH, buyBtnW) + end + + -- Determine hovered item and tooltip anchor position + local hoverItem = nil + local hoverItemsTab = nil + local hoverBoxX, hoverBoxY, hoverBoxW, hoverBoxH = 0, 0, 0, 0 + if pHover then + hoverItem = pItem + hoverItemsTab = primaryItemsTab + hoverBoxX, hoverBoxY, hoverBoxW, hoverBoxH = pBoxX, drawY, pBoxW, ITEM_BOX_H + elseif cHover then + hoverItem = cItem + hoverItemsTab = compareItemsTab + hoverBoxX, hoverBoxY, hoverBoxW, hoverBoxH = cBoxX, drawY, cBoxW, ITEM_BOX_H + end + + return pHover, cHover, b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H, + hoverItem, hoverItemsTab, hoverBoxX, hoverBoxY, hoverBoxW, hoverBoxH +end + +return M From 48e3b2430e721573b4490431fa328ec16e9ae33a Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Sat, 4 Apr 2026 15:39:01 +0200 Subject: [PATCH 36/59] add CompareBuySimilar.lua for logic related to buying similar items --- src/Classes/CompareBuySimilar.lua | 399 ++++++++++++++++++++++++++++++ src/Classes/CompareTab.lua | 389 +---------------------------- 2 files changed, 401 insertions(+), 387 deletions(-) create mode 100644 src/Classes/CompareBuySimilar.lua diff --git a/src/Classes/CompareBuySimilar.lua b/src/Classes/CompareBuySimilar.lua new file mode 100644 index 0000000000..53aac20c0c --- /dev/null +++ b/src/Classes/CompareBuySimilar.lua @@ -0,0 +1,399 @@ +-- Path of Building +-- +-- Module: Compare Buy Similar +-- Buy Similar popup UI and trade search URL builder for the Compare tab. +-- +local t_insert = table.insert +local m_floor = math.floor +local dkjson = require "dkjson" +local tradeHelpers = LoadModule("Classes/CompareTradeHelpers") + +local M = {} + +-- Realm display name to API id mapping +local REALM_API_IDS = { + ["PC"] = "pc", + ["PS4"] = "sony", + ["Xbox"] = "xbox", +} + +-- Listed status display names and their API option values +local LISTED_STATUS_OPTIONS = { + { label = "Instant Buyout & In Person", apiValue = "available" }, + { label = "Instant Buyout", apiValue = "securable" }, + { label = "In Person (Online)", apiValue = "online" }, + { label = "Any", apiValue = "any" }, +} +local LISTED_STATUS_LABELS = { } +for i, entry in ipairs(LISTED_STATUS_OPTIONS) do + LISTED_STATUS_LABELS[i] = entry.label +end + +-- Helper: create a numeric EditControl without +/- spinner buttons +local function newPlainNumericEdit(anchor, rect, init, prompt, limit) + local ctrl = new("EditControl", anchor, rect, init, prompt, "%D", limit) + -- Remove the +/- spinner buttons that "%D" filter triggers + ctrl.isNumeric = false + if ctrl.controls then + if ctrl.controls.buttonDown then ctrl.controls.buttonDown.shown = false end + if ctrl.controls.buttonUp then ctrl.controls.buttonUp.shown = false end + end + return ctrl +end + +-- Build the trade search URL based on popup selections +local function buildURL(item, slotName, controls, modEntries, defenceEntries, isUnique) + -- Determine realm and league from the popup's dropdowns + local realmDisplayValue = controls.realmDrop and controls.realmDrop:GetSelValue() or "PC" + local realm = REALM_API_IDS[realmDisplayValue] or "pc" + local league = controls.leagueDrop and controls.leagueDrop:GetSelValue() + if not league or league == "" or league == "Loading..." then + league = "Standard" + end + local hostName = "https://www.pathofexile.com/" + + -- Determine listed status from dropdown + local listedIndex = controls.listedDrop and controls.listedDrop.selIndex or 1 + local listedApiValue = LISTED_STATUS_OPTIONS[listedIndex] and LISTED_STATUS_OPTIONS[listedIndex].apiValue or "available" + + -- Build query + local queryTable = { + query = { + status = { option = listedApiValue }, + stats = { + { + type = "and", + filters = {} + } + }, + }, + sort = { price = "asc" } + } + local queryFilters = {} + + if isUnique then + -- Search by unique name + -- Strip "Foulborn" prefix from unique name for trade search + local tradeName = (item.title or item.name):gsub("^Foulborn%s+", "") + queryTable.query.name = tradeName + queryTable.query.type = item.baseName + -- If item is Foulborn, add the foulborn_item filter + if item.foulborn then + queryFilters.misc_filters = queryFilters.misc_filters or { filters = {} } + queryFilters.misc_filters.filters.foulborn_item = { option = "true" } + end + else + -- Category filter + local categoryStr = tradeHelpers.getTradeCategory(slotName, item) + if categoryStr then + queryFilters.type_filters = { + filters = { + category = { option = categoryStr } + } + } + end + + -- Base type filter + if controls.baseTypeCheck and controls.baseTypeCheck.state then + queryTable.query.type = item.baseName + end + + -- Item level filter + local ilvlMin = controls.ilvlMin and tonumber(controls.ilvlMin.buf) + local ilvlMax = controls.ilvlMax and tonumber(controls.ilvlMax.buf) + if ilvlMin or ilvlMax then + local ilvlFilter = {} + if ilvlMin then ilvlFilter.min = ilvlMin end + if ilvlMax then ilvlFilter.max = ilvlMax end + queryFilters.misc_filters = { + filters = { + ilvl = ilvlFilter + } + } + end + + -- Defence stat filters + local armourFilters = {} + for i, def in ipairs(defenceEntries) do + local prefix = "def" .. i + if controls[prefix .. "Check"] and controls[prefix .. "Check"].state then + local minVal = tonumber(controls[prefix .. "Min"].buf) + local maxVal = tonumber(controls[prefix .. "Max"].buf) + local filter = {} + if minVal then filter.min = minVal end + if maxVal then filter.max = maxVal end + if minVal or maxVal then + armourFilters[def.tradeKey] = filter + end + end + end + if next(armourFilters) then + queryFilters.armour_filters = { + filters = armourFilters + } + end + end + + -- Mod filters + for i, entry in ipairs(modEntries) do + local prefix = "mod" .. i + if entry.tradeId and controls[prefix .. "Check"] and controls[prefix .. "Check"].state then + local minVal = tonumber(controls[prefix .. "Min"].buf) + local maxVal = tonumber(controls[prefix .. "Max"].buf) + local filter = { id = entry.tradeId } + local value = {} + if minVal then value.min = minVal end + if maxVal then value.max = maxVal end + if next(value) then + filter.value = value + end + t_insert(queryTable.query.stats[1].filters, filter) + end + end + + -- Only include filters if we have any + if next(queryFilters) then + queryTable.query.filters = queryFilters + end + + -- Build URL + local queryJson = dkjson.encode(queryTable) + local url = hostName .. "trade/search" + if realm and realm ~= "" and realm ~= "pc" then + url = url .. "/" .. realm + end + local encodedLeague = league:gsub("[^%w%-%.%_%~]", function(c) + return string.format("%%%02X", string.byte(c)) + end):gsub(" ", "+") + url = url .. "/" .. encodedLeague + url = url .. "?q=" .. urlEncode(queryJson) + + return url +end + +-- Open the Buy Similar popup for a compared item +function M.openPopup(item, slotName, primaryBuild) + if not item then return end + + local isUnique = item.rarity == "UNIQUE" or item.rarity == "RELIC" + local controls = {} + local rowHeight = 24 + local popupWidth = 700 + local leftMargin = 20 + local minFieldX = popupWidth - 160 + local maxFieldX = popupWidth - 80 + local fieldW = 60 + local fieldH = 20 + local checkboxSize = 20 + + -- Collect mod entries with trade IDs + local modEntries = {} + local modTypeSources = { + { list = item.implicitModLines, type = "implicit" }, + { list = item.enchantModLines, type = "enchant" }, + { list = item.scourgeModLines, type = "explicit" }, + { list = item.explicitModLines, type = "explicit" }, + { list = item.crucibleModLines, type = "explicit" }, + } + for _, source in ipairs(modTypeSources) do + if source.list then + for _, modLine in ipairs(source.list) do + if item:CheckModLineVariant(modLine) then + local formatted = itemLib.formatModLine(modLine) + if formatted then + -- Use range-resolved text for matching + local resolvedLine = (modLine.range and itemLib.applyRange(modLine.line, modLine.range, modLine.valueScalar)) or modLine.line + local tradeId = tradeHelpers.findTradeModId(resolvedLine, source.type) + local value = tradeHelpers.modLineValue(resolvedLine) + t_insert(modEntries, { + line = modLine.line, + formatted = formatted:gsub("%^x%x%x%x%x%x%x", ""):gsub("%^%x", ""), -- strip color codes + tradeId = tradeId, + value = value, + modType = source.type, + }) + end + end + end + end + end + + -- Collect defence stats for non-unique gear items + local defenceEntries = {} + if not isUnique and item.armourData and item.base and item.base.armour then + local defences = { + { key = "Armour", label = "Armour", tradeKey = "ar" }, + { key = "Evasion", label = "Evasion", tradeKey = "ev" }, + { key = "EnergyShield", label = "Energy Shield", tradeKey = "es" }, + { key = "Ward", label = "Ward", tradeKey = "ward" }, + } + for _, def in ipairs(defences) do + local val = item.armourData[def.key] + if val and val > 0 then + t_insert(defenceEntries, { + label = def.label, + value = val, + tradeKey = def.tradeKey, + }) + end + end + end + + -- Build controls + local ctrlY = 25 + + -- Realm and league dropdowns + local tradeQuery = primaryBuild.itemsTab and primaryBuild.itemsTab.tradeQuery + local tradeQueryRequests = tradeQuery and tradeQuery.tradeQueryRequests + if not tradeQueryRequests then + tradeQueryRequests = new("TradeQueryRequests") + end + + -- Helper to fetch and populate leagues for a given realm API id + local function fetchLeaguesForRealm(realmApiId) + controls.leagueDrop:SetList({"Loading..."}) + controls.leagueDrop.selIndex = 1 + tradeQueryRequests:FetchLeagues(realmApiId, function(leagues, errMsg) + if errMsg then + controls.leagueDrop:SetList({"Standard"}) + return + end + local leagueList = {} + for _, league in ipairs(leagues) do + if league ~= "Standard" and league ~= "Ruthless" and league ~= "Hardcore" and league ~= "Hardcore Ruthless" then + if not (league:find("Hardcore") or league:find("Ruthless")) then + t_insert(leagueList, 1, league) + else + t_insert(leagueList, league) + end + end + end + t_insert(leagueList, "Standard") + t_insert(leagueList, "Hardcore") + t_insert(leagueList, "Ruthless") + t_insert(leagueList, "Hardcore Ruthless") + controls.leagueDrop:SetList(leagueList) + end) + end + + -- Realm dropdown + controls.realmLabel = new("LabelControl", {"TOPLEFT", nil, "TOPLEFT"}, {leftMargin, ctrlY, 0, 16}, "^7Realm:") + controls.realmDrop = new("DropDownControl", {"LEFT", controls.realmLabel, "RIGHT"}, {4, 0, 80, 20}, {"PC", "PS4", "Xbox"}, function(index, value) + local realmApiId = REALM_API_IDS[value] or "pc" + fetchLeaguesForRealm(realmApiId) + end) + + -- League dropdown + controls.leagueLabel = new("LabelControl", {"LEFT", controls.realmDrop, "RIGHT"}, {12, 0, 0, 16}, "^7League:") + controls.leagueDrop = new("DropDownControl", {"LEFT", controls.leagueLabel, "RIGHT"}, {4, 0, 160, 20}, {"Loading..."}, function(index, value) + -- League selection stored in the dropdown itself + end) + controls.leagueDrop.enabled = function() return #controls.leagueDrop.list > 0 and controls.leagueDrop.list[1] ~= "Loading..." end + + -- Listed status dropdown + controls.listedLabel = new("LabelControl", {"LEFT", controls.leagueDrop, "RIGHT"}, {12, 0, 0, 16}, "^7Listed:") + controls.listedDrop = new("DropDownControl", {"LEFT", controls.listedLabel, "RIGHT"}, {4, 0, 180, 20}, LISTED_STATUS_LABELS, function(index, value) + -- Listed status selection stored in the dropdown itself + end) + + -- Fetch initial leagues for default realm + fetchLeaguesForRealm("pc") + ctrlY = ctrlY + rowHeight + 4 + + if isUnique then + -- Unique item name label + controls.nameLabel = new("LabelControl", nil, {0, ctrlY, 0, 16}, "^x" .. (colorCodes[item.rarity] or "FFFFFF"):gsub("%^x","") .. item.name) + ctrlY = ctrlY + rowHeight + else + -- Category label + local categoryLabel = tradeHelpers.getTradeCategoryLabel(slotName, item) + controls.categoryLabel = new("LabelControl", {"TOPLEFT", nil, "TOPLEFT"}, {leftMargin, ctrlY, 0, 16}, "^7Category: " .. categoryLabel) + ctrlY = ctrlY + rowHeight + + -- Base type checkbox + controls.baseTypeCheck = new("CheckBoxControl", nil, {-popupWidth/2 + leftMargin + checkboxSize/2, ctrlY, checkboxSize}, "", nil, nil) + controls.baseTypeLabel = new("LabelControl", {"LEFT", controls.baseTypeCheck, "RIGHT"}, {4, 0, 0, 16}, "^7Use specific base: " .. (item.baseName or "Unknown")) + ctrlY = ctrlY + rowHeight + + -- Item level + ctrlY = ctrlY + 4 + controls.ilvlLabel = new("LabelControl", {"TOPLEFT", nil, "TOPLEFT"}, {leftMargin, ctrlY, 0, 16}, "^7Item Level:") + controls.ilvlMin = newPlainNumericEdit(nil, {minFieldX - popupWidth/2, ctrlY, fieldW, fieldH}, "", "Min", 4) + controls.ilvlMax = newPlainNumericEdit(nil, {maxFieldX - popupWidth/2, ctrlY, fieldW, fieldH}, "", "Max", 4) + ctrlY = ctrlY + rowHeight + + -- Defence stat rows + for i, def in ipairs(defenceEntries) do + local prefix = "def" .. i + controls[prefix .. "Check"] = new("CheckBoxControl", nil, {-popupWidth/2 + leftMargin + checkboxSize/2, ctrlY, checkboxSize}, "", nil, nil) + controls[prefix .. "Label"] = new("LabelControl", {"LEFT", controls[prefix .. "Check"], "RIGHT"}, {4, 0, 0, 16}, "^7" .. def.label) + controls[prefix .. "Min"] = newPlainNumericEdit(nil, {minFieldX - popupWidth/2, ctrlY, fieldW, fieldH}, tostring(m_floor(def.value)), "Min", 6) + controls[prefix .. "Max"] = newPlainNumericEdit(nil, {maxFieldX - popupWidth/2, ctrlY, fieldW, fieldH}, "", "Max", 6) + ctrlY = ctrlY + rowHeight + end + + -- Separator between defence stats and mods + if #defenceEntries > 0 then + ctrlY = ctrlY + 8 + end + end + + -- Mod rows + for i, entry in ipairs(modEntries) do + local prefix = "mod" .. i + local canSearch = entry.tradeId ~= nil + controls[prefix .. "Check"] = new("CheckBoxControl", nil, {-popupWidth/2 + leftMargin + checkboxSize/2, ctrlY, checkboxSize}, "", nil, nil) + controls[prefix .. "Check"].enabled = function() return canSearch end + -- Truncate long mod text to fit + local displayText = entry.formatted + if #displayText > 45 then + displayText = displayText:sub(1, 42) .. "..." + end + controls[prefix .. "Label"] = new("LabelControl", {"LEFT", controls[prefix .. "Check"], "RIGHT"}, {4, 0, 0, 16}, (canSearch and "^7" or "^8") .. displayText) + controls[prefix .. "Min"] = newPlainNumericEdit(nil, {minFieldX - popupWidth/2, ctrlY, fieldW, fieldH}, entry.value ~= 0 and tostring(m_floor(entry.value)) or "", "Min", 8) + controls[prefix .. "Max"] = newPlainNumericEdit(nil, {maxFieldX - popupWidth/2, ctrlY, fieldW, fieldH}, "", "Max", 8) + if not canSearch then + controls[prefix .. "Min"].enabled = function() return false end + controls[prefix .. "Max"].enabled = function() return false end + end + ctrlY = ctrlY + rowHeight + end + + -- Search button + ctrlY = ctrlY + 8 + controls.search = new("ButtonControl", nil, {0, ctrlY, 100, 20}, "Generate URL", function() + local success, result = pcall(function() + return buildURL(item, slotName, controls, modEntries, defenceEntries, isUnique) + end) + if success and result then + controls.uri:SetText(result, true) + elseif not success then + controls.uri:SetText("Error: " .. tostring(result), true) + else + controls.uri:SetText("Error: could not determine league", true) + end + end) + ctrlY = ctrlY + rowHeight + 4 + + -- URL field + controls.uri = new("EditControl", nil, {-30, ctrlY, popupWidth - 100, fieldH}, "", nil, "^%C\t\n") + controls.uri:SetPlaceholder("Press 'Generate URL' then Ctrl+Click to open") + controls.uri.tooltipFunc = function(tooltip) + tooltip:Clear() + if controls.uri.buf and controls.uri.buf ~= "" then + tooltip:AddLine(16, "^7Ctrl + Click to open in web browser") + end + end + controls.close = new("ButtonControl", nil, {popupWidth/2 - 50, ctrlY, 60, 20}, "Close", function() + main:ClosePopup() + end) + + -- Calculate popup height from final control position + local popupHeight = ctrlY + fieldH + 16 + if popupHeight > 600 then popupHeight = 600 end + + local title = "Buy Similar" + main:OpenPopup(popupWidth, popupHeight, title, controls, "search", nil, "close") +end + +return M diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 0149ae23f4..7a81b749a8 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -11,25 +11,7 @@ local m_floor = math.floor local s_format = string.format local dkjson = require "dkjson" local tradeHelpers = LoadModule("Classes/CompareTradeHelpers") - --- Realm display name to API id mapping (used by Buy Similar popup and URL builder) -local REALM_API_IDS = { - ["PC"] = "pc", - ["PS4"] = "sony", - ["Xbox"] = "xbox", -} - --- Listed status display names and their API option values -local LISTED_STATUS_OPTIONS = { - { label = "Instant Buyout & In Person", apiValue = "available" }, - { label = "Instant Buyout", apiValue = "securable" }, - { label = "In Person (Online)", apiValue = "online" }, - { label = "Any", apiValue = "any" }, -} -local LISTED_STATUS_LABELS = { } -for i, entry in ipairs(LISTED_STATUS_OPTIONS) do - LISTED_STATUS_LABELS[i] = entry.label -end +local buySimilar = LoadModule("Classes/CompareBuySimilar") -- Node IDs below this value are normal passive tree nodes; IDs at or above are cluster jewel nodes local CLUSTER_NODE_OFFSET = 65536 @@ -1308,373 +1290,6 @@ function CompareTabClass:CopyCompareItemToPrimary(slotName, compareEntry, andUse self.primaryBuild.buildFlag = true end --- Helper: create a numeric EditControl without +/- spinner buttons -local function newPlainNumericEdit(anchor, rect, init, prompt, limit) - local ctrl = new("EditControl", anchor, rect, init, prompt, "%D", limit) - -- Remove the +/- spinner buttons that "%D" filter triggers - ctrl.isNumeric = false - if ctrl.controls then - if ctrl.controls.buttonDown then ctrl.controls.buttonDown.shown = false end - if ctrl.controls.buttonUp then ctrl.controls.buttonUp.shown = false end - end - return ctrl -end - --- Open the Buy Similar popup for a compared item -function CompareTabClass:OpenBuySimilarPopup(item, slotName) - if not item then return end - - local isUnique = item.rarity == "UNIQUE" or item.rarity == "RELIC" - local controls = {} - local rowHeight = 24 - local popupWidth = 700 - local leftMargin = 20 - local minFieldX = popupWidth - 160 - local maxFieldX = popupWidth - 80 - local fieldW = 60 - local fieldH = 20 - local checkboxSize = 20 - - -- Collect mod entries with trade IDs - local modEntries = {} - local modTypeSources = { - { list = item.implicitModLines, type = "implicit" }, - { list = item.enchantModLines, type = "enchant" }, - { list = item.scourgeModLines, type = "explicit" }, - { list = item.explicitModLines, type = "explicit" }, - { list = item.crucibleModLines, type = "explicit" }, - } - for _, source in ipairs(modTypeSources) do - if source.list then - for _, modLine in ipairs(source.list) do - if item:CheckModLineVariant(modLine) then - local formatted = itemLib.formatModLine(modLine) - if formatted then - -- Use range-resolved text for matching - local resolvedLine = (modLine.range and itemLib.applyRange(modLine.line, modLine.range, modLine.valueScalar)) or modLine.line - local tradeId = tradeHelpers.findTradeModId(resolvedLine, source.type) - local value = tradeHelpers.modLineValue(resolvedLine) - t_insert(modEntries, { - line = modLine.line, - formatted = formatted:gsub("%^x%x%x%x%x%x%x", ""):gsub("%^%x", ""), -- strip color codes - tradeId = tradeId, - value = value, - modType = source.type, - }) - end - end - end - end - end - - -- Collect defence stats for non-unique gear items - local defenceEntries = {} - if not isUnique and item.armourData and item.base and item.base.armour then - local defences = { - { key = "Armour", label = "Armour", tradeKey = "ar" }, - { key = "Evasion", label = "Evasion", tradeKey = "ev" }, - { key = "EnergyShield", label = "Energy Shield", tradeKey = "es" }, - { key = "Ward", label = "Ward", tradeKey = "ward" }, - } - for _, def in ipairs(defences) do - local val = item.armourData[def.key] - if val and val > 0 then - t_insert(defenceEntries, { - label = def.label, - value = val, - tradeKey = def.tradeKey, - }) - end - end - end - - -- Build controls - local ctrlY = 25 - - -- Realm and league dropdowns - local tradeQuery = self.primaryBuild.itemsTab and self.primaryBuild.itemsTab.tradeQuery - local tradeQueryRequests = tradeQuery and tradeQuery.tradeQueryRequests - if not tradeQueryRequests then - tradeQueryRequests = new("TradeQueryRequests") - end - - -- Helper to fetch and populate leagues for a given realm API id - local function fetchLeaguesForRealm(realmApiId) - controls.leagueDrop:SetList({"Loading..."}) - controls.leagueDrop.selIndex = 1 - tradeQueryRequests:FetchLeagues(realmApiId, function(leagues, errMsg) - if errMsg then - controls.leagueDrop:SetList({"Standard"}) - return - end - local leagueList = {} - for _, league in ipairs(leagues) do - if league ~= "Standard" and league ~= "Ruthless" and league ~= "Hardcore" and league ~= "Hardcore Ruthless" then - if not (league:find("Hardcore") or league:find("Ruthless")) then - t_insert(leagueList, 1, league) - else - t_insert(leagueList, league) - end - end - end - t_insert(leagueList, "Standard") - t_insert(leagueList, "Hardcore") - t_insert(leagueList, "Ruthless") - t_insert(leagueList, "Hardcore Ruthless") - controls.leagueDrop:SetList(leagueList) - end) - end - - -- Realm dropdown - controls.realmLabel = new("LabelControl", {"TOPLEFT", nil, "TOPLEFT"}, {leftMargin, ctrlY, 0, 16}, "^7Realm:") - controls.realmDrop = new("DropDownControl", {"LEFT", controls.realmLabel, "RIGHT"}, {4, 0, 80, 20}, {"PC", "PS4", "Xbox"}, function(index, value) - local realmApiId = REALM_API_IDS[value] or "pc" - fetchLeaguesForRealm(realmApiId) - end) - - -- League dropdown - controls.leagueLabel = new("LabelControl", {"LEFT", controls.realmDrop, "RIGHT"}, {12, 0, 0, 16}, "^7League:") - controls.leagueDrop = new("DropDownControl", {"LEFT", controls.leagueLabel, "RIGHT"}, {4, 0, 160, 20}, {"Loading..."}, function(index, value) - -- League selection stored in the dropdown itself - end) - controls.leagueDrop.enabled = function() return #controls.leagueDrop.list > 0 and controls.leagueDrop.list[1] ~= "Loading..." end - - -- Listed status dropdown - controls.listedLabel = new("LabelControl", {"LEFT", controls.leagueDrop, "RIGHT"}, {12, 0, 0, 16}, "^7Listed:") - controls.listedDrop = new("DropDownControl", {"LEFT", controls.listedLabel, "RIGHT"}, {4, 0, 180, 20}, LISTED_STATUS_LABELS, function(index, value) - -- Listed status selection stored in the dropdown itself - end) - - -- Fetch initial leagues for default realm - fetchLeaguesForRealm("pc") - ctrlY = ctrlY + rowHeight + 4 - - if isUnique then - -- Unique item name label - controls.nameLabel = new("LabelControl", nil, {0, ctrlY, 0, 16}, "^x" .. (colorCodes[item.rarity] or "FFFFFF"):gsub("%^x","") .. item.name) - ctrlY = ctrlY + rowHeight - else - -- Category label - local categoryLabel = tradeHelpers.getTradeCategoryLabel(slotName, item) - controls.categoryLabel = new("LabelControl", {"TOPLEFT", nil, "TOPLEFT"}, {leftMargin, ctrlY, 0, 16}, "^7Category: " .. categoryLabel) - ctrlY = ctrlY + rowHeight - - -- Base type checkbox - controls.baseTypeCheck = new("CheckBoxControl", nil, {-popupWidth/2 + leftMargin + checkboxSize/2, ctrlY, checkboxSize}, "", nil, nil) - controls.baseTypeLabel = new("LabelControl", {"LEFT", controls.baseTypeCheck, "RIGHT"}, {4, 0, 0, 16}, "^7Use specific base: " .. (item.baseName or "Unknown")) - ctrlY = ctrlY + rowHeight - - -- Item level - ctrlY = ctrlY + 4 - controls.ilvlLabel = new("LabelControl", {"TOPLEFT", nil, "TOPLEFT"}, {leftMargin, ctrlY, 0, 16}, "^7Item Level:") - controls.ilvlMin = newPlainNumericEdit(nil, {minFieldX - popupWidth/2, ctrlY, fieldW, fieldH}, "", "Min", 4) - controls.ilvlMax = newPlainNumericEdit(nil, {maxFieldX - popupWidth/2, ctrlY, fieldW, fieldH}, "", "Max", 4) - ctrlY = ctrlY + rowHeight - - -- Defence stat rows - for i, def in ipairs(defenceEntries) do - local prefix = "def" .. i - controls[prefix .. "Check"] = new("CheckBoxControl", nil, {-popupWidth/2 + leftMargin + checkboxSize/2, ctrlY, checkboxSize}, "", nil, nil) - controls[prefix .. "Label"] = new("LabelControl", {"LEFT", controls[prefix .. "Check"], "RIGHT"}, {4, 0, 0, 16}, "^7" .. def.label) - controls[prefix .. "Min"] = newPlainNumericEdit(nil, {minFieldX - popupWidth/2, ctrlY, fieldW, fieldH}, tostring(m_floor(def.value)), "Min", 6) - controls[prefix .. "Max"] = newPlainNumericEdit(nil, {maxFieldX - popupWidth/2, ctrlY, fieldW, fieldH}, "", "Max", 6) - ctrlY = ctrlY + rowHeight - end - - -- Separator between defence stats and mods - if #defenceEntries > 0 then - ctrlY = ctrlY + 8 - end - end - - -- Mod rows - for i, entry in ipairs(modEntries) do - local prefix = "mod" .. i - local canSearch = entry.tradeId ~= nil - controls[prefix .. "Check"] = new("CheckBoxControl", nil, {-popupWidth/2 + leftMargin + checkboxSize/2, ctrlY, checkboxSize}, "", nil, nil) - controls[prefix .. "Check"].enabled = function() return canSearch end - -- Truncate long mod text to fit - local displayText = entry.formatted - if #displayText > 45 then - displayText = displayText:sub(1, 42) .. "..." - end - controls[prefix .. "Label"] = new("LabelControl", {"LEFT", controls[prefix .. "Check"], "RIGHT"}, {4, 0, 0, 16}, (canSearch and "^7" or "^8") .. displayText) - controls[prefix .. "Min"] = newPlainNumericEdit(nil, {minFieldX - popupWidth/2, ctrlY, fieldW, fieldH}, entry.value ~= 0 and tostring(m_floor(entry.value)) or "", "Min", 8) - controls[prefix .. "Max"] = newPlainNumericEdit(nil, {maxFieldX - popupWidth/2, ctrlY, fieldW, fieldH}, "", "Max", 8) - if not canSearch then - controls[prefix .. "Min"].enabled = function() return false end - controls[prefix .. "Max"].enabled = function() return false end - end - ctrlY = ctrlY + rowHeight - end - - -- Search button - ctrlY = ctrlY + 8 - controls.search = new("ButtonControl", nil, {0, ctrlY, 100, 20}, "Generate URL", function() - local success, result = pcall(function() - return self:BuildBuySimilarURL(item, slotName, controls, modEntries, defenceEntries, isUnique) - end) - if success and result then - controls.uri:SetText(result, true) - elseif not success then - controls.uri:SetText("Error: " .. tostring(result), true) - else - controls.uri:SetText("Error: could not determine league", true) - end - end) - ctrlY = ctrlY + rowHeight + 4 - - -- URL field - controls.uri = new("EditControl", nil, {-30, ctrlY, popupWidth - 100, fieldH}, "", nil, "^%C\t\n") - controls.uri:SetPlaceholder("Press 'Generate URL' then Ctrl+Click to open") - controls.uri.tooltipFunc = function(tooltip) - tooltip:Clear() - if controls.uri.buf and controls.uri.buf ~= "" then - tooltip:AddLine(16, "^7Ctrl + Click to open in web browser") - end - end - controls.close = new("ButtonControl", nil, {popupWidth/2 - 50, ctrlY, 60, 20}, "Close", function() - main:ClosePopup() - end) - - -- Calculate popup height from final control position - local popupHeight = ctrlY + fieldH + 16 - if popupHeight > 600 then popupHeight = 600 end - - local title = "Buy Similar" - main:OpenPopup(popupWidth, popupHeight, title, controls, "search", nil, "close") -end - --- Build the trade search URL based on popup selections -function CompareTabClass:BuildBuySimilarURL(item, slotName, controls, modEntries, defenceEntries, isUnique) - -- Determine realm and league from the popup's dropdowns - local realmDisplayValue = controls.realmDrop and controls.realmDrop:GetSelValue() or "PC" - local realm = REALM_API_IDS[realmDisplayValue] or "pc" - local league = controls.leagueDrop and controls.leagueDrop:GetSelValue() - if not league or league == "" or league == "Loading..." then - league = "Standard" - end - local hostName = "https://www.pathofexile.com/" - - -- Determine listed status from dropdown - local listedIndex = controls.listedDrop and controls.listedDrop.selIndex or 1 - local listedApiValue = LISTED_STATUS_OPTIONS[listedIndex] and LISTED_STATUS_OPTIONS[listedIndex].apiValue or "available" - - -- Build query - local queryTable = { - query = { - status = { option = listedApiValue }, - stats = { - { - type = "and", - filters = {} - } - }, - }, - sort = { price = "asc" } - } - local queryFilters = {} - - if isUnique then - -- Search by unique name - -- Strip "Foulborn" prefix from unique name for trade search - local tradeName = (item.title or item.name):gsub("^Foulborn%s+", "") - queryTable.query.name = tradeName - queryTable.query.type = item.baseName - -- If item is Foulborn, add the foulborn_item filter - if item.foulborn then - queryFilters.misc_filters = queryFilters.misc_filters or { filters = {} } - queryFilters.misc_filters.filters.foulborn_item = { option = "true" } - end - else - -- Category filter - local categoryStr = tradeHelpers.getTradeCategory(slotName, item) - if categoryStr then - queryFilters.type_filters = { - filters = { - category = { option = categoryStr } - } - } - end - - -- Base type filter - if controls.baseTypeCheck and controls.baseTypeCheck.state then - queryTable.query.type = item.baseName - end - - -- Item level filter - local ilvlMin = controls.ilvlMin and tonumber(controls.ilvlMin.buf) - local ilvlMax = controls.ilvlMax and tonumber(controls.ilvlMax.buf) - if ilvlMin or ilvlMax then - local ilvlFilter = {} - if ilvlMin then ilvlFilter.min = ilvlMin end - if ilvlMax then ilvlFilter.max = ilvlMax end - queryFilters.misc_filters = { - filters = { - ilvl = ilvlFilter - } - } - end - - -- Defence stat filters - local armourFilters = {} - for i, def in ipairs(defenceEntries) do - local prefix = "def" .. i - if controls[prefix .. "Check"] and controls[prefix .. "Check"].state then - local minVal = tonumber(controls[prefix .. "Min"].buf) - local maxVal = tonumber(controls[prefix .. "Max"].buf) - local filter = {} - if minVal then filter.min = minVal end - if maxVal then filter.max = maxVal end - if minVal or maxVal then - armourFilters[def.tradeKey] = filter - end - end - end - if next(armourFilters) then - queryFilters.armour_filters = { - filters = armourFilters - } - end - end - - -- Mod filters - for i, entry in ipairs(modEntries) do - local prefix = "mod" .. i - if entry.tradeId and controls[prefix .. "Check"] and controls[prefix .. "Check"].state then - local minVal = tonumber(controls[prefix .. "Min"].buf) - local maxVal = tonumber(controls[prefix .. "Max"].buf) - local filter = { id = entry.tradeId } - local value = {} - if minVal then value.min = minVal end - if maxVal then value.max = maxVal end - if next(value) then - filter.value = value - end - t_insert(queryTable.query.stats[1].filters, filter) - end - end - - -- Only include filters if we have any - if next(queryFilters) then - queryTable.query.filters = queryFilters - end - - -- Build URL - local queryJson = dkjson.encode(queryTable) - local url = hostName .. "trade/search" - if realm and realm ~= "" and realm ~= "pc" then - url = url .. "/" .. realm - end - local encodedLeague = league:gsub("[^%w%-%.%_%~]", function(c) - return string.format("%%%02X", string.byte(c)) - end):gsub(" ", "+") - url = url .. "/" .. encodedLeague - url = url .. "?q=" .. urlEncode(queryJson) - - return url -end - -- Open the import popup for adding a comparison build function CompareTabClass:OpenImportPopup() local controls = {} @@ -3771,7 +3386,7 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) -- Process buy button click if clickedBuySlot and clickedBuyItem then - self:OpenBuySimilarPopup(clickedBuyItem, clickedBuySlot) + buySimilar.openPopup(clickedBuyItem, clickedBuySlot, self.primaryBuild) end -- Draw item tooltip on hover (compact mode only, on top of everything) From 40d7219aa396302f317e90adc8fc741b8ae01889 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Sat, 4 Apr 2026 16:26:01 +0200 Subject: [PATCH 37/59] adds CompareCalcsHelpers for isolated calcs logic --- src/Classes/CompareCalcsHelpers.lua | 320 ++++++++++++++++++++++++++++ src/Classes/CompareTab.lua | 315 +-------------------------- 2 files changed, 327 insertions(+), 308 deletions(-) create mode 100644 src/Classes/CompareCalcsHelpers.lua diff --git a/src/Classes/CompareCalcsHelpers.lua b/src/Classes/CompareCalcsHelpers.lua new file mode 100644 index 0000000000..a31221b453 --- /dev/null +++ b/src/Classes/CompareCalcsHelpers.lua @@ -0,0 +1,320 @@ +-- Path of Building +-- +-- Module: Compare Calcs Helpers +-- Stateless calcs tooltip helper functions for the Compare Tab. +-- Handles modifier formatting, source resolution, tabulation, and tooltip rendering. +-- +local t_insert = table.insert +local s_format = string.format + +local M = {} + +-- Format a modifier value with its type for display +function M.FormatCalcModValue(value, modType) + if modType == "BASE" then + return s_format("%+g base", value) + elseif modType == "INC" then + if value >= 0 then + return value .. "% increased" + else + return (-value) .. "% reduced" + end + elseif modType == "MORE" then + if value >= 0 then + return value .. "% more" + else + return (-value) .. "% less" + end + elseif modType == "OVERRIDE" then + return "Override: " .. tostring(value) + elseif modType == "FLAG" then + return value and "True" or "False" + else + return tostring(value) + end +end + +-- Format CamelCase mod name to spaced words +function M.FormatCalcModName(modName) + return modName:gsub("([%l%d]:?)(%u)", "%1 %2"):gsub("(%l)(%d)", "%1 %2") +end + +-- Resolve a modifier's source to a human-readable name +function M.ResolveSourceName(mod, build) + if not mod.source then return "" end + local sourceType = mod.source:match("[^:]+") or "" + if sourceType == "Item" then + local itemId = mod.source:match("Item:(%d+):.+") + local item = build.itemsTab and build.itemsTab.items[tonumber(itemId)] + if item then + return colorCodes[item.rarity] .. item.name + end + elseif sourceType == "Tree" then + local nodeId = mod.source:match("Tree:(%d+)") + if nodeId then + local nodeIdNum = tonumber(nodeId) + local node = (build.spec and build.spec.nodes[nodeIdNum]) + or (build.spec and build.spec.tree and build.spec.tree.nodes[nodeIdNum]) + or (build.latestTree and build.latestTree.nodes[nodeIdNum]) + if node then + return node.dn or node.name or "" + end + end + elseif sourceType == "Skill" then + local skillId = mod.source:match("Skill:(.+)") + if skillId and build.data and build.data.skills[skillId] then + return build.data.skills[skillId].name + end + elseif sourceType == "Pantheon" then + return mod.source:match("Pantheon:(.+)") or "" + elseif sourceType == "Spectre" then + return mod.source:match("Spectre:(.+)") or "" + end + return "" +end + +-- Get the modDB and config for a sectionData entry and actor +function M.GetModStoreAndCfg(sectionData, actor) + local cfg = {} + if sectionData.cfg and actor.mainSkill and actor.mainSkill[sectionData.cfg .. "Cfg"] then + cfg = copyTable(actor.mainSkill[sectionData.cfg .. "Cfg"], true) + end + cfg.source = sectionData.modSource + cfg.actor = sectionData.actor + + local modStore + if sectionData.enemy and actor.enemy then + modStore = actor.enemy.modDB + elseif sectionData.cfg and actor.mainSkill then + modStore = actor.mainSkill.skillModList + else + modStore = actor.modDB + end + return modStore, cfg +end + +-- Tabulate modifiers for a sectionData entry and actor +function M.TabulateMods(sectionData, actor) + local modStore, cfg = M.GetModStoreAndCfg(sectionData, actor) + if not modStore then return {} end + + local rowList + if type(sectionData.modName) == "table" then + rowList = modStore:Tabulate(sectionData.modType, cfg, unpack(sectionData.modName)) + else + rowList = modStore:Tabulate(sectionData.modType, cfg, sectionData.modName) + end + return rowList or {} +end + +-- Build a unique key for a modifier row to match between builds +function M.ModRowKey(row) + local src = row.mod.source or "" + local name = row.mod.name or "" + local mtype = row.mod.type or "" + -- Normalize Item sources by stripping the build-specific numeric ID + -- "Item:5:Body Armour" -> "Item:Body Armour" so same items match across builds + local normalizedSrc = src:gsub("^(Item):%d+:", "%1:") + return normalizedSrc .. "|" .. name .. "|" .. mtype +end + +-- Format a single modifier row as a tooltip line +function M.FormatModRow(row, sectionData, build) + local displayValue + if not sectionData.modType then + displayValue = M.FormatCalcModValue(row.value, row.mod.type) + else + displayValue = formatRound(row.value, 2) + end + + local sourceType = row.mod.source and row.mod.source:match("[^:]+") or "?" + local sourceName = M.ResolveSourceName(row.mod, build) + local modName = "" + if type(sectionData.modName) == "table" then + modName = " " .. M.FormatCalcModName(row.mod.name) + end + + return displayValue, sourceType, sourceName, modName +end + +-- Get breakdown text lines for a build's actor +function M.GetBreakdownLines(sectionData, build) + if not sectionData.breakdown then return nil end + local calcsActor = build.calcsTab and build.calcsTab.calcsEnv and build.calcsTab.calcsEnv.player + if not calcsActor or not calcsActor.breakdown then return nil end + + local breakdown + local ns, name = sectionData.breakdown:match("^(%a+)%.(%a+)$") + if ns then + breakdown = calcsActor.breakdown[ns] and calcsActor.breakdown[ns][name] + else + breakdown = calcsActor.breakdown[sectionData.breakdown] + end + + if not breakdown or #breakdown == 0 then return nil end + + local lines = {} + for _, line in ipairs(breakdown) do + if type(line) == "string" then + t_insert(lines, line) + end + end + return #lines > 0 and lines or nil +end + +-- Draw the calcs hover tooltip showing breakdown for both builds with common/unique grouping +-- tooltip, primaryBuild, primaryLabel passed as args instead of self +function M.DrawCalcsTooltip(tooltip, primaryBuild, primaryLabel, colData, rowLabel, rowX, rowY, rowW, rowH, vp, compareEntry) + if tooltip:CheckForUpdate(colData, rowLabel) then + -- Get calcsEnv actors (these have breakdown data populated) + local primaryCalcsActor = primaryBuild.calcsTab and primaryBuild.calcsTab.calcsEnv + and primaryBuild.calcsTab.calcsEnv.player + local compareCalcsActor = compareEntry.calcsTab and compareEntry.calcsTab.calcsEnv + and compareEntry.calcsTab.calcsEnv.player + + local primaryActor = primaryCalcsActor or (primaryBuild.calcsTab.mainEnv and primaryBuild.calcsTab.mainEnv.player) + local compareActor = compareCalcsActor or (compareEntry.calcsTab.mainEnv and compareEntry.calcsTab.mainEnv.player) + + if not primaryActor and not compareActor then + return + end + + local compareLabel = compareEntry.label or "Compare Build" + + -- Tooltip header + tooltip:AddLine(16, "^7" .. (rowLabel or "")) + tooltip:AddSeparator(10) + + -- Process each sectionData entry in colData + for _, sectionData in ipairs(colData) do + -- Show breakdown formulas per build (these are always build-specific) + if sectionData.breakdown then + local primaryLines = M.GetBreakdownLines(sectionData, primaryBuild) + local compareLines = M.GetBreakdownLines(sectionData, compareEntry) + + if primaryLines then + tooltip:AddLine(14, colorCodes.POSITIVE .. primaryLabel .. ":") + for _, line in ipairs(primaryLines) do + tooltip:AddLine(14, "^7 " .. line) + end + end + if compareLines then + tooltip:AddLine(14, colorCodes.WARNING .. compareLabel .. ":") + for _, line in ipairs(compareLines) do + tooltip:AddLine(14, "^7 " .. line) + end + end + if primaryLines or compareLines then + tooltip:AddSeparator(10) + end + end + + -- Show modifier sources split into common / primary-only / compare-only + if sectionData.modName then + local pRows = primaryActor and M.TabulateMods(sectionData, primaryActor) or {} + local cRows = compareActor and M.TabulateMods(sectionData, compareActor) or {} + + if #pRows > 0 or #cRows > 0 then + -- Build lookup of compare rows by key + local cByKey = {} + for _, row in ipairs(cRows) do + local key = M.ModRowKey(row) + cByKey[key] = row + end + + -- Classify into common, primary-only, compare-only + local common = {} -- { { pRow, cRow }, ... } + local pOnly = {} + local cMatched = {} -- keys that were matched + + for _, pRow in ipairs(pRows) do + local key = M.ModRowKey(pRow) + if cByKey[key] then + t_insert(common, { pRow, cByKey[key] }) + cMatched[key] = true + else + t_insert(pOnly, pRow) + end + end + + local cOnly = {} + for _, cRow in ipairs(cRows) do + local key = M.ModRowKey(cRow) + if not cMatched[key] then + t_insert(cOnly, cRow) + end + end + + -- Sub-section header (e.g., "Sources", "Increased Life Regeneration Rate") + local sectionLabel = sectionData.label or "Player modifiers" + tooltip:AddLine(14, "^7" .. sectionLabel .. ":") + + -- Common modifiers + if #common > 0 then + -- Sort by primary value descending + table.sort(common, function(a, b) + if type(a[1].value) == "number" and type(b[1].value) == "number" then + return a[1].value > b[1].value + end + return false + end) + tooltip:AddLine(12, "^x808080 Common:") + for _, pair in ipairs(common) do + local pVal, sourceType, sourceName, modName = M.FormatModRow(pair[1], sectionData, primaryBuild) + local cVal = M.FormatModRow(pair[2], sectionData, compareEntry) + local valStr + if pVal == cVal then + valStr = s_format("^7%-10s", pVal) + else + valStr = colorCodes.POSITIVE .. s_format("%-5s", pVal) .. "^7/" .. colorCodes.WARNING .. s_format("%-5s", cVal) + end + local line = s_format(" %s ^7%-6s ^7%s%s", valStr, sourceType, sourceName, modName) + tooltip:AddLine(12, line) + end + end + + -- Primary-only modifiers + if #pOnly > 0 then + table.sort(pOnly, function(a, b) + if type(a.value) == "number" and type(b.value) == "number" then + return a.value > b.value + end + return false + end) + tooltip:AddLine(12, colorCodes.POSITIVE .. " " .. primaryLabel .. " only:") + for _, row in ipairs(pOnly) do + local displayValue, sourceType, sourceName, modName = M.FormatModRow(row, sectionData, primaryBuild) + local line = s_format(" ^7%-10s ^7%-6s ^7%s%s", displayValue, sourceType, sourceName, modName) + tooltip:AddLine(12, line) + end + end + + -- Compare-only modifiers + if #cOnly > 0 then + table.sort(cOnly, function(a, b) + if type(a.value) == "number" and type(b.value) == "number" then + return a.value > b.value + end + return false + end) + tooltip:AddLine(12, colorCodes.WARNING .. " " .. compareLabel .. " only:") + for _, row in ipairs(cOnly) do + local displayValue, sourceType, sourceName, modName = M.FormatModRow(row, sectionData, compareEntry) + local line = s_format(" ^7%-10s ^7%-6s ^7%s%s", displayValue, sourceType, sourceName, modName) + tooltip:AddLine(12, line) + end + end + + -- Separator between sub-sections + tooltip:AddSeparator(6) + end + end + end + end + + SetDrawLayer(nil, 100) + tooltip:Draw(rowX, rowY, rowW, rowH, vp) + SetDrawLayer(nil, 0) +end + +return M diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 7a81b749a8..86280c9056 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -12,6 +12,7 @@ local s_format = string.format local dkjson = require "dkjson" local tradeHelpers = LoadModule("Classes/CompareTradeHelpers") local buySimilar = LoadModule("Classes/CompareBuySimilar") +local calcsHelpers = LoadModule("Classes/CompareCalcsHelpers") -- Node IDs below this value are normal passive tree nodes; IDs at or above are cluster jewel nodes local CLUSTER_NODE_OFFSET = 65536 @@ -3745,316 +3746,14 @@ function CompareTabClass:DrawSkills(vp, compareEntry) end -- ============================================================ --- CALCS TOOLTIP HELPERS +-- CALCS TOOLTIP HELPERS (delegated to CompareCalcsHelpers) -- ============================================================ - --- Format a modifier value with its type for display -function CompareTabClass:FormatCalcModValue(value, modType) - if modType == "BASE" then - return s_format("%+g base", value) - elseif modType == "INC" then - if value >= 0 then - return value .. "% increased" - else - return (-value) .. "% reduced" - end - elseif modType == "MORE" then - if value >= 0 then - return value .. "% more" - else - return (-value) .. "% less" - end - elseif modType == "OVERRIDE" then - return "Override: " .. tostring(value) - elseif modType == "FLAG" then - return value and "True" or "False" - else - return tostring(value) - end -end - --- Format CamelCase mod name to spaced words -function CompareTabClass:FormatCalcModName(modName) - return modName:gsub("([%l%d]:?)(%u)", "%1 %2"):gsub("(%l)(%d)", "%1 %2") -end - --- Resolve a modifier's source to a human-readable name -function CompareTabClass:ResolveSourceName(mod, build) - if not mod.source then return "" end - local sourceType = mod.source:match("[^:]+") or "" - if sourceType == "Item" then - local itemId = mod.source:match("Item:(%d+):.+") - local item = build.itemsTab and build.itemsTab.items[tonumber(itemId)] - if item then - return colorCodes[item.rarity] .. item.name - end - elseif sourceType == "Tree" then - local nodeId = mod.source:match("Tree:(%d+)") - if nodeId then - local nodeIdNum = tonumber(nodeId) - local node = (build.spec and build.spec.nodes[nodeIdNum]) - or (build.spec and build.spec.tree and build.spec.tree.nodes[nodeIdNum]) - or (build.latestTree and build.latestTree.nodes[nodeIdNum]) - if node then - return node.dn or node.name or "" - end - end - elseif sourceType == "Skill" then - local skillId = mod.source:match("Skill:(.+)") - if skillId and build.data and build.data.skills[skillId] then - return build.data.skills[skillId].name - end - elseif sourceType == "Pantheon" then - return mod.source:match("Pantheon:(.+)") or "" - elseif sourceType == "Spectre" then - return mod.source:match("Spectre:(.+)") or "" - end - return "" -end - --- Get the modDB and config for a sectionData entry and actor -function CompareTabClass:GetModStoreAndCfg(sectionData, actor) - local cfg = {} - if sectionData.cfg and actor.mainSkill and actor.mainSkill[sectionData.cfg .. "Cfg"] then - cfg = copyTable(actor.mainSkill[sectionData.cfg .. "Cfg"], true) - end - cfg.source = sectionData.modSource - cfg.actor = sectionData.actor - - local modStore - if sectionData.enemy and actor.enemy then - modStore = actor.enemy.modDB - elseif sectionData.cfg and actor.mainSkill then - modStore = actor.mainSkill.skillModList - else - modStore = actor.modDB - end - return modStore, cfg -end - --- Tabulate modifiers for a sectionData entry and actor -function CompareTabClass:TabulateMods(sectionData, actor) - local modStore, cfg = self:GetModStoreAndCfg(sectionData, actor) - if not modStore then return {} end - - local rowList - if type(sectionData.modName) == "table" then - rowList = modStore:Tabulate(sectionData.modType, cfg, unpack(sectionData.modName)) - else - rowList = modStore:Tabulate(sectionData.modType, cfg, sectionData.modName) - end - return rowList or {} -end - --- Build a unique key for a modifier row to match between builds -function CompareTabClass:ModRowKey(row) - local src = row.mod.source or "" - local name = row.mod.name or "" - local mtype = row.mod.type or "" - -- Normalize Item sources by stripping the build-specific numeric ID - -- "Item:5:Body Armour" -> "Item:Body Armour" so same items match across builds - local normalizedSrc = src:gsub("^(Item):%d+:", "%1:") - return normalizedSrc .. "|" .. name .. "|" .. mtype -end - --- Format a single modifier row as a tooltip line -function CompareTabClass:FormatModRow(row, sectionData, build) - local displayValue - if not sectionData.modType then - displayValue = self:FormatCalcModValue(row.value, row.mod.type) - else - displayValue = formatRound(row.value, 2) - end - - local sourceType = row.mod.source and row.mod.source:match("[^:]+") or "?" - local sourceName = self:ResolveSourceName(row.mod, build) - local modName = "" - if type(sectionData.modName) == "table" then - modName = " " .. self:FormatCalcModName(row.mod.name) - end - - return displayValue, sourceType, sourceName, modName -end - --- Get breakdown text lines for a build's actor -function CompareTabClass:GetBreakdownLines(sectionData, build) - if not sectionData.breakdown then return nil end - local calcsActor = build.calcsTab and build.calcsTab.calcsEnv and build.calcsTab.calcsEnv.player - if not calcsActor or not calcsActor.breakdown then return nil end - - local breakdown - local ns, name = sectionData.breakdown:match("^(%a+)%.(%a+)$") - if ns then - breakdown = calcsActor.breakdown[ns] and calcsActor.breakdown[ns][name] - else - breakdown = calcsActor.breakdown[sectionData.breakdown] - end - - if not breakdown or #breakdown == 0 then return nil end - - local lines = {} - for _, line in ipairs(breakdown) do - if type(line) == "string" then - t_insert(lines, line) - end - end - return #lines > 0 and lines or nil -end - --- Draw the calcs hover tooltip showing breakdown for both builds with common/unique grouping function CompareTabClass:DrawCalcsTooltip(colData, rowLabel, rowX, rowY, rowW, rowH, vp, compareEntry) - local tooltip = self.calcsTooltip - if tooltip:CheckForUpdate(colData, rowLabel) then - -- Get calcsEnv actors (these have breakdown data populated) - local primaryCalcsActor = self.primaryBuild.calcsTab and self.primaryBuild.calcsTab.calcsEnv - and self.primaryBuild.calcsTab.calcsEnv.player - local compareCalcsActor = compareEntry.calcsTab and compareEntry.calcsTab.calcsEnv - and compareEntry.calcsTab.calcsEnv.player - - local primaryActor = primaryCalcsActor or (self.primaryBuild.calcsTab.mainEnv and self.primaryBuild.calcsTab.mainEnv.player) - local compareActor = compareCalcsActor or (compareEntry.calcsTab.mainEnv and compareEntry.calcsTab.mainEnv.player) - - if not primaryActor and not compareActor then - return - end - - local primaryLabel = self:GetShortBuildName(self.primaryBuild.buildName) - local compareLabel = compareEntry.label or "Compare Build" - - -- Tooltip header - tooltip:AddLine(16, "^7" .. (rowLabel or "")) - tooltip:AddSeparator(10) - - -- Process each sectionData entry in colData - for _, sectionData in ipairs(colData) do - -- Show breakdown formulas per build (these are always build-specific) - if sectionData.breakdown then - local primaryLines = self:GetBreakdownLines(sectionData, self.primaryBuild) - local compareLines = self:GetBreakdownLines(sectionData, compareEntry) - - if primaryLines then - tooltip:AddLine(14, colorCodes.POSITIVE .. primaryLabel .. ":") - for _, line in ipairs(primaryLines) do - tooltip:AddLine(14, "^7 " .. line) - end - end - if compareLines then - tooltip:AddLine(14, colorCodes.WARNING .. compareLabel .. ":") - for _, line in ipairs(compareLines) do - tooltip:AddLine(14, "^7 " .. line) - end - end - if primaryLines or compareLines then - tooltip:AddSeparator(10) - end - end - - -- Show modifier sources split into common / primary-only / compare-only - if sectionData.modName then - local pRows = primaryActor and self:TabulateMods(sectionData, primaryActor) or {} - local cRows = compareActor and self:TabulateMods(sectionData, compareActor) or {} - - if #pRows > 0 or #cRows > 0 then - -- Build lookup of compare rows by key - local cByKey = {} - for _, row in ipairs(cRows) do - local key = self:ModRowKey(row) - cByKey[key] = row - end - - -- Classify into common, primary-only, compare-only - local common = {} -- { { pRow, cRow }, ... } - local pOnly = {} - local cMatched = {} -- keys that were matched - - for _, pRow in ipairs(pRows) do - local key = self:ModRowKey(pRow) - if cByKey[key] then - t_insert(common, { pRow, cByKey[key] }) - cMatched[key] = true - else - t_insert(pOnly, pRow) - end - end - - local cOnly = {} - for _, cRow in ipairs(cRows) do - local key = self:ModRowKey(cRow) - if not cMatched[key] then - t_insert(cOnly, cRow) - end - end - - -- Sub-section header (e.g., "Sources", "Increased Life Regeneration Rate") - local sectionLabel = sectionData.label or "Player modifiers" - tooltip:AddLine(14, "^7" .. sectionLabel .. ":") - - -- Common modifiers - if #common > 0 then - -- Sort by primary value descending - table.sort(common, function(a, b) - if type(a[1].value) == "number" and type(b[1].value) == "number" then - return a[1].value > b[1].value - end - return false - end) - tooltip:AddLine(12, "^x808080 Common:") - for _, pair in ipairs(common) do - local pVal, sourceType, sourceName, modName = self:FormatModRow(pair[1], sectionData, self.primaryBuild) - local cVal = self:FormatModRow(pair[2], sectionData, compareEntry) - local valStr - if pVal == cVal then - valStr = s_format("^7%-10s", pVal) - else - valStr = colorCodes.POSITIVE .. s_format("%-5s", pVal) .. "^7/" .. colorCodes.WARNING .. s_format("%-5s", cVal) - end - local line = s_format(" %s ^7%-6s ^7%s%s", valStr, sourceType, sourceName, modName) - tooltip:AddLine(12, line) - end - end - - -- Primary-only modifiers - if #pOnly > 0 then - table.sort(pOnly, function(a, b) - if type(a.value) == "number" and type(b.value) == "number" then - return a.value > b.value - end - return false - end) - tooltip:AddLine(12, colorCodes.POSITIVE .. " " .. primaryLabel .. " only:") - for _, row in ipairs(pOnly) do - local displayValue, sourceType, sourceName, modName = self:FormatModRow(row, sectionData, self.primaryBuild) - local line = s_format(" ^7%-10s ^7%-6s ^7%s%s", displayValue, sourceType, sourceName, modName) - tooltip:AddLine(12, line) - end - end - - -- Compare-only modifiers - if #cOnly > 0 then - table.sort(cOnly, function(a, b) - if type(a.value) == "number" and type(b.value) == "number" then - return a.value > b.value - end - return false - end) - tooltip:AddLine(12, colorCodes.WARNING .. " " .. compareLabel .. " only:") - for _, row in ipairs(cOnly) do - local displayValue, sourceType, sourceName, modName = self:FormatModRow(row, sectionData, compareEntry) - local line = s_format(" ^7%-10s ^7%-6s ^7%s%s", displayValue, sourceType, sourceName, modName) - tooltip:AddLine(12, line) - end - end - - -- Separator between sub-sections - tooltip:AddSeparator(6) - end - end - end - end - - SetDrawLayer(nil, 100) - tooltip:Draw(rowX, rowY, rowW, rowH, vp) - SetDrawLayer(nil, 0) + local primaryLabel = self:GetShortBuildName(self.primaryBuild.buildName) + calcsHelpers.DrawCalcsTooltip( + self.calcsTooltip, self.primaryBuild, primaryLabel, + colData, rowLabel, rowX, rowY, rowW, rowH, vp, compareEntry + ) end -- ============================================================ From be418326fe683fe8fad0bc28d17861e7e925d3f2 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Sat, 4 Apr 2026 22:38:29 +0200 Subject: [PATCH 38/59] include notes from compared build --- src/Classes/CompareEntry.lua | 14 ++++++ src/Classes/CompareTab.lua | 88 ++++++++++++++++++++++++++++++++++-- 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/src/Classes/CompareEntry.lua b/src/Classes/CompareEntry.lua index 71d20de621..d0374f0c77 100644 --- a/src/Classes/CompareEntry.lua +++ b/src/Classes/CompareEntry.lua @@ -25,6 +25,7 @@ local CompareEntryClass = newClass("CompareEntry", "ControlHost", function(self, self.pantheonMinorGod = "None" self.characterLevelAutoMode = main.defaultCharLevel == 1 or main.defaultCharLevel == nil self.mainSocketGroup = 1 + self.notesText = "" self.spectreList = {} self.timelessData = { @@ -151,6 +152,19 @@ function CompareEntryClass:LoadFromXML(xmlText) end end + -- Extract notes from the build XML + for _, node in ipairs(self.xmlSectionList) do + if node.elem == "Notes" then + for _, child in ipairs(node) do + if type(child) == "string" then + self.notesText = child + break + end + end + break + end + end + if next(self.configTab.input) == nil then if self.configTab.ImportCalcSettings then self.configTab:ImportCalcSettings() diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 86280c9056..97fb0df126 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -129,6 +129,9 @@ local CompareTabClass = newClass("CompareTab", "ControlHost", "Control", functio self.configSectionLayout = {} -- computed section layout for drawing self.configTotalContentHeight = 0 + -- Notes view state + self.notesActiveEntry = nil + -- Compare power report state self.comparePowerStat = nil -- selected data.powerStatList entry self.comparePowerCategories = { treeNodes = true, items = true, skillGems = true, supportGems = true, config = true } @@ -149,8 +152,8 @@ end) function CompareTabClass:InitControls() -- Sub-tab buttons - local subTabs = { "Summary", "Tree", "Skills", "Items", "Calcs", "Config" } - local subTabModes = { "SUMMARY", "TREE", "SKILLS", "ITEMS", "CALCS", "CONFIG" } + local subTabs = { "Summary", "Tree", "Skills", "Items", "Calcs", "Config", "Notes" } + local subTabModes = { "SUMMARY", "TREE", "SKILLS", "ITEMS", "CALCS", "CONFIG", "NOTES" } self.controls.subTabAnchor = new("Control", nil, {0, 0, 0, 20}) for i, tabName in ipairs(subTabs) do @@ -179,12 +182,23 @@ function CompareTabClass:InitControls() end end + -- Notes sub-tab controls + self.controls.notesDesc = new("LabelControl", nil, {0, 0, 0, 16}, "^7These are the notes from the compared build. Any edits here are saved with this comparison and do not affect the main build's notes.") + self.controls.notesDesc.shown = function() + return self.compareViewMode == "NOTES" and #self.compareEntries > 0 + end + self.controls.notesEdit = new("EditControl", nil, {0, 0, 0, 0}, "", nil, "^%C\t\n", nil, nil, 16, true) + self.controls.notesEdit.shown = function() + return self.compareViewMode == "NOTES" and #self.compareEntries > 0 + end + -- Build B selector dropdown self.controls.compareBuildLabel = new("LabelControl", {"TOPLEFT", self.controls.subTabAnchor, "TOPLEFT"}, {0, -70, 0, 16}, "^7Compare with:") self.controls.compareBuildSelect = new("DropDownControl", {"LEFT", self.controls.compareBuildLabel, "RIGHT"}, {4, 0, 250, 20}, {}, function(index, value) if index and index > 0 and index <= #self.compareEntries then self.activeCompareIndex = index self.treeSearchNeedsSync = true + self.notesActiveEntry = nil end end) self.controls.compareBuildSelect.enabled = function() @@ -1041,6 +1055,10 @@ function CompareTabClass:Save(xml) xml.attrib = { activeCompareIndex = tostring(self.activeCompareIndex), } + -- Sync current notes edit buffer to the active entry before saving + if self.notesActiveEntry then + self.notesActiveEntry.notesText = self.controls.notesEdit.buf + end for _, entry in ipairs(self.compareEntries) do local attrib = { label = entry.label, @@ -1058,10 +1076,14 @@ function CompareTabClass:Save(xml) if entry.configTab then attrib.activeConfigSetId = tostring(entry.configTab.activeConfigSetId) end - t_insert(xml, { + local entryNode = { elem = "CompareEntry", attrib = attrib, - }) + } + if entry.notesText and entry.notesText ~= "" then + t_insert(entryNode, { elem = "Notes", attrib = {}, entry.notesText }) + end + t_insert(xml, entryNode) end end @@ -1092,6 +1114,18 @@ function CompareTabClass:Load(xml, dbFileName) if savedConfigSet and entry.configTab and entry.configTab.configSets[savedConfigSet] then entry.configTab:SetActiveConfigSet(savedConfigSet) end + -- Restore edited notes (overrides notes from original build XML) + for _, grandchild in ipairs(child) do + if type(grandchild) == "table" and grandchild.elem == "Notes" then + for _, text in ipairs(grandchild) do + if type(text) == "string" then + entry.notesText = text + break + end + end + break + end + end end end end @@ -1110,6 +1144,7 @@ function CompareTabClass:RemoveBuild(index) if index >= 1 and index <= #self.compareEntries then t_remove(self.compareEntries, index) self.modFlag = true + self.notesActiveEntry = nil if self.activeCompareIndex > #self.compareEntries then self.activeCompareIndex = #self.compareEntries end @@ -1500,6 +1535,8 @@ function CompareTabClass:Draw(viewPort, inputEvents) self:DrawCalcs(contentVP, compareEntry) elseif self.compareViewMode == "CONFIG" then self:DrawConfig(contentVP, compareEntry) + elseif self.compareViewMode == "NOTES" then + self:DrawNotes(contentVP, compareEntry, inputEvents) end end @@ -4077,4 +4114,47 @@ function CompareTabClass:DrawConfig(vp, compareEntry) SetViewport() end +function CompareTabClass:DrawNotes(vp, compareEntry, inputEvents) + if not compareEntry then return end + + -- Sync EditControl with the active compare entry + if self.notesActiveEntry ~= compareEntry then + self.controls.notesEdit:SetText(compareEntry.notesText or "") + self.notesActiveEntry = compareEntry + end + + -- Handle undo/redo + for id, event in ipairs(inputEvents) do + if event.type == "KeyDown" then + if event.key == "z" and IsKeyDown("CTRL") then + self.controls.notesEdit:Undo() + elseif event.key == "y" and IsKeyDown("CTRL") then + self.controls.notesEdit:Redo() + end + end + end + + -- Position label and edit control + self.controls.notesDesc.x = vp.x + 8 + self.controls.notesDesc.y = vp.y + 8 + + local editY = vp.y + 30 + self.controls.notesEdit.x = vp.x + 8 + self.controls.notesEdit.y = editY + self.controls.notesEdit.width = function() + return vp.width - 16 + end + self.controls.notesEdit.height = function() + return vp.height - 38 + end + + -- Sync edits back to the compare entry + if compareEntry.notesText ~= self.controls.notesEdit.buf then + compareEntry.notesText = self.controls.notesEdit.buf + self.modFlag = true + end + + main:DrawBackground(vp) +end + return CompareTabClass From e0cbe0f10a6aece7f4c2e42c62e2ae1545217f58 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Sun, 5 Apr 2026 01:47:49 +0200 Subject: [PATCH 39/59] add local helpers to remove duplicate blocks --- src/Classes/CompareTab.lua | 297 ++++++++++--------------------------- 1 file changed, 82 insertions(+), 215 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 97fb0df126..e0bc7f87ae 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -833,6 +833,27 @@ function CompareTabClass:FormatStr(str, actor, colData) return str end +-- Populate a set-selector dropdown from a tab's ordered set list. +-- tab: the tab object (e.g. itemsTab, skillsTab, configTab) +-- orderListField/setsField/activeIdField: string keys on tab +-- control: the DropDownControl to populate +function CompareTabClass:PopulateSetDropdown(tab, orderListField, setsField, activeIdField, control) + local list = {} + local orderList = tab[orderListField] + local sets = tab[setsField] + local activeId = tab[activeIdField] + if orderList then + for index, setId in ipairs(orderList) do + local set = sets[setId] + t_insert(list, (set and set.title) or "Default") + if setId == activeId then + control.selIndex = index + end + end + end + control:SetList(list) +end + -- Check visibility flags for a section/row against an actor function CompareTabClass:CheckCalcFlag(obj, actor) if not actor or not actor.mainSkill then return true end @@ -1498,28 +1519,12 @@ function CompareTabClass:Draw(viewPort, inputEvents) -- Populate primary build item set list if self.primaryBuild.itemsTab and self.primaryBuild.itemsTab.itemSetOrderList then - local itemList = {} - for index, itemSetId in ipairs(self.primaryBuild.itemsTab.itemSetOrderList) do - local itemSet = self.primaryBuild.itemsTab.itemSets[itemSetId] - t_insert(itemList, itemSet.title or "Default") - if itemSetId == self.primaryBuild.itemsTab.activeItemSetId then - self.controls.primaryItemSetSelect.selIndex = index - end - end - self.controls.primaryItemSetSelect:SetList(itemList) + self:PopulateSetDropdown(self.primaryBuild.itemsTab, "itemSetOrderList", "itemSets", "activeItemSetId", self.controls.primaryItemSetSelect) end -- Populate compare build item set list if compareEntry and compareEntry.itemsTab and compareEntry.itemsTab.itemSetOrderList then - local itemList = {} - for index, itemSetId in ipairs(compareEntry.itemsTab.itemSetOrderList) do - local itemSet = compareEntry.itemsTab.itemSets[itemSetId] - t_insert(itemList, itemSet.title or "Default") - if itemSetId == compareEntry.itemsTab.activeItemSetId then - self.controls.compareItemSetSelect2.selIndex = index - end - end - self.controls.compareItemSetSelect2:SetList(itemList) + self:PopulateSetDropdown(compareEntry.itemsTab, "itemSetOrderList", "itemSets", "activeItemSetId", self.controls.compareItemSetSelect2) end end @@ -1872,39 +1877,15 @@ function CompareTabClass:UpdateSetSelectors(compareEntry) end -- Skill set list if compareEntry.skillsTab then - local skillList = {} - for index, skillSetId in ipairs(compareEntry.skillsTab.skillSetOrderList) do - local skillSet = compareEntry.skillsTab.skillSets[skillSetId] - t_insert(skillList, skillSet.title or "Default") - if skillSetId == compareEntry.skillsTab.activeSkillSetId then - self.controls.compareSkillSetSelect.selIndex = index - end - end - self.controls.compareSkillSetSelect:SetList(skillList) + self:PopulateSetDropdown(compareEntry.skillsTab, "skillSetOrderList", "skillSets", "activeSkillSetId", self.controls.compareSkillSetSelect) end -- Item set list if compareEntry.itemsTab then - local itemList = {} - for index, itemSetId in ipairs(compareEntry.itemsTab.itemSetOrderList) do - local itemSet = compareEntry.itemsTab.itemSets[itemSetId] - t_insert(itemList, itemSet.title or "Default") - if itemSetId == compareEntry.itemsTab.activeItemSetId then - self.controls.compareItemSetSelect.selIndex = index - end - end - self.controls.compareItemSetSelect:SetList(itemList) + self:PopulateSetDropdown(compareEntry.itemsTab, "itemSetOrderList", "itemSets", "activeItemSetId", self.controls.compareItemSetSelect) end -- Config set list if compareEntry.configTab then - local configList = {} - for index, configSetId in ipairs(compareEntry.configTab.configSetOrderList) do - local configSet = compareEntry.configTab.configSets[configSetId] - t_insert(configList, configSet and configSet.title or "Default") - if configSetId == compareEntry.configTab.activeConfigSetId then - self.controls.compareConfigSetSelect.selIndex = index - end - end - self.controls.compareConfigSetSelect:SetList(configList) + self:PopulateSetDropdown(compareEntry.configTab, "configSetOrderList", "configSets", "activeConfigSetId", self.controls.compareConfigSetSelect) end -- Refresh comparison build skill selector controls @@ -3145,79 +3126,68 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) end maxLabelW = maxLabelW + 2 - for _, slotName in ipairs(baseSlots) do - -- Separator - SetDrawColor(0.3, 0.3, 0.3) - DrawImage(nil, 4, drawY, vp.width - 8, 1) - drawY = drawY + 2 - - -- Get items from both builds - local pSlot = self.primaryBuild.itemsTab and self.primaryBuild.itemsTab.slots and self.primaryBuild.itemsTab.slots[slotName] - local cSlot = compareEntry.itemsTab and compareEntry.itemsTab.slots and compareEntry.itemsTab.slots[slotName] - local pItem = pSlot and self.primaryBuild.itemsTab.items and self.primaryBuild.itemsTab.items[pSlot.selItemId] - local cItem = cSlot and compareEntry.itemsTab and compareEntry.itemsTab.items and compareEntry.itemsTab.items[cSlot.selItemId] + -- Helper: process copy/buy button hover state and click events for a slot. + -- Closes over hoverCopyUse*/clicked* locals above. + local function processSlotButtons(b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H, cItem, copySlotName, copyUseSlotName) + if b2Hover and cItem then + hoverCopyUseItem = cItem + hoverCopyUseSlotName = copyUseSlotName + hoverCopyUseBtnX, hoverCopyUseBtnY = b2X, b2Y + hoverCopyUseBtnW, hoverCopyUseBtnH = b2W, b2H + end + if cItem and inputEvents then + for id, event in ipairs(inputEvents) do + if event.type == "KeyUp" and event.key == "LEFTBUTTON" then + if b1Hover then + clickedCopySlot = copySlotName + inputEvents[id] = nil + elseif b2Hover then + clickedCopyUseSlot = copyUseSlotName + inputEvents[id] = nil + elseif b3Hover then + clickedBuySlot = copyUseSlotName + clickedBuyItem = cItem + inputEvents[id] = nil + end + end + end + end + end + -- Helper: draw a single slot entry (expanded or compact mode). + -- Closes over drawY, colWidth, cursorX/Y, vp, self, compareEntry, hoverItem/hoverX/Y/W/H/hoverItemsTab. + local function drawSlotEntry(label, pItem, cItem, copySlotName, copyUseSlotName, labelW, pWarn, cWarn, slotMissing) if self.itemsExpandedMode then -- === EXPANDED MODE === - -- Slot label + diff indicator SetDrawColor(1, 1, 1) - DrawString(10, drawY, "LEFT", 16, "VAR", "^7" .. slotName .. ":") + DrawString(10, drawY, "LEFT", 16, "VAR", "^7" .. label .. ":" .. (pWarn or "")) DrawString(colWidth - 10, drawY, "RIGHT", 14, "VAR", tradeHelpers.getSlotDiffLabel(pItem, cItem)) - -- Copy/Buy buttons for compare item if cItem then - local slotMissing = slotName == "Ring 3" and not primaryHasRing3 local b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H = tradeHelpers.drawCopyButtons(cursorX, cursorY, vp.width - 196, drawY + 1, slotMissing, LAYOUT.itemsCopyBtnW, LAYOUT.itemsCopyBtnH, LAYOUT.itemsBuyBtnW) - if b2Hover then - hoverCopyUseItem = cItem - hoverCopyUseSlotName = slotName - hoverCopyUseBtnX, hoverCopyUseBtnY = b2X, b2Y - hoverCopyUseBtnW, hoverCopyUseBtnH = b2W, b2H - end - if inputEvents then - for id, event in ipairs(inputEvents) do - if event.type == "KeyUp" and event.key == "LEFTBUTTON" then - if b1Hover then - clickedCopySlot = slotName - inputEvents[id] = nil - elseif b2Hover then - clickedCopyUseSlot = slotName - inputEvents[id] = nil - elseif b3Hover then - clickedBuySlot = slotName - clickedBuyItem = cItem - inputEvents[id] = nil - end - end - end - end + processSlotButtons(b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H, cItem, copySlotName, copyUseSlotName) end drawY = drawY + 20 - -- Build mod maps for diff highlighting local pModMap = tradeHelpers.buildModMap(pItem) local cModMap = tradeHelpers.buildModMap(cItem) - - -- Draw both items expanded side by side local itemStartY = drawY local leftHeight = self:DrawItemExpanded(pItem, 20, drawY, colWidth - 30, cModMap) local rightHeight = self:DrawItemExpanded(cItem, colWidth + 20, drawY, colWidth - 30, pModMap) - -- Vertical separator between columns SetDrawColor(0.25, 0.25, 0.25) local maxH = m_max(leftHeight, rightHeight) DrawImage(nil, colWidth, itemStartY, 1, maxH) drawY = drawY + maxH + 6 else - -- === COMPACT MODE (single-line with bordered boxes) === + -- === COMPACT MODE === local pHover, cHover, b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H, rowHoverItem, rowHoverItemsTab, rowHoverX, rowHoverY, rowHoverW, rowHoverH = - tradeHelpers.drawCompactSlotRow(drawY, slotName, pItem, cItem, - colWidth, cursorX, cursorY, maxLabelW, - self.primaryBuild.itemsTab, compareEntry.itemsTab, nil, nil, - slotName == "Ring 3" and not primaryHasRing3, + tradeHelpers.drawCompactSlotRow(drawY, label, pItem, cItem, + colWidth, cursorX, cursorY, labelW, + self.primaryBuild.itemsTab, compareEntry.itemsTab, pWarn, cWarn, slotMissing, LAYOUT.itemsCopyBtnW, LAYOUT.itemsCopyBtnH, LAYOUT.itemsBuyBtnW) if rowHoverItem then @@ -3227,35 +3197,28 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) hoverW, hoverH = rowHoverW, rowHoverH end - if b2Hover and cItem then - hoverCopyUseItem = cItem - hoverCopyUseSlotName = slotName - hoverCopyUseBtnX, hoverCopyUseBtnY = b2X, b2Y - hoverCopyUseBtnW, hoverCopyUseBtnH = b2W, b2H - end - - if cItem and inputEvents then - for id, event in ipairs(inputEvents) do - if event.type == "KeyUp" and event.key == "LEFTBUTTON" then - if b1Hover then - clickedCopySlot = slotName - inputEvents[id] = nil - elseif b2Hover then - clickedCopyUseSlot = slotName - inputEvents[id] = nil - elseif b3Hover then - clickedBuySlot = slotName - clickedBuyItem = cItem - inputEvents[id] = nil - end - end - end - end + processSlotButtons(b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H, cItem, copySlotName, copyUseSlotName) drawY = drawY + 20 end end + for _, slotName in ipairs(baseSlots) do + -- Separator + SetDrawColor(0.3, 0.3, 0.3) + DrawImage(nil, 4, drawY, vp.width - 8, 1) + drawY = drawY + 2 + + -- Get items from both builds + local pSlot = self.primaryBuild.itemsTab and self.primaryBuild.itemsTab.slots and self.primaryBuild.itemsTab.slots[slotName] + local cSlot = compareEntry.itemsTab and compareEntry.itemsTab.slots and compareEntry.itemsTab.slots[slotName] + local pItem = pSlot and self.primaryBuild.itemsTab.items and self.primaryBuild.itemsTab.items[pSlot.selItemId] + local cItem = cSlot and compareEntry.itemsTab and compareEntry.itemsTab.items and compareEntry.itemsTab.items[cSlot.selItemId] + + local slotMissing = slotName == "Ring 3" and not primaryHasRing3 + drawSlotEntry(slotName, pItem, cItem, slotName, slotName, maxLabelW, nil, nil, slotMissing) + end + -- === TREE SET DROPDOWNS === drawY = drawY + 12 SetDrawColor(0.5, 0.5, 0.5) @@ -3304,9 +3267,6 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) end for jIdx, jEntry in ipairs(jewelSlots) do - local pItem = jEntry.pItem - local cItem = jEntry.cItem - -- Separator (skip before first jewel since section header already has one) if jIdx > 1 then SetDrawColor(0.3, 0.3, 0.3) @@ -3315,103 +3275,10 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) end -- Tree allocation warning text - local pWarn = (pItem and not jEntry.pNodeAllocated) and colorCodes.WARNING .. " (tree missing allocated node)" or "" - local cWarn = (cItem and not jEntry.cNodeAllocated) and colorCodes.WARNING .. " (tree missing allocated node)" or "" - - if self.itemsExpandedMode then - -- === EXPANDED MODE === - SetDrawColor(1, 1, 1) - DrawString(10, drawY, "LEFT", 16, "VAR", "^7" .. jEntry.label .. ":" .. pWarn) - DrawString(colWidth - 10, drawY, "RIGHT", 14, "VAR", tradeHelpers.getSlotDiffLabel(pItem, cItem)) - - -- Copy/Buy buttons for compare jewel - if cItem then - local b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H = tradeHelpers.drawCopyButtons(cursorX, cursorY, vp.width - 196, drawY + 1, nil, LAYOUT.itemsCopyBtnW, LAYOUT.itemsCopyBtnH, LAYOUT.itemsBuyBtnW) - if b2Hover then - hoverCopyUseItem = cItem - hoverCopyUseSlotName = jEntry.pSlotName - hoverCopyUseBtnX, hoverCopyUseBtnY = b2X, b2Y - hoverCopyUseBtnW, hoverCopyUseBtnH = b2W, b2H - end - if inputEvents then - for id, event in ipairs(inputEvents) do - if event.type == "KeyUp" and event.key == "LEFTBUTTON" then - if b1Hover then - clickedCopySlot = jEntry.cSlotName - inputEvents[id] = nil - elseif b2Hover then - clickedCopyUseSlot = jEntry.pSlotName - inputEvents[id] = nil - elseif b3Hover then - clickedBuySlot = jEntry.pSlotName - clickedBuyItem = cItem - inputEvents[id] = nil - end - end - end - end - end - - drawY = drawY + 20 - - -- Build mod maps for diff highlighting - local pModMap = tradeHelpers.buildModMap(pItem) - local cModMap = tradeHelpers.buildModMap(cItem) - - -- Draw both items expanded side by side - local itemStartY = drawY - local leftHeight = self:DrawItemExpanded(pItem, 20, drawY, colWidth - 30, cModMap) - local rightHeight = self:DrawItemExpanded(cItem, colWidth + 20, drawY, colWidth - 30, pModMap) - - -- Vertical separator between columns - SetDrawColor(0.25, 0.25, 0.25) - local maxH = m_max(leftHeight, rightHeight) - DrawImage(nil, colWidth, itemStartY, 1, maxH) - - drawY = drawY + maxH + 6 - else - -- === COMPACT MODE (single-line with bordered boxes) === - local pHover, cHover, b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H, - rowHoverItem, rowHoverItemsTab, rowHoverX, rowHoverY, rowHoverW, rowHoverH = - tradeHelpers.drawCompactSlotRow(drawY, jEntry.label, pItem, cItem, - colWidth, cursorX, cursorY, maxJewelLabelW, - self.primaryBuild.itemsTab, compareEntry.itemsTab, pWarn, cWarn, nil, - LAYOUT.itemsCopyBtnW, LAYOUT.itemsCopyBtnH, LAYOUT.itemsBuyBtnW) - - if rowHoverItem then - hoverItem = rowHoverItem - hoverItemsTab = rowHoverItemsTab - hoverX, hoverY = rowHoverX, rowHoverY - hoverW, hoverH = rowHoverW, rowHoverH - end - - if b2Hover and cItem then - hoverCopyUseItem = cItem - hoverCopyUseSlotName = jEntry.pSlotName - hoverCopyUseBtnX, hoverCopyUseBtnY = b2X, b2Y - hoverCopyUseBtnW, hoverCopyUseBtnH = b2W, b2H - end - - if cItem and inputEvents then - for id, event in ipairs(inputEvents) do - if event.type == "KeyUp" and event.key == "LEFTBUTTON" then - if b1Hover then - clickedCopySlot = jEntry.cSlotName - inputEvents[id] = nil - elseif b2Hover then - clickedCopyUseSlot = jEntry.pSlotName - inputEvents[id] = nil - elseif b3Hover then - clickedBuySlot = jEntry.pSlotName - clickedBuyItem = cItem - inputEvents[id] = nil - end - end - end - end + local pWarn = (jEntry.pItem and not jEntry.pNodeAllocated) and colorCodes.WARNING .. " (tree missing allocated node)" or "" + local cWarn = (jEntry.cItem and not jEntry.cNodeAllocated) and colorCodes.WARNING .. " (tree missing allocated node)" or "" - drawY = drawY + 20 - end + drawSlotEntry(jEntry.label, jEntry.pItem, jEntry.cItem, jEntry.cSlotName, jEntry.pSlotName, maxJewelLabelW, pWarn, cWarn, nil) end end From c9824afc304e9a5ffbf98bde7b77d0ba3fbaacc7 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Mon, 6 Apr 2026 20:52:10 +0200 Subject: [PATCH 40/59] add skill and calc controls to calcs tab --- src/Classes/CompareCalcsHelpers.lua | 147 +++++++ src/Classes/CompareEntry.lua | 23 +- src/Classes/CompareTab.lua | 572 +++++++++++++++++++++++++++- 3 files changed, 724 insertions(+), 18 deletions(-) diff --git a/src/Classes/CompareCalcsHelpers.lua b/src/Classes/CompareCalcsHelpers.lua index a31221b453..455c7b3a23 100644 --- a/src/Classes/CompareCalcsHelpers.lua +++ b/src/Classes/CompareCalcsHelpers.lua @@ -317,4 +317,151 @@ function M.DrawCalcsTooltip(tooltip, primaryBuild, primaryLabel, colData, rowLab SetDrawLayer(nil, 0) end +-- Resolve a modifier's source name for breakdown panel display +local function resolveModSource(mod, build) + local sourceType = mod.source and mod.source:match("[^:]+") or "?" + local sourceName = "" + if sourceType == "Item" then + local itemId = mod.source:match("Item:(%d+):.+") + local item = build.itemsTab and build.itemsTab.items[tonumber(itemId)] + if item then + sourceName = colorCodes[item.rarity] .. item.name + end + elseif sourceType == "Tree" then + local nodeId = mod.source:match("Tree:(%d+)") + if nodeId then + local nodeIdNum = tonumber(nodeId) + local node = (build.spec and build.spec.nodes[nodeIdNum]) + or (build.spec and build.spec.tree and build.spec.tree.nodes[nodeIdNum]) + or (build.latestTree and build.latestTree.nodes[nodeIdNum]) + if node then + sourceName = node.dn or node.name or "" + end + end + elseif sourceType == "Skill" then + local skillId = mod.source:match("Skill:(.+)") + if skillId and build.data and build.data.skills[skillId] then + sourceName = build.data.skills[skillId].name + end + elseif sourceType == "Pantheon" then + sourceName = mod.source:match("Pantheon:(.+)") or "" + elseif sourceType == "Spectre" then + sourceName = mod.source:match("Spectre:(.+)") or "" + end + return sourceType, sourceName +end + +-- Draw a breakdown panel for a single build's SkillBuffs or SkillDebuffs, +function M.DrawSkillBreakdownPanel(build, breakdownKey, label, cellX, cellY, cellW, cellH, vp) + local player = build.calcsTab and build.calcsTab.calcsEnv + and build.calcsTab.calcsEnv.player + if not player or not player.breakdown then return end + + local breakdown = player.breakdown[breakdownKey] + if not breakdown or not breakdown.modList or #breakdown.modList == 0 then return end + + local modList = breakdown.modList + + -- Sort by mod name then value + local rowList = {} + for _, entry in ipairs(modList) do + t_insert(rowList, entry) + end + table.sort(rowList, function(a, b) + return a.mod.name > b.mod.name or (a.mod.name == b.mod.name + and type(a.value) == "number" and type(b.value) == "number" + and a.value > b.value) + end) + + -- Process rows: compute display strings and measure column widths + local colDefs = { + { label = "Value", key = "displayValue" }, + { label = "Stat", key = "name" }, + { label = "Source", key = "source" }, + { label = "Source Name", key = "sourceName" }, + } + + local rows = {} + for _, entry in ipairs(rowList) do + local mod = entry.mod + local row = {} + row.displayValue = M.FormatCalcModValue(entry.value, mod.type) + row.name = M.FormatCalcModName(mod.name or "") + local sourceType, sourceName = resolveModSource(mod, build) + row.source = sourceType + row.sourceName = sourceName + t_insert(rows, row) + end + + -- Measure column widths + for _, col in ipairs(colDefs) do + col.width = DrawStringWidth(16, "VAR", col.label) + 6 + for _, row in ipairs(rows) do + if row[col.key] then + col.width = math.max(col.width, DrawStringWidth(12, "VAR", row[col.key]) + 6) + end + end + end + + -- Calculate panel size + local panelPadding = 4 + local headerRowH = 20 + local dataRowH = 14 + local panelW = panelPadding + for _, col in ipairs(colDefs) do + panelW = panelW + col.width + end + local panelH = headerRowH + #rows * dataRowH + 4 + + -- Position panel next to the hovered cell (right side, or left if no room) + local panelX = cellX + cellW + 5 + if panelX + panelW > vp.x + vp.width then + panelX = math.max(vp.x, cellX - 5 - panelW) + end + local panelY = math.min(cellY, vp.y + vp.height - panelH) + + -- Draw background + SetDrawLayer(nil, 10) + SetDrawColor(0, 0, 0, 0.9) + DrawImage(nil, panelX + 2, panelY + 2, panelW - 4, panelH - 4) + + -- Draw border + SetDrawLayer(nil, 11) + SetDrawColor(0.33, 0.66, 0.33) + DrawImage(nil, panelX, panelY, panelW, 2) + DrawImage(nil, panelX, panelY + panelH - 2, panelW, 2) + DrawImage(nil, panelX, panelY, 2, panelH) + DrawImage(nil, panelX + panelW - 2, panelY, 2, panelH) + SetDrawLayer(nil, 10) + + -- Draw column headers and separators + local colX = panelX + panelPadding + for i, col in ipairs(colDefs) do + col.x = colX + if i > 1 then + SetDrawColor(0.5, 0.5, 0.5) + DrawImage(nil, colX - 2, panelY + 2, 1, panelH - 4) + end + SetDrawColor(1, 1, 1) + DrawString(colX, panelY + 2, "LEFT", 16, "VAR", col.label) + colX = colX + col.width + end + + -- Draw rows + local rowY = panelY + headerRowH + for _, row in ipairs(rows) do + -- Row separator + SetDrawColor(0.5, 0.5, 0.5) + DrawImage(nil, panelX + 2, rowY - 1, panelW - 4, 1) + for _, col in ipairs(colDefs) do + if row[col.key] and row[col.key] ~= "" then + DrawString(col.x, rowY + 1, "LEFT", 12, "VAR", "^7" .. row[col.key]) + end + end + rowY = rowY + dataRowH + end + + SetDrawLayer(nil, 0) +end + return M diff --git a/src/Classes/CompareEntry.lua b/src/Classes/CompareEntry.lua index d0374f0c77..f0a74b9f4f 100644 --- a/src/Classes/CompareEntry.lua +++ b/src/Classes/CompareEntry.lua @@ -171,7 +171,7 @@ function CompareEntryClass:LoadFromXML(xmlText) end end - -- Build calculation output tables + self:SyncCalcsSkillSelection() self.calcsTab:BuildOutput() self.buildFlag = false end @@ -217,6 +217,27 @@ function CompareEntryClass:GetSpec() return self.spec end +function CompareEntryClass:SyncCalcsSkillSelection() + self.calcsTab.input.skill_number = self.mainSocketGroup + + local mainGroup = self.skillsTab and self.skillsTab.socketGroupList[self.mainSocketGroup] + if not mainGroup then return end + + mainGroup.mainActiveSkillCalcs = mainGroup.mainActiveSkill + + local displaySkillList = mainGroup.displaySkillList + local activeSkill = displaySkillList and displaySkillList[mainGroup.mainActiveSkill or 1] + if activeSkill and activeSkill.activeEffect and activeSkill.activeEffect.srcInstance then + local src = activeSkill.activeEffect.srcInstance + src.skillPartCalcs = src.skillPart + src.skillStageCountCalcs = src.skillStageCount + src.skillMineCountCalcs = src.skillMineCount + src.skillMinionCalcs = src.skillMinion + src.skillMinionItemSetCalcs = src.skillMinionItemSet + src.skillMinionSkillCalcs = src.skillMinionSkill + end +end + function CompareEntryClass:Rebuild() wipeGlobalCache() self.outputRevision = self.outputRevision + 1 diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index e0bc7f87ae..4253401b66 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -449,6 +449,239 @@ function CompareTabClass:InitControls() end) self.controls.cmpMinionSkill.shown = false + -- ============================================================ + -- Calcs view skill detail controls (per-build, independent of sidebar & regular Calcs tab) + -- ============================================================ + local calcsBuffModeDropList = { + { label = "Unbuffed", buffMode = "UNBUFFED" }, + { label = "Buffed", buffMode = "BUFFED" }, + { label = "In Combat", buffMode = "COMBAT" }, + { label = "Effective DPS", buffMode = "EFFECTIVE" }, + } + -- Primary build calcs skill controls + self.controls.primCalcsSocketGroup = new("DropDownControl", nil, {0, 0, 200, 18}, {}, function(index, value) + self.primaryBuild.calcsTab.input.skill_number = index + self.primaryBuild.buildFlag = true + end) + self.controls.primCalcsSocketGroup.shown = false + self.controls.primCalcsSocketGroup.maxDroppedWidth = 400 + self.controls.primCalcsSocketGroup.enableDroppedWidth = true + + self.controls.primCalcsMainSkill = new("DropDownControl", nil, {0, 0, 200, 18}, {}, function(index, value) + local mainSocketGroup = self.primaryBuild.skillsTab.socketGroupList[self.primaryBuild.calcsTab.input.skill_number] + if mainSocketGroup then + mainSocketGroup.mainActiveSkillCalcs = index + self.primaryBuild.buildFlag = true + end + end) + self.controls.primCalcsMainSkill.shown = false + + self.controls.primCalcsSkillPart = new("DropDownControl", nil, {0, 0, 150, 18}, {}, function(index, value) + local mainSocketGroup = self.primaryBuild.skillsTab.socketGroupList[self.primaryBuild.calcsTab.input.skill_number] + if mainSocketGroup then + local displaySkillList = mainSocketGroup.displaySkillListCalcs + local activeSkill = displaySkillList and displaySkillList[mainSocketGroup.mainActiveSkillCalcs or 1] + if activeSkill and activeSkill.activeEffect then + activeSkill.activeEffect.srcInstance.skillPartCalcs = index + self.primaryBuild.buildFlag = true + end + end + end) + self.controls.primCalcsSkillPart.shown = false + + self.controls.primCalcsStageCount = new("EditControl", nil, {0, 0, 52, 18}, "", nil, "%D", 5, function(buf) + local mainSocketGroup = self.primaryBuild.skillsTab.socketGroupList[self.primaryBuild.calcsTab.input.skill_number] + if mainSocketGroup then + local displaySkillList = mainSocketGroup.displaySkillListCalcs + local activeSkill = displaySkillList and displaySkillList[mainSocketGroup.mainActiveSkillCalcs or 1] + if activeSkill and activeSkill.activeEffect then + activeSkill.activeEffect.srcInstance.skillStageCountCalcs = tonumber(buf) + self.primaryBuild.buildFlag = true + end + end + end) + self.controls.primCalcsStageCount.shown = false + + self.controls.primCalcsMineCount = new("EditControl", nil, {0, 0, 52, 18}, "", nil, "%D", 5, function(buf) + local mainSocketGroup = self.primaryBuild.skillsTab.socketGroupList[self.primaryBuild.calcsTab.input.skill_number] + if mainSocketGroup then + local displaySkillList = mainSocketGroup.displaySkillListCalcs + local activeSkill = displaySkillList and displaySkillList[mainSocketGroup.mainActiveSkillCalcs or 1] + if activeSkill and activeSkill.activeEffect then + activeSkill.activeEffect.srcInstance.skillMineCountCalcs = tonumber(buf) + self.primaryBuild.buildFlag = true + end + end + end) + self.controls.primCalcsMineCount.shown = false + + self.controls.primCalcsMinion = new("DropDownControl", nil, {0, 0, 140, 18}, {}, function(index, value) + local mainSocketGroup = self.primaryBuild.skillsTab.socketGroupList[self.primaryBuild.calcsTab.input.skill_number] + if mainSocketGroup then + local displaySkillList = mainSocketGroup.displaySkillListCalcs + local activeSkill = displaySkillList and displaySkillList[mainSocketGroup.mainActiveSkillCalcs or 1] + if activeSkill and activeSkill.activeEffect then + local selected = self.controls.primCalcsMinion.list[index] + if selected then + if selected.itemSetId then + activeSkill.activeEffect.srcInstance.skillMinionItemSetCalcs = selected.itemSetId + elseif selected.minionId then + activeSkill.activeEffect.srcInstance.skillMinionCalcs = selected.minionId + end + self.primaryBuild.buildFlag = true + end + end + end + end) + self.controls.primCalcsMinion.shown = false + + self.controls.primCalcsMinionSkill = new("DropDownControl", nil, {0, 0, 140, 18}, {}, function(index, value) + local mainSocketGroup = self.primaryBuild.skillsTab.socketGroupList[self.primaryBuild.calcsTab.input.skill_number] + if mainSocketGroup then + local displaySkillList = mainSocketGroup.displaySkillListCalcs + local activeSkill = displaySkillList and displaySkillList[mainSocketGroup.mainActiveSkillCalcs or 1] + if activeSkill and activeSkill.activeEffect then + activeSkill.activeEffect.srcInstance.skillMinionSkillCalcs = index + self.primaryBuild.buildFlag = true + end + end + end) + self.controls.primCalcsMinionSkill.shown = false + + self.controls.primCalcsMode = new("DropDownControl", nil, {0, 0, 120, 18}, calcsBuffModeDropList, function(index, value) + self.primaryBuild.calcsTab.input.misc_buffMode = value.buffMode + self.primaryBuild.buildFlag = true + end) + self.controls.primCalcsMode.shown = false + + -- Compare build calcs skill controls + self.controls.cmpCalcsSocketGroup = new("DropDownControl", nil, {0, 0, 200, 18}, {}, function(index, value) + local entry = self:GetActiveCompare() + if entry then + entry.calcsTab.input.skill_number = index + entry.buildFlag = true + self.modFlag = true + end + end) + self.controls.cmpCalcsSocketGroup.shown = false + self.controls.cmpCalcsSocketGroup.maxDroppedWidth = 400 + self.controls.cmpCalcsSocketGroup.enableDroppedWidth = true + + self.controls.cmpCalcsMainSkill = new("DropDownControl", nil, {0, 0, 200, 18}, {}, function(index, value) + local entry = self:GetActiveCompare() + if entry then + local mainSocketGroup = entry.skillsTab.socketGroupList[entry.calcsTab.input.skill_number] + if mainSocketGroup then + mainSocketGroup.mainActiveSkillCalcs = index + entry.buildFlag = true + self.modFlag = true + end + end + end) + self.controls.cmpCalcsMainSkill.shown = false + + self.controls.cmpCalcsSkillPart = new("DropDownControl", nil, {0, 0, 150, 18}, {}, function(index, value) + local entry = self:GetActiveCompare() + if entry then + local mainSocketGroup = entry.skillsTab.socketGroupList[entry.calcsTab.input.skill_number] + if mainSocketGroup then + local displaySkillList = mainSocketGroup.displaySkillListCalcs + local activeSkill = displaySkillList and displaySkillList[mainSocketGroup.mainActiveSkillCalcs or 1] + if activeSkill and activeSkill.activeEffect then + activeSkill.activeEffect.srcInstance.skillPartCalcs = index + entry.buildFlag = true + self.modFlag = true + end + end + end + end) + self.controls.cmpCalcsSkillPart.shown = false + + self.controls.cmpCalcsStageCount = new("EditControl", nil, {0, 0, 52, 18}, "", nil, "%D", 5, function(buf) + local entry = self:GetActiveCompare() + if entry then + local mainSocketGroup = entry.skillsTab.socketGroupList[entry.calcsTab.input.skill_number] + if mainSocketGroup then + local displaySkillList = mainSocketGroup.displaySkillListCalcs + local activeSkill = displaySkillList and displaySkillList[mainSocketGroup.mainActiveSkillCalcs or 1] + if activeSkill and activeSkill.activeEffect then + activeSkill.activeEffect.srcInstance.skillStageCountCalcs = tonumber(buf) + entry.buildFlag = true + self.modFlag = true + end + end + end + end) + self.controls.cmpCalcsStageCount.shown = false + + self.controls.cmpCalcsMineCount = new("EditControl", nil, {0, 0, 52, 18}, "", nil, "%D", 5, function(buf) + local entry = self:GetActiveCompare() + if entry then + local mainSocketGroup = entry.skillsTab.socketGroupList[entry.calcsTab.input.skill_number] + if mainSocketGroup then + local displaySkillList = mainSocketGroup.displaySkillListCalcs + local activeSkill = displaySkillList and displaySkillList[mainSocketGroup.mainActiveSkillCalcs or 1] + if activeSkill and activeSkill.activeEffect then + activeSkill.activeEffect.srcInstance.skillMineCountCalcs = tonumber(buf) + entry.buildFlag = true + self.modFlag = true + end + end + end + end) + self.controls.cmpCalcsMineCount.shown = false + + self.controls.cmpCalcsMinion = new("DropDownControl", nil, {0, 0, 140, 18}, {}, function(index, value) + local entry = self:GetActiveCompare() + if entry then + local mainSocketGroup = entry.skillsTab.socketGroupList[entry.calcsTab.input.skill_number] + if mainSocketGroup then + local displaySkillList = mainSocketGroup.displaySkillListCalcs + local activeSkill = displaySkillList and displaySkillList[mainSocketGroup.mainActiveSkillCalcs or 1] + if activeSkill and activeSkill.activeEffect then + local selected = self.controls.cmpCalcsMinion.list[index] + if selected then + if selected.itemSetId then + activeSkill.activeEffect.srcInstance.skillMinionItemSetCalcs = selected.itemSetId + elseif selected.minionId then + activeSkill.activeEffect.srcInstance.skillMinionCalcs = selected.minionId + end + entry.buildFlag = true + self.modFlag = true + end + end + end + end + end) + self.controls.cmpCalcsMinion.shown = false + + self.controls.cmpCalcsMinionSkill = new("DropDownControl", nil, {0, 0, 140, 18}, {}, function(index, value) + local entry = self:GetActiveCompare() + if entry then + local mainSocketGroup = entry.skillsTab.socketGroupList[entry.calcsTab.input.skill_number] + if mainSocketGroup then + local displaySkillList = mainSocketGroup.displaySkillListCalcs + local activeSkill = displaySkillList and displaySkillList[mainSocketGroup.mainActiveSkillCalcs or 1] + if activeSkill and activeSkill.activeEffect then + activeSkill.activeEffect.srcInstance.skillMinionSkillCalcs = index + entry.buildFlag = true + self.modFlag = true + end + end + end + end) + self.controls.cmpCalcsMinionSkill.shown = false + + self.controls.cmpCalcsMode = new("DropDownControl", nil, {0, 0, 120, 18}, calcsBuffModeDropList, function(index, value) + local entry = self:GetActiveCompare() + if entry then + entry.calcsTab.input.misc_buffMode = value.buffMode + entry.buildFlag = true + self.modFlag = true + end + end) + self.controls.cmpCalcsMode.shown = false + -- ============================================================ -- Tree footer controls (visible only in TREE view mode with a comparison loaded) -- ============================================================ @@ -1445,8 +1678,19 @@ function CompareTabClass:Draw(viewPort, inputEvents) if compareEntry then self:UpdateSetSelectors(compareEntry) end + -- Layout and refresh calcs skill detail controls + self.calcsSkillHeaderHeight = 0 + if self.compareViewMode == "CALCS" and compareEntry then + self.calcsSkillHeaderHeight = self:LayoutCalcsSkillControls(contentVP, compareEntry) + end self:HandleScrollInput(contentVP, inputEvents) + -- Draw calcs skill header background + if self.compareViewMode == "CALCS" and self.calcsSkillHeaderHeight > 0 then + SetDrawColor(0.05, 0.05, 0.05) + DrawImage(nil, contentVP.x, contentVP.y, contentVP.width, self.calcsSkillHeaderHeight) + end + -- Process input events for our controls (including footer controls) self:ProcessControlsInput(inputEvents, viewPort) @@ -1902,6 +2146,177 @@ function CompareTabClass:UpdateSetSelectors(compareEntry) compareEntry:RefreshSkillSelectControls(cmpControls, compareEntry.mainSocketGroup, "") end +-- Refresh calcs skill detail controls for both builds. +function CompareTabClass:RefreshCalcsSkillControls(compareEntry) + -- Build control maps for RefreshSkillSelectControls + local primControls = { + mainSocketGroup = self.controls.primCalcsSocketGroup, + mainSkill = self.controls.primCalcsMainSkill, + mainSkillPart = self.controls.primCalcsSkillPart, + mainSkillStageCount = self.controls.primCalcsStageCount, + mainSkillMineCount = self.controls.primCalcsMineCount, + mainSkillMinion = self.controls.primCalcsMinion, + mainSkillMinionLibrary = { shown = false }, + mainSkillMinionSkill = self.controls.primCalcsMinionSkill, + } + self.primaryBuild:RefreshSkillSelectControls(primControls, self.primaryBuild.calcsTab.input.skill_number, "Calcs") + self.controls.primCalcsSocketGroup.shown = true + self.controls.primCalcsMode.shown = true + self.controls.primCalcsMode:SelByValue(self.primaryBuild.calcsTab.input.misc_buffMode, "buffMode") + + local cmpControls = { + mainSocketGroup = self.controls.cmpCalcsSocketGroup, + mainSkill = self.controls.cmpCalcsMainSkill, + mainSkillPart = self.controls.cmpCalcsSkillPart, + mainSkillStageCount = self.controls.cmpCalcsStageCount, + mainSkillMineCount = self.controls.cmpCalcsMineCount, + mainSkillMinion = self.controls.cmpCalcsMinion, + mainSkillMinionLibrary = { shown = false }, + mainSkillMinionSkill = self.controls.cmpCalcsMinionSkill, + } + compareEntry:RefreshSkillSelectControls(cmpControls, compareEntry.calcsTab.input.skill_number, "Calcs") + self.controls.cmpCalcsSocketGroup.shown = true + self.controls.cmpCalcsMode.shown = true + self.controls.cmpCalcsMode:SelByValue(compareEntry.calcsTab.input.misc_buffMode, "buffMode") + + -- Wrap .shown booleans set by RefreshSkillSelectControls with a view-mode gate, + -- so controls auto-hide when not in CALCS mode (matching configShown pattern) + local calcsControlNames = { + "primCalcsSocketGroup", "primCalcsMainSkill", "primCalcsSkillPart", + "primCalcsStageCount", "primCalcsMineCount", "primCalcsMinion", + "primCalcsMinionSkill", "primCalcsMode", + "cmpCalcsSocketGroup", "cmpCalcsMainSkill", "cmpCalcsSkillPart", + "cmpCalcsStageCount", "cmpCalcsMineCount", "cmpCalcsMinion", + "cmpCalcsMinionSkill", "cmpCalcsMode", + } + for _, name in ipairs(calcsControlNames) do + local ctrl = self.controls[name] + local baseShown = ctrl.shown + if baseShown then + ctrl.shown = function() + return self.compareViewMode == "CALCS" and self:GetActiveCompare() ~= nil + and (type(baseShown) == "function" and baseShown() or baseShown) + end + end + end +end + +-- Layout calcs skill detail controls into a two-column header area +function CompareTabClass:LayoutCalcsSkillControls(vp, compareEntry) + if self.compareViewMode ~= "CALCS" or not compareEntry then return 0 end + + self:RefreshCalcsSkillControls(compareEntry) + + local colWidth = m_floor((vp.width - 20) / 2) + local leftX = vp.x + 4 + local rightX = leftX + colWidth + 12 + local labelW = 100 + local controlW = colWidth - labelW - 8 + local rowH = 22 + local y = vp.y + 4 + + -- Helper to position a row of label + control + local function layoutRow(control, x, currentY, width) + if control.shown == false or (type(control.shown) == "function" and not control:IsShown()) then + return false + end + control.x = x + labelW + control.y = currentY + if control.width then + control.width = m_min(width or controlW, control.width) + end + return true + end + + -- Track max rows for both columns + local leftY = y + local rightY = y + + -- Title row + leftY = leftY + rowH + rightY = rightY + rowH + + -- Socket Group + layoutRow(self.controls.primCalcsSocketGroup, leftX, leftY, controlW) + layoutRow(self.controls.cmpCalcsSocketGroup, rightX, rightY, controlW) + leftY = leftY + rowH + rightY = rightY + rowH + + -- Active Skill + if layoutRow(self.controls.primCalcsMainSkill, leftX, leftY, controlW) then + leftY = leftY + rowH + end + if layoutRow(self.controls.cmpCalcsMainSkill, rightX, rightY, controlW) then + rightY = rightY + rowH + end + + -- Skill Part + if layoutRow(self.controls.primCalcsSkillPart, leftX, leftY, controlW) then + leftY = leftY + rowH + end + if layoutRow(self.controls.cmpCalcsSkillPart, rightX, rightY, controlW) then + rightY = rightY + rowH + end + + -- Stage Count + if layoutRow(self.controls.primCalcsStageCount, leftX, leftY) then + leftY = leftY + rowH + end + if layoutRow(self.controls.cmpCalcsStageCount, rightX, rightY) then + rightY = rightY + rowH + end + + -- Mine Count + if layoutRow(self.controls.primCalcsMineCount, leftX, leftY) then + leftY = leftY + rowH + end + if layoutRow(self.controls.cmpCalcsMineCount, rightX, rightY) then + rightY = rightY + rowH + end + + -- Minion + if layoutRow(self.controls.primCalcsMinion, leftX, leftY, controlW) then + leftY = leftY + rowH + end + if layoutRow(self.controls.cmpCalcsMinion, rightX, rightY, controlW) then + rightY = rightY + rowH + end + + -- Minion Skill + if layoutRow(self.controls.primCalcsMinionSkill, leftX, leftY, controlW) then + leftY = leftY + rowH + end + if layoutRow(self.controls.cmpCalcsMinionSkill, rightX, rightY, controlW) then + rightY = rightY + rowH + end + + -- Calc Mode + layoutRow(self.controls.primCalcsMode, leftX, leftY) + layoutRow(self.controls.cmpCalcsMode, rightX, rightY) + leftY = leftY + rowH + rightY = rightY + rowH + + -- Account for text info lines (Aura/Buffs, Combat Buffs, Curses) + separator + local textLinesHeight = 2 -- padding before text + local primaryEnv = self.primaryBuild.calcsTab and self.primaryBuild.calcsTab.calcsEnv + local compareEnv = compareEntry.calcsTab and compareEntry.calcsTab.calcsEnv + local pOutput = primaryEnv and primaryEnv.player and primaryEnv.player.output + local cOutput = compareEnv and compareEnv.player and compareEnv.player.output + if pOutput or cOutput then + local infoKeys = { "BuffList", "CombatList", "CurseList" } + for _, key in ipairs(infoKeys) do + local pVal = pOutput and pOutput[key] + local cVal = cOutput and cOutput[key] + if (pVal and pVal ~= "") or (cVal and cVal ~= "") then + textLinesHeight = textLinesHeight + 18 + end + end + end + + local headerHeight = m_max(leftY, rightY) - vp.y + textLinesHeight + 4 -- +4 for separator padding + return headerHeight +end + -- Handle scroll events for scrollable views. function CompareTabClass:HandleScrollInput(contentVP, inputEvents) local cursorX, cursorY = GetCursorPos() @@ -3663,15 +4078,141 @@ end -- ============================================================ -- CALCS VIEW (card-based sections with comparison) -- ============================================================ + +-- Draw the skill detail header area with labels for controls and text info lines +function CompareTabClass:DrawCalcsSkillHeader(vp, compareEntry, headerHeight, primaryEnv, compareEnv) + local colWidth = m_floor((vp.width - 20) / 2) + local leftX = vp.x + 4 + local rightX = leftX + colWidth + 12 + local labelW = 100 + local rowH = 22 + local y = vp.y + 4 + + -- Build name headers + SetDrawColor(1, 1, 1) + DrawString(leftX, y + 2, "LEFT", 16, "VAR BOLD", + colorCodes.POSITIVE .. self:GetShortBuildName(self.primaryBuild.buildName)) + DrawString(rightX, y + 2, "LEFT", 16, "VAR BOLD", + colorCodes.WARNING .. (compareEntry.label or "Compare Build")) + y = y + rowH + + -- Draw labels next to each control row + local function drawLabel(label, x, cy, control) + if control.shown == false or (type(control.shown) == "function" and not control:IsShown()) then + return false + end + DrawString(x, cy + 2, "LEFT", 14, "VAR", "^7" .. label .. ":") + return true + end + + local leftY = y + local rightY = y + + -- Socket Group + drawLabel("Socket Group", leftX, leftY, self.controls.primCalcsSocketGroup) + drawLabel("Socket Group", rightX, rightY, self.controls.cmpCalcsSocketGroup) + leftY = leftY + rowH + rightY = rightY + rowH + + -- Active Skill + if drawLabel("Active Skill", leftX, leftY, self.controls.primCalcsMainSkill) then leftY = leftY + rowH end + if drawLabel("Active Skill", rightX, rightY, self.controls.cmpCalcsMainSkill) then rightY = rightY + rowH end + + -- Skill Part + if drawLabel("Skill Part", leftX, leftY, self.controls.primCalcsSkillPart) then leftY = leftY + rowH end + if drawLabel("Skill Part", rightX, rightY, self.controls.cmpCalcsSkillPart) then rightY = rightY + rowH end + + -- Stage Count + if drawLabel("Stages", leftX, leftY, self.controls.primCalcsStageCount) then leftY = leftY + rowH end + if drawLabel("Stages", rightX, rightY, self.controls.cmpCalcsStageCount) then rightY = rightY + rowH end + + -- Mine Count + if drawLabel("Mines", leftX, leftY, self.controls.primCalcsMineCount) then leftY = leftY + rowH end + if drawLabel("Mines", rightX, rightY, self.controls.cmpCalcsMineCount) then rightY = rightY + rowH end + + -- Minion + if drawLabel("Minion", leftX, leftY, self.controls.primCalcsMinion) then leftY = leftY + rowH end + if drawLabel("Minion", rightX, rightY, self.controls.cmpCalcsMinion) then rightY = rightY + rowH end + + -- Minion Skill + if drawLabel("Minion Skill", leftX, leftY, self.controls.primCalcsMinionSkill) then leftY = leftY + rowH end + if drawLabel("Minion Skill", rightX, rightY, self.controls.cmpCalcsMinionSkill) then rightY = rightY + rowH end + + -- Calc Mode + drawLabel("Calc Mode", leftX, leftY, self.controls.primCalcsMode) + drawLabel("Calc Mode", rightX, rightY, self.controls.cmpCalcsMode) + leftY = leftY + rowH + rightY = rightY + rowH + + -- Text info lines (Aura/Buffs, Combat Buffs, Curses) + local textY = m_max(leftY, rightY) + 2 + local pOutput = primaryEnv.player and primaryEnv.player.output + local cOutput = compareEnv.player and compareEnv.player.output + self.calcsSkillHeaderHover = nil -- Reset hover state + if pOutput or cOutput then + local cursorX, cursorY = GetCursorPos() + local infoLines = { + { label = "Aura/Buff Skills", key = "BuffList", breakdown = "SkillBuffs" }, + { label = "Combat Buffs", key = "CombatList" }, + { label = "Curses/Debuffs", key = "CurseList", breakdown = "SkillDebuffs" }, + } + for _, info in ipairs(infoLines) do + local pVal = pOutput and pOutput[info.key] + local cVal = cOutput and cOutput[info.key] + if (pVal and pVal ~= "") or (cVal and cVal ~= "") then + -- Check hover per-side for lines that have breakdown data + if info.breakdown and cursorY >= textY and cursorY < textY + 18 then + local onLeft = cursorX >= leftX and cursorX < rightX + local onRight = cursorX >= rightX and cursorX < vp.x + vp.width + if onLeft then + SetDrawColor(0.15, 0.25, 0.15) + DrawImage(nil, leftX, textY, colWidth, 18) + self.calcsSkillHeaderHover = { + breakdown = info.breakdown, + label = info.label, + build = self.primaryBuild, + x = leftX, y = textY, w = colWidth, h = 18, + } + elseif onRight then + SetDrawColor(0.15, 0.25, 0.15) + DrawImage(nil, rightX, textY, colWidth, 18) + self.calcsSkillHeaderHover = { + breakdown = info.breakdown, + label = info.label, + build = compareEntry, + x = rightX, y = textY, w = colWidth, h = 18, + } + end + end + DrawString(leftX, textY + 1, "LEFT", 14, "VAR", "^7" .. info.label .. ": " .. (pVal or "")) + DrawString(rightX, textY + 1, "LEFT", 14, "VAR", "^7" .. info.label .. ": " .. (cVal or "")) + textY = textY + 18 + end + end + end + + -- Separator line + SetDrawColor(0.4, 0.4, 0.4) + DrawImage(nil, vp.x + 2, vp.y + headerHeight - 2, vp.width - 4, 1) +end + function CompareTabClass:DrawCalcs(vp, compareEntry) - -- Get actors from both builds (use mainEnv, not calcsEnv, so skill dropdown is respected) - local primaryEnv = self.primaryBuild.calcsTab.mainEnv - local compareEnv = compareEntry.calcsTab and compareEntry.calcsTab.mainEnv + -- Use calcsEnv for both values and tooltips (has breakdown data + respects Calcs skill selection) + local primaryEnv = self.primaryBuild.calcsTab.calcsEnv + local compareEnv = compareEntry.calcsTab and compareEntry.calcsTab.calcsEnv if not primaryEnv or not compareEnv then return end local primaryActor = primaryEnv.player local compareActor = compareEnv.player if not primaryActor or not compareActor then return end + -- Skill detail header height + local skillHeaderHeight = self.calcsSkillHeaderHeight or 0 + + -- Draw skill detail header background and labels + if skillHeaderHeight > 0 then + self:DrawCalcsSkillHeader(vp, compareEntry, skillHeaderHeight, primaryEnv, compareEnv) + end + -- Card dimensions -- Layout: [2px border | 130px label | 2px gap | 2px sep | valW | 2px sep | valW | 2px border] local cardWidth = m_min(LAYOUT.calcsMaxCardWidth, vp.width - 16) @@ -3747,27 +4288,17 @@ function CompareTabClass:DrawCalcs(vp, compareEntry) maxY = m_max(maxY, colY[col]) end - -- Set viewport for scroll clipping - SetViewport(vp.x, vp.y, vp.width, vp.height) + -- Set viewport for scroll clipping, offset below skill header so cards can't bleed into it + SetViewport(vp.x, vp.y + skillHeaderHeight, vp.width, vp.height - skillHeaderHeight) -- Cursor position relative to viewport (for hover detection) local cursorX, cursorY = GetCursorPos() local vpCursorX = cursorX - vp.x - local vpCursorY = cursorY - vp.y + local vpCursorY = cursorY - (vp.y + skillHeaderHeight) local hoverColData = nil local hoverRowLabel = nil local hoverRowX, hoverRowY, hoverRowW, hoverRowH = 0, 0, 0, 0 - -- Draw header bar with build names - local headerY = 4 - self.scrollY - SetDrawColor(1, 1, 1) - DrawString(baseX + valCol1X, headerY, "LEFT", 14, "VAR", - colorCodes.POSITIVE .. self:GetShortBuildName(self.primaryBuild.buildName)) - DrawString(baseX + valCol2X, headerY, "LEFT", 14, "VAR", - colorCodes.WARNING .. (compareEntry.label or "Compare Build")) - SetDrawColor(0.5, 0.5, 0.5) - DrawImage(nil, 4, headerY + 16, vp.width - 8, 1) - -- Draw section cards for _, sec in ipairs(sections) do local x = sec.drawX @@ -3880,7 +4411,14 @@ function CompareTabClass:DrawCalcs(vp, compareEntry) -- Draw hover tooltip for calcs breakdown (reset viewport first so tooltip can extend beyond) if hoverColData then SetViewport() - self:DrawCalcsTooltip(hoverColData, hoverRowLabel, hoverRowX + vp.x, hoverRowY + vp.y, hoverRowW, hoverRowH, vp, compareEntry) + self:DrawCalcsTooltip(hoverColData, hoverRowLabel, hoverRowX + vp.x, hoverRowY + vp.y + skillHeaderHeight, hoverRowW, hoverRowH, vp, compareEntry) + elseif self.calcsSkillHeaderHover then + SetViewport() + local hover = self.calcsSkillHeaderHover + calcsHelpers.DrawSkillBreakdownPanel( + hover.build, hover.breakdown, hover.label, + hover.x, hover.y, hover.w, hover.h, vp + ) else SetViewport() end From 2296ab4fb97d61d662020984c1929dba4f297d60 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Sun, 12 Apr 2026 22:56:30 +0200 Subject: [PATCH 41/59] rename tmpl to template --- src/Classes/CompareTab.lua | 4 ++-- src/Classes/CompareTradeHelpers.lua | 28 ++++++++++++++-------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 4253401b66..417b37b263 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -3438,8 +3438,8 @@ function CompareTabClass:DrawItemExpanded(item, x, startY, colWidth, otherModMap local formatted = itemLib.formatModLine(modLine) if formatted then if otherModMap then - local tmpl = tradeHelpers.modLineTemplate(modLine.line) - local otherEntry = otherModMap[tmpl] + local template = tradeHelpers.modLineTemplate(modLine.line) + local otherEntry = otherModMap[template] if not otherEntry then -- Mod exists only on this side formatted = colorCodes.POSITIVE .. "+ " .. formatted diff --git a/src/Classes/CompareTradeHelpers.lua b/src/Classes/CompareTradeHelpers.lua index c2ab26e51c..15226d13d9 100644 --- a/src/Classes/CompareTradeHelpers.lua +++ b/src/Classes/CompareTradeHelpers.lua @@ -48,14 +48,14 @@ local function getTradeModLookup() end -- Also store with template-converted text for mods with literal numbers -- (e.g. "1 Added Passive Skill is X" → "# Added Passive Skill is X") - local tmpl = M.modLineTemplate(text) - if tmpl ~= text then - local tmplKey = tmpl .. "|" .. modType - if not _tradeModLookup[tmplKey] then - _tradeModLookup[tmplKey] = id + local template = M.modLineTemplate(text) + if template ~= text then + local templateKey = template .. "|" .. modType + if not _tradeModLookup[templateKey] then + _tradeModLookup[templateKey] = id end - if not _tradeModLookup[tmpl] then - _tradeModLookup[tmpl] = id + if not _tradeModLookup[template] then + _tradeModLookup[template] = id end end end @@ -113,21 +113,21 @@ M.sourceTypeToCategory = { function M.findTradeModId(modLine, modType) -- Try QueryMods-based lookup local lookup = getTradeModLookup() - local tmpl = M.modLineTemplate(modLine) + local template = M.modLineTemplate(modLine) -- Try exact match with type first - local key = tmpl .. "|" .. modType + local key = template .. "|" .. modType if lookup[key] then return lookup[key] end -- Try without leading +/- sign - local stripped = tmpl:gsub("^[%+%-]", "") + local stripped = template:gsub("^[%+%-]", "") key = stripped .. "|" .. modType if lookup[key] then return lookup[key] end -- Fallback: match by template text only (any type) - if lookup[tmpl] then - return lookup[tmpl] + if lookup[template] then + return lookup[template] end if lookup[stripped] then return lookup[stripped] @@ -207,8 +207,8 @@ function M.buildModMap(item) if item:CheckModLineVariant(modLine) then local formatted = itemLib.formatModLine(modLine) if formatted then - local tmpl = M.modLineTemplate(modLine.line) - modMap[tmpl] = { line = modLine.line, value = M.modLineValue(modLine.line) } + local template = M.modLineTemplate(modLine.line) + modMap[template] = { line = modLine.line, value = M.modLineValue(modLine.line) } end end end From 9a60271a02ac0d4e88e8779a54997ca0688938cf Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Sun, 12 Apr 2026 23:44:59 +0200 Subject: [PATCH 42/59] improve compared gems/skills sorting and grouping --- src/Classes/CompareTab.lua | 218 +++++++++++++++++++++---------------- 1 file changed, 123 insertions(+), 95 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 417b37b263..542993808d 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -3883,47 +3883,133 @@ function CompareTabClass:DrawSkills(vp, compareEntry) local gemLineHeight = 18 local gemTextWidth = colWidth - 30 - -- Position pre-pass: compute gem positions without drawing to enable hover hit-testing - local gemEntries = {} -- { gem, x, y, group } - local preY = 4 - self.scrollY + 24 -- after headers - for _, pair in ipairs(renderPairs) do - preY = preY + 2 -- separator - local pSet = pair.pIdx and pSets[pair.pIdx] or {} - local cSet = pair.cIdx and cSets[pair.cIdx] or {} - local pGemY = preY + lineHeight - local cGemY = preY + lineHeight + -- Helper: build aligned display lists for a matched pair of groups + -- Common gems appear first, then additional, then missing + local function getGemName(gem) + return gem.grantedEffect and gem.grantedEffect.name or gem.nameSpec + end - -- Primary group gems - local pGroup = pair.pIdx and pGroups[pair.pIdx] + local function buildAlignedGemLists(pGroup, cGroup, pSet, cSet) + local pDisplay = {} + local cDisplay = {} + + -- Build name->gem lookup for compare side (common gems only) + local cGemByName = {} + if cGroup then + for _, gem in ipairs(cGroup.gemList or {}) do + local name = getGemName(gem) + if name and pSet[name] and not cGemByName[name] then + cGemByName[name] = gem + end + end + end + + -- Common gems in primary build's order + local emittedCommon = {} if pGroup then for _, gem in ipairs(pGroup.gemList or {}) do - t_insert(gemEntries, { gem = gem, x = 20, y = pGemY, group = pGroup }) - pGemY = pGemY + gemLineHeight - end - if pair.cIdx then - for name in pairs(cSet) do - if not pSet[name] then - pGemY = pGemY + gemLineHeight -- missing gem placeholder - end + local name = getGemName(gem) + if name and cSet[name] and not emittedCommon[name] then + emittedCommon[name] = true + t_insert(pDisplay, { gem = gem, name = name, status = "common" }) + t_insert(cDisplay, { gem = cGemByName[name], name = name, status = "common" }) end end end - -- Compare group gems - local cGroup = pair.cIdx and cGroups[pair.cIdx] + -- Additional gems (unique to each side), preserving original order + if pGroup then + for _, gem in ipairs(pGroup.gemList or {}) do + local name = getGemName(gem) + if name and not cSet[name] then + t_insert(pDisplay, { gem = gem, name = name, status = "additional" }) + end + end + end if cGroup then for _, gem in ipairs(cGroup.gemList or {}) do - t_insert(gemEntries, { gem = gem, x = colWidth + 20, y = cGemY, group = cGroup }) - cGemY = cGemY + gemLineHeight + local name = getGemName(gem) + if name and not pSet[name] then + t_insert(cDisplay, { gem = gem, name = name, status = "additional" }) + end end - if pair.pIdx then - for name in pairs(pSet) do - if not cSet[name] then - cGemY = cGemY + gemLineHeight - end + end + + -- Missing gems (sorted alphabetically) + if pGroup and cGroup then + local pMissing = {} + local cMissing = {} + for name in pairs(cSet) do + if not pSet[name] then t_insert(pMissing, name) end + end + for name in pairs(pSet) do + if not cSet[name] then t_insert(cMissing, name) end + end + table.sort(pMissing) + table.sort(cMissing) + for _, name in ipairs(pMissing) do + t_insert(pDisplay, { gem = nil, name = name, status = "missing" }) + end + for _, name in ipairs(cMissing) do + t_insert(cDisplay, { gem = nil, name = name, status = "missing" }) + end + end + + return pDisplay, cDisplay + end + + -- Helper: collect gem positions from a display list into gemEntries for hit-testing + local function collectGemEntries(gemEntries, displayList, xOffset, startY, group) + local y = startY + for _, entry in ipairs(displayList) do + if entry.gem then + t_insert(gemEntries, { gem = entry.gem, x = xOffset, y = y, group = group }) + end + y = y + gemLineHeight + end + return y + end + + -- Helper: draw a list of gems (common, additional, missing) at a given x offset + local function drawGemList(displayList, xOffset, startY, highlightSet) + local y = startY + for _, entry in ipairs(displayList) do + if entry.status == "missing" then + DrawString(xOffset, y, "LEFT", gemFontSize, "VAR", colorCodes.NEGATIVE .. "- " .. entry.name .. "^7") + elseif entry.gem then + if highlightSet[entry.gem] then + SetDrawColor(0.33, 1, 0.33, 0.25) + DrawImage(nil, xOffset, y, gemTextWidth, gemLineHeight) end + local gemName = entry.gem.grantedEffect and entry.gem.grantedEffect.name or entry.gem.nameSpec or "?" + local gemColor = entry.gem.color or colorCodes.GEM + local levelStr = entry.gem.level and (" Lv" .. entry.gem.level) or "" + local qualStr = entry.gem.quality and entry.gem.quality > 0 and ("/" .. entry.gem.quality .. "q") or "" + local prefix = "" + if entry.status == "additional" then + prefix = colorCodes.POSITIVE .. "+ " + end + DrawString(xOffset, y, "LEFT", gemFontSize, "VAR", prefix .. gemColor .. gemName .. "^7" .. levelStr .. qualStr) end + y = y + gemLineHeight end + return y + end + + -- Position pre-pass: compute gem positions without drawing to enable hover hit-testing + local gemEntries = {} -- { gem, x, y, group } + local preY = 4 - self.scrollY + 24 -- after headers + for _, pair in ipairs(renderPairs) do + preY = preY + 2 -- separator + local pSet = pair.pIdx and pSets[pair.pIdx] or {} + local cSet = pair.cIdx and cSets[pair.cIdx] or {} + + local pGroup = pair.pIdx and pGroups[pair.pIdx] + local cGroup = pair.cIdx and cGroups[pair.cIdx] + local pDisplayList, cDisplayList = buildAlignedGemLists(pGroup, cGroup, pSet, cSet) + + local pGemY = collectGemEntries(gemEntries, pDisplayList, 20, preY + lineHeight, pGroup) + local cGemY = collectGemEntries(gemEntries, cDisplayList, colWidth + 20, preY + lineHeight, cGroup) preY = preY + m_max(pGemY - preY, cGemY - preY) + 6 end @@ -3973,90 +4059,32 @@ function CompareTabClass:DrawSkills(vp, compareEntry) local pFinalGemY = drawY + lineHeight local cFinalGemY = drawY + lineHeight - -- Primary group (left side) + -- Build aligned display lists local pGroup = pair.pIdx and pGroups[pair.pIdx] + local cGroup = pair.cIdx and cGroups[pair.cIdx] + local pDisplayList, cDisplayList = buildAlignedGemLists(pGroup, cGroup, pSet, cSet) + + -- Primary group label (left side) if pGroup then local groupLabel = pGroup.displayLabel or pGroup.label or ("Group " .. pair.pIdx) if pGroup.slot then groupLabel = groupLabel .. " (" .. pGroup.slot .. ")" end DrawString(10, drawY, "LEFT", 16, "VAR", "^7" .. groupLabel) - local gemY = drawY + lineHeight - for _, gem in ipairs(pGroup.gemList or {}) do - if highlightSet[gem] then - SetDrawColor(0.33, 1, 0.33, 0.25) - DrawImage(nil, 20, gemY, gemTextWidth, gemLineHeight) - end - local gemName = gem.grantedEffect and gem.grantedEffect.name or gem.nameSpec or "?" - local gemColor = gem.color or colorCodes.GEM - local levelStr = gem.level and (" Lv" .. gem.level) or "" - local qualStr = gem.quality and gem.quality > 0 and ("/" .. gem.quality .. "q") or "" - local prefix = "" - if pair.cIdx and not cSet[gemName] then - prefix = colorCodes.POSITIVE .. "+ " - end - DrawString(20, gemY, "LEFT", gemFontSize, "VAR", prefix .. gemColor .. gemName .. "^7" .. levelStr .. qualStr) - gemY = gemY + gemLineHeight - end - -- Show gems missing from primary but present in compare - if pair.cIdx then - local missing = {} - for name in pairs(cSet) do - if not pSet[name] then - t_insert(missing, name) - end - end - table.sort(missing) - for _, name in ipairs(missing) do - DrawString(20, gemY, "LEFT", gemFontSize, "VAR", colorCodes.NEGATIVE .. "- " .. name .. "^7") - gemY = gemY + gemLineHeight - end - end - pFinalGemY = gemY end - -- Compare group (right side) - local cGroup = pair.cIdx and cGroups[pair.cIdx] + -- Compare group label (right side) if cGroup then local groupLabel = cGroup.displayLabel or cGroup.label or ("Group " .. pair.cIdx) if cGroup.slot then groupLabel = groupLabel .. " (" .. cGroup.slot .. ")" end DrawString(colWidth + 10, drawY, "LEFT", 16, "VAR", "^7" .. groupLabel) - local gemY = drawY + lineHeight - for _, gem in ipairs(cGroup.gemList or {}) do - if highlightSet[gem] then - SetDrawColor(0.33, 1, 0.33, 0.25) - DrawImage(nil, colWidth + 20, gemY, gemTextWidth, gemLineHeight) - end - local gemName = gem.grantedEffect and gem.grantedEffect.name or gem.nameSpec or "?" - local gemColor = gem.color or colorCodes.GEM - local levelStr = gem.level and (" Lv" .. gem.level) or "" - local qualStr = gem.quality and gem.quality > 0 and ("/" .. gem.quality .. "q") or "" - local prefix = "" - if pair.pIdx and not pSet[gemName] then - prefix = colorCodes.POSITIVE .. "+ " - end - DrawString(colWidth + 20, gemY, "LEFT", gemFontSize, "VAR", prefix .. gemColor .. gemName .. "^7" .. levelStr .. qualStr) - gemY = gemY + gemLineHeight - end - -- Show gems missing from compare but present in primary - if pair.pIdx then - local missing = {} - for name in pairs(pSet) do - if not cSet[name] then - t_insert(missing, name) - end - end - table.sort(missing) - for _, name in ipairs(missing) do - DrawString(colWidth + 20, gemY, "LEFT", gemFontSize, "VAR", colorCodes.NEGATIVE .. "- " .. name .. "^7") - gemY = gemY + gemLineHeight - end - end - cFinalGemY = gemY end + pFinalGemY = drawGemList(pDisplayList, 20, drawY + lineHeight, highlightSet) + cFinalGemY = drawGemList(cDisplayList, colWidth + 20, drawY + lineHeight, highlightSet) + -- Calculate height for this row drawY = drawY + m_max(pFinalGemY - drawY, cFinalGemY - drawY) + 6 end From 00ebedd9e1274f6bbab3f960fe470eddcd794d54 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Mon, 13 Apr 2026 00:39:19 +0200 Subject: [PATCH 43/59] update compare tab top content layout/spacing --- src/Classes/CompareTab.lua | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 542993808d..88160a6a2d 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -20,7 +20,7 @@ local CLUSTER_NODE_OFFSET = 65536 -- Layout constants (shared across Draw, DrawConfig, DrawItems, DrawCalcs, etc.) local LAYOUT = { -- Main tab control bar - controlBarHeight = 96, + controlBarHeight = 126, -- Tree view header/footer treeHeaderHeight = 58, @@ -193,7 +193,7 @@ function CompareTabClass:InitControls() end -- Build B selector dropdown - self.controls.compareBuildLabel = new("LabelControl", {"TOPLEFT", self.controls.subTabAnchor, "TOPLEFT"}, {0, -70, 0, 16}, "^7Compare with:") + self.controls.compareBuildLabel = new("LabelControl", {"TOPLEFT", self.controls.subTabAnchor, "TOPLEFT"}, {0, -88, 0, 16}, "^7Compare with:") self.controls.compareBuildSelect = new("DropDownControl", {"LEFT", self.controls.compareBuildLabel, "RIGHT"}, {4, 0, 250, 20}, {}, function(index, value) if index and index > 0 and index <= #self.compareEntries then self.activeCompareIndex = index @@ -250,11 +250,8 @@ function CompareTabClass:InitControls() return #self.compareEntries > 0 end - self.controls.compareSetsLabel = new("LabelControl", {"TOPLEFT", self.controls.subTabAnchor, "TOPLEFT"}, {0, -44, 0, 16}, "^7Sets:") - self.controls.compareSetsLabel.shown = setsEnabled - -- Tree spec selector for comparison build - self.controls.compareSpecLabel = new("LabelControl", {"LEFT", self.controls.compareSetsLabel, "RIGHT"}, {4, 0, 0, 16}, "^7Tree set:") + self.controls.compareSpecLabel = new("LabelControl", {"TOPLEFT", self.controls.subTabAnchor, "TOPLEFT"}, {0, -54, 0, 16}, "^7Tree set:") self.controls.compareSpecLabel.shown = setsEnabled self.controls.compareSpecSelect = new("DropDownControl", {"LEFT", self.controls.compareSpecLabel, "RIGHT"}, {2, 0, 150, 20}, {}, function(index, value) local entry = self:GetActiveCompare() @@ -314,7 +311,7 @@ function CompareTabClass:InitControls() -- ============================================================ -- Comparison build main skill selector (row between sets and sub-tabs) -- ============================================================ - self.controls.cmpSkillLabel = new("LabelControl", {"TOPLEFT", self.controls.subTabAnchor, "TOPLEFT"}, {0, -22, 0, 16}, "^7Skill:") + self.controls.cmpSkillLabel = new("LabelControl", {"TOPLEFT", self.controls.subTabAnchor, "TOPLEFT"}, {0, -32, 0, 16}, "^7Skill:") self.controls.cmpSkillLabel.shown = setsEnabled -- Socket group dropdown @@ -1650,7 +1647,15 @@ end function CompareTabClass:Draw(viewPort, inputEvents) -- Position top-bar controls self.controls.subTabAnchor.x = viewPort.x + 4 - self.controls.subTabAnchor.y = viewPort.y + 74 + self.controls.subTabAnchor.y = viewPort.y + 96 + + -- Draw dividers between top-bar sections when a comparison is loaded + if #self.compareEntries > 0 then + SetDrawColor(0.25, 0.25, 0.25) + DrawImage(nil, viewPort.x + 4, viewPort.y + 32, viewPort.width - 8, 2) + DrawImage(nil, viewPort.x + 4, viewPort.y + 88, viewPort.width - 8, 2) + DrawImage(nil, viewPort.x + 4, viewPort.y + 122, viewPort.width - 8, 2) + end self.controls.compareBuildLabel.x = function() return 0 @@ -4118,9 +4123,9 @@ function CompareTabClass:DrawCalcsSkillHeader(vp, compareEntry, headerHeight, pr -- Build name headers SetDrawColor(1, 1, 1) - DrawString(leftX, y + 2, "LEFT", 16, "VAR BOLD", + DrawString(leftX, y + 2, "LEFT", 18, "VAR", colorCodes.POSITIVE .. self:GetShortBuildName(self.primaryBuild.buildName)) - DrawString(rightX, y + 2, "LEFT", 16, "VAR BOLD", + DrawString(rightX, y + 2, "LEFT", 18, "VAR", colorCodes.WARNING .. (compareEntry.label or "Compare Build")) y = y + rowH From faa4a66f4fb72528e6dc5548ad39c7f5c7494815 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Tue, 14 Apr 2026 20:01:44 +0200 Subject: [PATCH 44/59] remove redundant Name label --- src/Classes/CompareTab.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 88160a6a2d..cd6f65376b 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -1590,8 +1590,8 @@ function CompareTabClass:OpenImportPopup() controls.go.onClick() end end - controls.nameLabel = new("LabelControl", nil, {-175, 80, 0, 16}, "^7Name:") - controls.name = new("EditControl", nil, {40, 80, 300, 20}, "", "Name (optional)", nil, 100, nil) + + controls.name = new("EditControl", nil, {0, 80, 450, 20}, "", "Name (optional)", nil, 100, nil) controls.state = new("LabelControl", {"TOPLEFT", controls.name, "BOTTOMLEFT"}, {0, 4, 0, 16}) controls.state.label = function() return stateText or "" From e2b1450aea4018ce79287f249607c48273f1158c Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Tue, 14 Apr 2026 20:08:32 +0200 Subject: [PATCH 45/59] disable re-import current button instead of using a popup when none imported --- src/Classes/CompareTab.lua | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index cd6f65376b..c55a3d5110 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -232,6 +232,10 @@ function CompareTabClass:InitControls() tooltip:AddLine(14, "^7Go to Import/Export Build tab and select a character first.") end end + self.controls.reimportBtn.enabled = function() + local importTab = self.primaryBuild.importTab + return importTab and importTab.charImportMode == "SELECTCHAR" + end -- Remove comparison build button self.controls.removeBtn = new("ButtonControl", {"LEFT", self.controls.reimportBtn, "RIGHT"}, {4, 0, 70, 20}, "Remove", function() @@ -1409,14 +1413,6 @@ end -- Re-import primary build using character import (same as Import/Export tab) function CompareTabClass:ReimportPrimary() local importTab = self.primaryBuild.importTab - if not importTab then - main:OpenMessagePopup("Re-import", "Import tab not available.") - return - end - if importTab.charImportMode ~= "SELECTCHAR" then - main:OpenMessagePopup("Re-import", "No character selected.\nGo to the Import/Export Build tab, enter your account name,\nand select a character first.") - return - end -- Set clear checkboxes to true (delete existing jewels, skills, equipment) importTab.controls.charImportTreeClearJewels.state = true importTab.controls.charImportItemsClearSkills.state = true From e249a054d7b65d7878c4cf9e70ef74719b7ebb1f Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Tue, 14 Apr 2026 21:30:07 +0200 Subject: [PATCH 46/59] fix power report metric dropdown tooltip --- src/Classes/CompareTab.lua | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index c55a3d5110..3daacca05c 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -946,7 +946,13 @@ function CompareTabClass:InitControls() end end) self.controls.comparePowerStatSelect.shown = powerReportShown - self.controls.comparePowerStatSelect.tooltipText = "Select a metric to calculate power report" + self.controls.comparePowerStatSelect.tooltipFunc = function(tooltip, mode, index, value) + tooltip:Clear() + if mode == "OUT" or self.controls.comparePowerStatSelect.dropped then + return + end + tooltip:AddLine(14, "Select a metric to calculate power report") + end -- Category checkboxes self.controls.comparePowerTreeCheck = new("CheckBoxControl", nil, {0, 0, 18}, "Tree:", function(state) From e3a4a2280d444bce3b2876da915a0acfd6bc14f4 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Tue, 14 Apr 2026 23:39:02 +0200 Subject: [PATCH 47/59] improve spacings/positions of inputs --- src/Classes/CompareBuySimilar.lua | 10 ++--- src/Classes/CompareTab.lua | 11 ++--- src/Classes/CompareTradeHelpers.lua | 63 ++++++++++++++++------------- 3 files changed, 47 insertions(+), 37 deletions(-) diff --git a/src/Classes/CompareBuySimilar.lua b/src/Classes/CompareBuySimilar.lua index 53aac20c0c..698d5be9b8 100644 --- a/src/Classes/CompareBuySimilar.lua +++ b/src/Classes/CompareBuySimilar.lua @@ -180,8 +180,8 @@ function M.openPopup(item, slotName, primaryBuild) local rowHeight = 24 local popupWidth = 700 local leftMargin = 20 - local minFieldX = popupWidth - 160 - local maxFieldX = popupWidth - 80 + local minFieldX = popupWidth - 130 + local maxFieldX = popupWidth - 50 local fieldW = 60 local fieldH = 20 local checkboxSize = 20 @@ -291,10 +291,10 @@ function M.openPopup(item, slotName, primaryBuild) controls.leagueDrop.enabled = function() return #controls.leagueDrop.list > 0 and controls.leagueDrop.list[1] ~= "Loading..." end -- Listed status dropdown - controls.listedLabel = new("LabelControl", {"LEFT", controls.leagueDrop, "RIGHT"}, {12, 0, 0, 16}, "^7Listed:") - controls.listedDrop = new("DropDownControl", {"LEFT", controls.listedLabel, "RIGHT"}, {4, 0, 180, 20}, LISTED_STATUS_LABELS, function(index, value) + controls.listedDrop = new("DropDownControl", {"TOPRIGHT", nil, "TOPRIGHT"}, {-leftMargin, ctrlY, 242, 20}, LISTED_STATUS_LABELS, function(index, value) -- Listed status selection stored in the dropdown itself end) + controls.listedLabel = new("LabelControl", {"RIGHT", controls.listedDrop, "LEFT"}, {-4, 0, 0, 16}, "^7Listed:") -- Fetch initial leagues for default realm fetchLeaguesForRealm("pc") @@ -361,7 +361,7 @@ function M.openPopup(item, slotName, primaryBuild) -- Search button ctrlY = ctrlY + 8 - controls.search = new("ButtonControl", nil, {0, ctrlY, 100, 20}, "Generate URL", function() + controls.search = new("ButtonControl", nil, {0, ctrlY, 110, 20}, "Generate URL", function() local success, result = pcall(function() return buildURL(item, slotName, controls, modEntries, defenceEntries, isUnique) end) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 3daacca05c..2385b73d5e 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -36,6 +36,7 @@ local LAYOUT = { -- Items view itemsCheckboxOffset = 60, itemsCopyBtnW = 60, + itemsCopyUseBtnW = 78, itemsCopyBtnH = 18, itemsBuyBtnW = 60, @@ -211,7 +212,7 @@ function CompareTabClass:InitControls() end) -- Re-import current build button - self.controls.reimportBtn = new("ButtonControl", {"LEFT", self.controls.importBtn, "RIGHT"}, {4, 0, 120, 20}, "Re-import Current", function() + self.controls.reimportBtn = new("ButtonControl", {"LEFT", self.controls.importBtn, "RIGHT"}, {4, 0, 140, 20}, "Re-import Current", function() self:ReimportPrimary() end) self.controls.reimportBtn.tooltipFunc = function(tooltip) @@ -850,7 +851,7 @@ function CompareTabClass:InitControls() self.controls.rightVersionSelect.shown = treeFooterShown -- Copy compared tree to primary build - self.controls.copySpecBtn = new("ButtonControl", {"LEFT", self.controls.rightVersionSelect, "RIGHT"}, {4, 0, 66, 20}, "Copy tree", function() + self.controls.copySpecBtn = new("ButtonControl", {"LEFT", self.controls.rightVersionSelect, "RIGHT"}, {4, 0, 76, 20}, "Copy tree", function() self:CopyCompareSpecToPrimary(false) end) self.controls.copySpecBtn.shown = treeFooterShown @@ -859,7 +860,7 @@ function CompareTabClass:InitControls() return entry and entry.treeTab and entry.treeTab.specList[entry.treeTab.activeSpec] ~= nil end - self.controls.copySpecUseBtn = new("ButtonControl", {"LEFT", self.controls.copySpecBtn, "RIGHT"}, {2, 0, 90, 20}, "Copy and use", function() + self.controls.copySpecUseBtn = new("ButtonControl", {"LEFT", self.controls.copySpecBtn, "RIGHT"}, {2, 0, 100, 20}, "Copy and use", function() self:CopyCompareSpecToPrimary(true) end) self.controls.copySpecUseBtn.shown = treeFooterShown @@ -3586,7 +3587,7 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) DrawString(colWidth - 10, drawY, "RIGHT", 14, "VAR", tradeHelpers.getSlotDiffLabel(pItem, cItem)) if cItem then - local b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H = tradeHelpers.drawCopyButtons(cursorX, cursorY, vp.width - 196, drawY + 1, slotMissing, LAYOUT.itemsCopyBtnW, LAYOUT.itemsCopyBtnH, LAYOUT.itemsBuyBtnW) + local b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H = tradeHelpers.drawCopyButtons(cursorX, cursorY, vp.width - 214, drawY + 1, slotMissing, LAYOUT.itemsCopyBtnW, LAYOUT.itemsCopyBtnH, LAYOUT.itemsBuyBtnW, LAYOUT.itemsCopyUseBtnW) processSlotButtons(b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H, cItem, copySlotName, copyUseSlotName) end @@ -3610,7 +3611,7 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) tradeHelpers.drawCompactSlotRow(drawY, label, pItem, cItem, colWidth, cursorX, cursorY, labelW, self.primaryBuild.itemsTab, compareEntry.itemsTab, pWarn, cWarn, slotMissing, - LAYOUT.itemsCopyBtnW, LAYOUT.itemsCopyBtnH, LAYOUT.itemsBuyBtnW) + LAYOUT.itemsCopyBtnW, LAYOUT.itemsCopyBtnH, LAYOUT.itemsBuyBtnW, LAYOUT.itemsCopyUseBtnW) if rowHoverItem then hoverItem = rowHoverItem diff --git a/src/Classes/CompareTradeHelpers.lua b/src/Classes/CompareTradeHelpers.lua index 15226d13d9..b967032160 100644 --- a/src/Classes/CompareTradeHelpers.lua +++ b/src/Classes/CompareTradeHelpers.lua @@ -236,53 +236,62 @@ end -- btnStartX is the left edge where the first button (Buy) should appear. -- copyBtnW, copyBtnH, buyBtnW are button dimensions (passed from LAYOUT by caller). -- Returns copyHovered, copyUseHovered, buyHovered booleans. -function M.drawCopyButtons(cursorX, cursorY, btnStartX, btnY, slotMissing, copyBtnW, copyBtnH, buyBtnW) - local btnW = copyBtnW - local btnH = copyBtnH - local buyW = buyBtnW +function M.drawCopyButtons(cursorX, cursorY, btnStartX, btnY, slotMissing, copyBtnW, copyBtnH, buyBtnW, copyUseBtnW) + local btnW = copyBtnW + local btnH = copyBtnH + local buyW = buyBtnW + local copyUseW = copyUseBtnW local btn3X = btnStartX local btn1X = btn3X + buyW + 4 local btn2X = btn1X + btnW + 4 + local function drawBtn(x, w, hover, label) + local pressed = hover and IsKeyDown("LEFTBUTTON") + -- Outer border + if hover then + SetDrawColor(1, 1, 1) + else + SetDrawColor(0.5, 0.5, 0.5) + end + DrawImage(nil, x, btnY, w, btnH) + -- Inner fill + if pressed then + SetDrawColor(0.5, 0.5, 0.5) + elseif hover then + SetDrawColor(0.33, 0.33, 0.33) + else + SetDrawColor(0, 0, 0) + end + DrawImage(nil, x + 1, btnY + 1, w - 2, btnH - 2) + -- Label + SetDrawColor(1, 1, 1) + DrawString(x + w / 2, btnY + 1, "CENTER_X", 14, "VAR", label) + end + -- "Buy" button local b3Hover = cursorX >= btn3X and cursorX < btn3X + buyW and cursorY >= btnY and cursorY < btnY + btnH - SetDrawColor(b3Hover and 0.5 or 0.35, b3Hover and 0.5 or 0.35, b3Hover and 0.5 or 0.35) - DrawImage(nil, btn3X, btnY, buyW, btnH) - SetDrawColor(0.1, 0.1, 0.1) - DrawImage(nil, btn3X + 1, btnY + 1, buyW - 2, btnH - 2) - SetDrawColor(1, 1, 1) - DrawString(btn3X + buyW / 2, btnY + 1, "CENTER_X", 14, "VAR", "^7Buy") + drawBtn(btn3X, buyW, b3Hover, "^7Buy") -- "Copy" button local b1Hover = cursorX >= btn1X and cursorX < btn1X + btnW and cursorY >= btnY and cursorY < btnY + btnH - SetDrawColor(b1Hover and 0.5 or 0.35, b1Hover and 0.5 or 0.35, b1Hover and 0.5 or 0.35) - DrawImage(nil, btn1X, btnY, btnW, btnH) - SetDrawColor(0.1, 0.1, 0.1) - DrawImage(nil, btn1X + 1, btnY + 1, btnW - 2, btnH - 2) - SetDrawColor(1, 1, 1) - DrawString(btn1X + btnW / 2, btnY + 1, "CENTER_X", 14, "VAR", "^7Copy") + drawBtn(btn1X, btnW, b1Hover, "^7Copy") local b2Hover if slotMissing then -- Show "Missing slot" label instead of Copy+Use button SetDrawColor(1, 1, 1) - DrawString(btn2X + btnW / 2, btnY + 1, "CENTER_X", 14, "VAR", "^xBBBBBBMissing slot") + DrawString(btn2X + copyUseW / 2, btnY + 1, "CENTER_X", 14, "VAR", "^xBBBBBBMissing slot") b2Hover = false else -- "Copy+Use" button - b2Hover = cursorX >= btn2X and cursorX < btn2X + btnW + b2Hover = cursorX >= btn2X and cursorX < btn2X + copyUseW and cursorY >= btnY and cursorY < btnY + btnH - SetDrawColor(b2Hover and 0.5 or 0.35, b2Hover and 0.5 or 0.35, b2Hover and 0.5 or 0.35) - DrawImage(nil, btn2X, btnY, btnW, btnH) - SetDrawColor(0.1, 0.1, 0.1) - DrawImage(nil, btn2X + 1, btnY + 1, btnW - 2, btnH - 2) - SetDrawColor(1, 1, 1) - DrawString(btn2X + btnW / 2, btnY + 1, "CENTER_X", 14, "VAR", "^7Copy+Use") + drawBtn(btn2X, copyUseW, b2Hover, "^7Copy+Use") end - return b1Hover, b2Hover, b3Hover, btn2X, btnY, btnW, btnH + return b1Hover, b2Hover, b3Hover, btn2X, btnY, copyUseW, btnH end -- Helper: fit a colored item name within maxW pixels, truncating with "..." if needed. @@ -311,7 +320,7 @@ local ITEM_BOX_H = 20 function M.drawCompactSlotRow(drawY, slotLabel, pItem, cItem, colWidth, cursorX, cursorY, maxLabelW, primaryItemsTab, compareItemsTab, pWarn, cWarn, slotMissing, - copyBtnW, copyBtnH, buyBtnW) + copyBtnW, copyBtnH, buyBtnW, copyUseBtnW) local pName = pItem and pItem.name or "(empty)" local cName = cItem and cItem.name or "(empty)" @@ -368,7 +377,7 @@ function M.drawCompactSlotRow(drawY, slotLabel, pItem, cItem, if cItem then local btnStartX = cBoxX + cBoxW + 6 b1Hover, b2Hover, b3Hover, b2X, b2Y, b2W, b2H = - M.drawCopyButtons(cursorX, cursorY, btnStartX, drawY + 1, slotMissing, copyBtnW, copyBtnH, buyBtnW) + M.drawCopyButtons(cursorX, cursorY, btnStartX, drawY + 1, slotMissing, copyBtnW, copyBtnH, buyBtnW, copyUseBtnW) end -- Determine hovered item and tooltip anchor position From 0ee2c7542e8efba568ad9a61b0381fb668ad1268 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Fri, 17 Apr 2026 02:08:58 +0200 Subject: [PATCH 48/59] filter out same and non-impacting items in power report --- src/Classes/CompareTab.lua | 161 ++++++++++++++++++++----------------- 1 file changed, 87 insertions(+), 74 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 2385b73d5e..2fecb3d34a 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -2539,8 +2539,9 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories -- Helper to format an impact value and compute percentage local function formatImpact(impact) local displayVal = impact * ((displayStat.pc or displayStat.mod) and 100 or 1) - local numStr = s_format("%" .. displayStat.fmt, displayVal) - numStr = formatNumSep(numStr) + local rawNumStr = s_format("%" .. displayStat.fmt, displayVal) + local isZero = (tonumber(rawNumStr) == 0) + local numStr = formatNumSep(rawNumStr) -- Determine color local isPositive = (displayVal > 0 and not displayStat.lowerIsBetter) or (displayVal < 0 and displayStat.lowerIsBetter) @@ -2562,7 +2563,7 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories combinedStr = str .. " ^7(" .. color .. pctStr .. "^7)" end - return str, displayVal, combinedStr, percent + return str, displayVal, combinedStr, percent, isZero end -- ========================================== @@ -2592,23 +2593,25 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories if pathDist == 0 then pathDist = 1 end end local perPoint = impact / pathDist - local impactStr, impactVal, combinedImpactStr, impactPercent = formatImpact(impact) + local impactStr, impactVal, combinedImpactStr, impactPercent, impactIsZero = formatImpact(impact) local perPointStr = formatImpact(perPoint) - t_insert(results, { - category = "Tree", - categoryColor = "^7", - nameColor = "^7", - name = pNode.dn, - nodeId = nodeId, - impact = impactVal, - impactStr = impactStr, - impactPercent = impactPercent, - combinedImpactStr = combinedImpactStr, - pathDist = pathDist, - perPoint = perPoint * ((displayStat.pc or displayStat.mod) and 100 or 1), - perPointStr = perPointStr, - }) + if not impactIsZero then + t_insert(results, { + category = "Tree", + categoryColor = "^7", + nameColor = "^7", + name = pNode.dn, + nodeId = nodeId, + impact = impactVal, + impactStr = impactStr, + impactPercent = impactPercent, + combinedImpactStr = combinedImpactStr, + pathDist = pathDist, + perPoint = perPoint * ((displayStat.pc or displayStat.mod) and 100 or 1), + perPointStr = perPointStr, + }) + end processed = processed + 1 if coroutine.running() and GetTime() - start > 100 then @@ -2632,31 +2635,35 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories for _, slotName in ipairs(baseSlots) do local cSlot = compareEntry.itemsTab and compareEntry.itemsTab.slots[slotName] local cItem = cSlot and compareEntry.itemsTab.items[cSlot.selItemId] - if cItem and cItem.raw then + local pSlot = self.primaryBuild.itemsTab and self.primaryBuild.itemsTab.slots[slotName] + local pItem = pSlot and self.primaryBuild.itemsTab.items[pSlot.selItemId] + if cItem and cItem.raw and not (pItem and pItem.name == cItem.name) then local newItem = new("Item", cItem.raw) newItem:NormaliseQuality() local output = calcFunc({ repSlotName = slotName, repItem = newItem }, useFullDPS) local impact = self:CalculatePowerStat(powerStat, output, calcBase) - local impactStr, impactVal, combinedImpactStr, impactPercent = formatImpact(impact) - - -- Get rarity color for item name - local rarityColor = colorCodes[cItem.rarity] or colorCodes.NORMAL - - t_insert(results, { - category = "Item", - categoryColor = rarityColor, - nameColor = rarityColor, - name = (cItem.name or "Unknown") .. ", " .. slotName, - itemObj = newItem, - slotName = slotName, - impact = impactVal, - impactStr = impactStr, - impactPercent = impactPercent, - combinedImpactStr = combinedImpactStr, - pathDist = nil, - perPoint = nil, - perPointStr = nil, - }) + local impactStr, impactVal, combinedImpactStr, impactPercent, impactIsZero = formatImpact(impact) + + if not impactIsZero then + -- Get rarity color for item name + local rarityColor = colorCodes[cItem.rarity] or colorCodes.NORMAL + + t_insert(results, { + category = "Item", + categoryColor = rarityColor, + nameColor = rarityColor, + name = (cItem.name or "Unknown") .. ", " .. slotName, + itemObj = newItem, + slotName = slotName, + impact = impactVal, + impactStr = impactStr, + impactPercent = impactPercent, + combinedImpactStr = combinedImpactStr, + pathDist = nil, + perPoint = nil, + perPointStr = nil, + }) + end end processed = processed + 1 if coroutine.running() and GetTime() - start > 100 then @@ -2692,7 +2699,7 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories local jewelSlots = self:GetJewelComparisonSlots(compareEntry) for _, jEntry in ipairs(jewelSlots) do - if jEntry.cItem and jEntry.cItem.raw then + if jEntry.cItem and jEntry.cItem.raw and not (jEntry.pItem and jEntry.pItem.name == jEntry.cItem.name) then local newItem = new("Item", jEntry.cItem.raw) newItem:NormaliseQuality() @@ -2722,7 +2729,8 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories end if bestImpactVal ~= nil then - local impactStr, impactVal, combinedImpactStr, impactPercent = formatImpact(bestImpactVal) + local impactStr, impactVal, combinedImpactStr, impactPercent, impactIsZero = formatImpact(bestImpactVal) + if not impactIsZero then local rarityColor = colorCodes[jEntry.cItem.rarity] or colorCodes.NORMAL t_insert(results, { @@ -2739,6 +2747,7 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories perPoint = nil, perPointStr = nil, }) + end end end processed = processed + 1 @@ -2784,22 +2793,24 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories ConPrintf("Compare power (gem): %s", tostring(gemCalcFunc)) else local impact = self:CalculatePowerStat(powerStat, gemCalcBase, calcBase) - local impactStr, impactVal, combinedImpactStr, impactPercent = formatImpact(impact) - local label = self:GetSocketGroupLabel(cGroup) - - t_insert(results, { - category = "Skill gem", - categoryColor = colorCodes.GEM, - nameColor = colorCodes.GEM, - name = label, - impact = impactVal, - impactStr = impactStr, - impactPercent = impactPercent, - combinedImpactStr = combinedImpactStr, - pathDist = nil, - perPoint = nil, - perPointStr = nil, - }) + local impactStr, impactVal, combinedImpactStr, impactPercent, impactIsZero = formatImpact(impact) + if not impactIsZero then + local label = self:GetSocketGroupLabel(cGroup) + + t_insert(results, { + category = "Skill gem", + categoryColor = colorCodes.GEM, + nameColor = colorCodes.GEM, + name = label, + impact = impactVal, + impactStr = impactStr, + impactPercent = impactPercent, + combinedImpactStr = combinedImpactStr, + pathDist = nil, + perPoint = nil, + perPointStr = nil, + }) + end end end processed = processed + 1 @@ -2864,21 +2875,23 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories ConPrintf("Compare power (support gem): %s", tostring(sgCalcFunc)) else local impact = self:CalculatePowerStat(powerStat, sgCalcBase, calcBase) - local impactStr, impactVal, combinedImpactStr, impactPercent = formatImpact(impact) - - t_insert(results, { - category = "Support gem", - categoryColor = colorCodes.GEM, - nameColor = colorCodes.GEM, - name = name, - impact = impactVal, - impactStr = impactStr, - impactPercent = impactPercent, - combinedImpactStr = combinedImpactStr, - pathDist = nil, - perPoint = nil, - perPointStr = nil, - }) + local impactStr, impactVal, combinedImpactStr, impactPercent, impactIsZero = formatImpact(impact) + + if not impactIsZero then + t_insert(results, { + category = "Support gem", + categoryColor = colorCodes.GEM, + nameColor = colorCodes.GEM, + name = name, + impact = impactVal, + impactStr = impactStr, + impactPercent = impactPercent, + combinedImpactStr = combinedImpactStr, + pathDist = nil, + perPoint = nil, + perPointStr = nil, + }) + end end processed = processed + 1 if coroutine.running() and GetTime() - start > 100 then @@ -2933,10 +2946,10 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories ConPrintf("Compare power (config): %s", tostring(cfgCalcFunc)) else local impact = self:CalculatePowerStat(powerStat, cfgCalcBase, calcBase) - local impactStr, impactVal, combinedImpactStr, impactPercent = formatImpact(impact) + local impactStr, impactVal, combinedImpactStr, impactPercent, impactIsZero = formatImpact(impact) -- Only include configs with non-zero impact - if impactVal ~= 0 then + if not impactIsZero then -- Build display name with value change description local displayName = varData.label or varData.var displayName = displayName:gsub(":$", "") From dab360979df2ab378f92a7f8cc84c3453177a378 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Fri, 17 Apr 2026 02:46:55 +0200 Subject: [PATCH 49/59] add scrollbar to calcs tab --- src/Classes/CompareTab.lua | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 2fecb3d34a..15206bf530 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -995,6 +995,13 @@ function CompareTabClass:InitControls() self.controls.comparePowerReportList = new("ComparePowerReportListControl", nil, {0, 0, 750, 250}) self.controls.comparePowerReportList.compareTab = self self.controls.comparePowerReportList.shown = powerReportShown + + -- Scrollbar for Calcs sub-tab + self.controls.calcsScrollBar = new("ScrollBarControl", nil, {0, 0, 18, 0}, 50, "VERTICAL", true) + local calcsScrollBar = self.controls.calcsScrollBar + self.controls.calcsScrollBar.shown = function() + return self.compareViewMode == "CALCS" and self:GetActiveCompare() ~= nil and calcsScrollBar.enabled + end end -- Get a short display name from a build name (strips "AccountName - " prefix) @@ -2336,7 +2343,15 @@ function CompareTabClass:HandleScrollInput(contentVP, inputEvents) for id, event in ipairs(inputEvents) do if event.type == "KeyDown" and mouseInContent and not mouseOverList then - if event.key == "WHEELUP" and self.compareViewMode ~= "TREE" then + if self.compareViewMode == "CALCS" then + if event.key == "WHEELUP" then + self.controls.calcsScrollBar:Scroll(-1) + inputEvents[id] = nil + elseif event.key == "WHEELDOWN" then + self.controls.calcsScrollBar:Scroll(1) + inputEvents[id] = nil + end + elseif event.key == "WHEELUP" and self.compareViewMode ~= "TREE" then self.scrollY = m_max(self.scrollY - 40, 0) inputEvents[id] = nil elseif event.key == "WHEELDOWN" and self.compareViewMode ~= "TREE" then @@ -4262,9 +4277,13 @@ function CompareTabClass:DrawCalcs(vp, compareEntry) self:DrawCalcsSkillHeader(vp, compareEntry, skillHeaderHeight, primaryEnv, compareEnv) end + -- Reserve space on the right for the scrollbar + local scrollBarWidth = 20 + local gridWidth = vp.width - scrollBarWidth + -- Card dimensions -- Layout: [2px border | 130px label | 2px gap | 2px sep | valW | 2px sep | valW | 2px border] - local cardWidth = m_min(LAYOUT.calcsMaxCardWidth, vp.width - 16) + local cardWidth = m_min(LAYOUT.calcsMaxCardWidth, gridWidth - 16) local labelWidth = LAYOUT.calcsLabelWidth local sepW = LAYOUT.calcsSepW local valColWidth = m_floor((cardWidth - 140) / 2) @@ -4272,7 +4291,7 @@ function CompareTabClass:DrawCalcs(vp, compareEntry) local valCol2X = valCol1X + valColWidth + sepW -- Layout parameters - local maxCol = m_max(1, m_floor(vp.width / (cardWidth + 8))) + local maxCol = m_max(1, m_floor(gridWidth / (cardWidth + 8))) local baseX = 4 local headerBarHeight = LAYOUT.calcsHeaderBarHeight local baseY = headerBarHeight @@ -4337,8 +4356,15 @@ function CompareTabClass:DrawCalcs(vp, compareEntry) maxY = m_max(maxY, colY[col]) end + -- Position scrollbar and set content dimensions based on laid-out content + local scrollBar = self.controls.calcsScrollBar + scrollBar.x = vp.x + vp.width - 18 + scrollBar.y = vp.y + skillHeaderHeight + scrollBar.height = vp.height - skillHeaderHeight + scrollBar:SetContentDimension(maxY + 26, vp.height - skillHeaderHeight) + -- Set viewport for scroll clipping, offset below skill header so cards can't bleed into it - SetViewport(vp.x, vp.y + skillHeaderHeight, vp.width, vp.height - skillHeaderHeight) + SetViewport(vp.x, vp.y + skillHeaderHeight, gridWidth, vp.height - skillHeaderHeight) -- Cursor position relative to viewport (for hover detection) local cursorX, cursorY = GetCursorPos() @@ -4351,7 +4377,7 @@ function CompareTabClass:DrawCalcs(vp, compareEntry) -- Draw section cards for _, sec in ipairs(sections) do local x = sec.drawX - local y = sec.drawY - self.scrollY + local y = sec.drawY - scrollBar.offset -- Skip if entirely off-screen if y + sec.height >= 0 and y < vp.height then From 3307d9266a32aec5f1a3bd1449293e1b0847f6bd Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Fri, 17 Apr 2026 03:43:14 +0200 Subject: [PATCH 50/59] properly handle minion damage calculation --- src/Classes/CompareTab.lua | 75 ++++++++++++++++++++++++++++++-------- 1 file changed, 60 insertions(+), 15 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 15206bf530..931bdb00cb 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -517,6 +517,12 @@ function CompareTabClass:InitControls() end) self.controls.primCalcsMineCount.shown = false + self.controls.primCalcsShowMinion = new("CheckBoxControl", nil, {0, 0, 18}, nil, function(state) + self.primaryBuild.calcsTab.input.showMinion = state + self.primaryBuild.buildFlag = true + end, "Show stats for the minion instead of the player.") + self.controls.primCalcsShowMinion.shown = false + self.controls.primCalcsMinion = new("DropDownControl", nil, {0, 0, 140, 18}, {}, function(index, value) local mainSocketGroup = self.primaryBuild.skillsTab.socketGroupList[self.primaryBuild.calcsTab.input.skill_number] if mainSocketGroup then @@ -633,6 +639,16 @@ function CompareTabClass:InitControls() end) self.controls.cmpCalcsMineCount.shown = false + self.controls.cmpCalcsShowMinion = new("CheckBoxControl", nil, {0, 0, 18}, nil, function(state) + local entry = self:GetActiveCompare() + if entry then + entry.calcsTab.input.showMinion = state + entry.buildFlag = true + self.modFlag = true + end + end, "Show stats for the minion instead of the player.") + self.controls.cmpCalcsShowMinion.shown = false + self.controls.cmpCalcsMinion = new("DropDownControl", nil, {0, 0, 140, 18}, {}, function(index, value) local entry = self:GetActiveCompare() if entry then @@ -2178,6 +2194,8 @@ function CompareTabClass:RefreshCalcsSkillControls(compareEntry) self.controls.primCalcsSocketGroup.shown = true self.controls.primCalcsMode.shown = true self.controls.primCalcsMode:SelByValue(self.primaryBuild.calcsTab.input.misc_buffMode, "buffMode") + self.controls.primCalcsShowMinion.shown = self.controls.primCalcsMinion.shown == true + self.controls.primCalcsShowMinion.state = self.primaryBuild.calcsTab.input.showMinion and true or false local cmpControls = { mainSocketGroup = self.controls.cmpCalcsSocketGroup, @@ -2193,15 +2211,17 @@ function CompareTabClass:RefreshCalcsSkillControls(compareEntry) self.controls.cmpCalcsSocketGroup.shown = true self.controls.cmpCalcsMode.shown = true self.controls.cmpCalcsMode:SelByValue(compareEntry.calcsTab.input.misc_buffMode, "buffMode") + self.controls.cmpCalcsShowMinion.shown = self.controls.cmpCalcsMinion.shown == true + self.controls.cmpCalcsShowMinion.state = compareEntry.calcsTab.input.showMinion and true or false -- Wrap .shown booleans set by RefreshSkillSelectControls with a view-mode gate, -- so controls auto-hide when not in CALCS mode (matching configShown pattern) local calcsControlNames = { "primCalcsSocketGroup", "primCalcsMainSkill", "primCalcsSkillPart", - "primCalcsStageCount", "primCalcsMineCount", "primCalcsMinion", + "primCalcsStageCount", "primCalcsMineCount", "primCalcsShowMinion", "primCalcsMinion", "primCalcsMinionSkill", "primCalcsMode", "cmpCalcsSocketGroup", "cmpCalcsMainSkill", "cmpCalcsSkillPart", - "cmpCalcsStageCount", "cmpCalcsMineCount", "cmpCalcsMinion", + "cmpCalcsStageCount", "cmpCalcsMineCount", "cmpCalcsShowMinion", "cmpCalcsMinion", "cmpCalcsMinionSkill", "cmpCalcsMode", } for _, name in ipairs(calcsControlNames) do @@ -2225,7 +2245,7 @@ function CompareTabClass:LayoutCalcsSkillControls(vp, compareEntry) local colWidth = m_floor((vp.width - 20) / 2) local leftX = vp.x + 4 local rightX = leftX + colWidth + 12 - local labelW = 100 + local labelW = 140 local controlW = colWidth - labelW - 8 local rowH = 22 local y = vp.y + 4 @@ -2289,6 +2309,14 @@ function CompareTabClass:LayoutCalcsSkillControls(vp, compareEntry) rightY = rightY + rowH end + -- Show Minion Stats + if layoutRow(self.controls.primCalcsShowMinion, leftX, leftY) then + leftY = leftY + rowH + end + if layoutRow(self.controls.cmpCalcsShowMinion, rightX, rightY) then + rightY = rightY + rowH + end + -- Minion if layoutRow(self.controls.primCalcsMinion, leftX, leftY, controlW) then leftY = leftY + rowH @@ -3038,8 +3066,20 @@ end -- SUMMARY VIEW -- ============================================================ function CompareTabClass:DrawSummary(vp, compareEntry) - local primaryOutput = self.primaryBuild.calcsTab.mainOutput - local compareOutput = compareEntry:GetOutput() + local primaryCalcs = self.primaryBuild.calcsTab + local compareCalcs = compareEntry.calcsTab + local primaryEnvMain = primaryCalcs and primaryCalcs.mainEnv + local compareEnvMain = compareCalcs and compareCalcs.mainEnv + + -- If each selected builds skill is a minion skill, use it + local primaryMinionSkill = primaryEnvMain and primaryEnvMain.player and primaryEnvMain.player.mainSkill + and primaryEnvMain.player.mainSkill.minion and primaryEnvMain.minion + local compareMinionSkill = compareEnvMain and compareEnvMain.player and compareEnvMain.player.mainSkill + and compareEnvMain.player.mainSkill.minion and compareEnvMain.minion + local summaryUseMinion = primaryMinionSkill or compareMinionSkill + + local primaryOutput = primaryMinionSkill and primaryEnvMain.minion.output or primaryCalcs.mainOutput + local compareOutput = compareMinionSkill and compareEnvMain.minion.output or compareEntry:GetOutput() if not primaryOutput or not compareOutput then return end @@ -3079,11 +3119,11 @@ function CompareTabClass:DrawSummary(vp, compareEntry) drawY = drawY + 6 -- Stat comparison - local displayStats = self.primaryBuild.displayStats - local primaryEnv = self.primaryBuild.calcsTab.mainEnv - local compareEnv = compareEntry.calcsTab.mainEnv + local displayStats = summaryUseMinion and self.primaryBuild.minionDisplayStats or self.primaryBuild.displayStats + local primaryActor = primaryMinionSkill and primaryEnvMain.minion or primaryEnvMain.player + local compareActor = compareMinionSkill and compareEnvMain.minion or compareEnvMain.player - drawY = self:DrawStatList(drawY, displayStats, primaryOutput, compareOutput, primaryEnv, compareEnv, col1, col4, col2R, col3R) + drawY = self:DrawStatList(drawY, displayStats, primaryOutput, compareOutput, primaryActor, compareActor, col1, col4, col2R, col3R) -- ======================================== -- Compare Power Report section @@ -3173,12 +3213,13 @@ function CompareTabClass:DrawSummary(vp, compareEntry) end -function CompareTabClass:DrawStatList(drawY, displayStats, primaryOutput, compareOutput, primaryEnv, compareEnv, col1, col4, col2R, col3R) +function CompareTabClass:DrawStatList(drawY, displayStats, primaryOutput, compareOutput, primaryActor, compareActor, col1, col4, col2R, col3R) local lineHeight = 16 - -- Get skill flags from both builds for stat filtering - local primaryFlags = primaryEnv and primaryEnv.player and primaryEnv.player.mainSkill and primaryEnv.player.mainSkill.skillFlags or {} - local compareFlags = compareEnv and compareEnv.player and compareEnv.player.mainSkill and compareEnv.player.mainSkill.skillFlags or {} + -- Get skill flags from each build's selected actor (player, or minion when the + -- top-section "Skill:" is a minion skill) for stat filtering + local primaryFlags = primaryActor and primaryActor.mainSkill and primaryActor.mainSkill.skillFlags or {} + local compareFlags = compareActor and compareActor.mainSkill and compareActor.mainSkill.skillFlags or {} for _, statData in ipairs(displayStats) do if not statData.stat and not statData.label then @@ -4194,6 +4235,10 @@ function CompareTabClass:DrawCalcsSkillHeader(vp, compareEntry, headerHeight, pr if drawLabel("Mines", leftX, leftY, self.controls.primCalcsMineCount) then leftY = leftY + rowH end if drawLabel("Mines", rightX, rightY, self.controls.cmpCalcsMineCount) then rightY = rightY + rowH end + -- Show Minion Stats + if drawLabel("Show Minion Stats", leftX, leftY, self.controls.primCalcsShowMinion) then leftY = leftY + rowH end + if drawLabel("Show Minion Stats", rightX, rightY, self.controls.cmpCalcsShowMinion) then rightY = rightY + rowH end + -- Minion if drawLabel("Minion", leftX, leftY, self.controls.primCalcsMinion) then leftY = leftY + rowH end if drawLabel("Minion", rightX, rightY, self.controls.cmpCalcsMinion) then rightY = rightY + rowH end @@ -4265,8 +4310,8 @@ function CompareTabClass:DrawCalcs(vp, compareEntry) local primaryEnv = self.primaryBuild.calcsTab.calcsEnv local compareEnv = compareEntry.calcsTab and compareEntry.calcsTab.calcsEnv if not primaryEnv or not compareEnv then return end - local primaryActor = primaryEnv.player - local compareActor = compareEnv.player + local primaryActor = (self.primaryBuild.calcsTab.input.showMinion and primaryEnv.minion) or primaryEnv.player + local compareActor = (compareEntry.calcsTab.input.showMinion and compareEnv.minion) or compareEnv.player if not primaryActor or not compareActor then return end -- Skill detail header height From d8a169cc3abd759cfd58bc774094b9a2bac8c93d Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Fri, 17 Apr 2026 03:58:13 +0200 Subject: [PATCH 51/59] cleanup controls layout --- src/Classes/CompareTab.lua | 85 +++++++++----------------------------- 1 file changed, 19 insertions(+), 66 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 931bdb00cb..a59912a26a 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -2271,74 +2271,27 @@ function CompareTabClass:LayoutCalcsSkillControls(vp, compareEntry) leftY = leftY + rowH rightY = rightY + rowH - -- Socket Group - layoutRow(self.controls.primCalcsSocketGroup, leftX, leftY, controlW) - layoutRow(self.controls.cmpCalcsSocketGroup, rightX, rightY, controlW) - leftY = leftY + rowH - rightY = rightY + rowH - - -- Active Skill - if layoutRow(self.controls.primCalcsMainSkill, leftX, leftY, controlW) then - leftY = leftY + rowH - end - if layoutRow(self.controls.cmpCalcsMainSkill, rightX, rightY, controlW) then - rightY = rightY + rowH - end - - -- Skill Part - if layoutRow(self.controls.primCalcsSkillPart, leftX, leftY, controlW) then - leftY = leftY + rowH - end - if layoutRow(self.controls.cmpCalcsSkillPart, rightX, rightY, controlW) then - rightY = rightY + rowH - end - - -- Stage Count - if layoutRow(self.controls.primCalcsStageCount, leftX, leftY) then - leftY = leftY + rowH - end - if layoutRow(self.controls.cmpCalcsStageCount, rightX, rightY) then - rightY = rightY + rowH - end - - -- Mine Count - if layoutRow(self.controls.primCalcsMineCount, leftX, leftY) then - leftY = leftY + rowH - end - if layoutRow(self.controls.cmpCalcsMineCount, rightX, rightY) then - rightY = rightY + rowH - end - - -- Show Minion Stats - if layoutRow(self.controls.primCalcsShowMinion, leftX, leftY) then - leftY = leftY + rowH - end - if layoutRow(self.controls.cmpCalcsShowMinion, rightX, rightY) then - rightY = rightY + rowH - end - - -- Minion - if layoutRow(self.controls.primCalcsMinion, leftX, leftY, controlW) then - leftY = leftY + rowH - end - if layoutRow(self.controls.cmpCalcsMinion, rightX, rightY, controlW) then - rightY = rightY + rowH - end - - -- Minion Skill - if layoutRow(self.controls.primCalcsMinionSkill, leftX, leftY, controlW) then - leftY = leftY + rowH - end - if layoutRow(self.controls.cmpCalcsMinionSkill, rightX, rightY, controlW) then - rightY = rightY + rowH + -- { suffix, useControlW, alwaysAdvance } + local calcsRows = { + { "SocketGroup", true, true }, + { "MainSkill", true, false }, + { "SkillPart", true, false }, + { "StageCount", false, false }, + { "MineCount", false, false }, + { "ShowMinion", false, false }, + { "Minion", true, false }, + { "MinionSkill", true, false }, + { "Mode", false, true }, + } + for _, row in ipairs(calcsRows) do + local suffix, useControlW, alwaysAdvance = row[1], row[2], row[3] + local width = useControlW and controlW or nil + local primShown = layoutRow(self.controls["primCalcs" .. suffix], leftX, leftY, width) + local cmpShown = layoutRow(self.controls["cmpCalcs" .. suffix], rightX, rightY, width) + if primShown or alwaysAdvance then leftY = leftY + rowH end + if cmpShown or alwaysAdvance then rightY = rightY + rowH end end - -- Calc Mode - layoutRow(self.controls.primCalcsMode, leftX, leftY) - layoutRow(self.controls.cmpCalcsMode, rightX, rightY) - leftY = leftY + rowH - rightY = rightY + rowH - -- Account for text info lines (Aura/Buffs, Combat Buffs, Curses) + separator local textLinesHeight = 2 -- padding before text local primaryEnv = self.primaryBuild.calcsTab and self.primaryBuild.calcsTab.calcsEnv From 89d99f2d5686554e638f8c9ac25353110d1609f0 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Fri, 17 Apr 2026 04:22:47 +0200 Subject: [PATCH 52/59] move common calc format code between CompareTab and CalcSectionControl to shared module --- src/Classes/CalcSectionControl.lua | 62 +----------------------- src/Classes/CompareTab.lua | 75 ++---------------------------- src/Modules/CalcFormat.lua | 74 +++++++++++++++++++++++++++++ src/Modules/Main.lua | 1 + 4 files changed, 81 insertions(+), 131 deletions(-) create mode 100644 src/Modules/CalcFormat.lua diff --git a/src/Classes/CalcSectionControl.lua b/src/Classes/CalcSectionControl.lua index bee372e91f..75be52c567 100644 --- a/src/Classes/CalcSectionControl.lua +++ b/src/Classes/CalcSectionControl.lua @@ -157,64 +157,6 @@ function CalcSectionClass:UpdatePos() end end -function CalcSectionClass:FormatVal(val, p) - return formatNumSep(tostring(round(val, p))) -end - -function CalcSectionClass:FormatStr(str, actor, colData) - str = str:gsub("{output:([%a%.:]+)}", function(c) - local ns, var = c:match("^(%a+)%.(%a+)$") - if ns then - return actor.output[ns] and actor.output[ns][var] or "" - else - return actor.output[c] or "" - end - end) - str = str:gsub("{(%d+):output:([%a%.:]+)}", function(p, c) - local ns, var = c:match("^(%a+)%.(%a+)$") - if ns then - return self:FormatVal(actor.output[ns] and actor.output[ns][var] or 0, tonumber(p)) - else - return self:FormatVal(actor.output[c] or 0, tonumber(p)) - end - end) - str = str:gsub("{(%d+):mod:([%d,]+)}", function(p, n) - local numList = { } - for num in n:gmatch("%d+") do - t_insert(numList, tonumber(num)) - end - local modType = colData[numList[1]].modType - local modTotal = modType == "MORE" and 1 or 0 - for _, num in ipairs(numList) do - local sectionData = colData[num] - local modCfg = (sectionData.cfg and actor.mainSkill[sectionData.cfg.."Cfg"]) or { } - if sectionData.modSource then - modCfg.source = sectionData.modSource - end - if sectionData.actor then - modCfg.actor = sectionData.actor - end - local modVal - local modStore = (sectionData.enemy and actor.enemy.modDB) or (sectionData.cfg and actor.mainSkill.skillModList) or actor.modDB - if type(sectionData.modName) == "table" then - modVal = modStore:Combine(sectionData.modType, modCfg, unpack(sectionData.modName)) - else - modVal = modStore:Combine(sectionData.modType, modCfg, sectionData.modName) - end - if modType == "MORE" then - modTotal = modTotal * modVal - else - modTotal = modTotal + modVal - end - end - if modType == "MORE" then - modTotal = (modTotal - 1) * 100 - end - return self:FormatVal(modTotal, tonumber(p)) - end) - return str -end - function CalcSectionClass:Draw(viewPort, noTooltip) local x, y = self:GetPos() local width, height = self:GetSize() @@ -245,7 +187,7 @@ function CalcSectionClass:Draw(viewPort, noTooltip) DrawString(x + 3, lineY + 3, "LEFT", 16, "VAR BOLD", textColor..subSec.label..":") if subSec.data.extra then local x = x + 3 + DrawStringWidth(16, "VAR BOLD", subSec.label) + 10 - DrawString(x, lineY + 3, "LEFT", 16, "VAR", "^7"..self:FormatStr(subSec.data.extra, actor)) + DrawString(x, lineY + 3, "LEFT", 16, "VAR", "^7"..formatCalcStr(subSec.data.extra, actor)) end end -- Draw line below label @@ -300,7 +242,7 @@ function CalcSectionClass:Draw(viewPort, noTooltip) end local textSize = rowData.textSize or 14 SetViewport(colData.x + 3, colData.y, colData.width - 4, colData.height) - DrawString(1, 9 - textSize/2, "LEFT", textSize, "VAR", "^7"..self:FormatStr(colData.format, actor, colData)) + DrawString(1, 9 - textSize/2, "LEFT", textSize, "VAR", "^7"..formatCalcStr(colData.format, actor, colData)) SetViewport() end end diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index a59912a26a..65c81cbeb5 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -1030,73 +1030,6 @@ function CompareTabClass:GetShortBuildName(fullName) return fullName end --- Format a numeric value with separator and rounding -function CompareTabClass:FormatVal(val, p) - return formatNumSep(tostring(round(val, p))) -end - --- Resolve format strings against an actor's output/modDB --- Handles: {output:Key}, {p:output:Key}, {p:mod:indices} -function CompareTabClass:FormatStr(str, actor, colData) - if not actor then return "" end - str = str:gsub("{output:([%a%.:]+)}", function(c) - local ns, var = c:match("^(%a+)%.(%a+)$") - if ns then - return actor.output[ns] and actor.output[ns][var] or "" - else - return actor.output[c] or "" - end - end) - str = str:gsub("{(%d+):output:([%a%.:]+)}", function(p, c) - local ns, var = c:match("^(%a+)%.(%a+)$") - if ns then - return self:FormatVal(actor.output[ns] and actor.output[ns][var] or 0, tonumber(p)) - else - return self:FormatVal(actor.output[c] or 0, tonumber(p)) - end - end) - str = str:gsub("{(%d+):mod:([%d,]+)}", function(p, n) - local numList = { } - for num in n:gmatch("%d+") do - t_insert(numList, tonumber(num)) - end - if not colData[numList[1]] or not colData[numList[1]].modType then - return "?" - end - local modType = colData[numList[1]].modType - local modTotal = modType == "MORE" and 1 or 0 - for _, num in ipairs(numList) do - local sectionData = colData[num] - if not sectionData then break end - local modCfg = (sectionData.cfg and actor.mainSkill and actor.mainSkill[sectionData.cfg.."Cfg"]) or { } - if sectionData.modSource then - modCfg.source = sectionData.modSource - end - if sectionData.actor then - modCfg.actor = sectionData.actor - end - local modVal - local modStore = (sectionData.enemy and actor.enemy and actor.enemy.modDB) or (sectionData.cfg and actor.mainSkill and actor.mainSkill.skillModList) or actor.modDB - if not modStore then break end - if type(sectionData.modName) == "table" then - modVal = modStore:Combine(sectionData.modType, modCfg, unpack(sectionData.modName)) - else - modVal = modStore:Combine(sectionData.modType, modCfg, sectionData.modName) - end - if modType == "MORE" then - modTotal = modTotal * modVal - else - modTotal = modTotal + modVal - end - end - if modType == "MORE" then - modTotal = (modTotal - 1) * 100 - end - return self:FormatVal(modTotal, tonumber(p)) - end) - return str -end - -- Populate a set-selector dropdown from a tab's ordered set list. -- tab: the tab object (e.g. itemsTab, skillsTab, configTab) -- orderListField/setsField/activeIdField: string keys on tab @@ -4399,8 +4332,8 @@ function CompareTabClass:DrawCalcs(vp, compareEntry) if subSec.data and subSec.data.extra then local extraTextW = DrawStringWidth(16, "VAR BOLD", subSec.label .. ":") local extraX = x + 3 + extraTextW + 8 - local ok1, pExtra = pcall(self.FormatStr, self, subSec.data.extra, primaryActor) - local ok2, cExtra = pcall(self.FormatStr, self, subSec.data.extra, compareActor) + local ok1, pExtra = pcall(formatCalcStr, subSec.data.extra, primaryActor) + local ok2, cExtra = pcall(formatCalcStr, subSec.data.extra, compareActor) if ok1 and ok2 then DrawString(extraX, lineY + 3, "LEFT", 16, "VAR", colorCodes.POSITIVE .. pExtra .. " ^8| " .. colorCodes.WARNING .. cExtra) @@ -4452,7 +4385,7 @@ function CompareTabClass:DrawCalcs(vp, compareEntry) DrawImage(nil, x + valCol1X, lineY, valColWidth, 18) end if colData and colData.format then - local ok, str = pcall(self.FormatStr, self, colData.format, primaryActor, colData) + local ok, str = pcall(formatCalcStr, colData.format, primaryActor, colData) if ok and str then DrawString(x + valCol1X + 2, lineY + 9 - textSize / 2, "LEFT", textSize, "VAR", "^7" .. str) end @@ -4466,7 +4399,7 @@ function CompareTabClass:DrawCalcs(vp, compareEntry) DrawImage(nil, x + valCol2X, lineY, valColWidth, 18) end if colData and colData.format then - local ok, str = pcall(self.FormatStr, self, colData.format, compareActor, colData) + local ok, str = pcall(formatCalcStr, colData.format, compareActor, colData) if ok and str then DrawString(x + valCol2X + 2, lineY + 9 - textSize / 2, "LEFT", textSize, "VAR", "^7" .. str) end diff --git a/src/Modules/CalcFormat.lua b/src/Modules/CalcFormat.lua new file mode 100644 index 0000000000..e6349be0ed --- /dev/null +++ b/src/Modules/CalcFormat.lua @@ -0,0 +1,74 @@ +-- Path of Building +-- +-- Module: CalcFormat +-- Format helpers for calc section cells/labels. Resolves a small placeholder +-- language against an actor's output/modDB: +-- {output:Key}, {ns.var} variant -> actor.output value +-- {p:output:Key} -> rounded + thousand-separated +-- {p:mod:indices} -> combined mod total (INC/MORE/...) +-- +local t_insert = table.insert + +function formatCalcVal(val, p) + return formatNumSep(tostring(round(val, p))) +end + +function formatCalcStr(str, actor, colData) + if not actor then return "" end + str = str:gsub("{output:([%a%.:]+)}", function(c) + local ns, var = c:match("^(%a+)%.(%a+)$") + if ns then + return actor.output[ns] and actor.output[ns][var] or "" + else + return actor.output[c] or "" + end + end) + str = str:gsub("{(%d+):output:([%a%.:]+)}", function(p, c) + local ns, var = c:match("^(%a+)%.(%a+)$") + if ns then + return formatCalcVal(actor.output[ns] and actor.output[ns][var] or 0, tonumber(p)) + else + return formatCalcVal(actor.output[c] or 0, tonumber(p)) + end + end) + str = str:gsub("{(%d+):mod:([%d,]+)}", function(p, n) + local numList = { } + for num in n:gmatch("%d+") do + t_insert(numList, tonumber(num)) + end + if not colData[numList[1]] or not colData[numList[1]].modType then + return "?" + end + local modType = colData[numList[1]].modType + local modTotal = modType == "MORE" and 1 or 0 + for _, num in ipairs(numList) do + local sectionData = colData[num] + if not sectionData then break end + local modCfg = (sectionData.cfg and actor.mainSkill and actor.mainSkill[sectionData.cfg.."Cfg"]) or { } + if sectionData.modSource then + modCfg.source = sectionData.modSource + end + if sectionData.actor then + modCfg.actor = sectionData.actor + end + local modVal + local modStore = (sectionData.enemy and actor.enemy and actor.enemy.modDB) or (sectionData.cfg and actor.mainSkill and actor.mainSkill.skillModList) or actor.modDB + if not modStore then break end + if type(sectionData.modName) == "table" then + modVal = modStore:Combine(sectionData.modType, modCfg, unpack(sectionData.modName)) + else + modVal = modStore:Combine(sectionData.modType, modCfg, sectionData.modName) + end + if modType == "MORE" then + modTotal = modTotal * modVal + else + modTotal = modTotal + modVal + end + end + if modType == "MORE" then + modTotal = (modTotal - 1) * 100 + end + return formatCalcVal(modTotal, tonumber(p)) + end) + return str +end diff --git a/src/Modules/Main.lua b/src/Modules/Main.lua index 3d4ba4a323..3f6e19d74d 100644 --- a/src/Modules/Main.lua +++ b/src/Modules/Main.lua @@ -16,6 +16,7 @@ local m_pi = math.pi LoadModule("GameVersions") LoadModule("Modules/Common") +LoadModule("Modules/CalcFormat") LoadModule("Modules/Data") LoadModule("Modules/ModTools") LoadModule("Modules/ItemTools") From 17272244406634042c24626661ecb9fdf1492334 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Fri, 17 Apr 2026 04:44:25 +0200 Subject: [PATCH 53/59] use CheckFlag and CalculatePowerStat from CalcsTab --- src/Classes/CalcsTab.lua | 4 +- src/Classes/CompareTab.lua | 78 +++++--------------------------------- 2 files changed, 11 insertions(+), 71 deletions(-) diff --git a/src/Classes/CalcsTab.lua b/src/Classes/CalcsTab.lua index 464e728159..ce9303856d 100644 --- a/src/Classes/CalcsTab.lua +++ b/src/Classes/CalcsTab.lua @@ -372,8 +372,8 @@ function CalcsTabClass:SetDisplayStat(displayData, pin) self.controls.breakdown:SetBreakdownData(displayData, pin) end -function CalcsTabClass:CheckFlag(obj) - local actor = self.input.showMinion and self.calcsEnv.minion or self.calcsEnv.player +function CalcsTabClass:CheckFlag(obj, actor) + actor = actor or (self.input.showMinion and self.calcsEnv.minion or self.calcsEnv.player) local skillFlags = actor.mainSkill.skillFlags if obj.flag and not skillFlags[obj.flag] then return diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 65c81cbeb5..61133e0d46 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -1051,46 +1051,6 @@ function CompareTabClass:PopulateSetDropdown(tab, orderListField, setsField, act control:SetList(list) end --- Check visibility flags for a section/row against an actor -function CompareTabClass:CheckCalcFlag(obj, actor) - if not actor or not actor.mainSkill then return true end - local skillFlags = actor.mainSkill.skillFlags or {} - if obj.flag and not skillFlags[obj.flag] then - return false - end - if obj.flagList then - for _, flag in ipairs(obj.flagList) do - if not skillFlags[flag] then - return false - end - end - end - if obj.playerFlag and not skillFlags[obj.playerFlag] then - return false - end - if obj.notFlag and skillFlags[obj.notFlag] then - return false - end - if obj.notFlagList then - for _, flag in ipairs(obj.notFlagList) do - if skillFlags[flag] then - return false - end - end - end - if obj.haveOutput then - local ns, var = obj.haveOutput:match("^(%a+)%.(%a+)$") - if ns then - if not actor.output[ns] or not actor.output[ns][var] or actor.output[ns][var] == 0 then - return false - end - elseif not actor.output[obj.haveOutput] or actor.output[obj.haveOutput] == 0 then - return false - end - end - return true -end - -- Format a config value for read-only display function CompareTabClass:FormatConfigValue(varData, val) if val == nil then return "^8(not set)" end @@ -2287,26 +2247,6 @@ end -- COMPARE POWER REPORT -- ============================================================ --- Calculate the stat difference for a given power stat selection --- output: result from calcFunc (with the change applied) --- calcBase: baseline output (without the change) --- Returns positive value if the change improves the stat -function CompareTabClass:CalculatePowerStat(selection, output, calcBase) - local withChange = output - local baseline = calcBase - if baseline.Minion and not selection.stat == "FullDPS" then - withChange = withChange.Minion - baseline = baseline.Minion - end - local withValue = withChange[selection.stat] or 0 - local baseValue = baseline[selection.stat] or 0 - if selection.transform then - withValue = selection.transform(withValue) - baseValue = selection.transform(baseValue) - end - return withValue - baseValue -end - -- Resolve the granted effect for a gem instance function CompareTabClass:GetGemGrantedEffect(gem) if gem.gemData and gem.gemData.grantedEffect then @@ -2515,7 +2455,7 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories output = calcFunc({ addNodes = { [pNode] = true } }, useFullDPS) cache[pNode.modKey] = output end - local impact = self:CalculatePowerStat(powerStat, output, calcBase) + local impact = self.primaryBuild.calcsTab:CalculatePowerStat(powerStat, output, calcBase) local pathDist = pNode.pathDist or 0 if pathDist == 0 then pathDist = #(pNode.path or {}) @@ -2570,7 +2510,7 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories local newItem = new("Item", cItem.raw) newItem:NormaliseQuality() local output = calcFunc({ repSlotName = slotName, repItem = newItem }, useFullDPS) - local impact = self:CalculatePowerStat(powerStat, output, calcBase) + local impact = self.primaryBuild.calcsTab:CalculatePowerStat(powerStat, output, calcBase) local impactStr, impactVal, combinedImpactStr, impactPercent, impactIsZero = formatImpact(impact) if not impactIsZero then @@ -2638,7 +2578,7 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories if jEntry.pNodeAllocated then -- Socket is allocated in primary build, test directly in that socket local output = calcFunc({ repSlotName = jEntry.cSlotName, repItem = newItem }, useFullDPS) - bestImpactVal = self:CalculatePowerStat(powerStat, output, calcBase) + bestImpactVal = self.primaryBuild.calcsTab:CalculatePowerStat(powerStat, output, calcBase) else -- Socket is NOT allocated in primary build; try the jewel in every -- jewel socket on the primary build's tree, temporarily allocating @@ -2649,7 +2589,7 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories override.addNodes = { [socketInfo.node] = true } end local output = calcFunc(override, useFullDPS) - local impact = self:CalculatePowerStat(powerStat, output, calcBase) + local impact = self.primaryBuild.calcsTab:CalculatePowerStat(powerStat, output, calcBase) if bestImpactVal == nil or impact > bestImpactVal then bestImpactVal = impact bestSlotLabel = jEntry.label .. " (best socket)" @@ -2721,7 +2661,7 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories -- gemCalcFunc contains the error message on failure; skip this group ConPrintf("Compare power (gem): %s", tostring(gemCalcFunc)) else - local impact = self:CalculatePowerStat(powerStat, gemCalcBase, calcBase) + local impact = self.primaryBuild.calcsTab:CalculatePowerStat(powerStat, gemCalcBase, calcBase) local impactStr, impactVal, combinedImpactStr, impactPercent, impactIsZero = formatImpact(impact) if not impactIsZero then local label = self:GetSocketGroupLabel(cGroup) @@ -2803,7 +2743,7 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories if not ok then ConPrintf("Compare power (support gem): %s", tostring(sgCalcFunc)) else - local impact = self:CalculatePowerStat(powerStat, sgCalcBase, calcBase) + local impact = self.primaryBuild.calcsTab:CalculatePowerStat(powerStat, sgCalcBase, calcBase) local impactStr, impactVal, combinedImpactStr, impactPercent, impactIsZero = formatImpact(impact) if not impactIsZero then @@ -2874,7 +2814,7 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories -- cfgCalcFunc contains the error message on failure; skip this config ConPrintf("Compare power (config): %s", tostring(cfgCalcFunc)) else - local impact = self:CalculatePowerStat(powerStat, cfgCalcBase, calcBase) + local impact = self.primaryBuild.calcsTab:CalculatePowerStat(powerStat, cfgCalcBase, calcBase) local impactStr, impactVal, combinedImpactStr, impactPercent, impactIsZero = formatImpact(impact) -- Only include configs with non-zero impact @@ -4233,7 +4173,7 @@ function CompareTabClass:DrawCalcs(vp, compareEntry) local secWidth, id, group, colour, subSections = secDef[1], secDef[2], secDef[3], secDef[4], secDef[5] local secData = subSections[1].data -- Check section-level flags against primary actor - if self:CheckCalcFlag(secData, primaryActor) then + if self.primaryBuild.calcsTab:CheckFlag(secData, primaryActor) then local subSecInfo = {} local sectionHasRows = false for _, subSec in ipairs(subSections) do @@ -4241,7 +4181,7 @@ function CompareTabClass:DrawCalcs(vp, compareEntry) for _, rowData in ipairs(subSec.data) do -- Only include rows with a label and a first column with a format string if rowData.label and rowData[1] and rowData[1].format then - if self:CheckCalcFlag(rowData, primaryActor) or self:CheckCalcFlag(rowData, compareActor) then + if self.primaryBuild.calcsTab:CheckFlag(rowData, primaryActor) or self.primaryBuild.calcsTab:CheckFlag(rowData, compareActor) then t_insert(rows, rowData) end end From 8ac927cfa333e22faca6ae51592b9ccae0e907b1 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Fri, 17 Apr 2026 04:59:18 +0200 Subject: [PATCH 54/59] EXTRAct common trade category info between TradeQueryGenerator and CompareTradeHelpers --- src/Classes/CompareTradeHelpers.lua | 74 +++++++++++-------- src/Classes/TradeQueryGenerator.lua | 109 ++++------------------------ 2 files changed, 58 insertions(+), 125 deletions(-) diff --git a/src/Classes/CompareTradeHelpers.lua b/src/Classes/CompareTradeHelpers.lua index b967032160..a4b86358de 100644 --- a/src/Classes/CompareTradeHelpers.lua +++ b/src/Classes/CompareTradeHelpers.lua @@ -153,43 +153,53 @@ function M.findTradeModId(modLine, modType) return nil end --- Helper: map slot name + item type to trade API category string -function M.getTradeCategory(slotName, item) - if not item or not item.base then return nil end - local itemType = item.type or (item.base and item.base.type) +-- Map slot name + item type to (trade API category string, itemCategoryTags key). +-- queryStr: e.g. "armour.shield", "weapon.onemace" +-- categoryLabel: e.g. "Shield", "1HMace", "1HWeapon" (nil for flask / generic jewel / unsupported) +function M.getTradeCategoryInfo(slotName, item) + if not slotName then return nil, nil end + local itemType = item and (item.type or (item.base and item.base.type)) if slotName:find("^Weapon %d") then - if itemType == "Shield" then return "armour.shield" - elseif itemType == "Quiver" then return "armour.quiver" - elseif itemType == "Bow" then return "weapon.bow" - elseif itemType == "Staff" then return "weapon.staff" - elseif itemType == "Two Handed Sword" then return "weapon.twosword" - elseif itemType == "Two Handed Axe" then return "weapon.twoaxe" - elseif itemType == "Two Handed Mace" then return "weapon.twomace" - elseif itemType == "Fishing Rod" then return "weapon.rod" - elseif itemType == "One Handed Sword" then return "weapon.onesword" - elseif itemType == "One Handed Axe" then return "weapon.oneaxe" - elseif itemType == "One Handed Mace" or itemType == "Sceptre" then return "weapon.onemace" - elseif itemType == "Wand" then return "weapon.wand" - elseif itemType == "Dagger" then return "weapon.dagger" - elseif itemType == "Claw" then return "weapon.claw" - elseif itemType and itemType:find("Two Handed") then return "weapon.twomelee" - elseif itemType and itemType:find("One Handed") then return "weapon.one" - else return "weapon" + if not itemType then return "weapon.one", "1HWeapon" end + if itemType == "Shield" then return "armour.shield", "Shield" + elseif itemType == "Quiver" then return "armour.quiver", "Quiver" + elseif itemType == "Bow" then return "weapon.bow", "Bow" + elseif itemType == "Staff" then return "weapon.staff", "Staff" + elseif itemType == "Two Handed Sword" then return "weapon.twosword", "2HSword" + elseif itemType == "Two Handed Axe" then return "weapon.twoaxe", "2HAxe" + elseif itemType == "Two Handed Mace" then return "weapon.twomace", "2HMace" + elseif itemType == "Fishing Rod" then return "weapon.rod", "FishingRod" + elseif itemType == "One Handed Sword" then return "weapon.onesword", "1HSword" + elseif itemType == "One Handed Axe" then return "weapon.oneaxe", "1HAxe" + elseif itemType == "One Handed Mace" or itemType == "Sceptre" then return "weapon.onemace", "1HMace" + elseif itemType == "Wand" then return "weapon.wand", "Wand" + elseif itemType == "Dagger" then return "weapon.dagger", "Dagger" + elseif itemType == "Claw" then return "weapon.claw", "Claw" + elseif itemType:find("Two Handed") then return "weapon.twomelee", "2HWeapon" + elseif itemType:find("One Handed") then return "weapon.one", "1HWeapon" + else return "weapon", "1HWeapon" end - elseif slotName == "Body Armour" then return "armour.chest" - elseif slotName == "Helmet" then return "armour.helmet" - elseif slotName == "Gloves" then return "armour.gloves" - elseif slotName == "Boots" then return "armour.boots" - elseif slotName == "Amulet" then return "accessory.amulet" - elseif slotName == "Ring 1" or slotName == "Ring 2" or slotName == "Ring 3" then return "accessory.ring" - elseif slotName == "Belt" then return "accessory.belt" - elseif slotName:find("Abyssal") then return "jewel.abyss" - elseif slotName:find("Jewel") then return "jewel" - elseif slotName:find("Flask") then return "flask" - else return nil + elseif slotName == "Body Armour" then return "armour.chest", "Chest" + elseif slotName == "Helmet" then return "armour.helmet", "Helmet" + elseif slotName == "Gloves" then return "armour.gloves", "Gloves" + elseif slotName == "Boots" then return "armour.boots", "Boots" + elseif slotName == "Amulet" then return "accessory.amulet", "Amulet" + elseif slotName == "Ring 1" or slotName == "Ring 2" or slotName == "Ring 3" then return "accessory.ring", "Ring" + elseif slotName == "Belt" then return "accessory.belt", "Belt" + elseif slotName:find("Abyssal") then return "jewel.abyss", "AbyssJewel" + elseif slotName:find("Jewel") then return "jewel", nil + elseif slotName:find("Flask") then return "flask", "Flask" + else return nil, nil end end +-- Helper: map slot name + item type to trade API category string +function M.getTradeCategory(slotName, item) + if not item or not item.base then return nil end + local queryStr = M.getTradeCategoryInfo(slotName, item) + return queryStr +end + -- Helper: get a display-friendly category name from slot name function M.getTradeCategoryLabel(slotName, item) if not item or not item.base then return "Item" end diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index 2e21816092..eeb2fdeaab 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -9,6 +9,7 @@ local curl = require("lcurl.safe") local m_max = math.max local s_format = string.format local t_insert = table.insert +local tradeHelpers = LoadModule("Classes/CompareTradeHelpers") -- TODO generate these from data files local itemCategoryTags = { @@ -762,103 +763,25 @@ function TradeQueryGeneratorClass:StartQuery(slot, options) itemCategory = "AnyJewel" itemCategoryQueryStr = "jewel" end - elseif slot.slotName:find("^Weapon %d") then - if existingItem then - if existingItem.type == "Shield" then - itemCategoryQueryStr = "armour.shield" - itemCategory = "Shield" - elseif existingItem.type == "Quiver" then - itemCategoryQueryStr = "armour.quiver" - itemCategory = "Quiver" - elseif existingItem.type == "Bow" then - itemCategoryQueryStr = "weapon.bow" - itemCategory = "Bow" - elseif existingItem.type == "Staff" then - itemCategoryQueryStr = "weapon.staff" - itemCategory = "Staff" - elseif existingItem.type == "Two Handed Sword" then - itemCategoryQueryStr = "weapon.twosword" - itemCategory = "2HSword" - elseif existingItem.type == "Two Handed Axe" then - itemCategoryQueryStr = "weapon.twoaxe" - itemCategory = "2HAxe" - elseif existingItem.type == "Two Handed Mace" then - itemCategoryQueryStr = "weapon.twomace" - itemCategory = "2HMace" - elseif existingItem.type == "Fishing Rod" then - itemCategoryQueryStr = "weapon.rod" - itemCategory = "FishingRod" - elseif existingItem.type == "One Handed Sword" then - itemCategoryQueryStr = "weapon.onesword" - itemCategory = "1HSword" - elseif existingItem.type == "One Handed Axe" then - itemCategoryQueryStr = "weapon.oneaxe" - itemCategory = "1HAxe" - elseif existingItem.type == "One Handed Mace" or existingItem.type == "Sceptre" then - itemCategoryQueryStr = "weapon.onemace" - itemCategory = "1HMace" - elseif existingItem.type == "Wand" then - itemCategoryQueryStr = "weapon.wand" - itemCategory = "Wand" - elseif existingItem.type == "Dagger" then - itemCategoryQueryStr = "weapon.dagger" - itemCategory = "Dagger" - elseif existingItem.type == "Claw" then - itemCategoryQueryStr = "weapon.claw" - itemCategory = "Claw" - elseif existingItem.type:find("Two Handed") ~= nil then - itemCategoryQueryStr = "weapon.twomelee" - itemCategory = "2HWeapon" - elseif existingItem.type:find("One Handed") ~= nil then - itemCategoryQueryStr = "weapon.one" - itemCategory = "1HWeapon" + else + itemCategoryQueryStr, itemCategory = tradeHelpers.getTradeCategoryInfo(slot.slotName, existingItem) + + -- Generic Jewel slot: caller selects the jewel subtype. + if slot.slotName:find("Jewel") ~= nil and not slot.slotName:find("Abyssal") then + itemCategory = options.jewelType .. "Jewel" + if itemCategory == "AbyssJewel" then + itemCategoryQueryStr = "jewel.abyss" + elseif itemCategory == "BaseJewel" then + itemCategoryQueryStr = "jewel.base" else - logToFile("'%s' is not supported for weighted trade query generation", existingItem.type) - return + itemCategoryQueryStr = "jewel" end - else - -- Item does not exist in this slot so assume 1H weapon - itemCategoryQueryStr = "weapon.one" - itemCategory = "1HWeapon" end - elseif slot.slotName == "Body Armour" then - itemCategoryQueryStr = "armour.chest" - itemCategory = "Chest" - elseif slot.slotName == "Helmet" then - itemCategoryQueryStr = "armour.helmet" - itemCategory = "Helmet" - elseif slot.slotName == "Gloves" then - itemCategoryQueryStr = "armour.gloves" - itemCategory = "Gloves" - elseif slot.slotName == "Boots" then - itemCategoryQueryStr = "armour.boots" - itemCategory = "Boots" - elseif slot.slotName == "Amulet" then - itemCategoryQueryStr = "accessory.amulet" - itemCategory = "Amulet" - elseif slot.slotName == "Ring 1" or slot.slotName == "Ring 2" or slot.slotName == "Ring 3" then - itemCategoryQueryStr = "accessory.ring" - itemCategory = "Ring" - elseif slot.slotName == "Belt" then - itemCategoryQueryStr = "accessory.belt" - itemCategory = "Belt" - elseif slot.slotName:find("Abyssal") ~= nil then - itemCategoryQueryStr = "jewel.abyss" - itemCategory = "AbyssJewel" - elseif slot.slotName:find("Jewel") ~= nil then - itemCategoryQueryStr = "jewel" - itemCategory = options.jewelType .. "Jewel" - if itemCategory == "AbyssJewel" then - itemCategoryQueryStr = "jewel.abyss" - elseif itemCategory == "BaseJewel" then - itemCategoryQueryStr = "jewel.base" + + if not itemCategoryQueryStr then + logToFile("'%s' is not supported for weighted trade query generation", existingItem and existingItem.type or "n/a") + return end - elseif slot.slotName:find("Flask") ~= nil then - itemCategoryQueryStr = "flask" - itemCategory = "Flask" - else - logToFile("'%s' is not supported for weighted trade query generation", existingItem and existingItem.type or "n/a") - return end -- Create a temp item for the slot with no mods From a8373592f076723121843354d70c11c8e17b3b51 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Fri, 17 Apr 2026 10:39:30 +0200 Subject: [PATCH 55/59] remove compared build from save --- src/Classes/BuildListControl.lua | 7 -- src/Classes/CompareEntry.lua | 2 - src/Classes/CompareTab.lua | 122 ------------------------------- src/Classes/ImportTab.lua | 1 - src/Modules/Build.lua | 4 +- src/Modules/BuildList.lua | 4 - 6 files changed, 1 insertion(+), 139 deletions(-) diff --git a/src/Classes/BuildListControl.lua b/src/Classes/BuildListControl.lua index 22c30f3c08..3f42430ce9 100644 --- a/src/Classes/BuildListControl.lua +++ b/src/Classes/BuildListControl.lua @@ -175,13 +175,6 @@ function BuildListClass:GetRowValue(column, index, build) label = ">> " .. build.folderName else label = build.buildName or "?" - if build.compareLabels and #build.compareLabels > 0 then - if #build.compareLabels == 1 then - label = label .. " (+" .. build.compareLabels[1] .. ")" - else - label = label .. " (+" .. #build.compareLabels .. " compared builds)" - end - end end if self.cutBuild and self.cutBuild.buildName == build.buildName and self.cutBuild.folderName == build.folderName then return "^xC0B0B0"..label diff --git a/src/Classes/CompareEntry.lua b/src/Classes/CompareEntry.lua index f0a74b9f4f..f4e2ca5570 100644 --- a/src/Classes/CompareEntry.lua +++ b/src/Classes/CompareEntry.lua @@ -41,7 +41,6 @@ local CompareEntryClass = newClass("CompareEntry", "ControlHost", function(self, self.data = data -- Flags - self.modFlag = false self.buildFlag = false self.outputRevision = 1 @@ -273,7 +272,6 @@ end function CompareEntryClass:SetMainSocketGroup(index) self.mainSocketGroup = index - self.modFlag = true self.buildFlag = true end diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 61133e0d46..32f7171ee1 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -262,7 +262,6 @@ function CompareTabClass:InitControls() local entry = self:GetActiveCompare() if entry and entry.treeTab and entry.treeTab.specList[index] then entry:SetActiveSpec(index) - self.modFlag = true -- Restore primary build's window title (SetActiveSpec changes it) if self.primaryBuild.spec then self.primaryBuild.spec:SetWindowTitleWithBuildClass() @@ -280,7 +279,6 @@ function CompareTabClass:InitControls() local entry = self:GetActiveCompare() if entry and entry.skillsTab and entry.skillsTab.skillSetOrderList[index] then entry:SetActiveSkillSet(entry.skillsTab.skillSetOrderList[index]) - self.modFlag = true end end) self.controls.compareSkillSetSelect.enabled = setsEnabled @@ -291,7 +289,6 @@ function CompareTabClass:InitControls() local entry = self:GetActiveCompare() if entry and entry.itemsTab and entry.itemsTab.itemSetOrderList[index] then entry:SetActiveItemSet(entry.itemsTab.itemSetOrderList[index]) - self.modFlag = true end end) self.controls.compareItemSetSelect.enabled = setsEnabled @@ -305,7 +302,6 @@ function CompareTabClass:InitControls() if setId then entry.configTab:SetActiveConfigSet(setId) entry.buildFlag = true - self.modFlag = true self.configNeedsRebuild = true end end @@ -337,8 +333,6 @@ function CompareTabClass:InitControls() local mainSocketGroup = entry.skillsTab.socketGroupList[entry.mainSocketGroup] if mainSocketGroup then mainSocketGroup.mainActiveSkill = index - entry.modFlag = true - self.modFlag = true entry.buildFlag = true end end @@ -355,8 +349,6 @@ function CompareTabClass:InitControls() local activeSkill = displaySkillList and displaySkillList[mainSocketGroup.mainActiveSkill or 1] if activeSkill and activeSkill.activeEffect then activeSkill.activeEffect.srcInstance.skillPart = index - entry.modFlag = true - self.modFlag = true entry.buildFlag = true end end @@ -376,8 +368,6 @@ function CompareTabClass:InitControls() local activeSkill = displaySkillList and displaySkillList[mainSocketGroup.mainActiveSkill or 1] if activeSkill and activeSkill.activeEffect then activeSkill.activeEffect.srcInstance.skillStageCount = tonumber(buf) - entry.modFlag = true - self.modFlag = true entry.buildFlag = true end end @@ -397,8 +387,6 @@ function CompareTabClass:InitControls() local activeSkill = displaySkillList and displaySkillList[mainSocketGroup.mainActiveSkill or 1] if activeSkill and activeSkill.activeEffect then activeSkill.activeEffect.srcInstance.skillMineCount = tonumber(buf) - entry.modFlag = true - self.modFlag = true entry.buildFlag = true end end @@ -422,8 +410,6 @@ function CompareTabClass:InitControls() elseif selected.minionId then activeSkill.activeEffect.srcInstance.skillMinion = selected.minionId end - entry.modFlag = true - self.modFlag = true entry.buildFlag = true end end @@ -442,8 +428,6 @@ function CompareTabClass:InitControls() local activeSkill = displaySkillList and displaySkillList[mainSocketGroup.mainActiveSkill or 1] if activeSkill and activeSkill.activeEffect then activeSkill.activeEffect.srcInstance.skillMinionSkill = index - entry.modFlag = true - self.modFlag = true entry.buildFlag = true end end @@ -568,7 +552,6 @@ function CompareTabClass:InitControls() if entry then entry.calcsTab.input.skill_number = index entry.buildFlag = true - self.modFlag = true end end) self.controls.cmpCalcsSocketGroup.shown = false @@ -582,7 +565,6 @@ function CompareTabClass:InitControls() if mainSocketGroup then mainSocketGroup.mainActiveSkillCalcs = index entry.buildFlag = true - self.modFlag = true end end end) @@ -598,7 +580,6 @@ function CompareTabClass:InitControls() if activeSkill and activeSkill.activeEffect then activeSkill.activeEffect.srcInstance.skillPartCalcs = index entry.buildFlag = true - self.modFlag = true end end end @@ -615,7 +596,6 @@ function CompareTabClass:InitControls() if activeSkill and activeSkill.activeEffect then activeSkill.activeEffect.srcInstance.skillStageCountCalcs = tonumber(buf) entry.buildFlag = true - self.modFlag = true end end end @@ -632,7 +612,6 @@ function CompareTabClass:InitControls() if activeSkill and activeSkill.activeEffect then activeSkill.activeEffect.srcInstance.skillMineCountCalcs = tonumber(buf) entry.buildFlag = true - self.modFlag = true end end end @@ -644,7 +623,6 @@ function CompareTabClass:InitControls() if entry then entry.calcsTab.input.showMinion = state entry.buildFlag = true - self.modFlag = true end end, "Show stats for the minion instead of the player.") self.controls.cmpCalcsShowMinion.shown = false @@ -665,7 +643,6 @@ function CompareTabClass:InitControls() activeSkill.activeEffect.srcInstance.skillMinionCalcs = selected.minionId end entry.buildFlag = true - self.modFlag = true end end end @@ -683,7 +660,6 @@ function CompareTabClass:InitControls() if activeSkill and activeSkill.activeEffect then activeSkill.activeEffect.srcInstance.skillMinionSkillCalcs = index entry.buildFlag = true - self.modFlag = true end end end @@ -695,7 +671,6 @@ function CompareTabClass:InitControls() if entry then entry.calcsTab.input.misc_buffMode = value.buffMode entry.buildFlag = true - self.modFlag = true end end) self.controls.cmpCalcsMode.shown = false @@ -770,7 +745,6 @@ function CompareTabClass:InitControls() local entry = self:GetActiveCompare() if entry and entry.itemsTab and entry.itemsTab.itemSetOrderList[index] then entry:SetActiveItemSet(entry.itemsTab.itemSetOrderList[index]) - self.modFlag = true end end) self.controls.compareItemSetSelect2.enabled = itemsShown @@ -797,7 +771,6 @@ function CompareTabClass:InitControls() local entry = self:GetActiveCompare() if entry and entry.treeTab and entry.treeTab.specList[index] then entry:SetActiveSpec(index) - self.modFlag = true if self.primaryBuild.spec then self.primaryBuild.spec:SetWindowTitleWithBuildClass() end @@ -845,7 +818,6 @@ function CompareTabClass:InitControls() local entry = self:GetActiveCompare() if entry and entry.treeTab and entry.treeTab.specList[index] then entry:SetActiveSpec(index) - self.modFlag = true -- Restore primary build's window title (compare entry's SetActiveSpec changes it) if self.primaryBuild.spec then self.primaryBuild.spec:SetWindowTitleWithBuildClass() @@ -1222,106 +1194,15 @@ function CompareTabClass:ImportFromCode(code) return false end if self:ImportBuild(xmlText, "Imported build") then - self.modFlag = true return true end return false end --- Save comparison builds to the build file -function CompareTabClass:Save(xml) - xml.attrib = { - activeCompareIndex = tostring(self.activeCompareIndex), - } - -- Sync current notes edit buffer to the active entry before saving - if self.notesActiveEntry then - self.notesActiveEntry.notesText = self.controls.notesEdit.buf - end - for _, entry in ipairs(self.compareEntries) do - local attrib = { - label = entry.label, - buildCode = common.base64.encode(Deflate(entry.xmlText)):gsub("+","-"):gsub("/","_"), - } - if entry.treeTab then - attrib.activeSpec = tostring(entry.treeTab.activeSpec) - end - if entry.skillsTab then - attrib.activeSkillSetId = tostring(entry.skillsTab.activeSkillSetId) - end - if entry.itemsTab then - attrib.activeItemSetId = tostring(entry.itemsTab.activeItemSetId) - end - if entry.configTab then - attrib.activeConfigSetId = tostring(entry.configTab.activeConfigSetId) - end - local entryNode = { - elem = "CompareEntry", - attrib = attrib, - } - if entry.notesText and entry.notesText ~= "" then - t_insert(entryNode, { elem = "Notes", attrib = {}, entry.notesText }) - end - t_insert(xml, entryNode) - end -end - --- Load comparison builds from the build file -function CompareTabClass:Load(xml, dbFileName) - local savedIndex = tonumber(xml.attrib and xml.attrib.activeCompareIndex) or 0 - for _, child in ipairs(xml) do - if type(child) == "table" and child.elem == "CompareEntry" then - local code = child.attrib and child.attrib.buildCode - if code then - local xmlText = Inflate(common.base64.decode(code:gsub("-","+"):gsub("_","/"))) - if xmlText then - if self:ImportBuild(xmlText, child.attrib.label or "Comparison Build") then - local entry = self.compareEntries[#self.compareEntries] - local savedSpec = tonumber(child.attrib.activeSpec) - if savedSpec and entry.treeTab and entry.treeTab.specList[savedSpec] then - entry:SetActiveSpec(savedSpec) - end - local savedSkillSet = tonumber(child.attrib.activeSkillSetId) - if savedSkillSet and entry.skillsTab then - entry:SetActiveSkillSet(savedSkillSet) - end - local savedItemSet = tonumber(child.attrib.activeItemSetId) - if savedItemSet and entry.itemsTab then - entry:SetActiveItemSet(savedItemSet) - end - local savedConfigSet = tonumber(child.attrib.activeConfigSetId) - if savedConfigSet and entry.configTab and entry.configTab.configSets[savedConfigSet] then - entry.configTab:SetActiveConfigSet(savedConfigSet) - end - -- Restore edited notes (overrides notes from original build XML) - for _, grandchild in ipairs(child) do - if type(grandchild) == "table" and grandchild.elem == "Notes" then - for _, text in ipairs(grandchild) do - if type(text) == "string" then - entry.notesText = text - break - end - end - break - end - end - end - end - end - end - end - if #self.compareEntries > 0 then - self.activeCompareIndex = m_max(1, m_min(savedIndex, #self.compareEntries)) - else - self.activeCompareIndex = 0 - end - self:UpdateBuildSelector() -end - -- Remove a comparison build function CompareTabClass:RemoveBuild(index) if index >= 1 and index <= #self.compareEntries then t_remove(self.compareEntries, index) - self.modFlag = true self.notesActiveEntry = nil if self.activeCompareIndex > #self.compareEntries then self.activeCompareIndex = #self.compareEntries @@ -1531,7 +1412,6 @@ function CompareTabClass:OpenImportPopup() local xmlText = Inflate(common.base64.decode(codeData:gsub("-","+"):gsub("_","/"))) if xmlText then self:ImportBuild(xmlText, customName or ("Imported from " .. site.label)) - self.modFlag = true main:ClosePopup() else stateText = colorCodes.NEGATIVE .. "Failed to decode build data" @@ -1548,7 +1428,6 @@ function CompareTabClass:OpenImportPopup() local xmlText = Inflate(common.base64.decode(buf:gsub("-","+"):gsub("_","/"))) if xmlText then self:ImportBuild(xmlText, customName or "Imported build") - self.modFlag = true main:ClosePopup() else stateText = colorCodes.NEGATIVE .. "Invalid build code" @@ -4502,7 +4381,6 @@ function CompareTabClass:DrawNotes(vp, compareEntry, inputEvents) -- Sync edits back to the compare entry if compareEntry.notesText ~= self.controls.notesEdit.buf then compareEntry.notesText = self.controls.notesEdit.buf - self.modFlag = true end main:DrawBackground(vp) diff --git a/src/Classes/ImportTab.lua b/src/Classes/ImportTab.lua index bab9cc116c..e339366f9e 100644 --- a/src/Classes/ImportTab.lua +++ b/src/Classes/ImportTab.lua @@ -318,7 +318,6 @@ You can get this from your web browser's cookies while logged into the Path of E -- Import as comparison build if self.build.compareTab then if self.build.compareTab:ImportBuild(self.importCodeXML, "Imported comparison") then - self.build.compareTab.modFlag = true self.build.viewMode = "COMPARE" else main:OpenMessagePopup("Import Error", "Failed to import build for comparison.") diff --git a/src/Modules/Build.lua b/src/Modules/Build.lua index 02d67c72b1..aa5987ae10 100644 --- a/src/Modules/Build.lua +++ b/src/Modules/Build.lua @@ -585,7 +585,6 @@ function buildMode:Init(dbFileName, buildName, buildXML, convertBuild, importLin ["Skills"] = self.skillsTab, ["Calcs"] = self.calcsTab, ["Import"] = self.importTab, - ["Compare"] = self.compareTab, } self.legacyLoaders = { -- Special loaders for legacy sections ["Spec"] = self.treeTab, @@ -1043,7 +1042,6 @@ function buildMode:ResetModFlags() self.skillsTab.modFlag = false self.itemsTab.modFlag = false self.calcsTab.modFlag = false - self.compareTab.modFlag = false end function buildMode:OnFrame(inputEvents) @@ -1154,7 +1152,7 @@ function buildMode:OnFrame(inputEvents) self.compareTab:Draw(tabViewPort, inputEvents) end - self.unsaved = self.modFlag or self.notesTab.modFlag or self.partyTab.modFlag or self.configTab.modFlag or self.treeTab.modFlag or self.treeTab.searchFlag or self.spec.modFlag or self.skillsTab.modFlag or self.itemsTab.modFlag or self.calcsTab.modFlag or self.compareTab.modFlag + self.unsaved = self.modFlag or self.notesTab.modFlag or self.partyTab.modFlag or self.configTab.modFlag or self.treeTab.modFlag or self.treeTab.searchFlag or self.spec.modFlag or self.skillsTab.modFlag or self.itemsTab.modFlag or self.calcsTab.modFlag SetDrawLayer(5) diff --git a/src/Modules/BuildList.lua b/src/Modules/BuildList.lua index fa32b272dd..f0c1ba68e2 100644 --- a/src/Modules/BuildList.lua +++ b/src/Modules/BuildList.lua @@ -220,10 +220,6 @@ function listMode:BuildList() main:OpenCloudErrorPopup(build.fullFileName) return end - build.compareLabels = { } - for label in fileText:gmatch(']-label="([^"]*)"') do - t_insert(build.compareLabels, label) - end fileText = fileText:match("()") if fileText then local xml = common.xml.ParseXML(fileText.."") From 7f182d68d1e15cba669131c61d4ce5fb28487f22 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Fri, 17 Apr 2026 13:53:51 +0200 Subject: [PATCH 56/59] add option to import compared builds from local build folder --- src/Classes/CompareTab.lua | 120 +++++++++++++++++++++++--- src/Modules/BuildList.lua | 116 ++----------------------- src/Modules/BuildListHelpers.lua | 140 +++++++++++++++++++++++++++++++ 3 files changed, 253 insertions(+), 123 deletions(-) create mode 100644 src/Modules/BuildListHelpers.lua diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 32f7171ee1..92f801bd62 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -13,6 +13,7 @@ local dkjson = require "dkjson" local tradeHelpers = LoadModule("Classes/CompareTradeHelpers") local buySimilar = LoadModule("Classes/CompareBuySimilar") local calcsHelpers = LoadModule("Classes/CompareCalcsHelpers") +local buildListHelpers = LoadModule("Modules/BuildListHelpers") -- Node IDs below this value are normal passive tree nodes; IDs at or above are cluster jewel nodes local CLUSTER_NODE_OFFSET = 65536 @@ -183,11 +184,6 @@ function CompareTabClass:InitControls() end end - -- Notes sub-tab controls - self.controls.notesDesc = new("LabelControl", nil, {0, 0, 0, 16}, "^7These are the notes from the compared build. Any edits here are saved with this comparison and do not affect the main build's notes.") - self.controls.notesDesc.shown = function() - return self.compareViewMode == "NOTES" and #self.compareEntries > 0 - end self.controls.notesEdit = new("EditControl", nil, {0, 0, 0, 0}, "", nil, "^%C\t\n", nil, nil, 16, true) self.controls.notesEdit.shown = function() return self.compareViewMode == "NOTES" and #self.compareEntries > 0 @@ -1396,7 +1392,7 @@ function CompareTabClass:OpenImportPopup() controls.state.label = function() return stateText or "" end - controls.go = new("ButtonControl", nil, {-45, 130, 80, 20}, "Import", function() + controls.go = new("ButtonControl", nil, {-118, 130, 80, 20}, "Import", function() local buf = controls.input.buf if not buf or buf == "" then return @@ -1433,12 +1429,115 @@ function CompareTabClass:OpenImportPopup() stateText = colorCodes.NEGATIVE .. "Invalid build code" end end) - controls.cancel = new("ButtonControl", nil, {45, 130, 80, 20}, "Cancel", function() + controls.importFolder = new("ButtonControl", nil, {0, 130, 140, 20}, "Import from Folder", function() + main:ClosePopup() + self:OpenImportFolderPopup() + end) + controls.cancel = new("ButtonControl", nil, {118, 130, 80, 20}, "Cancel", function() main:ClosePopup() end) main:OpenPopup(500, 160, "Import Comparison Build", controls, "go", "input", "cancel") end +-- Open the "Import from Folder" popup: browse the user's local builds folder and +-- import the selected build file as a comparison. +function CompareTabClass:OpenImportFolderPopup() + local controls = {} + local searchText = "" + local sortMode = main.buildSortMode + + -- Minimal listMode-like host consumed by BuildListControl/PathControl. + local listHost = { + subPath = "", + list = { }, + controls = { }, + } + function listHost:BuildList() + wipeTable(self.list) + local scanned = buildListHelpers.ScanFolder(self.subPath, searchText) + for _, entry in ipairs(scanned) do + t_insert(self.list, entry) + end + buildListHelpers.SortList(self.list, sortMode) + end + function listHost:SelectControl(control) + -- Focus is managed by the popup's ControlHost; this is a no-op for the popup list. + end + + -- Import the given build entry (xml file on disk) as a comparison. + local function importBuildEntry(build) + local fileHnd = io.open(build.fullFileName, "r") + if not fileHnd then + main:OpenMessagePopup("Import Error", "Couldn't open '"..build.fullFileName.."'.") + return + end + local xmlText = fileHnd:read("*a") + fileHnd:close() + if not xmlText or xmlText == "" then + main:OpenMessagePopup("Import Error", "Build file is empty or unreadable.") + return + end + if self:ImportBuild(xmlText, build.buildName) then + main:ClosePopup() + else + main:OpenMessagePopup("Import Error", "Failed to import build for comparison.") + end + end + + -- Search box and sort dropdown sit above the build list. + controls.searchText = new("EditControl", {"TOPLEFT", nil, "TOPLEFT"}, {15, 25, 450, 20}, "", "Search", "%c%(%)", 100, function(buf) + searchText = buf + listHost:BuildList() + end, nil, nil, true) + controls.sort = new("DropDownControl", {"TOPLEFT", nil, "TOPLEFT"}, {475, 25, 210, 20}, buildListHelpers.buildSortDropList, function(index, value) + sortMode = value.sortMode + main.buildSortMode = value.sortMode + buildListHelpers.SortList(listHost.list, sortMode) + end) + controls.sort:SelByValue(sortMode, "sortMode") + + -- Build list itself. Reuses BuildListControl (which provides the PathControl breadcrumbs) + controls.buildList = new("BuildListControl", {"TOPLEFT", nil, "TOPLEFT"}, {15, 75, 0, 0}, listHost) + controls.buildList.width = function() return 670 end + controls.buildList.height = function() return 355 end + + -- Override instance methods on the BuildListControl to tailor it for the popup: + -- navigate folders, import builds, and suppress rename/delete/drag behaviors. + function controls.buildList:LoadBuild(build) + if build.folderName then + self.controls.path:SetSubPath(self.listMode.subPath .. build.folderName .. "/") + else + importBuildEntry(build) + end + end + function controls.buildList:OnSelKeyDown(index, build, key) + if key == "RETURN" then + self:LoadBuild(build) + end + end + function controls.buildList:CanReceiveDrag() return false end + function controls.buildList:OnSelCopy() end + function controls.buildList:OnSelCut() end + function controls.buildList:OnSelDelete() end + function controls.buildList.controls.path:CanReceiveDrag() return false end + + -- Populate the initial list now that the control (and its path control) exist. + listHost:BuildList() + + controls.open = new("ButtonControl", {"TOPLEFT", nil, "TOPLEFT"}, {255, 465, 80, 20}, "Open", function() + local sel = controls.buildList.selValue + if sel then + controls.buildList:LoadBuild(sel) + end + end) + controls.open.enabled = function() return controls.buildList.selValue ~= nil end + controls.close = new("ButtonControl", {"TOPLEFT", nil, "TOPLEFT"}, {365, 465, 80, 20}, "Close", function() + main:ClosePopup() + end) + + main:OpenPopup(700, 500, "Import from Folder", controls, "open", "searchText", "close") +end + -- ============================================================ -- DRAW - Main render method -- ============================================================ @@ -4364,13 +4463,8 @@ function CompareTabClass:DrawNotes(vp, compareEntry, inputEvents) end end - -- Position label and edit control - self.controls.notesDesc.x = vp.x + 8 - self.controls.notesDesc.y = vp.y + 8 - - local editY = vp.y + 30 self.controls.notesEdit.x = vp.x + 8 - self.controls.notesEdit.y = editY + self.controls.notesEdit.y = vp.y + 8 self.controls.notesEdit.width = function() return vp.width - 16 end diff --git a/src/Modules/BuildList.lua b/src/Modules/BuildList.lua index f0c1ba68e2..c9b67025cd 100644 --- a/src/Modules/BuildList.lua +++ b/src/Modules/BuildList.lua @@ -7,12 +7,8 @@ local pairs = pairs local ipairs = ipairs local t_insert = table.insert -local buildSortDropList = { - { label = "Sort by Name", sortMode = "NAME" }, - { label = "Sort by Class", sortMode = "CLASS" }, - { label = "Sort by Last Edited", sortMode = "EDITED"}, - { label = "Sort by Level", sortMode = "LEVEL"}, -} +local buildListHelpers = LoadModule("Modules/BuildListHelpers") +local buildSortDropList = buildListHelpers.buildSortDropList local listMode = new("ControlHost") @@ -197,116 +193,16 @@ end function listMode:BuildList() wipeTable(self.list) - local filterList = main.filterBuildList or "" - local handle = nil - if filterList ~= "" then - handle = NewFileSearch(main.buildPath..self.subPath.."*"..filterList.."*.xml") - else - handle = NewFileSearch(main.buildPath..self.subPath.."*.xml") - end - while handle do - local fileName = handle:GetFileName() - local build = { } - build.fileName = fileName - build.subPath = self.subPath - build.fullFileName = main.buildPath..self.subPath..fileName - build.modified = handle:GetFileModifiedTime() - build.buildName = fileName:gsub("%.xml$","") - local fileHnd = io.open(build.fullFileName, "r") - if fileHnd then - local fileText = fileHnd:read("*a") - fileHnd:close() - if not fileText then - main:OpenCloudErrorPopup(build.fullFileName) - return - end - fileText = fileText:match("()") - if fileText then - local xml = common.xml.ParseXML(fileText.."") - if xml and xml[1] then - build.level = tonumber(xml[1].attrib.level) - build.className = xml[1].attrib.className - build.ascendClassName = xml[1].attrib.ascendClassName - end - end - end - t_insert(self.list, build) - if not handle:NextFile() then - break - end - end -handle = NewFileSearch(main.buildPath..self.subPath.."*", true) - while handle do - local folderName = handle:GetFileName() - t_insert(self.list, { - folderName = folderName, - subPath = self.subPath, - fullFileName = main.buildPath..self.subPath..folderName, - modified = handle:GetFileModifiedTime() - }) - if not handle:NextFile() then - break - end + local scanned = buildListHelpers.ScanFolder(self.subPath, main.filterBuildList or "") + for _, entry in ipairs(scanned) do + t_insert(self.list, entry) end self:SortList() end function listMode:SortList() local oldSelFileName = self.controls.buildList.selValue and self.controls.buildList.selValue.fileName - table.sort(self.list, function(a, b) - local a_is_folder = a.folderName ~= nil - local b_is_folder = b.folderName ~= nil - - if a_is_folder and not b_is_folder then return true end - if not a_is_folder and b_is_folder then return false end - - - if main.buildSortMode == "EDITED" then - local modA = a.modified or 0 -- Use 0 as fallback if modified time is nil - local modB = b.modified or 0 - if modA ~= modB then - return modA > modB -- Newest first maybe allow for inverting of order? - end - -- If modified times are the same or both 0 fall back to name sort - if a_is_folder then - return naturalSortCompare(a.folderName, b.folderName) - else - return naturalSortCompare(a.fileName, b.fileName) - end - end - - if a_is_folder then - return naturalSortCompare(a.folderName, b.folderName) - else - if main.buildSortMode == "CLASS" then - local a_has_class = a.className ~= nil - local b_has_class = b.className ~= nil - if not a_has_class and b_has_class then return true - elseif a_has_class and not b_has_class then return false - elseif a_has_class and b_has_class and a.className ~= b.className then - return a.className < b.className - end - - local a_has_asc = a.ascendClassName ~= nil - local b_has_asc = b.ascendClassName ~= nil - if not a_has_asc and b_has_asc then return true - elseif a_has_asc and not b_has_asc then return false - elseif a_has_asc and b_has_asc and a.ascendClassName ~= b.ascendClassName then - return a.ascendClassName < b.ascendClassName - end - return naturalSortCompare(a.fileName, b.fileName) - elseif main.buildSortMode == "LEVEL" then - if a.level and not b.level then return false - elseif not a.level and b.level then return true - elseif a.level and b.level then - if a.level ~= b.level then return a.level < b.level end - end - return naturalSortCompare(a.fileName, b.fileName) - else - return naturalSortCompare(a.fileName, b.fileName) - end - end - end) + buildListHelpers.SortList(self.list, main.buildSortMode) if oldSelFileName then self.controls.buildList:SelByFileName(oldSelFileName) end diff --git a/src/Modules/BuildListHelpers.lua b/src/Modules/BuildListHelpers.lua new file mode 100644 index 0000000000..aefc34f174 --- /dev/null +++ b/src/Modules/BuildListHelpers.lua @@ -0,0 +1,140 @@ +-- Path of Building +-- +-- Module: Build List Helpers +-- Shared helpers for scanning and sorting the builds folder. +-- Used by both the startup build list (Modules/BuildList) and the +-- "Import from Folder" popup in the Compare tab. +-- +local t_insert = table.insert + +local buildSortDropList = { + { label = "Sort by Name", sortMode = "NAME" }, + { label = "Sort by Class", sortMode = "CLASS" }, + { label = "Sort by Last Edited", sortMode = "EDITED"}, + { label = "Sort by Level", sortMode = "LEVEL"}, +} + +-- Scan main.buildPath..subPath for .xml builds and sub-folders. +-- filterText is an optional substring filter applied to build filenames. +-- Returns a freshly allocated list of entries in the shape used by BuildListControl. +-- On cloud-read failure opens main:OpenCloudErrorPopup and returns whatever has been +-- collected so far (matching the prior in-module behavior in Modules/BuildList). +local function ScanFolder(subPath, filterText) + subPath = subPath or "" + filterText = filterText or "" + local list = { } + local handle + if filterText ~= "" then + handle = NewFileSearch(main.buildPath..subPath.."*"..filterText.."*.xml") + else + handle = NewFileSearch(main.buildPath..subPath.."*.xml") + end + while handle do + local fileName = handle:GetFileName() + local build = { } + build.fileName = fileName + build.subPath = subPath + build.fullFileName = main.buildPath..subPath..fileName + build.modified = handle:GetFileModifiedTime() + build.buildName = fileName:gsub("%.xml$","") + local fileHnd = io.open(build.fullFileName, "r") + if fileHnd then + local fileText = fileHnd:read("*a") + fileHnd:close() + if not fileText then + main:OpenCloudErrorPopup(build.fullFileName) + return list + end + fileText = fileText:match("()") + if fileText then + local xml = common.xml.ParseXML(fileText.."") + if xml and xml[1] then + build.level = tonumber(xml[1].attrib.level) + build.className = xml[1].attrib.className + build.ascendClassName = xml[1].attrib.ascendClassName + end + end + end + t_insert(list, build) + if not handle:NextFile() then + break + end + end + handle = NewFileSearch(main.buildPath..subPath.."*", true) + while handle do + local folderName = handle:GetFileName() + t_insert(list, { + folderName = folderName, + subPath = subPath, + fullFileName = main.buildPath..subPath..folderName, + modified = handle:GetFileModifiedTime() + }) + if not handle:NextFile() then + break + end + end + return list +end + +-- Sort the given list in place using the same rules as the startup build list. +-- sortMode: "NAME" (default), "CLASS", "EDITED", or "LEVEL". +local function SortList(list, sortMode) + table.sort(list, function(a, b) + local a_is_folder = a.folderName ~= nil + local b_is_folder = b.folderName ~= nil + + if a_is_folder and not b_is_folder then return true end + if not a_is_folder and b_is_folder then return false end + + if sortMode == "EDITED" then + local modA = a.modified or 0 + local modB = b.modified or 0 + if modA ~= modB then + return modA > modB + end + if a_is_folder then + return naturalSortCompare(a.folderName, b.folderName) + else + return naturalSortCompare(a.fileName, b.fileName) + end + end + + if a_is_folder then + return naturalSortCompare(a.folderName, b.folderName) + else + if sortMode == "CLASS" then + local a_has_class = a.className ~= nil + local b_has_class = b.className ~= nil + if not a_has_class and b_has_class then return true + elseif a_has_class and not b_has_class then return false + elseif a_has_class and b_has_class and a.className ~= b.className then + return a.className < b.className + end + + local a_has_asc = a.ascendClassName ~= nil + local b_has_asc = b.ascendClassName ~= nil + if not a_has_asc and b_has_asc then return true + elseif a_has_asc and not b_has_asc then return false + elseif a_has_asc and b_has_asc and a.ascendClassName ~= b.ascendClassName then + return a.ascendClassName < b.ascendClassName + end + return naturalSortCompare(a.fileName, b.fileName) + elseif sortMode == "LEVEL" then + if a.level and not b.level then return false + elseif not a.level and b.level then return true + elseif a.level and b.level then + if a.level ~= b.level then return a.level < b.level end + end + return naturalSortCompare(a.fileName, b.fileName) + else + return naturalSortCompare(a.fileName, b.fileName) + end + end + end) +end + +return { + buildSortDropList = buildSortDropList, + ScanFolder = ScanFolder, + SortList = SortList, +} From 23a6169ca3d0deb2b36928d573b2acc7160c349f Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Fri, 17 Apr 2026 14:48:29 +0200 Subject: [PATCH 57/59] fix overlapping save names in config tab when main characters name is long --- src/Classes/CompareTab.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 92f801bd62..34a87d8aed 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -4366,7 +4366,7 @@ function CompareTabClass:DrawConfig(vp, compareEntry) SetDrawColor(1, 1, 1) -- Column headers aligned with first column's control offsets local headerBaseX = 10 - DrawString(headerBaseX + LAYOUT.configCol2, colHeaderY, "LEFT", columnHeaderHeight, "VAR", + DrawString(headerBaseX + LAYOUT.configCol3 - 8, colHeaderY, "RIGHT_X", columnHeaderHeight, "VAR", colorCodes.POSITIVE .. self:GetShortBuildName(self.primaryBuild.buildName)) DrawString(headerBaseX + LAYOUT.configCol3, colHeaderY, "LEFT", columnHeaderHeight, "VAR", colorCodes.WARNING .. (compareEntry.label or "Compare Build")) From 885e780831e8ef70b170501df01886d4d683518f Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Fri, 17 Apr 2026 15:13:42 +0200 Subject: [PATCH 58/59] fix hover error on exclusive jewel nodes after cleanup --- src/Classes/PassiveTreeView.lua | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Classes/PassiveTreeView.lua b/src/Classes/PassiveTreeView.lua index 43bb210062..1468a149b0 100644 --- a/src/Classes/PassiveTreeView.lua +++ b/src/Classes/PassiveTreeView.lua @@ -1379,7 +1379,8 @@ function PassiveTreeViewClass:AddNodeTooltip(tooltip, node, build) local cAllocated = self.compareSpec.allocNodes and self.compareSpec.allocNodes[node.id] if cJewel and cAllocated then -- Show the compare build's jewel tooltip instead of generic socket info - cItemsTab:AddItemTooltip(tooltip, cJewel, { nodeId = node.id }) + local socket = build.itemsTab:GetSocketAndJewelForNodeID(node.id) + cItemsTab:AddItemTooltip(tooltip, cJewel, socket) tooltip:AddSeparator(14) tooltip:AddLine(14, colorCodes.DEXTERITY .. "Jewel from compared build") tooltip:AddLine(14, colorCodes.TIP.."Tip: Hold Shift or Ctrl to hide this tooltip.") @@ -1607,7 +1608,7 @@ function PassiveTreeViewClass:AddCompareNodeTooltip(tooltip, node, build) local cJewel = self:GetCompareJewel(node.id) local cItemsTab = self.compareSpec.build and self.compareSpec.build.itemsTab if cJewel and cItemsTab then - cItemsTab:AddItemTooltip(tooltip, cJewel, { nodeId = node.id }) + cItemsTab:AddItemTooltip(tooltip, cJewel, nil) else self:AddCompareNodeName(tooltip, node) end From 0123926305ab283dc6a6e9d5ea24484eb329bcb6 Mon Sep 17 00:00:00 2001 From: Wires77 Date: Fri, 17 Apr 2026 16:51:57 -0500 Subject: [PATCH 59/59] Remove Notes from Compare tab --- src/Classes/CompareTab.lua | 51 ++------------------------------------ 1 file changed, 2 insertions(+), 49 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 34a87d8aed..0980ed7694 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -131,9 +131,6 @@ local CompareTabClass = newClass("CompareTab", "ControlHost", "Control", functio self.configSectionLayout = {} -- computed section layout for drawing self.configTotalContentHeight = 0 - -- Notes view state - self.notesActiveEntry = nil - -- Compare power report state self.comparePowerStat = nil -- selected data.powerStatList entry self.comparePowerCategories = { treeNodes = true, items = true, skillGems = true, supportGems = true, config = true } @@ -154,8 +151,8 @@ end) function CompareTabClass:InitControls() -- Sub-tab buttons - local subTabs = { "Summary", "Tree", "Skills", "Items", "Calcs", "Config", "Notes" } - local subTabModes = { "SUMMARY", "TREE", "SKILLS", "ITEMS", "CALCS", "CONFIG", "NOTES" } + local subTabs = { "Summary", "Tree", "Skills", "Items", "Calcs", "Config" } + local subTabModes = { "SUMMARY", "TREE", "SKILLS", "ITEMS", "CALCS", "CONFIG" } self.controls.subTabAnchor = new("Control", nil, {0, 0, 0, 20}) for i, tabName in ipairs(subTabs) do @@ -184,18 +181,12 @@ function CompareTabClass:InitControls() end end - self.controls.notesEdit = new("EditControl", nil, {0, 0, 0, 0}, "", nil, "^%C\t\n", nil, nil, 16, true) - self.controls.notesEdit.shown = function() - return self.compareViewMode == "NOTES" and #self.compareEntries > 0 - end - -- Build B selector dropdown self.controls.compareBuildLabel = new("LabelControl", {"TOPLEFT", self.controls.subTabAnchor, "TOPLEFT"}, {0, -88, 0, 16}, "^7Compare with:") self.controls.compareBuildSelect = new("DropDownControl", {"LEFT", self.controls.compareBuildLabel, "RIGHT"}, {4, 0, 250, 20}, {}, function(index, value) if index and index > 0 and index <= #self.compareEntries then self.activeCompareIndex = index self.treeSearchNeedsSync = true - self.notesActiveEntry = nil end end) self.controls.compareBuildSelect.enabled = function() @@ -1199,7 +1190,6 @@ end function CompareTabClass:RemoveBuild(index) if index >= 1 and index <= #self.compareEntries then t_remove(self.compareEntries, index) - self.notesActiveEntry = nil if self.activeCompareIndex > #self.compareEntries then self.activeCompareIndex = #self.compareEntries end @@ -1686,8 +1676,6 @@ function CompareTabClass:Draw(viewPort, inputEvents) self:DrawCalcs(contentVP, compareEntry) elseif self.compareViewMode == "CONFIG" then self:DrawConfig(contentVP, compareEntry) - elseif self.compareViewMode == "NOTES" then - self:DrawNotes(contentVP, compareEntry, inputEvents) end end @@ -4443,41 +4431,6 @@ function CompareTabClass:DrawConfig(vp, compareEntry) SetViewport() end -function CompareTabClass:DrawNotes(vp, compareEntry, inputEvents) - if not compareEntry then return end - -- Sync EditControl with the active compare entry - if self.notesActiveEntry ~= compareEntry then - self.controls.notesEdit:SetText(compareEntry.notesText or "") - self.notesActiveEntry = compareEntry - end - - -- Handle undo/redo - for id, event in ipairs(inputEvents) do - if event.type == "KeyDown" then - if event.key == "z" and IsKeyDown("CTRL") then - self.controls.notesEdit:Undo() - elseif event.key == "y" and IsKeyDown("CTRL") then - self.controls.notesEdit:Redo() - end - end - end - - self.controls.notesEdit.x = vp.x + 8 - self.controls.notesEdit.y = vp.y + 8 - self.controls.notesEdit.width = function() - return vp.width - 16 - end - self.controls.notesEdit.height = function() - return vp.height - 38 - end - - -- Sync edits back to the compare entry - if compareEntry.notesText ~= self.controls.notesEdit.buf then - compareEntry.notesText = self.controls.notesEdit.buf - end - - main:DrawBackground(vp) -end return CompareTabClass