From fe695c645daea0fcaf2107f4729f274caeeaf08d Mon Sep 17 00:00:00 2001 From: Floris Date: Sun, 13 Sep 2026 18:38:17 +0200 Subject: [PATCH 1/2] widget selector: added errors button + errors only toggle (#9236) --- language/en/interface.json | 4 + luaui/Include/keybind_dropdown.lua | 8 ++ luaui/Include/keybind_editbox.lua | 7 + luaui/Widgets/gui_gameinfo.lua | 97 ++++++++++++-- luaui/Widgets/widget_selector.lua | 199 +++++++++++++++++++++-------- luaui/barwidgets.lua | 74 ++++++++++- 6 files changed, 320 insertions(+), 69 deletions(-) diff --git a/language/en/interface.json b/language/en/interface.json index 6bb630980a1..1305995c0c6 100644 --- a/language/en/interface.json +++ b/language/en/interface.json @@ -752,6 +752,7 @@ "off": "Off" }, "enabledonly": "Enabled only", + "errorsonly": "Errors only", "byorder": "By load order", "total": "total", "defaulton": "default enabled", @@ -766,6 +767,7 @@ "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.", + "errorsonlydesc": "Show only the widgets that have raised an error this session - the ones tagged error - whether an error stopped them or they are running again.", "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.", "profilerdesc": "Show what each widget costs: processor time as a share of the frame, and memory allocated per second. Measuring it means timing every call-in of every widget, so this is only paid for while it is switched on.", "byloaddesc": "Order the list by what each widget costs, heaviest first. The order stands still while the cursor is over the list, so nothing slides out from under a click, and catches up when the cursor leaves.", @@ -774,6 +776,8 @@ "order": "Load order", "cleardata": "Reset", "showdata": "Show data", + "errorsloading": "while loading", + "errorsmore": "more", "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.", diff --git a/luaui/Include/keybind_dropdown.lua b/luaui/Include/keybind_dropdown.lua index e24da13b28c..22727b3b604 100644 --- a/luaui/Include/keybind_dropdown.lua +++ b/luaui/Include/keybind_dropdown.lua @@ -42,6 +42,10 @@ function Dropdown.new(opts) self.onSelect = opts.onSelect self.selected = opts.selected or 1 self.placeholder = opts.placeholder + -- An outline to draw the text with, for a panel that pins its own. The font is shared with + -- every other widget and keeps whatever outline was set on it last; without one this takes + -- that, as it always has. + self.outline = opts.outline self.open = false self.rect = { 0, 0, 0, 0 } self.optRects = {} @@ -151,6 +155,9 @@ function Dropdown:draw() end font:Begin() + if self.outline then + font:SetOutlineColor(self.outline) + end local current = self.options[self.selected] local label = self.placeholder or (current and optionLabel(current) or "") -- A profile name is free text and can outrun the control, which is fixed width so the @@ -180,6 +187,7 @@ function Dropdown:draw() end end + -- Still the caption's outline: it was set on this same font a moment ago, in this draw. font:Begin() for i, opt in ipairs(self.options) do local r = self.optRects[i] diff --git a/luaui/Include/keybind_editbox.lua b/luaui/Include/keybind_editbox.lua index 77b97cb682c..2e14ee372d8 100644 --- a/luaui/Include/keybind_editbox.lua +++ b/luaui/Include/keybind_editbox.lua @@ -51,6 +51,10 @@ function Editbox.new(opts) self.placeholder = opts.placeholder or "" self.maxChars = opts.maxChars or 127 self.onChange = opts.onChange + -- An outline to draw the text with, for a panel that pins its own. The font is shared with + -- every other widget and keeps whatever outline was set on it last; without one this takes + -- that, as it always has. + self.outline = opts.outline self.rect = { 0, 0, 0, 0 } self.fontSize = 14 self.pad = 6 @@ -377,6 +381,9 @@ function Editbox:draw() end font:Begin() + if self.outline then + font:SetOutlineColor(self.outline) + end font:Print(shown, tx, ty, self.fontSize, "o") font:End() diff --git a/luaui/Widgets/gui_gameinfo.lua b/luaui/Widgets/gui_gameinfo.lua index 705c21e242a..e4bced5d8f3 100644 --- a/luaui/Widgets/gui_gameinfo.lua +++ b/luaui/Widgets/gui_gameinfo.lua @@ -1073,7 +1073,9 @@ end function unitStrip.layout() local s = unitStrip local n = 0 - local listed = selectedCategory == "tweakunits" + -- Its own category, and All while only changes are listed: that view is mostly the tweaks, + -- and a unit block is as hard to find in it as it is in the tweakunits one. + local listed = selectedCategory == "tweakunits" or (selectedCategory == nil and changedOnly) -- The first row of each unit the list shows, per block and overall. Kept whatever the -- category, since a buildoptions picture jumps to the unit it names through these too. local blockRows, anyRow = {}, {} @@ -1097,6 +1099,11 @@ function unitStrip.layout() end end end + -- Under All the list is shared with every other category, so a strip of a unit or three + -- would cost more room than the scrolling it saves. + if selectedCategory == nil and n < 4 then + n = 0 + end s.count = n s.blockRows, s.anyRow = blockRows, anyRow @@ -1714,6 +1721,45 @@ function unitStrip.optionAt(x, y) return (inBlock and inBlock[id]) or (unitStrip.anyRow and unitStrip.anyRow[id]) end +-- Whether x,y is in the picture column down the left of a tweakunits block, and the unit +-- whose picture is under it when there is one. The column belongs to the pictures, not the +-- source, so nothing in it selects. +function unitStrip.gutterAt(x, y) + local r = rowAt(y) + local row = r and rows[scroll + r] + local gutter = row and row.type == "code" and row.gutter or 0 + if gutter <= 0 or x < listX1 or x >= listX1 + metrics.rowPad * 2 + gutter then + return false + end + + local x1 = listX1 + metrics.rowPad + -- The small picture a search puts against the right of the column, on its own row. + if row.needsOwner and x >= x1 + gutter - metrics.tinyIcon and x <= x1 + gutter then + return true, row.ownerUnitDefID + end + if x > x1 + metrics.iconSize then + return true + end + + -- A block's picture hangs from its opening row over the rows under it, so it is found by + -- looking up from the hovered row for that opening, as far as a picture reaches. + local base = scrollOffset() + local reach = mathFloor((metrics.iconTop + metrics.iconSize) / mathMax(1, metrics.codeRowHeight)) + 1 + for i = scroll + r, mathMax(scroll + 1, scroll + r - reach), -1 do + local above = rows[i] + if above.unitDefID then + local top = listTop - (above.off - base) - metrics.iconTop + if y <= top and y >= top - metrics.iconSize then + return true, above.unitDefID + end + + return true + end + end + + return true +end + ---------------------------------------------------------------- -- Drawing ---------------------------------------------------------------- @@ -2333,17 +2379,29 @@ function widget:DrawScreen() Spring.SetMouseCursor("cursornormal") local row = hover.row > 0 and rows[scroll + hover.row] + local inGutter, gutterUnit = false, nil + if row then + inGutter, gutterUnit = unitStrip.gutterAt(mx, my) + end + -- A picture on its own does not say which unit it is to anyone who does not know the + -- art, so every one of them names its unit and says what it is. Not the def name: the + -- source beside it already spells that out. + local unit = (hover.strip > 0 and unitStrip.ids[hover.strip]) + or (hover.option > 0 and row and row.optionUnitDefID) + or gutterUnit -- Source says how to take it somewhere else, since nothing about a row of it looks - -- like something you could drag across. - local tip = row and (row.tooltip or (row.type == "code" and L.copyHint)) - if hover.strip > 0 and WG.tooltip then - -- A picture on its own does not say which unit it is to anyone who does not know - -- the art; the def name under it is the one the source beneath uses. - local def = UnitDefs[unitStrip.ids[hover.strip]] - WG.tooltip.ShowTooltip("gameinfo", colorDim .. def.name, nil, nil, def.translatedHumanName) - elseif hover.option > 0 and row and WG.tooltip then - local def = UnitDefs[row.optionUnitDefID] - WG.tooltip.ShowTooltip("gameinfo", colorDim .. def.name, nil, nil, def.translatedHumanName) + -- like something you could drag across. Not over the picture column, which does not + -- select. + local tip = row and not inGutter and (row.tooltip or (row.type == "code" and L.copyHint)) + if unit and WG.tooltip then + local def = UnitDefs[unit] + WG.tooltip.ShowTooltip( + "gameinfo", + "\255\240\240\240" .. (def.translatedTooltip or ""), + nil, + nil, + def.translatedHumanName + ) elseif tip and WG.tooltip then WG.tooltip.ShowTooltip("gameinfo", tip.text, nil, nil, tip.title) end @@ -2540,6 +2598,10 @@ local function mouseEvent(x, y, button, release) if playSounds then Spring.PlaySoundFile(buttonclick, 0.6, "ui") end + elseif unitStrip.gutterAt(x, y) then + -- The picture column is not source, so a press in it selects nothing and + -- puts down whatever was selected, like a press on any other row. + clearSelection() elseif row and row.type == "code" then selFrom, selTo = scroll + r, scroll + r selecting = true @@ -2707,6 +2769,19 @@ function widget:Shutdown() end end +-- The changed-only filter is kept between games: someone who only wants to see what a lobby +-- adjusted wants that every time they open the panel, not only the first. +function widget:GetConfigData() + return { changedOnly = changedOnly } +end + +-- Runs before Initialize, so the first list built already honours it. +function widget:SetConfigData(data) + if data and data.changedOnly ~= nil then + changedOnly = data.changedOnly == true + end +end + function widget:LanguageChanged() loadLabels() if searchBox then diff --git a/luaui/Widgets/widget_selector.lua b/luaui/Widgets/widget_selector.lua index fd7f95d6b77..618a9778df6 100644 --- a/luaui/Widgets/widget_selector.lua +++ b/luaui/Widgets/widget_selector.lua @@ -151,6 +151,11 @@ local look = { confirmFill = { 0.17, 0.38, 0.21, 1 }, confirmFillHover = { 0.24, 0.52, 0.29, 1 }, scrim = { 0, 0, 0, 0.55 }, + -- The outline every string the panel prints is drawn with, set on every batch rather than + -- inherited. The font objects are shared with every other widget, many of those set an + -- outline and leave it set, and text baked into a display list keeps whatever outline was + -- current when it was baked - so the panel's text went heavier and lighter as it re-baked. + outline = { 0, 0, 0, 0.4 }, } -- FlowUI's Button gradients from a bottom stop to a top one. Left to its defaults it -- fades the fill up to a near-transparent white, which colours only the bottom edge and @@ -184,6 +189,10 @@ local tagColors = { islocal = "\255\130\175\230", isrml = "\255\200\150\235", iserror = "\255\255\120\120", + -- The same, once the widget runs again: it still raised something, it is just not what is + -- wrong with it now. Still red, so the tag reads as an error at a glance; softer, so the + -- widgets actually broken stand out from the ones that recovered. + iserrorSoft = "\255\220\150\150", } local colorDanger = "\255\255\190\190" -- And the other half of that pair: a press that turns something on rather than off. @@ -276,6 +285,8 @@ local setCatScroll -- locals threaded through the layout, the draw, the hover test and the press. local switches = { { key = "enabledOnly" }, + -- Beside it, the other switch that narrows the list rather than ordering it. + { key = "errorsOnly" }, { key = "byOrder" }, { key = "profiler" }, -- Only offered while the column it orders by is showing. `sub` is what keeps it out @@ -304,6 +315,9 @@ 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. +-- `errorsOnly` keeps the widgets that have raised an error this session - the rows tagged +-- error. Remembered like the rest, though the log it goes by starts again with every reload, +-- so it can come back to a shorter list than it left. -- 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. @@ -314,7 +328,7 @@ local selectedCategory -- 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 } +local filters = { enabledOnly = false, errorsOnly = false, byOrder = false, profiler = false, byLoad = false } ---@type table local searchBox ---@type table @@ -359,7 +373,7 @@ local dialogBox = {} ---@type string? local pressedRow local pressedButton = 0 -local pressedClear, pressedData = false, false +local pressedOn local hover = { sb = 0, row = 0, sw = 0, tog = 0, bar = 0, btn = "", dlg = "" } @@ -409,7 +423,7 @@ end -- `dirty` is set whenever this panel asks the handler for something. Those changes do -- not have to be discovered by looking: the panel already knows it asked, and the answer -- lands once the queued operation has run, which is before the next Update. -local sweep = { at = 1, order = 1, slice = 16, dirty = false, now = {}, was = {} } +local sweep = { at = 1, order = 1, slice = 16, dirty = false, now = {}, was = {}, errorCount = 0 } -- `now` and `was` are the other half of the same job: what the baked panel is painted -- from this frame against what it was painted from last frame. Filled in place and never @@ -424,6 +438,15 @@ function sweep.hasConfig(name) return type(d) == "table" and next(d) ~= nil end +-- What barwidgets has kept of a widget's errors this session, if there are any. Keyed by +-- file, since most of the ways loading can fail happen before a widget has a name. +function sweep.errors(data) + local logs = widgetHandler.errorLog + local log = logs and data.basename and logs[data.basename] + + return (log and log.entries[1]) and log or nil +end + -- Which column a widget belongs in, from the prefix on its filename. local function groupOf(data) local base = data.basename or "" @@ -448,6 +471,8 @@ end -- Walks what the handler knows and builds the rows from it. Called when the handler says -- its list changed, which covers a widget being toggled, loaded or removed. local function buildEntries() + -- The handler's error count as these rows see it. contentMoved rebuilds when it moves. + sweep.errorCount = widgetHandler.errorCount or 0 local myName = widget:GetInfo().name entries = {} entryByName = {} @@ -470,6 +495,7 @@ local function buildEntries() -- def exporter is a build tool rather than something to switch on in a game. if name ~= myName and name ~= "Write customparam.__def to files" and not data.hidden then local desc = oneLine(data.desc) + local log = sweep.errors(data) entries[#entries + 1] = { name = name, data = data, @@ -488,10 +514,13 @@ local function buildEntries() -- 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 + -- Everything it has raised this session. Until barwidgets kept these 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, + errors = log, + -- And the error that is why it is not running, while it is not. A widget running + -- again says nothing about whatever stopped it last time. + stoppedBy = log and log.stopped and not data.active and log.stopped.message or nil, -- Lowercased once here rather than per keystroke: a search walks every one of -- these on every letter typed. searchName = string.lower(name), @@ -510,16 +539,16 @@ end -- The column: All, then the game's own prefixes in a fixed order, then Other. A category -- with nothing in it is left out rather than shown empty. -- --- The counts follow the local filter, so each one says what clicking it would show. They --- do not follow the search: that is transient, and a column of numbers flickering on --- every letter typed is noise rather than information. +-- The counts follow the switches that narrow the list, so each one says what clicking it +-- would show. They do not follow the search: that is transient, and a column of numbers +-- flickering on 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.enabledOnly or e.state > 0 then + if (not filters.enabledOnly or e.state > 0) and (not filters.errorsOnly or e.errors) then counts[e.group] = (counts[e.group] or 0) + 1 total = total + 1 if e.data.active then @@ -652,7 +681,7 @@ function sortBy.pick() 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. +-- The rows the list shows: what the column, the search box and the filter switches left. -- A search ranks what it finds, so the closest answer is at the top; with no search the -- authored order stands, since a list that reshuffles as it is read loses the reader. rebuildRows = function() @@ -674,7 +703,9 @@ rebuildRows = function() or (selectedCategory == "changed" and e.changed) or (selectedCategory == "local" and e.isLocal) or e.group == selectedCategory - ) and (not filters.enabledOnly or e.state > 0) + ) + and (not filters.enabledOnly or e.state > 0) + and (not filters.errorsOnly or e.errors) then if query.empty then rows[#rows + 1] = e @@ -1657,6 +1688,8 @@ setLayout = function() 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) + -- And the error tag, on rows that have raised anything this session. + metrics.errorTagW = font and mathFloor(font:GetTextWidth(L.iserror) * metrics.rowFs) or mathFloor(26 * s) if dialog then dialogGeometry() @@ -1707,6 +1740,9 @@ local function fitRow(row) -- A local row ends with its tag, so the description stops short of it rather than -- running underneath. local descW = metrics.dataX1 - descX1 - metrics.rowPad * 2 + if row.errors then + descW = descW - metrics.errorTagW - metrics.rowPad + end if row.isLocal then descW = descW - metrics.localTagW - metrics.rowPad end @@ -1734,6 +1770,7 @@ local function flushText() return end font:Begin() + font:SetOutlineColor(look.outline) for i = 1, #textQueue do local t = textQueue[i] font:Print(t[1], t[2], t[3], t[4], t[5]) @@ -1750,13 +1787,12 @@ end local function drawRow(row, top, bottom, hovered, overSwitch, overClear, overData) fitRow(row) - -- 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) + -- A widget an error stopped 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 broke it. + local fill = (row.stoppedBy 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) + local accent = (row.stoppedBy and look.errorAccent) or (row.state == 1 and look.activeAccent) or (row.state == 0.5 and look.pendingAccent) if fill then @@ -1785,6 +1821,15 @@ local function drawRow(row, top, bottom, hovered, overSwitch, overClear, overDat -- 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 + -- A widget that has raised an error this session says so first, nearest the buttons: red + -- while an error is what stopped it, a softer red once it runs again. What the errors were + -- is in its tooltip - each is nearly always one short line, which a tooltip shows at a + -- glance and a window of its own made a click and a close out of. + if row.errors then + local tint = row.stoppedBy and tagColors.iserror or tagColors.iserrorSoft + queueText(tint .. L.iserror, tagX, ty, metrics.rowFs, "rov") + tagX = tagX - metrics.errorTagW - metrics.rowPad + end if row.isLocal then queueText(tagColors.islocal .. L.islocal, tagX, ty, metrics.rowFs, "rov") tagX = tagX - metrics.localTagW - metrics.rowPad @@ -1793,9 +1838,6 @@ local function drawRow(row, top, bottom, hovered, overSwitch, overClear, overDat 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: -- a column of red buttons down a list of two hundred rows would read as a warning @@ -1842,6 +1884,7 @@ local function drawCostColumns() end look.mono:Begin() + look.mono:SetOutlineColor(look.outline) for i = 1, #rows - scroll do local row = rows[scroll + i] if not row then @@ -1939,6 +1982,7 @@ function dataView.draw() end font:Begin() + font:SetOutlineColor(look.outline) font:Print(colorTitle .. dataView.name, x1 + pad, dataView.titleY, dataView.titleFs, "ov") font:Print( colorText .. L.close, @@ -1954,6 +1998,7 @@ function dataView.draw() -- width by geometry, so a row here is a row on screen. local code = look.mono or font code:Begin() + code:SetOutlineColor(look.outline) for i = 1, dataView.page do local row = dataView.rows[dataView.scroll + i] if not row then @@ -2189,6 +2234,7 @@ local function drawDialog(d) end font:Begin() + font:SetOutlineColor(look.outline) font:Print(colorText .. d.title, cx, by2 - mathFloor(26 * s), tfs, "cov") local lines = text.wrap(font, d.message, bx2 - bx1 - mathFloor(32 * s), sfs) local step = mathFloor(sfs * 1.45) @@ -2515,6 +2561,14 @@ local function contentMoved() return true end + -- Then an error raised anywhere since the rows were built. One that does not stop its + -- widget changes nothing the sweep below looks at, and still gives a row a button. The + -- handler keeps one count for all of them, so noticing is a comparison a frame rather + -- than a look at every row's log - and a row off screen hears of it as soon as one on it. + if (widgetHandler.errorCount or 0) ~= sweep.errorCount then + return true + end + -- Then what is on screen, which is what is being looked at. local page = mathFloor((listTop - listBottom) / metrics.rowHeight) for i = 1, page do @@ -2593,6 +2647,7 @@ local function loadLabels() L.search = tr("search", "Search...") L.mine = tr("category.local", "Your own") L.enabledOnly = tr("enabledonly", "Enabled only") + L.errorsOnly = tr("errorsonly", "Errors only") L.byOrder = tr("byorder", "By load order") L.sets = tr("sets", "Widget sets") L.noSet = tr("noset", "No set") @@ -2653,6 +2708,8 @@ local function loadLabels() L.byLoad = tr("byload", "By cost") L.cleardata = tr("cleardata", "Reset") L.showdata = tr("showdata", "Show data") + L.errorsLoading = tr("errorsloading", "while loading") + L.errorsMore = tr("errorsmore", "more") 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 @@ -2689,6 +2746,10 @@ local function loadLabels() "enabledonlydesc", "Show only the widgets the config says to load - running or not - so what is off stays out of the way." ), + errorsOnly = tr( + "errorsonlydesc", + "Show only the widgets that have raised an error this session - the ones tagged error - whether an error stopped them or they are running again." + ), byOrder = tr( "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." @@ -2779,14 +2840,16 @@ local function bindUi() if not searchBox then searchBox = Editbox.new({ + outline = look.outline, placeholder = L.search, onChange = function() setScroll(0) rebuildRows() end, }) - nameBox = Editbox.new({}) + nameBox = Editbox.new({ outline = look.outline }) setPicker = Dropdown.new({ + outline = look.outline, placeholder = L.noSet, onSelect = function(name) -- Picked, not loaded: Load is its own button, so choosing a set to delete does @@ -3160,11 +3223,13 @@ local function showTooltip(row) -- Everything below builds two strings with a wrap and a substitution in them, and none -- of what they are built from moves while the cursor rests on one row. Held until the - -- row, its state or its place in the order changes. + -- row, its state or its place in the order changes. Keyed on the row itself rather than + -- its name: a new error rebuilds the rows, and a tooltip held by name would go on showing + -- the errors it was built with. -- With the breakdown showing, the reading changes once per sample rather than never, -- so the sample counter joins what the cache is keyed on. local gen = filters.profiler and profiling.gen or 0 - if tipCache.same("row", row.name, row.state, (row.order or 0) + gen * 100000) then + if tipCache.same("row", row, row.state, (row.order or 0) + gen * 100000) then WG.tooltip.ShowTooltip("widgetselector", tipCache.text, nil, nil, tipCache.title) return @@ -3209,10 +3274,37 @@ 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" + -- Straight after the state, because for a widget that has raised errors they are the + -- thing worth reading. Newest first and at most three, each the message before where it + -- happened, with Lua's [string "LuaUI/Widgets/x.lua"] cut down to the file name - the path + -- is on the File line below, and a glance wants the line number. The one that stopped the + -- widget is in the tag's red; any others, or all of them once it runs again, quieter. + if row.errors then + local entries = row.errors.entries + local shown = 0 + for i = #entries, 1, -1 do + if shown == 3 then + tip = tip .. "\255\150\150\150+" .. i .. " " .. L.errorsMore .. "\n" + break + end + shown = shown + 1 + local e = entries[i] + local msg = string.match(tostring(e.message), "^[^\n]*") + msg = string.gsub(msg, '%[string "([^"]*)"%]', function(path) + return string.match(path, "([^/]+)$") or path + end) + local where = e.callin and (e.callin .. "()") or L.errorsLoading + if e.count > 1 then + where = where .. ", x" .. e.count + end + local color = (row.stoppedBy and e == row.errors.stopped) and tagColors.iserror or tagColors.iserrorSoft + tip = tip + .. color + .. string.gsub(font:WrapText(L.iserror .. ": " .. msg, maxWidth), "[\n]", "\n" .. color) + .. "\255\140\140\140 (" + .. where + .. ")\n" + end end if d.desc and d.desc ~= "" then tip = tip @@ -3278,7 +3370,7 @@ local function showTooltip(row) end end - tipCache.keep("row", row.name, row.state, (row.order or 0) + gen * 100000, title, tip) + tipCache.keep("row", row, row.state, (row.order or 0) + gen * 100000, title, tip) WG.tooltip.ShowTooltip("widgetselector", tipCache.text, nil, nil, tipCache.title) end @@ -3658,33 +3750,36 @@ local function mouseEvent(x, y, button, release) overRow = r and rows[scroll + r] or nil end - -- The clear button is part of the row, so the press has to remember which of the two - -- 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 + -- The buttons are part of the row, so the press has to remember which of them was under + -- the cursor: releasing over the row after pressing one would otherwise toggle the + -- widget. One value rather than a flag per button, since only one can be under it. + local onButton + if overRow and overRow.hasConfig and x >= clearX1 and x <= listRight then + onButton = "clear" + elseif overRow and overRow.hasConfig and x >= metrics.dataX1 and x < clearX1 then + onButton = "data" + end if not release then pressedRow = overRow and overRow.name or nil pressedButton = button - pressedClear = onClear - pressedData = onData + pressedOn = onButton 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 (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 - local name = overRow.name - local running = overRow.state == 1 - confirm(L.cleardataTitle, clearDataWarning(name, running), function() - clearConfigData(name) - end, true) + if button == 1 and (onButton or pressedOn) then + -- Both halves of the click have to be on the same button. Pressing one and sliding + -- off before letting go is how a player takes an accidental press back. + if onButton == pressedOn then + if onButton == "data" then + dataView.open(overRow.name) + -- Named rather than left as an else: this is the branch that throws settings + -- away, and a button added later must not fall through into it. + elseif onButton == "clear" then + local name = overRow.name + local running = overRow.state == 1 + confirm(L.cleardataTitle, clearDataWarning(name, running), function() + clearConfigData(name) + end, true) + end click() end elseif button == 1 then @@ -3707,7 +3802,7 @@ local function mouseEvent(x, y, button, release) end end if release then - pressedRow, pressedButton, pressedClear, pressedData = nil, 0, false, false + pressedRow, pressedButton, pressedOn = nil, 0, nil end return true @@ -3746,6 +3841,7 @@ end function widget:GetConfigData() return { enabledOnly = filters.enabledOnly, + errorsOnly = filters.errorsOnly, byOrder = filters.byOrder, profiler = filters.profiler, byLoad = filters.byLoad, @@ -3779,6 +3875,7 @@ function widget:SetConfigData(data) end pickedSet = type(data.pickedSet) == "string" and data.pickedSet or nil filters.enabledOnly = data.enabledOnly == true + filters.errorsOnly = data.errorsOnly == true filters.byOrder = data.byOrder == true -- Restored like the rest of the switches. It is not free - the column costs every -- widget in the game a wrapper round every callin for as long as it is on - but a diff --git a/luaui/barwidgets.lua b/luaui/barwidgets.lua index cfdde382487..56d39a4017c 100644 --- a/luaui/barwidgets.lua +++ b/luaui/barwidgets.lua @@ -70,8 +70,10 @@ widgetHandler = { widgets = {}, configData = {}, - -- Why each widget that failed to load did, keyed by its file. See loadFailed. - loadErrors = {}, + -- Every error each widget has raised this session, kept against its file. See RecordError. + errorLog = {}, + -- And how many in all, so a panel can notice a new one with a single comparison. + errorCount = 0, orderList = {}, knownWidgets = {}, @@ -542,6 +544,51 @@ function widgetHandler:ReloadUserWidgetFromGameRaw(name) return w end +-- Every error a widget raises is kept against its file for the rest of the session: by +-- file rather than by name, because most of the ways loading can fail happen before the +-- widget has told anyone its name. The same error from the same place is one entry with +-- a count, moved to the end, rather than an entry per time - a widget re-enabled into the +-- same crash would otherwise push everything else out. At most twenty are kept. +-- +-- `callin` is nil for a failure to load. `stops` says this error is why the widget is not +-- running, and `stopped` holds it until the widget next loads. The handler's `errorCount` +-- counts every error of every widget, repeats included, so a panel can tell a new one has +-- arrived with one comparison instead of reading anybody's log. +function widgetHandler:RecordError(basename, callin, message, stops) + local log = self.errorLog[basename] + if not log then + log = { entries = {} } + self.errorLog[basename] = log + end + + local entries = log.entries + local entry + for i = 1, #entries do + if entries[i].callin == callin and entries[i].message == message then + entry = table.remove(entries, i) + break + end + end + local frame = Spring.GetGameFrame() + if entry then + entry.count = entry.count + 1 + entry.lastFrame = frame + else + entry = { callin = callin, message = message, count = 1, frame = frame, lastFrame = frame } + end + entries[#entries + 1] = entry + if #entries > 20 then + table.remove(entries, 1) + end + + self.errorCount = self.errorCount + 1 + if stops then + log.stopped = entry + end + + return entry +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 @@ -550,7 +597,7 @@ end -- 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 + widgetHandler:RecordError(basename, nil, reason, true) return nil end @@ -706,8 +753,12 @@ 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 + -- It loaded, so whatever stopped it last time no longer is. What it raised stays in the + -- log: a widget that crashed and came back is still worth being able to look into. + local log = self.errorLog[basename] + if log then + log.stopped = nil + end return widget end @@ -909,6 +960,10 @@ end local function widgetFailure(w, funcName, errorMsg) local name = w.whInfo.name + -- Kept against the widget as well as said, and before anything below can reload it: the + -- log is how the widget selector shows what went wrong once the line has scrolled out of + -- the console. Shutdown is the one callin whose failure does not take the widget down. + widgetHandler:RecordError(w.whInfo.basename, funcName, tostring(errorMsg), funcName ~= "Shutdown") local errorBase = "Error" if funcName ~= "Shutdown" then widgetHandler:RemoveWidget(w) @@ -1128,7 +1183,7 @@ function widgetHandler:InsertWidgetRaw(widget) self.knownWidgets[name].active = false end Spring.Echo("Missing capabilities: " .. name .. ". Disabling.") - self.loadErrors[widget.whInfo.basename] = "missing capabilities" + self:RecordError(widget.whInfo.basename, nil, "missing capabilities", true) return end -- Gracefully ignore/reload good control widgets advertising themselves as such, if user 'unit control' widgets disabled. @@ -1136,7 +1191,12 @@ 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" + self:RecordError( + widget.whInfo.basename, + nil, + "user 'unit control' widgets are disabled for this game", + true + ) end return end From 7bd78449daf49760f7d887deb95402083b9e017e Mon Sep 17 00:00:00 2001 From: Floris Date: Sun, 13 Sep 2026 21:49:38 +0200 Subject: [PATCH 2/2] widget selector: category coloring + added 'uses' / 'used by' info (#9237) --- language/en/interface.json | 10 + luaui/Include/keybind_editbox.lua | 57 +++ luaui/Include/keybind_editor_view.lua | 6 +- luaui/Include/keybind_text.lua | 18 + luaui/Include/widget_dependencies.lua | 240 ++++++++++ luaui/Widgets/gui_gameinfo.lua | 53 ++- luaui/Widgets/widget_selector.lua | 657 +++++++++++++++++++++++--- luaui/barwidgets.lua | 63 ++- 8 files changed, 1031 insertions(+), 73 deletions(-) create mode 100644 luaui/Include/widget_dependencies.lua diff --git a/language/en/interface.json b/language/en/interface.json index 1305995c0c6..642b32548b8 100644 --- a/language/en/interface.json +++ b/language/en/interface.json @@ -776,8 +776,18 @@ "order": "Load order", "cleardata": "Reset", "showdata": "Show data", + "showdatadesc": "Everything this widget has saved, the way it is stored. Click to read all of it.", "errorsloading": "while loading", "errorsmore": "more", + "depsuses": "Uses", + "depsusedby": "Used by", + "depsoff": "off", + "depsrunningone": "running widget", + "depsrunning": "running widgets", + "depsunchecked": "never check it is there", + "depswarn": "%{count} running widgets use what %{name} provides without checking it is there, so switching it off will most likely break them:", + "depswarnone": "A running widget uses what %{name} provides without checking it is there, so switching it off will most likely break it:", + "depscause": "%{key} comes from %{provider}, which is off", "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.", diff --git a/luaui/Include/keybind_editbox.lua b/luaui/Include/keybind_editbox.lua index 2e14ee372d8..00b04f5a670 100644 --- a/luaui/Include/keybind_editbox.lua +++ b/luaui/Include/keybind_editbox.lua @@ -55,6 +55,9 @@ function Editbox.new(opts) -- every other widget and keeps whatever outline was set on it last; without one this takes -- that, as it always has. self.outline = opts.outline + -- A faint button at the right end that empties the field, shown while there is text. Asked + -- for rather than given: a field whose text is not a filter has nothing it should clear. + self.clearable = opts.clearable self.rect = { 0, 0, 0, 0 } self.fontSize = 14 self.pad = 6 @@ -270,12 +273,38 @@ function Editbox:keyPress(key) return true end +-- The clear button: a square the height of the field against its right end, inset like the +-- caret and the selection are. +local function clearRect(self) + local x2, y1, y2 = self.rect[3], self.rect[2], self.rect[4] + local inset = floor((y2 - y1) * 0.18) + + return x2 - (y2 - y1) + inset, y1 + inset, x2 - inset, y2 - inset +end + +local function overClear(self, x, y) + if not self.clearable or self.text == "" then + return false + end + local bx1, by1, bx2, by2 = clearRect(self) + + return x >= bx1 and x <= bx2 and y >= by1 and y <= by2 +end + -- Click to place the caret, or start a drag selection. function Editbox:mousePress(x, y) if x < self.rect[1] or x > self.rect[3] or y < self.rect[2] or y > self.rect[4] then return false end + -- Focus stays, so the next thing typed starts a new search. + if overClear(self, x, y) then + self:focus() + self:setText("") + + return true + end + local _, _, _, shift = Spring.GetModKeyState() local idx = self:indexFromX(x) @@ -330,6 +359,30 @@ end -- Held rather than built per draw: a colour table a frame is an allocation a frame. local fieldFill = { 0, 0, 0, 0.35 } +local clearFill = { 1, 1, 1, 0.04 } + +-- A thin cross, drawn as geometry rather than a glyph so it does not depend on the font +-- carrying one. The second bar is two halves either side of the first, so the middle is not +-- painted twice and does not show as a brighter dot. +local function drawClear(self, hot, cs) + local bx1, by1, bx2, by2 = clearRect(self) + WG.FlowUI.Draw.RectRound(bx1, by1, bx2, by2, cs, 1, 1, 1, 1, clearFill) + if hot then + WG.FlowUI.Draw.SelectHighlight(bx1, by1, bx2, by2, cs, hoverOpacity, white) + end + + local arm = math.max(2, floor((bx2 - bx1) * 0.24)) + local half = math.max(1, floor((bx2 - bx1) * 0.035 + 0.5)) + gl.Color(1, 1, 1, hot and 0.75 or 0.32) + gl.PushMatrix() + gl.Translate(floor((bx1 + bx2) * 0.5), floor((by1 + by2) * 0.5), 0) + gl.Rotate(45, 0, 0, 1) + gl.Rect(-arm, -half, arm, half) + gl.Rect(-half, half, half, arm) + gl.Rect(-half, -arm, half, -half) + gl.PopMatrix() + gl.Color(1, 1, 1, 1) +end function Editbox:draw() update(self) @@ -387,6 +440,10 @@ function Editbox:draw() font:Print(shown, tx, ty, self.fontSize, "o") font:End() + if self.clearable and self.text ~= "" then + drawClear(self, overClear(self, mx, my), cs) + end + if self.focused then -- Sharp bar rather than a rounded one, sized and placed off the font like chat's: -- a fixed span around the text's middle, so it does not stretch with the field. diff --git a/luaui/Include/keybind_editor_view.lua b/luaui/Include/keybind_editor_view.lua index 40c7859a34b..5c0be65cb83 100644 --- a/luaui/Include/keybind_editor_view.lua +++ b/luaui/Include/keybind_editor_view.lua @@ -1192,7 +1192,11 @@ local function ensureControls() return end - searchBox = Editbox.new({ placeholder = BAR.I18N("ui.keybinds.editor.search"), onChange = rebuildRows }) + searchBox = Editbox.new({ + placeholder = BAR.I18N("ui.keybinds.editor.search"), + clearable = true, + onChange = rebuildRows, + }) presetDropdown = Dropdown.new({ options = presetOptions, onSelect = switchToPreset }) nameBox = Editbox.new({ maxChars = 40 }) end diff --git a/luaui/Include/keybind_text.lua b/luaui/Include/keybind_text.lua index 363092cd558..2a1a7537a09 100644 --- a/luaui/Include/keybind_text.lua +++ b/luaui/Include/keybind_text.lua @@ -95,4 +95,22 @@ function M.wrap(font, text, maxWidth, size) return lines end +-- Starts each line of wrapped text in the colour the line before it ended in. A tooltip prints its +-- text a line at a time, each line from the tooltip's own colour, so a colour set partway along one +-- line would otherwise stop where the line does - and wrapping ends lines wherever it has to. +function M.carryColors(str) + local out, current = {}, nil + for line in (str .. "\n"):gmatch("([^\n]*)\n") do + if current and line ~= "" and line:byte(1) ~= 255 then + line = current .. line + end + for code in line:gmatch("\255...") do + current = code + end + out[#out + 1] = line + end + + return table.concat(out, "\n") +end + return M diff --git a/luaui/Include/widget_dependencies.lua b/luaui/Include/widget_dependencies.lua new file mode 100644 index 00000000000..c6f46c968dd --- /dev/null +++ b/luaui/Include/widget_dependencies.lua @@ -0,0 +1,240 @@ +-- What a widget shares with others through WG, read out of its source. +-- +-- Widgets hand each other functionality through tables on WG - gui_fonthandler sets WG.fonts, and +-- dozens of widgets reach for it - and nothing declares any of that. So it is read out of the +-- source: the keys a widget assigns (`provides`), the keys it reaches for (`uses`), and whether it +-- ever checks a key is there before using it (`guards`). A use that is never checked breaks when +-- whatever provides it is switched off; a checked one only loses what it offered. +-- +-- A static reading, so a good guess rather than a fact. A key looked up through a variable is +-- invisible, and "checks it" means the file tests it somewhere, not that every use is covered. The +-- LuaUI files a widget VFS.Includes are read as well, since a fair part of what widgets share is +-- reached through those. +-- +-- Pure Lua with no engine calls: `load(path)` hands back an included file's source, or nil, so the +-- same code runs in the game and in the offline tests. It reads every widget there is, so it finds +-- things with plain searches and reads patterns only where those land. A pattern search over the +-- seven megabytes of widget source, or a copy of it with the comments taken out, is what made the +-- first version take a third of a second. + +local M = {} + +local find, sub, match, byte, lower = string.find, string.sub, string.match, string.byte, string.lower + +-- Directory variables the game sets for widgets, for include paths built from them. +---@type table +local KNOWN_DIRS = { LUAUI_DIRNAME = "LuaUI/" } + +local MAX_INCLUDE_DEPTH = 4 + +-- Each included file is read once and its reading kept, since LuaShader.lua and the like are +-- included by dozens of widgets. `false` while a file is being read, so one that includes itself +-- back gets nothing rather than looping. +local included = {} + +function M.clearCache() + included = {} +end + +-- Whether a byte is one a Lua name can be made of. +local function isNameByte(b) + return b ~= nil and (b == 95 or (b >= 48 and b <= 57) or (b >= 65 and b <= 90) or (b >= 97 and b <= 122)) +end + +-- Whether position `s` comes after a `--` on the same line. Comments name keys all the time +-- without using them. Read backwards to the start of the line, a few dozen bytes, rather than +-- copying every source without its comments first. +local function inComment(src, s) + local i = s - 1 + while i > 1 do + local b = byte(src, i) + if b == 10 or b == 13 then + return false + end + if b == 45 and byte(src, i - 1) == 45 then + return true + end + i = i - 1 + end + + return false +end + +-- Calls fn(start, finish, key) for every WG.name, WG["name"] or WG['name'] outside a comment. A +-- match inside a longer name, like myWG.x, is not WG. +local function eachKey(src, fn) + local init = 1 + while true do + local s = find(src, "WG", init, true) + if not s then + return + end + init = s + 2 + if not isNameByte(byte(src, s - 1)) then + local _, e, key = find(src, "^%.([%a_][%w_]*)", s + 2) + if not e then + _, e, key = find(src, "^%[%s*[\"']([%a_][%w_]*)[\"']%s*%]", s + 2) + end + if e then + init = e + 1 + if not inComment(src, s) then + fn(s, e, key) + end + end + end + end +end + +-- Whether the name between `s` and `e` is being tested rather than used: followed by then, and, +-- or or a comparison; behind a not, or handed to type(); or the last operand inside a condition, +-- as in `not (a or WG.x)`. `if WG.x.y then` is not a test of WG.x - it fails without it - and +-- neither is passing it along to something else. +local function isCheck(src, s, e) + local after = sub(src, e + 1, e + 16) + if + find(after, "^%s*then%f[^%w_]") + or find(after, "^%s*and%f[^%w_]") + or find(after, "^%s*or%f[^%w_]") + or find(after, "^%s*[~=]=") + then + return true + end + local before = sub(src, math.max(1, s - 16), s - 1) + if find(before, "%f[%w_]not%s*%(?%s*$") or find(before, "%f[%w_]type%s*%(%s*$") then + return true + end + + return find(after, "^%s*%)") ~= nil + and (find(before, "%f[%w_]or%s*$") ~= nil or find(before, "%f[%w_]and%s*$") ~= nil) +end + +-- The variable a key is put in on a line of its own, like `local grid = WG.gridmenu`, or nil. +local function assignedTo(src, s, e) + if not find(sub(src, e + 1, e + 8), "^%s*;?[ \t]*[\r\n]") then + return nil + end + + return match(sub(src, math.max(1, s - 48), s - 1), "([%a_][%w_]*)%s*=%s*$") +end + +-- Whether a variable a key was put in is tested somewhere, so `if not grid then return end` checks +-- WG.gridmenu through the variable. +local function variableChecked(src, name) + local init, length = 1, #name + while true do + local s = find(src, name, init, true) + if not s then + return false + end + local e = s + length - 1 + if + not isNameByte(byte(src, s - 1)) + and not isNameByte(byte(src, e + 1)) + and isCheck(src, s, e) + and not inComment(src, s) + then + return true + end + init = e + 1 + end +end + +-- The LuaUI files a source includes, where the path can be read without running anything: a +-- literal, or a literal added to a directory variable the file sets itself or the game provides. +-- Only LuaUI's own: gamedata and config tables are large and cannot reach WG. +local function includePaths(src) + local paths = {} + local init = 1 + while true do + local s, e = find(src, "VFS.Include", init, true) + if not s then + return paths + end + init = e + 1 + if not inComment(src, s) then + local _, _, path = find(src, "^%s*[%(,]%s*[\"']([^\"'\r\n]+)[\"']", e + 1) + if not path then + local _, _, name, rest = find(src, "^%s*[%(,]%s*([%a_][%w_]*)%s*%.%.%s*[\"']([^\"'\r\n]+)[\"']", e + 1) + if name then + local base = KNOWN_DIRS[name] + or match(src, "%f[%w_]" .. name .. '%s*=%s*"([^"\r\n]*)"') + or match(src, "%f[%w_]" .. name .. "%s*=%s*'([^'\r\n]*)'") + path = base and base .. rest + end + end + local key = path and lower(path) + if key and find(key, "^luaui/") and find(key, "%.lua$") then + paths[#paths + 1] = path + end + end + end +end + +local function merge(into, from) + for key in pairs(from) do + into[key] = true + end +end + +local function read(text, load, depth) + local provides, uses, guards = {}, {}, {} + local follow = load and depth < MAX_INCLUDE_DEPTH and find(text, "VFS.Include", 1, true) ~= nil + if not (follow or find(text, "WG", 1, true)) then + return { provides = provides, uses = uses, guards = guards } + end + + local variables = {} + eachKey(text, function(s, e, key) + if find(sub(text, e + 1, e + 16), "^%s*=[^=]") then + provides[key] = true + return + end + uses[key] = true + if isCheck(text, s, e) then + guards[key] = true + else + local name = assignedTo(text, s, e) + if name then + variables[#variables + 1] = { name, key } + end + end + end) + for _, v in ipairs(variables) do + if not guards[v[2]] and variableChecked(text, v[1]) then + guards[v[2]] = true + end + end + + if follow then + for _, path in ipairs(includePaths(text)) do + local key = lower(path) + local result = included[key] + if result == nil then + included[key] = false + local ok, source = pcall(load, path) + result = ok and type(source) == "string" and read(source, load, depth + 1) or false + included[key] = result + end + if result then + merge(provides, result.provides) + merge(uses, result.uses) + merge(guards, result.guards) + end + end + end + + return { provides = provides, uses = uses, guards = guards } +end + +-- Reads a widget's source, and the LuaUI sources it includes, into three sets of WG keys. +function M.scan(src, load) + local result = read(src, load, 0) + -- What a widget provides is not something it depends on, even where it reads it back. + for key in pairs(result.provides) do + result.uses[key] = nil + end + + return result +end + +return M diff --git a/luaui/Widgets/gui_gameinfo.lua b/luaui/Widgets/gui_gameinfo.lua index e4bced5d8f3..51594ee47c9 100644 --- a/luaui/Widgets/gui_gameinfo.lua +++ b/luaui/Widgets/gui_gameinfo.lua @@ -1321,6 +1321,28 @@ rebuildRows = function() end end + -- How many rows of its own block each opening row has under its picture, itself included. + -- A block shows at least three unfiltered, but a search can leave it one or two, and a + -- picture three rows tall would then run down over the next unit's. Under a search none + -- goes past two: the results are a list of matches, not the blocks, and pictures at full + -- size down it read as the blocks again. + local searchText = searchBox and searchBox:getText() or "" + local maxRows = string.find(searchText, "%S") and 2 or 3 + for i = 1, #rows do + local row = rows[i] + if row.unitDefID then + local k = 1 + while k < maxRows do + local below = rows[i + k] + if not (below and below.srcBlock == row.srcBlock and below.ownerUnitDefID == row.ownerUnitDefID) then + break + end + k = k + 1 + end + row.pictureRows = k + end + end + unitStrip.layout() clampScroll() end @@ -1721,6 +1743,24 @@ function unitStrip.optionAt(x, y) return (inBlock and inBlock[id]) or (unitStrip.anyRow and unitStrip.anyRow[id]) end +-- Where the picture of a block's opening row goes, given that row's edges: left, top and +-- size. Full size over three rows of its block, two rows tall over two, and the small +-- single-row picture over one - always against the right of the column, where the full one +-- ends, so they line up whatever size each came out at. +function unitStrip.pictureFor(row, top, bottom) + local right = listX1 + metrics.rowPad + (row.gutter or metrics.iconSize) + local k = row.pictureRows or 3 + if k >= 3 then + return right - metrics.iconSize, top - metrics.iconTop, metrics.iconSize + elseif k == 2 then + local size = metrics.codeRowHeight * 2 - metrics.iconTop - metrics.iconBottom + return right - size, top - metrics.iconTop, size + end + + local size = metrics.tinyIcon + return right - size, mathFloor(mathFloor((top + bottom) * 0.5) + size * 0.5), size +end + -- Whether x,y is in the picture column down the left of a tweakunits block, and the unit -- whose picture is under it when there is one. The column belongs to the pictures, not the -- source, so nothing in it selects. @@ -1737,10 +1777,6 @@ function unitStrip.gutterAt(x, y) if row.needsOwner and x >= x1 + gutter - metrics.tinyIcon and x <= x1 + gutter then return true, row.ownerUnitDefID end - if x > x1 + metrics.iconSize then - return true - end - -- A block's picture hangs from its opening row over the rows under it, so it is found by -- looking up from the hovered row for that opening, as far as a picture reaches. local base = scrollOffset() @@ -1748,8 +1784,9 @@ function unitStrip.gutterAt(x, y) for i = scroll + r, mathMax(scroll + 1, scroll + r - reach), -1 do local above = rows[i] if above.unitDefID then - local top = listTop - (above.off - base) - metrics.iconTop - if y <= top and y >= top - metrics.iconSize then + local top = listTop - (above.off - base) + local px, py, size = unitStrip.pictureFor(above, top, top - rowHeightOf(above)) + if x >= px and x <= px + size and y <= py and y >= py - size then return true, above.unitDefID end @@ -1924,7 +1961,8 @@ local function drawRow(row, top, bottom, hovered, selected) ) end if row.unitDefID then - queueIcon(row.unitDefID, listX1 + metrics.rowPad, top - metrics.iconTop, metrics.iconSize) + local px, py, size = unitStrip.pictureFor(row, top, bottom) + queueIcon(row.unitDefID, px, py, size) elseif row.needsOwner then -- Against the right of the gutter, so it reads as belonging to the name it is in -- front of rather than floating out at the panel edge. @@ -2299,6 +2337,7 @@ function widget:ViewResize() if not searchBox then searchBox = Editbox.new({ placeholder = L.search, + clearable = true, onChange = function() setScroll(0) rebuildRows() diff --git a/luaui/Widgets/widget_selector.lua b/luaui/Widgets/widget_selector.lua index 618a9778df6..5eddaf7ac76 100644 --- a/luaui/Widgets/widget_selector.lua +++ b/luaui/Widgets/widget_selector.lua @@ -172,6 +172,48 @@ look.gradients = setmetatable({}, { return pair end, }) +-- The colour each prefix group is marked with: a small square ahead of its label in the +-- column, and the same square ahead of the name on every row, so a list showing more than +-- one group says which group each widget is in. The hues are spread round the wheel at a +-- like lightness, and kept off the green, amber and red the row states are marked in. +look.groups = { + interface = { 0.38, 0.62, 0.96, 1 }, + commands = { 0.96, 0.6, 0.24, 1 }, + units = { 0.3, 0.8, 0.68, 1 }, + camera = { 0.7, 0.52, 0.96, 1 }, + graphics = { 0.95, 0.5, 0.74, 1 }, + sound = { 0.92, 0.84, 0.36, 1 }, + map = { 0.62, 0.78, 0.3, 1 }, + minimap = { 0.36, 0.82, 0.94, 1 }, + api = { 0.74, 0.58, 0.44, 1 }, + debug = { 0.9, 0.4, 0.42, 1 }, + other = { 0.6, 0.6, 0.62, 1 }, +} +-- The same colours faded, for a row that is off and a category the search left empty: both +-- are drawn quieter than the rest, and a square at full strength would undo that. Derived +-- once per colour and kept, like the gradients. +look.faded = setmetatable({}, { + __index = function(self, color) + local faded = { color[1], color[2], color[3], 0.55 } + self[color] = faded + + return faded + end, +}) +-- A category the search found nothing in. It stays in the column, so the column does not +-- jump about under the typing, but it reads quieter than the ones holding matches. +look.emptyText = "\255\95\95\95" +-- The row tooltip's colours. Every line there is a label and what it labels, and one grey for both +-- made the lines a block to read through: the labels step back and the values come forward. The +-- names of other widgets get a colour of their own, being what a reader follows from one tooltip to +-- the next, and widgets that never check for what they use are in the warning colour, since those +-- are the ones that would break. +look.tip = { + label = "\255\140\140\140", + value = "\255\225\225\225", + name = "\255\175\205\240", + warn = "\255\235\195\125", +} local colorTitle = "\255\235\235\235" local colorName = "\255\145\143\140" local colorNameOn = "\255\248\248\248" @@ -438,6 +480,268 @@ function sweep.hasConfig(name) return type(d) == "table" and next(d) ~= nil end +-- What the widgets share with each other through WG, as barwidgets read it out of their sources +-- when they loaded: which widget provides a key, which widgets use it, and whether they check it +-- is there first. A table rather than a local per function, for the reason `sweep` is one. The +-- reading is a static one, so everything said with it says "uses" rather than "requires". +local deps = {} + +-- Rebuilt with the entries: key -> the names of the widgets that assign it, sorted, so a key two +-- widgets both provide reads the same way every time. +function deps.rebuild() + local providers = {} + for name, data in pairs(widgetHandler.knownWidgets) do + if data.deps then + for key in pairs(data.deps.provides) do + local list = providers[key] + if not list then + list = {} + providers[key] = list + end + list[#list + 1] = name + end + end + end + for _, list in pairs(providers) do + table.sort(list) + end + deps.providers = providers +end + +function deps.keys(set) + local out = {} + for key in pairs(set) do + out[#out + 1] = key + end + table.sort(out) + + return out +end + +-- The running widget providing a key, if one is: nil when nothing known provides it at all, false +-- when something does and none of them is running. +function deps.provider(key) + local list = deps.providers and deps.providers[key] + if not list then + return nil + end + local known = widgetHandler.knownWidgets + for i = 1, #list do + if known[list[i]] and known[list[i]].active then + return list[i] + end + end + + return false +end + +-- The running widgets that use a key `name` provides, and those of them that never check it is +-- there. With `sole`, only keys no other running widget provides as well: what switching `name` +-- off would actually take away from them. +function deps.users(name, sole) + local known = widgetHandler.knownWidgets + local own = known[name] and known[name].deps + local users, unchecked = {}, {} + if not (own and deps.providers) then + return users, unchecked + end + local keys = {} + for key in pairs(own.provides) do + local alone = true + if sole then + for _, other in ipairs(deps.providers[key] or {}) do + if other ~= name and known[other] and known[other].active then + alone = false + end + end + end + if alone then + keys[#keys + 1] = key + end + end + if #keys == 0 then + return users, unchecked + end + for other, data in pairs(known) do + if other ~= name and data.active and data.deps then + local uses, blind = false, false + for i = 1, #keys do + if data.deps.uses[keys[i]] then + uses = true + blind = blind or not data.deps.guards[keys[i]] + end + end + if uses then + users[#users + 1] = other + if blind then + unchecked[#unchecked + 1] = other + end + end + end + end + table.sort(users) + table.sort(unchecked) + + return users, unchecked +end + +-- A list of names cut to `limit`, with a count of the rest. +function deps.names(list, limit, sep) + local shown = {} + for i = 1, math.min(#list, limit) do + shown[i] = list[i] + end + + -- Given a colour for the separators, the entries carry colours of their own. + sep = sep or "" + + return table.concat(shown, sep .. ", ") .. (#list > limit and (sep .. " +" .. (#list - limit)) or "") +end + +-- The dependency lines of a row's tooltip, each ending in a newline, or "". What the widget uses, +-- by the widget providing it, with anything switched off marked - red where the widget never +-- checks for it, quiet where it does. And, for a widget others use, how many running ones do. +function deps.tooltip(row, maxWidth) + local own = row.data.deps + if not (own and deps.providers) then + return "" + end + local tip = look.tip + local out = "" + + local names, state = {}, {} + for _, key in ipairs(deps.keys(own.uses)) do + local running = deps.provider(key) + if running ~= nil then + local shown = running or deps.providers[key][1] + if shown ~= row.name then + if not state[shown] then + names[#names + 1] = shown + state[shown] = "on" + end + if running == false then + state[shown] = (state[shown] == "blind" or not own.guards[key]) and "blind" or "off" + end + end + end + end + if #names > 0 then + -- What is off first: that is what the line is there to say. + local rank = { blind = 1, off = 2, on = 3 } + table.sort(names, function(a, b) + if rank[state[a]] ~= rank[state[b]] then + return rank[state[a]] < rank[state[b]] + end + + return a < b + end) + local parts = {} + for i, name in ipairs(names) do + if state[name] == "blind" then + parts[i] = tagColors.iserror .. name .. " (" .. L.depsOff .. ")" + elseif state[name] == "off" then + parts[i] = "\255\130\130\130" .. name .. " (" .. L.depsOff .. ")" + else + parts[i] = tip.name .. name + end + end + local line = tip.label .. L.depsUses .. ": " .. deps.names(parts, 8, tip.label) + out = out .. text.carryColors(font:WrapText(line, maxWidth)) .. "\n" + end + + local users, unchecked = deps.users(row.name, false) + if #users > 0 then + -- The ones that never check first, since those are the ones that would break. + local blind = {} + for _, name in ipairs(unchecked) do + blind[name] = true + end + table.sort(users, function(a, b) + if (blind[a] or false) ~= (blind[b] or false) then + return blind[a] == true + end + + return a < b + end) + local line = tip.label + .. L.depsUsedBy + .. ": " + .. tip.value + .. #users + .. tip.label + .. " " + .. (#users == 1 and L.depsRunningOne or L.depsRunning) + if #unchecked > 0 then + line = line .. " (" .. tip.warn .. #unchecked .. " " .. L.depsUnchecked .. tip.label .. ")" + end + local parts = {} + for i, name in ipairs(users) do + parts[i] = (blind[name] and tip.warn or tip.name) .. name + end + line = line .. " - " .. deps.names(parts, 3, tip.label) + out = out .. text.carryColors(font:WrapText(line, maxWidth)) .. "\n" + end + + return out +end + +-- For a widget that has raised errors: the things it uses that are switched off, where that is a +-- likely reason - it never checks the key is there, or one of its errors names the key. +function deps.causes(row) + local own = row.data.deps + if not (own and row.errors and deps.providers) then + return "" + end + local out = "" + for _, key in ipairs(deps.keys(own.uses)) do + if deps.provider(key) == false then + local named = false + for _, e in ipairs(row.errors.entries) do + local message = tostring(e.message) + named = named + or string.find(message, "'" .. key .. "'", 1, true) ~= nil + or string.find(message, "WG." .. key, 1, true) ~= nil + end + if named or not own.guards[key] then + local what, provider = "WG." .. key, deps.providers[key][1] + -- Looked up with both values in hand: i18n fills placeholders at lookup. The gsubs + -- cover the fallback, which comes back untouched. + local text = BAR.I18N( + "ui.widgetselector.depscause", + { key = what, provider = provider, default = L.depsCauseFallback } + ) + text = string.gsub(text, "%%{key}", function() + return what + end) + text = string.gsub(text, "%%{provider}", function() + return provider + end) + out = out .. tagColors.iserrorSoft .. text .. "\n" + end + end + end + + return out +end + +-- What the switch-off warning says: how many running widgets use what `name` provides without +-- checking, and which. +function deps.warning(name, unchecked) + local count = #unchecked + local text = BAR.I18N( + "ui.widgetselector." .. (count == 1 and "depswarnone" or "depswarn"), + { name = name, count = count, default = count == 1 and L.depsWarnOneFallback or L.depsWarnFallback } + ) + text = string.gsub(text, "%%{name}", function() + return name + end) + text = string.gsub(text, "%%{count}", function() + return tostring(count) + end) + + return text .. " " .. deps.names(unchecked, 6) +end + -- What barwidgets has kept of a widget's errors this session, if there are any. Keyed by -- file, since most of the ways loading can fail happen before a widget has a name. function sweep.errors(data) @@ -473,6 +777,8 @@ end local function buildEntries() -- The handler's error count as these rows see it. contentMoved rebuilds when it moves. sweep.errorCount = widgetHandler.errorCount or 0 + -- Who provides what, for the tooltip lines and the switch-off warning. + deps.rebuild() local myName = widget:GetInfo().name entries = {} entryByName = {} @@ -692,23 +998,42 @@ rebuildRows = function() -- Filled once and rewritten per widget rather than allocated for each of them. local primary, secondary = { "" }, { "", "", "" } local scored = not query.empty and {} or nil + -- While something is typed, the column counts what each category would show with it, so its + -- numbers say where the matches are rather than how much there is in all. That takes scoring + -- every widget the switches leave, not only the selected category's. The categories stay + -- put, empty or not, so the column does not jump about under the typing. + local found + if scored then + found = {} + for _, c in ipairs(categories) do + c.found, c.foundOn = 0, 0 + if c.key then + found[c.key] = c + end + end + end + local function add(c, on) + if c then + c.found = c.found + 1 + if on then + c.foundOn = c.foundOn + 1 + end + end + end for i = 1, #entries do local e = entries[i] - if + if (not filters.enabledOnly or e.state > 0) and (not filters.errorsOnly or e.errors) then -- `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 + local inView = 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) - and (not filters.errorsOnly or e.errors) - then - if query.empty then - rows[#rows + 1] = e + if not scored then + if inView then + rows[#rows + 1] = e + end else primary[1] = e.searchName secondary[1], secondary[2], secondary[3] = e.searchDesc, e.searchFile, e.searchAuthor @@ -716,12 +1041,35 @@ rebuildRows = function() -- is a guess, and a list of guesses is worse than a short list. local score = Search.score(query, primary, secondary) if score > 0 then - scored[#scored + 1] = { e = e, score = score } + if inView then + scored[#scored + 1] = { e = e, score = score } + end + local on = e.data.active + add(categories[1], on) + add(found[e.group], on) + add(e.changed and found.changed, on) + add(e.isLocal and found["local"], on) end end end end + -- What the column prints for each category: what is on out of what there is - the count of + -- enabled widgets is the thing worth knowing at a glance, the total is what says how much + -- there is to look through - or, while something is typed, the same of what it finds. Only + -- redone where the numbers moved. + for _, c in ipairs(categories) do + local n, on = c.count, c.active + if found then + n, on = c.found, c.foundOn + end + if c.shown ~= n or c.shownOn ~= on then + c.shown, c.shownOn = n, on + c.empty = n == 0 + c.countText = (c.empty and look.emptyText or colorDim) .. on .. "/" .. n + end + end + if scored then table.sort(scored, function(a, b) if a.score ~= b.score then @@ -1058,7 +1406,9 @@ end -- 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 } +-- `previewLines` and `previewChars` are how much of it the Show data button's tooltip shows. +local dataView = + { scroll = 0, lines = {}, rows = {}, rect = {}, close = {}, indentChars = 4, previewLines = 15, previewChars = 72 } -- 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 @@ -1162,24 +1512,79 @@ function dataView.write(value, depth, prefix) dataView.emit(depth, "},") end +-- What a widget has saved, as the listing's lines - none with nothing saved - written as the chunk +-- it is stored as, so what is on screen is what is on disk. Built in dataView.lines, where emit +-- writes, and handed back with what was there put back: the Show data tooltip reads it while the +-- window is shut, and must leave nothing behind in it. +function dataView.listing(name) + local keep = dataView.lines + dataView.lines = {} + local data = widgetHandler.configData[name] + if type(data) == "table" then + 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 + local lines = dataView.lines + dataView.lines = keep + + return lines +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 + dataView.lines = dataView.listing(name) +end + +-- The Show data button's tooltip: what the widget has saved, as the first lines of the listing the +-- button opens, in its colours. A tooltip is one line after another in a proportional face, so +-- nesting is shown with spaces rather than lined up, and a long line is cut rather than flowed - the +-- window is there for reading all of it. +function dataView.preview(name, maxWidth) + local lines = dataView.listing(name) + local out = text.carryColors("\255\255\255\255" .. font:WrapText(L.showdataDesc, maxWidth)) .. "\n" + for i = 1, math.min(#lines, dataView.previewLines) do + local line = lines[i] + local row, room = "", dataView.previewChars + for j, part in ipairs(line.parts) do + local piece = part.s + if #piece > room then + -- Cut between characters, not inside one: a saved string can hold anything. + local cut = room + while + cut > 0 + and (string.byte(piece, cut + 1) or 0) >= 128 + and (string.byte(piece, cut + 1) or 0) < 192 + do + cut = cut - 1 + end + piece = string.sub(piece, 1, cut) .. ".." + end + -- The colour ahead of the indent, so the line says its colour before anything is on it. + row = row + .. (codeColors[part.k] or codeColors.name) + .. (j == 1 and string.rep(" ", line.depth * 3) or "") + .. piece + room = room - #part.s + if room <= 0 then + break + end + end + out = out .. row .. "\n" 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) .. " = ") + if #lines > dataView.previewLines then + out = out .. look.tip.label .. "+" .. (#lines - dataView.previewLines) .. " " .. L.errorsMore .. "\n" end - dataView.emit(0, "}") + + -- Without the last line break, which a tooltip would draw as an empty line under the rest. + return (string.gsub(out, "\n$", "")) end function dataView.shut() @@ -1569,7 +1974,13 @@ setLayout = function() if filters.byOrder then metrics.orderW = font and mathFloor(font:GetTextWidth("8888") * metrics.rowFs) or mathFloor(34 * s) end - nameX1 = orderX1 + metrics.orderW + (metrics.orderW > 0 and metrics.rowPad * 2 or 0) + -- The square in the widget's group colour, just ahead of its name: the name is what the eye + -- lands on, so the colour beside it is read with it rather than looked for along the row. + metrics.swatch = mathMax(5, mathFloor(8 * s)) + metrics.swatchGap = mathFloor(7 * s) + metrics.swatchCorner = mathMax(1, mathFloor(metrics.swatch * 0.25)) + metrics.swatchX1 = orderX1 + metrics.orderW + (metrics.orderW > 0 and metrics.rowPad * 2 or 0) + nameX1 = metrics.swatchX1 + metrics.swatch + metrics.swatchGap listTop = area.y2 - metrics.headerH - metrics.headerGap local footerTop = area.y1 + metrics.footerH listBottom = footerTop + metrics.footerGap @@ -1703,14 +2114,18 @@ end -- Cuts the category captions to the column once per layout, rather than measuring them on -- every frame the panel is baked. local function fitCategories() - local avail = metrics.sidebarW - metrics.sidePad * 2 - mathFloor(46 * widgetScale) + -- Every entry leaves room for a group's colour ahead of its label, so the labels line up + -- whether an entry has one or not. + local avail = metrics.sidebarW + - metrics.sidePad * 2 + - metrics.swatch + - metrics.swatchGap + - mathFloor(46 * widgetScale) for _, c in ipairs(categories) do local label = text.fit(font, c.label, avail, metrics.catFs) c.textDim = colorDim .. label c.textSel = colorSelected .. label - -- What is on out of what there is. The count of enabled widgets is the thing worth - -- knowing at a glance; the total is what says how much there is to look through. - c.countText = colorDim .. c.active .. "/" .. c.count + c.textEmpty = look.emptyText .. label c.fitGen = layoutGen end end @@ -1813,6 +2228,25 @@ local function drawRow(row, top, bottom, hovered, overSwitch, overClear, overDat -- Right-aligned, so the ranks line up as a column however many digits they run to. queueText(row.fitOrder, orderX1 + metrics.orderW, ty, metrics.rowFs, "rov") end + -- The group the widget is in, in that group's colour from the column: in All, and in the + -- views and searches that cut across the groups, this is what says where a row belongs. + -- Faded on a row that is off, the way its name and description are. + local swatch = look.groups[row.group] + if swatch then + local swatchY = ty - mathFloor(metrics.swatch * 0.5) + RectRound( + metrics.swatchX1, + swatchY, + metrics.swatchX1 + metrics.swatch, + swatchY + metrics.swatch, + metrics.swatchCorner, + 1, + 1, + 1, + 1, + row.state == 0 and look.faded[swatch] or swatch + ) + end queueText(row.fitName, nameX1, ty, metrics.rowFs, "ov") if row.fitDesc then queueText(row.fitDesc, descX1, ty, metrics.rowFs, "ov") @@ -2037,7 +2471,9 @@ end -- The sets block at the foot of the column. The picker draws itself, live, since it can -- open over the list. local function drawSetsBlock() - queueText(colorDim .. L.sets, area.x1 + metrics.sidePad, metrics.setsCaptionY, metrics.catFs, "ov") + -- With how many there are, once there are any: the picker only ever shows the one picked. + local caption = #sets > 0 and (L.sets .. " (" .. #sets .. ")") or L.sets + queueText(colorDim .. caption, area.x1 + metrics.sidePad, metrics.setsCaptionY, metrics.catFs, "ov") for _, b in ipairs(setButtons) do -- Load and Delete are not drawn at all without a set picked: a button that can do @@ -2130,7 +2566,26 @@ local function drawSidebar() ) end local ty = mathFloor((y1 + y2) * 0.5) - queueText(selected and c.textSel or c.textDim, x1 + metrics.sidePad, ty, metrics.catFs, "ov") + -- A prefix group carries its colour ahead of its label, which is the key to the squares on + -- the rows. All, Changed and Your own cut across the groups, so they have none. + local swatch = c.key and look.groups[c.key] + if swatch then + local swatchY = ty - mathFloor(metrics.swatch * 0.5) + RectRound( + x1 + metrics.sidePad, + swatchY, + x1 + metrics.sidePad + metrics.swatch, + swatchY + metrics.swatch, + metrics.swatchCorner, + 1, + 1, + 1, + 1, + (c.empty and not selected) and look.faded[swatch] or swatch + ) + end + local label = (selected and c.textSel) or (c.empty and c.textEmpty) or c.textDim + queueText(label, x1 + metrics.sidePad + metrics.swatch + metrics.swatchGap, ty, metrics.catFs, "ov") queueText(c.countText, x2 - metrics.sidePad, ty, metrics.catFs, "rov") end @@ -2708,8 +3163,23 @@ local function loadLabels() L.byLoad = tr("byload", "By cost") L.cleardata = tr("cleardata", "Reset") L.showdata = tr("showdata", "Show data") + L.showdataDesc = + tr("showdatadesc", "Everything this widget has saved, the way it is stored. Click to read all of it.") L.errorsLoading = tr("errorsloading", "while loading") L.errorsMore = tr("errorsmore", "more") + L.depsUses = tr("depsuses", "Uses") + L.depsUsedBy = tr("depsusedby", "Used by") + L.depsOff = tr("depsoff", "off") + L.depsRunningOne = tr("depsrunningone", "running widget") + L.depsRunning = tr("depsrunning", "running widgets") + L.depsUnchecked = tr("depsunchecked", "never check it is there") + -- Fallbacks only: these carry placeholders, which i18n fills in at lookup, so they are looked + -- up where the values are known. + L.depsWarnFallback = + "%{count} running widgets use what %{name} provides without checking it is there, so switching it off will most likely break them:" + L.depsWarnOneFallback = + "A running widget uses what %{name} provides without checking it is there, so switching it off will most likely break it:" + L.depsCauseFallback = "%{key} comes from %{provider}, which is off" 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 @@ -2842,6 +3312,7 @@ local function bindUi() searchBox = Editbox.new({ outline = look.outline, placeholder = L.search, + clearable = true, onChange = function() setScroll(0) rebuildRows() @@ -2879,6 +3350,18 @@ function widget:ViewResize() if not (uiBound or bindUi()) then return end + -- Asked for again on every resize rather than kept from the first bind. The font handler deletes + -- every font it has handed out when the view changes size, and it sits on a far lower layer, so + -- its ViewResize has already run: the fonts from before are deleted fonts by now. Measuring with + -- one took this widget down - and /widgetselector, F11 and the top bar button with it - until + -- LuaUI was reloaded. FlowUI resizes its corner the same way, before any widget runs. + if WG.fonts and WG.fonts.getFont then + font = WG.fonts.getFont() + look.mono = WG.fonts.getFont(3) + end + if WG.FlowUI and WG.FlowUI.elementCorner then + elementCorner = WG.FlowUI.elementCorner + end setLayout() rebuildRows() @@ -2924,6 +3407,18 @@ local function setShow(state) if not uiBound then widget:ViewResize() end + -- Still nothing to draw with: FlowUI or the font handler is not running, which is what switching + -- one of them off leaves after the next reload. This panel draws with both and is the way to + -- switch them back on, so asking for it brings them back rather than opening a panel that can + -- never appear. Queued, so it shows a few frames on, on the first frame that has them. + if not uiBound then + for _, name in ipairs({ "FlowUI", "Font handler" }) do + local known = widgetHandler.knownWidgets[name] + if known and not known.active then + widgetHandler:EnableWidget(name) + end + end + end refreshContent() else closePanel() @@ -3221,6 +3716,51 @@ local function showTooltip(row) return end + local maxWidth = WG.tooltip.getFontsize() * 90 + + -- The row's two buttons come first, ahead of the row's own held tooltip below. That one is kept for + -- the row alone, and moving along the row onto a button does not change the row - so over the + -- buttons it went on showing the row's details. + -- Over the clear button the tooltip is about the button, not the widget: the row's + -- details are what the rest of the row already answers, and a button that throws + -- settings away should say so before it is pressed rather than only after. Word for + -- word what the confirmation asks, so nothing new turns up at the last step. + if hover.clr == 1 then + if not tipCache.same("clear", row.name, row.state, false) then + local warn = clearDataWarning(row.name, row.state == 1) + tipCache.keep( + "clear", + row.name, + row.state, + false, + colorDanger .. L.cleardataTitle .. "\n", + "\255\255\255\255" .. string.gsub(font:WrapText(warn, maxWidth), "[\n]", "\n\255\255\255\255") + ) + end + WG.tooltip.ShowTooltip("widgetselector", tipCache.text, nil, nil, tipCache.title) + + return + end + -- Over Show data, what the widget has saved: the head of the listing the button opens, so a glance + -- answers what a click would, and a click is only needed for the rest. Held until what is saved + -- changes, which is when the handler stores a new table for the widget. + if hover.dat == 1 then + local data = widgetHandler.configData[row.name] + if not tipCache.same("data", row.name, data, false) then + tipCache.keep( + "data", + row.name, + data, + false, + colorTitle .. L.showdata .. "\n", + dataView.preview(row.name, maxWidth) + ) + end + WG.tooltip.ShowTooltip("widgetselector", tipCache.text, nil, nil, tipCache.title) + + return + end + -- Everything below builds two strings with a wrap and a substitution in them, and none -- of what they are built from moves while the cursor rests on one row. Held until the -- row, its state or its place in the order changes. Keyed on the row itself rather than @@ -3251,28 +3791,6 @@ local function showTooltip(row) local title = stateColor .. row.name .. "\n" - local maxWidth = WG.tooltip.getFontsize() * 90 - - -- Over the clear button the tooltip is about the button, not the widget: the row's - -- details are what the rest of the row already answers, and a button that throws - -- settings away should say so before it is pressed rather than only after. Word for - -- word what the confirmation asks, so nothing new turns up at the last step. - if hover.clr == 1 then - if not tipCache.same("clear", row.name, row.state, false) then - local warn = clearDataWarning(row.name, row.state == 1) - tipCache.keep( - "clear", - row.name, - row.state, - false, - colorDanger .. L.cleardataTitle .. "\n", - "\255\255\255\255" .. string.gsub(font:WrapText(warn, maxWidth), "[\n]", "\n\255\255\255\255") - ) - end - WG.tooltip.ShowTooltip("widgetselector", tipCache.text, nil, nil, tipCache.title) - - return - end local tip = stateColor .. stateWord .. "\n" -- Straight after the state, because for a widget that has raised errors they are the -- thing worth reading. Newest first and at most three, each the message before where it @@ -3305,6 +3823,7 @@ local function showTooltip(row) .. where .. ")\n" end + tip = tip .. deps.causes(row) end if d.desc and d.desc ~= "" then tip = tip @@ -3312,29 +3831,36 @@ local function showTooltip(row) .. string.gsub(font:WrapText(d.desc, maxWidth), "[\n]", "\n\255\255\255\255") .. "\n" end + local label, value = look.tip.label, look.tip.value if d.author and d.author ~= "" then - tip = tip .. "\255\175\175\175" .. L.author .. ": " .. d.author .. "\n" + tip = tip .. label .. L.author .. ": " .. value .. d.author .. "\n" end if row.order then tip = tip - .. "\255\175\175\175" + .. label .. L.order .. ": " + .. value .. row.order + .. label .. " (" .. L.layer .. " " + .. value .. tostring(row.layer) - .. ")" - .. "\n" + .. label + .. ")\n" end tip = tip - .. "\255\175\175\175" + .. label .. L.file .. ": " + .. value .. (d.basename or "") - .. (row.isLocal and " (" .. L.islocal .. ")" or "") - .. "\n\255\130\130\130" + .. (row.isLocal and (label .. " (" .. tagColors.islocal .. L.islocal .. label .. ")") or "") + .. "\n" + .. deps.tooltip(row, maxWidth) + .. "\255\130\130\130" .. L.hint -- With the cost column on, what the widget is spending it on, broken down the way the -- profiler breaks it down: time, allocations, callin. A tooltip is one string in a @@ -3783,8 +4309,23 @@ local function mouseEvent(x, y, button, release) click() end elseif button == 1 then - widgetHandler:ToggleWidget(overRow.name) - sweep.dirty = true + -- Switching off a widget that running widgets use without checking it is there asks + -- first: they will most likely break, and nothing on the row says so. + local name = overRow.name + local unchecked = {} + if overRow.data.active then + local _ + _, unchecked = deps.users(name, true) + end + if #unchecked > 0 then + confirm(name, deps.warning(name, unchecked), function() + widgetHandler:ToggleWidget(name) + sweep.dirty = true + end, true) + else + widgetHandler:ToggleWidget(name) + sweep.dirty = true + end click() elseif button == 2 or button == 3 then diff --git a/luaui/barwidgets.lua b/luaui/barwidgets.lua index 56d39a4017c..216972df8c9 100644 --- a/luaui/barwidgets.lua +++ b/luaui/barwidgets.lua @@ -15,6 +15,15 @@ VFS.Include(LUAUI_DIRNAME .. "system.lua", nil, VFS.ZIP) VFS.Include(LUAUI_DIRNAME .. "callins.lua", nil, VFS.ZIP) VFS.Include(LUAUI_DIRNAME .. "savetable.lua", nil, VFS.ZIP) +-- What each widget shares with others through WG, read out of its source as it loads, so the widget +-- selector can say what depends on what. Optional: a file added to the game since it started is +-- invisible to VFS until the next start, and that is no reason for LuaUI not to load. +local widgetDependencies +do + local ok, module = pcall(VFS.Include, LUAUI_DIRNAME .. "Include/widget_dependencies.lua", nil, VFS.ZIP) + widgetDependencies = ok and module or nil +end + local gl = gl local CONFIG_FILENAME = LUAUI_DIRNAME .. "Config/" .. Game.gameShortName .. ".lua" @@ -707,6 +716,12 @@ function widgetHandler:LoadWidget(filename, fromZip, enableLocalsAccess, reload) -- 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 + -- And what it shares with other widgets through WG. The source is in hand here, and a source + -- the reading trips over must not stop the widget from loading. + if widgetDependencies then + local ok, deps = pcall(widgetDependencies.scan, text, VFS.LoadFile) + knownInfo.deps = ok and deps or nil + end self.knownWidgets[name] = knownInfo self.knownCount = self.knownCount + 1 self.knownChanged = true @@ -1808,19 +1823,38 @@ function widgetHandler:Update() return end +-- The widget selector is how widgets get switched back on from inside the game, so asking for it has to +-- work when it is not running. An error in it removes the widget, and with it the /widgetselector action +-- it registers - and so F11 - which left a LuaUI reload as the only way back. Switches it on again, and +-- it opens itself once loaded, through the flag it reopens with after a reload it asked for. While it +-- runs this does nothing, its own action answering instead; returns whether it did anything. +function widgetHandler:RecoverWidgetSelector() + for name, ki in pairs(self.knownWidgets) do + if ki.basename == "widget_selector.lua" then + if ki.active then + return false + end + if type(self.configData[name]) ~= "table" then + self.configData[name] = {} + end + self.configData[name].reopen = true + self:EnableWidget(name) + return true + end + end + return false +end + function widgetHandler:ConfigureLayout(command) if command == "reconf" then self:SendConfigData() return true elseif command == "selector" then - for _, w in ipairs(self.widgets) do - if w.whInfo.basename == SELECTOR_BASENAME then - return true -- there can only be one - end + -- F11's original binding, which looked for LuaUI/selector.lua. This game ships no such file: its + -- selector is Widgets/widget_selector.lua, which binds F11 to /widgetselector itself once it runs. + if not self:RecoverWidgetSelector() and self.WG.widgetselector then + self.WG.widgetselector.toggle() end - local sw = self:LoadWidget(LUAUI_DIRNAME .. SELECTOR_BASENAME, true) -- load the game's included widget_selector.lua, instead of the default selector.lua - self:InsertWidgetRaw(sw) - self:RaiseWidgetRaw(sw) return true elseif string.find(command, "togglewidget") == 1 then self:ToggleWidgetRaw(string.sub(command, 14)) @@ -1833,6 +1867,11 @@ function widgetHandler:ConfigureLayout(command) return true end + -- Answered by the widget selector's own action while it runs. + if command == "widgetselector" and self:RecoverWidgetSelector() then + return true + end + if self.actionHandler:TextAction(command) then return true end @@ -2316,6 +2355,16 @@ function widgetHandler:KeyPress(key, mods, isRepeat, label, unicode, scanCode, a return true end + -- A key bound to /widgetselector - F11 - with no selector running to answer it. + if actions and not isRepeat then + for _, bound in ipairs(actions) do + if bound.command == "widgetselector" and self:RecoverWidgetSelector() then + tracy.ZoneEnd() + return true + end + end + end + for _, w in ipairs(self.KeyPressList) do if allowedWidget(w) then if w:KeyPress(key, mods, isRepeat, label, unicode, scanCode, actions) then