From a9f5ae510298865a6369fcca5dd253d6ffc39789 Mon Sep 17 00:00:00 2001 From: SethDGamre <165520713+SethDGamre@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:28:37 -0500 Subject: [PATCH 1/4] Quick Start - remove debug echo's (#9230) I left these debug echos in there to investigate miss-spawn reports for quickstart. I've received no bug reports thereafter so it's time for these to go away. --- luarules/gadgets/game_quick_start.lua | 111 +------------------------- luaui/Widgets/gui_pregame_build.lua | 20 ----- 2 files changed, 2 insertions(+), 129 deletions(-) diff --git a/luarules/gadgets/game_quick_start.lua b/luarules/gadgets/game_quick_start.lua index 7f21188d5da..22224b7d4fc 100644 --- a/luarules/gadgets/game_quick_start.lua +++ b/luarules/gadgets/game_quick_start.lua @@ -380,9 +380,7 @@ local function getCommanderBuildQueue(commanderID) generateOverlapLines(commanderID) end - Spring.Echo(string.format("=== Validating Build Queue for Commander %d (Team %d) ===", commanderID, comData.teamID)) - - for i, cmd in ipairs(commands) do + for _, cmd in ipairs(commands) do if isBuildCommand(cmd.id) then local unitDefID = -cmd.id local spawnParams = { @@ -394,7 +392,6 @@ local function getCommanderBuildQueue(commanderID) cmdTag = cmd.tag, } local unitDef = unitDefs[unitDefID] - local unitDefName = unitDef and unitDef.name or "UNKNOWN" local distance = distance2d(comData.spawnX, comData.spawnZ, spawnParams.x, spawnParams.z) local isTraversable = traversabilityGrid.canMoveToPosition( commanderID, @@ -410,14 +407,6 @@ local function getCommanderBuildQueue(commanderID) comData.overlapLines ) - local validationResults = { - distanceCheck = distance <= INSTANT_BUILD_RANGE, - traversableCheck = isTraversable, - notPastLinesCheck = not isPastFriendlyLines, - distance = distance, - maxDistance = INSTANT_BUILD_RANGE, - } - if distance <= INSTANT_BUILD_RANGE and isTraversable and not isPastFriendlyLines then local budgetCost = defMetergies[unitDefID] or 0 @@ -437,19 +426,6 @@ local function getCommanderBuildQueue(commanderID) totalBudgetCost = totalBudgetCost + budgetCost if totalBudgetCost > comData.budget then - Spring.Echo( - string.format( - " [%d] %s at (%.1f, %.1f, %.1f) facing: %d - REJECTED (Budget exceeded: %.1f > %.1f)", - i, - unitDefName, - spawnParams.x, - spawnParams.y, - spawnParams.z, - spawnParams.facing, - totalBudgetCost, - comData.budget - ) - ) comData.commandsToRemove = commandsToRemove return spawnQueue end @@ -457,42 +433,10 @@ local function getCommanderBuildQueue(commanderID) if cmd.tag then table.insert(commandsToRemove, cmd.tag) end - else - local failReasons = {} - if not validationResults.distanceCheck then - table.insert( - failReasons, - string.format( - "OutOfRange(%.1f > %.1f)", - validationResults.distance, - validationResults.maxDistance - ) - ) - end - if not validationResults.traversableCheck then - table.insert(failReasons, "NotTraversable") - end - if not validationResults.notPastLinesCheck then - table.insert(failReasons, "PastFriendlyLines") - end - local failReasonsStr = table.concat(failReasons, ", ") - Spring.Echo( - string.format( - " [%d] %s at (%.1f, %.1f, %.1f) facing: %d - REJECTED (%s)", - i, - unitDefName, - spawnParams.x, - spawnParams.y, - spawnParams.z, - spawnParams.facing, - failReasonsStr - ) - ) end end end comData.commandsToRemove = commandsToRemove - Spring.Echo(string.format("=== Accepted %d/%d build queue items ===", #spawnQueue, #commands)) return spawnQueue end @@ -890,22 +834,11 @@ end local function tryToSpawnBuild(commanderID, unitDefID, buildX, buildY, buildZ, facing) local unitDef, comData = unitDefs[unitDefID], commanders[commanderID] - local unitDefName = unitDef and unitDef.name or "UNKNOWN" local discount = getFactoryDiscount(unitDef, commanderID) local cost = defMetergies[unitDefID] - discount local unitID = spCreateUnit(unitDef.name, buildX, buildY, buildZ, facing, comData.teamID) if not unitID then - Spring.Echo( - string.format( - " SPAWN FAILED: %s at (%.1f, %.1f, %.1f) facing: %d - CreateUnit returned nil (terrain/collision conflict)", - unitDefName, - buildX, - buildY, - buildZ, - facing - ) - ) return false, nil end @@ -959,19 +892,13 @@ function gadget:GameFrame(frame) break end local loop = modOptions.quick_start ~= "factory_discount_only" - if loop and gameFrameTryCount == 1 then - Spring.Echo("=== Beginning Quick Start Spawn Phase ===") - end while loop do loop = false for commanderID, comData in pairs(commanders) do if comData.spawnQueue then - for i, buildItem in ipairs(comData.spawnQueue) do + for _, buildItem in ipairs(comData.spawnQueue) do local buildType = optionDefIDToTypes[buildItem.id] - local unitDef = unitDefs[buildItem.id] - local unitDefName = unitDef and unitDef.name or "UNKNOWN" local buildX, buildY, buildZ = buildItem.x, buildItem.y, buildItem.z - local hadCoordinates = buildX and buildY and buildZ if not buildX or not buildZ or not buildY then buildX, buildY, buildZ = getBuildSpace(commanderID, buildType) end @@ -981,40 +908,6 @@ function gadget:GameFrame(frame) if success then loop = true end - else - local failReasons = {} - if not buildItem.id then - table.insert(failReasons, "NoUnitDefID") - end - if not buildX then - table.insert( - failReasons, - hadCoordinates and "InvalidCoordinates" or "NoBuildSpaceAvailable" - ) - end - if comData.budget <= 0 then - table.insert(failReasons, "NoBudget") - end - if #failReasons > 0 then - local failReasonsStr = table.concat(failReasons, ", ") - local coordsStr = buildItem.x - and string.format( - "(%.1f, %.1f, %.1f)", - buildItem.x, - buildItem.y or 0, - buildItem.z - ) - or "(no coords)" - Spring.Echo( - string.format( - " SPAWN SKIPPED: %s at %s facing: %d - %s", - unitDefName, - coordsStr, - facing, - failReasonsStr - ) - ) - end end end end diff --git a/luaui/Widgets/gui_pregame_build.lua b/luaui/Widgets/gui_pregame_build.lua index e994f83b32a..d247e707a01 100644 --- a/luaui/Widgets/gui_pregame_build.lua +++ b/luaui/Widgets/gui_pregame_build.lua @@ -1601,26 +1601,6 @@ function widget:GameFrame(n) end end if tasker then - local quickStartOption = Spring.GetModOptions().quick_start - local quickStartEnabled = quickStartOption ~= "disabled" - - if quickStartEnabled and #buildQueue > 0 then - --we have to temporary Echo data like this because there are reports of builds that should be spawned in quickstart not being spawned. - --Widget data isn't caught in replays so we have to echo this for now. 1/12/26 - Spring.Echo(string.format("=== Build Queue for Commander (unitID: %d) ===", tasker)) - for b = 1, #buildQueue do - local buildData = buildQueue[b] - local unitDefID = buildData[1] - local unitDefName = unitDefID > 0 and UnitDefs[unitDefID] and UnitDefs[unitDefID].name or "MOVE_COMMAND" - local x, y, z = buildData[2], buildData[3], buildData[4] - local facing = buildData[5] or 0 - Spring.Echo( - string.format(" [%d] %s at (%.1f, %.1f, %.1f) facing: %d", b, unitDefName, x, y, z, facing) - ) - end - Spring.Echo(string.format("=== Total queue items: %d ===", #buildQueue)) - end - for b = 1, #buildQueue do local buildData = buildQueue[b] Spring.GiveOrderToUnit( From c21f3e01e7dc637d36ea536483fd755b28da1f33 Mon Sep 17 00:00:00 2001 From: Floris Date: Sun, 13 Sep 2026 00:38:25 +0200 Subject: [PATCH 2/4] widget selected: added show data button per widget (#9231) --- language/en/interface.json | 4 + luaui/Include/keybind_dropdown.lua | 12 +- luaui/Widgets/widget_selector.lua | 629 +++++++++++++++++++++++++++-- luaui/barwidgets.lua | 4 + 4 files changed, 613 insertions(+), 36 deletions(-) diff --git a/language/en/interface.json b/language/en/interface.json index 9aef3f21329..0d8855c55fd 100644 --- a/language/en/interface.json +++ b/language/en/interface.json @@ -750,6 +750,8 @@ "enabledonly": "Enabled only", "byorder": "By load order", "total": "total", + "defaulton": "default enabled", + "defaultoff": "default disabled", "profiler": "Cost", "byload": "By cost", "localonlydesc": "Show only the widgets in your own LuaUI folder, leaving out the ones the game ships.", @@ -761,6 +763,8 @@ "loadsetdesc": "Switches on every widget in the chosen set and switches off everything else, so the list ends up exactly as the set describes it.", "order": "Load order", "cleardata": "Reset", + "showdata": "Show data", + "close": "Close", "cleardatatitle": "Clear saved settings", "cleardatawarn": "Throws away everything %{name} has saved - its options, its window position, whatever it remembers - and it starts again from its defaults. Nothing else in the list is touched.", "cleardatarestartwarn": "Throws away everything %{name} has saved - its options, its window position, whatever it remembers. It is running, so it is switched off and on again to start from its defaults. Nothing else in the list is touched.", diff --git a/luaui/Include/keybind_dropdown.lua b/luaui/Include/keybind_dropdown.lua index f42e1c4fcee..e24da13b28c 100644 --- a/luaui/Include/keybind_dropdown.lua +++ b/luaui/Include/keybind_dropdown.lua @@ -127,7 +127,9 @@ function Dropdown:draw() local inset = floor((y2 - y1) * 0.3) Selector(x1, y1, x2, y2) - if mx >= x1 and mx <= x2 and my >= y1 and my <= y2 then + -- A control with nothing to choose from does not light under the cursor. Lighting is + -- what tells a player something will happen when they press, and here nothing will. + if not self.disabled and mx >= x1 and mx <= x2 and my >= y1 and my <= y2 then Highlight(x1, y1, x2, y2, floor(WG.FlowUI.elementCorner * 0.66), controlHoverOpacity, white) end @@ -137,7 +139,7 @@ function Dropdown:draw() local arrowH = floor((y2 - y1) * 0.16) local arrowX = x2 - inset - arrowH local arrowY = floor((y1 + y2) * 0.5 + arrowH * 0.5) - gl.Color(1, 1, 1, self.open and 0.9 or 0.55) + gl.Color(1, 1, 1, self.disabled and 0.25 or (self.open and 0.9 or 0.55)) chevronX, chevronY, chevronH = arrowX, arrowY, arrowH gl.BeginEnd(GL.TRIANGLES, chevronVertices) gl.Color(1, 1, 1, 1) @@ -195,6 +197,12 @@ function Dropdown:draw() end function Dropdown:mousePress(x, y) + if self.disabled then + self.open = false + + return false + end + if self.open then for i, r in ipairs(self.optRects) do if x >= r.x1 and x <= r.x2 and y >= r.y1 and y <= r.y2 then diff --git a/luaui/Widgets/widget_selector.lua b/luaui/Widgets/widget_selector.lua index 1578b775768..86233e4e71e 100644 --- a/luaui/Widgets/widget_selector.lua +++ b/luaui/Widgets/widget_selector.lua @@ -182,6 +182,21 @@ local colorDanger = "\255\255\190\190" -- -- One table rather than eight locals, for the same reason `metrics` and `look` are -- tables: this chunk is at Lua's ceiling of 200. +-- The colours gui_gameinfo lists source in, so stored settings read the same way there +-- and here. A kind with no entry falls back to plain text. +local codeColors = { + comment = "\255\125\140\155", + keyword = "\255\198\146\234", + string = "\255\180\215\140", + number = "\255\240\165\125", + call = "\255\130\170\245", + op = "\255\135\185\205", + -- Commas, dots and brackets are most of any source; tinting them like the arithmetic + -- turns the highlighting into noise, so they stay close to the plain text. + punct = "\255\140\148\156", + name = "\255\205\205\205", +} + local cost = { cool = "\255\140\140\140", warm = "\255\225\195\130", @@ -322,7 +337,7 @@ local dialogBox = {} ---@type string? local pressedRow local pressedButton = 0 -local pressedClear = false +local pressedClear, pressedData = false, false local hover = { sb = 0, row = 0, sw = 0, tog = 0, bar = 0, btn = "", dlg = "" } @@ -515,7 +530,11 @@ end -- By where they load, when the switch asks for it: what runs first is what draws first -- and gets the call-ins first, and reading it off the list is the only way to see it. -- Anything not running has no place in that order, so it follows, alphabetically. -local function sortByOrder(a, b) +-- The orderings the list can be in. One table rather than four locals: this chunk is at +-- Lua's ceiling of 200, and they belong together anyway. +local sortBy = {} + +function sortBy.order(a, b) if a.order and b.order then return a.order < b.order end @@ -528,7 +547,7 @@ end -- Mod widgets first and then the player's own, each alphabetical, with the profiler on -- top: it is the one a player opens this panel to reach in a hurry. -local function sortEntries(a, b) +function sortBy.name(a, b) if a.name == "Widget Profiler" then return true elseif b.name == "Widget Profiler" then @@ -547,7 +566,7 @@ end -- cursor is the hold in Update, not a slower number. -- -- Anything not running has nothing measured and sorts to the bottom. -local function sortByLoad(a, b) +function sortBy.cost(a, b) local sa = profiling.stats[a.name] local sb = profiling.stats[b.name] if sa and sb then @@ -563,12 +582,12 @@ local function sortByLoad(a, b) return a.name < b.name end -local function rowOrder() +function sortBy.pick() if filters.byLoad and filters.profiler then - return sortByLoad + return sortBy.cost end - return filters.byOrder and sortByOrder or sortEntries + return filters.byOrder and sortBy.order or sortBy.name end -- The rows the list shows: what the column, the search box and the filter toggle left. @@ -611,13 +630,13 @@ rebuildRows = function() return a.score > b.score end - return rowOrder()(a.e, b.e) + return sortBy.pick()(a.e, b.e) end) for i = 1, #scored do rows[i] = scored[i].e end else - table.sort(rows, rowOrder()) + table.sort(rows, sortBy.pick()) end end @@ -753,6 +772,10 @@ local function refreshSets() end end setPicker.placeholder = (not pickedSet or #names == 0) and L.noSet or nil + -- With nothing saved there is nothing to pick, so the control does not light under the + -- cursor and does not open: a list that drops open empty is worse than one that does not + -- move at all. + setPicker.disabled = #names == 0 setPicker:setOptions(names) setPicker:setSelected(selected) -- Load and Delete come and go with the pick, and the block is a row shorter without @@ -922,6 +945,335 @@ local function dialogName() return name, name == "" end +-- What a widget has actually saved, shown as the Lua it is stored as. +-- +-- The same lexer gui_gameinfo lists tweakdefs through, so stored settings read the way +-- source does everywhere else in the UI. The line breaks are this file's own: the formatter +-- breaks on statements and blocks, and a table constructor is neither, so a config of any +-- size would come back as one very long line. Each line is handed to it separately for the +-- colours, which is all that is wanted from it here. +-- +-- One table rather than a function and half a dozen state locals, for the reason every +-- other table in this file is one: the chunk is at Lua's ceiling of 200. +-- `indentChars` is how far one level of nesting steps in, in characters: the listing is +-- drawn in a monospaced face, so everything about its layout is arithmetic on that. +local dataView = { scroll = 0, lines = {}, rows = {}, rect = {}, close = {}, indentChars = 4 } + +-- The same lexer gui_gameinfo lists tweakdefs through. Optional, and kept on the table +-- rather than in a local of its own: a /luaui reload runs without a file that was added +-- since the game started, and a viewer without colours beats a panel that will not load. +do + local ok, mod = pcall(VFS.Include, "luaui/Include/lua_source.lua") + dataView.source = ok and mod or nil +end + +-- Strings before numbers and each in order, so the same settings read the same way twice. +function dataView.keys(t) + local out = {} + for k in pairs(t) do + out[#out + 1] = k + end + table.sort(out, function(a, b) + local ta, tb = type(a), type(b) + if ta ~= tb then + return ta == "string" + end + if ta == "string" or ta == "number" then + return a < b + end + + return tostring(a) < tostring(b) + end) + + return out +end + +-- A key as it would be written: a plain name bare, anything else in brackets. +function dataView.key(k) + if type(k) == "string" and string.find(k, "^[%a_][%w_]*$") then + return k + end + if type(k) == "string" then + return "[" .. dataView.quote(k) .. "]" + end + + return "[" .. tostring(k) .. "]" +end + +-- A string as it would be written, on one line. +-- +-- Not string.format("%q"): that escapes a newline as a backslash followed by a real one, +-- which is valid Lua but ends the line here - and a setting holding a few lines of text +-- then draws all of them on top of each other. +function dataView.quote(v) + local out = string.gsub(v, '[\\"]', "\\%0") + out = string.gsub(out, "\n", "\\n") + out = string.gsub(out, "\r", "\\r") + out = string.gsub(out, "\t", "\\t") + -- Anything else unprintable goes by its number, the way Lua writes it. + out = string.gsub(out, "%c", function(c) + return string.format("\\%d", string.byte(c)) + end) + + return '"' .. out .. '"' +end +function dataView.value(v) + local t = type(v) + if t == "string" then + return dataView.quote(v) + end + if t == "number" or t == "boolean" then + return tostring(v) + end + + -- A function or a userdata cannot be written back out, so it is said rather than shown. + return "<" .. t .. ">" +end + +-- Appends one line, lexed for its colours. Anything the lexer cannot read is kept as plain +-- text: this is a viewer, and half a reading beats an error. +function dataView.emit(depth, text) + local parts + if dataView.source then + local ok, lines = pcall(dataView.source.format, text) + if ok and lines[1] then + parts = lines[1].parts + end + end + dataView.lines[#dataView.lines + 1] = { depth = depth, parts = parts or { { s = text, k = "name" } } } +end + +function dataView.write(value, depth, prefix) + if type(value) ~= "table" then + dataView.emit(depth, prefix .. dataView.value(value) .. ",") + + return + end + if not next(value) then + dataView.emit(depth, prefix .. "{},") + + return + end + dataView.emit(depth, prefix .. "{") + for _, k in ipairs(dataView.keys(value)) do + dataView.write(value[k], depth + 1, dataView.key(k) .. " = ") + end + dataView.emit(depth, "},") +end + +function dataView.open(name) + dataView.name = name + dataView.scroll = 0 + dataView.lines = {} + dataView.rows = {} + -- The flow is keyed on the width and the widget; the settings themselves can have + -- changed under both, so opening always flows again. + dataView.wrappedFor = nil + local data = widgetHandler.configData[name] + if type(data) ~= "table" then + return + end + -- Written as the chunk it is stored as, so what is on screen is what is on disk. + dataView.emit(0, "return {") + for _, k in ipairs(dataView.keys(data)) do + dataView.write(data[k], 1, dataView.key(k) .. " = ") + end + dataView.emit(0, "}") +end + +function dataView.shut() + dataView.name = nil + dataView.lines = {} +end +-- Where the window sits: most of the panel, since the whole point is to see a lot of it at +-- once, but inside it rather than over the screen - it belongs to the panel it was opened +-- from. +function dataView.geometry() + local w = mathFloor(screenWidth * 0.6) + local h = mathFloor(screenHeight * 0.8) + local cx = mathFloor(screenX + screenWidth * 0.5) + local cy = mathFloor(screenY - screenHeight * 0.5) + local x1, y1 = cx - mathFloor(w * 0.5), cy - mathFloor(h * 0.5) + dataView.rect[1], dataView.rect[2], dataView.rect[3], dataView.rect[4] = x1, y1, x1 + w, y1 + h + + local pad = mathFloor(14 * widgetScale) + local bh = mathFloor(28 * widgetScale) + local bw = mathFloor(110 * widgetScale) + dataView.pad = pad + dataView.close[1], dataView.close[2] = x1 + w - pad - bw, y1 + pad + dataView.close[3], dataView.close[4] = x1 + w - pad, y1 + pad + bh + + -- The title takes a band of its own across the top, and the listing starts a whole line + -- below it: the lines are drawn from their baselines, so one starting level with the + -- bottom of the title band would have its ascenders run up into the title. + dataView.titleFs = mathFloor(metrics.rowHeight * 0.72) + dataView.titleY = y1 + h - pad - mathFloor(dataView.titleFs * 0.5) + dataView.lineH = mathFloor(metrics.rowHeight * 0.62) + dataView.fs = mathFloor(dataView.lineH * 0.78) + dataView.top = y1 + h - pad - dataView.titleFs - dataView.lineH + dataView.bottom = y1 + pad * 2 + bh + dataView.page = mathFloor((dataView.top - dataView.bottom) / dataView.lineH) + 1 + + -- The bar runs the height of the reading area, against the right edge. Same width as + -- the list's, so the two read as the same control. + dataView.barX1 = x1 + w - pad - metrics.barW + dataView.barX2 = x1 + w - pad + dataView.textX1 = x1 + pad + + -- How many characters fit across. The face is monospaced, so a width in characters is + -- exact and the wrapping below is arithmetic rather than measurement per part. + local code = look.mono or font + dataView.charW = code:GetTextWidth("0") * dataView.fs + local room = dataView.barX1 - pad - dataView.textX1 + dataView.chars = mathMax(16, mathFloor(room / mathMax(1, dataView.charW))) + + -- The lines only need flowing again when the width they were flowed to changes, or when + -- a different widget's settings are being shown. + if dataView.wrappedAt ~= dataView.chars or dataView.wrappedFor ~= dataView.name then + dataView.wrappedAt = dataView.chars + dataView.wrappedFor = dataView.name + dataView.wrap() + end + + return x1, y1, x1 + w, y1 + h +end + +-- Flows the stored lines into the width there is, keeping each token's kind so a line that +-- had to be broken is still coloured. A wrapped line carries on one indent further in, so +-- it reads as a continuation rather than as the next setting. +function dataView.wrap() + local rows = {} + dataView.rows = rows + + for _, line in ipairs(dataView.lines) do + local indent = line.depth * dataView.indentChars + local room = mathMax(8, dataView.chars - indent) + local parts, used = {}, 0 + + local function flush(depth) + rows[#rows + 1] = { indent = depth, parts = parts } + parts, used = {}, 0 + end + + for _, part in ipairs(line.parts) do + local text = part.s + while text ~= "" do + local left = room - used + if #text <= left then + parts[#parts + 1] = { s = text, k = part.k } + used = used + #text + break + end + -- Break at the last space that fits, so words survive; if there is no space + -- to break at - a long string or path - it is cut where the room runs out, + -- which is still better than running off the edge. + local cut = left + for i = left, 1, -1 do + if string.sub(text, i, i) == " " then + cut = i + break + end + end + if cut < 1 or (used == 0 and cut < 1) then + cut = left + end + if cut > 0 then + parts[#parts + 1] = { s = string.sub(text, 1, cut), k = part.k } + end + text = string.sub(text, cut + 1) + flush(indent) + -- Continuations sit one step further in, and get that step back in room. + indent = line.depth * dataView.indentChars + dataView.indentChars + room = mathMax(8, dataView.chars - indent) + end + end + if #parts > 0 then + flush(indent) + end + end +end + +function dataView.maxScroll() + local over = #(dataView.rows or {}) - dataView.page + + return over > 0 and over or 0 +end + +-- Where the thumb is, in the same terms the list's bar answers in. +function dataView.thumb() + return UiScrollerAt( + dataView.barX1, + dataView.bottom, + dataView.barX2, + dataView.top + dataView.lineH, + #dataView.rows * dataView.lineH, + dataView.scroll * dataView.lineH + ) +end + +-- Taking hold of the bar. On the thumb it is taken where it was grabbed, so the listing +-- does not jump before the drag starts; on the bare track it goes there at once, which is +-- what a press away from the thumb is asking for. +function dataView.grab(y) + local top, height = dataView.thumb() + if not top then + return + end + + dataView.dragging = true + if y <= top and y >= top - height then + dataView.grabAt = y - top + else + dataView.grabAt = -mathFloor(height * 0.5) + dataView.dragTo(y) + end +end + +function dataView.dragTo(y) + local _, _, trackTop, travel = dataView.thumb() + if not travel or travel <= 0 then + return + end + + local f = (trackTop - (y - dataView.grabAt)) / travel + if f < 0 then + f = 0 + elseif f > 1 then + f = 1 + end + dataView.setScroll(mathFloor(f * dataView.maxScroll() + 0.5)) +end + +function dataView.setScroll(v) + local max = dataView.maxScroll() + dataView.scroll = (v < 0 and 0) or (v > max and max) or v +end + +-- A press inside the window is the window's, whether or not it lands on anything: a click +-- meant for the settings must not reach the rows behind it. +function dataView.press(x, y) + if not dataView.name then + return false + end + local r = dataView.rect + if not math_isInRect(x, y, r[1], r[2], r[3], r[4]) then + -- Pressing outside it is how a window like this is dismissed. + dataView.shut() + + return true + end + local c = dataView.close + if math_isInRect(x, y, c[1], c[2], c[3], c[4]) then + dataView.shut() + elseif + dataView.maxScroll() > 0 + and math_isInRect(x, y, dataView.barX1, dataView.bottom, dataView.barX2, dataView.top + dataView.lineH) + then + dataView.grab(y) + end + + return true +end local function closeDialog() local d = dialog dialog = nil @@ -1065,6 +1417,10 @@ setLayout = function() metrics.buttonGap = mathFloor(6 * s) metrics.listGap = mathFloor(12 * s) metrics.cardLip = mathFloor(5 * s) + -- The gap between the category card and the sets block below it. The two were one + -- card, which left the sets reading as the last few categories rather than as a + -- different thing that happens to sit in the same column. + metrics.setsGap = mathFloor(7 * s) metrics.titleY = mathFloor(17 * s) metrics.titleFs = mathFloor(metrics.rowHeight * 0.85) metrics.sidebarDrop = mathFloor(8 * s) @@ -1085,8 +1441,8 @@ setLayout = function() -- from the widest they can print and the figures are right-aligned in them, so the -- decimal points line up down the list instead of wandering with the digits. metrics.loadFs = mathFloor(metrics.rowFs * 0.95) - metrics.cpuW = cost.font and mathFloor(cost.font:GetTextWidth(cost.sampleCpu) * metrics.loadFs) or mathFloor(34 * s) - metrics.memW = cost.font and mathFloor(cost.font:GetTextWidth(cost.sampleMem) * metrics.loadFs) or mathFloor(30 * s) + metrics.cpuW = look.mono and mathFloor(look.mono:GetTextWidth(cost.sampleCpu) * metrics.loadFs) or mathFloor(34 * s) + metrics.memW = look.mono and mathFloor(look.mono:GetTextWidth(cost.sampleMem) * metrics.loadFs) or mathFloor(30 * s) -- What the switch leaves above and below itself inside the row. It is held the same -- distance from the accent bar down the left edge, so the air around it reads as even -- rather than pinched on one side. @@ -1214,6 +1570,12 @@ setLayout = function() metrics.clearW = font and (mathFloor(font:GetTextWidth(L.cleardata) * metrics.clearFs) + metrics.rowPad * 3) or mathFloor(46 * s) clearX1 = listRight - metrics.rowPad - metrics.clearW + -- And beside it, the button that shows what the widget has actually saved. Same + -- reservation: it appears and disappears with the settings, and a description that + -- reflowed when one was saved would read worse than the gap. + metrics.dataW = font and (mathFloor(font:GetTextWidth(L.showdata) * metrics.clearFs) + metrics.rowPad * 3) + or mathFloor(66 * s) + metrics.dataX1 = clearX1 - metrics.rowPad - metrics.dataW -- What the tag at the end of a local row takes, so a description can be kept out of it. metrics.localTagW = font and mathFloor(font:GetTextWidth(L.islocal) * metrics.rowFs) or mathFloor(30 * s) @@ -1265,7 +1627,7 @@ local function fitRow(row) if row.desc ~= "" then -- A local row ends with its tag, so the description stops short of it rather than -- running underneath. - local descW = clearX1 - descX1 - metrics.rowPad * 2 + local descW = metrics.dataX1 - descX1 - metrics.rowPad * 2 if row.isLocal then descW = descW - metrics.localTagW - metrics.rowPad end @@ -1303,7 +1665,7 @@ local function drawButtonFace(r, fill) UiButton(r[1], r[2], r[3], r[4], 1, 1, 1, 1, 1, 1, 1, 1, nil, pair[1], pair[2]) end -local function drawRow(row, top, bottom, hovered, overSwitch, overClear) +local function drawRow(row, top, bottom, hovered, overSwitch, overClear, overData) fitRow(row) local fill = (row.state == 1 and look.activeFill) or (row.state == 0.5 and look.pendingFill) @@ -1333,7 +1695,7 @@ local function drawRow(row, top, bottom, hovered, overSwitch, overClear) if row.isLocal then -- The one thing about a widget that is not in its name or its description, and the -- thing a player most needs to tell apart: their own files from the game's. - queueText(colorLocal .. L.islocal, clearX1 - metrics.rowPad, ty, metrics.rowFs, "rov") + queueText(colorLocal .. L.islocal, metrics.dataX1 - metrics.rowPad, ty, metrics.rowFs, "rov") end -- Only where there is something to clear. Quiet until it is pointed at, and red then: @@ -1350,6 +1712,21 @@ local function drawRow(row, top, bottom, hovered, overSwitch, overClear) metrics.clearFs, "cov" ) + + -- And beside it, what the widget has saved. Plain rather than red: reading settings + -- takes nothing away, and only the button that does should look like it might. + local d = { metrics.dataX1, cy1, metrics.dataX1 + metrics.dataW, cy1 + metrics.clearH } + drawButtonFace(d, look.buttonFill) + if overData then + Highlight(d[1], d[2], d[3], d[4], metrics.csButton, look.hoverOpacity, look.white) + end + queueText( + (overData and colorText or colorDim) .. L.showdata, + mathFloor((d[1] + d[3]) * 0.5), + ty, + metrics.clearFs, + "cov" + ) end end @@ -1361,11 +1738,11 @@ end -- Nothing is drawn behind them. A plate per row would put two hundred small boxes down -- the panel and turn a column of figures into a table nobody asked for. local function drawCostColumns() - if not (filters.profiler and cost.font) then + if not (filters.profiler and look.mono) then return end - cost.font:Begin() + look.mono:Begin() for i = 1, #rows - scroll do local row = rows[scroll + i] if not row then @@ -1384,7 +1761,7 @@ local function drawCostColumns() local cpu = stat.load local mem = stat.space -- Right-aligned in its own column, so the figures line up down the list. - cost.font:Print( + look.mono:Print( (cpu >= cost.cpuHot and cost.hot or cpu >= cost.cpuWarn and cost.warm or cost.cool) .. string.format("%.1f%%", cpu), metrics.cpuX1 + metrics.cpuW, @@ -1392,7 +1769,7 @@ local function drawCostColumns() metrics.loadFs, "rov" ) - cost.font:Print( + look.mono:Print( (mem >= cost.memHot and cost.hot or mem >= cost.memWarn and cost.warm or cost.cool) .. string.format("%.0fk", mem), metrics.memX1 + metrics.memW, @@ -1402,7 +1779,7 @@ local function drawCostColumns() ) end end - cost.font:End() + look.mono:End() end local function drawRows() @@ -1416,7 +1793,100 @@ local function drawRows() if bottom < listBottom then break end - drawRow(row, top, bottom, hover.row == i, hover.row == i and hover.sw == 1, hover.row == i and hover.clr == 1) + drawRow( + row, + top, + bottom, + hover.row == i, + hover.row == i and hover.sw == 1, + hover.row == i and hover.clr == 1, + hover.row == i and hover.dat == 1 + ) + end +end + +function dataView.draw() + local x1, y1, x2, y2 = dataView.geometry() + local mx, my, lmb = spGetMouseState() + local pad = dataView.pad + + -- A drag runs for as long as the button is held, wherever the cursor goes: letting go + -- is the only thing that ends it. + if dataView.dragging then + if lmb then + dataView.dragTo(my) + else + dataView.dragging = false + end + end + + -- Everything behind it dims, the same as a confirmation does: this is the only thing + -- that will answer while it is up. + RectRound(screenX, screenY - screenHeight, screenX + screenWidth, screenY, elementCorner, 1, 1, 1, 1, look.scrim) + UiElement(x1, y1, x2, y2, 1, 1, 1, 1, 1, 1, 1, 1, WG.FlowUI.clampedOpacity) + + local overClose = math_isInRect(mx, my, dataView.close[1], dataView.close[2], dataView.close[3], dataView.close[4]) + drawButtonFace(dataView.close, look.buttonFill) + if overClose then + Highlight( + dataView.close[1], + dataView.close[2], + dataView.close[3], + dataView.close[4], + metrics.csButton, + look.hoverOpacity, + look.white + ) + end + + font:Begin() + font:Print(colorTitle .. dataView.name, x1 + pad, dataView.titleY, dataView.titleFs, "ov") + font:Print( + colorText .. L.close, + mathFloor((dataView.close[1] + dataView.close[3]) * 0.5), + mathFloor((dataView.close[2] + dataView.close[4]) * 0.5), + metrics.setsFs, + "cov" + ) + font:End() + + -- The listing, in the monospaced face: this is source, and source whose columns do not + -- line up is harder to read than source with no colour at all. Already flowed to the + -- width by geometry, so a row here is a row on screen. + local code = look.mono or font + code:Begin() + for i = 1, dataView.page do + local row = dataView.rows[dataView.scroll + i] + if not row then + break + end + local ly = dataView.top - (i - 1) * dataView.lineH + local lx = dataView.textX1 + row.indent * dataView.charW + for _, part in ipairs(row.parts) do + code:Print((codeColors[part.k] or codeColors.name) .. part.s, lx, ly, dataView.fs, "o") + lx = lx + #part.s * dataView.charW + end + end + code:End() + + -- How much is off the bottom, and how to get at it. A bar rather than a count: a count + -- says there is more without saying where, and this panel scrolls everything else with + -- a bar you can take hold of. + if dataView.maxScroll() > 0 then + -- Lit under the cursor and again while it is being dragged, the same as the list's + -- bar: a bar that does not react is a bar nobody tries to take hold of. + local top, height = dataView.thumb() + local onThumb = top and my <= top and my >= top - height and mx >= dataView.barX1 and mx <= dataView.barX2 + UiScroller( + dataView.barX1, + dataView.bottom, + dataView.barX2, + dataView.top + dataView.lineH, + #dataView.rows * dataView.lineH, + dataView.scroll * dataView.lineH, + onThumb or false, + dataView.dragging or false + ) end end @@ -1446,9 +1916,10 @@ local function drawSetsBlock() end local function drawSidebar() + -- The categories get their own card, ending where the sets block begins. RectRound( area.x1, - listBottom, + setsTop, area.x1 + metrics.sidebarW, sidebarTop() + metrics.cardLip, metrics.csPanel, @@ -1459,6 +1930,22 @@ local function drawSidebar() look.sidebarFill, look.sidebarFillTop ) + + -- And the sets get theirs, with air between them. One card holding both made the + -- sets read as the tail of the category list rather than as their own thing. + RectRound( + area.x1, + listBottom, + area.x1 + metrics.sidebarW, + setsTop - metrics.setsGap, + metrics.csPanel, + 1, + 1, + 1, + 1, + look.sidebarFill, + look.sidebarFillTop + ) queueText(colorTitle .. L.title, area.x1 + metrics.sidePad, area.y2 - metrics.titleY, metrics.titleFs, "ov") if categories[1] and categories[1].fitGen ~= layoutGen then @@ -1748,7 +2235,9 @@ local function drawFloating(name, fn) end local function updateShading() - if dialog and dialogBox[1] then + if dataView.name and dataView.rect[1] then + shadeRect("dialog", dataView.rect[1], dataView.rect[2], dataView.rect[3], dataView.rect[4]) + elseif dialog and dialogBox[1] then shadeRect("dialog", dialogBox[1], dialogBox[2], dialogBox[3], dialogBox[4]) else shadeRect("dialog") @@ -1791,10 +2280,14 @@ local function rowClearable(i) end local function panelChanged(mx, my) - hover.sb, hover.row, hover.sw, hover.tog, hover.bar, hover.clr = 0, 0, 0, 0, 0, 0 + hover.sb, hover.row, hover.sw, hover.tog, hover.bar, hover.clr, hover.dat = 0, 0, 0, 0, 0, 0, 0 hover.btn, hover.dlg = "", "" - if dialog then + if dataView.name then + -- The settings window takes the cursor outright, the same as a modal does: nothing + -- behind it lights, because nothing behind it will answer a click. The hover fields + -- are already cleared above, so there is nothing more to do here. + elseif dialog then -- A modal takes the cursor outright: lighting anything behind it would say it could -- still be clicked. local _, blocked = dialogName() @@ -1823,6 +2316,8 @@ local function panelChanged(mx, my) hover.sw = 1 elseif hover.row > 0 and mx >= clearX1 and rowClearable(hover.row) then hover.clr = 1 + elseif hover.row > 0 and mx >= metrics.dataX1 and rowClearable(hover.row) then + hover.dat = 1 end elseif mx >= barX1 and mx <= area.x2 then local top, height = scrollerThumb() @@ -1857,6 +2352,7 @@ local function panelChanged(mx, my) now[4] = hover.tog now[5] = hover.bar now[6] = hover.clr + now[17] = hover.dat now[7] = filters.profiler now[8] = hover.btn now[9] = hover.dlg @@ -1871,7 +2367,7 @@ local function panelChanged(mx, my) now[16] = dialog ~= nil and select(2, dialogName()) or false local changed = false - for i = 1, 16 do + for i = 1, 17 do if was[i] ~= now[i] then was[i] = now[i] changed = true @@ -1899,7 +2395,10 @@ end -- the panel cost. So the sweep is spread. The rows actually on screen are checked every -- frame, because those are the ones being looked at and a click has to show in the row it -- landed on; the rest of the list and the load order are swept a slice at a time, which --- finds a widget switched from somewhere else within a few frames instead of within one. +-- finds a widget switched from somewhere else within about a fifth of a second instead of +-- within a frame. Nothing this panel does itself waits on that - `dirty` answers those +-- outright - and doubling the slice to halve that wait costs a third more for a delay +-- nobody can see. -- Nobody can see the difference, and it is several times cheaper. function sweep.moved(e) return e.state ~= stateOf(e.name, e.data) or e.hasConfig ~= sweep.hasConfig(e.name) @@ -2047,9 +2546,13 @@ local function loadLabels() L.hint = tr("hint", "Click to toggle. Right-click sends it to the front of its layer, middle-click to the back.") L.order = tr("order", "Load order") L.total = tr("total", "total") + L.defaultOn = tr("defaulton", "default enabled") + L.defaultOff = tr("defaultoff", "default disabled") L.profiler = tr("profiler", "Cost") L.byLoad = tr("byload", "By cost") L.cleardata = tr("cleardata", "Reset") + L.showdata = tr("showdata", "Show data") + L.close = tr("close", "Close") L.cleardataTitle = tr("cleardatatitle", "Clear saved settings") -- The fallbacks only. These two carry the widget's name, and i18n fills a %{...} in -- as the string is looked up - so looking one up here, with no name to hand, would @@ -2139,11 +2642,11 @@ local function bindUi() end font = WG.fonts.getFont() - -- The monospaced face, for the cost columns alone. Figures that change several times + -- The monospaced face, for the cost columns and the stored-settings listing. Figures that change several times -- a second wander sideways in a proportional face as the digits under them change, -- which turns a column that should be read at a glance into one that has to be -- re-read. Fixed widths hold the decimal point still. - cost.font = WG.fonts.getFont(3) + look.mono = WG.fonts.getFont(3) elementCorner = WG.FlowUI.elementCorner RectRound = WG.FlowUI.Draw.RectRound UiElement = WG.FlowUI.Draw.Element @@ -2379,9 +2882,13 @@ function widget:Update() -- While a dialog is asking for a name it owns the keyboard too, or nothing typed -- into it arrives. + -- widgetHandler:KeyPress tries the text owner first, then the action bindings, and only + -- then the widgets. So a panel that does not own the keyboard never sees Escape: the + -- binding has already closed something by the time its own KeyPress would run. Anything + -- here that has to answer Escape itself therefore has to own it. local wantsInput = show and uiBound - and ((dialog and dialog.field) or (not dialog and searchBox and searchBox:isFocused())) + and (dataView.name ~= nil or (dialog and dialog.field) or (not dialog and searchBox and searchBox:isFocused())) if wantsInput then if not fieldHasInput then fieldHasInput = true @@ -2507,6 +3014,11 @@ local function showTooltip(row) elseif row.state == 0.5 then stateColor, stateWord = "\255\255\240\160", L.statePending end + -- Said on the state line rather than a line of its own: what matters about the shipped + -- default is how it sits against the state now, and "Off (on by default)" is that whole + -- story in one reading. + stateWord = stateWord .. " \255\140\140\140(" .. (d.enabled and L.defaultOn or L.defaultOff) .. ")" + local title = stateColor .. row.name .. "\n" local maxWidth = WG.tooltip.getFontsize() * 90 @@ -2648,7 +3160,10 @@ function widget:DrawScreen() -- Live, over the baked panel: a text field's caret blinks and its contents change as -- it is typed into, and the picker's list opens over the rows. if show then - if dialog then + if dataView.name then + dropFloat("picker") + drawFloating("dialog", dataView.draw) + elseif dialog then dropFloat("picker") drawFloating("dialog", function() drawDialog(dialog) @@ -2699,6 +3214,16 @@ function widget:KeyPress(key) return false end + -- Innermost first: the settings window is over everything, so Escape closes it before + -- anything else is considered. + if dataView.name then + if key == KEYSYMS.ESCAPE then + dataView.shut() + end + + return true + end + if dialog then if key == KEYSYMS.ESCAPE then closeDialog() @@ -2751,8 +3276,11 @@ function widget:KeyRelease(key) if not show or not uiBound then return false end - -- A dialog swallows releases as well as presses: the key that was typed into it must - -- not fire whatever it is bound to on the way back up. + -- The window and a dialog swallow releases as well as presses: the key answered by + -- either must not fire whatever it is bound to on the way back up. + if dataView.name then + return true + end if dialog then return dialog.field == true end @@ -2773,6 +3301,11 @@ function widget:TextInput(utf8char) if not (show and uiBound) then return false end + -- The settings window owns the keyboard while it is up, so nothing typed at it leaks + -- into the search field behind it. + if dataView.name then + return true + end if dialog then return dialog.field and nameBox:textInput(utf8char) or false end @@ -2797,6 +3330,12 @@ function widget:MouseWheel(up, _value) if dialog then return true end + -- The settings window takes the wheel while it is up: it is the thing being read. + if dataView.name then + dataView.setScroll(dataView.scroll + (up and -3 or 3)) + + return true + end -- Over the column it scrolls the column, over anything else the list. A wheel that -- moved the list while the cursor was on the categories would read as broken. @@ -2953,13 +3492,21 @@ local function mouseEvent(x, y, button, release) -- was under the cursor: releasing over the row after pressing the button would -- otherwise toggle the widget. local onClear = overRow and overRow.hasConfig and x >= clearX1 and x <= listRight or false + local onData = overRow and overRow.hasConfig and x >= metrics.dataX1 and x < clearX1 or false if not release then pressedRow = overRow and overRow.name or nil pressedButton = button pressedClear = onClear + pressedData = onData elseif overRow and overRow.name == pressedRow and button == pressedButton then -- A click, rather than a drag that happened to finish over a row. - if button == 1 and (onClear or pressedClear) then + if button == 1 and (onData or pressedData) then + -- Both halves on the button, the same as the one beside it. + if onData and pressedData then + dataView.open(overRow.name) + click() + end + elseif button == 1 and (onClear or pressedClear) then -- Both halves of the click have to be on the button. Pressing it and sliding off -- before letting go is how a player takes an accidental press back. if onClear and pressedClear then @@ -2990,7 +3537,7 @@ local function mouseEvent(x, y, button, release) end end if release then - pressedRow, pressedButton, pressedClear = nil, 0, false + pressedRow, pressedButton, pressedClear, pressedData = nil, 0, false, false end return true @@ -3005,10 +3552,24 @@ local function mouseEvent(x, y, button, release) end function widget:MousePress(x, y, button) + -- The settings window is over everything, so it answers first: a press meant for it + -- must not fall through to the rows underneath. + if dataView.name and dataView.press(x, y) then + return true + end + return mouseEvent(x, y, button, false) end function widget:MouseRelease(x, y, button) + -- The window owns the release as well as the press: a drag down its bar can end with + -- the cursor anywhere, and that must not read as a click on a row behind it. + if dataView.name then + dataView.dragging = false + + return true + end + return mouseEvent(x, y, button, true) end diff --git a/luaui/barwidgets.lua b/luaui/barwidgets.lua index 103d66be49f..c68f6efe41d 100644 --- a/luaui/barwidgets.lua +++ b/luaui/barwidgets.lua @@ -630,6 +630,10 @@ function widgetHandler:LoadWidget(filename, fromZip, enableLocalsAccess, reload) knownInfo.filename = widget.whInfo.filename knownInfo.fromZip = fromZip knownInfo.hidden = widget.whInfo.hidden + -- Whether the widget ships switched on. Kept here because this is the only place it is + -- seen for a widget that ends up not being loaded: whInfo belongs to the instance, and + -- a widget that is off has no instance. + knownInfo.enabled = widget.whInfo.enabled self.knownWidgets[name] = knownInfo self.knownCount = self.knownCount + 1 self.knownChanged = true From 3110cde9fc845833bd697bc36c469ce155fc0ff1 Mon Sep 17 00:00:00 2001 From: Floris Date: Sun, 13 Sep 2026 01:48:22 +0200 Subject: [PATCH 3/4] widget selector: add if its an rml widget and also if errored + added category Changed (from default) (#9232) --- language/en/interface.json | 10 ++ luaui/Include/keybind_editor_view.lua | 15 +- luaui/Widgets/gui_changelog_info.lua | 24 ++- luaui/Widgets/gui_gameinfo.lua | 24 ++- luaui/Widgets/widget_selector.lua | 210 +++++++++++++++++++++----- luaui/barwidgets.lua | 58 ++++--- 6 files changed, 267 insertions(+), 74 deletions(-) diff --git a/language/en/interface.json b/language/en/interface.json index 0d8855c55fd..6bb630980a1 100644 --- a/language/en/interface.json +++ b/language/en/interface.json @@ -717,7 +717,11 @@ "file": "File", "author": "Author", "islocal": "local", + "isrml": "rml", + "iserror": "error", "category": { + "changed": "Changed", + "local": "Your own", "all": "All", "interface": "Interface", "commands": "Commands", @@ -754,6 +758,12 @@ "defaultoff": "default disabled", "profiler": "Cost", "byload": "By cost", + "alldesc": "Every widget the game knows about, whether it is running or not.", + "changeddesc": "Every widget switched to something other than what it ships as - on when it ships off, or off when it ships on. What this game has been customised into, and exactly what Factory defaults would undo.", + "prefixdesc": "Widgets whose file begins with %{prefix}.", + "otherdesc": "Widgets whose file begins with something this panel has no category for.", + "countsdesc": "The count reads how many are running out of how many there are.", + "localdesc": "The widgets in your own LuaUI folder rather than the ones the game ships. They carry a local tag on the row too.", "localonlydesc": "Show only the widgets in your own LuaUI folder, leaving out the ones the game ships.", "enabledonlydesc": "Show only the widgets the config says to load - running or not - so what is off stays out of the way.", "byorderdesc": "Order the list the way the widgets load, which is the order their call-ins run in. Anything not running has no place in that order and follows at the end.", diff --git a/luaui/Include/keybind_editor_view.lua b/luaui/Include/keybind_editor_view.lua index dda9a828aac..40c7859a34b 100644 --- a/luaui/Include/keybind_editor_view.lua +++ b/luaui/Include/keybind_editor_view.lua @@ -2165,8 +2165,15 @@ end -- `grab` does - this chunk is at Lua's ceiling of 200 locals. local function categoryRect(i) local top = sidebarTop() - (i - 1 - hover.cat) * metrics.catRowHeight + -- The right edge gives way to the bar when there is one. Without that an entry runs + -- under it and its hover plate disappears beneath the bar rather than stopping beside + -- it. The page count is worked out inline: this chunk is at Lua's 200. + local right = area.x1 + sidebarW + if #categories > math.max(1, floor((sidebarTop() - listBottom()) / metrics.catRowHeight)) then + right = right - metrics.catInset - metrics.catBarW - metrics.catInset + end - return area.x1, top - metrics.catRowHeight, area.x1 + sidebarW, top + return area.x1, top - metrics.catRowHeight, right, top end -- Scrolls the category column by `delta` entries and answers how far it can be scrolled @@ -2316,7 +2323,11 @@ local function drawSidebar(hoverIdx) local bx2 = area.x1 + sidebarW - metrics.catInset Scroller( bx2 - metrics.catBarW, - listBottom(), + -- Over the entries rather than the whole card: the last row rarely lands exactly on + -- the bottom, and a bar running past it reads as dead space at the foot of the column + -- - and its thumb then says more fits than does. + sidebarTop() + - math.max(1, floor((sidebarTop() - listBottom()) / metrics.catRowHeight)) * metrics.catRowHeight, bx2, sidebarTop(), #categories * metrics.catRowHeight, diff --git a/luaui/Widgets/gui_changelog_info.lua b/luaui/Widgets/gui_changelog_info.lua index 3825a9e27f1..6a9cb80e168 100644 --- a/luaui/Widgets/gui_changelog_info.lua +++ b/luaui/Widgets/gui_changelog_info.lua @@ -403,12 +403,24 @@ local function sidebarTop() return listTop - metrics.sidebarDrop end +-- How many entries the column has room for, and how far it can be scrolled. +local function catPageRows() + return mathMax(1, mathFloor((sidebarTop() - listBottom) / metrics.catRowHeight)) +end + -- `i` is the entry's place in `versions`, not its place on screen: the two differ by -- however far the column is scrolled. local function categoryRect(i) local top = sidebarTop() - (i - 1 - catScroll) * metrics.catRowHeight + -- The right edge gives way to the bar when there is one. Without that the count reads + -- right up against it and the hover plate runs underneath it, which looks like the + -- plate is behind the bar rather than the bar being beside the row. + local right = area.x1 + metrics.sidebarW + if #versions > catPageRows() then + right = right - metrics.catInset - metrics.catBarW - metrics.catInset + end - return area.x1, top - metrics.catRowHeight, area.x1 + metrics.sidebarW, top + return area.x1, top - metrics.catRowHeight, right, top end -- The month entry under x,y, or nil. Half-open on the shared edge, so one point never @@ -432,11 +444,6 @@ local function sidebarIndexAt(x, y) return i end --- How many entries the column has room for, and how far it can be scrolled. -local function catPageRows() - return mathMax(1, mathFloor((sidebarTop() - listBottom) / metrics.catRowHeight)) -end - local function maxCatScroll() return mathMax(0, #versions - catPageRows()) end @@ -504,9 +511,12 @@ local function drawSidebar() -- there are more months than the card has room for. if maxCatScroll() > 0 then local bx2 = area.x1 + metrics.sidebarW - metrics.catInset + -- Over the entries, not over the whole card: the last row rarely lands exactly on the + -- bottom, and a bar running past it reads as a column with dead space at its foot - + -- and makes the thumb say more fits than does. UiScroller( bx2 - metrics.catBarW, - listBottom, + sidebarTop() - catPageRows() * metrics.catRowHeight, bx2, sidebarTop(), #versions * metrics.catRowHeight, diff --git a/luaui/Widgets/gui_gameinfo.lua b/luaui/Widgets/gui_gameinfo.lua index 0822bbb0829..df8d6c62975 100644 --- a/luaui/Widgets/gui_gameinfo.lua +++ b/luaui/Widgets/gui_gameinfo.lua @@ -1198,12 +1198,24 @@ local function sidebarTop() return listTop - metrics.sidebarDrop end +-- How many entries the column has room for, and how far it can be scrolled. +local function catPageRows() + return mathMax(1, mathFloor((sidebarTop() - listBottom) / metrics.catRowHeight)) +end + -- `i` is the entry's place in `categories`, not its place on screen: the two differ by -- however far the column is scrolled. local function categoryRect(i) local top = sidebarTop() - (i - 1 - catScroll) * metrics.catRowHeight + -- The right edge gives way to the bar when there is one. Without that the count reads + -- right up against it and the hover plate runs underneath it, which looks like the + -- plate is behind the bar rather than the bar being beside the row. + local right = area.x1 + metrics.sidebarW + if #categories > catPageRows() then + right = right - metrics.catInset - metrics.catBarW - metrics.catInset + end - return area.x1, top - metrics.catRowHeight, area.x1 + metrics.sidebarW, top + return area.x1, top - metrics.catRowHeight, right, top end -- The category entry under x,y, or nil. Half-open on the shared edge, like the rows, so @@ -1227,11 +1239,6 @@ local function sidebarIndexAt(x, y) return i end --- How many entries the column has room for, and how far it can be scrolled. -local function catPageRows() - return mathMax(1, mathFloor((sidebarTop() - listBottom) / metrics.catRowHeight)) -end - local function maxCatScroll() return mathMax(0, #categories - catPageRows()) end @@ -1789,9 +1796,12 @@ local function drawSidebar() -- there are more categories than the card has room for. if maxCatScroll() > 0 then local bx2 = area.x1 + metrics.sidebarW - metrics.catInset + -- Over the entries, not over the whole card: the last row rarely lands exactly on the + -- bottom, and a bar running past it reads as a column with dead space at its foot - + -- and makes the thumb say more fits than does. UiScroller( bx2 - metrics.catBarW, - listBottom, + sidebarTop() - catPageRows() * metrics.catRowHeight, bx2, sidebarTop(), #categories * metrics.catRowHeight, diff --git a/luaui/Widgets/widget_selector.lua b/luaui/Widgets/widget_selector.lua index 86233e4e71e..dc43e69be56 100644 --- a/luaui/Widgets/widget_selector.lua +++ b/luaui/Widgets/widget_selector.lua @@ -138,6 +138,10 @@ local look = { -- out or its conditions were not met. pendingFill = { 1, 0.8, 0.35, 0.09 }, pendingAccent = { 1, 0.78, 0.3, 0.9 }, + -- Asked for and it would not load. The same three marks the other states get, in the + -- colour the panel uses for everything that has gone wrong. + errorFill = { 1, 0.35, 0.35, 0.12 }, + errorAccent = { 1, 0.35, 0.35, 0.95 }, buttonFill = { 0.18, 0.18, 0.18, 1 }, -- Anything that cannot be undone without a reload. The same stops the keybind -- editor's destructive buttons use, so the two panels read alike. @@ -174,7 +178,13 @@ local colorText = "\255\235\235\235" -- A widget the player wrote or dropped in themselves, rather than one the game ships. -- Enabled but not running: warm, because nothing is actually happening. local colorPending = "\255\255\210\135" -local colorLocal = "\255\130\175\230" +-- The tags at the end of a row: whose file the widget is, and which UI it draws +-- through. One table rather than one local each - this chunk is at Lua's 200. +local tagColors = { + islocal = "\255\130\175\230", + isrml = "\255\200\150\235", + iserror = "\255\255\120\120", +} local colorDanger = "\255\255\190\190" -- How the two cost columns read. Quiet while a widget is cheap and warm once it is not, -- on the thresholds the profiler overlay marks a widget red at; `sample` is the widest @@ -262,7 +272,6 @@ local setCatScroll -- was last laid out with, so adding one is an entry here rather than another pair of -- locals threaded through the layout, the draw, the hover test and the press. local switches = { - { key = "localOnly" }, { key = "enabledOnly" }, { key = "byOrder" }, { key = "profiler" }, @@ -289,10 +298,10 @@ local selectedCategory -- What the header switches are set to, keyed the way they name themselves so a switch is -- one entry in the list above and one field here. -- --- `localOnly` keeps the player's own files. `enabledOnly` keeps anything the config says +-- `enabledOnly` keeps anything the config says -- to load, whether or not it is running. `byOrder` sorts by where each widget sits in the -- handler's list rather than by name, which is the only way the load order can be seen. -local filters = { localOnly = false, enabledOnly = false, byOrder = false, profiler = false, byLoad = false } +local filters = { enabledOnly = false, byOrder = false, profiler = false, byLoad = false } ---@type table local searchBox ---@type table @@ -458,6 +467,18 @@ local function buildEntries() layer = layer[name], desc = desc, isLocal = not data.fromZip, + -- Whether it draws through RmlUi, which barwidgets reads out of the source at load: + -- where the file sits does not answer it, since a player's own RmlUi widget can live + -- anywhere. Worth saying on the row, and said alongside `local` rather than instead + -- of it - a widget can be both. + isRml = data.rml == true or (data.filename or ""):find("RmlWidgets", 1, true) ~= nil, + -- Switched to something other than what it ships as. `enabled` is what the GetInfo + -- block asked for, which barwidgets keeps for every widget it has ever seen. + changed = (stateOf(name, data) > 0) ~= (data.enabled == true), + -- Why it did not load, if it did not. Until barwidgets kept this the panel could + -- say a widget was asked for and is not running, and nothing about why - the reason + -- was in infolog.txt and nowhere else. + loadError = widgetHandler.loadErrors and widgetHandler.loadErrors[data.basename] or nil, -- Lowercased once here rather than per keystroke: a search walks every one of -- these on every letter typed. searchName = string.lower(name), @@ -481,19 +502,47 @@ end -- every letter typed is noise rather than information. local function buildCategories() local counts, active, total, on = {}, {}, 0, 0 + local changed, changedOn = 0, 0 + local mine, mineOn = 0, 0 for i = 1, #entries do local e = entries[i] - if (not filters.localOnly or e.isLocal) and (not filters.enabledOnly or e.state > 0) then + if not filters.enabledOnly or e.state > 0 then counts[e.group] = (counts[e.group] or 0) + 1 total = total + 1 if e.data.active then active[e.group] = (active[e.group] or 0) + 1 on = on + 1 end + if e.changed then + changed = changed + 1 + if e.data.active then + changedOn = changedOn + 1 + end + end + if e.isLocal then + mine = mine + 1 + if e.data.active then + mineOn = mineOn + 1 + end + end end end categories = { { key = nil, label = L.all, count = total, active = on } } + -- Everything switched to something other than what it ships as: what this game has + -- been customised into, which is the question the panel is usually opened with. It + -- sits at the head of the column rather than as a sixth header switch, which is more + -- than the header holds at 1280 - and it is a view of the whole list rather than a + -- filter on one part of it, so it belongs with All. + if changed > 0 then + categories[#categories + 1] = { key = "changed", label = L.changed, count = changed, active = changedOn } + end + -- The player's own files, which was a header switch until the column turned out to be + -- the better home for it: it is a view of the whole list like the two above it, and + -- the header had no room to spare. + if mine > 0 then + categories[#categories + 1] = { key = "local", label = L.mine, count = mine, active = mineOn } + end for _, g in ipairs(GROUP_ORDER) do if counts[g] then categories[#categories + 1] = { key = g, label = L[g] or g, count = counts[g], active = active[g] or 0 } @@ -605,9 +654,14 @@ rebuildRows = function() for i = 1, #entries do local e = entries[i] if - (not selectedCategory or e.group == selectedCategory) - and (not filters.localOnly or e.isLocal) - and (not filters.enabledOnly or e.state > 0) + -- `changed` and `local` are views of the whole list rather than filename prefixes, so + -- each is matched on what it means instead of on the group. + ( + not selectedCategory + or (selectedCategory == "changed" and e.changed) + or (selectedCategory == "local" and e.isLocal) + or e.group == selectedCategory + ) and (not filters.enabledOnly or e.state > 0) then if query.empty then rows[#rows + 1] = e @@ -1345,28 +1399,38 @@ local function sidebarTop() return listTop - metrics.sidebarDrop end --- `i` is the entry's place in `categories`, not its place on screen: the two differ by --- however far the column is scrolled. -local function categoryRect(i) - local top = sidebarTop() - (i - 1 - catScroll) * metrics.catRowHeight - - return area.x1, top - metrics.catRowHeight, area.x1 + metrics.sidebarW, top +-- How many entries fit between the title and the sets block below. +local function catPageRows() + return mathMax(1, mathFloor((sidebarTop() - setsTop) / metrics.catRowHeight)) end --- The column runs from the title down to whatever the sets block leaves it. +-- Where the column stops: a whole number of entries below the title, not wherever the +-- sets block happens to begin. The leftover is never a full row, and a card and a +-- scrollbar drawn over it read as a column with dead space at the foot of it. local function categoryBottom() - return setsTop -end - --- How many entries the column has room for, and how far it can be scrolled. -local function catPageRows() - return mathMax(1, mathFloor((sidebarTop() - categoryBottom()) / metrics.catRowHeight)) + return sidebarTop() - catPageRows() * metrics.catRowHeight end local function maxCatScroll() return mathMax(0, #categories - catPageRows()) end +-- Where an entry sits. `i` is its place in `categories`, not its place on screen: the two +-- differ by however far the column is scrolled. +-- +-- The right edge gives way to the bar when there is one. Without that the count reads +-- right up against it and the hover plate runs underneath it, which looks like the plate +-- is behind the bar rather than the bar being beside the row. +local function categoryRect(i) + local top = sidebarTop() - (i - 1 - catScroll) * metrics.catRowHeight + local right = area.x1 + metrics.sidebarW + if maxCatScroll() > 0 then + right = right - metrics.catInset - metrics.catBarW - metrics.catInset + end + + return area.x1, top - metrics.catRowHeight, right, top +end + setCatScroll = function(n) local m = maxCatScroll() catScroll = (n < 0 and 0) or (n > m and m) or n @@ -1578,6 +1642,8 @@ setLayout = function() metrics.dataX1 = clearX1 - metrics.rowPad - metrics.dataW -- What the tag at the end of a local row takes, so a description can be kept out of it. metrics.localTagW = font and mathFloor(font:GetTextWidth(L.islocal) * metrics.rowFs) or mathFloor(30 * s) + -- And what the RmlUi tag takes beside it. Both can be on the same row. + metrics.rmlTagW = font and mathFloor(font:GetTextWidth(L.isrml) * metrics.rowFs) or mathFloor(22 * s) if dialog then dialogGeometry() @@ -1631,6 +1697,9 @@ local function fitRow(row) if row.isLocal then descW = descW - metrics.localTagW - metrics.rowPad end + if row.isRml then + descW = descW - metrics.rmlTagW - metrics.rowPad + end row.fitDesc = descColor .. text.fit(font, row.desc, descW, metrics.rowFs) else row.fitDesc = nil @@ -1668,8 +1737,15 @@ end local function drawRow(row, top, bottom, hovered, overSwitch, overClear, overData) fitRow(row) - local fill = (row.state == 1 and look.activeFill) or (row.state == 0.5 and look.pendingFill) - local accent = (row.state == 1 and look.activeAccent) or (row.state == 0.5 and look.pendingAccent) + -- A widget that was asked for and would not load reads as its own state rather than + -- as the amber one: it is not waiting for anything, it is broken, and the panel knows + -- what is wrong with it. + local fill = (row.loadError and look.errorFill) + or (row.state == 1 and look.activeFill) + or (row.state == 0.5 and look.pendingFill) + local accent = (row.loadError and look.errorAccent) + or (row.state == 1 and look.activeAccent) + or (row.state == 0.5 and look.pendingAccent) if fill then RectRound(listX1, bottom, listRight, top, metrics.csSmall, 1, 1, 1, 1, fill) RectRound(listX1, bottom + 1, listX1 + metrics.accentW, top - 1, metrics.csSmall, 1, 1, 1, 1, accent) @@ -1692,10 +1768,20 @@ local function drawRow(row, top, bottom, hovered, overSwitch, overClear, overDat if row.fitDesc then queueText(row.fitDesc, descX1, ty, metrics.rowFs, "ov") end + -- The two things about a widget that are not in its name or its description: whose + -- file it is, and whether it draws through RmlUi rather than through this UI. Both + -- right to left from the buttons, so a row with both still reads in order. + local tagX = metrics.dataX1 - metrics.rowPad if row.isLocal then - -- The one thing about a widget that is not in its name or its description, and the - -- thing a player most needs to tell apart: their own files from the game's. - queueText(colorLocal .. L.islocal, metrics.dataX1 - metrics.rowPad, ty, metrics.rowFs, "rov") + queueText(tagColors.islocal .. L.islocal, tagX, ty, metrics.rowFs, "rov") + tagX = tagX - metrics.localTagW - metrics.rowPad + end + if row.isRml then + queueText(tagColors.isrml .. L.isrml, tagX, ty, metrics.rowFs, "rov") + tagX = tagX - metrics.rmlTagW - metrics.rowPad + end + if row.loadError then + queueText(tagColors.iserror .. L.iserror, tagX, ty, metrics.rowFs, "rov") end -- Only where there is something to clear. Quiet until it is pointed at, and red then: @@ -1916,10 +2002,12 @@ local function drawSetsBlock() end local function drawSidebar() - -- The categories get their own card, ending where the sets block begins. + -- The categories get their own card, with the same lip below the last entry as it has + -- above the first. Ending exactly on the last row leaves it sitting on the edge, and a + -- selected or hovered last entry then has its plate flush with the card's own border. RectRound( area.x1, - setsTop, + categoryBottom() - metrics.cardLip, area.x1 + metrics.sidebarW, sidebarTop() + metrics.cardLip, metrics.csPanel, @@ -2488,7 +2576,7 @@ local function loadLabels() L.other = tr("category.other", "Other") L.search = tr("search", "Search...") - L.localOnly = tr("localonly", "Local only") + L.mine = tr("category.local", "Your own") L.enabledOnly = tr("enabledonly", "Enabled only") L.byOrder = tr("byorder", "By load order") L.sets = tr("sets", "Widget sets") @@ -2545,6 +2633,9 @@ local function loadLabels() -- never leave that band. L.hint = tr("hint", "Click to toggle. Right-click sends it to the front of its layer, middle-click to the back.") L.order = tr("order", "Load order") + L.changed = tr("category.changed", "Changed") + L.isrml = tr("isrml", "rml") + L.iserror = tr("iserror", "error") L.total = tr("total", "total") L.defaultOn = tr("defaulton", "default enabled") L.defaultOff = tr("defaultoff", "default disabled") @@ -2568,9 +2659,21 @@ local function loadLabels() -- destructive ones point at the wording their own confirmation uses, so what the -- tooltip promises and what the dialog asks cannot drift apart. L.desc = { - localOnly = tr( - "localonlydesc", - "Show only the widgets in your own LuaUI folder, leaving out the ones the game ships." + -- The column. What a full-word category actually collects is the one thing its label + -- deliberately does not say. + all = tr("alldesc", "Every widget the game knows about, whether it is running or not."), + changed = tr( + "changeddesc", + "Every widget switched to something other than what it ships as - on when it ships off, or off when it ships on. What this game has been customised into, and exactly what Factory defaults would undo." + ), + -- The fallback only: see where it is used. Fetching it here would fill the + -- placeholder in with nothing. + prefix = "Widgets whose file begins with %{prefix}.", + other = tr("otherdesc", "Widgets whose file begins with something this panel has no category for."), + counts = tr("countsdesc", "The count reads how many are running out of how many there are."), + mine = tr( + "localdesc", + "The widgets in your own LuaUI folder rather than the ones the game ships. They carry a local tag on the row too." ), enabledOnly = tr( "enabledonlydesc", @@ -2950,7 +3053,41 @@ end local function showTooltip(row) local caption, body - if hover.tog > 0 and switches[hover.tog] and switches[hover.tog].draw then + if hover.sb > 0 and categories[hover.sb] then + local c = categories[hover.sb] + caption = c.label + if c.key == nil then + body = L.desc.all + elseif c.key == "changed" then + body = L.desc.changed + elseif c.key == "local" then + body = L.desc.mine + else + -- Which filename prefix this one collects. The full-word label deliberately does + -- not say it, and it is the one thing about a category worth knowing. + local prefix + for p, g in pairs(GROUPS) do + if g == c.key then + prefix = p + end + end + if prefix then + -- Looked up here with the prefix in hand rather than taken from L: i18n fills a + -- %{...} in as the string is looked up, and looking it up without one bakes the + -- word nil into the sentence. The gsub covers the other path, where the key is + -- missing and the fallback is handed back untouched. + body = BAR.I18N("ui.widgetselector.prefixdesc", { prefix = prefix .. "_", default = L.desc.prefix }) + body = (body:gsub("%%{prefix}", prefix .. "_")) + else + body = L.desc.other + end + end + -- A category with nothing to say shows nothing rather than taking the panel down with + -- it: the tooltip is not worth a crash. + if body then + body = body .. "\n" .. L.desc.counts + end + elseif hover.tog > 0 and switches[hover.tog] and switches[hover.tog].draw then local sw = switches[hover.tog] caption, body = sw.label, L.desc[sw.key] elseif hover.btn ~= "" then @@ -3044,6 +3181,11 @@ local function showTooltip(row) return end local tip = stateColor .. stateWord .. "\n" + -- Straight after the state, because for a widget that would not load it is the only + -- thing worth reading: what the handler said when it tried. + if row.loadError then + tip = tip .. "\255\255\120\120" .. L.iserror .. ": " .. row.loadError .. "\n" + end if d.desc and d.desc ~= "" then tip = tip .. "\255\255\255\255" @@ -3575,7 +3717,6 @@ end function widget:GetConfigData() return { - localOnly = filters.localOnly, enabledOnly = filters.enabledOnly, byOrder = filters.byOrder, profiler = filters.profiler, @@ -3590,7 +3731,6 @@ function widget:SetConfigData(data) if type(data) ~= "table" then return end - filters.localOnly = data.localOnly == true -- Rebuilt rather than taken as read: this comes off disk, and a malformed entry here -- would otherwise reach the picker and the apply. sets = {} diff --git a/luaui/barwidgets.lua b/luaui/barwidgets.lua index c68f6efe41d..36c73d00f24 100644 --- a/luaui/barwidgets.lua +++ b/luaui/barwidgets.lua @@ -70,6 +70,8 @@ widgetHandler = { widgets = {}, configData = {}, + -- Why each widget that failed to load did, keyed by its file. See loadFailed. + loadErrors = {}, orderList = {}, knownWidgets = {}, @@ -522,6 +524,19 @@ function widgetHandler:ReloadUserWidgetFromGameRaw(name) return w end +-- Why a widget did not load, keyed by its file. +-- +-- These used to be echoed and forgotten, which left the widget selector able to say a +-- widget was asked for and is not running, but not why - and the reason was sitting in +-- infolog.txt the whole time. Keyed by basename because most of the ways loading can fail +-- happen before the widget has told anyone its name. +local function loadFailed(basename, reason) + Spring.Echo("Failed to load: " .. basename .. " (" .. reason .. ")") + widgetHandler.loadErrors[basename] = reason + + return nil +end + function widgetHandler:LoadWidget(filename, fromZip, enableLocalsAccess, reload) local basename = Basename(filename) local text = VFS.LoadFile( @@ -529,8 +544,7 @@ function widgetHandler:LoadWidget(filename, fromZip, enableLocalsAccess, reload) not (self.allowUserWidgets and allowuserwidgets and not reload) and VFS.ZIP or VFS.RAW_FIRST ) if text == nil then - Spring.Echo("Failed to load: " .. basename .. " (missing file: " .. filename .. ")") - return nil + return loadFailed(basename, "missing file: " .. filename) end if enableLocalsAccess then @@ -544,16 +558,14 @@ function widgetHandler:LoadWidget(filename, fromZip, enableLocalsAccess, reload) local chunk, err = loadstring(textWithLocalsDetector, filename) if chunk == nil then - Spring.Echo("Failed to load: " .. basename .. " (" .. err .. ")") - return nil + return loadFailed(basename, err) end local widget = widgetHandler:NewWidget(enableLocalsAccess, fromZip) setfenv(chunk, widget) local success, err = pcall(chunk) if not success then - Spring.Echo("Failed to load: " .. basename .. " (" .. err .. ")") - return nil + return loadFailed(basename, err) end if err == false then return nil -- widget asked for a silent death @@ -566,16 +578,14 @@ function widgetHandler:LoadWidget(filename, fromZip, enableLocalsAccess, reload) local chunk, err = loadstring(text, filename) if chunk == nil then - Spring.Echo("Failed to load: " .. basename .. " (" .. err .. ")") - return nil + return loadFailed(basename, err) end local widget = widgetHandler:NewWidget(enableLocalsAccess, fromZip) setfenv(chunk, widget) local success, err = pcall(chunk) if not success then - Spring.Echo("Failed to load: " .. basename .. " (" .. err .. ")") - return nil + return loadFailed(basename, err) end if err == false then return nil -- widget asked for a silent death @@ -591,13 +601,7 @@ function widgetHandler:LoadWidget(filename, fromZip, enableLocalsAccess, reload) if fromZip or true then widget.widgetHandler = self else - Spring.Echo( - "Failed to load: " .. basename .. " (user widgets may not access widgetHandler)", - fromZip, - filename, - allowuserwidgets - ) - return nil + return loadFailed(basename, "user widgets may not access widgetHandler") end end @@ -611,15 +615,13 @@ function widgetHandler:LoadWidget(filename, fromZip, enableLocalsAccess, reload) err = self:ValidateWidget(widget) if err then - Spring.Echo("Failed to load: " .. basename .. " (" .. err .. ")") - return nil + return loadFailed(basename, err) end local knownInfo = self.knownWidgets[name] if knownInfo and not reload then if knownInfo.active then - Spring.Echo("Failed to load: " .. basename .. " (duplicate name)") - return nil + return loadFailed(basename, "duplicate name") end else -- create a knownInfo table @@ -634,6 +636,12 @@ function widgetHandler:LoadWidget(filename, fromZip, enableLocalsAccess, reload) -- seen for a widget that ends up not being loaded: whInfo belongs to the instance, and -- a widget that is off has no instance. knownInfo.enabled = widget.whInfo.enabled + -- And whether it draws through RmlUi, which nothing else records. Read out of the + -- source, because where the file sits does not answer it: most RmlUi widgets live + -- under LuaUI/RmlWidgets but a player's own can sit anywhere and still use the API. + -- Matched on the API being reached for rather than the word appearing, so a widget + -- that only mentions RmlUi in a comment is not mistaken for one. + knownInfo.rml = string.find(text, "RmlUi%s*[%.%[]") ~= nil or string.find(text, "not%s+RmlUi") ~= nil self.knownWidgets[name] = knownInfo self.knownCount = self.knownCount + 1 self.knownChanged = true @@ -642,8 +650,7 @@ function widgetHandler:LoadWidget(filename, fromZip, enableLocalsAccess, reload) knownInfo.localsAccess = enableLocalsAccess if widget.GetInfo == nil then - Spring.Echo("Failed to load: " .. basename .. " (no GetInfo() call)") - return nil + return loadFailed(basename, "no GetInfo() call") end -- Get widget information @@ -681,6 +688,9 @@ function widgetHandler:LoadWidget(filename, fromZip, enableLocalsAccess, reload) widget:SetConfigData(config) end + -- It loaded, so whatever was wrong with it last time no longer is. + self.loadErrors[basename] = nil + return widget end @@ -1100,6 +1110,7 @@ function widgetHandler:InsertWidgetRaw(widget) self.knownWidgets[name].active = false end Spring.Echo("Missing capabilities: " .. name .. ". Disabling.") + self.loadErrors[widget.whInfo.basename] = "missing capabilities" return end -- Gracefully ignore/reload good control widgets advertising themselves as such, if user 'unit control' widgets disabled. @@ -1107,6 +1118,7 @@ function widgetHandler:InsertWidgetRaw(widget) local name = widget.whInfo.name if not self:ReloadUserWidgetFromGameRaw(name) then Spring.Echo("Blocked loading: " .. name .. " (user 'unit control' widgets disabled for this game)") + self.loadErrors[widget.whInfo.basename] = "user 'unit control' widgets are disabled for this game" end return end From 28e122237458f39cd89d254aa44bfc31448cc7f6 Mon Sep 17 00:00:00 2001 From: Floris Date: Sun, 13 Sep 2026 02:54:30 +0200 Subject: [PATCH 4/4] widget selector: removed unload widgets button + fix (#9233) --- luaui/Widgets/widget_selector.lua | 89 +++++++++++++++++++++---------- luaui/barwidgets.lua | 29 +++++++--- 2 files changed, 82 insertions(+), 36 deletions(-) diff --git a/luaui/Widgets/widget_selector.lua b/luaui/Widgets/widget_selector.lua index dc43e69be56..fd7f95d6b77 100644 --- a/luaui/Widgets/widget_selector.lua +++ b/luaui/Widgets/widget_selector.lua @@ -186,6 +186,8 @@ local tagColors = { iserror = "\255\255\120\120", } local colorDanger = "\255\255\190\190" +-- And the other half of that pair: a press that turns something on rather than off. +local colorGood = "\255\190\255\190" -- How the two cost columns read. Quiet while a widget is cheap and warm once it is not, -- on the thresholds the profiler overlay marks a widget red at; `sample` is the widest -- each column ever prints and what its width is measured from. @@ -224,6 +226,7 @@ local cost = { -- this list stays a curation of the game's own rather than a catch-all that grows a -- category out of every typo. local GROUPS = { + gui = "interface", cmd = "commands", unit = "units", @@ -301,6 +304,16 @@ local selectedCategory -- `enabledOnly` keeps anything the config says -- to load, whether or not it is running. `byOrder` sorts by where each widget sits in the -- handler's list rather than by name, which is the only way the load order can be seen. +-- The switches, and one field that is not a switch. A reload tears every widget down and +-- builds it again, so a panel that does not say it was open comes back closed - which +-- reads as the button having switched the panel off rather than reloaded the UI. +-- `reopen` carries that across, and has two states rather than one because the panel +-- that asks and the panel that answers are different panels: `"asked"` is set on this +-- side of the reload and must survive every Update until the settings are taken, and +-- `"restore"` is what the fresh panel reads back and opens on, once. One flag with two +-- readings rather than two flags, and here rather than in a local of its own, because +-- this chunk is at Lua's ceiling of 200 - and because this is the table the saved +-- settings round-trip. local filters = { enabledOnly = false, byOrder = false, profiler = false, byLoad = false } ---@type table local searchBox @@ -916,17 +929,12 @@ local function deleteSet(name) end local function reloadLuaUI() + -- Before the command, not after: the handler's Shutdown asks every widget for its + -- settings on the way out, and that is what carries this across. + filters.reopen = "asked" spSendCommands("luarules reloadluaui") end -local function disableAll() - for i = 1, #entries do - widgetHandler:DisableWidget(entries[i].name) - sweep.dirty = true - end - widgetHandler:SaveConfigData() -end - local function toggleUserWidgets() if widgetHandler.allowUserWidgets then widgetHandler.__allowUserWidgets = false @@ -938,8 +946,16 @@ local function toggleUserWidgets() reloadLuaUI() end +-- Back to the set of widgets the game enables by default, keeping what each of them has +-- saved. +-- +-- Not `luaui reset`: there is no such command - ConfigureLayout has no branch for it and +-- never had, so this button has quietly done nothing since long before this panel was +-- rewritten. The handler drops the load order on the way out instead, the same way +-- Factory defaults drops the whole config. local function resetLuaUI() - spSendCommands("luaui reset") + widgetHandler.__blankOutOrder = true + reloadLuaUI() end local function factoryReset() @@ -1375,15 +1391,12 @@ end local function buttonAction(id) if id == "reload" then reloadLuaUI() - elseif id == "disableall" then - confirm(L.disableAll, L.disableAllWarn, disableAll, true) + elseif id == "userwidgets" then - confirm( - widgetHandler.allowUserWidgets and L.disallowUser or L.allowUser, - widgetHandler.allowUserWidgets and L.disallowUserWarn or L.allowUserWarn, - toggleUserWidgets, - widgetHandler.allowUserWidgets - ) + -- No confirmation. Nothing is thrown away either way, the same button puts it + -- straight back, and the panel returns after the reload; the wording that used to + -- be the question is still on the tooltip, where it is read before the press. + toggleUserWidgets() elseif id == "reset" then confirm(L.reset, L.resetWarn, resetLuaUI, true) elseif id == "factory" then @@ -2122,7 +2135,9 @@ local function drawFooter() local r = b.rect if r then local hovered = hover.btn == b.id - local fill = b.danger and (hovered and look.dangerFillHover or look.dangerFill) or nil + local fill = b.danger and (hovered and look.dangerFillHover or look.dangerFill) + or b.good and (hovered and look.confirmFillHover or look.confirmFill) + or nil drawButtonFace(r, fill or look.buttonFill) -- A tinted button would lose its colour under the white overlay, so it brightens -- its own fill above instead. @@ -2130,7 +2145,7 @@ local function drawFooter() Highlight(r[1], r[2], r[3], r[4], metrics.csButton, look.hoverOpacity, look.white) end queueText( - (b.danger and colorDanger or colorText) .. (b.label or ""), + (b.danger and colorDanger or b.good and colorGood or colorText) .. (b.label or ""), mathFloor((r[1] + r[3]) * 0.5), mathFloor((r[2] + r[4]) * 0.5), metrics.buttonFs, @@ -2160,7 +2175,7 @@ local function drawDialog(d) local buttons = { { r = dialogCancel, id = "cancel" } } if not blocked then -- Green when the accept saves something, red when it takes something away. - buttons[2] = { r = dialogOk, id = "ok", danger = d.danger, confirm = d.field } + buttons[2] = { r = dialogOk, id = "ok", danger = d.danger, confirm = d.field or not d.danger } end for _, b in ipairs(buttons) do local hovered = hover.dlg == b.id @@ -2194,7 +2209,7 @@ local function drawDialog(d) ) if not blocked then font:Print( - (d.danger and colorDanger or colorText) .. (d.field and L.save or L.confirm), + (d.danger and colorDanger or colorGood) .. (d.field and L.save or L.confirm), mathFloor((dialogOk[1] + dialogOk[3]) * 0.5), mathFloor((dialogOk[2] + dialogOk[4]) * 0.5), sfs, @@ -2603,7 +2618,6 @@ local function loadLabels() L.cancel = tr("cancel", "Cancel") L.confirm = tr("confirm", "Confirm") L.reload = tr("button_reloadluaui", "Reload LuaUI") - L.disableAll = tr("button_unloadallwidgets", "Unload All Widgets") L.disallowUser = tr("button_disallowuserwidgets", "Disallow User Widgets") L.allowUser = tr("button_allowuserwidgets", "Allow User Widgets") L.reset = tr("button_resetluaui", "Reset LuaUI") @@ -2612,10 +2626,6 @@ local function loadLabels() "factorydefaultswarn", "This throws away every interface setting you have: which widgets are on, their positions, and anything you have configured in them. LuaUI reloads immediately. It cannot be undone." ) - L.disableAllWarn = tr( - "unloadallwarn", - "Switches off every widget in the list at once. Your settings are kept, and you can switch them back on one at a time." - ) L.disallowUserWarn = tr( "disallowuserwarn", "Stops loading widgets from your own LuaUI folder, leaving only the ones the game ships. LuaUI reloads immediately." @@ -2695,7 +2705,6 @@ local function loadLabels() "reloaddesc", "Loads every widget again from disk, keeping what is switched on. The quickest way to pick up a widget you have just edited." ), - disableall = L.disableAllWarn, reset = L.resetWarn, factory = L.factoryWarn, loadset = tr( @@ -2723,17 +2732,26 @@ end local function buildButtons() buttons = { { id = "reload", label = L.reload }, - { id = "disableall", label = L.disableAll, danger = true }, { id = "userwidgets", label = widgetHandler.allowUserWidgets and L.disallowUser or L.allowUser, + -- Red while they are allowed, because the press takes them away; green while + -- they are not, because the press brings them back. danger = widgetHandler.allowUserWidgets, + good = not widgetHandler.allowUserWidgets, }, { id = "reset", label = L.reset, danger = true }, { id = "factory", label = L.factoryDefaults, danger = true }, } if not allowuserwidgets then - table.remove(buttons, 3) + -- By id, not by position: the list has lost a button before now and the index + -- went stale with it, which took out the wrong one. + for i, b in ipairs(buttons) do + if b.id == "userwidgets" then + table.remove(buttons, i) + break + end + end end end @@ -2942,6 +2960,16 @@ function widget:Update() applyProfiling() end + -- Back up after a reload this panel asked for. Only `"restore"`, never `"asked"`: the + -- panel that asked is still open and still running, and clearing the flag here would + -- take it back before the handler ever gets to write it down. Waits for FlowUI the way + -- the first open does, and clears itself, so closing the panel cannot reopen it. + if filters.reopen == "restore" and (uiBound or bindUi()) then + filters.reopen = nil + widget:ViewResize() + setShow(true) + end + -- Only the row under the cursor is broken down per callin: the include smooths one -- widget at a time, and nothing reads more than one at once. if filters.profiler then @@ -3724,6 +3752,8 @@ function widget:GetConfigData() category = selectedCategory, sets = sets, pickedSet = pickedSet, + -- Only written while it is set, so it is not a line in everyone's config saying no. + reopen = filters.reopen ~= nil or nil, } end @@ -3759,4 +3789,5 @@ function widget:SetConfigData(data) filters.profiler = data.profiler == true filters.byLoad = filters.profiler and data.byLoad == true selectedCategory = type(data.category) == "string" and data.category or nil + filters.reopen = data.reopen == true and "restore" or nil end diff --git a/luaui/barwidgets.lua b/luaui/barwidgets.lua index 36c73d00f24..cfdde382487 100644 --- a/luaui/barwidgets.lua +++ b/luaui/barwidgets.lua @@ -348,7 +348,21 @@ function widgetHandler:LoadConfigData() end end +-- Writes the widget config out. +-- +-- A reset asked for on the way out is honoured here rather than at the call site, +-- because this is called from more places than the shutdown: a widget's own Shutdown can +-- call it (cmd_terraform_suite does, to leave its suite switched off), and those run +-- after Shutdown has written - which put the whole config back and left a factory reset +-- looking like it had done nothing at all. function widgetHandler:SaveConfigData() + if self.__blankOutConfig then + -- Everything goes: which widgets are on, and whatever each of them had saved. + table.save({ allowUserWidgets = self.allowUserWidgets }, CONFIG_FILENAME, "-- Widget Custom data and order") + + return + end + local filetable = {} for i, w in ipairs(self.widgets) do if w.GetConfigData then @@ -356,7 +370,11 @@ function widgetHandler:SaveConfigData() end self.orderList[w.whInfo.name] = i end - filetable.order = self.orderList + -- Which widgets are on goes back to what the game enables by default; what each of + -- them has saved is kept. + if not self.__blankOutOrder then + filetable.order = self.orderList + end filetable.data = self.configData filetable.allowUserWidgets = self.allowUserWidgets table.save(filetable, CONFIG_FILENAME, "-- Widget Custom data and order, order = 0 disabled widget") @@ -1479,12 +1497,9 @@ function widgetHandler:Shutdown() self.allowUserWidgets = self.__allowUserWidgets end - -- save config - if self.__blankOutConfig then - table.save({ allowUserWidgets = self.allowUserWidgets }, CONFIG_FILENAME, "-- Widget Custom data and order") - else - self:SaveConfigData() - end + -- save config. SaveConfigData knows about the two reset flags, so a widget's own + -- Shutdown calling it below cannot put back what a reset just took out. + self:SaveConfigData() for _, w in ipairs(self.ShutdownList) do w:Shutdown()