From 6cb89ae17da1aa7e160f33c519c193a0ac6a0e6a Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Fri, 6 Mar 2026 18:01:54 +0100 Subject: [PATCH 01/17] 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 1427ff4a97..4241f7050b 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 b33f75c995..8288763e44 100644 --- a/src/Modules/Build.lua +++ b/src/Modules/Build.lua @@ -454,6 +454,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) @@ -588,6 +592,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 = { @@ -1201,6 +1206,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 @@ -1220,6 +1227,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 2adf8c6a7b89921ae2421b3048be89608a6a8ca5 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Sat, 7 Mar 2026 15:08:23 +0100 Subject: [PATCH 02/17] 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 6ce2412ae9ce1a5ad3e6927fa12628fb9d3d91cd Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Tue, 10 Mar 2026 09:54:43 +0100 Subject: [PATCH 03/17] 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 ea630c7f037e135c014610f8ca9ba6836fd9efe5 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Tue, 10 Mar 2026 16:28:18 +0100 Subject: [PATCH 04/17] 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 27ed5bd42db2427ad89fe1c3ca36c7ce0a8344dd Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Tue, 17 Mar 2026 10:03:31 +0100 Subject: [PATCH 05/17] 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 7008b6df7ef5f3dda0da3659fd2a0f5e7ace37b0 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Tue, 17 Mar 2026 12:34:34 +0100 Subject: [PATCH 06/17] 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 19f02333ed23beecf25e11d35480fdad0e40121d Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Tue, 17 Mar 2026 13:28:24 +0100 Subject: [PATCH 07/17] 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 5d9c0d80064520c989e3005ec81ac01bd53c0ca4 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Tue, 17 Mar 2026 15:18:56 +0100 Subject: [PATCH 08/17] 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 aac868095dce59c916497e71afe294a43eef6857 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Wed, 18 Mar 2026 21:55:35 +0100 Subject: [PATCH 09/17] 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 5470dc44cc98e200282c389b628da2d0aad40a24 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Wed, 18 Mar 2026 22:28:30 +0100 Subject: [PATCH 10/17] 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 c8b542f164bdac0c582e5640722a6dc7c9bb527b Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Thu, 19 Mar 2026 10:04:33 +0100 Subject: [PATCH 11/17] 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 25208bf0559f9dc3f010431ef427561fe1b73d9a Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Fri, 20 Mar 2026 09:30:39 +0100 Subject: [PATCH 12/17] 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 7aa705d40a9fc4f8eff4f8270570b9b11df9bdb4 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Sat, 21 Mar 2026 08:57:38 +0100 Subject: [PATCH 13/17] 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 1486b1f885befff3c892df63c3c0019d0a59c241 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Sun, 22 Mar 2026 01:35:33 +0100 Subject: [PATCH 14/17] 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 3370a436e2..84bada98df 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 ce953e9c7834a6cef8150b3fbe1067861448523c Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Sun, 22 Mar 2026 10:29:15 +0100 Subject: [PATCH 15/17] 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 3268853c0c5a19ed6a791eeba401ab72d03d47c6 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Sun, 22 Mar 2026 10:59:25 +0100 Subject: [PATCH 16/17] 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 5b52f89e6ca2dc725edec280fadbb0f213ddd271 Mon Sep 17 00:00:00 2001 From: Oscar Boking Date: Mon, 23 Mar 2026 00:19:20 +0100 Subject: [PATCH 17/17] 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()