From 9276ee1c158902deb1cdee00bd1c0b466b20184f Mon Sep 17 00:00:00 2001 From: xoxorwr Date: Tue, 21 Jul 2026 11:18:43 +0200 Subject: [PATCH 1/4] Lazy gem DPS sorting --- src/Classes/GemSelectControl.lua | 133 ++++++++++++++++++++++++------- 1 file changed, 102 insertions(+), 31 deletions(-) diff --git a/src/Classes/GemSelectControl.lua b/src/Classes/GemSelectControl.lua index dddbde3a88..85b7677678 100644 --- a/src/Classes/GemSelectControl.lua +++ b/src/Classes/GemSelectControl.lua @@ -168,11 +168,12 @@ function GemSelectClass:BuildList(buf) t_remove(tagsList, 1) -- Search for gem name using increasingly broad search patterns + local lowerSearch = searchTerm:lower() local patternList = { - "^ " .. searchTerm:lower().."$", -- Exact match - "^" .. searchTerm:lower():gsub("%a", " %0%%l+") .. "$", -- Simple abbreviation ("CtF" -> "Cold to Fire") - "^ " .. searchTerm:lower(), -- Starts with - searchTerm:lower(), -- Contains + "^ " .. lowerSearch.."$", -- Exact match + "^" .. lowerSearch:gsub("%a", " %0%%l+") .. "$", -- Simple abbreviation ("CtF" -> "Cold to Fire") + "^ " .. lowerSearch, -- Starts with + lowerSearch, -- Contains } for i, pattern in ipairs(patternList) do local matchList = { } @@ -340,22 +341,22 @@ function GemSelectClass:UpdateSortCache() -- Check for nil because some fields may not be populated, default to 0 local baseDPS = (dpsField == "FullDPS" and calcBase[dpsField] ~= nil and calcBase[dpsField]) or (calcBase.Minion and calcBase.Minion.CombinedDPS) or (calcBase[dpsField] ~= nil and calcBase[dpsField]) or 0 + sortCache.calcFunc = calcFunc + sortCache.useFullDPS = useFullDPS + sortCache.baseDPS = baseDPS + sortCache.dpsField = dpsField + sortCache.pendingGems = { } + sortCache.dpsBatchIndex = 1 + for gemId, gemData in pairs(self.gems) do sortCache.dps[gemId] = baseDPS - -- Ignore gems that don't support the active skill + -- Gems that support the active skill or have global effects need DPS calc if sortCache.canSupport[gemId] or (gemData.grantedEffect.hasGlobalEffect and not gemData.grantedEffect.support) then - local output = self:CalcOutputWithThisGem(calcFunc, gemData, useFullDPS) - -- Check for nil because some fields may not be populated, default to 0 - sortCache.dps[gemId] = (dpsField == "FullDPS" and output[dpsField] ~= nil and output[dpsField]) or (output.Minion and output.Minion.CombinedDPS) or (output[dpsField] ~= nil and output[dpsField]) or 0 - end - -- Color based on the DPS - if sortCache.dps[gemId] > baseDPS then - sortCache.dpsColor[gemId] = "^x228866" - elseif sortCache.dps[gemId] < baseDPS then - sortCache.dpsColor[gemId] = "^xFF4422" - else - sortCache.dpsColor[gemId] = "^xFFFF66" + sortCache.pendingGems[#sortCache.pendingGems + 1] = gemId end + -- Neutral color until DPS is computed + -- TODO: perhaps we can insert a symbol to incidate sorting is pending? + sortCache.dpsColor[gemId] = "" end --ConPrintf("Gem Selector time: %d ms", GetTime() - start) @@ -363,14 +364,19 @@ end function GemSelectClass:SortGemList(gemList) local sortCache = self.sortCache + local gems = self.gems + -- cache names to avoid repeated table lookups in comparator + local names = {} + for _, gemId in ipairs(gemList) do + local gem = gems[gemId] + names[gemId] = gem and gem.name or gemId + end t_sort(gemList, function(a, b) if sortCache.canSupport[a] == sortCache.canSupport[b] then if self.skillsTab.sortGemsByDPS and sortCache.dps[a] ~= sortCache.dps[b] then return sortCache.dps[a] > sortCache.dps[b] else - local nameA = (self.gems[a] and self.gems[a].name) or a - local nameB = (self.gems[b] and self.gems[b].name) or b - return nameA < nameB + return names[a] < names[b] end else return sortCache.canSupport[a] @@ -378,6 +384,54 @@ function GemSelectClass:SortGemList(gemList) end) end +function GemSelectClass:UpdatePendingDPS(batchSize) + local sortCache = self.sortCache + if not sortCache or not sortCache.pendingGems then return end + + batchSize = batchSize or 8 + local index = sortCache.dpsBatchIndex + local pending = sortCache.pendingGems + local calcFunc = sortCache.calcFunc + local useFullDPS = sortCache.useFullDPS + local baseDPS = sortCache.baseDPS + local dpsField = sortCache.dpsField + local needsResort = false + + for i = 1, batchSize do + if index > #pending then break end + local gemId = pending[index] + local gemData = self.gems[gemId] + if gemData then + local output = self:CalcOutputWithThisGem(calcFunc, gemData, useFullDPS) + sortCache.dps[gemId] = (dpsField == "FullDPS" and output[dpsField] ~= nil and output[dpsField]) or (output.Minion and output.Minion.CombinedDPS) or (output[dpsField] ~= nil and output[dpsField]) or 0 + -- Color based on the DPS + if sortCache.dps[gemId] > baseDPS then + sortCache.dpsColor[gemId] = "^x228866" + elseif sortCache.dps[gemId] < baseDPS then + sortCache.dpsColor[gemId] = "^xFF4422" + else + sortCache.dpsColor[gemId] = "^xFFFF66" + end + needsResort = true + end + index = index + 1 + end + + sortCache.dpsBatchIndex = index + + -- Only re-sort when there's no active search (empty buffer = search order doesn't apply) + if #self.searchStr == 0 then + if needsResort and index <= #pending then + self:SortGemList(self.list) + elseif index > #pending then + self:SortGemList(self.list) + sortCache.pendingGems = nil + end + elseif index > #pending then + sortCache.pendingGems = nil + end +end + function GemSelectClass:UpdateGem(setText, addUndo, focusLost) local gemId = self.list[m_max(self.selIndex, 1)] -- don't process unless the buffer equals an actual gem, whether typed, clicked, or navigated with arrows @@ -428,6 +482,10 @@ function GemSelectClass:IsMouseOver() end function GemSelectClass:Draw(viewPort, noTooltip) + -- Process pending DPS computations (8 per frame should be good) + -- TODO: Ideally this should run in the background + self:UpdatePendingDPS(8) + self.EditControl:Draw(viewPort, noTooltip and not self.forceTooltip) local x, y = self:GetPos() local width, height = self:GetSize() @@ -475,10 +533,10 @@ function GemSelectClass:Draw(viewPort, noTooltip) local gemText = gemData and gemData.name or "" DrawString(0, y, "LEFT", height - 4, "VAR", gemText) if gemData then - if gemData.grantedEffect.support and self.sortCache.canSupport[gemId] then + if gemData.grantedEffect.support and self.sortCache.canSupport[gemId] and self.sortCache.dpsColor[gemId] ~= "" then SetDrawColor(self.sortCache.dpsColor[gemId]) main:DrawCheckMark(width - 4 - height / 2 - (scrollBar.enabled and 18 or 0), y + (height - 4) / 2, (height - 4) * 0.8) - elseif gemData.grantedEffect.hasGlobalEffect then + elseif gemData.grantedEffect.hasGlobalEffect and self.sortCache.dpsColor[gemId] ~= "" then SetDrawColor(self.sortCache.dpsColor[gemId]) DrawString(width - 4 - height / 2 - (scrollBar.enabled and 18 or 0), y - 2, "CENTER_X", height, "VAR", "+") end @@ -487,14 +545,23 @@ function GemSelectClass:Draw(viewPort, noTooltip) SetViewport() self:DrawControls(viewPort, (noTooltip and not self.forceTooltip) and self) if self.hoverSel then - local calcFunc, calcBase = self.skillsTab.build.calcsTab:GetMiscCalculator(self.build) - if calcFunc then - self.tooltip:Clear() - local gemData = self.gems[self.list[self.hoverSel]] - local output= self:CalcOutputWithThisGem(calcFunc, gemData, self.skillsTab.sortGemsByDPSField == "FullDPS") - local gemInstance = { + -- Debounce hover + if self.hoverSel == self.lastHoverSel then + self.hoverFrameCount = (self.hoverFrameCount or 0) + 1 + else + self.lastHoverSel = self.hoverSel + self.hoverFrameCount = 0 + end + if self.hoverFrameCount >= 2 then + local calcFunc, calcBase = self.skillsTab.build.calcsTab:GetMiscCalculator(self.build) + if calcFunc then + self.tooltip:Clear() + local gemData = self.gems[self.list[self.hoverSel]] + local output= self:CalcOutputWithThisGem(calcFunc, gemData, self.skillsTab.sortGemsByDPSField == "FullDPS") + local gemInstance = { level = self.skillsTab:ProcessGemLevel(gemData, self.imbuedSelect), quality = self.skillsTab.defaultGemQuality or 0, + qualityId = "Default", count = 1, enabled = true, enableGlobal1 = true, @@ -505,11 +572,15 @@ function GemSelectClass:Draw(viewPort, noTooltip) displayEffect = nil, gemData = gemData } - self:AddGemTooltip(gemInstance) - self.tooltip:AddSeparator(10) - self.skillsTab.build:AddStatComparesToTooltip(self.tooltip, calcBase, output, "^7Selecting this gem will give you:") - self.tooltip:Draw(x, y + height + 2 + (self.hoverSel - 1) * (height - 4) - scrollBar.offset, width, height - 4, viewPort) + self:AddGemTooltip(gemInstance) + self.tooltip:AddSeparator(10) + self.skillsTab.build:AddStatComparesToTooltip(self.tooltip, calcBase, output, "^7Selecting this gem will give you:") + self.tooltip:Draw(x, y + height + 2 + (self.hoverSel - 1) * (height - 4) - scrollBar.offset, width, height - 4, viewPort) + end end + else + self.lastHoverSel = nil + self.hoverFrameCount = 0 end SetDrawLayer(nil, 0) else From dadefe8a569e6460f8b4d274798f695690e09609 Mon Sep 17 00:00:00 2001 From: xoxorwr Date: Tue, 21 Jul 2026 11:22:29 +0200 Subject: [PATCH 2/4] Apply spellcheck review --- src/Classes/GemSelectControl.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Classes/GemSelectControl.lua b/src/Classes/GemSelectControl.lua index 85b7677678..219676ac1c 100644 --- a/src/Classes/GemSelectControl.lua +++ b/src/Classes/GemSelectControl.lua @@ -355,7 +355,7 @@ function GemSelectClass:UpdateSortCache() sortCache.pendingGems[#sortCache.pendingGems + 1] = gemId end -- Neutral color until DPS is computed - -- TODO: perhaps we can insert a symbol to incidate sorting is pending? + -- TODO: perhaps we can insert a symbol to indicate sorting is pending? sortCache.dpsColor[gemId] = "" end From 845fea175e0a2d5fcf50dcfb2cf24e7b0a376371 Mon Sep 17 00:00:00 2001 From: xoxorwr Date: Tue, 21 Jul 2026 11:41:10 +0200 Subject: [PATCH 3/4] Replace fixed batch sort with coroutine --- src/Classes/GemSelectControl.lua | 55 ++++++++++++++++---------------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/src/Classes/GemSelectControl.lua b/src/Classes/GemSelectControl.lua index 219676ac1c..f45e72d09a 100644 --- a/src/Classes/GemSelectControl.lua +++ b/src/Classes/GemSelectControl.lua @@ -53,6 +53,7 @@ local GemSelectClass = newClass("GemSelectControl", "EditControl", function(self lifeReservationPercent = "LifePercent", } self.imbuedSelect = imbued + self.dpsBuildFlag = false end) function GemSelectClass:CalcOutputWithThisGem(calcFunc, gemData, useFullDPS) @@ -346,7 +347,6 @@ function GemSelectClass:UpdateSortCache() sortCache.baseDPS = baseDPS sortCache.dpsField = dpsField sortCache.pendingGems = { } - sortCache.dpsBatchIndex = 1 for gemId, gemData in pairs(self.gems) do sortCache.dps[gemId] = baseDPS @@ -355,11 +355,10 @@ function GemSelectClass:UpdateSortCache() sortCache.pendingGems[#sortCache.pendingGems + 1] = gemId end -- Neutral color until DPS is computed - -- TODO: perhaps we can insert a symbol to indicate sorting is pending? sortCache.dpsColor[gemId] = "" end - --ConPrintf("Gem Selector time: %d ms", GetTime() - start) + self.dpsBuildFlag = true end function GemSelectClass:SortGemList(gemList) @@ -384,27 +383,22 @@ function GemSelectClass:SortGemList(gemList) end) end -function GemSelectClass:UpdatePendingDPS(batchSize) +function GemSelectClass:DPSBuilder() local sortCache = self.sortCache if not sortCache or not sortCache.pendingGems then return end - batchSize = batchSize or 8 - local index = sortCache.dpsBatchIndex local pending = sortCache.pendingGems local calcFunc = sortCache.calcFunc local useFullDPS = sortCache.useFullDPS local baseDPS = sortCache.baseDPS local dpsField = sortCache.dpsField - local needsResort = false + local start = GetTime() - for i = 1, batchSize do - if index > #pending then break end - local gemId = pending[index] + for index, gemId in ipairs(pending) do local gemData = self.gems[gemId] if gemData then local output = self:CalcOutputWithThisGem(calcFunc, gemData, useFullDPS) sortCache.dps[gemId] = (dpsField == "FullDPS" and output[dpsField] ~= nil and output[dpsField]) or (output.Minion and output.Minion.CombinedDPS) or (output[dpsField] ~= nil and output[dpsField]) or 0 - -- Color based on the DPS if sortCache.dps[gemId] > baseDPS then sortCache.dpsColor[gemId] = "^x228866" elseif sortCache.dps[gemId] < baseDPS then @@ -412,24 +406,21 @@ function GemSelectClass:UpdatePendingDPS(batchSize) else sortCache.dpsColor[gemId] = "^xFFFF66" end - needsResort = true end - index = index + 1 + local now = GetTime() + if now - start > 50 then + if #self.searchStr == 0 then + self:SortGemList(self.list) + end + coroutine.yield() + start = now + end end - sortCache.dpsBatchIndex = index - - -- Only re-sort when there's no active search (empty buffer = search order doesn't apply) if #self.searchStr == 0 then - if needsResort and index <= #pending then - self:SortGemList(self.list) - elseif index > #pending then - self:SortGemList(self.list) - sortCache.pendingGems = nil - end - elseif index > #pending then - sortCache.pendingGems = nil + self:SortGemList(self.list) end + sortCache.pendingGems = nil end function GemSelectClass:UpdateGem(setText, addUndo, focusLost) @@ -482,9 +473,19 @@ function GemSelectClass:IsMouseOver() end function GemSelectClass:Draw(viewPort, noTooltip) - -- Process pending DPS computations (8 per frame should be good) - -- TODO: Ideally this should run in the background - self:UpdatePendingDPS(8) + if self.dpsBuildFlag then + self.dpsBuildFlag = false + self.dpsBuilder = coroutine.create(self.DPSBuilder) + end + if self.dpsBuilder then + local res, errMsg = coroutine.resume(self.dpsBuilder, self) + if launch.devMode and not res then + error(errMsg) + end + if coroutine.status(self.dpsBuilder) == "dead" then + self.dpsBuilder = nil + end + end self.EditControl:Draw(viewPort, noTooltip and not self.forceTooltip) local x, y = self:GetPos() From f6efd1d609f124e7ef864393793bdc978a1c5eab Mon Sep 17 00:00:00 2001 From: xoxorwr Date: Tue, 21 Jul 2026 11:50:58 +0200 Subject: [PATCH 4/4] Add sorting indicator --- src/Classes/GemSelectControl.lua | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Classes/GemSelectControl.lua b/src/Classes/GemSelectControl.lua index f45e72d09a..19f17644b6 100644 --- a/src/Classes/GemSelectControl.lua +++ b/src/Classes/GemSelectControl.lua @@ -505,6 +505,11 @@ function GemSelectClass:Draw(viewPort, noTooltip) end if self.dropped then SetDrawLayer(nil, 5) + if self.dpsBuilder then + local dots = ("."):rep((math.floor(GetTime() / 500) % 3) + 1) + SetDrawColor(0.75, 0.75, 0.75) + DrawString(x + width - 80, y, "LEFT", height - 2, "VAR", "Sorting" .. dots) + end local cursorX, cursorY = GetCursorPos() self.hoverSel = mOverComp == "DROP" and math.floor((cursorY - y - height + scrollBar.offset) / (height - 4)) + 1 if self.hoverSel and not self.gems[self.list[self.hoverSel]] then