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 6700cbc7ca..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 @@ -149,11 +150,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 = { } @@ -327,37 +329,41 @@ 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) 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,64 @@ 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 + + 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() + + for index, gemId in ipairs(pending) do + local gemData = self.gems[gemId] + if gemData then + 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" + 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 + self:SortCurrentList() + if self.dpsBuilderCallback then + self.dpsBuilderCallback(m_floor(index/#pending*100)) + end + coroutine.yield() + start = now + end + end + + self:SortCurrentList() + 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 @@ -414,7 +478,40 @@ 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 + 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 +530,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 +563,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 @@ -473,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 @@ -508,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 @@ -584,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 @@ -638,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 @@ -660,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) @@ -704,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