From d6bf665d6fdaa6edb5866e574748eabb7381e559 Mon Sep 17 00:00:00 2001 From: xoxorwr Date: Sun, 30 Aug 2026 10:07:24 +0000 Subject: [PATCH 1/2] Apply changes from https://github.com/PathOfBuildingCommunity/PathOfBuilding/pull/9959 --- src/Classes/GemSelectControl.lua | 89 +++++++++++++++++++++++++--- src/Classes/GemSelectControl.lua.rej | 30 ++++++++++ 2 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 src/Classes/GemSelectControl.lua.rej diff --git a/src/Classes/GemSelectControl.lua b/src/Classes/GemSelectControl.lua index 6700cbc7ca..45794b791c 100644 --- a/src/Classes/GemSelectControl.lua +++ b/src/Classes/GemSelectControl.lua @@ -149,11 +149,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 = { } @@ -350,14 +351,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] @@ -365,6 +371,49 @@ function GemSelectClass:SortGemList(gemList) end) end +function GemSelectClass:DPSBuilder() + local sortCache = self.sortCache + if not sortCache or not sortCache.pendingGems then return end + + local pending = sortCache.pendingGems + local calcFunc = sortCache.calcFunc + local useFullDPS = sortCache.useFullDPS + local baseDPS = sortCache.baseDPS + local dpsField = sortCache.dpsField + local start = GetTime() + + 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 + 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 + end + local now = GetTime() + if now - start > 50 then + if #self.searchStr == 0 then + self:SortGemList(self.list) + end + if self.dpsBuilderCallback then + self.dpsBuilderCallback(m_floor(index/#pending*100)) + end + coroutine.yield() + start = now + end + end + + if #self.searchStr == 0 then + self:SortGemList(self.list) + end + sortCache.pendingGems = nil +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 @@ -415,6 +464,24 @@ function GemSelectClass:IsMouseOver() end function GemSelectClass:Draw(viewPort, noTooltip) + self.sortPercentage = self.sortPercentage or "" + if self.dpsBuildFlag then + self.dpsBuildFlag = false + self.dpsBuilder = coroutine.create(self.DPSBuilder) + self.dpsBuilderCallback = function(percentage) + self.sortPercentage = ("%d%%"):format(percentage) + end + 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() local width, height = self:GetSize() @@ -433,6 +500,10 @@ function GemSelectClass:Draw(viewPort, noTooltip) end if self.dropped then SetDrawLayer(nil, 5) + if self.dpsBuilder then + SetDrawColor(0.75, 0.75, 0.75) + DrawString(x + width - 4, y, "RIGHT_X", height - 2, "VAR", "Sorting " .. self.sortPercentage) + 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 @@ -462,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 diff --git a/src/Classes/GemSelectControl.lua.rej b/src/Classes/GemSelectControl.lua.rej new file mode 100644 index 0000000000..6c7516eae6 --- /dev/null +++ b/src/Classes/GemSelectControl.lua.rej @@ -0,0 +1,30 @@ +diff a/src/Classes/GemSelectControl.lua b/src/Classes/GemSelectControl.lua (rejected hunks) +@@ -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) From 207dcf92bac0f1b8d9d6369b4bfea2276da95b73 Mon Sep 17 00:00:00 2001 From: LocalIdentity Date: Mon, 31 Aug 2026 02:52:17 +1000 Subject: [PATCH 2/2] Fix port --- spec/System/TestGemSelectControl_spec.lua | 99 ++++++++++++++++++++ src/Classes/GemSelectControl.lua | 106 +++++++++++++--------- src/Classes/GemSelectControl.lua.rej | 30 ------ 3 files changed, 164 insertions(+), 71 deletions(-) create mode 100644 spec/System/TestGemSelectControl_spec.lua delete mode 100644 src/Classes/GemSelectControl.lua.rej diff --git a/spec/System/TestGemSelectControl_spec.lua b/spec/System/TestGemSelectControl_spec.lua new file mode 100644 index 0000000000..031cd90927 --- /dev/null +++ b/spec/System/TestGemSelectControl_spec.lua @@ -0,0 +1,99 @@ +describe("TestGemSelectControl", function() + before_each(function() + newBuild() + build.skillsTab:PasteSocketGroup("Fireball 20/0 1") + runCallback("OnFrame") + end) + + local function getGemSelect() + return build.skillsTab.gemSlots[1].nameSpec + end + + local function selectTemporaryGem(control, gemName) + control:OnFocusGained() + control.buf = gemName + control:BuildList(control.buf) + control.selIndex = 1 + control:UpdateGem(false, false, false) + assert.are.equal(gemName, build.skillsTab.displayGroup.gemList[1].nameSpec) + end + + it("queues DPS sorting with PoE2's fast calculation options", function() + local control = getGemSelect() + assert.is_false(control.dpsBuildFlag) + + control:OnFocusGained() + + assert.is_true(control.dpsBuildFlag) + assert.is_table(control.sortCache.pendingGems) + assert.same({ + nodeAlloc = true, + requirementsItems = true, + requirementsGems = true, + skipEHP = true, + fullDPSOnly = false, + }, control.sortCache.fastCalcOptions) + end) + + it("keeps the current gem selected when the DPS list is resorted", function() + local control = getGemSelect() + control:OnFocusGained() + control.buf = control.buf:lower() + control.selIndex = 0 + + control:SortCurrentList() + + local selectedGem = control.gems[control.list[control.selIndex]] + assert.is_not_nil(selectedGem) + assert.are.equal("fireball", selectedGem.name:lower()) + end) + + it("waits for the hover selection to settle before calculating its tooltip", function() + local control = getGemSelect() + control.hoverSel = 2 + + assert.is_false(control:IsHoverSelectionReady()) + assert.is_false(control:IsHoverSelectionReady()) + assert.is_true(control:IsHoverSelectionReady()) + + control.hoverSel = 3 + assert.is_false(control:IsHoverSelectionReady()) + control.hoverSel = nil + assert.is_false(control:IsHoverSelectionReady()) + assert.is_nil(control.lastHoverSel) + assert.are.equal(0, control.hoverFrameCount) + end) + + it("restores the existing gem when selection is cancelled with Escape", function() + local control = getGemSelect() + selectTemporaryGem(control, "Spark") + + control:OnKeyDown("ESCAPE") + + assert.are.equal("Fireball", control.buf) + assert.are.equal("Fireball", build.skillsTab.displayGroup.gemList[1].nameSpec) + end) + + it("restores the existing gem when the control loses focus", function() + local control = getGemSelect() + selectTemporaryGem(control, "Spark") + + control:OnFocusLost() + + assert.are.equal("Fireball", control.buf) + assert.are.equal("Fireball", build.skillsTab.displayGroup.gemList[1].nameSpec) + end) + + it("restores the existing gem when clicking outside the dropdown", function() + local control = getGemSelect() + selectTemporaryGem(control, "Spark") + control.IsMouseOver = function() + return false + end + + control:OnKeyDown("LEFTBUTTON") + + assert.are.equal("Fireball", control.buf) + assert.are.equal("Fireball", build.skillsTab.displayGroup.gemList[1].nameSpec) + end) +end) diff --git a/src/Classes/GemSelectControl.lua b/src/Classes/GemSelectControl.lua index 45794b791c..9cd49c6ce5 100644 --- a/src/Classes/GemSelectControl.lua +++ b/src/Classes/GemSelectControl.lua @@ -45,6 +45,7 @@ function GemSelectClass:GemSelectControl(anchor, rect, skillsTab, index, changeF self.forceTooltip = forceTooltip self.list = { } self.mode = "" + self.dpsBuildFlag = false self.changeFunc = function() if not self.dropped then self.dropped = true @@ -328,25 +329,24 @@ 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.fastCalcOptions = fastCalcOptions + sortCache.baseDPS = baseDPS + sortCache.dpsField = dpsField + sortCache.pendingGems = { } + 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, fastCalcOptions) - -- 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" + t_insert(sortCache.pendingGems, gemId) end + -- Neutral color until DPS is computed + sortCache.dpsColor[gemId] = "" end - --ConPrintf("Gem Selector time: %d ms", GetTime() - start) + self.dpsBuildFlag = true end function GemSelectClass:SortGemList(gemList) @@ -371,6 +371,24 @@ function GemSelectClass:SortGemList(gemList) end) end +function GemSelectClass:SyncSelection() + self.selIndex = 0 + for index, gemId in ipairs(self.list) do + if self.gems[gemId] and self.gems[gemId].name:lower() == self.buf:lower() then + self.selIndex = index + self:ScrollSelIntoView() + break + end + end +end + +function GemSelectClass:SortCurrentList() + if #self.searchStr == 0 then + self:SortGemList(self.list) + self:SyncSelection() + end +end + function GemSelectClass:DPSBuilder() local sortCache = self.sortCache if not sortCache or not sortCache.pendingGems then return end @@ -378,6 +396,7 @@ function GemSelectClass:DPSBuilder() local pending = sortCache.pendingGems local calcFunc = sortCache.calcFunc local useFullDPS = sortCache.useFullDPS + local fastCalcOptions = sortCache.fastCalcOptions local baseDPS = sortCache.baseDPS local dpsField = sortCache.dpsField local start = GetTime() @@ -385,7 +404,7 @@ function GemSelectClass:DPSBuilder() for index, gemId in ipairs(pending) do local gemData = self.gems[gemId] if gemData then - local output = self:CalcOutputWithThisGem(calcFunc, gemData, useFullDPS) + local output = self:CalcOutputWithThisGem(calcFunc, gemData, useFullDPS, fastCalcOptions) 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 if sortCache.dps[gemId] > baseDPS then sortCache.dpsColor[gemId] = "^x228866" @@ -397,9 +416,7 @@ function GemSelectClass:DPSBuilder() end local now = GetTime() if now - start > 50 then - if #self.searchStr == 0 then - self:SortGemList(self.list) - end + self:SortCurrentList() if self.dpsBuilderCallback then self.dpsBuilderCallback(m_floor(index/#pending*100)) end @@ -408,9 +425,7 @@ function GemSelectClass:DPSBuilder() end end - if #self.searchStr == 0 then - self:SortGemList(self.list) - end + self:SortCurrentList() sortCache.pendingGems = nil end @@ -463,6 +478,21 @@ function GemSelectClass:IsMouseOver() return mOver, mOverComp end +function GemSelectClass:IsHoverSelectionReady() + if not self.hoverSel then + self.lastHoverSel = nil + self.hoverFrameCount = 0 + return false + end + if self.hoverSel == self.lastHoverSel then + self.hoverFrameCount = (self.hoverFrameCount or 0) + 1 + else + self.lastHoverSel = self.hoverSel + self.hoverFrameCount = 0 + end + return self.hoverFrameCount >= 2 +end + function GemSelectClass:Draw(viewPort, noTooltip) self.sortPercentage = self.sortPercentage or "" if self.dpsBuildFlag then @@ -544,7 +574,7 @@ function GemSelectClass:Draw(viewPort, noTooltip) end SetViewport() self:DrawControls(viewPort, (noTooltip and not self.forceTooltip) and self) - if self.hoverSel then + if self:IsHoverSelectionReady() then local calcFunc, calcBase = self.skillsTab.build.calcsTab:GetMiscCalculator(self.build) if calcFunc then self.tooltip.maxWidth = 500 @@ -579,6 +609,8 @@ function GemSelectClass:Draw(viewPort, noTooltip) end SetDrawLayer(nil, 0) else + self.lastHoverSel = nil + self.hoverFrameCount = 0 -- not dropped local hoverControl if self.skillsTab.selControl and self.skillsTab.selControl._className == "GemSelectControl" then @@ -655,27 +687,23 @@ end function GemSelectClass:OnFocusGained() self.EditControl:OnFocusGained() self.dropped = true - self.selIndex = 0 self:UpdateSortCache() self:BuildList("") - for index, gemId in pairs(self.list) do - if self.gems[gemId].name == self.buf then - self.selIndex = index - self:ScrollSelIntoView() - break - end - end + self:SyncSelection() self.initialBuf = self.buf - self.initialIndex = self.selIndex +end + +function GemSelectClass:CancelSelection() + self.dropped = false + self.buf = self.initialBuf + self:BuildList("") + self:SyncSelection() + self:UpdateGem(false, true, true) end function GemSelectClass:OnFocusLost() if self.dropped then - self.dropped = false - if self.noMatches then - self:SetText("") - end - self:UpdateGem(true, true, true) + self:CancelSelection() end end @@ -709,6 +737,7 @@ function GemSelectClass:OnKeyDown(key, doubleClick) end if self.dropped then if key:match("BUTTON") and not self:IsMouseOver() then + self:CancelSelection() return end if key == "LEFTBUTTON" then @@ -731,11 +760,7 @@ function GemSelectClass:OnKeyDown(key, doubleClick) self:UpdateGem(true, true, true) return elseif key == "ESCAPE" then - self.dropped = false - self:BuildList("") - self.buf = self.initialBuf - self.selIndex = self.initialIndex - self:UpdateGem(false,true, true) + self:CancelSelection() return elseif self.controls.scrollBar:IsScrollUpKey(key) then self.controls.scrollBar:Scroll(-1) @@ -775,7 +800,6 @@ function GemSelectClass:OnKeyDown(key, doubleClick) elseif key == "RETURN" or key == "RIGHTBUTTON" then self.dropped = true self:UpdateSortCache() - self.initialIndex = self.selIndex self.initialBuf = self.buf return self end diff --git a/src/Classes/GemSelectControl.lua.rej b/src/Classes/GemSelectControl.lua.rej deleted file mode 100644 index 6c7516eae6..0000000000 --- a/src/Classes/GemSelectControl.lua.rej +++ /dev/null @@ -1,30 +0,0 @@ -diff a/src/Classes/GemSelectControl.lua b/src/Classes/GemSelectControl.lua (rejected hunks) -@@ -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)