From d033b0a31ab2be3c63b65c3b356a54e0c0024d31 Mon Sep 17 00:00:00 2001 From: xoxorwr Date: Sat, 1 Aug 2026 09:54:26 +0200 Subject: [PATCH 1/3] Allow recursive build list search with class filtering --- src/Classes/BuildListControl.lua | 53 +++++++--- src/Classes/CompareTab.lua | 5 +- src/Classes/EditControl.lua | 4 + src/Modules/BuildList.lua | 9 +- src/Modules/BuildListHelpers.lua | 168 ++++++++++++++++++++++--------- 5 files changed, 172 insertions(+), 67 deletions(-) diff --git a/src/Classes/BuildListControl.lua b/src/Classes/BuildListControl.lua index 3f42430ce9a..191c3c3a181 100644 --- a/src/Classes/BuildListControl.lua +++ b/src/Classes/BuildListControl.lua @@ -29,13 +29,21 @@ local BuildListClass = newClass("BuildListControl", "ListControl", function(self function self.controls.path:ReceiveDrag(type, build, source) if type == "Build" then for index, folder in ipairs(self.folderList) do - if index < #self.folderList and folder.button:IsMouseOver() then - if build.folderName then - main:MoveFolder(build.folderName, main.buildPath..build.subPath, main.buildPath..folder.path) - else - os.rename(build.fullFileName, listMode:GetDestName(folder.path, build.fileName)) + if folder.button:IsMouseOver() then + if build.subPath ~= folder.path then + if build.folderName then + main:MoveFolder(build.folderName, main.buildPath..build.subPath, main.buildPath..folder.path) + else + local destPath = listMode:GetDestName(folder.path, build.fileName) + local res, msg = os.rename(build.fullFileName, destPath) + if not res then + main:OpenMessagePopup("Error", "Couldn't move '"..build.fullFileName.."' to '"..destPath.."': "..(msg or "")) + return + end + end + listMode:BuildList() end - listMode:BuildList() + break end end end @@ -57,7 +65,7 @@ end function BuildListClass:LoadBuild(build) if build.folderName then - self.controls.path:SetSubPath(self.listMode.subPath .. build.folderName .. "/") + self.controls.path:SetSubPath(build.subPath .. build.folderName .. "/") else main:SetMode("BUILD", build.fullFileName, build.buildName) end @@ -171,10 +179,19 @@ end function BuildListClass:GetRowValue(column, index, build) if column == 1 then local label + local subPathPrefix = "" + if build.subPath and self.listMode and self.listMode.subPath and build.subPath ~= self.listMode.subPath then + local baseSub = self.listMode.subPath + if build.subPath:sub(1, #baseSub) == baseSub then + subPathPrefix = build.subPath:sub(#baseSub + 1) + else + subPathPrefix = build.subPath + end + end if build.folderName then - label = ">> " .. build.folderName + label = ">> " .. subPathPrefix .. build.folderName else - label = build.buildName or "?" + label = subPathPrefix .. (build.buildName or "?") end if self.cutBuild and self.cutBuild.buildName == build.buildName and self.cutBuild.folderName == build.folderName then return "^xC0B0B0"..label @@ -205,12 +222,20 @@ end function BuildListClass:ReceiveDrag(type, build, source) if type == "Build" then if self.hoverValue and self.hoverValue.folderName then - if build.folderName then - main:MoveFolder(build.folderName, main.buildPath..build.subPath, main.buildPath..self.hoverValue.subPath..self.hoverValue.folderName.."/") - else - os.rename(build.fullFileName, self.listMode:GetDestName(self.listMode.subPath..self.hoverValue.folderName.."/", build.fileName)) + local targetSubPath = self.hoverValue.subPath .. self.hoverValue.folderName .. "/" + if build.subPath ~= targetSubPath then + if build.folderName then + main:MoveFolder(build.folderName, main.buildPath..build.subPath, main.buildPath..targetSubPath) + else + local destPath = self.listMode:GetDestName(targetSubPath, build.fileName) + local res, msg = os.rename(build.fullFileName, destPath) + if not res then + main:OpenMessagePopup("Error", "Couldn't move '"..build.fullFileName.."' to '"..destPath.."': "..(msg or "")) + return + end + end + self.listMode:BuildList() end - self.listMode:BuildList() end end end diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 33f41615acd..3dda42de236 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -1570,10 +1570,11 @@ function CompareTabClass:OpenImportFolderPopup() end -- Search box and sort dropdown sit above the build list. - controls.searchText = new("EditControl", {"TOPLEFT", nil, "TOPLEFT"}, {15, 25, 450, 20}, "", "Search", "%c%(%)", 100, function(buf) + controls.searchText = new("EditControl", {"TOPLEFT", nil, "TOPLEFT"}, {15, 25, 450, 20}, "", nil, "%c%(%)", 100, function(buf) searchText = buf listHost:BuildList() end, nil, nil, true) + controls.searchText:SetPlaceholder("Search (e.g. class:assassin myfilename)") controls.sort = new("DropDownControl", {"TOPLEFT", nil, "TOPLEFT"}, {475, 25, 210, 20}, buildListHelpers.buildSortDropList, function(index, value) sortMode = value.sortMode main.buildSortMode = value.sortMode @@ -1590,7 +1591,7 @@ function CompareTabClass:OpenImportFolderPopup() -- navigate folders, import builds, and suppress rename/delete/drag behaviors. function controls.buildList:LoadBuild(build) if build.folderName then - self.controls.path:SetSubPath(self.listMode.subPath .. build.folderName .. "/") + self.controls.path:SetSubPath(build.subPath .. build.folderName .. "/") else importBuildEntry(build) end diff --git a/src/Classes/EditControl.lua b/src/Classes/EditControl.lua index 115d2551e14..97ce2c92fe1 100644 --- a/src/Classes/EditControl.lua +++ b/src/Classes/EditControl.lua @@ -401,6 +401,10 @@ function EditClass:Draw(viewPort, noTooltip) DrawImage(nil, caretX, textY, 1, textHeight) end else + if self.buf == '' and self.placeholder then + SetDrawColor(self.disableCol) + DrawString(textX, textY, "LEFT", textHeight, self.font, self.placeholder) + end local pre = self.textCol .. self.buf:sub(1, self.caret - 1) local post = self.buf:sub(self.caret) if self.protected then diff --git a/src/Modules/BuildList.lua b/src/Modules/BuildList.lua index c9b67025cd6..ccd9726f100 100644 --- a/src/Modules/BuildList.lua +++ b/src/Modules/BuildList.lua @@ -93,10 +93,11 @@ function listMode:Init(selBuildName, subPath) self.controls.ExtBuildList = self:getPublicBuilds() end - self.controls.searchText = new("EditControl", {"TOP",self.anchor,"TOP"}, {0, 25, 640, 20}, self.filterBuildList, "Search", "%c%(%)", 100, function(buf) + self.controls.searchText = new("EditControl", {"TOP",self.anchor,"TOP"}, {0, 25, 640, 20}, self.filterBuildList, nil, "%c%(%)", 100, function(buf) main.filterBuildList = buf self:BuildList() end, nil, nil, true) + self.controls.searchText:SetPlaceholder("Search (e.g. class:assassin myfilename)") self.controls.searchText.width = buildListWidth self.controls.searchText.x = buildListOffset @@ -180,9 +181,11 @@ function listMode:GetDestName(subPath, fileName) local i = 2 local destName = fileName while true do - local test = io.open(destName, "r") + local test = io.open(main.buildPath..subPath..destName, "r") if test then - destName = fileName .. "[" .. i .. "]" + test:close() + local baseName = fileName:gsub("%.xml$", "") + destName = baseName .. "[" .. i .. "].xml" i = i + 1 else break diff --git a/src/Modules/BuildListHelpers.lua b/src/Modules/BuildListHelpers.lua index aefc34f1745..54a583b094d 100644 --- a/src/Modules/BuildListHelpers.lua +++ b/src/Modules/BuildListHelpers.lua @@ -14,65 +14,137 @@ local buildSortDropList = { { label = "Sort by Level", sortMode = "LEVEL"}, } --- Scan main.buildPath..subPath for .xml builds and sub-folders. --- filterText is an optional substring filter applied to build filenames. --- Returns a freshly allocated list of entries in the shape used by BuildListControl. --- On cloud-read failure opens main:OpenCloudErrorPopup and returns whatever has been --- collected so far (matching the prior in-module behavior in Modules/BuildList). +local function ReadBuildHeader(fullFileName) + local fileHnd = io.open(fullFileName, "r") + if not fileHnd then return nil, nil, nil end + local headerText = fileHnd:read(2048) + fileHnd:close() + if not headerText then return nil, nil, nil end + + local buildTag = headerText:match("]->") + if not buildTag then return nil, nil, nil end + + local level = tonumber(buildTag:match('level="([^"]+)"')) + local className = buildTag:match('className="([^"]+)"') + local ascendClassName = buildTag:match('ascendClassName="([^"]+)"') + return level, className, ascendClassName +end + +local function MatchEntry(entry, terms) + for _, term in ipairs(terms) do + local val = term:match("^class:(.*)$") + if val then + val = val:lower() + if val ~= "" then + local match = (entry.className and entry.className:lower():find(val, 1, true)) or + (entry.ascendClassName and entry.ascendClassName:lower():find(val, 1, true)) or + (entry.folderName and entry.folderName:lower():find(val, 1, true)) + if not match then return false end + end + else + local termLower = term:lower() + local match = (entry.buildName and entry.buildName:lower():find(termLower, 1, true)) or + (entry.folderName and entry.folderName:lower():find(termLower, 1, true)) or + (entry.className and entry.className:lower():find(termLower, 1, true)) or + (entry.ascendClassName and entry.ascendClassName:lower():find(termLower, 1, true)) or + (entry.subPath and entry.subPath:lower():find(termLower, 1, true)) + if not match then return false end + end + end + return true +end + local function ScanFolder(subPath, filterText) subPath = subPath or "" filterText = filterText or "" local list = { } - local handle - if filterText ~= "" then - handle = NewFileSearch(main.buildPath..subPath.."*"..filterText.."*.xml") - else - handle = NewFileSearch(main.buildPath..subPath.."*.xml") + + local terms = { } + if filterText:match("%S") then + for term in filterText:gmatch("%S+") do + t_insert(terms, term) + end end - while handle do - local fileName = handle:GetFileName() - local build = { } - build.fileName = fileName - build.subPath = subPath - build.fullFileName = main.buildPath..subPath..fileName - build.modified = handle:GetFileModifiedTime() - build.buildName = fileName:gsub("%.xml$","") - local fileHnd = io.open(build.fullFileName, "r") - if fileHnd then - local fileText = fileHnd:read("*a") - fileHnd:close() - if not fileText then - main:OpenCloudErrorPopup(build.fullFileName) - return list - end - fileText = fileText:match("()") - if fileText then - local xml = common.xml.ParseXML(fileText.."") - if xml and xml[1] then - build.level = tonumber(xml[1].attrib.level) - build.className = xml[1].attrib.className - build.ascendClassName = xml[1].attrib.ascendClassName + + local function scanDir(currentSubPath) + local handle = NewFileSearch(main.buildPath..currentSubPath.."*.xml") + while handle do + local fileName = handle:GetFileName() + local buildName = fileName:gsub("%.xml$","") + local fullFileName = main.buildPath..currentSubPath..fileName + + if #terms == 0 then + if currentSubPath == subPath then + local level, className, ascendClassName = ReadBuildHeader(fullFileName) + t_insert(list, { + fileName = fileName, + subPath = currentSubPath, + fullFileName = fullFileName, + modified = handle:GetFileModifiedTime(), + buildName = buildName, + level = level, + className = className, + ascendClassName = ascendClassName, + }) + end + else + local level, className, ascendClassName = ReadBuildHeader(fullFileName) + local entry = { + fileName = fileName, + subPath = currentSubPath, + fullFileName = fullFileName, + modified = handle:GetFileModifiedTime(), + buildName = buildName, + level = level, + className = className, + ascendClassName = ascendClassName, + } + if MatchEntry(entry, terms) then + t_insert(list, entry) end end + + if not handle:NextFile() then break end end - t_insert(list, build) - if not handle:NextFile() then - break + + handle = NewFileSearch(main.buildPath..currentSubPath.."*", true) + local subFolders = { } + while handle do + local folderName = handle:GetFileName() + local modified = handle:GetFileModifiedTime() + t_insert(subFolders, { name = folderName, modified = modified }) + if not handle:NextFile() then break end end - end - handle = NewFileSearch(main.buildPath..subPath.."*", true) - while handle do - local folderName = handle:GetFileName() - t_insert(list, { - folderName = folderName, - subPath = subPath, - fullFileName = main.buildPath..subPath..folderName, - modified = handle:GetFileModifiedTime() - }) - if not handle:NextFile() then - break + + for _, folder in ipairs(subFolders) do + local folderName = folder.name + local nextSubPath = currentSubPath .. folderName .. "/" + + if #terms == 0 then + if currentSubPath == subPath then + t_insert(list, { + folderName = folderName, + subPath = currentSubPath, + fullFileName = main.buildPath..currentSubPath..folderName, + modified = folder.modified + }) + end + else + local folderEntry = { + folderName = folderName, + subPath = currentSubPath, + fullFileName = main.buildPath..currentSubPath..folderName, + modified = folder.modified + } + if MatchEntry(folderEntry, terms) then + t_insert(list, folderEntry) + end + scanDir(nextSubPath) + end end end + + scanDir(subPath) return list end From c12e625bd37d8e663be9de13b0f44070d65ead16 Mon Sep 17 00:00:00 2001 From: xoxorwr Date: Sat, 1 Aug 2026 10:01:25 +0200 Subject: [PATCH 2/3] Cleaner placeholder --- src/Classes/CompareTab.lua | 4 ++-- src/Modules/BuildList.lua | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 3dda42de236..7c4ecab95cd 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -1570,11 +1570,11 @@ function CompareTabClass:OpenImportFolderPopup() end -- Search box and sort dropdown sit above the build list. - controls.searchText = new("EditControl", {"TOPLEFT", nil, "TOPLEFT"}, {15, 25, 450, 20}, "", nil, "%c%(%)", 100, function(buf) + controls.searchText = new("EditControl", {"TOPLEFT", nil, "TOPLEFT"}, {15, 25, 450, 20}, "", "Search", "%c%(%)", 100, function(buf) searchText = buf listHost:BuildList() end, nil, nil, true) - controls.searchText:SetPlaceholder("Search (e.g. class:assassin myfilename)") + controls.searchText:SetPlaceholder("(e.g. class:assassin myfilename)") controls.sort = new("DropDownControl", {"TOPLEFT", nil, "TOPLEFT"}, {475, 25, 210, 20}, buildListHelpers.buildSortDropList, function(index, value) sortMode = value.sortMode main.buildSortMode = value.sortMode diff --git a/src/Modules/BuildList.lua b/src/Modules/BuildList.lua index ccd9726f100..62cc542f653 100644 --- a/src/Modules/BuildList.lua +++ b/src/Modules/BuildList.lua @@ -93,11 +93,11 @@ function listMode:Init(selBuildName, subPath) self.controls.ExtBuildList = self:getPublicBuilds() end - self.controls.searchText = new("EditControl", {"TOP",self.anchor,"TOP"}, {0, 25, 640, 20}, self.filterBuildList, nil, "%c%(%)", 100, function(buf) + self.controls.searchText = new("EditControl", {"TOP",self.anchor,"TOP"}, {0, 25, 640, 20}, self.filterBuildList, "Search", "%c%(%)", 100, function(buf) main.filterBuildList = buf self:BuildList() end, nil, nil, true) - self.controls.searchText:SetPlaceholder("Search (e.g. class:assassin myfilename)") + self.controls.searchText:SetPlaceholder("(e.g. class:assassin myfilename)") self.controls.searchText.width = buildListWidth self.controls.searchText.x = buildListOffset From 5455ff5dcaeabbd3e45e0adabdb19f82567ec54e Mon Sep 17 00:00:00 2001 From: xoxorwr Date: Sat, 1 Aug 2026 10:20:16 +0200 Subject: [PATCH 3/3] Comment --- src/Modules/BuildListHelpers.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Modules/BuildListHelpers.lua b/src/Modules/BuildListHelpers.lua index 54a583b094d..dbe6ed26c1a 100644 --- a/src/Modules/BuildListHelpers.lua +++ b/src/Modules/BuildListHelpers.lua @@ -54,6 +54,10 @@ local function MatchEntry(entry, terms) return true end +-- Scan main.buildPath..subPath for .xml builds and sub-folders. +-- filterText is an optional space-separated filter with class: prefix support. +-- Recursively searches subfolders when filterText is non-empty. +-- Returns a list of build and folder entries for BuildListControl. local function ScanFolder(subPath, filterText) subPath = subPath or "" filterText = filterText or ""