diff --git a/spec/System/TestItemListControl_spec.lua b/spec/System/TestItemListControl_spec.lua new file mode 100644 index 00000000000..17982f5a310 --- /dev/null +++ b/spec/System/TestItemListControl_spec.lua @@ -0,0 +1,175 @@ +describe("ItemListControl", function() + local originalOpenConfirmPopup + + local function newItemListControl() + local activeItemSet = { + id = 1, + title = "Boss", + ["Body Armour"] = { selItemId = 1 }, + } + local otherItemSet = { + id = 2, + title = "Mapping", + ["Body Armour"] = { selItemId = 2 }, + } + local treeTab = { + activeSpec = 1, + specList = { + { + title = "Boss", + jewels = { [100] = 3 }, + nodes = { [100] = { alloc = true } }, + }, + { + title = "Mapping", + jewels = { [200] = 4 }, + nodes = { [200] = { alloc = true } }, + }, + }, + } + local itemsTab = { + itemOrderList = { 1, 2, 3, 4 }, + items = { + [1] = { id = 1, type = "Body Armour", base = { subType = "" } }, + [2] = { id = 2, type = "Body Armour", base = { subType = "" } }, + [3] = { id = 3, type = "Jewel", base = { subType = "" } }, + [4] = { id = 4, type = "Jewel", base = { subType = "" } }, + }, + itemSetOrderList = { 1, 2 }, + itemSets = { activeItemSet, otherItemSet }, + activeItemSetId = 1, + activeItemSet = activeItemSet, + slots = { }, + build = { + itemListSpecialLinks = { }, + treeListSpecialLinks = { }, + controls = { + buildLoadouts = { list = { "Boss", "Mapping" } }, + }, + treeTab = treeTab, + }, + PopulateSlots = function() end, + AddUndoState = function() end, + } + local control = new("ItemListControl", nil, { 0, 0, 360, 308 }, itemsTab, true) + return control, itemsTab, treeTab + end + + before_each(function() + originalOpenConfirmPopup = main.OpenConfirmPopup + end) + + after_each(function() + main.OpenConfirmPopup = originalOpenConfirmPopup + end) + + it("only shows items from the active item set and passive tree", function() + local control = newItemListControl() + control:UpdateLoadoutList() + control.controls.loadoutFilter.selIndex = 2 + + control:UpdateList() + + assert.are.same({ 1, 3 }, control.list) + end) + + it("uses the selected item set and passive tree for named loadouts", function() + local control = newItemListControl() + control:UpdateLoadoutList() + control.controls.loadoutFilter.selIndex = isValueInArray(control.controls.loadoutFilter.list, "Mapping") + + control:UpdateList() + + assert.are.same({ 2, 4 }, control.list) + end) + + it("matches linked sets and old passive tree display names", function() + local control, itemsTab, treeTab = newItemListControl() + itemsTab.itemSets[2].title = "Gear {mapping}" + treeTab.specList[2].title = "Tree {mapping}" + itemsTab.build.itemListSpecialLinks.mapping = { setId = 2 } + itemsTab.build.treeListSpecialLinks.mapping = { setId = 2 } + itemsTab.build.controls.buildLoadouts.list = { "Tree {mapping}", "[3.28] Boss" } + control:UpdateLoadoutList() + control.controls.loadoutFilter.selIndex = isValueInArray(control.controls.loadoutFilter.list, "Tree {mapping}") + + control:UpdateList() + + assert.are.same({ 2, 4 }, control.list) + + control.controls.loadoutFilter.selIndex = isValueInArray(control.controls.loadoutFilter.list, "[3.28] Boss") + control:UpdateList() + + assert.are.same({ 1, 3 }, control.list) + end) + + it("clears hidden selections and preserves visible selections by item ID", function() + local control = newItemListControl() + control:UpdateLoadoutList() + control.selIndex = 2 + control.selValue = 2 + control.controls.loadoutFilter.selIndex = 2 + + control:UpdateList() + + assert.is_nil(control.selIndex) + assert.is_nil(control.selValue) + + control.selIndex = 3 + control.selValue = 3 + control:UpdateList() + + assert.are.equal(2, control.selIndex) + assert.are.equal(3, control.selValue) + end) + + it("only allows internal reordering in the unfiltered item list", function() + local control = newItemListControl() + control:UpdateLoadoutList() + control:UpdateList() + + assert.is_true(control.isMutable) + + control.controls.loadoutFilter.selIndex = 2 + control:UpdateList() + + assert.is_false(control.isMutable) + + control.controls.loadoutFilter.selIndex = 1 + control:UpdateList() + + assert.is_true(control.isMutable) + end) + + it("refreshes filter options when loadouts are renamed without a new output revision", function() + local control, itemsTab, treeTab = newItemListControl() + itemsTab.build.outputRevision = 1 + control.lastOutputRevision = 1 + control:UpdateLoadoutList() + itemsTab.itemSets[2].title = "Renamed" + treeTab.specList[2].title = "Renamed" + itemsTab.build.controls.buildLoadouts.list = { "Boss", "Renamed" } + wipeTable(itemsTab.itemOrderList) + wipeTable(itemsTab.items) + + control:Draw({ x = 0, y = 0, width = 1920, height = 1080 }) + + assert.is_nil(isValueInArray(control.controls.loadoutFilter.list, "Mapping")) + assert.is_not_nil(isValueInArray(control.controls.loadoutFilter.list, "Renamed")) + end) + + it("clears the canonical item order when deleting all from a filtered list", function() + local control, itemsTab = newItemListControl() + control:UpdateLoadoutList() + control.controls.loadoutFilter.selIndex = isValueInArray(control.controls.loadoutFilter.list, "Mapping") + control:UpdateList() + main.OpenConfirmPopup = function(_, _, _, _, onConfirm) + onConfirm() + end + + control.controls.deleteAll.onClick() + + assert.are.same({ }, itemsTab.itemOrderList) + assert.are.same({ }, itemsTab.items) + end) +end) diff --git a/src/Classes/ItemListControl.lua b/src/Classes/ItemListControl.lua index 53f9fde103e..11ae71487b7 100644 --- a/src/Classes/ItemListControl.lua +++ b/src/Classes/ItemListControl.lua @@ -4,21 +4,46 @@ -- Build item list control. -- local pairs = pairs +local ipairs = ipairs local t_insert = table.insert local ItemListClass = newClass("ItemListControl", "ListControl", function(self, anchor, rect, itemsTab, forceTooltip) self.ListControl(anchor, rect, 16, "VERTICAL", true, itemsTab.itemOrderList, forceTooltip) self.itemsTab = itemsTab - self.label = "^7All items:" self.defaultText = "^x7F7F7FThis is the list of items that have been added to this build.\nYou can add items to this list by dragging them from\none of the other lists, or by clicking 'Add to build' when\nviewing an item." self.dragTargetList = { } - self.controls.delete = new("ButtonControl", {"BOTTOMRIGHT",self,"TOPRIGHT"}, {0, -2, 60, 18}, "Delete", function() - self:OnSelDelete(self.selIndex, self.selValue) + self.controls.loadoutFilter = new("DropDownControl", {"BOTTOMLEFT",self,"TOPLEFT"}, {0, -2, 110, 18}, nil, function() + self:UpdateList() end) - self.controls.delete.enabled = function() - return self.selValue ~= nil + self.controls.loadoutFilter.enableDroppedWidth = true + self.controls.sort = new("ButtonControl", {"LEFT",self.controls.loadoutFilter,"RIGHT"}, {4, 0, 42, 18}, "Sort", function() + itemsTab:SortItemList() + self:UpdateList() + end) + self.controls.deleteUnused = new("ButtonControl", {"LEFT",self.controls.sort,"RIGHT"}, {4, 0, 84, 18}, "Del Unused", function() + local delList = {} + for _, itemId in pairs(itemsTab.itemOrderList) do + if not itemsTab:GetEquippedSlotForItem(itemsTab.items[itemId]) and not self:FindEquippedAbyssJewel(itemId, false) and not self:FindSocketedJewel(itemId, false) then + t_insert(delList, itemId) + end + end + -- Delete in reverse order so as to not delete the wrong item whilst deleting + for i = #delList, 1, -1 do + itemsTab:DeleteItem(itemsTab.items[delList[i]], true) + end + -- Rebuild cluster jewel graphs, populate slots, and create an undo state, as we deferred doing this during itemsTab:DeleteItem(...) + for _, spec in pairs(itemsTab.build.treeTab.specList) do + spec:BuildClusterJewelGraphs() + end + itemsTab:PopulateSlots() + itemsTab:AddUndoState() + itemsTab.build.buildFlag = true + self:UpdateList() + end) + self.controls.deleteUnused.enabled = function() + return #self.list > 0 end - self.controls.deleteAll = new("ButtonControl", {"RIGHT",self.controls.delete,"LEFT"}, {-4, 0, 70, 18}, "Delete All", function() + self.controls.deleteAll = new("ButtonControl", {"LEFT",self.controls.deleteUnused,"RIGHT"}, {4, 0, 58, 18}, "Del All", function() main:OpenConfirmPopup("Delete All", "Are you sure you want to delete all items in this build?", "Delete", function() for _, slot in pairs(itemsTab.slots) do slot:SetSelItemId(0) @@ -28,44 +53,147 @@ local ItemListClass = newClass("ItemListControl", "ListControl", function(self, spec.jewels[nodeId] = 0 end end - wipeTable(self.list) + wipeTable(itemsTab.itemOrderList) wipeTable(self.itemsTab.items) itemsTab:PopulateSlots() itemsTab:AddUndoState() itemsTab.build.buildFlag = true self.selIndex = nil self.selValue = nil + self:UpdateList() end) end) self.controls.deleteAll.enabled = function() return #self.list > 0 end - self.controls.deleteUnused = new("ButtonControl", {"RIGHT",self.controls.deleteAll,"LEFT"}, {-4, 0, 100, 18}, "Delete Unused", function() - local delList = {} - for _, itemId in pairs(self.list) do - if not itemsTab:GetEquippedSlotForItem(itemsTab.items[itemId]) and not self:FindEquippedAbyssJewel(itemId, false) and not self:FindSocketedJewel(itemId, false) then - t_insert(delList, itemId) + self.controls.delete = new("ButtonControl", {"LEFT",self.controls.deleteAll,"RIGHT"}, {4, 0, 50, 18}, "Delete", function() + self:OnSelDelete(self.selIndex, self.selValue) + end) + self.controls.delete.enabled = function() + return self.selValue ~= nil + end +end) + +function ItemListClass:UpdateLoadoutList() + local list = { "Any Loadout", "Current Loadout", "Unused Items" } + local listValues = { ["Any Loadout"] = true, ["Current Loadout"] = true, ["Unused Items"] = true } + local build = self.itemsTab.build + if build and build.controls and build.controls.buildLoadouts then + for _, val in ipairs(build.controls.buildLoadouts.list) do + if val ~= "^7^7Loadouts:" and val ~= "^7^7-----" and val ~= "^7^7New Loadout" and val ~= "^7^7Sync" and val ~= "^7^7Help >>" then + if not listValues[val] then + t_insert(list, val) + listValues[val] = true + end end end - -- Delete in reverse order so as to not delete the wrong item whilst deleting - for i = #delList, 1, -1 do - itemsTab:DeleteItem(itemsTab.items[delList[i]], true) + end + if self.itemsTab.itemSetOrderList then + for _, itemSetId in ipairs(self.itemsTab.itemSetOrderList) do + local itemSet = self.itemsTab.itemSets[itemSetId] + local title = itemSet and (itemSet.title or "Default") + if title and not listValues[title] then + t_insert(list, title) + listValues[title] = true + end end - -- Rebuild cluster jewel graphs, populate slots, and create an undo state, as we deferred doing this during itemsTab:DeleteItem(...) - for _, spec in pairs(itemsTab.build.treeTab.specList) do - spec:BuildClusterJewelGraphs() + end + local listKey = table.concat(list, "\0") + if self.loadoutListKey == listKey then + return false + end + self.loadoutListKey = listKey + local selIndex = self.controls.loadoutFilter.selIndex or 1 + local selValue = self.controls.loadoutFilter.list and self.controls.loadoutFilter.list[selIndex] or "Any Loadout" + self.controls.loadoutFilter:SetList(list) + self.controls.loadoutFilter.selIndex = isValueInArray(list, selValue) or 1 + return true +end + +function ItemListClass:UpdateList() + self:UpdateLoadoutList() + local selFilter = self.controls.loadoutFilter.selIndex or 1 + local filterVal = self.controls.loadoutFilter.list[selFilter] or "Any Loadout" + local selectedItemId = self.selValue + + if selFilter == 1 or filterVal == "Any Loadout" then + self.list = self.itemsTab.itemOrderList + self.isMutable = true + else + self.isMutable = false + local filterItemSet + local filterSpec + if selFilter == 2 or filterVal == "Current Loadout" then + filterItemSet = self.itemsTab.activeItemSet + filterSpec = self.itemsTab.build.treeTab.specList[self.itemsTab.build.treeTab.activeSpec] + elseif selFilter ~= 3 and filterVal ~= "Unused Items" then + local filterTitle = filterVal:gsub("^%[[^%]]+%]%s*", "") + for _, itemSetId in ipairs(self.itemsTab.itemSetOrderList) do + local itemSet = self.itemsTab.itemSets[itemSetId] + if (itemSet.title or "Default") == filterTitle then + filterItemSet = itemSet + break + end + end + local treeTab = self.itemsTab.build.treeTab + for _, spec in ipairs(treeTab.specList) do + if (spec.title or "Default") == filterTitle then + filterSpec = spec + break + end + end + local linkId = filterVal:match("%{(%w+)%}") + local itemLink = linkId and self.itemsTab.build.itemListSpecialLinks and self.itemsTab.build.itemListSpecialLinks[linkId] + local treeLink = linkId and self.itemsTab.build.treeListSpecialLinks and self.itemsTab.build.treeListSpecialLinks[linkId] + filterItemSet = filterItemSet or #self.itemsTab.itemSetOrderList == 1 and self.itemsTab.itemSets[self.itemsTab.itemSetOrderList[1]] or itemLink and self.itemsTab.itemSets[itemLink.setId] + filterSpec = filterSpec or #treeTab.specList == 1 and treeTab.specList[1] or treeLink and treeTab.specList[treeLink.setId] end - itemsTab:PopulateSlots() - itemsTab:AddUndoState() - itemsTab.build.buildFlag = true - end) - self.controls.deleteUnused.enabled = function() - return #self.list > 0 + filterItemSet = filterItemSet or { } + local newList = {} + for _, itemId in ipairs(self.itemsTab.itemOrderList) do + local item = self.itemsTab.items[itemId] + if item then + if selFilter == 3 or filterVal == "Unused Items" then + if not self.itemsTab:GetEquippedSlotForItem(item) and not self:FindEquippedAbyssJewel(itemId, false) and not self:FindSocketedJewel(itemId, false) then + t_insert(newList, itemId) + end + else + local inLoadout = false + for _, slot in pairs(filterItemSet) do + if type(slot) == "table" and slot.selItemId == itemId then + inLoadout = true + break + end + end + if not inLoadout and filterSpec then + for nodeId, jewelId in pairs(filterSpec.jewels) do + if jewelId == itemId and filterSpec.nodes[nodeId] and filterSpec.nodes[nodeId].alloc then + inLoadout = true + break + end + end + end + if inLoadout then + t_insert(newList, itemId) + end + end + end + end + self.list = newList end - self.controls.sort = new("ButtonControl", {"RIGHT",self.controls.deleteUnused,"LEFT"}, {-4, 0, 60, 18}, "Sort", function() - itemsTab:SortItemList() - end) -end) + self.selIndex = selectedItemId and isValueInArray(self.list, selectedItemId) or nil + self.selValue = self.selIndex and self.list[self.selIndex] or nil +end + +function ItemListClass:Draw(viewPort) + local loadoutListChanged = self:UpdateLoadoutList() + local outputRevision = self.itemsTab.build and self.itemsTab.build.outputRevision + if loadoutListChanged or outputRevision ~= self.lastOutputRevision then + self.lastOutputRevision = outputRevision + self:UpdateList() + end + self.ListControl.Draw(self, viewPort) +end function ItemListClass:FindSocketedJewel(jewelId, excludeActiveSpec) if not self.itemsTab.items[jewelId] or self.itemsTab.items[jewelId].type ~= "Jewel" then @@ -151,6 +279,7 @@ function ItemListClass:ReceiveDrag(type, value, source) self.itemsTab:AddItem(newItem, true, self.selDragIndex) self.itemsTab:PopulateSlots() self.itemsTab:AddUndoState() + self:UpdateList() end end @@ -204,6 +333,7 @@ function ItemListClass:OnSelDelete(index, itemId) self.itemsTab:DeleteItem(item) self.selIndex = nil self.selValue = nil + self:UpdateList() end) else local equipSet = self:FindEquippedAbyssJewel(itemId, true) @@ -213,6 +343,7 @@ function ItemListClass:OnSelDelete(index, itemId) self.itemsTab:DeleteItem(item) self.selIndex = nil self.selValue = nil + self:UpdateList() end) else local equipTree = self:FindSocketedJewel(itemId, true) @@ -221,11 +352,13 @@ function ItemListClass:OnSelDelete(index, itemId) self.itemsTab:DeleteItem(item) self.selIndex = nil self.selValue = nil + self:UpdateList() end) else self.itemsTab:DeleteItem(item) self.selIndex = nil self.selValue = nil + self:UpdateList() end end end