From 578e423a7e01ffec5d97acdef23007974b1aaa93 Mon Sep 17 00:00:00 2001 From: Can Uysal Date: Fri, 14 Aug 2026 22:56:27 +0300 Subject: [PATCH 1/5] Add loadout manager for copying, renaming and deleting loadouts * Add "Manage Loadouts" option to the loadouts dropdown, replacing "Sync" * Manager supports New, Copy, Rename and Delete across all four associated sets, plus double-click to activate * Copy creates the new loadout automatically with a unique name * Move Sync into the manager popup * Update help.txt and add tests --- help.txt | 8 +- spec/System/TestLoadouts_spec.lua | 70 ++++++++ src/Classes/LoadoutListControl.lua | 278 +++++++++++++++++++++++++++++ src/Modules/Build.lua | 127 ++++++++----- 4 files changed, 439 insertions(+), 44 deletions(-) create mode 100644 spec/System/TestLoadouts_spec.lua create mode 100644 src/Classes/LoadoutListControl.lua diff --git a/help.txt b/help.txt index b728173120d..f05c843fec3 100644 --- a/help.txt +++ b/help.txt @@ -134,7 +134,13 @@ Loadouts can be selected from the dropdown in the top middle of the screen. Sele 3) If there is only one set for a set type (except passive tree), e.g. "Default" config set, it will be assigned to all existing loadouts. The "New Loadout" option allows the user to create all four sets from a single popup for convenience. -The "Sync" option is a backup option to force the UI to update in case the user has changed this data behind the scenes. +The "Manage Loadouts" option opens the loadout manager, which works like the set managers found on the Tree, Skills, Items, and Configuration tabs: + - "New" creates all four sets from a single popup, the same as the "New Loadout" option + - "Copy" duplicates all four sets of the selected loadout into a new loadout automatically + - "Rename" renames all four sets of the selected loadout, preserving any identifiers inside braces { } + - "Delete" deletes all four sets of the selected loadout; sets shared with other loadouts, or the last remaining set of a type, are kept + - Double-clicking a loadout activates it + - "Sync" is a backup option to force the UI to update in case the user has changed this data behind the scenes ---[Party Tab] diff --git a/spec/System/TestLoadouts_spec.lua b/spec/System/TestLoadouts_spec.lua new file mode 100644 index 00000000000..6d877d50482 --- /dev/null +++ b/spec/System/TestLoadouts_spec.lua @@ -0,0 +1,70 @@ +describe("TestLoadouts", function() + before_each(function() + newBuild() + end) + + teardown(function() + -- newBuild() takes care of resetting everything in before_each() + end) + + it("creates a new loadout with all four sets", function() + build:NewLoadout("My Loadout") + assert.are.equals("My Loadout", build.treeTab.specList[#build.treeTab.specList].title) + assert.are.equals("My Loadout", build.itemsTab.itemSets[build.itemsTab.itemSetOrderList[#build.itemsTab.itemSetOrderList]].title) + assert.are.equals("My Loadout", build.skillsTab.skillSets[build.skillsTab.skillSetOrderList[#build.skillsTab.skillSetOrderList]].title) + assert.are.equals("My Loadout", build.configTab.configSets[build.configTab.configSetOrderList[#build.configTab.configSetOrderList]].title) + local found = false + for _, loadout in ipairs(build.loadoutList) do + if loadout.name == "My Loadout" then + found = true + end + end + assert.is_true(found) + end) + + it("copies a loadout automatically", function() + build:NewLoadout("My Loadout") + local listControl = new("LoadoutListControl"):LoadoutListControl(nil, {0, 0, 450, 200}, build) + local loadout + for _, entry in ipairs(build.loadoutList) do + if entry.name == "My Loadout" then + loadout = entry + end + end + assert.is_not_nil(loadout) + listControl:CopyLoadout(loadout) + local copyFound = false + for _, entry in ipairs(build.loadoutList) do + if entry.name == "My Loadout (copy)" then + copyFound = true + end + end + assert.is_true(copyFound) + -- Copying again picks a unique name + listControl:CopyLoadout(loadout) + local copy2Found = false + for _, entry in ipairs(build.loadoutList) do + if entry.name == "My Loadout (copy 2)" then + copy2Found = true + end + end + assert.is_true(copy2Found) + end) + + it("resolves link identifier loadouts", function() + build.treeTab.specList[1].title = "Tree {A}" + local itemSet = build.itemsTab.itemSets[build.itemsTab.itemSetOrderList[1]] + itemSet.title = "Items {A}" + -- With only one skill and config set, they are shared by all loadouts + build:SyncLoadouts() + local loadout + for _, entry in ipairs(build.loadoutList) do + if entry.name == "Tree {A}" then + loadout = entry + end + end + assert.is_not_nil(loadout) + assert.are.equals(1, loadout.specId) + assert.are.equals(build.itemsTab.itemSetOrderList[1], loadout.itemSetId) + end) +end) diff --git a/src/Classes/LoadoutListControl.lua b/src/Classes/LoadoutListControl.lua new file mode 100644 index 00000000000..6716cb132dd --- /dev/null +++ b/src/Classes/LoadoutListControl.lua @@ -0,0 +1,278 @@ +-- Path of Building +-- +-- Class: Loadout List +-- Loadout list control. +-- +local t_insert = table.insert +local t_remove = table.remove +local m_max = math.max + +---@class LoadoutListControl: ListControl +local LoadoutListClass = newClass("LoadoutListControl", "ListControl") + +function LoadoutListClass:LoadoutListControl(anchor, rect, build) + self:ListControl(anchor, rect, 16, "VERTICAL", false, build.loadoutList) + self.build = build + self.controls.copy = new("ButtonControl"):ButtonControl({"BOTTOMLEFT",self,"TOP"}, {2, -4, 60, 18}, "Copy", function() + self:CopyLoadout(self.selValue) + end) + self.controls.copy.enabled = function() + return self.selValue ~= nil + end + self.controls.delete = new("ButtonControl"):ButtonControl({"LEFT",self.controls.copy,"RIGHT"}, {4, 0, 60, 18}, "Delete", function() + self:OnSelDelete(self.selIndex, self.selValue) + end) + self.controls.delete.enabled = function() + return self.selValue ~= nil + end + self.controls.rename = new("ButtonControl"):ButtonControl({"BOTTOMRIGHT",self,"TOP"}, {-2, -4, 60, 18}, "Rename", function() + self:RenameLoadout(self.selValue) + end) + self.controls.rename.enabled = function() + return self.selValue ~= nil + end + self.controls.new = new("ButtonControl"):ButtonControl({"RIGHT",self.controls.rename,"LEFT"}, {-4, 0, 60, 18}, "New", function() + build:OpenLoadoutNamePopup() + end) + return self +end + +-- Returns true if another loadout also uses this set +function LoadoutListClass:IsSetShared(field, setId, loadout) + for _, other in ipairs(self.build.loadoutList) do + if other ~= loadout and other[field] == setId then + return true + end + end + return false +end + +function LoadoutListClass:NameInUse(name) + for _, loadout in ipairs(self.build.loadoutList) do + if loadout.name == name then + return true + end + end + return false +end + +-- Re-selects the loadout owning the given passive tree after the list has been rebuilt +function LoadoutListClass:SelectLoadoutBySpecId(specId) + self.selIndex = nil + self.selValue = nil + for index, loadout in ipairs(self.list) do + if loadout.specId == specId then + self.selIndex = index + self.selValue = loadout + break + end + end +end + +-- Copies all four sets of the given loadout into a new loadout +function LoadoutListClass:CopyLoadout(loadout) + local build = self.build + local baseName = (loadout.name:gsub("%s*%{[%w,]+%}", "")) + if baseName == "" then + baseName = "Default" + end + local newName = baseName .. " (copy)" + local suffix = 1 + while self:NameInUse(newName) do + suffix = suffix + 1 + newName = baseName .. " (copy " .. suffix .. ")" + end + + local spec = build.treeTab.specList[loadout.specId] + local newSpec = new("PassiveSpec"):PassiveSpec(build, spec.treeVersion) + newSpec.title = newName + newSpec.jewels = copyTable(spec.jewels) + newSpec:RestoreUndoState(spec:CreateUndoState()) + newSpec:BuildClusterJewelGraphs() + t_insert(build.treeTab.specList, newSpec) + build.treeTab.modFlag = true + + local itemsTab = build.itemsTab + local newItemSet = copyTable(itemsTab.itemSets[loadout.itemSetId]) + newItemSet.title = newName + newItemSet.id = 1 + while itemsTab.itemSets[newItemSet.id] do + newItemSet.id = newItemSet.id + 1 + end + itemsTab.itemSets[newItemSet.id] = newItemSet + t_insert(itemsTab.itemSetOrderList, newItemSet.id) + itemsTab:AddUndoState() + + local skillsTab = build.skillsTab + local skillSet = skillsTab.skillSets[loadout.skillSetId] + local newSkillSet = copyTable(skillSet, true) + newSkillSet.title = newName + newSkillSet.socketGroupList = { } + for _, socketGroup in pairs(skillSet.socketGroupList) do + local newGroup = copyTable(socketGroup, true) + newGroup.gemList = { } + for gemIndex, gem in pairs(socketGroup.gemList) do + newGroup.gemList[gemIndex] = copyTable(gem, true) + end + t_insert(newSkillSet.socketGroupList, newGroup) + end + newSkillSet.id = 1 + while skillsTab.skillSets[newSkillSet.id] do + newSkillSet.id = newSkillSet.id + 1 + end + skillsTab.skillSets[newSkillSet.id] = newSkillSet + t_insert(skillsTab.skillSetOrderList, newSkillSet.id) + skillsTab:AddUndoState() + + local configTab = build.configTab + local newConfigSet = copyTable(configTab.configSets[loadout.configSetId]) + newConfigSet.title = newName + newConfigSet.id = 1 + while configTab.configSets[newConfigSet.id] do + newConfigSet.id = newConfigSet.id + 1 + end + configTab.configSets[newConfigSet.id] = newConfigSet + t_insert(configTab.configSetOrderList, newConfigSet.id) + configTab:AddUndoState() + + self:UpdateItemsTabPassiveTreeDropdown() + build:SyncLoadouts() + self:SelectLoadoutBySpecId(#build.treeTab.specList) +end + +function LoadoutListClass:RenameLoadout(loadout) + local build = self.build + local spec = build.treeTab.specList[loadout.specId] + local controls = { } + local currentName = ((spec.title or "Default"):gsub("%s*%{[%w,]+%}", "")) + controls.label = new("LabelControl"):LabelControl(nil, {0, 20, 0, 16}, "^7Enter new name for this loadout:") + controls.edit = new("EditControl"):EditControl(nil, {0, 40, 350, 20}, currentName, nil, nil, 100, function(buf) + controls.save.enabled = buf:match("%S") + end) + controls.save = new("ButtonControl"):ButtonControl(nil, {-45, 70, 80, 20}, "Save", function() + local newName = controls.edit.buf + -- Rename each of the associated sets, preserving any {link} identifiers in their titles + local function newTitle(title) + local linkIdentifier = title and title:match("%{[%w,]+%}") + return linkIdentifier and (newName .. " " .. linkIdentifier) or newName + end + spec.title = newTitle(spec.title) + local itemSet = build.itemsTab.itemSets[loadout.itemSetId] + itemSet.title = newTitle(itemSet.title) + local skillSet = build.skillsTab.skillSets[loadout.skillSetId] + skillSet.title = newTitle(skillSet.title) + local configSet = build.configTab.configSets[loadout.configSetId] + configSet.title = newTitle(configSet.title) + build.treeTab.modFlag = true + build.itemsTab:AddUndoState() + build.skillsTab:AddUndoState() + build.configTab:AddUndoState() + self:UpdateItemsTabPassiveTreeDropdown() + build:SyncLoadouts() + self:SelectLoadoutBySpecId(loadout.specId) + main:ClosePopup() + end) + controls.save.enabled = false + controls.cancel = new("ButtonControl"):ButtonControl(nil, {45, 70, 80, 20}, "Cancel", function() + main:ClosePopup() + end) + main:OpenPopup(370, 100, "Rename", controls, "save", "edit", "cancel") +end + +function LoadoutListClass:IsActiveLoadout(loadout) + local build = self.build + return loadout.specId == build.treeTab.activeSpec + and loadout.itemSetId == build.itemsTab.activeItemSetId + and loadout.skillSetId == build.skillsTab.activeSkillSetId + and loadout.configSetId == build.configTab.activeConfigSetId +end + +function LoadoutListClass:GetRowValue(column, index, loadout) + if column == 1 then + return loadout.name .. (self:IsActiveLoadout(loadout) and " ^9(Current)" or "") + end +end + +function LoadoutListClass:OnSelClick(index, loadout, doubleClick) + if doubleClick then + local build = self.build + if loadout.specId ~= build.treeTab.activeSpec then + build.treeTab:SetActiveSpec(loadout.specId) + end + if loadout.itemSetId ~= build.itemsTab.activeItemSetId then + build.itemsTab:SetActiveItemSet(loadout.itemSetId) + end + if loadout.skillSetId ~= build.skillsTab.activeSkillSetId then + build.skillsTab:SetActiveSkillSet(loadout.skillSetId) + end + if loadout.configSetId ~= build.configTab.activeConfigSetId then + build.configTab:SetActiveConfigSet(loadout.configSetId) + end + build:SyncLoadouts() + self:SelectLoadoutBySpecId(loadout.specId) + end +end + +function LoadoutListClass:OnSelDelete(index, loadout) + local build = self.build + main:OpenConfirmPopup("Delete Loadout", "Are you sure you want to delete '"..loadout.name.."'?\nThis will delete the passive tree, item set, skill set and config set associated with it.\nSets shared with other loadouts, or the last remaining set of a type, will be kept.", "Delete", function() + local treeTab, itemsTab, skillsTab, configTab = build.treeTab, build.itemsTab, build.skillsTab, build.configTab + if #treeTab.specList > 1 and not self:IsSetShared("specId", loadout.specId, loadout) then + t_remove(treeTab.specList, loadout.specId) + if loadout.specId == treeTab.activeSpec then + treeTab:SetActiveSpec(m_max(1, loadout.specId - 1)) + else + treeTab.activeSpec = isValueInArray(treeTab.specList, build.spec) + end + treeTab.modFlag = true + end + if #itemsTab.itemSetOrderList > 1 and not self:IsSetShared("itemSetId", loadout.itemSetId, loadout) then + local setIndex = isValueInArray(itemsTab.itemSetOrderList, loadout.itemSetId) + t_remove(itemsTab.itemSetOrderList, setIndex) + itemsTab.itemSets[loadout.itemSetId] = nil + if loadout.itemSetId == itemsTab.activeItemSetId then + itemsTab:SetActiveItemSet(itemsTab.itemSetOrderList[m_max(1, setIndex - 1)]) + end + itemsTab:AddUndoState() + end + if #skillsTab.skillSetOrderList > 1 and not self:IsSetShared("skillSetId", loadout.skillSetId, loadout) then + local setIndex = isValueInArray(skillsTab.skillSetOrderList, loadout.skillSetId) + t_remove(skillsTab.skillSetOrderList, setIndex) + skillsTab.skillSets[loadout.skillSetId] = nil + if loadout.skillSetId == skillsTab.activeSkillSetId then + skillsTab:SetActiveSkillSet(skillsTab.skillSetOrderList[m_max(1, setIndex - 1)]) + end + skillsTab:AddUndoState() + end + if #configTab.configSetOrderList > 1 and not self:IsSetShared("configSetId", loadout.configSetId, loadout) then + local setIndex = isValueInArray(configTab.configSetOrderList, loadout.configSetId) + t_remove(configTab.configSetOrderList, setIndex) + configTab.configSets[loadout.configSetId] = nil + if loadout.configSetId == configTab.activeConfigSetId then + configTab:SetActiveConfigSet(configTab.configSetOrderList[m_max(1, setIndex - 1)]) + end + configTab:AddUndoState() + end + self.selIndex = nil + self.selValue = nil + self:UpdateItemsTabPassiveTreeDropdown() + build:SyncLoadouts() + end) +end + +function LoadoutListClass:OnSelKeyDown(index, loadout, key) + if key == "F2" then + self:RenameLoadout(loadout) + end +end + +-- Update the passive tree dropdown control in itemsTab +function LoadoutListClass:UpdateItemsTabPassiveTreeDropdown() + local build = self.build + local newSpecList = { } + for i, spec in ipairs(build.treeTab.specList) do + newSpecList[i] = spec.title or "Default" + end + build.itemsTab.controls.specSelect:SetList(newSpecList) + build.itemsTab.controls.specSelect.selIndex = build.treeTab.activeSpec +end diff --git a/src/Modules/Build.lua b/src/Modules/Build.lua index dcfbc74e892..96e3c48b5b2 100644 --- a/src/Modules/Build.lua +++ b/src/Modules/Build.lua @@ -306,8 +306,8 @@ function buildMode:Init(dbFileName, buildName, buildXML, convertBuild, importLin self.controls.buildLoadouts:SetSel(1) return end - if value == "^7^7Sync" then - self:SyncLoadouts() + if value == "^7^7Manage Loadouts" then + self:OpenLoadoutManagePopup() self.controls.buildLoadouts:SetSel(1) return end @@ -317,40 +317,7 @@ function buildMode:Init(dbFileName, buildName, buildXML, convertBuild, importLin return end if value == "^7^7New Loadout" then - local controls = { } - controls.label = new("LabelControl"):LabelControl(nil, {0, 20, 0, 16}, "^7Enter name for this loadout:") - controls.edit = new("EditControl"):EditControl(nil, {0, 40, 350, 20}, "New Loadout", nil, nil, 100, function(buf) - controls.save.enabled = buf:match("%S") - end) - controls.save = new("ButtonControl"):ButtonControl(nil, {-45, 70, 80, 20}, "Save", function() - local loadout = controls.edit.buf - - local newSpec = new("PassiveSpec"):PassiveSpec(self, latestTreeVersion) - newSpec.title = loadout - t_insert(self.treeTab.specList, newSpec) - - local itemSet = self.itemsTab:NewItemSet(#self.itemsTab.itemSets + 1) - t_insert(self.itemsTab.itemSetOrderList, itemSet.id) - itemSet.title = loadout - - local skillSet = self.skillsTab:NewSkillSet(#self.skillsTab.skillSets + 1) - t_insert(self.skillsTab.skillSetOrderList, skillSet.id) - skillSet.title = loadout - - local configSet = self.configTab:NewConfigSet(#self.configTab.configSets + 1) - t_insert(self.configTab.configSetOrderList, configSet.id) - configSet.title = loadout - - self:SyncLoadouts() - self.modFlag = true - main:ClosePopup() - end) - controls.save.enabled = false - controls.cancel = new("ButtonControl"):ButtonControl(nil, {45, 70, 80, 20}, "Cancel", function() - main:ClosePopup() - end) - main:OpenPopup(370, 100, "Set Name", controls, "save", "edit", "cancel") - + self:OpenLoadoutNamePopup() self.controls.buildLoadouts:SetSel(1) return end @@ -758,6 +725,9 @@ end function buildMode:SyncLoadouts() self.controls.buildLoadouts.list = {"No Loadouts"} + -- Resolved loadouts (name plus the ids of the four associated sets), used by the loadout manager + self.loadoutList = self.loadoutList or { } + wipeTable(self.loadoutList) local filteredList = {"^7^7Loadouts:"} local treeList = {} @@ -797,7 +767,7 @@ function buildMode:SyncLoadouts() transferTable = {} end else - t_insert(treeList, (spec.treeVersion ~= latestTreeVersion and ("["..treeVersions[spec.treeVersion].display.."] ") or "")..(specTitle)) + t_insert(treeList, { specId = id, name = (spec.treeVersion ~= latestTreeVersion and ("["..treeVersions[spec.treeVersion].display.."] ") or "")..(specTitle) }) end end @@ -822,7 +792,7 @@ function buildMode:SyncLoadouts() transferTable = {} end else - setList[setTitle] = true + setList[setTitle] = set end end end @@ -832,15 +802,31 @@ function buildMode:SyncLoadouts() -- loop over all for exact match loadouts for id, tree in ipairs(treeList) do - if (oneItem or itemList[tree]) and (oneSkill or skillList[tree]) and (oneConfig or configList[tree]) then - t_insert(filteredList, tree) + local itemSetId = oneItem and self.itemsTab.itemSetOrderList[1] or itemList[tree.name] + local skillSetId = oneSkill and self.skillsTab.skillSetOrderList[1] or skillList[tree.name] + local configSetId = oneConfig and self.configTab.configSetOrderList[1] or configList[tree.name] + if itemSetId and skillSetId and configSetId then + t_insert(filteredList, tree.name) + t_insert(self.loadoutList, { name = tree.name, specId = tree.specId, itemSetId = itemSetId, skillSetId = skillSetId, configSetId = configSetId }) end end -- loop over the identifiers found within braces and set the loadout name to the TreeSet for _, tree in ipairs(sortedTreeListSpecialLinks) do local treeLinkId = tree.linkId - if ((oneItem or self.itemListSpecialLinks[treeLinkId]) and (oneSkill or self.skillListSpecialLinks[treeLinkId]) and (oneConfig or self.configListSpecialLinks[treeLinkId])) then - t_insert(filteredList, tree.setName .." {"..treeLinkId.."}") + local itemLink = self.itemListSpecialLinks[treeLinkId] + local skillLink = self.skillListSpecialLinks[treeLinkId] + local configLink = self.configListSpecialLinks[treeLinkId] + if ((oneItem or itemLink) and (oneSkill or skillLink) and (oneConfig or configLink)) then + local loadoutName = tree.setName .." {"..treeLinkId.."}" + t_insert(filteredList, loadoutName) + t_insert(self.loadoutList, { + name = loadoutName, + specId = tree.setId, + linkId = treeLinkId, + itemSetId = oneItem and self.itemsTab.itemSetOrderList[1] or itemLink.setId, + skillSetId = oneSkill and self.skillsTab.skillSetOrderList[1] or skillLink.setId, + configSetId = oneConfig and self.configTab.configSetOrderList[1] or configLink.setId, + }) end end end @@ -848,7 +834,7 @@ function buildMode:SyncLoadouts() -- giving the options unique formatting so it can not match with user-created sets t_insert(filteredList, "^7^7-----") t_insert(filteredList, "^7^7New Loadout") - t_insert(filteredList, "^7^7Sync") + t_insert(filteredList, "^7^7Manage Loadouts") t_insert(filteredList, "^7^7Help >>") if #filteredList > 0 then @@ -883,6 +869,61 @@ function buildMode:SyncLoadouts() return treeList, itemList, skillList, configList end +-- Creates a new loadout: a passive tree, item set, skill set and config set sharing the given name +function buildMode:NewLoadout(loadoutName) + local newSpec = new("PassiveSpec"):PassiveSpec(self, latestTreeVersion) + newSpec.title = loadoutName + t_insert(self.treeTab.specList, newSpec) + + local itemSet = self.itemsTab:NewItemSet(#self.itemsTab.itemSets + 1) + t_insert(self.itemsTab.itemSetOrderList, itemSet.id) + itemSet.title = loadoutName + + local skillSet = self.skillsTab:NewSkillSet(#self.skillsTab.skillSets + 1) + t_insert(self.skillsTab.skillSetOrderList, skillSet.id) + skillSet.title = loadoutName + + local configSet = self.configTab:NewConfigSet(#self.configTab.configSets + 1) + t_insert(self.configTab.configSetOrderList, configSet.id) + configSet.title = loadoutName + + self:SyncLoadouts() + self.modFlag = true +end + +-- Opens the naming popup for a new loadout +function buildMode:OpenLoadoutNamePopup() + local controls = { } + controls.label = new("LabelControl"):LabelControl(nil, {0, 20, 0, 16}, "^7Enter name for this loadout:") + controls.edit = new("EditControl"):EditControl(nil, {0, 40, 350, 20}, "New Loadout", nil, nil, 100, function(buf) + controls.save.enabled = buf:match("%S") + end) + controls.save = new("ButtonControl"):ButtonControl(nil, {-45, 70, 80, 20}, "Save", function() + self:NewLoadout(controls.edit.buf) + main:ClosePopup() + end) + controls.save.enabled = false + controls.cancel = new("ButtonControl"):ButtonControl(nil, {45, 70, 80, 20}, "Cancel", function() + main:ClosePopup() + end) + main:OpenPopup(370, 100, "Set Name", controls, "save", "edit", "cancel") +end + +-- Opens the loadout manager +function buildMode:OpenLoadoutManagePopup() + local syncButton = new("ButtonControl"):ButtonControl(nil, {-50, 260, 90, 20}, "Sync", function() + self:SyncLoadouts() + end) + syncButton.tooltipText = "Force the loadout list to update in case sets have been changed behind the scenes." + main:OpenPopup(470, 290, "Manage Loadouts", { + new("LoadoutListControl"):LoadoutListControl(nil, {0, 50, 450, 200}, self), + syncButton, + new("ButtonControl"):ButtonControl(nil, {50, 260, 90, 20}, "Done", function() + main:ClosePopup() + end), + }) +end + function buildMode:EstimatePlayerProgress() if self.spec then local PointsUsed, AscUsed, SecondaryAscUsed = self.spec:CountAllocNodes() From 1090b85fdaa0714d9a21db6432f19802fd5b07c2 Mon Sep 17 00:00:00 2001 From: Can Uysal Date: Sat, 15 Aug 2026 11:09:06 +0300 Subject: [PATCH 2/5] Add custom loadout creation to the loadout manager * Add "New/Copy Custom" button, which creates a loadout choosing per set whether to start fresh or copy an existing one * Default the choices to the currently active sets, so saving immediately copies the active loadout * Split the per-set copy and delete logic out of Copy/Delete so both paths share it * Add tests for copying the untitled default loadout, deleting a loadout, keeping shared sets, and resetting the dropdown selection --- help.txt | 1 + spec/System/TestLoadouts_spec.lua | 130 ++++++++++++ src/Classes/LoadoutListControl.lua | 319 ++++++++++++++++++++--------- 3 files changed, 351 insertions(+), 99 deletions(-) diff --git a/help.txt b/help.txt index f05c843fec3..b071e6d8304 100644 --- a/help.txt +++ b/help.txt @@ -137,6 +137,7 @@ The "New Loadout" option allows the user to create all four sets from a single p The "Manage Loadouts" option opens the loadout manager, which works like the set managers found on the Tree, Skills, Items, and Configuration tabs: - "New" creates all four sets from a single popup, the same as the "New Loadout" option - "Copy" duplicates all four sets of the selected loadout into a new loadout automatically + - "New/Copy Custom" creates a loadout from a mix of sets, choosing for each of the four whether to start fresh or copy an existing set. It defaults to the currently active sets, so saving straight away copies the active loadout - "Rename" renames all four sets of the selected loadout, preserving any identifiers inside braces { } - "Delete" deletes all four sets of the selected loadout; sets shared with other loadouts, or the last remaining set of a type, are kept - Double-clicking a loadout activates it diff --git a/spec/System/TestLoadouts_spec.lua b/spec/System/TestLoadouts_spec.lua index 6d877d50482..0b7997125c6 100644 --- a/spec/System/TestLoadouts_spec.lua +++ b/spec/System/TestLoadouts_spec.lua @@ -51,6 +51,136 @@ describe("TestLoadouts", function() assert.is_true(copy2Found) end) + it("creates a custom loadout from a mix of new and copied sets", function() + build:NewLoadout("Source") + local listControl = new("LoadoutListControl"):LoadoutListControl(nil, {0, 0, 450, 200}, build) + local source + for _, entry in ipairs(build.loadoutList) do + if entry.name == "Source" then + source = entry + end + end + assert.is_not_nil(source) + + -- Copy the tree and item set, but start the skill and config sets fresh + listControl:CreateLoadout("Custom", source.specId, source.itemSetId, nil, nil) + local custom + for _, entry in ipairs(build.loadoutList) do + if entry.name == "Custom" then + custom = entry + end + end + assert.is_not_nil(custom) + -- Every set is newly created, so none are shared with the source loadout + assert.are_not.equals(source.specId, custom.specId) + assert.are_not.equals(source.itemSetId, custom.itemSetId) + assert.are_not.equals(source.skillSetId, custom.skillSetId) + assert.are_not.equals(source.configSetId, custom.configSetId) + assert.are.equals("Custom", build.treeTab.specList[custom.specId].title) + assert.are.equals("Custom", build.itemsTab.itemSets[custom.itemSetId].title) + assert.are.equals("Custom", build.skillsTab.skillSets[custom.skillSetId].title) + assert.are.equals("Custom", build.configTab.configSets[custom.configSetId].title) + end) + + it("creates an all new custom loadout when nothing is copied", function() + local listControl = new("LoadoutListControl"):LoadoutListControl(nil, {0, 0, 450, 200}, build) + listControl:CreateLoadout("All New", nil, nil, nil, nil) + local found = false + for _, entry in ipairs(build.loadoutList) do + if entry.name == "All New" then + found = true + end + end + assert.is_true(found) + end) + + it("generates unique default names for custom loadouts", function() + local listControl = new("LoadoutListControl"):LoadoutListControl(nil, {0, 0, 450, 200}, build) + assert.are.equals("New Loadout Custom", listControl:UniqueName("New Loadout Custom")) + listControl:CreateLoadout("New Loadout Custom", nil, nil, nil, nil) + assert.are.equals("New Loadout Custom 2", listControl:UniqueName("New Loadout Custom")) + end) + + it("copies the untitled default loadout without erroring", function() + local listControl = new("LoadoutListControl"):LoadoutListControl(nil, {0, 0, 450, 200}, build) + -- A fresh build has one untitled set of each type, which resolves to a "Default" loadout + assert.are.equals(1, #build.loadoutList) + assert.are.equals("Default", build.loadoutList[1].name) + listControl:CopyLoadout(build.loadoutList[1]) + local copyFound = false + for _, entry in ipairs(build.loadoutList) do + if entry.name == "Default (copy)" then + copyFound = true + end + end + assert.is_true(copyFound) + end) + + it("deletes a newly added loadout without breaking the remaining sets", function() + build:NewLoadout("Second") + local listControl = new("LoadoutListControl"):LoadoutListControl(nil, {0, 0, 450, 200}, build) + assert.are.equals(2, #build.loadoutList) + local second + for _, entry in ipairs(build.loadoutList) do + if entry.name == "Second" then + second = entry + end + end + assert.is_not_nil(second) + + listControl:DeleteLoadout(second) + + assert.are.equals(1, #build.loadoutList) + assert.are.equals(1, #build.treeTab.specList) + assert.are.equals(1, #build.itemsTab.itemSetOrderList) + assert.are.equals(1, #build.skillsTab.skillSetOrderList) + assert.are.equals(1, #build.configTab.configSetOrderList) + -- The active sets must still point at sets that exist + assert.is_not_nil(build.treeTab.specList[build.treeTab.activeSpec]) + assert.is_not_nil(build.itemsTab.itemSets[build.itemsTab.activeItemSetId]) + assert.is_not_nil(build.skillsTab.skillSets[build.skillsTab.activeSkillSetId]) + assert.is_not_nil(build.configTab.configSets[build.configTab.activeConfigSetId]) + end) + + it("keeps sets that are shared with another loadout when deleting", function() + -- Two trees sharing a single item, skill and config set each + build.treeTab.specList[1].title = "Shared A" + local listControl = new("LoadoutListControl"):LoadoutListControl(nil, {0, 0, 450, 200}, build) + listControl:AddSpec(1, "Shared B") + build:SyncLoadouts() + assert.are.equals(2, #build.loadoutList) + + local sharedItemSetId = build.itemsTab.itemSetOrderList[1] + local toDelete + for _, entry in ipairs(build.loadoutList) do + if entry.name == "Shared B" then + toDelete = entry + end + end + listControl:DeleteLoadout(toDelete) + + -- The tree is gone, but the single item set is still used by "Shared A" + assert.are.equals(1, #build.treeTab.specList) + assert.is_not_nil(build.itemsTab.itemSets[sharedItemSetId]) + assert.are.equals(1, #build.loadoutList) + end) + + it("resets the dropdown selection after opening the manager", function() + local dropdown = build.controls.buildLoadouts + local manageIndex + for i, value in ipairs(dropdown.list) do + if value == "^7^7Manage Loadouts" then + manageIndex = i + end + end + assert.is_not_nil(manageIndex) + dropdown:SetSel(manageIndex) + -- Selection must fall back to the header, otherwise picking "Manage Loadouts" + -- a second time would not fire the callback again + assert.are.equals(1, dropdown.selIndex) + main:ClosePopup() + end) + it("resolves link identifier loadouts", function() build.treeTab.specList[1].title = "Tree {A}" local itemSet = build.itemsTab.itemSets[build.itemsTab.itemSetOrderList[1]] diff --git a/src/Classes/LoadoutListControl.lua b/src/Classes/LoadoutListControl.lua index 6716cb132dd..7e192c045f8 100644 --- a/src/Classes/LoadoutListControl.lua +++ b/src/Classes/LoadoutListControl.lua @@ -13,26 +13,33 @@ local LoadoutListClass = newClass("LoadoutListControl", "ListControl") function LoadoutListClass:LoadoutListControl(anchor, rect, build) self:ListControl(anchor, rect, 16, "VERTICAL", false, build.loadoutList) self.build = build - self.controls.copy = new("ButtonControl"):ButtonControl({"BOTTOMLEFT",self,"TOP"}, {2, -4, 60, 18}, "Copy", function() + -- The button row spans the full width of the list: four equal buttons plus the wider + -- "New/Copy Custom", separated by 4px gaps + local buttonWidth, buttonGap = 78, 4 + local customWidth = rect[3] - buttonWidth * 4 - buttonGap * 4 + self.controls.new = new("ButtonControl"):ButtonControl({"BOTTOMLEFT",self,"TOPLEFT"}, {0, -4, buttonWidth, 18}, "New", function() + build:OpenLoadoutNamePopup() + end) + self.controls.rename = new("ButtonControl"):ButtonControl({"LEFT",self.controls.new,"RIGHT"}, {buttonGap, 0, buttonWidth, 18}, "Rename", function() + self:RenameLoadout(self.selValue) + end) + self.controls.rename.enabled = function() + return self.selValue ~= nil + end + self.controls.copy = new("ButtonControl"):ButtonControl({"LEFT",self.controls.rename,"RIGHT"}, {buttonGap, 0, buttonWidth, 18}, "Copy", function() self:CopyLoadout(self.selValue) end) self.controls.copy.enabled = function() return self.selValue ~= nil end - self.controls.delete = new("ButtonControl"):ButtonControl({"LEFT",self.controls.copy,"RIGHT"}, {4, 0, 60, 18}, "Delete", function() + self.controls.delete = new("ButtonControl"):ButtonControl({"LEFT",self.controls.copy,"RIGHT"}, {buttonGap, 0, buttonWidth, 18}, "Delete", function() self:OnSelDelete(self.selIndex, self.selValue) end) self.controls.delete.enabled = function() return self.selValue ~= nil end - self.controls.rename = new("ButtonControl"):ButtonControl({"BOTTOMRIGHT",self,"TOP"}, {-2, -4, 60, 18}, "Rename", function() - self:RenameLoadout(self.selValue) - end) - self.controls.rename.enabled = function() - return self.selValue ~= nil - end - self.controls.new = new("ButtonControl"):ButtonControl({"RIGHT",self.controls.rename,"LEFT"}, {-4, 0, 60, 18}, "New", function() - build:OpenLoadoutNamePopup() + self.controls.custom = new("ButtonControl"):ButtonControl({"LEFT",self.controls.delete,"RIGHT"}, {buttonGap, 0, customWidth, 18}, "New/Copy Custom", function() + self:CreateCustomLoadoutPopup() end) return self end @@ -69,75 +76,183 @@ function LoadoutListClass:SelectLoadoutBySpecId(specId) end end --- Copies all four sets of the given loadout into a new loadout -function LoadoutListClass:CopyLoadout(loadout) - local build = self.build - local baseName = (loadout.name:gsub("%s*%{[%w,]+%}", "")) - if baseName == "" then - baseName = "Default" - end - local newName = baseName .. " (copy)" +-- Returns a loadout name not already in use, appending a numeric suffix if needed +function LoadoutListClass:UniqueName(baseName, copySuffix) + local newName = baseName .. (copySuffix and " (copy)" or "") local suffix = 1 while self:NameInUse(newName) do suffix = suffix + 1 - newName = baseName .. " (copy " .. suffix .. ")" + newName = baseName .. (copySuffix and (" (copy " .. suffix .. ")") or (" " .. suffix)) end + return newName +end - local spec = build.treeTab.specList[loadout.specId] - local newSpec = new("PassiveSpec"):PassiveSpec(build, spec.treeVersion) - newSpec.title = newName - newSpec.jewels = copyTable(spec.jewels) - newSpec:RestoreUndoState(spec:CreateUndoState()) - newSpec:BuildClusterJewelGraphs() +-- Adds a passive tree, either a copy of the given one or a fresh tree, and returns its index +function LoadoutListClass:AddSpec(specId, name) + local build = self.build + local newSpec + if specId then + local spec = build.treeTab.specList[specId] + newSpec = new("PassiveSpec"):PassiveSpec(build, spec.treeVersion) + newSpec.jewels = copyTable(spec.jewels) + newSpec:RestoreUndoState(spec:CreateUndoState()) + newSpec:BuildClusterJewelGraphs() + else + newSpec = new("PassiveSpec"):PassiveSpec(build, latestTreeVersion) + end + newSpec.title = name t_insert(build.treeTab.specList, newSpec) build.treeTab.modFlag = true + return #build.treeTab.specList +end - local itemsTab = build.itemsTab - local newItemSet = copyTable(itemsTab.itemSets[loadout.itemSetId]) - newItemSet.title = newName - newItemSet.id = 1 - while itemsTab.itemSets[newItemSet.id] do - newItemSet.id = newItemSet.id + 1 +-- Adds an item set, either a copy of the given one or a fresh set, and returns its id +function LoadoutListClass:AddItemSet(itemSetId, name) + local itemsTab = self.build.itemsTab + local newSet + if itemSetId then + newSet = copyTable(itemsTab.itemSets[itemSetId]) + newSet.id = 1 + while itemsTab.itemSets[newSet.id] do + newSet.id = newSet.id + 1 + end + itemsTab.itemSets[newSet.id] = newSet + else + newSet = itemsTab:NewItemSet() end - itemsTab.itemSets[newItemSet.id] = newItemSet - t_insert(itemsTab.itemSetOrderList, newItemSet.id) + newSet.title = name + t_insert(itemsTab.itemSetOrderList, newSet.id) itemsTab:AddUndoState() + return newSet.id +end - local skillsTab = build.skillsTab - local skillSet = skillsTab.skillSets[loadout.skillSetId] - local newSkillSet = copyTable(skillSet, true) - newSkillSet.title = newName - newSkillSet.socketGroupList = { } - for _, socketGroup in pairs(skillSet.socketGroupList) do - local newGroup = copyTable(socketGroup, true) - newGroup.gemList = { } - for gemIndex, gem in pairs(socketGroup.gemList) do - newGroup.gemList[gemIndex] = copyTable(gem, true) +-- Adds a skill set, either a copy of the given one or a fresh set, and returns its id +function LoadoutListClass:AddSkillSet(skillSetId, name) + local skillsTab = self.build.skillsTab + local newSet + if skillSetId then + local skillSet = skillsTab.skillSets[skillSetId] + newSet = copyTable(skillSet, true) + newSet.socketGroupList = { } + for _, socketGroup in pairs(skillSet.socketGroupList) do + local newGroup = copyTable(socketGroup, true) + newGroup.gemList = { } + for gemIndex, gem in pairs(socketGroup.gemList) do + newGroup.gemList[gemIndex] = copyTable(gem, true) + end + t_insert(newSet.socketGroupList, newGroup) end - t_insert(newSkillSet.socketGroupList, newGroup) - end - newSkillSet.id = 1 - while skillsTab.skillSets[newSkillSet.id] do - newSkillSet.id = newSkillSet.id + 1 + newSet.id = 1 + while skillsTab.skillSets[newSet.id] do + newSet.id = newSet.id + 1 + end + skillsTab.skillSets[newSet.id] = newSet + else + newSet = skillsTab:NewSkillSet() end - skillsTab.skillSets[newSkillSet.id] = newSkillSet - t_insert(skillsTab.skillSetOrderList, newSkillSet.id) + newSet.title = name + t_insert(skillsTab.skillSetOrderList, newSet.id) skillsTab:AddUndoState() + return newSet.id +end - local configTab = build.configTab - local newConfigSet = copyTable(configTab.configSets[loadout.configSetId]) - newConfigSet.title = newName - newConfigSet.id = 1 - while configTab.configSets[newConfigSet.id] do - newConfigSet.id = newConfigSet.id + 1 +-- Adds a config set, either a copy of the given one or a fresh set, and returns its id +function LoadoutListClass:AddConfigSet(configSetId, name) + local configTab = self.build.configTab + local newSet + if configSetId then + newSet = copyTable(configTab.configSets[configSetId]) + newSet.id = 1 + while configTab.configSets[newSet.id] do + newSet.id = newSet.id + 1 + end + configTab.configSets[newSet.id] = newSet + else + newSet = configTab:NewConfigSet() end - configTab.configSets[newConfigSet.id] = newConfigSet - t_insert(configTab.configSetOrderList, newConfigSet.id) + newSet.title = name + t_insert(configTab.configSetOrderList, newSet.id) configTab:AddUndoState() + return newSet.id +end +-- Creates a loadout from the given sets; a nil set id creates a fresh set of that type +function LoadoutListClass:CreateLoadout(name, specId, itemSetId, skillSetId, configSetId) + local specIndex = self:AddSpec(specId, name) + self:AddItemSet(itemSetId, name) + self:AddSkillSet(skillSetId, name) + self:AddConfigSet(configSetId, name) + self.build.modFlag = true self:UpdateItemsTabPassiveTreeDropdown() - build:SyncLoadouts() - self:SelectLoadoutBySpecId(#build.treeTab.specList) + self.build:SyncLoadouts() + self:SelectLoadoutBySpecId(specIndex) +end + +-- Copies all four sets of the given loadout into a new loadout +function LoadoutListClass:CopyLoadout(loadout) + local baseName = (loadout.name:gsub("%s*%{[%w,]+%}", "")) + if baseName == "" then + baseName = "Default" + end + self:CreateLoadout(self:UniqueName(baseName, true), loadout.specId, loadout.itemSetId, loadout.skillSetId, loadout.configSetId) +end + +-- Opens the popup for creating a loadout from a chosen mix of new and existing sets +function LoadoutListClass:CreateCustomLoadoutPopup() + local build = self.build + local controls = { } + + -- The first entry creates a fresh set, the rest copy an existing one + local function buildSetList(orderList, sets) + local list = { { label = "New" } } + for _, setId in ipairs(orderList) do + t_insert(list, { label = sets[setId].title or "Default", id = setId }) + end + return list + end + local treeList = { { label = "New" } } + for specId, spec in ipairs(build.treeTab.specList) do + t_insert(treeList, { + label = (spec.treeVersion ~= latestTreeVersion and ("["..treeVersions[spec.treeVersion].display.."] ") or "")..(spec.title or "Default"), + id = specId, + }) + end + + controls.label = new("LabelControl"):LabelControl(nil, {0, 20, 0, 16}, "^7Enter name for this loadout:") + controls.edit = new("EditControl"):EditControl(nil, {0, 40, 350, 20}, self:UniqueName("New Loadout Custom"), nil, nil, 100, function(buf) + controls.save.enabled = buf:match("%S") + end) + controls.treeSelect = new("DropDownControl"):DropDownControl(nil, {0, 90, 350, 20}, treeList) + controls.treeLabel = new("LabelControl"):LabelControl({"BOTTOMLEFT",controls.treeSelect,"TOPLEFT"}, {0, -4, 0, 16}, "^7Copy from Tree:") + controls.skillSelect = new("DropDownControl"):DropDownControl(nil, {0, 140, 350, 20}, buildSetList(build.skillsTab.skillSetOrderList, build.skillsTab.skillSets)) + controls.skillLabel = new("LabelControl"):LabelControl({"BOTTOMLEFT",controls.skillSelect,"TOPLEFT"}, {0, -4, 0, 16}, "^7Copy from Skill Set:") + controls.itemSelect = new("DropDownControl"):DropDownControl(nil, {0, 190, 350, 20}, buildSetList(build.itemsTab.itemSetOrderList, build.itemsTab.itemSets)) + controls.itemLabel = new("LabelControl"):LabelControl({"BOTTOMLEFT",controls.itemSelect,"TOPLEFT"}, {0, -4, 0, 16}, "^7Copy from Item Set:") + controls.configSelect = new("DropDownControl"):DropDownControl(nil, {0, 240, 350, 20}, buildSetList(build.configTab.configSetOrderList, build.configTab.configSets)) + controls.configLabel = new("LabelControl"):LabelControl({"BOTTOMLEFT",controls.configSelect,"TOPLEFT"}, {0, -4, 0, 16}, "^7Copy from Config Set:") + + -- Every set defaults to "New"; if a loadout is selected in the manager, its sets are + -- preselected instead so they can be kept or swapped out one at a time + local selected = self.selValue + if selected then + controls.treeSelect:SelByValue(selected.specId, "id") + controls.skillSelect:SelByValue(selected.skillSetId, "id") + controls.itemSelect:SelByValue(selected.itemSetId, "id") + controls.configSelect:SelByValue(selected.configSetId, "id") + end + + controls.save = new("ButtonControl"):ButtonControl(nil, {-45, 275, 80, 20}, "Save", function() + self:CreateLoadout(controls.edit.buf, + controls.treeSelect:GetSelValue().id, + controls.itemSelect:GetSelValue().id, + controls.skillSelect:GetSelValue().id, + controls.configSelect:GetSelValue().id) + main:ClosePopup() + end) + controls.cancel = new("ButtonControl"):ButtonControl(nil, {45, 275, 80, 20}, "Cancel", function() + main:ClosePopup() + end) + main:OpenPopup(370, 305, "Create Custom Loadout", controls, "save", "edit", "cancel") end function LoadoutListClass:RenameLoadout(loadout) @@ -213,50 +328,56 @@ function LoadoutListClass:OnSelClick(index, loadout, doubleClick) end end -function LoadoutListClass:OnSelDelete(index, loadout) +-- Deletes the four sets of the given loadout, keeping any that are shared with another +-- loadout or that are the last remaining set of their type +function LoadoutListClass:DeleteLoadout(loadout) local build = self.build - main:OpenConfirmPopup("Delete Loadout", "Are you sure you want to delete '"..loadout.name.."'?\nThis will delete the passive tree, item set, skill set and config set associated with it.\nSets shared with other loadouts, or the last remaining set of a type, will be kept.", "Delete", function() - local treeTab, itemsTab, skillsTab, configTab = build.treeTab, build.itemsTab, build.skillsTab, build.configTab - if #treeTab.specList > 1 and not self:IsSetShared("specId", loadout.specId, loadout) then - t_remove(treeTab.specList, loadout.specId) - if loadout.specId == treeTab.activeSpec then - treeTab:SetActiveSpec(m_max(1, loadout.specId - 1)) - else - treeTab.activeSpec = isValueInArray(treeTab.specList, build.spec) - end - treeTab.modFlag = true + local treeTab, itemsTab, skillsTab, configTab = build.treeTab, build.itemsTab, build.skillsTab, build.configTab + if #treeTab.specList > 1 and not self:IsSetShared("specId", loadout.specId, loadout) then + t_remove(treeTab.specList, loadout.specId) + if loadout.specId == treeTab.activeSpec then + treeTab:SetActiveSpec(m_max(1, loadout.specId - 1)) + else + treeTab.activeSpec = isValueInArray(treeTab.specList, build.spec) end - if #itemsTab.itemSetOrderList > 1 and not self:IsSetShared("itemSetId", loadout.itemSetId, loadout) then - local setIndex = isValueInArray(itemsTab.itemSetOrderList, loadout.itemSetId) - t_remove(itemsTab.itemSetOrderList, setIndex) - itemsTab.itemSets[loadout.itemSetId] = nil - if loadout.itemSetId == itemsTab.activeItemSetId then - itemsTab:SetActiveItemSet(itemsTab.itemSetOrderList[m_max(1, setIndex - 1)]) - end - itemsTab:AddUndoState() + treeTab.modFlag = true + end + if #itemsTab.itemSetOrderList > 1 and not self:IsSetShared("itemSetId", loadout.itemSetId, loadout) then + local setIndex = isValueInArray(itemsTab.itemSetOrderList, loadout.itemSetId) + t_remove(itemsTab.itemSetOrderList, setIndex) + itemsTab.itemSets[loadout.itemSetId] = nil + if loadout.itemSetId == itemsTab.activeItemSetId then + itemsTab:SetActiveItemSet(itemsTab.itemSetOrderList[m_max(1, setIndex - 1)]) end - if #skillsTab.skillSetOrderList > 1 and not self:IsSetShared("skillSetId", loadout.skillSetId, loadout) then - local setIndex = isValueInArray(skillsTab.skillSetOrderList, loadout.skillSetId) - t_remove(skillsTab.skillSetOrderList, setIndex) - skillsTab.skillSets[loadout.skillSetId] = nil - if loadout.skillSetId == skillsTab.activeSkillSetId then - skillsTab:SetActiveSkillSet(skillsTab.skillSetOrderList[m_max(1, setIndex - 1)]) - end - skillsTab:AddUndoState() + itemsTab:AddUndoState() + end + if #skillsTab.skillSetOrderList > 1 and not self:IsSetShared("skillSetId", loadout.skillSetId, loadout) then + local setIndex = isValueInArray(skillsTab.skillSetOrderList, loadout.skillSetId) + t_remove(skillsTab.skillSetOrderList, setIndex) + skillsTab.skillSets[loadout.skillSetId] = nil + if loadout.skillSetId == skillsTab.activeSkillSetId then + skillsTab:SetActiveSkillSet(skillsTab.skillSetOrderList[m_max(1, setIndex - 1)]) end - if #configTab.configSetOrderList > 1 and not self:IsSetShared("configSetId", loadout.configSetId, loadout) then - local setIndex = isValueInArray(configTab.configSetOrderList, loadout.configSetId) - t_remove(configTab.configSetOrderList, setIndex) - configTab.configSets[loadout.configSetId] = nil - if loadout.configSetId == configTab.activeConfigSetId then - configTab:SetActiveConfigSet(configTab.configSetOrderList[m_max(1, setIndex - 1)]) - end - configTab:AddUndoState() + skillsTab:AddUndoState() + end + if #configTab.configSetOrderList > 1 and not self:IsSetShared("configSetId", loadout.configSetId, loadout) then + local setIndex = isValueInArray(configTab.configSetOrderList, loadout.configSetId) + t_remove(configTab.configSetOrderList, setIndex) + configTab.configSets[loadout.configSetId] = nil + if loadout.configSetId == configTab.activeConfigSetId then + configTab:SetActiveConfigSet(configTab.configSetOrderList[m_max(1, setIndex - 1)]) end - self.selIndex = nil - self.selValue = nil - self:UpdateItemsTabPassiveTreeDropdown() - build:SyncLoadouts() + configTab:AddUndoState() + end + self.selIndex = nil + self.selValue = nil + self:UpdateItemsTabPassiveTreeDropdown() + build:SyncLoadouts() +end + +function LoadoutListClass:OnSelDelete(index, loadout) + main:OpenConfirmPopup("Delete Loadout", "Are you sure you want to delete '"..loadout.name.."'?\nThis will delete the passive tree, item set, skill set and config set associated with it.\nSets shared with other loadouts, or the last remaining set of a type, will be kept.", "Delete", function() + self:DeleteLoadout(loadout) end) end From e1091b1fbb4d97a151072d575dcf01ae13d4f95a Mon Sep 17 00:00:00 2001 From: Can Uysal Date: Fri, 25 Sep 2026 22:57:02 +0300 Subject: [PATCH 3/5] Keep the right loadout selected when the manager's list is rebuilt * Match rows on their link identifier as well as their passive tree, as a tree with several identifiers has a row for each and the selection jumped to the first one after a double-click or rename * Move the selection onto the rebuilt row after Sync, and select the loadout New creates, instead of leaving it on a discarded row that Delete could not remove --- spec/System/TestLoadouts_spec.lua | 54 ++++++++++++++++++++++++++++++ src/Classes/LoadoutListControl.lua | 17 ++++++---- src/Modules/Build.lua | 21 +++++++++--- 3 files changed, 80 insertions(+), 12 deletions(-) diff --git a/spec/System/TestLoadouts_spec.lua b/spec/System/TestLoadouts_spec.lua index 0b7997125c6..cc62a904e2b 100644 --- a/spec/System/TestLoadouts_spec.lua +++ b/spec/System/TestLoadouts_spec.lua @@ -1,4 +1,13 @@ describe("TestLoadouts", function() + -- Returns the loadout with the given name and its index in the loadout list + local function findLoadout(name) + for index, loadout in ipairs(build.loadoutList) do + if loadout.name == name then + return loadout, index + end + end + end + before_each(function() newBuild() end) @@ -197,4 +206,49 @@ describe("TestLoadouts", function() assert.are.equals(1, loadout.specId) assert.are.equals(build.itemsTab.itemSetOrderList[1], loadout.itemSetId) end) + + it("keeps a linked loadout selected when its tree has several identifiers", function() + -- One tree shared by two loadouts, each with its own item, skill and config set + local listControl = new("LoadoutListControl"):LoadoutListControl(nil, {0, 0, 450, 200}, build) + build.treeTab.specList[1].title = "Linked {A,B}" + build.itemsTab.itemSets[build.itemsTab.itemSetOrderList[1]].title = "Items {A}" + build.skillsTab.skillSets[build.skillsTab.skillSetOrderList[1]].title = "Skills {A}" + build.configTab.configSets[build.configTab.configSetOrderList[1]].title = "Config {A}" + listControl:AddItemSet(nil, "Items {B}") + listControl:AddSkillSet(nil, "Skills {B}") + listControl:AddConfigSet(nil, "Config {B}") + build:SyncLoadouts() + local loadoutB, index = findLoadout("Linked {B}") + assert.is_not_nil(loadoutB) + + listControl:SelectIndex(index) + listControl:OnSelClick(index, loadoutB, true) + + assert.are.equals("Linked {B}", listControl.selValue.name) + assert.is_true(listControl:IsActiveLoadout(listControl.selValue)) + end) + + it("keeps the manager's selection on a current row after Sync and New", function() + build:NewLoadout("First") + build:OpenLoadoutManagePopup() + local manager = main.popups[1] + local listControl = manager.controls[1] + listControl:SelectIndex(select(2, findLoadout("First"))) + + -- Sync rebuilds every row, so the selection has to follow the loadout to its new row + manager.controls[2]:Click() + assert.are.equals("First", listControl.selValue.name) + assert.are.equals(listControl.list[listControl.selIndex], listControl.selValue) + listControl:DeleteLoadout(listControl.selValue) + assert.is_nil(findLoadout("First")) + + -- New selects the loadout it creates, like Copy and New/Copy Custom do + listControl.controls.new:Click() + local controls = main.popups[1].controls + controls.edit:SetText("Second", true) + controls.save:Click() + assert.are.equals("Second", listControl.selValue.name) + assert.are.equals(listControl.list[listControl.selIndex], listControl.selValue) + main:ClosePopup() + end) end) diff --git a/src/Classes/LoadoutListControl.lua b/src/Classes/LoadoutListControl.lua index 7e192c045f8..74b6d4310a6 100644 --- a/src/Classes/LoadoutListControl.lua +++ b/src/Classes/LoadoutListControl.lua @@ -18,7 +18,9 @@ function LoadoutListClass:LoadoutListControl(anchor, rect, build) local buttonWidth, buttonGap = 78, 4 local customWidth = rect[3] - buttonWidth * 4 - buttonGap * 4 self.controls.new = new("ButtonControl"):ButtonControl({"BOTTOMLEFT",self,"TOPLEFT"}, {0, -4, buttonWidth, 18}, "New", function() - build:OpenLoadoutNamePopup() + build:OpenLoadoutNamePopup(function(specId) + self:SelectLoadout(specId) + end) end) self.controls.rename = new("ButtonControl"):ButtonControl({"LEFT",self.controls.new,"RIGHT"}, {buttonGap, 0, buttonWidth, 18}, "Rename", function() self:RenameLoadout(self.selValue) @@ -63,12 +65,13 @@ function LoadoutListClass:NameInUse(name) return false end --- Re-selects the loadout owning the given passive tree after the list has been rebuilt -function LoadoutListClass:SelectLoadoutBySpecId(specId) +-- Re-selects the loadout using the given passive tree after the list has been rebuilt. A tree with +-- several link identifiers has a row for each, so the identifier picks between them when given +function LoadoutListClass:SelectLoadout(specId, linkId) self.selIndex = nil self.selValue = nil for index, loadout in ipairs(self.list) do - if loadout.specId == specId then + if loadout.specId == specId and (not linkId or loadout.linkId == linkId) then self.selIndex = index self.selValue = loadout break @@ -185,7 +188,7 @@ function LoadoutListClass:CreateLoadout(name, specId, itemSetId, skillSetId, con self.build.modFlag = true self:UpdateItemsTabPassiveTreeDropdown() self.build:SyncLoadouts() - self:SelectLoadoutBySpecId(specIndex) + self:SelectLoadout(specIndex) end -- Copies all four sets of the given loadout into a new loadout @@ -284,7 +287,7 @@ function LoadoutListClass:RenameLoadout(loadout) build.configTab:AddUndoState() self:UpdateItemsTabPassiveTreeDropdown() build:SyncLoadouts() - self:SelectLoadoutBySpecId(loadout.specId) + self:SelectLoadout(loadout.specId, loadout.linkId) main:ClosePopup() end) controls.save.enabled = false @@ -324,7 +327,7 @@ function LoadoutListClass:OnSelClick(index, loadout, doubleClick) build.configTab:SetActiveConfigSet(loadout.configSetId) end build:SyncLoadouts() - self:SelectLoadoutBySpecId(loadout.specId) + self:SelectLoadout(loadout.specId, loadout.linkId) end end diff --git a/src/Modules/Build.lua b/src/Modules/Build.lua index 96e3c48b5b2..990b7f4df6a 100644 --- a/src/Modules/Build.lua +++ b/src/Modules/Build.lua @@ -869,7 +869,8 @@ function buildMode:SyncLoadouts() return treeList, itemList, skillList, configList end --- Creates a new loadout: a passive tree, item set, skill set and config set sharing the given name +-- Creates a new loadout: a passive tree, item set, skill set and config set sharing the given name. +-- Returns the index of the new passive tree function buildMode:NewLoadout(loadoutName) local newSpec = new("PassiveSpec"):PassiveSpec(self, latestTreeVersion) newSpec.title = loadoutName @@ -889,18 +890,22 @@ function buildMode:NewLoadout(loadoutName) self:SyncLoadouts() self.modFlag = true + return #self.treeTab.specList end --- Opens the naming popup for a new loadout -function buildMode:OpenLoadoutNamePopup() +-- Opens the naming popup for a new loadout, passing the new passive tree's index to onCreate if given +function buildMode:OpenLoadoutNamePopup(onCreate) local controls = { } controls.label = new("LabelControl"):LabelControl(nil, {0, 20, 0, 16}, "^7Enter name for this loadout:") controls.edit = new("EditControl"):EditControl(nil, {0, 40, 350, 20}, "New Loadout", nil, nil, 100, function(buf) controls.save.enabled = buf:match("%S") end) controls.save = new("ButtonControl"):ButtonControl(nil, {-45, 70, 80, 20}, "Save", function() - self:NewLoadout(controls.edit.buf) + local specId = self:NewLoadout(controls.edit.buf) main:ClosePopup() + if onCreate then + onCreate(specId) + end end) controls.save.enabled = false controls.cancel = new("ButtonControl"):ButtonControl(nil, {45, 70, 80, 20}, "Cancel", function() @@ -911,12 +916,18 @@ end -- Opens the loadout manager function buildMode:OpenLoadoutManagePopup() + local loadoutList = new("LoadoutListControl"):LoadoutListControl(nil, {0, 50, 450, 200}, self) local syncButton = new("ButtonControl"):ButtonControl(nil, {-50, 260, 90, 20}, "Sync", function() + local selected = loadoutList.selValue self:SyncLoadouts() + -- Syncing rebuilds every row, so move the selection onto the new row for the same loadout + if selected then + loadoutList:SelectLoadout(selected.specId, selected.linkId) + end end) syncButton.tooltipText = "Force the loadout list to update in case sets have been changed behind the scenes." main:OpenPopup(470, 290, "Manage Loadouts", { - new("LoadoutListControl"):LoadoutListControl(nil, {0, 50, 450, 200}, self), + loadoutList, syncButton, new("ButtonControl"):ButtonControl(nil, {50, 260, 90, 20}, "Done", function() main:ClosePopup() From 16aec6c13a0e8dea4d58229f147de75847e38360 Mon Sep 17 00:00:00 2001 From: Can Uysal Date: Fri, 25 Sep 2026 22:58:23 +0300 Subject: [PATCH 4/5] Reject loadout names that are already in use * Disable Save in the New, New/Copy Custom and Rename popups when another passive tree or set already has the name, as loadouts are matched to their sets by name and a duplicate left two loadouts sharing one set of each * Allow keeping the current name when renaming, and skip the check for linked loadouts, whose sets are tied together by identifier * Explain why Save is disabled in its tooltip, and note the rule in help.txt --- help.txt | 1 + spec/System/TestLoadouts_spec.lua | 65 ++++++++++++++++++++++++++++++ src/Classes/LoadoutListControl.lua | 20 ++++----- src/Modules/Build.lua | 25 +++++++++++- 4 files changed, 98 insertions(+), 13 deletions(-) diff --git a/help.txt b/help.txt index b071e6d8304..3a21b24d662 100644 --- a/help.txt +++ b/help.txt @@ -139,6 +139,7 @@ The "Manage Loadouts" option opens the loadout manager, which works like the set - "Copy" duplicates all four sets of the selected loadout into a new loadout automatically - "New/Copy Custom" creates a loadout from a mix of sets, choosing for each of the four whether to start fresh or copy an existing set. It defaults to the currently active sets, so saving straight away copies the active loadout - "Rename" renames all four sets of the selected loadout, preserving any identifiers inside braces { } + - A name already used by another loadout or set can't be reused, as loadouts are matched to their sets by name - "Delete" deletes all four sets of the selected loadout; sets shared with other loadouts, or the last remaining set of a type, are kept - Double-clicking a loadout activates it - "Sync" is a backup option to force the UI to update in case the user has changed this data behind the scenes diff --git a/spec/System/TestLoadouts_spec.lua b/spec/System/TestLoadouts_spec.lua index cc62a904e2b..693571c7216 100644 --- a/spec/System/TestLoadouts_spec.lua +++ b/spec/System/TestLoadouts_spec.lua @@ -251,4 +251,69 @@ describe("TestLoadouts", function() assert.are.equals(listControl.list[listControl.selIndex], listControl.selValue) main:ClosePopup() end) + + it("rejects renaming a loadout to a name already in use", function() + build:NewLoadout("First") + build:NewLoadout("Second") + local listControl = new("LoadoutListControl"):LoadoutListControl(nil, {0, 0, 450, 200}, build) + listControl:RenameLoadout(findLoadout("Second")) + local controls = main.popups[1].controls + controls.edit:SetText("First", true) + assert.is_falsy(controls.save:IsEnabled()) + -- Keeping the current name is allowed, as is a free one + controls.edit:SetText("Second", true) + assert.is_truthy(controls.save:IsEnabled()) + controls.edit:SetText("Third", true) + assert.is_truthy(controls.save:IsEnabled()) + main:ClosePopup() + end) + + it("allows renaming a linked loadout to the name of an unlinked one", function() + build:NewLoadout("First") + build.treeTab.specList[1].title = "Linked {A}" + build.itemsTab.itemSets[build.itemsTab.itemSetOrderList[1]].title = "Items {A}" + build.skillsTab.skillSets[build.skillsTab.skillSetOrderList[1]].title = "Skills {A}" + build.configTab.configSets[build.configTab.configSetOrderList[1]].title = "Config {A}" + build:SyncLoadouts() + local listControl = new("LoadoutListControl"):LoadoutListControl(nil, {0, 0, 450, 200}, build) + local first = findLoadout("First") + listControl:RenameLoadout(findLoadout("Linked {A}")) + local controls = main.popups[1].controls + controls.edit:SetText("First", true) + assert.is_truthy(controls.save:IsEnabled()) + controls.save:Click() + + -- The sets are tied together by the identifier, so both loadouts keep their own sets + local renamed = findLoadout("First {A}") + assert.is_not_nil(renamed) + assert.are.equals(first.itemSetId, findLoadout("First").itemSetId) + assert.are_not.equals(first.itemSetId, renamed.itemSetId) + end) + + it("rejects new loadouts named after an existing passive tree or set", function() + build:NewLoadout("First") + -- A tree without matching sets is not a loadout, but one of the same name would still clash with it + build.treeTab.specList[1].title = "Unmatched" + build:SyncLoadouts() + assert.is_nil(findLoadout("Unmatched")) + local listControl = new("LoadoutListControl"):LoadoutListControl(nil, {0, 0, 450, 200}, build) + + listControl:CreateCustomLoadoutPopup() + local controls = main.popups[1].controls + controls.edit:SetText("First", true) + assert.is_falsy(controls.save:IsEnabled()) + controls.edit:SetText("Unmatched", true) + assert.is_falsy(controls.save:IsEnabled()) + controls.edit:SetText("Third", true) + assert.is_truthy(controls.save:IsEnabled()) + main:ClosePopup() + + build:OpenLoadoutNamePopup() + controls = main.popups[1].controls + controls.edit:SetText("First", true) + assert.is_falsy(controls.save:IsEnabled()) + controls.edit:SetText("Third", true) + assert.is_truthy(controls.save:IsEnabled()) + main:ClosePopup() + end) end) diff --git a/src/Classes/LoadoutListControl.lua b/src/Classes/LoadoutListControl.lua index 74b6d4310a6..89a2daaf85d 100644 --- a/src/Classes/LoadoutListControl.lua +++ b/src/Classes/LoadoutListControl.lua @@ -56,15 +56,6 @@ function LoadoutListClass:IsSetShared(field, setId, loadout) return false end -function LoadoutListClass:NameInUse(name) - for _, loadout in ipairs(self.build.loadoutList) do - if loadout.name == name then - return true - end - end - return false -end - -- Re-selects the loadout using the given passive tree after the list has been rebuilt. A tree with -- several link identifiers has a row for each, so the identifier picks between them when given function LoadoutListClass:SelectLoadout(specId, linkId) @@ -83,7 +74,7 @@ end function LoadoutListClass:UniqueName(baseName, copySuffix) local newName = baseName .. (copySuffix and " (copy)" or "") local suffix = 1 - while self:NameInUse(newName) do + while self.build:LoadoutNameInUse(newName) do suffix = suffix + 1 newName = baseName .. (copySuffix and (" (copy " .. suffix .. ")") or (" " .. suffix)) end @@ -223,7 +214,9 @@ function LoadoutListClass:CreateCustomLoadoutPopup() controls.label = new("LabelControl"):LabelControl(nil, {0, 20, 0, 16}, "^7Enter name for this loadout:") controls.edit = new("EditControl"):EditControl(nil, {0, 40, 350, 20}, self:UniqueName("New Loadout Custom"), nil, nil, 100, function(buf) - controls.save.enabled = buf:match("%S") + local nameInUse = build:LoadoutNameInUse(buf) + controls.save.enabled = buf:match("%S") and not nameInUse + controls.save.tooltipText = nameInUse and "This name is already used by another loadout or set." or nil end) controls.treeSelect = new("DropDownControl"):DropDownControl(nil, {0, 90, 350, 20}, treeList) controls.treeLabel = new("LabelControl"):LabelControl({"BOTTOMLEFT",controls.treeSelect,"TOPLEFT"}, {0, -4, 0, 16}, "^7Copy from Tree:") @@ -265,7 +258,10 @@ function LoadoutListClass:RenameLoadout(loadout) local currentName = ((spec.title or "Default"):gsub("%s*%{[%w,]+%}", "")) controls.label = new("LabelControl"):LabelControl(nil, {0, 20, 0, 16}, "^7Enter new name for this loadout:") controls.edit = new("EditControl"):EditControl(nil, {0, 40, 350, 20}, currentName, nil, nil, 100, function(buf) - controls.save.enabled = buf:match("%S") + -- Linked loadouts are tied to their sets by identifier rather than by name, so their names can't clash + local nameInUse = not loadout.linkId and build:LoadoutNameInUse(buf, loadout) + controls.save.enabled = buf:match("%S") and not nameInUse + controls.save.tooltipText = nameInUse and "This name is already used by another loadout or set." or nil end) controls.save = new("ButtonControl"):ButtonControl(nil, {-45, 70, 80, 20}, "Save", function() local newName = controls.edit.buf diff --git a/src/Modules/Build.lua b/src/Modules/Build.lua index 990b7f4df6a..349598c5a9c 100644 --- a/src/Modules/Build.lua +++ b/src/Modules/Build.lua @@ -869,6 +869,27 @@ function buildMode:SyncLoadouts() return treeList, itemList, skillList, configList end +-- Returns true if a passive tree, item set, skill set or config set, other than those of the given loadout, +-- already has this name. Loadouts are matched to their sets by name, so reusing one can tie a loadout to the wrong sets +function buildMode:LoadoutNameInUse(name, loadout) + for specId, spec in ipairs(self.treeTab.specList) do + if (spec.title or "Default") == name and not (loadout and loadout.specId == specId) then + return true + end + end + local function setNameInUse(orderList, sets, ownSetId) + for _, setId in ipairs(orderList) do + if (sets[setId].title or "Default") == name and setId ~= ownSetId then + return true + end + end + return false + end + return setNameInUse(self.itemsTab.itemSetOrderList, self.itemsTab.itemSets, loadout and loadout.itemSetId) + or setNameInUse(self.skillsTab.skillSetOrderList, self.skillsTab.skillSets, loadout and loadout.skillSetId) + or setNameInUse(self.configTab.configSetOrderList, self.configTab.configSets, loadout and loadout.configSetId) +end + -- Creates a new loadout: a passive tree, item set, skill set and config set sharing the given name. -- Returns the index of the new passive tree function buildMode:NewLoadout(loadoutName) @@ -898,7 +919,9 @@ function buildMode:OpenLoadoutNamePopup(onCreate) local controls = { } controls.label = new("LabelControl"):LabelControl(nil, {0, 20, 0, 16}, "^7Enter name for this loadout:") controls.edit = new("EditControl"):EditControl(nil, {0, 40, 350, 20}, "New Loadout", nil, nil, 100, function(buf) - controls.save.enabled = buf:match("%S") + local nameInUse = self:LoadoutNameInUse(buf) + controls.save.enabled = buf:match("%S") and not nameInUse + controls.save.tooltipText = nameInUse and "This name is already used by another loadout or set." or nil end) controls.save = new("ButtonControl"):ButtonControl(nil, {-45, 70, 80, 20}, "Save", function() local specId = self:NewLoadout(controls.edit.buf) From f495fc9aebd4c60f351a99647d9321bacee3ceff Mon Sep 17 00:00:00 2001 From: Can Uysal Date: Fri, 25 Sep 2026 22:59:53 +0300 Subject: [PATCH 5/5] Default custom loadouts to the active sets * Preselect the active tree, item, skill and config sets in New/Copy Custom when no loadout is selected in the manager, as the help text describes, instead of starting every set fresh * A loadout selected in the manager still takes precedence, and help.txt now says so --- help.txt | 2 +- spec/System/TestLoadouts_spec.lua | 22 ++++++++++++++++++++++ src/Classes/LoadoutListControl.lua | 21 ++++++++++++--------- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/help.txt b/help.txt index 3a21b24d662..9a872012026 100644 --- a/help.txt +++ b/help.txt @@ -137,7 +137,7 @@ The "New Loadout" option allows the user to create all four sets from a single p The "Manage Loadouts" option opens the loadout manager, which works like the set managers found on the Tree, Skills, Items, and Configuration tabs: - "New" creates all four sets from a single popup, the same as the "New Loadout" option - "Copy" duplicates all four sets of the selected loadout into a new loadout automatically - - "New/Copy Custom" creates a loadout from a mix of sets, choosing for each of the four whether to start fresh or copy an existing set. It defaults to the currently active sets, so saving straight away copies the active loadout + - "New/Copy Custom" creates a loadout from a mix of sets, choosing for each of the four whether to start fresh or copy an existing set. It defaults to the sets of the selected loadout, or the currently active sets if none is selected, so saving straight away copies that loadout - "Rename" renames all four sets of the selected loadout, preserving any identifiers inside braces { } - A name already used by another loadout or set can't be reused, as loadouts are matched to their sets by name - "Delete" deletes all four sets of the selected loadout; sets shared with other loadouts, or the last remaining set of a type, are kept diff --git a/spec/System/TestLoadouts_spec.lua b/spec/System/TestLoadouts_spec.lua index 693571c7216..6dd66da8117 100644 --- a/spec/System/TestLoadouts_spec.lua +++ b/spec/System/TestLoadouts_spec.lua @@ -316,4 +316,26 @@ describe("TestLoadouts", function() assert.is_truthy(controls.save:IsEnabled()) main:ClosePopup() end) + + it("defaults a custom loadout to the active sets, or those of the selected loadout", function() + build:NewLoadout("Other") + local listControl = new("LoadoutListControl"):LoadoutListControl(nil, {0, 0, 450, 200}, build) + listControl:CreateCustomLoadoutPopup() + local controls = main.popups[1].controls + assert.are.equals(build.treeTab.activeSpec, controls.treeSelect:GetSelValue().id) + assert.are.equals(build.itemsTab.activeItemSetId, controls.itemSelect:GetSelValue().id) + assert.are.equals(build.skillsTab.activeSkillSetId, controls.skillSelect:GetSelValue().id) + assert.are.equals(build.configTab.activeConfigSetId, controls.configSelect:GetSelValue().id) + main:ClosePopup() + + local other, index = findLoadout("Other") + listControl:SelectIndex(index) + listControl:CreateCustomLoadoutPopup() + controls = main.popups[1].controls + assert.are.equals(other.specId, controls.treeSelect:GetSelValue().id) + assert.are.equals(other.itemSetId, controls.itemSelect:GetSelValue().id) + assert.are.equals(other.skillSetId, controls.skillSelect:GetSelValue().id) + assert.are.equals(other.configSetId, controls.configSelect:GetSelValue().id) + main:ClosePopup() + end) end) diff --git a/src/Classes/LoadoutListControl.lua b/src/Classes/LoadoutListControl.lua index 89a2daaf85d..4c3810f43dd 100644 --- a/src/Classes/LoadoutListControl.lua +++ b/src/Classes/LoadoutListControl.lua @@ -227,15 +227,18 @@ function LoadoutListClass:CreateCustomLoadoutPopup() controls.configSelect = new("DropDownControl"):DropDownControl(nil, {0, 240, 350, 20}, buildSetList(build.configTab.configSetOrderList, build.configTab.configSets)) controls.configLabel = new("LabelControl"):LabelControl({"BOTTOMLEFT",controls.configSelect,"TOPLEFT"}, {0, -4, 0, 16}, "^7Copy from Config Set:") - -- Every set defaults to "New"; if a loadout is selected in the manager, its sets are - -- preselected instead so they can be kept or swapped out one at a time - local selected = self.selValue - if selected then - controls.treeSelect:SelByValue(selected.specId, "id") - controls.skillSelect:SelByValue(selected.skillSetId, "id") - controls.itemSelect:SelByValue(selected.itemSetId, "id") - controls.configSelect:SelByValue(selected.configSetId, "id") - end + -- The sets of the loadout selected in the manager are preselected, or the active sets if none is + -- selected, so saving straight away copies that loadout and each set can be swapped out one at a time + local source = self.selValue or { + specId = build.treeTab.activeSpec, + itemSetId = build.itemsTab.activeItemSetId, + skillSetId = build.skillsTab.activeSkillSetId, + configSetId = build.configTab.activeConfigSetId, + } + controls.treeSelect:SelByValue(source.specId, "id") + controls.skillSelect:SelByValue(source.skillSetId, "id") + controls.itemSelect:SelByValue(source.itemSetId, "id") + controls.configSelect:SelByValue(source.configSetId, "id") controls.save = new("ButtonControl"):ButtonControl(nil, {-45, 275, 80, 20}, "Save", function() self:CreateLoadout(controls.edit.buf,