From e0bead865e8591da2ac41aef1c1922a59282686d Mon Sep 17 00:00:00 2001 From: Floris Date: Sat, 12 Sep 2026 20:04:28 +0200 Subject: [PATCH 1/3] widget selector: overhaul (#9224) --- language/en/interface.json | 46 +- luaui/Include/keybind_editor_view.lua | 95 +- luaui/Widgets/gui_changelog_info.lua | 101 +- luaui/Widgets/gui_gameinfo.lua | 123 +- luaui/Widgets/widget_selector.lua | 3394 ++++++++++++++++--------- 5 files changed, 2532 insertions(+), 1227 deletions(-) diff --git a/language/en/interface.json b/language/en/interface.json index 68af9a1b2f5..e2bc0fbc847 100644 --- a/language/en/interface.json +++ b/language/en/interface.json @@ -716,7 +716,50 @@ "button_factoryresetluaui": "Factory Reset LuaUI", "file": "File", "author": "Author", - "islocal": "local" + "islocal": "local", + "category": { + "all": "All", + "interface": "Interface", + "commands": "Commands", + "units": "Units", + "camera": "Camera", + "graphics": "Graphics", + "sound": "Sound", + "map": "Map", + "minimap": "Minimap", + "api": "API", + "debug": "Debug", + "other": "Other" + }, + "search": "Search...", + "localonly": "Local only", + "factorydefaults": "Factory defaults", + "cancel": "Cancel", + "confirm": "Confirm", + "hint": "Click to toggle. Right-click sends it to the front of its layer, middle-click to the back.", + "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.", + "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.", + "disallowuserwarn": "Stops loading widgets from your own LuaUI folder, leaving only the ones the game ships. LuaUI reloads immediately.", + "allowuserwarn": "Loads widgets from your own LuaUI folder again alongside the ones the game ships. LuaUI reloads immediately.", + "resetwarn": "Puts every widget back to the set the game enables by default. Widgets you added stay on disk. LuaUI reloads immediately.", + "state": { + "on": "Running", + "pending": "Enabled, but not running", + "off": "Off" + }, + "enabledonly": "Enabled only", + "byorder": "By load order", + "order": "Load order", + "layer": "Layer", + "sets": "Widget sets", + "noset": "No set", + "saveset": "Save", + "deleteset": "Delete", + "savesettitle": "Save widget set", + "savesetwarn": "Remembers which widgets are switched on right now under this name. Saving over a name you already have replaces it.", + "deletesettitle": "Delete widget set", + "deletesetwarn": "Forgets this set. The widgets it switched on stay as they are.", + "loadset": "Load" }, "unitstats": { "prog": "Prog", @@ -3688,5 +3731,4 @@ "desc": "A base64 encoded snippet of code that modifies game definitions." } } - } diff --git a/luaui/Include/keybind_editor_view.lua b/luaui/Include/keybind_editor_view.lua index 766dd3614fa..ea55cd25e56 100644 --- a/luaui/Include/keybind_editor_view.lua +++ b/luaui/Include/keybind_editor_view.lua @@ -26,7 +26,6 @@ local view = {} local floor = math.floor local spGetMouseState = Spring.GetMouseState -local spGetModKeyState = Spring.GetModKeyState local spGetTimer = Spring.GetTimer local spDiffTimers = Spring.DiffTimers local isInRect = math.isInRect @@ -115,6 +114,14 @@ local rows = {} -- Bumped by rebuildRows, so the baked panel knows the list behind it changed. local rowsGen = 0 local scroll = 0 + +-- What the cursor is over, in the terms the panel paints hover with. Refilled in place +-- each frame rather than allocated. +-- `grab` is where the scrollbar's thumb was taken hold of, as the distance from the cursor +-- to its top edge, so the thumb follows the cursor instead of jumping its middle to the +-- press. It rides here rather than in a local of its own: this chunk is at Lua's ceiling of +-- 200 locals, which is why the sizes above share `metrics` too. +local hover = { sb = 0, row = 0, zone = "", idx = 0, gk = "", ga = 0, gb = 0, btn = "", bar = 0, grab = 0, cat = 0 } local dragging = false local dirty = false ---@type table? @@ -1296,6 +1303,8 @@ function view.setArea(x1, y1, x2, y2, s) scale = s or 1 rowHeight = floor(24 * scale) metrics.catRowHeight = floor(29 * scale) + metrics.catBarW = math.max(3, floor(6 * scale)) + metrics.catBarW = math.max(3, floor(6 * scale)) -- Whole pixels throughout: a size or a corner landing on a fraction puts glyph and -- rectangle edges between pixels, which the renderer then blends across both. metrics.rowFs = floor(rowHeight * 0.55) @@ -1749,7 +1758,9 @@ local function modPrefix() return "" end - local alt, ctrl, meta, shift = spGetModKeyState() + -- Not localised like its neighbours: this chunk is at Lua's ceiling of 200 locals and + -- a slot is worth more elsewhere. It runs on a key press, not on a frame. + local alt, ctrl, meta, shift = Spring.GetModKeyState() local prefix = "" if alt then prefix = prefix .. "Alt+" @@ -1962,7 +1973,10 @@ local function rowLayout(row) elseif row.type == "link" then lay.text = colorAction .. row.label lay.arrow = look.arrow - lay.arrowX = listX1 + metrics.rowPad * 5 + floor(font:GetTextWidth(row.label) * metrics.rowFs) + metrics.rowPad * 2 + lay.arrowX = listX1 + + metrics.rowPad * 5 + + floor(font:GetTextWidth(row.label) * metrics.rowFs) + + metrics.rowPad * 2 else local labelW = metrics.keyAreaX1 - (listX1 + metrics.rowPad) - metrics.rowPad lay.text = colorAction .. text.fit(font, row.label, labelW, metrics.rowFs) @@ -2062,12 +2076,27 @@ 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. That offset rides in `hover` for the same reason +-- `grab` does - this chunk is at Lua's ceiling of 200 locals. local function categoryRect(i) - local top = sidebarTop() - (i - 1) * metrics.catRowHeight + local top = sidebarTop() - (i - 1 - hover.cat) * metrics.catRowHeight return area.x1, top - metrics.catRowHeight, area.x1 + sidebarW, top end +-- Scrolls the category column by `delta` entries and answers how far it can be scrolled +-- at all, so nought means everything fits. One function rather than the usual three, +-- this chunk being at the local ceiling; passing 0 just clamps. +local function catScrolled(delta) + local page = math.max(1, floor((sidebarTop() - listBottom()) / metrics.catRowHeight)) + local most = math.max(0, #categories - page) + local n = hover.cat + delta + hover.cat = (n < 0 and 0) or (n > most and most) or n + + return most +end + -- The category entry under x,y, or nil. Half-open on the shared edge, like the rows, so -- one point never lands in two entries. local function sidebarIndexAt(x, y) @@ -2076,7 +2105,7 @@ local function sidebarIndexAt(x, y) return nil end - local i = floor((top - y) / metrics.catRowHeight) + 1 + local i = floor((top - y) / metrics.catRowHeight) + 1 + hover.cat if not categories[i] then return nil end @@ -2197,13 +2226,28 @@ local function drawSidebar(hoverIdx) ) queueText(L.titleText, area.x1 + metrics.sidePad, area.y2 - metrics.titleY, metrics.titleFs, "ov") + -- A bar of its own, and a slim one: the column is narrow and this only shows up when + -- there are more categories than the card has room for. + if catScrolled(0) > 0 then + local bx2 = area.x1 + sidebarW - metrics.catInset + Scroller( + bx2 - metrics.catBarW, + listBottom(), + bx2, + sidebarTop(), + #categories * metrics.catRowHeight, + hover.cat * metrics.catRowHeight + ) + end + -- Laid out before the font existed, so the labels are still waiting to be fitted. if categories[1] and not categories[1].textDim then fitCategories() end local lb = listBottom() - for i, c in ipairs(categories) do + for i = hover.cat + 1, #categories do + local c = categories[i] local x1, y1, x2, y2 = categoryRect(i) if y1 >= lb then local selected = selectedCategory == c.key @@ -2211,7 +2255,15 @@ local function drawSidebar(hoverIdx) local sx1, sx2 = x1 + metrics.catInset, x2 - metrics.catInset RectRound(sx1, y1, sx2, y2, metrics.csSmall, 1, 1, 1, 1, look.selectedFill) elseif i == hoverIdx then - Highlight(x1 + metrics.catInset, y1, x2 - metrics.catInset, y2, metrics.csSmall, look.rowHoverOpacity, look.white) + Highlight( + x1 + metrics.catInset, + y1, + x2 - metrics.catInset, + y2, + metrics.csSmall, + look.rowHoverOpacity, + look.white + ) end local ty = floor((y1 + y2) * 0.5) queueText((selected and c.textSel or c.textDim) or c.label, x1 + metrics.sidePad, ty, metrics.catFs, "ov") @@ -2746,14 +2798,6 @@ local function drawButtons(hotId) end end --- What the cursor is over, in the terms the panel paints hover with. Refilled in place --- each frame rather than allocated. --- `grab` is where the scrollbar's thumb was taken hold of, as the distance from the cursor --- to its top edge, so the thumb follows the cursor instead of jumping its middle to the --- press. It rides here rather than in a local of its own: this chunk is at Lua's ceiling of --- 200 locals, which is why the sizes above share `metrics` too. -local hover = { sb = 0, row = 0, zone = "", idx = 0, gk = "", ga = 0, gb = 0, btn = "", bar = 0, grab = 0 } - -- The thumb, where it is now. Nil when the list fits and no bar is drawn. Reached through -- WG rather than a local of its own, this chunk being at the 200-local ceiling; it is only -- asked for on a press or a hover test, so the lookup costs nothing that matters. @@ -2843,6 +2887,8 @@ local function panelSignature(mx, my) .. "|" .. h.bar .. "|" + .. h.cat + .. "|" .. (dragging and 1 or 0) end @@ -2988,8 +3034,12 @@ function view.mouseWheel(up, value) return end - local _, my = spGetMouseState() - if my >= listBottom() and my <= listTop then + local mx, my = spGetMouseState() + -- 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. + if mx <= area.x1 + sidebarW and my > listBottom() and my <= sidebarTop() then + catScrolled(up and -1 or 1) + elseif my >= listBottom() and my <= listTop then scroll = scroll + (up and -3 or 3) clampScroll() end @@ -3230,6 +3280,17 @@ function view.keyPress(key, scanCode) return true end + -- A grid category replaces the list outright, and picking another category in the + -- column is otherwise the only way back out of it. Escape is the other way, and it + -- has to come before the panel closes: leaving a view is what the key is for. + if gridGroup and key == 27 then + selectedCategory = nil + scroll = 0 + rebuildRows() + + return true + end + -- Escape empties the search before it closes the panel: the list being read is the one -- the search made, and the first Escape is asking for that back. With nothing left to -- clear it goes unclaimed, and the widget above closes the panel on it. diff --git a/luaui/Widgets/gui_changelog_info.lua b/luaui/Widgets/gui_changelog_info.lua index a11f66b7c4d..3825a9e27f1 100644 --- a/luaui/Widgets/gui_changelog_info.lua +++ b/luaui/Widgets/gui_changelog_info.lua @@ -147,7 +147,14 @@ local barHover = false -- Month column state: the entry under the cursor and the one lit as current. The -- sidebar list is rebuilt whenever either changes. local hoverIdx, selectedIdx -local sidebarHover, sidebarSelected +local sidebarHover, sidebarSelected, sidebarScroll + +-- How far the month column is scrolled, in whole entries. A changelog gathers versions +-- for as long as the game has been going, so this one overflows as a matter of course. +local catScroll = 0 +-- Declared here because setStartRow keeps the month being read in view and setLayout +-- clamps the column, and both run well before the column measures itself below. +local revealCategory, setCatScroll local function dropLists() if panelList then @@ -200,6 +207,10 @@ local function setStartRow(n, chosen) end end selectedIdx = chosen or versionAt(startRow) + -- The month being read stays in view, however far the text has been scrolled. + if selectedIdx then + revealCategory(selectedIdx) + end end -- Where the text currently sits, in the pixels the scrollbar is drawn against. @@ -350,6 +361,7 @@ local function setLayout() metrics.sidePad = mathFloor(12 * s) metrics.catInset = mathFloor(4 * s) metrics.catRowHeight = mathFloor(29 * s) + metrics.catBarW = mathMax(3, mathFloor(6 * s)) metrics.catFs = mathFloor(metrics.catRowHeight * 0.55 * 0.85) metrics.headerH = mathFloor(34 * s) metrics.headerGap = mathFloor(4 * s) @@ -372,6 +384,9 @@ local function setLayout() barX1 = area.x2 - metrics.edgeInset - metrics.barW listRight = barX1 - metrics.listGap + -- A shorter panel holds fewer months, so the column can be left past its own end. + setCatScroll(catScroll) + -- The markdown sizes scale with the panel; its palette follows the panel's look. ctx = Markdown.defaultContext(s) ctx.fonts = { regular = font, bold = fontBold, mono = fontMono } @@ -383,12 +398,15 @@ end -- The month column starts below where the text does, so the title above it is not -- crowded by the first entry. Everything in the column measures from here. + local function sidebarTop() return listTop - metrics.sidebarDrop 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) * metrics.catRowHeight + local top = sidebarTop() - (i - 1 - catScroll) * metrics.catRowHeight return area.x1, top - metrics.catRowHeight, area.x1 + metrics.sidebarW, top end @@ -401,7 +419,7 @@ local function sidebarIndexAt(x, y) return nil end - local i = mathFloor((top - y) / metrics.catRowHeight) + 1 + local i = mathFloor((top - y) / metrics.catRowHeight) + 1 + catScroll if not versions[i] then return nil end @@ -414,31 +432,87 @@ 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 + +setCatScroll = function(n) + local m = maxCatScroll() + catScroll = (n < 0 and 0) or (n > m and m) or n +end + +-- Keeps the month being read in view. The column is scrolled by the reader as well, so +-- this only moves it when the entry has actually gone off one end. +revealCategory = function(i) + if i <= catScroll then + setCatScroll(i - 1) + elseif i > catScroll + catPageRows() then + setCatScroll(i - catPageRows()) + end +end + -- The month column's entries: the lit current one, the hover, then the labels. The card -- itself is part of the panel list, since it never changes with the cursor. local function drawSidebar() - local n = #versions local shown = 0 - for i = 1, n do + for i = catScroll + 1, #versions do local x1, y1, x2, y2 = categoryRect(i) if y1 < listBottom then break end shown = i if i == selectedIdx then - RectRound(x1 + metrics.catInset, y1, x2 - metrics.catInset, y2, metrics.csSmall, 1, 1, 1, 1, look.selectedFill) + RectRound( + x1 + metrics.catInset, + y1, + x2 - metrics.catInset, + y2, + metrics.csSmall, + 1, + 1, + 1, + 1, + look.selectedFill + ) elseif i == hoverIdx then - Highlight(x1 + metrics.catInset, y1, x2 - metrics.catInset, y2, metrics.csSmall, look.rowHoverOpacity, look.white) + Highlight( + x1 + metrics.catInset, + y1, + x2 - metrics.catInset, + y2, + metrics.csSmall, + look.rowHoverOpacity, + look.white + ) end end font:Begin() - for i = 1, shown do + for i = catScroll + 1, shown do local x1, y1, _, y2 = categoryRect(i) local label = (i == selectedIdx and colorSelected or colorDim) .. versionLabels[i] font:Print(label, x1 + metrics.sidePad, mathFloor((y1 + y2) * 0.5), metrics.catFs, "ov") end font:End() + + -- A bar of its own, and a slim one: the column is narrow and this only shows up when + -- there are more months than the card has room for. + if maxCatScroll() > 0 then + local bx2 = area.x1 + metrics.sidebarW - metrics.catInset + UiScroller( + bx2 - metrics.catBarW, + listBottom, + bx2, + sidebarTop(), + #versions * metrics.catRowHeight, + catScroll * metrics.catRowHeight + ) + end end -- The panel: its backdrop, the title, the month card, the text and the scrollbar. Baked @@ -556,13 +630,14 @@ function widget:DrawScreen() if not panelList then panelList = glCreateList(drawPanel) end - if not sidebarList or hoverIdx ~= sidebarHover or selectedIdx ~= sidebarSelected then + if not sidebarList or hoverIdx ~= sidebarHover or selectedIdx ~= sidebarSelected or catScroll ~= sidebarScroll then if sidebarList then glDeleteList(sidebarList) end sidebarList = glCreateList(drawSidebar) sidebarHover = hoverIdx sidebarSelected = selectedIdx + sidebarScroll = catScroll end glCallList(panelList) @@ -600,7 +675,13 @@ function widget:MouseWheel(up, _value) return false end - setStartRow(startRow + (up and -metrics.wheelRows or metrics.wheelRows)) + -- Over the column it scrolls the column, over anything else the text. A wheel that + -- moved the text while the cursor was on the months would read as broken. + if x <= area.x1 + metrics.sidebarW and y > listBottom and y <= sidebarTop() then + setCatScroll(catScroll + (up and -1 or 1)) + else + setStartRow(startRow + (up and -metrics.wheelRows or metrics.wheelRows)) + end return true end diff --git a/luaui/Widgets/gui_gameinfo.lua b/luaui/Widgets/gui_gameinfo.lua index e5e24b52367..0822bbb0829 100644 --- a/luaui/Widgets/gui_gameinfo.lua +++ b/luaui/Widgets/gui_gameinfo.lua @@ -220,6 +220,9 @@ local rowsGen = 0 local layoutGen = 0 local rowMetrics = { gen = -1, rows = -1, totalH = 0 } local scroll = 0 +-- How far the category column is scrolled, in whole entries. A game with enough +-- modoption sections, or a short enough panel, has more of them than the column holds. +local catScroll = 0 local dragging = false -- Where the thumb was taken hold of, as the distance from the cursor to its top edge. The -- thumb then follows the cursor by that much, instead of jumping its middle to wherever @@ -378,7 +381,8 @@ end -- normally has local function getModoptionTooltipText(key, showDefault) local option = optionDefs[key] - local str = BAR.I18N("modoptions." .. key .. ".desc", { default = option and stripColorCodes(option.desc or "") or "" }) + local str = + BAR.I18N("modoptions." .. key .. ".desc", { default = option and stripColorCodes(option.desc or "") or "" }) if option then if option.min and option.max then str = appendTooltipLine( @@ -1194,8 +1198,10 @@ 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) * metrics.catRowHeight + local top = sidebarTop() - (i - 1 - catScroll) * metrics.catRowHeight return area.x1, top - metrics.catRowHeight, area.x1 + metrics.sidebarW, top end @@ -1208,7 +1214,7 @@ local function sidebarIndexAt(x, y) return nil end - local i = mathFloor((top - y) / metrics.catRowHeight) + 1 + local i = mathFloor((top - y) / metrics.catRowHeight) + 1 + catScroll if not categories[i] then return nil end @@ -1221,6 +1227,20 @@ 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 + +local function setCatScroll(n) + local m = maxCatScroll() + catScroll = (n < 0 and 0) or (n > m and m) or n +end + -- Rebuilds every rect against the panel size. Whole pixels throughout, so glyph and -- rectangle edges do not land between pixels. local function setLayout() @@ -1259,6 +1279,7 @@ local function setLayout() metrics.sidebarDrop = mathFloor(8 * s) metrics.sidebarW = mathFloor(240 * s) metrics.barW = mathFloor(14 * s) + metrics.catBarW = mathMax(3, mathFloor(6 * s)) -- Rounded like the settings panel's inner elements, which take a share of this too. metrics.csPanel = mathFloor(elementCorner) metrics.csSmall = mathFloor(elementCorner * 0.66) @@ -1307,11 +1328,11 @@ local function setLayout() -- toggle beside it rather than to the field it would otherwise sit against. searchBox:setRect(listX1, rowBottom, toggleHit[1] - mathFloor(28 * s), rowTop, fs) + setCatScroll(catScroll) layoutGen = layoutGen + 1 clampScroll() end - -- A unit's name, cut to the column reserved for it and padded out to it, so the source -- beside it starts at the same place on every line however long the names are. local function ownerLabel(name, width) @@ -1503,8 +1524,7 @@ local function fitRow(row) end row.fitName = (row.changed and colorNameOn or colorName) .. text.fit(font, row.name, valueX1 - listX1 - metrics.rowPad * 3, metrics.rowFs) - row.fitValue = valueColor - .. text.fit(font, row.value, listRight - valueX1 - metrics.rowPad * 2, metrics.rowFs) + row.fitValue = valueColor .. text.fit(font, row.value, listRight - valueX1 - metrics.rowPad * 2, metrics.rowFs) end end @@ -1553,21 +1573,7 @@ local function drawIcons() -- over the picture is a gradient, and it leaves its last colour behind. Setting white -- once outside the loop leaves every picture after the first modulated by that. glColor(1, 1, 1, 1) - UiUnit( - x, - y - size, - x + size, - y, - nil, - 1, - 1, - 1, - 1, - iconZoom, - nil, - nil, - "#" .. pendingIcons[at + 3] - ) + UiUnit(x, y - size, x + size, y, nil, 1, 1, 1, 1, iconZoom, nil, nil, "#" .. pendingIcons[at + 3]) end gl.Scissor(false) glColor(1, 1, 1, 1) @@ -1605,7 +1611,19 @@ end -- The band a category heading sits on: the sheen and the line closing it off underneath, -- so the heading closes off the block above it rather than floating in the middle. local function drawHeaderBand(top, bottom, caption, failed) - RectRound(listX1, bottom, listRight, top - metrics.csSmall, metrics.csSmall, 1, 1, 0, 0, look.sheenTop, look.sheenTop) + RectRound( + listX1, + bottom, + listRight, + top - metrics.csSmall, + metrics.csSmall, + 1, + 1, + 0, + 0, + look.sheenTop, + look.sheenTop + ) RectRound( listX1, bottom, @@ -1729,16 +1747,36 @@ local function drawSidebar() fitCategories() end - for i, c in ipairs(categories) do + for i = catScroll + 1, #categories do + local c = categories[i] local x1, y1, x2, y2 = categoryRect(i) if y1 < listBottom then break end local selected = selectedCategory == c.key if selected then - RectRound(x1 + metrics.catInset, y1, x2 - metrics.catInset, y2, metrics.csSmall, 1, 1, 1, 1, look.selectedFill) + RectRound( + x1 + metrics.catInset, + y1, + x2 - metrics.catInset, + y2, + metrics.csSmall, + 1, + 1, + 1, + 1, + look.selectedFill + ) elseif i == hover.sb then - Highlight(x1 + metrics.catInset, y1, x2 - metrics.catInset, y2, metrics.csSmall, look.rowHoverOpacity, look.white) + Highlight( + x1 + metrics.catInset, + y1, + x2 - metrics.catInset, + y2, + metrics.csSmall, + look.rowHoverOpacity, + look.white + ) end local ty = mathFloor((y1 + y2) * 0.5) queueText(selected and c.textSel or c.textDim, x1 + metrics.sidePad, ty, metrics.catFs, "ov", 1) @@ -1746,6 +1784,20 @@ local function drawSidebar() queueText(c.countText, x2 - metrics.sidePad, ty, metrics.catFs, "rov", 1) end end + + -- A bar of its own, and a slim one: the column is narrow and this only shows up when + -- there are more categories than the card has room for. + if maxCatScroll() > 0 then + local bx2 = area.x1 + metrics.sidebarW - metrics.catInset + UiScroller( + bx2 - metrics.catBarW, + listBottom, + bx2, + sidebarTop(), + #categories * metrics.catRowHeight, + catScroll * metrics.catRowHeight + ) + end end -- The filter toggle and its caption. The search field draws itself, live, so its caret @@ -1882,6 +1934,8 @@ local function panelSignature(mx, my) .. "|" .. layoutGen .. "|" + .. catScroll + .. "|" .. selFrom .. "|" .. selTo @@ -1943,10 +1997,13 @@ function widget:ViewResize() UiUnit = WG.FlowUI.Draw.Unit if not searchBox then - searchBox = Editbox.new({ placeholder = L.search, onChange = function() - setScroll(0) - rebuildRows() - end }) + searchBox = Editbox.new({ + placeholder = L.search, + onChange = function() + setScroll(0) + rebuildRows() + end, + }) end setLayout() @@ -2137,7 +2194,13 @@ function widget:MouseWheel(up, _value) return false end - setScroll(scroll + (up and -metrics.wheelRows or metrics.wheelRows)) + -- 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. + if x <= area.x1 + metrics.sidebarW and y > listBottom and y <= sidebarTop() then + setCatScroll(catScroll + (up and -1 or 1)) + else + setScroll(scroll + (up and -metrics.wheelRows or metrics.wheelRows)) + end return true end diff --git a/luaui/Widgets/widget_selector.lua b/luaui/Widgets/widget_selector.lua index 017a2dc9849..004d83ffeae 100644 --- a/luaui/Widgets/widget_selector.lua +++ b/luaui/Widgets/widget_selector.lua @@ -1,28 +1,10 @@ --------------------------------------------------------------------------------- --------------------------------------------------------------------------------- --- --- file: selector.lua --- brief: the widget selector, loads and unloads widgets --- author: Dave Rodgers --- --- Copyright (C) 2007. --- Licensed under the terms of the GNU GPL, v2 or later. --- --------------------------------------------------------------------------------- --------------------------------------------------------------------------------- --- changes: --- jK (April@2009) - updated to new font system --- Bluestone (Jan 2015) - added to BA as a widget, added various stuff --------------------------------------------------------------------------------- --------------------------------------------------------------------------------- - local widget = widget ---@type Widget function widget:GetInfo() return { name = "Widget Selector", desc = "Widget selection widget", - author = "trepan, jK, Bluestone", + author = "trepan, jK, Bluestone, Floris", date = "Jan 8, 2007", license = "GNU GPL, v2 or later", layer = 999999, @@ -31,1382 +13,2458 @@ function widget:GetInfo() } end +-- Laid out the way the game info panel and the keybind editor are: the same inset area, +-- the title in the top-left corner over a darker card holding the category column, a +-- search field and a filter toggle in the header band, a 14 px scrollbar in its own +-- channel against the right edge, and the actions along the bottom. +-- +-- The categories come from the filename prefix every widget in the game already carries - +-- gui_, cmd_, unit_ and the rest - spelled out. A widget whose prefix is not one of them, +-- which is most of what a player writes themselves, falls into Other. +-- +-- Anything that cannot be taken back without a reload asks first, in a modal that says +-- what it is about to do. Toggling a single widget is not one of those: it is reversible +-- by clicking again, and asking every time would make the panel unusable. + +-- Shared with the keybind editor and the game info panel, which is where they were written. +local Editbox = VFS.Include("luaui/Include/keybind_editbox.lua") +local Dropdown = VFS.Include("luaui/Include/keybind_dropdown.lua") +local text = VFS.Include("luaui/Include/keybind_text.lua") +local KEYSYMS = VFS.Include("luaui/Include/keybind_keysyms.lua") +local Search = VFS.Include("luaui/Include/search.lua") + -- Localized functions for performance local mathFloor = math.floor local mathMax = math.max -local mathMin = math.min -- Localized Spring API for performance +local spGetViewGeometry = Spring.GetViewGeometry local spGetMouseState = Spring.GetMouseState +local spIsGUIHidden = Spring.IsGUIHidden local spEcho = Spring.Echo -local spGetViewGeometry = Spring.GetViewGeometry - -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- - -local showButtons = false - --- relies on a gadget to implement "luarules reloadluaui" --- relies on custom stuff in widgetHandler to implement blankOutConfig and allowUserWidgets - -include("keysym.h.lua") -include("fonts.lua") - -local WhiteStr = "\255\255\255\255" - -local sizeMultiplier = 1 +local spSendCommands = Spring.SendCommands +local glCreateList = gl.CreateList +local glCallList = gl.CallList +local glDeleteList = gl.DeleteList +local glColor = gl.Color +local glTexture = gl.Texture +local math_isInRect = math.isInRect + +local playSounds = true +local buttonclick = "LuaUI/Sounds/buildbar_waypoint.wav" + +local screenHeightOrg = 610 +local screenWidthOrg = 1100 +local screenHeight = screenHeightOrg +local screenWidth = screenWidthOrg -local buttons = {} -local floor = mathFloor - -local widgetsList = {} -local fullWidgetsList = {} +local vsx, vsy = spGetViewGeometry() +local widgetScale = (vsy / 1080) +local screenX = mathFloor((vsx * 0.5) - (screenWidth / 2)) +local screenY = mathFloor((vsy * 0.5) + (screenHeight / 2)) + +---@type function +local RectRound +---@type function +local UiElement +---@type function +local UiButton +---@type function +local UiScroller +---@type function +local UiScrollerAt +---@type function +local Highlight +---@type function +local UiToggle +local elementCorner +local font + +local area = { x1 = 0, y1 = 0, x2 = 0, y2 = 0 } +-- Sizes derived from the scale, in one table rather than a local each, the way the two +-- panels this is modelled on hold theirs: this chunk is close to Lua's limit of 200. +local metrics = { + rowHeight = 24, + catRowHeight = 29, + rowFs = 13, + catFs = 13, + rowPad = 6, + sidePad = 12, + catInset = 4, + accentW = 3, + edgeInset = 4, + headerH = 34, + headerGap = 4, + footerH = 38, + footerGap = 6, + buttonGap = 6, + listGap = 12, + cardLip = 5, + titleY = 17, + titleFs = 20, + sidebarDrop = 8, + sidebarW = 240, + barW = 14, + wheelRows = 3, + csSmall = 2, + csPanel = 4, + csButton = 3, + toggleFs = 13, + captionBleed = 3, + -- Where the description starts, as a fraction of the list width. A widget's name is + -- the thing being hunted for, so it gets the room; the description is context. + descSplit = 0.42, +} +local look = { + sidebarFill = { 0, 0, 0, 0.24 }, + sidebarFillTop = { 0, 0, 0, 0.16 }, + selectedFill = { 1, 1, 1, 0.13 }, + white = { 1, 1, 1 }, + rowHoverOpacity = 0.14, + hoverOpacity = 0.14, + -- A widget is in one of three states, and each is marked three ways at once - the + -- switch in front of it, a band across the row and a bar down its left edge - so it + -- still reads in a list being scrolled past quickly, where a change of text colour + -- alone reads as noise. + -- + -- Running: loaded and doing its job. + activeFill = { 0.45, 0.95, 0.5, 0.1 }, + activeAccent = { 0.4, 0.95, 0.45, 0.95 }, + -- Enabled but not running: the config says load it and it is not loaded, so it errored + -- out or its conditions were not met. + pendingFill = { 1, 0.8, 0.35, 0.09 }, + pendingAccent = { 1, 0.78, 0.3, 0.9 }, + 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. + dangerFill = { 0.46, 0.1, 0.1, 1 }, + dangerFillHover = { 0.66, 0.14, 0.14, 1 }, + -- And the other half of that pair: an accept that saves something. + confirmFill = { 0.17, 0.38, 0.21, 1 }, + confirmFillHover = { 0.24, 0.52, 0.29, 1 }, + scrim = { 0, 0, 0, 0.55 }, +} +-- 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 +-- washes the rest out to grey. Each fill becomes a darker bottom and itself on top +-- instead. Derived once per fill and kept: the pair is passed on every draw. +look.gradients = setmetatable({}, { + __index = function(self, fill) + local pair = { + { fill[1] * 0.55, fill[2] * 0.55, fill[3] * 0.55, fill[4] or 1 }, + { fill[1], fill[2], fill[3], fill[4] or 1 }, + } + self[fill] = pair + + return pair + end, +}) +local colorTitle = "\255\235\235\235" +local colorName = "\255\145\143\140" +local colorNameOn = "\255\248\248\248" +local colorDesc = "\255\105\105\105" +local colorDescOn = "\255\175\175\175" +local colorSelected = "\255\210\210\205" +local colorDim = "\255\160\160\160" +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" +local colorDanger = "\255\255\190\190" + +-- Filename prefixes, spelled out. Everything the game ships carries one; a widget with a +-- prefix that is not here - which is most of what a player writes - falls into Other, so +-- 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", + gfx = "graphics", + camera = "camera", + snd = "sound", + map = "map", + minimap = "minimap", + api = "api", + dbg = "debug", +} +-- The column's order, which is by what a player is most likely to be looking for rather +-- than by how many widgets each holds. +local GROUP_ORDER = { + "interface", + "commands", + "units", + "camera", + "graphics", + "sound", + "map", + "minimap", + "api", + "debug", +} +local OTHER = "other" + +local L = {} + +local show, showOnceMore +local panelList, windowList, backgroundGuishader, panelSig +local listTop, listBottom, listX1, listRight, descX1, barX1 = 0, 0, 0, 0, 0, 0 +local switchX1, orderX1, nameX1 = 0, 0, 0 +-- The sets block at the foot of the category column: a caption, the picker, and the +-- two buttons that make and unmake a set. It lives there rather than in the header +-- because the column already has the room and the header has none left. +local setsTop = 0 +-- How far the category column is scrolled, in whole entries. The sets block below it +-- takes a fixed bite out of the card, so a game with enough widget prefixes - or a +-- short panel - can have more categories than there is room for. +local catScroll = 0 +-- Declared here because the content is built before the layout that measures the column, +-- and rebuilding it can leave the column scrolled past its own end. +local setCatScroll +-- The header switches, right to left from the panel's edge. Each carries the rects it +-- 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" }, +} + +-- Every widget the panel can show, as rows; and the categories they fall into. +local entries = {} +-- The same entries, by name, for the staleness scan below. +local entryByName = {} +local categories = {} +local rows = {} +local rowsGen = 0 +local layoutGen = 0 +local scroll = 0 +local dragging = false +local dragGrab = 0 local localWidgetCount = 0 +local allowuserwidgets = true +---@type string? +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 +-- 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 } +---@type table +local searchBox +---@type table +-- The name field the save dialog puts up. Its own, rather than the search field: a +-- dialog must not disturb what was typed in the panel behind it. +local nameBox +---@type table +local setPicker + +-- Named sets of widgets. A set is the list of widgets that were enabled when it was +-- saved; applying one switches on everything in it and switches off everything else, so +-- a set describes a whole state rather than a patch to the current one. +-- +-- Kept as an array so the picker's order is the order they were made in, which is stable +-- across sessions in a way that a hash's iteration order is not. +local sets = {} +-- The set showing in the picker. Choosing one only picks it: loading, saving over and +-- deleting are each their own button, so picking a set to delete does not load it on the +-- way past. +---@type string? +local pickedSet + +-- The buttons under the picker. Load and Delete need a set picked and are not drawn +-- without one; Save always has something to save. +local setButtons = { + { id = "loadset" }, + { id = "saveset" }, + { id = "deleteset" }, +} +-- The action buttons along the bottom, rebuilt on layout. +local buttons = {} +-- The open modal, or nil. `accept` is what the confirm button runs. +---@type table? +local dialog +local dialogOk, dialogCancel, dialogField = {}, {}, {} +-- The modal's own box, kept so the blur behind it can be placed without measuring +-- everything again. +local dialogBox = {} +-- The row a press landed on, by name and button. A click is a press and a release on +-- the same row: press one, slide off, let go, and nothing happens - the row under the +-- cursor at the end was never the one being clicked. +---@type string? +local pressedRow +local pressedButton = 0 + +local hover = { sb = 0, row = 0, sw = 0, tog = 0, bar = 0, btn = "", dlg = "" } + +-- Input ownership, taken once when the search field takes focus and given back when it +-- loses it. `widgetHandler:OwnText()` is not available here: it is built onto the +-- per-widget wrapper barwidgets hands out, and this widget asks for the real handler +-- (handler = true), which carries the textOwner field itself and none of the sugar. +local ownsInput = false +local fieldHasInput = false +local textInputStarted = false +local heldAtFocus = {} + +-- FlowUI and the font handler are widgets too, and this one is inserted before them: +-- widgetHandler calls Initialize the moment a widget loads, so everything taken from +-- them is picked up on the first frame that has them rather than at Initialize, where +-- they do not exist yet. The old selector survived this by never doing arithmetic on +-- what it read; this one lays out against it, so it has to wait. +local uiBound = false + +local rebuildRows +local setLayout + +---------------------------------------------------------------- +-- Content +---------------------------------------------------------------- + +-- Which of the three states a widget is in: 1 running, 0.5 enabled but not running, 0 +-- off. The same numbers FlowUI's switch takes, so the control in front of a row says the +-- state without translating it. +local function stateOf(name, data) + if data.active then + return 1 + end + local order = widgetHandler.orderList[name] + if order and order >= 1 then + return 0.5 + end + + return 0 +end -local minMaxEntries = 14 -local curMaxEntries = 24 - -local startEntry = 1 -local pageStep = floor(curMaxEntries / 2) - 1 - -local fontSize = 14.25 -local fontSpace = 8.5 -local yStep = fontSize + fontSpace +-- Which column a widget belongs in, from the prefix on its filename. +local function groupOf(data) + local base = data.basename or "" + local prefix = base:match("^(%a+)_") -local fontfile = "fonts/" .. Spring.GetConfigString("bar_font", "Poppins-Regular.otf") -local vsx, vsy = spGetViewGeometry() -local fontfileScale = (0.5 + (vsx * vsy / 5700000)) -local fontfileSize = 36 -local fontfileOutlineSize = 6 -local fontfileOutlineStrength = 1.3 -local font = - gl.LoadFont(fontfile, fontfileSize * fontfileScale, fontfileOutlineSize * fontfileScale, fontfileOutlineStrength) -local fontfile2 = "fonts/" .. Spring.GetConfigString("bar_font2", "Exo2-SemiBold.otf") -local font2 = - gl.LoadFont(fontfile2, fontfileSize * fontfileScale, fontfileOutlineSize * fontfileScale, fontfileOutlineStrength) - -local bgPadding = 4.5 - -local maxWidth = 0 -local borderx = yStep * 0.75 -local bordery = yStep * 0.75 - -local activeGuishader = false -local scrollbarOffset = -15 -local updateUi = true - -local midx = vsx * 0.5 -local minx = vsx * 0.4 -local maxx = vsx * 0.6 -local midy = vsy * 0.5 -local miny = vsy * 0.4 -local maxy = vsy * 0.6 - -local sbposx = 0.0 -local sbposy = 0.0 -local sbsizex = 0.0 -local sbsizey = 0.0 -local sby1 = 0.0 -local sby2 = 0.0 -local sbsize = 0.0 -local sbheight = 0.0 -local activescrollbar = false -local scrollbargrabpos = 0.0 - -local show = false -local pagestepped = false - -local RectRound, UiElement, UiSelectHighlight, elementPadding, elementCorner - -local dlistGuishader, dlistGuishader2, lastStart + return (prefix and GROUPS[prefix]) or OTHER +end -local widgetScale = (vsy / 1080) +-- One line of description, with the newlines a multi-line one carries turned into spaces: +-- this sits on the row beside the name, and the tooltip is where the whole thing lives. +local function oneLine(str) + if not str or str == "" then + return "" + end + str = string.gsub(str, "%s+", " ") -local allowuserwidgets = true -if not Spring.GetModOptions().allowuserwidgets and not Spring.IsReplay() then - allowuserwidgets = false - buttons[3] = "" + return (string.gsub(str, "^%s*(.-)%s*$", "%1")) end -local buttonFontSize = 15 -local buttonHeight = 24 -local buttonTop = 40 -- offset between top of buttons and bottom of widget +-- 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. +-- 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() + local myName = widget:GetInfo().name + entries = {} + entryByName = {} + localWidgetCount = 0 -local utf8 = VFS.Include("common/luaUtilities/utf8.lua") -local Search = VFS.Include("luaui/Include/search.lua") -local textInputDlist -local uiList -local updateTextInputDlist = true -local textCursorRect -local showTextInput = true -local inputText = "" -local inputTextPosition = 0 -local cursorBlinkTimer = 0 -local cursorBlinkDuration = 1 -local maxTextInputChars = 127 -- tested 127 as being the true max -local inputTextInsertActive = false -local floor = mathFloor -local inputMode = "" -local chobbyInterface - -function widget:RecvLuaMsg(msg, playerID) - if msg:sub(1, 18) == "LobbyOverlayActive" then - chobbyInterface = (msg:sub(1, 19) == "LobbyOverlayActive1") - end -end - -function widget:TextInput(char) -- if it isn't working: chobby probably hijacked it - if not chobbyInterface and not Spring.IsGUIHidden() and showTextInput and show then - if inputTextInsertActive then - inputText = utf8.sub(inputText, 1, inputTextPosition) .. char .. utf8.sub(inputText, inputTextPosition + 2) - if inputTextPosition <= utf8.len(inputText) then - inputTextPosition = inputTextPosition + 1 + -- Where each running widget sits in the handler's list. That index is the load order: + -- SaveConfigData writes it out as orderList[name], and it is the order the call-ins + -- run in. A widget that is not running has no place in it. + local order, layer = {}, {} + for i = 1, #widgetHandler.widgets do + local w = widgetHandler.widgets[i] + if w.whInfo then + order[w.whInfo.name] = i + layer[w.whInfo.name] = w.whInfo.layer + end + end + + for name, data in pairs(widgetHandler.knownWidgets) do + -- The selector cannot list itself: toggling it off would take the list with it. The + -- 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) + entries[#entries + 1] = { + name = name, + data = data, + group = groupOf(data), + state = stateOf(name, data), + order = order[name], + layer = layer[name], + desc = desc, + isLocal = not data.fromZip, + -- Lowercased once here rather than per keystroke: a search walks every one of + -- these on every letter typed. + searchName = string.lower(name), + searchDesc = string.lower(desc), + searchFile = string.lower(data.basename or ""), + searchAuthor = string.lower(data.author or ""), + } + entryByName[name] = entries[#entries] + if not data.fromZip then + localWidgetCount = localWidgetCount + 1 end - else - inputText = utf8.sub(inputText, 1, inputTextPosition) .. char .. utf8.sub(inputText, inputTextPosition + 1) - inputTextPosition = inputTextPosition + 1 end - if string.len(inputText) > maxTextInputChars then - inputText = string.sub(inputText, 1, maxTextInputChars) - if inputTextPosition > maxTextInputChars then - inputTextPosition = maxTextInputChars + end +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. +local function buildCategories() + local counts, active, total, on = {}, {}, 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 + 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 end - cursorBlinkTimer = 0 - updateTextInputDlist = true - if WG.limitidlefps and WG.limitidlefps.update then - WG.limitidlefps.update() + end + + categories = { { key = nil, label = L.all, count = total, active = on } } + 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 } end - UpdateList(true) - return true end -end + if counts[OTHER] then + categories[#categories + 1] = + { key = OTHER, label = L.other, count = counts[OTHER], active = active[OTHER] or 0 } + end -local function clearChatInput() - --showTextInput = false - inputText = "" - inputTextPosition = 0 - inputTextInsertActive = false - --backgroundGuishader = gl.DeleteList(backgroundGuishader) - if WG.guishader then - WG.guishader.RemoveRect("selectorinput") + -- Fewer categories than before can leave the column scrolled past its own end. + setCatScroll(catScroll) + + -- A category the filter emptied cannot stay selected, or the list shows nothing with + -- no way back to it. + if selectedCategory then + local found + for _, c in ipairs(categories) do + if c.key == selectedCategory then + found = true + end + end + if not found then + selectedCategory = nil + end end - UpdateList(true) end -local function cancelChatInput() - clearChatInput() - Spring.SDLStopTextInput() - widgetHandler.textOwner = nil --widgetHandler:DisownText() - UpdateList(true) +local function buildContent() + buildEntries() + buildCategories() end -function drawChatInputCursor() - if textCursorRect then - local a = 1 - (cursorBlinkTimer * (1 / cursorBlinkDuration)) + 0.15 - gl.Color(0.7, 0.7, 0.7, a) - gl.Rect(textCursorRect[1], textCursorRect[2], textCursorRect[3], textCursorRect[4]) - gl.Color(1, 1, 1, 1) +-- 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) + if a.order and b.order then + return a.order < b.order end + if a.order or b.order then + return a.order ~= nil + end + + return a.name < b.name end -function drawChatInput() - if showTextInput then - updateTextInputDlist = false - textInputDlist = gl.DeleteList(textInputDlist) - textInputDlist = gl.CreateList(function() - local activationArea = { - floor(minx - (bgPadding * sizeMultiplier)), - floor(miny - (bgPadding * sizeMultiplier)), - floor(maxx + (bgPadding * sizeMultiplier)), - floor(maxy + (bgPadding * sizeMultiplier)), - } - local usedFontSize = 15 * widgetScale - local lineHeight = floor(usedFontSize * 1.15) - local x, y, _ = spGetMouseState() - local chatlogHeightDiff = 0 - local inputFontSize = floor(usedFontSize * 1.03) - local inputHeight = floor(inputFontSize * 2.15) - local leftOffset = floor(lineHeight * 0.7) - local distance = 0 --elementMargin - local usedFont = inputMode == "" and font3 or font - local modeText = BAR.I18N("ui.settings.filter") - if inputMode ~= "" then - modeText = inputMode - end - local modeTextPosX = floor(activationArea[1] + elementPadding + elementPadding + leftOffset) - local textPosX = - floor(modeTextPosX + (usedFont:GetTextWidth(modeText) * inputFontSize) + leftOffset + inputFontSize) - local textCursorWidth = 1 + mathFloor(inputFontSize / 14) - if inputTextInsertActive then - textCursorWidth = mathFloor(textCursorWidth * 5) - end - local textCursorPos = - floor(usedFont:GetTextWidth(utf8.sub(inputText, 1, inputTextPosition)) * inputFontSize) +-- 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) + if a.name == "Widget Profiler" then + return true + elseif b.name == "Widget Profiler" then + return false + end + if a.isLocal ~= b.isLocal then + return b.isLocal + end - -- background - local x2 = mathMax( - textPosX + lineHeight + floor(usedFont:GetTextWidth(inputText) * inputFontSize), - floor(activationArea[1] + ((activationArea[3] - activationArea[1]) / 2)) - ) - chatInputArea = { - activationArea[1], - activationArea[2] + chatlogHeightDiff - distance - inputHeight, - x2, - activationArea[2] + chatlogHeightDiff - distance, - } - UiElement( - chatInputArea[1], - chatInputArea[2], - chatInputArea[3], - chatInputArea[4], - 0, - 0, - nil, - nil, - 0, - nil, - nil, - nil, - WG.FlowUI.clampedOpacity - ) + return a.name < b.name +end - if WG.guishader and activeGuishader then - WG.guishader.InsertRect( - activationArea[1], - activationArea[2] + chatlogHeightDiff - distance - inputHeight, - x2, - activationArea[2] + chatlogHeightDiff - distance, - "selectorinput", - widget - ) - end +-- The rows the list shows: what the column, the search box and the filter toggle 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() + rows = {} + rowsGen = rowsGen + 1 - -- button background - local inputButtonRect = { - activationArea[1] + elementPadding, - activationArea[2] + chatlogHeightDiff - distance - inputHeight + elementPadding, - textPosX - inputFontSize, - activationArea[2] + chatlogHeightDiff - distance, - } - if inputMode ~= "" then - gl.Color(0.03, 0.12, 0.03, 0.3) + local query = Search.query(searchBox and searchBox:getText()) + -- Filled once and rewritten per widget rather than allocated for each of them. + local primary, secondary = { "" }, { "", "", "" } + local scored = not query.empty and {} or nil + + 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) + then + if query.empty then + rows[#rows + 1] = e else - gl.Color(0, 0, 0, 0.3) + primary[1] = e.searchName + secondary[1], secondary[2], secondary[3] = e.searchDesc, e.searchFile, e.searchAuthor + -- Named by its name alone: a widget found only through its description or author + -- 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 } + end end - RectRound( - inputButtonRect[1], - inputButtonRect[2], - inputButtonRect[3], - inputButtonRect[4], - elementCorner * 0.6, - 0, - 0, - 0, - 1 - ) - gl.Color(1, 1, 1, 0.033) - gl.Rect(inputButtonRect[3] - 1, inputButtonRect[2], inputButtonRect[3], inputButtonRect[4]) - - -- button text - usedFont:Begin() - usedFont:SetTextColor(0.62, 0.62, 0.62, 1) - usedFont:Print( - modeText, - modeTextPosX, - activationArea[2] + chatlogHeightDiff - distance - (inputHeight * 0.61), - inputFontSize, - "o" - ) + end + end - -- text cursor - textCursorRect = { - textPosX + textCursorPos, - activationArea[2] + chatlogHeightDiff - distance - (inputHeight * 0.5) - (inputFontSize * 0.6), - textPosX + textCursorPos + textCursorWidth, - activationArea[2] + chatlogHeightDiff - distance - (inputHeight * 0.5) + (inputFontSize * 0.64), - } + if scored then + table.sort(scored, function(a, b) + if a.score ~= b.score then + return a.score > b.score + end - usedFont:SetTextColor(0.95, 0.95, 0.95, 1) - usedFont:Print( - inputText, - textPosX, - activationArea[2] + chatlogHeightDiff - distance - (inputHeight * 0.61), - inputFontSize, - "o" - ) - usedFont:End() + return (filters.byOrder and sortByOrder or sortEntries)(a.e, b.e) end) + for i = 1, #scored do + rows[i] = scored[i].e + end + else + table.sort(rows, filters.byOrder and sortByOrder or sortEntries) end end -------------------------------------------------------------------------------- +---------------------------------------------------------------- +-- Rows and scrolling +---------------------------------------------------------------- -local function UpdateGeometry() - midx = vsx * 0.5 - midy = vsy * 0.5 - - local halfWidth = (((maxWidth / fontSize) + 2) * fontSize) * sizeMultiplier * 0.5 - minx = floor(midx - halfWidth - (borderx * sizeMultiplier)) - maxx = floor(midx + halfWidth + (borderx * sizeMultiplier)) +local function pageRows() + return mathMax(1, mathFloor((listTop - listBottom) / metrics.rowHeight)) +end - local ySize = (yStep * sizeMultiplier) * mathMax(#widgetsList, 8) - miny = floor(midy - (0.5 * ySize)) - ((fontSize + bgPadding + bgPadding) * sizeMultiplier) - maxy = floor(midy + (0.5 * ySize)) +local function maxScroll() + return mathMax(0, #rows - pageRows()) end -local function UpdateListScroll() - local wCount = #fullWidgetsList - local lastStart = lastStart or wCount - curMaxEntries + 1 - if lastStart < 1 then - lastStart = 1 +local function clampScroll() + local m = maxScroll() + if scroll > m then + scroll = m end - if lastStart > wCount - curMaxEntries + 1 then - lastStart = 1 + if scroll < 0 then + scroll = 0 end - if startEntry > lastStart then - startEntry = lastStart +end + +local function setScroll(n) + scroll = n + clampScroll() +end + +-- The row under y, as an index into what is on screen, or nil. Half-open on the shared +-- edge so one point never lands in two rows. +local function rowAt(y) + if y > listTop or y <= listBottom then + return nil end - if startEntry < 1 then - startEntry = 1 + local i = mathFloor((listTop - y) / metrics.rowHeight) + 1 + if not rows[scroll + i] then + return nil end - widgetsList = {} - local se = startEntry - local ee = se + curMaxEntries - 1 - local n = 1 - for i = se, ee do - widgetsList[n], n = fullWidgetsList[i], n + 1 + return i +end + +local function scrollerThumb() + return UiScrollerAt( + barX1, + listBottom, + area.x2 - metrics.edgeInset, + listTop, + #rows * metrics.rowHeight, + scroll * metrics.rowHeight + ) +end + +-- Scrolls so the thumb's top sits where the cursor has dragged it. The offset taken at +-- the grab keeps this relative: the thumb moves with the cursor rather than centring +-- itself on it, so taking hold of it does not shift the list before the drag begins. +local function scrollFromY(y) + local _, _, trackTop, travel = scrollerThumb() + if not travel or travel <= 0 then + return end - updateUiList2 = true + local f = (trackTop - (y - dragGrab)) / travel + if f < 0 then + f = 0 + elseif f > 1 then + f = 1 + end + setScroll(mathFloor(f * maxScroll() + 0.5)) end -local function widgetselectorCmd(_, _, params) - show = not show - if show then - widgetHandler.textOwner = widget --widgetHandler:OwnText() - Spring.SDLStartTextInput() -- because: touch chobby's text edit field once and widget:TextInput is gone for the game, so we make sure its started! - Spring.SetConfigInt("widgetselector", 1) +-- Takes hold of the bar. On the thumb that is a grab and the list stays put; on the track +-- either side the thumb jumps to the cursor first and is then dragged from its middle, +-- which is what a press on bare track is asking for. +local function grabScroller(y) + local top, height = scrollerThumb() + if not top then + return + end + + dragging = true + if y <= top and y >= top - height then + dragGrab = y - top else - Spring.SDLStopTextInput() - widgetHandler.textOwner = nil --widgetHandler:DisownText() + dragGrab = -mathFloor(height * 0.5) + scrollFromY(y) end end -local function factoryresetCmd(_, _, params) - widgetHandler.__blankOutConfig = true - --widgetHandler.__allowUserWidgets = false - Spring.SendCommands("luarules reloadluaui") +-- The modal's box and its two buttons. Centred on the panel rather than the screen, so it +-- reads as belonging to what it is about to change. +local function dialogGeometry() + local s = widgetScale + local w = mathFloor(440 * s) + local h = mathFloor(190 * s) + local cx = mathFloor((area.x1 + area.x2) * 0.5) + local cy = mathFloor((area.y1 + area.y2) * 0.5) + local bx1, bx2 = cx - mathFloor(w * 0.5), cx + mathFloor(w * 0.5) + local by1, by2 = cy - mathFloor(h * 0.5), cy + mathFloor(h * 0.5) + local bw = mathFloor(130 * s) + local bh = mathFloor(30 * s) + local pad = mathFloor(16 * s) + local by = by1 + pad + + dialogCancel = { bx1 + pad, by, bx1 + pad + bw, by + bh } + dialogOk = { bx2 - pad - bw, by, bx2 - pad, by + bh } + -- The name box sits between the message and the buttons, the full width of the box. + dialogField = { bx1 + pad, by + bh + pad, bx2 - pad, by + bh + pad + mathFloor(30 * s) } + dialogBox = { bx1, by1, bx2, by2 } + + return bx1, by1, bx2, by2 end -local function userwidgetsCmd(_, _, params) - if widgetHandler.allowUserWidgets then - widgetHandler.__allowUserWidgets = false - spEcho("Disallowed user widgets, reloading...") - else - widgetHandler.__allowUserWidgets = true - spEcho("Allowed user widgets, reloading...") +---------------------------------------------------------------- +-- Actions +---------------------------------------------------------------- + +-- The picker's options, and which of them is showing. A set stops being the one that is +-- loaded the moment anything is toggled by hand, so the caption falls back to a +-- placeholder rather than naming a set the widgets no longer match. +local function refreshSets() + if not setPicker then + return + end + local names = {} + local selected = 1 + for i = 1, #sets do + names[i] = sets[i].name + if sets[i].name == pickedSet then + selected = i + end + end + setPicker.placeholder = (not pickedSet or #names == 0) and L.noSet or nil + setPicker:setOptions(names) + setPicker:setSelected(selected) + -- Load and Delete come and go with the pick, and the block is a row shorter without + -- them, so it has to be measured again. + if uiBound then + setLayout() end - Spring.SendCommands("luarules reloadluaui") end -function widget:Initialize() - buttons = { --see MouseRelease for which functions are called by which buttons - [1] = BAR.I18N("ui.widgetselector.button_reloadluaui"), - [2] = BAR.I18N("ui.widgetselector.button_unloadallwidgets"), - [3] = BAR.I18N("ui.widgetselector.button_disallowuserwidgets"), - [4] = BAR.I18N("ui.widgetselector.button_resetluaui"), - [5] = BAR.I18N("ui.widgetselector.button_factoryresetluaui"), - } - if not allowuserwidgets then - buttons[3] = "" - else - if widgetHandler.allowUserWidgets then - buttons[3] = BAR.I18N("ui.widgetselector.button_disallowuserwidgets") - else - buttons[3] = BAR.I18N("ui.widgetselector.button_allowuserwidgets") +-- What is switched on right now, as a set. Anything the config says to load counts, +-- running or not: a widget that failed to load this session is still part of what the +-- player asked for, and dropping it here would quietly lose it from the set. +local function currentSet() + local names = {} + for i = 1, #entries do + local e = entries[i] + if e.state > 0 then + names[e.name] = true end end - widgetHandler.knownChanged = true - Spring.SendCommands("unbindkeyset f11") - - -- lets the handler hide the rest of the interface while the list is open - -- (this widget holds the real widgetHandler, so it passes itself) - widgetHandler:RegisterModalWindow(widget, function() - return show == true - end) + return names +end - WG.widgetselector = {} - WG.widgetselector.toggle = function(state) - local newShow = state - if newShow == nil then - newShow = not show - end - if newShow and WG.topbar then - WG.topbar.hideWindows() - end - show = newShow - if show then - widgetHandler.textOwner = widget --widgetHandler:OwnText() - Spring.SDLStartTextInput() -- because: touch chobby's text edit field once and widget:TextInput is gone for the game, so we make sure its started! - Spring.SetConfigInt("widgetselector", 1) - else - Spring.SDLStopTextInput() - widgetHandler.textOwner = nil --widgetHandler:DisownText() +local function findSet(name) + for i = 1, #sets do + if sets[i].name == name then + return sets[i], i end end - WG.widgetselector.isvisible = function() - return show +end + +-- Switches the widgets to match the set: everything in it on, everything else off. Both +-- halves matter - a set that only turned things on would drift further from what it +-- described every time it was applied. +local function applySet(name) + local set = findSet(name) + if not set then + return end - WG.widgetselector.getLocalWidgetCount = function() - return localWidgetCount + + for i = 1, #entries do + local e = entries[i] + local want = set.widgets[e.name] == true + if want and e.state == 0 then + widgetHandler:EnableWidget(e.name) + elseif not want and e.state > 0 then + widgetHandler:DisableWidget(e.name) + end end +end - widget:ViewResize(spGetViewGeometry()) - UpdateList() +-- Saves what is on now under a name, replacing a set of that name if there is one. +local function saveSet(name) + name = name and name:gsub("^%s*(.-)%s*$", "%1") or "" + if name == "" then + return + end - widgetHandler.actionHandler:AddAction(self, "widgetselector", widgetselectorCmd, nil, "t") - widgetHandler.actionHandler:AddAction(self, "factoryreset", factoryresetCmd, nil, "t") - widgetHandler.actionHandler:AddAction(self, "userwidgets", userwidgetsCmd, nil, "t") + local set = findSet(name) + if set then + set.widgets = currentSet() + else + sets[#sets + 1] = { name = name, widgets = currentSet() } + end + pickedSet = name + widgetHandler:SaveConfigData() + refreshSets() end -local function ScrollUp(step) - startEntry = startEntry - step - UpdateListScroll() +local function deleteSet(name) + local _, i = findSet(name) + if not i then + return + end + table.remove(sets, i) + if pickedSet == name then + pickedSet = nil + end + widgetHandler:SaveConfigData() + refreshSets() end -local function ScrollDown(step) - startEntry = startEntry + step - UpdateListScroll() +local function reloadLuaUI() + spSendCommands("luarules reloadluaui") end -function widget:MouseWheel(up, value) - if not show then - return false +local function disableAll() + for i = 1, #entries do + widgetHandler:DisableWidget(entries[i].name) end + widgetHandler:SaveConfigData() +end - local a, c, m, s = Spring.GetModKeyState() - if a or m then - return false -- alt and meta allow normal control - end - local step = (s and 4) or (c and 1) or 2 - if up then - ScrollUp(step) +local function toggleUserWidgets() + if widgetHandler.allowUserWidgets then + widgetHandler.__allowUserWidgets = false + spEcho("Disallowed user widgets, reloading...") else - ScrollDown(step) + widgetHandler.__allowUserWidgets = true + spEcho("Allowed user widgets, reloading...") end - return true + reloadLuaUI() end -local function SortWidgetListFunc(nd1, nd2) - --does nd1 come before nd2? - -- widget profiler on top - if nd1[1] == "Widget Profiler" then - return true - elseif nd2[1] == "Widget Profiler" then - return false - end +local function resetLuaUI() + spSendCommands("luaui reset") +end + +local function factoryReset() + widgetHandler.__blankOutConfig = true + reloadLuaUI() +end - -- mod widgets first, then user widgets - if nd1[2].fromZip ~= nd2[2].fromZip then - return nd1[2].fromZip +-- What the wrapper's OwnText/DisownText do, against the real handler's own field. +local function ownText() + if widgetHandler.textOwner then + return widgetHandler.textOwner == widget end + widgetHandler.textOwner = widget - -- sort by name - return (nd1[1] < nd2[1]) + return true end -function UpdateList(force) - if not widgetHandler.knownChanged and not force then - return +local function disownText() + if widgetHandler.textOwner == widget then + widgetHandler.textOwner = nil end - widgetHandler.knownChanged = false +end - local myName = widget:GetInfo().name - --maxWidth = 0 - widgetsList = {} - fullWidgetsList = {} - local query = Search.query(inputText) - -- Filled once and rewritten per widget rather than allocated for each of them: a - -- keystroke walks every known widget. - local primary, secondary = { "" }, { "", "", "" } - local scoredList = not query.empty and {} or nil - for name, data in pairs(widgetHandler.knownWidgets) do - if name ~= myName and name ~= "Write customparam.__def to files" and not data.hidden then - if query.empty then - fullWidgetsList[#fullWidgetsList + 1] = { name, data } - local width = fontSize * font:GetTextWidth(name) - if width > maxWidth then - maxWidth = width - end - else - primary[1] = string.lower(name) - secondary[1] = data.desc and string.lower(data.desc) or "" - secondary[2] = data.basename and string.lower(data.basename) or "" - secondary[3] = data.author and string.lower(data.author) or "" - -- Named by its name alone: a widget found only through its description or author - -- 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 - scoredList[#scoredList + 1] = { name, data, score = score } - local width = fontSize * font:GetTextWidth(name) - if width > maxWidth then - maxWidth = width - end - end - end - end +-- The name the save dialog would use, and whether it is one at all. A set with no name +-- cannot be found again, so an empty field blocks the accept rather than saving one. +local function dialogName() + if not (dialog and dialog.field and nameBox) then + return nil, false end - --maxWidth = (maxWidth / fontSize) + local name = nameBox:getText():gsub("^%s*(.-)%s*$", "%1") - if scoredList then - -- Sort by score descending, then by default sort within same score tier - table.sort(scoredList, function(a, b) - if a.score ~= b.score then - return a.score > b.score - end - -- tie-break: mod widgets first, then alphabetical - if a[2].fromZip ~= b[2].fromZip then - return a[2].fromZip - end - return a[1] < b[1] - end) - for j = 1, #scoredList do - fullWidgetsList[j] = { scoredList[j][1], scoredList[j][2] } - end - else - table.sort(fullWidgetsList, SortWidgetListFunc) - end + return name, name == "" +end - localWidgetCount = 0 - for _, namedata in ipairs(fullWidgetsList) do - if not namedata[2].fromZip then - localWidgetCount = localWidgetCount + 1 - end +local function closeDialog() + local d = dialog + dialog = nil + + return d +end + +-- Raises the modal. Everything routed through here is something a reload undoes only by +-- accident, so the wording says what goes rather than asking "are you sure". +-- `field` puts a name box in the dialog and hands what was typed to `accept`. +local function confirm(title, message, accept, danger, field, initial) + dialog = { title = title, message = message, accept = accept, danger = danger, field = field } + -- Laid out here rather than at the first frame of it: the cursor is tested against + -- these buttons before anything is drawn, and empty rects there are a crash. + dialogGeometry() + if searchBox then + searchBox:blur() + end + if field and nameBox then + nameBox:setText(initial or "") + nameBox:focus() end +end - if force and WG.guishader then - activeGuishader = false - WG.guishader.RemoveDlist("widgetselector") - WG.guishader.RemoveDlist("widgetselector2") - WG.guishader.RemoveRect("selectorinput") - if textInputDlist then - textInputDlist = gl.DeleteList(textInputDlist) - 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 + ) + elseif id == "reset" then + confirm(L.reset, L.resetWarn, resetLuaUI, true) + elseif id == "factory" then + confirm(L.factoryDefaults, L.factoryWarn, factoryReset, true) end +end + +---------------------------------------------------------------- +-- Layout +---------------------------------------------------------------- - UpdateListScroll() - UpdateGeometry() +local function sidebarTop() + return listTop - metrics.sidebarDrop end -function widget:ViewResize(n_vsx, n_vsy) - vsx, vsy = spGetViewGeometry() - widgetScale = (vsy / 1080) - local fontfileScale = widgetScale - font = gl.LoadFont( - fontfile, - fontfileSize * fontfileScale, - fontfileOutlineSize * fontfileScale, - fontfileOutlineStrength - ) - font2 = gl.LoadFont( - fontfile2, - fontfileSize * fontfileScale, - fontfileOutlineSize * fontfileScale, - fontfileOutlineStrength - ) +-- `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 - sizeMultiplier = widgetScale * 0.95 + return area.x1, top - metrics.catRowHeight, area.x1 + metrics.sidebarW, top +end - RectRound = WG.FlowUI.Draw.RectRound - UiElement = WG.FlowUI.Draw.Element - elementPadding = WG.FlowUI.elementPadding - elementCorner = WG.FlowUI.elementCorner - UiSelectHighlight = WG.FlowUI.Draw.SelectHighlight +-- The column runs from the title down to whatever the sets block leaves it. +local function categoryBottom() + return setsTop +end - updateUi = true - UpdateGeometry() +-- 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)) end -------------------------------------------------------------------------------- +local function maxCatScroll() + return mathMax(0, #categories - catPageRows()) +end -function widget:KeyRelease() - -- Since we grab the keyboard, we need to specify a KeyRelease to make sure other release actions can be triggered - return false +setCatScroll = function(n) + local m = maxCatScroll() + catScroll = (n < 0 and 0) or (n > m and m) or n end -function widget:KeyPress(key, mods, isRepeat) - if - show and key == KEYSYMS.ESCAPE - or (key == KEYSYMS.F11 and not isRepeat and not (mods.alt or mods.ctrl or mods.meta or mods.shift)) - then - if key == KEYSYMS.ESCAPE and inputText and inputText ~= "" then - clearChatInput() - else - local newShow = not show - if newShow and WG.topbar then - WG.topbar.hideWindows() - end - show = newShow - if - show - and not ( - BAR.Utilities.IsDevMode() - or BAR.Utilities.ShowDevUI() - or Spring.GetConfigInt("widgetselector", 0) == 1 - or localWidgetCount > 0 - ) - then - show = false - end - if show then - widgetHandler.textOwner = widget --widgetHandler:OwnText() - Spring.SDLStartTextInput() -- because: touch chobby's text edit field once and widget:TextInput is gone for the game, so we make sure its started! - Spring.SetConfigInt("widgetselector", 1) - else - Spring.SDLStopTextInput() - widgetHandler.textOwner = nil --widgetHandler:DisownText() - end - end - return true - end - if show and key == KEYSYMS.PAGEUP then - ScrollUp(pageStep) - return true - end - if show and key == KEYSYMS.PAGEDOWN then - ScrollDown(pageStep) - return true +local function sidebarIndexAt(x, y) + local top = sidebarTop() + if x < area.x1 or x > area.x1 + metrics.sidebarW or y > top or y <= categoryBottom() then + return nil end - --return false - if not show then - return false + local i = mathFloor((top - y) / metrics.catRowHeight) + 1 + catScroll + if not categories[i] then + return nil end - if key >= 282 and key <= 293 then -- Function keys - return false + local _, y1 = categoryRect(i) + if y1 < categoryBottom() then + return nil end - --local alt, ctrl, _, shift = Spring.GetModKeyState() - if key == 27 then -- ESC - clearChatInput() - elseif key == 8 then -- BACKSPACE - if inputTextPosition > 0 then - inputText = utf8.sub(inputText, 1, inputTextPosition - 1) .. utf8.sub(inputText, inputTextPosition + 1) - inputTextPosition = inputTextPosition - 1 - end - cursorBlinkTimer = 0 - if inputText == "" then - clearChatInput() - else - UpdateList(true) - end - elseif key == 127 then -- DELETE - if inputTextPosition < utf8.len(inputText) then - inputText = utf8.sub(inputText, 1, inputTextPosition) .. utf8.sub(inputText, inputTextPosition + 2) - end - cursorBlinkTimer = 0 - UpdateList(true) - elseif key == 277 then -- INSERT - inputTextInsertActive = not inputTextInsertActive - elseif key == 276 then -- LEFT - inputTextPosition = inputTextPosition - 1 - if inputTextPosition < 0 then - inputTextPosition = 0 - end - cursorBlinkTimer = 0 - elseif key == 275 then -- RIGHT - inputTextPosition = inputTextPosition + 1 - if inputTextPosition > utf8.len(inputText) then - inputTextPosition = utf8.len(inputText) - end - cursorBlinkTimer = 0 - elseif key == 278 or key == 280 then -- HOME / PGUP - inputTextPosition = 0 - cursorBlinkTimer = 0 - elseif key == 279 or key == 281 then -- END / PGDN - inputTextPosition = utf8.len(inputText) - cursorBlinkTimer = 0 - elseif key == 273 then -- UP - elseif key == 274 then -- DOWN - elseif key == 9 then -- TAB + return i +end + +-- Rebuilds every rect against the panel size. Whole pixels throughout, so glyph and +-- rectangle edges do not land between pixels. +setLayout = function() + local s = widgetScale + local pad = mathFloor(8 * s) + area.x1 = screenX + pad + area.y1 = screenY - screenHeight + pad + area.x2 = screenX + screenWidth - pad + area.y2 = screenY - pad + + metrics.rowHeight = mathFloor(24 * s) + metrics.catRowHeight = mathFloor(29 * s) + metrics.rowFs = mathFloor(metrics.rowHeight * 0.55) + metrics.catFs = mathFloor(metrics.catRowHeight * 0.55 * 0.85) + metrics.accentW = mathMax(2, mathFloor(3 * s)) + metrics.rowPad = mathFloor(6 * s) + metrics.sidePad = mathFloor(12 * s) + metrics.catInset = mathFloor(4 * s) + metrics.edgeInset = mathFloor(4 * s) + metrics.headerH = mathFloor(34 * s) + metrics.headerGap = mathFloor(4 * s) + metrics.footerH = mathFloor(38 * s) + metrics.footerGap = mathFloor(6 * s) + metrics.buttonGap = mathFloor(6 * s) + metrics.listGap = mathFloor(12 * s) + metrics.cardLip = mathFloor(5 * s) + metrics.titleY = mathFloor(17 * s) + metrics.titleFs = mathFloor(metrics.rowHeight * 0.85) + metrics.sidebarDrop = mathFloor(8 * s) + metrics.sidebarW = mathFloor(240 * s) + metrics.barW = mathFloor(14 * s) + metrics.catBarW = mathMax(3, mathFloor(6 * s)) + metrics.csPanel = mathFloor(elementCorner) + metrics.csSmall = mathFloor(elementCorner * 0.66) + metrics.csButton = mathFloor(elementCorner * 0.66) + + listX1 = area.x1 + metrics.sidebarW + metrics.listGap + -- The switch owns a column at the head of the row, and the name starts after it. + metrics.switchH = mathFloor(metrics.rowHeight * 0.46) + metrics.switchW = mathFloor(metrics.switchH * 2.2) + -- 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. + metrics.switchGap = mathFloor((metrics.rowHeight - metrics.switchH) * 0.5) + switchX1 = listX1 + metrics.accentW + metrics.switchGap + -- The rank gets a column of its own only while the list is in that order: a number + -- nobody is reading is clutter, and the name is worth the room. + orderX1 = switchX1 + metrics.switchW + metrics.rowPad * 2 + metrics.orderW = 0 + 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) + listTop = area.y2 - metrics.headerH - metrics.headerGap + local footerTop = area.y1 + metrics.footerH + listBottom = footerTop + metrics.footerGap + barX1 = area.x2 - metrics.edgeInset - metrics.barW + listRight = barX1 - metrics.listGap + descX1 = listX1 + mathFloor((listRight - listX1) * metrics.descSplit) + + -- The header band: the switches against the right edge, and the search field takes + -- whatever width they leave it. + local rowTop = area.y2 - mathFloor(4 * s) + local rowBottom = area.y2 - metrics.headerH + mathFloor(4 * s) + local fs = mathFloor((rowTop - rowBottom) * 0.5) + local togW = mathFloor(38 * s) + local togH = mathFloor((rowTop - rowBottom) * 0.62) + local togY = mathFloor((rowTop + rowBottom) * 0.5) + local togY1 = togY - mathFloor(togH * 0.5) + metrics.toggleFs = mathFloor(metrics.rowFs * 1.05) + -- Outlined text spreads past the box it is measured in, so the caption's first glyph + -- already sits a little left of where its advance box starts. This buys the caption + -- side back the room its outline took, so the hover plate opens the same on both ends. + metrics.captionBleed = mathFloor(metrics.toggleFs * 0.2 + 0.5) + + local x2 = area.x2 - metrics.edgeInset + for i = 1, #switches do + local sw = switches[i] + sw.label = L[sw.key] + local w = font and mathFloor(font:GetTextWidth(sw.label) * metrics.toggleFs) or mathFloor(90 * s) + sw.draw = { x2 - togW, togY1, x2, togY1 + togH } + -- The caption is part of the control: a switch this small is a poor click target on + -- its own, and the words beside it are what names the thing being switched. + sw.hit = { sw.draw[1] - metrics.rowPad * 2 - w - metrics.captionBleed, rowBottom, x2 + metrics.rowPad, rowTop } + x2 = sw.hit[1] - mathFloor(14 * s) + end + + -- Wider than the gaps inside a switch, so the last caption reads as belonging to the + -- switch beside it rather than to the field it would otherwise sit against. + searchBox:setRect(listX1, rowBottom, switches[#switches].hit[1] - mathFloor(28 * s), rowTop, fs) + + -- The sets block, measured up from the foot of the category card. + local setsPad = mathFloor(8 * s) + local pickH = mathFloor(metrics.rowHeight * 1.1) + local setBtnH = mathFloor(metrics.rowHeight * 1.0) + metrics.setsFs = mathFloor(metrics.rowHeight * 0.5) + -- One row of buttons without a set picked, two with. + local buttonRows = pickedSet and 2 or 1 + local topOfButtons + setsTop = listBottom + setsPad * (buttonRows + 2) + pickH + setBtnH * buttonRows + metrics.catRowHeight + + local sx1 = area.x1 + metrics.catInset + setsPad + local sx2 = area.x1 + metrics.sidebarW - metrics.catInset - setsPad + -- Save and Delete share a row; Load takes one of its own above them, being the one + -- reached for most and the one whose label must not be cut at a narrow sidebar. + local pairTop = listBottom + setsPad + setBtnH + local half = mathFloor((sx2 - sx1 - setsPad) * 0.5) + if pickedSet then + local loadTop = pairTop + setsPad + setBtnH + setButtons[1].rect = { sx1, loadTop - setBtnH, sx2, loadTop } + setButtons[2].rect = { sx1, pairTop - setBtnH, sx1 + half, pairTop } + setButtons[3].rect = { sx2 - half, pairTop - setBtnH, sx2, pairTop } + topOfButtons = loadTop else - -- regular chars/keys handled in widget:TextInput + -- Nothing picked, so Save is the only button: it takes the whole row on its own + -- rather than half of one with a hole beside it and an empty row above. The other + -- two lose their rects outright, so a stale one from the last layout cannot be hit. + setButtons[1].rect = nil + setButtons[2].rect = { sx1, pairTop - setBtnH, sx2, pairTop } + setButtons[3].rect = nil + topOfButtons = pairTop + end + setPicker:setRect(sx1, topOfButtons + setsPad, sx2, topOfButtons + setsPad + pickH, metrics.setsFs) + metrics.setsCaptionY = topOfButtons + setsPad + pickH + mathFloor(metrics.catRowHeight * 0.5) + + -- The footer: the actions, spread evenly across the width. Held off both edges by the + -- same inset the scrollbar keeps against the right one, so the outermost buttons do + -- not sit against the panel's rounded corners. + local n = #buttons + if n > 0 then + local fx1 = area.x1 + metrics.edgeInset + local fx2 = area.x2 - metrics.edgeInset + local bw = mathFloor(((fx2 - fx1) - (n - 1) * metrics.buttonGap) / n) + local by1 = area.y1 + mathFloor(4 * s) + local by2 = by1 + metrics.footerH - mathFloor(8 * s) + for i = 1, n do + local bx1 = fx1 + (i - 1) * (bw + metrics.buttonGap) + -- The last one takes whatever the division left over, so the row ends flush with + -- the inset rather than a pixel or two short of it. + buttons[i].rect = { bx1, by1, (i == n) and fx2 or (bx1 + bw), by2 } + end end + metrics.buttonFs = mathFloor(metrics.rowHeight * 0.55) + -- 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) - updateTextInputDlist = true - return true + if dialog then + dialogGeometry() + end + + setCatScroll(catScroll) + layoutGen = layoutGen + 1 + clampScroll() end -function widget:Update(dt) - if not show then - return - end - cursorBlinkTimer = cursorBlinkTimer + dt - if cursorBlinkTimer > cursorBlinkDuration then - cursorBlinkTimer = 0 +-- 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) + 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.fitGen = layoutGen end end -function widget:DrawScreen() - if not show then - if WG.guishader and activeGuishader then - activeGuishader = false - WG.guishader.RemoveDlist("widgetselector") - WG.guishader.RemoveDlist("widgetselector2") - WG.guishader.RemoveRect("selectorinput") - if textInputDlist then - textInputDlist = gl.DeleteList(textInputDlist) - end +-- Cuts a row's name and description to their columns, once per row per layout. +local function fitRow(row) + if row.fitGen == layoutGen then + return + end + row.fitGen = layoutGen + + local nameColor = colorName + local descColor = colorDesc + if row.state == 1 then + nameColor, descColor = colorNameOn, colorDescOn + elseif row.state == 0.5 then + nameColor, descColor = colorPending, colorDescOn + end + + if metrics.orderW > 0 then + -- Nothing running has no place in the order, and a dash says that better than a gap. + row.fitOrder = colorDim .. (row.order and tostring(row.order) or "-") + end + local nameW = descX1 - nameX1 - metrics.rowPad + row.fitName = nameColor .. text.fit(font, row.name, nameW, metrics.rowFs) + if row.desc ~= "" then + -- A local row ends with its tag, so the description stops short of it rather than + -- running underneath. + local descW = listRight - descX1 - metrics.rowPad * 2 + if row.isLocal then + descW = descW - metrics.localTagW - metrics.rowPad end + row.fitDesc = descColor .. text.fit(font, row.desc, descW, metrics.rowFs) + else + row.fitDesc = nil + end +end + +---------------------------------------------------------------- +-- Drawing +---------------------------------------------------------------- + +local textQueue = {} + +local function queueText(str, x, y, size, opts) + textQueue[#textQueue + 1] = { str, x, y, size, opts } +end + +local function flushText() + if #textQueue == 0 then return end + font:Begin() + for i = 1, #textQueue do + local t = textQueue[i] + font:Print(t[1], t[2], t[3], t[4], t[5]) + end + font:End() + textQueue = {} +end - if not WG.guishader then - activeGuishader = false - end - - local mx, my, lmb, mmb, rmb = spGetMouseState() - - UpdateList() - - local bg1 = floor(minx - (bgPadding * sizeMultiplier)) - local bg2 = floor(miny - (bgPadding * sizeMultiplier)) - local bg3 = floor(maxx + (bgPadding * sizeMultiplier)) - local bg4 = floor(maxy + (bgPadding * sizeMultiplier)) - if - not backgroundRect - or bg1 ~= backgroundRect[1] - or bg2 ~= backgroundRect[2] - or bg3 ~= backgroundRect[3] - or bg4 ~= backgroundRect[4] - then - backgroundRect = { bg1, bg2, bg3, bg4 } - updateUi = true - end - - borderx = (yStep * sizeMultiplier) * 0.75 - bordery = (yStep * sizeMultiplier) * 0.75 - - if updateUi then - updateTextInputDlist = true - local title = BAR.I18N("ui.widgetselector.title") - local titleFontSize = 18 * widgetScale - titleRect = { - backgroundRect[1], - backgroundRect[4], - mathFloor(backgroundRect[1] + (font2:GetTextWidth(title) * titleFontSize) + (titleFontSize * 1.5)), - mathFloor(backgroundRect[4] + (titleFontSize * 1.7)), - } - dlistGuishader = gl.DeleteList(dlistGuishader) - dlistGuishader = gl.CreateList(function() - RectRound( - floor(minx - (bgPadding * sizeMultiplier)), - floor(miny - (bgPadding * sizeMultiplier)), - floor(maxx + (bgPadding * sizeMultiplier)), - floor(maxy + (bgPadding * sizeMultiplier)), - 6 * sizeMultiplier +local function drawRow(row, top, bottom, hovered, overSwitch) + 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) + 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) + end + if hovered then + Highlight(listX1, bottom, listRight, top, metrics.csSmall, look.rowHoverOpacity, look.white) + end + + local ty = mathFloor((top + bottom) * 0.5) + -- The switch says the state on its own, the way a bool row in the settings does: green + -- knob to the right for running, amber in the middle for enabled but not running, and + -- the off colour to the left for off. + local sy = ty - mathFloor(metrics.switchH * 0.5) + UiToggle(switchX1, sy, switchX1 + metrics.switchW, sy + metrics.switchH, row.state, overSwitch) + if metrics.orderW > 0 then + -- 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 + queueText(row.fitName, nameX1, ty, metrics.rowFs, "ov") + if row.fitDesc then + queueText(row.fitDesc, descX1, ty, metrics.rowFs, "ov") + end + 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, listRight - metrics.rowPad, ty, metrics.rowFs, "rov") + end +end + +local function drawRows() + for i = 1, #rows - scroll do + local row = rows[scroll + i] + if not row then + break + end + local top = listTop - (i - 1) * metrics.rowHeight + local bottom = top - metrics.rowHeight + if bottom < listBottom then + break + end + drawRow(row, top, bottom, hover.row == i, hover.row == i and hover.sw == 1) + end +end + +local function drawButtonFace(r, fill) + local pair = look.gradients[fill] + + UiButton(r[1], r[2], r[3], r[4], 1, 1, 1, 1, 1, 1, 1, 1, nil, pair[1], pair[2]) +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") + + for _, b in ipairs(setButtons) do + -- Load and Delete are not drawn at all without a set picked: a button that can do + -- nothing is worse than no button. + if b.rect then + local hovered = hover.btn == b.id + drawButtonFace(b.rect, look.buttonFill) + if hovered then + Highlight(b.rect[1], b.rect[2], b.rect[3], b.rect[4], metrics.csButton, look.hoverOpacity, look.white) + end + queueText( + colorText .. L[b.id], + mathFloor((b.rect[1] + b.rect[3]) * 0.5), + mathFloor((b.rect[2] + b.rect[4]) * 0.5), + metrics.setsFs, + "cov" ) - end) - dlistGuishader2 = gl.DeleteList(dlistGuishader2) - dlistGuishader2 = gl.CreateList(function() - RectRound(titleRect[1], titleRect[2], titleRect[3], titleRect[4], 6 * sizeMultiplier) - end) + end + end +end - uiList = gl.DeleteList(uiList) - uiList = gl.CreateList(function() - UiElement( - backgroundRect[1], - backgroundRect[2], - backgroundRect[3], - backgroundRect[4], - 0, - 1, - 1, - 0, +local function drawSidebar() + RectRound( + area.x1, + listBottom, + area.x1 + metrics.sidebarW, + sidebarTop() + metrics.cardLip, + 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 + fitCategories() + end + + for i = catScroll + 1, #categories do + local c = categories[i] + local x1, y1, x2, y2 = categoryRect(i) + if y1 < categoryBottom() then + break + end + local selected = selectedCategory == c.key + if selected then + RectRound( + x1 + metrics.catInset, + y1, + x2 - metrics.catInset, + y2, + metrics.csSmall, 1, 1, 1, 1, - WG.FlowUI.clampedOpacity + look.selectedFill + ) + elseif i == hover.sb then + Highlight( + x1 + metrics.catInset, + y1, + x2 - metrics.catInset, + y2, + metrics.csSmall, + look.rowHoverOpacity, + look.white ) + end + local ty = mathFloor((y1 + y2) * 0.5) + queueText(selected and c.textSel or c.textDim, x1 + metrics.sidePad, ty, metrics.catFs, "ov") + queueText(c.countText, x2 - metrics.sidePad, ty, metrics.catFs, "rov") + end + + -- A bar of its own, and a slim one: the column is narrow and this only appears when + -- there are more categories than the card has room for. + if maxCatScroll() > 0 then + local bx2 = area.x1 + metrics.sidebarW - metrics.catInset + UiScroller( + bx2 - metrics.catBarW, + categoryBottom(), + bx2, + sidebarTop(), + #categories * metrics.catRowHeight, + catScroll * metrics.catRowHeight + ) + end +end + +-- One switch and its caption. The plate goes behind the switch and the switch lights +-- itself: at the plate's opacity the switch has one of its own bright enough to swallow +-- it, and painting over the switch only dulls it. +local function drawSwitch(draw, hit, label, state, hovered) + if hovered then + Highlight(hit[1], hit[2], hit[3], hit[4], metrics.csSmall, look.rowHoverOpacity, look.white) + end + UiToggle(draw[1], draw[2], draw[3], draw[4], state, hovered) + queueText( + (state and colorSelected or colorDim) .. label, + draw[1] - metrics.rowPad, + mathFloor((hit[2] + hit[4]) * 0.5), + metrics.toggleFs, + "rov" + ) +end + +local function drawHeader() + for i = 1, #switches do + local sw = switches[i] + drawSwitch(sw.draw, sw.hit, sw.label, filters[sw.key], hover.tog == i) + end +end - -- title background - gl.Color(0, 0, 0, WG.FlowUI.clampedOpacity) - RectRound(titleRect[1], titleRect[2], titleRect[3], titleRect[4], elementCorner, 1, 1, 0, 0) - - -- title - font2:Begin() - font2:SetTextColor(1, 1, 1, 1) - font2:SetOutlineColor(0, 0, 0, 0.4) - font2:Print( - title, - backgroundRect[1] + (titleFontSize * 0.75), - backgroundRect[4] + (8 * widgetScale), - titleFontSize, - "on" +local function drawFooter() + for _, b in ipairs(buttons) do + 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 + 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. + if hovered and not fill then + 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 ""), + mathFloor((r[1] + r[3]) * 0.5), + mathFloor((r[2] + r[4]) * 0.5), + metrics.buttonFs, + "cov" ) - font2:End() - end) + end end +end - if WG.guishader and not activeGuishader then - activeGuishader = true - if dlistGuishader then - WG.guishader.InsertDlist(dlistGuishader, "widgetselector", nil, widget) - WG.guishader.InsertDlist(dlistGuishader2, "widgetselector2", nil, widget) +local function drawDialog(d) + local bx1, by1, bx2, by2 = dialogGeometry() + local s = widgetScale + local cx = mathFloor((bx1 + bx2) * 0.5) + local tfs = mathFloor(metrics.rowHeight * 0.6) + local sfs = mathFloor(metrics.rowHeight * 0.5) + + -- Everything behind it dims, so the modal is plainly the only thing that will answer. + RectRound(area.x1, area.y1, area.x2, area.y2, 0, 0, 0, 0, 0, look.scrim) + UiElement(bx1, by1, bx2, by2, 1, 1, 1, 1, 1, 1, 1, 1, WG.FlowUI.clampedOpacity) + + -- With nothing typed there is nothing to save, so the accept is not drawn at all: a + -- button that cannot do anything is worse than no button. + local _, blocked = dialogName() + 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 } + end + for _, b in ipairs(buttons) do + local hovered = hover.dlg == b.id + local base = (b.danger and look.dangerFill) or (b.confirm and look.confirmFill) + local lift = (b.danger and look.dangerFillHover) or (b.confirm and look.confirmFillHover) + local fill = base and (hovered and lift or base) + drawButtonFace(b.r, fill or look.buttonFill) + if hovered and not fill then + Highlight(b.r[1], b.r[2], b.r[3], b.r[4], metrics.csButton, look.hoverOpacity, look.white) end end - local aboveWidget = aboveLabel(mx, my) - local pointedName = (aboveWidget and aboveWidget[1]) or nil - if pointedName ~= prevPointedName then - updateUiList2 = true - end - prevPointedName = pointedName - - if - prevLmb ~= lmb - and math.isInRect(mx, my, backgroundRect[1], backgroundRect[2], backgroundRect[3], backgroundRect[4]) - then - updateUiList2 = true - end - prevLmb = lmb - - -- content - if updateUi or updateUiList2 then - uiList2 = gl.DeleteList(uiList2) - uiList2 = gl.CreateList(function() - font:Begin() - - -- draw the widgets - local pointedY = nil - local posy = maxy - ((yStep + bgPadding) * sizeMultiplier) - sby1 = posy + ((fontSize + fontSpace) * sizeMultiplier) * 0.5 - local prevFromZip = true - local customWidgetPosy - for _, namedata in ipairs(widgetsList) do - local name = namedata[1] - local data = namedata[2] - - if prevFromZip ~= data.fromZip then - customWidgetPosy = posy - font2:SetTextColor(0.5, 0.5, 0.5, 0.4) - font2:Print( - BAR.I18N("ui.widgetselector.islocal"), - minx + fontSize * sizeMultiplier * 0.25, - posy + (fontSize * sizeMultiplier) * 0.33, - fontSize * sizeMultiplier, - "" - ) - end + font:Begin() + 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) + -- Centred in the band the title and the buttons leave, not in the whole box: centring + -- on the box puts the text low, since the buttons take more room than the title. + local bandTop = by2 - mathFloor(26 * s) - tfs + local bandBottom = (d.field and dialogField[4] or dialogOk[4]) + mathFloor(8 * s) + local top = mathFloor((bandTop + bandBottom) * 0.5 + (#lines - 1) * step * 0.5) + for i = 1, #lines do + font:Print(colorDim .. lines[i], cx, top - (i - 1) * step, sfs, "cov") + end + font:Print( + colorText .. L.cancel, + mathFloor((dialogCancel[1] + dialogCancel[3]) * 0.5), + mathFloor((dialogCancel[2] + dialogCancel[4]) * 0.5), + sfs, + "cov" + ) + if not blocked then + font:Print( + (d.danger and colorDanger or colorText) .. (d.field and L.save or L.confirm), + mathFloor((dialogOk[1] + dialogOk[3]) * 0.5), + mathFloor((dialogOk[2] + dialogOk[4]) * 0.5), + sfs, + "cov" + ) + end + font:End() + + if d.field then + -- Sized here, drawn in DrawScreen: the panel around it is a display list replayed + -- until something moves, and a field being typed into moves every frame. + nameBox:setRect(dialogField[1], dialogField[2], dialogField[3], dialogField[4], sfs) + end +end - local color = "" - local pointed = (pointedName == name) - local order = widgetHandler.orderList[name] - local enabled = order and (order > 0) - local active = data.active - if pointed and not activescrollbar then - pointedY = posy - if not pagestepped and (lmb or mmb or rmb) then - color = WhiteStr - else - color = (active and "\255\128\255\128") - or (enabled and "\255\255\255\128") - or "\255\255\128\128" - end - else - color = (active and "\255\064\224\064") or (enabled and "\255\200\200\064") or "\255\224\064\064" - end - prevFromZip = data.fromZip - font:Print( - color .. name, - midx, - posy + (fontSize * sizeMultiplier) * 0.5, - fontSize * sizeMultiplier, - "vc" - ) - posy = posy - (yStep * sizeMultiplier) - end - if customWidgetPosy then - gl.Color(1, 1, 1, 0.07) - RectRound( - backgroundRect[1] + elementPadding, - customWidgetPosy + mathFloor(yStep * sizeMultiplier * 0.85), - backgroundRect[3] - elementPadding, - customWidgetPosy + mathFloor(yStep * sizeMultiplier * 0.85) - 1, - 0, - 0, - 0, - 0, - 0 - ) - gl.Color(1, 1, 1, 0.035) - RectRound( - backgroundRect[1] + elementPadding, - backgroundRect[2] + elementPadding, - backgroundRect[3] - elementPadding, - customWidgetPosy + mathFloor(yStep * sizeMultiplier * 0.85), - elementPadding, - 0, - 0, - 1, - 0 - ) - end +local function drawPanel() + drawSidebar() + drawSetsBlock() + drawHeader() + drawRows() + drawFooter() + flushText() + + if #rows > 0 then + UiScroller( + barX1, + listBottom, + area.x2 - metrics.edgeInset, + listTop, + #rows * metrics.rowHeight, + scroll * metrics.rowHeight, + hover.bar == 1, + dragging + ) + end - -- scrollbar - if #widgetsList < #fullWidgetsList then - sby2 = posy + (yStep * sizeMultiplier) - (fontSpace * sizeMultiplier) * 0.5 - sbheight = sby1 - sby2 - sbsize = sbheight * #widgetsList / #fullWidgetsList - if activescrollbar then - startEntry = mathMax( - 0, - mathMin( - floor( - #fullWidgetsList - * ((sby1 - sbsize) - (my - mathMin(scrollbargrabpos, sbsize))) - / sbheight - + 0.5 - ), - #fullWidgetsList - curMaxEntries - ) - ) + 1 - end - local sizex = maxx - minx - sbposx = minx + sizex + 1.0 + (scrollbarOffset * widgetScale) - sbposy = sby1 - sbsize - sbheight * (startEntry - 1) / #fullWidgetsList - sbsizex = (yStep * sizeMultiplier) - sbsizey = sbsize - - local scrollerPadding = 8 * sizeMultiplier - - -- background - if (sbposx < mx and mx < sbposx + sbsizex and miny < my and my < maxy) or activescrollbar then - RectRound( - sbposx, - miny, - sbposx + (sbsizex * 0.61), - maxy, - 4.5 * sizeMultiplier, - 1, - 1, - 1, - 1, - { 0.2, 0.2, 0.2, 0.2 }, - { 0.5, 0.5, 0.5, 0.2 } - ) - end +end - -- scroller - if sbposx < mx and mx < sbposx + sbsizex and sby2 < my and my < sby2 + sbheight then - gl.Color(1, 1, 1, 0.1) - gl.Blending(GL.SRC_ALPHA, GL.ONE) - RectRound( - sbposx + scrollerPadding, - sbposy, - sbposx + sbsizex - scrollerPadding, - sbposy + sbsizey, - 1.75 * sizeMultiplier - ) - gl.Blending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) - end - gl.Color(0.33, 0.33, 0.33, 0.8) - RectRound( - sbposx + scrollerPadding, - sbposy, - sbposx + sbsizex - scrollerPadding, - sbposy + sbsizey, - 1.75 * sizeMultiplier - ) - else - sbposx = 0.0 - sbposy = 0.0 - sbsizex = 0.0 - sbsizey = 0.0 +local function drawWindow() + UiElement( + screenX, + screenY - screenHeight, + screenX + screenWidth, + screenY, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + WG.FlowUI.clampedOpacity + ) +end + +local function dropLists() + if panelList then + glDeleteList(panelList) + panelList = nil + panelSig = nil + end + if windowList then + glDeleteList(windowList) + windowList = nil + end +end + +-- What is blurred behind the things that float over the panel: the modal, and the +-- picker's list while it is down. Both are drawn after the panel and over whatever +-- happens to be under them, so they get the same treatment the tooltip gives itself. +-- +-- Only touched when the rect actually changes: inserting one marks the screen stencil +-- dirty, and doing that every frame would have it rebuilt every frame. +local shaded = {} + +local function shadeRect(name, x1, y1, x2, y2) + if not WG.guishader then + return + end + local was = shaded[name] + if x1 then + if not (was and was[1] == x1 and was[2] == y1 and was[3] == x2 and was[4] == y2) then + WG.guishader.InsertScreenRect(x1, y1, x2, y2, "widgetselector_" .. name, widget) + shaded[name] = { x1, y1, x2, y2 } + end + elseif was then + WG.guishader.RemoveScreenRect("widgetselector_" .. name) + shaded[name] = nil + end +end + +-- Content that has to stay crisp above its own blur. +-- +-- widgetHandler draws the DrawScreen list in reverse layer order, so this widget (layer +-- 999999) draws first and gfx_guishader (-990000) draws its blur pass last, over +-- everything. Anything of ours sitting inside one of the rects above would be blurred +-- along with what is behind it. gui_options has the same problem with its select list and +-- solves it by handing the drawing to guishader, which replays it after the blur; the +-- other way out is gui_tooltip's, sitting below guishader's layer so it draws last, but +-- that would mean moving this whole panel. +-- +-- Rebuilt per frame: a modal carries a blinking caret and the picker's list lights the +-- option under the cursor, so there is nothing static to hold on to. +local floatLists = {} + +local function dropFloat(name) + local list = floatLists[name] + if list then + if WG.guishader then + WG.guishader.removeRenderDlist(list) + end + glDeleteList(list) + floatLists[name] = nil + end +end + +local function drawFloating(name, fn) + if not (WG.guishader and WG.guishader.insertRenderDlist) then + -- No blur will be drawn over it, so there is nothing to hand over. + fn() + return + end + dropFloat(name) + floatLists[name] = glCreateList(fn) + WG.guishader.insertRenderDlist(floatLists[name]) +end + +local function updateShading() + if dialog and dialogBox[1] then + shadeRect("dialog", dialogBox[1], dialogBox[2], dialogBox[3], dialogBox[4]) + else + shadeRect("dialog") + end + + -- The list the picker drops, which stands clear of the control and over the rows. + local opts = setPicker and setPicker:isOpen() and setPicker.optRects + if opts and opts[1] then + shadeRect("picker", opts[1].x1, opts[#opts].y1, opts[1].x2, opts[1].y2) + else + shadeRect("picker") + end +end + +local function deleteGuishader() + shadeRect("dialog") + shadeRect("picker") + dropFloat("dialog") + dropFloat("picker") + if backgroundGuishader ~= nil then + if WG.guishader then + WG.guishader.DeleteDlist("widgetselector") + else + glDeleteList(backgroundGuishader) + end + backgroundGuishader = nil + end +end + +-- Reads the hover state and answers a signature of everything the baked panel is painted +-- from. Same signature, same picture, so the display list is replayed as it is. +-- The accept button in a dialog appears the moment there is a name to save under, and +-- it is painted into the baked panel, so whether the field is empty is part of this. +local function panelSignature(mx, my) + hover.sb, hover.row, hover.sw, hover.tog, hover.bar = 0, 0, 0, 0, 0 + hover.btn, hover.dlg = "", "" + + if dialog then + -- A modal takes the cursor outright: lighting anything behind it would say it could + -- still be clicked. + local _, blocked = dialogName() + if not blocked and math_isInRect(mx, my, dialogOk[1], dialogOk[2], dialogOk[3], dialogOk[4]) then + hover.dlg = "ok" + elseif math_isInRect(mx, my, dialogCancel[1], dialogCancel[2], dialogCancel[3], dialogCancel[4]) then + hover.dlg = "cancel" + end + else + hover.sb = sidebarIndexAt(mx, my) or 0 + for i = 1, #switches do + local h = switches[i].hit + if h and math_isInRect(mx, my, h[1], h[2], h[3], h[4]) then + hover.tog = i end + end - -- highlight label - if (sbposx < mx and mx < sbposx + sbsizex and miny < my and my < maxy) or activescrollbar then - else - if pointedY then - local xn = minx + 0.5 - local xp = maxx - 0.5 - local yn = pointedY - ((fontSpace * 0.5 + 1) * sizeMultiplier) - local yp = pointedY + ((fontSize + fontSpace * 0.5 + 1) * sizeMultiplier) - if scrollbarOffset < 0 then - xp = xp + scrollbarOffset - --xn = xn - scrollbarOffset - end - yn = yn + 0.5 - yp = yp - 0.5 - gl.Blending(GL.SRC_ALPHA, GL.ONE) - UiSelectHighlight( - mathFloor(xn), - mathFloor(yn), - mathFloor(xp), - mathFloor(yp), - nil, - lmb and 0.18 or 0.11 - ) - gl.Blending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) + if hover.tog ~= 0 then + -- A switch has it; nothing behind it lights. + elseif mx >= listX1 and mx <= listRight then + hover.row = rowAt(my) or 0 + -- The switch lights on its own, so it is plain that it is the thing being pointed + -- at rather than the row behind it. + if hover.row > 0 and mx >= switchX1 - metrics.rowPad and mx <= nameX1 - metrics.rowPad then + hover.sw = 1 + end + elseif mx >= barX1 and mx <= area.x2 then + local top, height = scrollerThumb() + if top and my <= top and my >= top - height then + hover.bar = 1 + end + end + for _, set in ipairs({ buttons, setButtons }) do + for _, b in ipairs(set) do + local r = b.rect + if r and math_isInRect(mx, my, r[1], r[2], r[3], r[4]) then + hover.btn = b.id end end - font:End() - end) + end end - updateUi = false - updateUiList2 = false + return hover.sb + .. "|" + .. hover.row + .. "|" + .. hover.sw + .. "|" + .. hover.tog + .. "|" + .. hover.bar + .. "|" + .. hover.btn + .. "|" + .. hover.dlg + .. "|" + .. scroll + .. "|" + .. rowsGen + .. "|" + .. layoutGen + .. "|" + .. catScroll + .. "|" + .. (dragging and 1 or 0) + .. "|" + .. (dialog and 1 or 0) + .. "|" + .. (select(2, dialogName()) and 1 or 0) +end + +---------------------------------------------------------------- +-- Callins +---------------------------------------------------------------- - if uiList then - gl.CallList(uiList) +-- Has anything been switched on or off since the rows were built? +-- +-- Asking every frame the panel is open is what it takes. The handler queues Toggle, +-- Enable and Disable and runs them once the callin that asked has returned, so the state +-- cannot be read back on the click itself; and a widget can be switched from somewhere +-- else entirely - the settings panel, a /luaui command, one erroring out on load - which +-- nothing here would otherwise hear about. `knownChanged` does not cover it: the handler +-- raises that only when a widget it has never seen registers. +-- +-- A few hundred table lookups on a frame where a panel is being looked at. +local function contentMoved() + for i = 1, #entries do + local e = entries[i] + if e.state ~= stateOf(e.name, e.data) then + return true + end end - if uiList2 then - gl.CallList(uiList2) + -- And the load order, which moves without any state changing: raising or lowering a + -- widget only shifts it within the handler's list, and that is queued like the rest. + for i = 1, #widgetHandler.widgets do + local w = widgetHandler.widgets[i] + local e = w.whInfo and entryByName[w.whInfo.name] + if e and e.order ~= i then + return true + end end - if showButtons then - font:Begin() - local tcol - for i, name in ipairs(buttons) do - tcol = WhiteStr - if - minx < mx - and mx < maxx - and miny - (buttonTop * sizeMultiplier) - i * (buttonHeight * sizeMultiplier) < my - and my < miny - (buttonTop * sizeMultiplier) - (i - 1) * (buttonHeight * sizeMultiplier) - then - tcol = "\255\031\031\031" + return false +end + +local function refreshContent() + buildContent() + -- The picker follows: a set can be saved or forgotten between one build and the next. + refreshSets() + rebuildRows() + clampScroll() +end + +local function loadLabels() + local function tr(key, fallback) + return BAR.I18N("ui.widgetselector." .. key, { default = fallback }) + end + + L.title = tr("title", "Widget Selector") + L.all = tr("category.all", "All") + L.interface = tr("category.interface", "Interface") + L.commands = tr("category.commands", "Commands") + L.units = tr("category.units", "Units") + L.graphics = tr("category.graphics", "Graphics") + L.camera = tr("category.camera", "Camera") + L.sound = tr("category.sound", "Sound") + L.map = tr("category.map", "Map") + L.minimap = tr("category.minimap", "Minimap") + L.api = tr("category.api", "API") + L.debug = tr("category.debug", "Debug") + L.other = tr("category.other", "Other") + + L.search = tr("search", "Search...") + L.localOnly = tr("localonly", "Local only") + L.enabledOnly = tr("enabledonly", "Enabled only") + L.byOrder = tr("byorder", "By load order") + L.sets = tr("sets", "Widget sets") + L.noSet = tr("noset", "No set") + L.loadset = tr("loadset", "Load") + L.saveset = tr("saveset", "Save") + L.deleteset = tr("deleteset", "Delete") + L.save = L.saveset + L.saveSetTitle = tr("savesettitle", "Save widget set") + L.saveSetWarn = tr( + "savesetwarn", + "Remembers which widgets are switched on right now under this name. Saving over a name you already have replaces it." + ) + L.deleteSetTitle = tr("deletesettitle", "Delete widget set") + L.deleteSetWarn = tr("deletesetwarn", "Forgets this set. The widgets it switched on stay as they are.") + L.factoryDefaults = tr("factorydefaults", "Factory defaults") + L.islocal = tr("islocal", "local") + L.stateOn = tr("state.on", "Running") + L.statePending = tr("state.pending", "Enabled, but not running") + L.stateOff = tr("state.off", "Off") + L.file = tr("file", "File") + L.author = tr("author", "Author") + + 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") + + L.factoryWarn = tr( + "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." + ) + L.allowUserWarn = tr( + "allowuserwarn", + "Loads widgets from your own LuaUI folder again alongside the ones the game ships. LuaUI reloads immediately." + ) + L.resetWarn = tr( + "resetwarn", + "Puts every widget back to the set the game enables by default. Widgets you added stay on disk. LuaUI reloads immediately." + ) + -- Said plainly, because raise and lower do not move a widget by one place: they send + -- it to the front or the back of the band of widgets sharing its layer, and it can + -- 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.layer = tr("layer", "Layer") +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, + danger = widgetHandler.allowUserWidgets, + }, + { id = "reset", label = L.reset, danger = true }, + { id = "factory", label = L.factoryDefaults, danger = true }, + } + if not allowuserwidgets then + table.remove(buttons, 3) + end +end + +-- True once FlowUI and the font handler are up and everything taken from them is bound. +-- Nothing is drawn or laid out before that. +local function bindUi() + if not (WG.FlowUI and WG.FlowUI.Draw and WG.FlowUI.elementCorner and WG.fonts and WG.fonts.getFont) then + return false + end + + font = WG.fonts.getFont() + elementCorner = WG.FlowUI.elementCorner + RectRound = WG.FlowUI.Draw.RectRound + UiElement = WG.FlowUI.Draw.Element + UiButton = WG.FlowUI.Draw.Button + UiScroller = WG.FlowUI.Draw.Scroller + UiScrollerAt = WG.FlowUI.Draw.ScrollerGeometry + Highlight = WG.FlowUI.Draw.SelectHighlight + UiToggle = WG.FlowUI.Draw.Toggle + + if not searchBox then + searchBox = Editbox.new({ + placeholder = L.search, + onChange = function() + setScroll(0) + rebuildRows() + end, + }) + nameBox = Editbox.new({}) + setPicker = Dropdown.new({ + placeholder = L.noSet, + onSelect = function(name) + -- Picked, not loaded: Load is its own button, so choosing a set to delete does + -- not switch every widget on the way past. + pickedSet = name + -- The placeholder is what the picker shows until something is picked, and it + -- wins over the selection while it is set. + refreshSets() + end, + }) + end + + uiBound = true + + return true +end + +function widget:ViewResize() + vsx, vsy = spGetViewGeometry() + widgetScale = (vsy / 1080) + + screenHeight = mathFloor(screenHeightOrg * widgetScale) + screenWidth = mathFloor(screenWidthOrg * widgetScale) + screenX = mathFloor((vsx * 0.5) - (screenWidth / 2)) + screenY = mathFloor((vsy * 0.5) + (screenHeight / 2)) + + if not (uiBound or bindUi()) then + return + end + + setLayout() + rebuildRows() + dropLists() + deleteGuishader() +end + +local function closePanel() + show = false + dialog = nil + if setPicker then + setPicker:close() + end + shadeRect("dialog") + shadeRect("picker") + dropFloat("dialog") + dropFloat("picker") + if searchBox then + searchBox:blur() + end + if WG.tooltip then + WG.tooltip.RemoveTooltip("widgetselector") + end +end + +local function setShow(state) + if state == show then + return + end + + if state then + -- Before `show` is set, not after: hideWindows closes every window that says it is + -- visible, and this panel is one of them. Setting the flag first makes it close the + -- one it is in the middle of opening, which looks exactly like nothing happening. + if WG.topbar then + WG.topbar.hideWindows() + end + end + + show = state + if show then + Spring.SetConfigInt("widgetselector", 1) + if not uiBound then + widget:ViewResize() + end + refreshContent() + else + closePanel() + end +end + +function widget:Initialize() + loadLabels() + buildButtons() + + widgetHandler.knownChanged = true + -- barwidgets binds F11 to `luaui selector`, which looks for a loaded widget whose + -- basename is exactly selector.lua and otherwise tries to load LuaUI/selector.lua. + -- Neither is this file, so the key does nothing at all. Pointed at the action instead, + -- which is the same thing /widgetselector reaches. + spSendCommands({ "unbindkeyset f11", "bind f11 widgetselector" }) + + -- Lets the handler hide the rest of the interface while the list is open. This widget + -- holds the real widgetHandler, so it passes itself. + widgetHandler:RegisterModalWindow(widget, function() + return show == true + end) + + WG.widgetselector = {} + WG.widgetselector.toggle = function(state) + local newShow = state + if newShow == nil then + newShow = not show + end + setShow(newShow and true or false) + end + WG.widgetselector.isvisible = function() + return show + end + WG.widgetselector.getLocalWidgetCount = function() + return localWidgetCount + end + + -- Deliberately before the layout: this is what /widgetselector and the top bar reach, + -- and it has to work whether or not FlowUI has loaded yet. + -- Text and key press both: typed as /widgetselector, and bound to a key by anyone who + -- would rather not use F11. + widgetHandler.actionHandler:AddAction(self, "widgetselector", function() + setShow(not show) + end, nil, "tp") + widgetHandler.actionHandler:AddAction(self, "factoryreset", function() + factoryReset() + end, nil, "t") + widgetHandler.actionHandler:AddAction(self, "userwidgets", function() + toggleUserWidgets() + end, nil, "t") + + widget:ViewResize() + refreshContent() +end + +function widget:Shutdown() + deleteGuishader() + dropLists() + if WG.tooltip then + WG.tooltip.RemoveTooltip("widgetselector") + end + WG.widgetselector = nil + if ownsInput then + ownsInput = false + disownText() + end + if textInputStarted then + textInputStarted = false + if Spring.SDLStopTextInput then + Spring.SDLStopTextInput() + end + end +end + +function widget:LanguageChanged() + loadLabels() + buildButtons() + if searchBox then + searchBox.placeholder = L.search + end + refreshContent() + setLayout() + dropLists() +end + +function widget:Update() + if widgetHandler.knownChanged then + widgetHandler.knownChanged = false + refreshContent() + elseif show and contentMoved() then + refreshContent() + end + + -- While a dialog is asking for a name it owns the keyboard too, or nothing typed + -- into it arrives. + local wantsInput = show + and uiBound + and ((dialog and dialog.field) or (not dialog and searchBox and searchBox:isFocused())) + if wantsInput then + if not fieldHasInput then + fieldHasInput = true + heldAtFocus = Spring.GetPressedKeys and Spring.GetPressedKeys() or {} + ownsInput = ownText() + + -- Chat has to be asked to let go, and toggling its input flag is the only public + -- way to make it cancel. The flag goes straight back because gui_chat persists it. + if not ownsInput and WG.chat and WG.chat.isInputActive and WG.chat.isInputActive() then + WG.chat.setHandleInput(false) + WG.chat.setHandleInput(true) + ownsInput = ownText() + end + elseif not ownsInput then + ownsInput = ownText() + end + + if ownsInput and not textInputStarted then + textInputStarted = true + if Spring.SDLStartTextInput then + Spring.SDLStartTextInput() + end + end + elseif fieldHasInput then + fieldHasInput = false + if ownsInput then + ownsInput = false + disownText() + end + if textInputStarted then + textInputStarted = false + if Spring.SDLStopTextInput then + Spring.SDLStopTextInput() end - font:Print( - tcol .. buttons[i], - (minx + maxx) / 2, - miny - (buttonTop * sizeMultiplier) - (i * (buttonHeight * sizeMultiplier)), - buttonFontSize * sizeMultiplier, - "oc" - ) end - font:End() + end +end + +function widget:DrawScreen() + if not (show or showOnceMore) then + deleteGuishader() + return + end + -- The first frame that has FlowUI is where this one gets laid out, since Initialize + -- ran before FlowUI existed. + if not uiBound then + if not bindUi() then + return + end + widget:ViewResize() end - if WG.tooltip ~= nil then - if aboveWidget then - local n = aboveWidget[1] - local d = aboveWidget[2] + -- Pinned rather than assumed: widgets on lower layers draw first and leave blending, + -- colour and the texture wherever they finished. + glTexture(false) + glColor(1, 1, 1, 1) - --local tt = (d.active and GreenStr) or (enabled and YellowStr) or RedStr - local tooltipTitle = "" - local order = widgetHandler.orderList[n] - if order then - if order >= 1 then - if not d.active then - tooltipTitle = "\255\255\240\160" .. n .. "\n" - else - tooltipTitle = "\255\130\255\160" .. n .. "\n" - end - else - tooltipTitle = "\255\255\160\160" .. n .. "\n" + local mx, my, lmb = spGetMouseState() + if dragging then + if lmb then + scrollFromY(my) + else + dragging = false + end + end + + local sig = panelSignature(show and mx or -1, show and my or -1) + if sig ~= panelSig then + if panelList then + glDeleteList(panelList) + end + panelList = glCreateList(drawPanel) + panelSig = sig + end + + if not windowList then + windowList = glCreateList(drawWindow) + end + glCallList(windowList) + glCallList(panelList) + -- 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 + dropFloat("picker") + drawFloating("dialog", function() + drawDialog(dialog) + if dialog.field then + nameBox:draw() end + end) + else + dropFloat("dialog") + searchBox:draw() + if setPicker:isOpen() then + drawFloating("picker", function() + setPicker:draw() + end) + else + dropFloat("picker") + setPicker:draw() + end + end + -- After the picker has laid its list out, so the blur behind it is the right size + -- on the frame the list appears rather than the one after. + updateShading() + end + + if WG.guishader and backgroundGuishader == nil then + backgroundGuishader = glCreateList(function() + RectRound(screenX, screenY - screenHeight, screenX + screenWidth, screenY, elementCorner, 1, 1, 1, 1) + end) + WG.guishader.InsertDlist(backgroundGuishader, "widgetselector", nil, widget) + end + showOnceMore = false + + if math_isInRect(mx, my, screenX, screenY - screenHeight, screenX + screenWidth, screenY) then + Spring.SetMouseCursor("cursornormal") + + local row = not dialog and hover.row > 0 and rows[scroll + hover.row] + if row and WG.tooltip then + local d = row.data + -- The same three states the row is painted in, said in words: green is running, + -- amber is enabled but not running, red is off. + local stateColor, stateWord = "\255\255\160\160", L.stateOff + if row.state == 1 then + stateColor, stateWord = "\255\130\255\160", L.stateOn + elseif row.state == 0.5 then + stateColor, stateWord = "\255\255\240\160", L.statePending end - local tooltip = "" + local title = stateColor .. row.name .. "\n" + local maxWidth = WG.tooltip.getFontsize() * 90 + local tip = stateColor .. stateWord .. "\n" if d.desc and d.desc ~= "" then - local textLines, numLines = font:WrapText(d.desc, maxWidth) - tooltip = tooltip .. WhiteStr .. string.gsub(textLines, "[\n]", "\n" .. WhiteStr) .. "\n" + tip = tip + .. "\255\255\255\255" + .. string.gsub(font:WrapText(d.desc, maxWidth), "[\n]", "\n\255\255\255\255") + .. "\n" end if d.author and d.author ~= "" then - local textLines, numLines = font:WrapText(d.author, maxWidth) - tooltip = tooltip + tip = tip .. "\255\175\175\175" .. L.author .. ": " .. d.author .. "\n" + end + if row.order then + tip = tip .. "\255\175\175\175" - .. BAR.I18N("ui.widgetselector.author") + .. L.order .. ": " - .. string.gsub(textLines, "[\n]", "\n\255\175\175\175") + .. row.order + .. " (" + .. L.layer + .. " " + .. tostring(row.layer) + .. ")" .. "\n" end - tooltip = tooltip + tip = tip .. "\255\175\175\175" - .. BAR.I18N("ui.widgetselector.file") + .. L.file .. ": " - .. d.basename - .. (not d.fromZip and " (" .. BAR.I18N("ui.widgetselector.islocal") .. ")" or "") - if WG.tooltip then - WG.tooltip.ShowTooltip("info", tooltip, nil, nil, tooltipTitle) + .. (d.basename or "") + .. (row.isLocal and " (" .. L.islocal .. ")" or "") + .. "\n\255\130\130\130" + .. L.hint + WG.tooltip.ShowTooltip("widgetselector", tip, nil, nil, title) + end + end +end + +---------------------------------------------------------------- +-- Input +---------------------------------------------------------------- + +function widget:KeyPress(key) + if not show or not uiBound then + return false + end + + if dialog then + if key == KEYSYMS.ESCAPE then + closeDialog() + elseif key == KEYSYMS.RETURN then + local typed, blocked = dialogName() + if not blocked then + local d = closeDialog() + if d then + d.accept(typed) + end end + elseif dialog.field then + -- Everything else is typing. Escape and Return are handled above, which is why the + -- box never sees them. + nameBox:keyPress(key) end + + return true end - if showTextInput and (updateTextInputDlist or not textInputDlist) then - drawChatInput() + -- Escape takes one thing down at a time, innermost first, and only closes the panel + -- once there is nothing left over it: the picker's open list, then the search that + -- made the list being read, then the panel itself. + if key == KEYSYMS.ESCAPE then + if setPicker:isOpen() then + setPicker:close() + + return true + end + if searchBox:getText() ~= "" then + searchBox:setText("") + else + showOnceMore = true + closePanel() + end + + return true end - if showTextInput and textInputDlist then - gl.CallList(textInputDlist) - drawChatInputCursor() - elseif WG.guishader then - WG.guishader.RemoveRect("selectorinput") - textInputDlist = gl.DeleteList(textInputDlist) + + if searchBox:isFocused() then + searchBox:keyPress(key) + + return true end - --local windowClick = (backgroundRect and math.isInRect(mx, my, backgroundRect[1], backgroundRect[2], backgroundRect[3], backgroundRect[4])) - --local titleClick = (titleRect and math.isInRect(mx, my, titleRect[1], titleRect[2], titleRect[3], titleRect[4])) - --local chatinputClick = (chatInputArea and math.isInRect(mx, my, chatInputArea[1], chatInputArea[2], chatInputArea[3], chatInputArea[4])) - --if windowClick or titleClick or chatinputClick then - -- Spring.SetMouseCursor('cursornormal') - --end + return false end -function widget:MousePress(x, y, button) - if Spring.IsGUIHidden() or not show then +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. + if dialog then + return dialog.field == true + end + if not searchBox:isFocused() then return false end - UpdateList() + if heldAtFocus[key] then + heldAtFocus[key] = nil - local windowClick = ( - backgroundRect - and math.isInRect(x, y, backgroundRect[1], backgroundRect[2], backgroundRect[3], backgroundRect[4]) - ) - local titleClick = (titleRect and math.isInRect(x, y, titleRect[1], titleRect[2], titleRect[3], titleRect[4])) - local chatinputClick = ( - chatInputArea and math.isInRect(x, y, chatInputArea[1], chatInputArea[2], chatInputArea[3], chatInputArea[4]) - ) + return false + end - if button == 1 then - -- above a button - if showButtons then - if - minx < x - and x < maxx - and miny - (buttonTop * sizeMultiplier) - #buttons * (buttonHeight * sizeMultiplier) < y - and y < miny - (buttonTop * sizeMultiplier) - then - return true - end - end + return true +end - -- above the scrollbar - if x >= minx + scrollbarOffset and x <= maxx + scrollbarOffset + (yStep * sizeMultiplier) then - if y >= (maxy - bordery) and y <= maxy then - if x > maxx + scrollbarOffset then - ScrollUp(1) - else - ScrollUp(pageStep) - end - return true - elseif y >= miny and y <= miny + bordery then - if x > maxx + scrollbarOffset then - ScrollDown(1) - else - ScrollDown(pageStep) - end - return true - end - end +function widget:TextInput(utf8char) + if not (show and uiBound) then + return false + end + if dialog then + return dialog.field and nameBox:textInput(utf8char) or false + end + if searchBox:isFocused() then + return searchBox:textInput(utf8char) + end - -- above the list - if sbposx < x and x < sbposx + sbsizex and sbposy < y and y < sbposy + sbsizey then - activescrollbar = true - scrollbargrabpos = y - sbposy - return true - elseif sbposx < x and x < sbposx + sbsizex and sby2 < y and y < sby2 + sbheight then - if y > sbposy + sbsizey then - startEntry = mathMax(1, mathMin(startEntry - curMaxEntries, #fullWidgetsList - curMaxEntries + 1)) - elseif y < sbposy then - startEntry = mathMax(1, mathMin(startEntry + curMaxEntries, #fullWidgetsList - curMaxEntries + 1)) - end - UpdateListScroll() - pagestepped = true - return true - end + return false +end + +-- Swallowed across the whole panel, not just the list: a wheel that gets through zooms +-- the camera behind it. +function widget:MouseWheel(up, _value) + if not show or not uiBound then + return false end - if windowClick or titleClick or chatinputClick then + local x, y = spGetMouseState() + if not math_isInRect(x, y, screenX, screenY - screenHeight, screenX + screenWidth, screenY) then + return false + end + if dialog then 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. + if x <= area.x1 + metrics.sidebarW and y > categoryBottom() and y <= sidebarTop() then + setCatScroll(catScroll + (up and -1 or 1)) else - show = false - Spring.SDLStopTextInput() - widgetHandler.textOwner = nil --widgetHandler:DisownText() - return false + setScroll(scroll + (up and -metrics.wheelRows or metrics.wheelRows)) end + + return true end -function widget:MouseMove(x, y, dx, dy, button) - if show and activescrollbar then - startEntry = mathMax( - 0, - mathMin( - floor((#fullWidgetsList * ((sby1 - sbsize) - (y - mathMin(scrollbargrabpos, sbsize))) / sbheight) + 0.5), - #fullWidgetsList - curMaxEntries - ) - ) + 1 - UpdateListScroll() - return true +local function selectCategory(key) + if selectedCategory == key then + return + end + selectedCategory = key + setScroll(0) + rebuildRows() + if playSounds then + Spring.PlaySoundFile(buttonclick, 0.6, "ui") end - return false end -function widget:MouseRelease(x, y, mb) - if Spring.IsGUIHidden() or not show then - return -1 +local function click() + if playSounds then + Spring.PlaySoundFile(buttonclick, 0.6, "ui") end +end - UpdateList() - if pagestepped then - pagestepped = false - return true +-- A press inside the panel goes to the modal, the field, the switches, the column, the +-- scrollbar, a row or a footer button; a press outside closes it. +local function mouseEvent(x, y, button, release) + if spIsGUIHidden() or not show or not uiBound then + return false end - if mb == 1 and activescrollbar then - activescrollbar = false - scrollbargrabpos = 0.0 - return -1 + -- A press on a top bar button is the top bar's to handle: it closes the open windows + -- and opens the one that was clicked. + if WG.topbar and WG.topbar.buttonAt and WG.topbar.buttonAt(x, y) then + return false end - if mb == 1 then - if maxx - 10 < x and x < maxx and maxy + bgPadding < y and y < maxy + buttonFontSize + 7 + bgPadding then - -- + button - curMaxEntries = curMaxEntries + 1 - UpdateListScroll() - UpdateGeometry() - Spring.WarpMouse(x, y + 0.5 * (fontSize + fontSpace)) - return -1 - end - if minx < x and x < minx + 10 and maxy + bgPadding < y and y < maxy + buttonFontSize + 7 + bgPadding then - -- - button - if curMaxEntries > minMaxEntries then - curMaxEntries = curMaxEntries - 1 - UpdateListScroll() - UpdateGeometry() - Spring.WarpMouse(x, y - 0.5 * (fontSize + fontSpace)) + local inside = math_isInRect(x, y, screenX, screenY - screenHeight, screenX + screenWidth, screenY) + + if dialog then + -- The modal owns every press while it is up, inside the panel or out, so a stray + -- click cannot dismiss it or reach what it is asking about. + if not release and button == 1 then + if dialog.field and nameBox:mousePress(x, y) then + return true + end + local typed, blocked = dialogName() + if not blocked and math_isInRect(x, y, dialogOk[1], dialogOk[2], dialogOk[3], dialogOk[4]) then + local d = closeDialog() + click() + if d then + d.accept(typed) + end + elseif math_isInRect(x, y, dialogCancel[1], dialogCancel[2], dialogCancel[3], dialogCancel[4]) then + closeDialog() + click() end - return -1 end + + return true end - if showButtons and mb == 1 then - local buttonID = nil - for i, _ in ipairs(buttons) do - if - minx < x - and x < maxx - and miny - (buttonTop * sizeMultiplier) - i * (buttonHeight * sizeMultiplier) < y - and y < miny - (buttonTop * sizeMultiplier) - (i - 1) * (buttonHeight * sizeMultiplier) - then - buttonID = i - break + if inside then + if not release and button == 1 then + -- The picker first: while it is open its list is over the rows, and they must not + -- take a click meant for it. + if setPicker:mousePress(x, y) then + searchBox:blur() + click() + return true end - end - if buttonID == 1 then - Spring.SendCommands("luarules reloadluaui") - return -1 - end - if buttonID == 2 then - -- disable all widgets, but don't reload - for _, namedata in ipairs(fullWidgetsList) do - widgetHandler:DisableWidget(namedata[1]) + if searchBox:mousePress(x, y) then + return true end - widgetHandler:SaveConfigData() - return -1 - end - if buttonID == 3 and allowuserwidgets then - -- tell the widget handler that we allow/disallow user widgets and reload - if widgetHandler.allowUserWidgets then - widgetHandler.__allowUserWidgets = false - spEcho("Disallowed user widgets, reloading...") + searchBox:blur() + + for _, b in ipairs(setButtons) do + local r = b.rect + if r and math_isInRect(x, y, r[1], r[2], r[3], r[4]) then + click() + if b.id == "loadset" then + applySet(pickedSet) + elseif b.id == "saveset" then + confirm(L.saveSetTitle, L.saveSetWarn, saveSet, false, true, pickedSet) + else + local name = pickedSet + confirm(L.deleteSetTitle, L.deleteSetWarn, function() + deleteSet(name) + end, true) + end + + return true + end + end + + local i = sidebarIndexAt(x, y) + local hitSwitch + for n = 1, #switches do + local h = switches[n].hit + if h and math_isInRect(x, y, h[1], h[2], h[3], h[4]) then + hitSwitch = switches[n].key + end + end + if hitSwitch then + filters[hitSwitch] = not filters[hitSwitch] + -- The rank column appears and disappears with the sort, so the columns move. + setLayout() + -- The column counts what the filters leave, so they still say what clicking one + -- of them would show. + buildCategories() + setScroll(0) + rebuildRows() + click() + elseif i then + selectCategory(categories[i].key) + elseif math_isInRect(x, y, barX1, listBottom, area.x2, listTop) then + grabScroller(y) else - widgetHandler.__allowUserWidgets = true - spEcho("Allowed user widgets, reloading...") + for _, b in ipairs(buttons) do + local r = b.rect + if r and math_isInRect(x, y, r[1], r[2], r[3], r[4]) then + click() + buttonAction(b.id) + + return true + end + end end - Spring.SendCommands("luarules reloadluaui") - return -1 end - if buttonID == 4 then - Spring.SendCommands("luaui reset") - return -1 - end - if buttonID == 5 then - Spring.SendCommands("luaui factoryreset") - return -1 - end - end - - local namedata = aboveLabel(x, y) - if not namedata then - return false - end - local name = namedata[1] + -- Which row the press landed on, remembered by name: the list can be rebuilt between + -- the press and the release, so an index would not still mean the same widget. + local overRow + if math_isInRect(x, y, listX1, listBottom, listRight, listTop) then + local r = rowAt(y) + overRow = r and rows[scroll + r] or nil + end - if mb == 1 then - widgetHandler:ToggleWidget(name) - elseif mb == 2 or mb == 3 then - local w = widgetHandler:FindWidget(name) - if not w then - return -1 + if not release then + pressedRow = overRow and overRow.name or nil + pressedButton = button + 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 then + widgetHandler:ToggleWidget(overRow.name) + + click() + elseif button == 2 or button == 3 then + local w = widgetHandler:FindWidget(overRow.name) + if w then + if button == 2 then + widgetHandler:LowerWidget(w) + else + widgetHandler:RaiseWidget(w) + end + widgetHandler:SaveConfigData() + end + end end - if mb == 2 then - widgetHandler:LowerWidget(w) - spEcho("widgetHandler:LowerWidget") - else - widgetHandler:RaiseWidget(w) - spEcho("widgetHandler:RaiseWidget") + if release then + pressedRow, pressedButton = nil, 0 end - widgetHandler:SaveConfigData() - end - return -1 -end -function aboveLabel(x, y) - if x < minx or y < (miny + bordery) or x > maxx or y > (maxy - bordery) then - return nil - end - local count = #widgetsList - if count < 1 then - return nil - end + return true + elseif not release then + -- Only a press outside closes. A release out here belongs to a drag that started on + -- the scrollbar. + showOnceMore = true + closePanel() - local i = floor(1 + ((maxy - bordery) - y) / (yStep * sizeMultiplier)) - if i < 1 then - i = 1 - elseif i == count then - i = count + return true end +end + +function widget:MousePress(x, y, button) + return mouseEvent(x, y, button, false) +end - return widgetsList[i] +function widget:MouseRelease(x, y, button) + return mouseEvent(x, y, button, true) end function widget:GetConfigData() - local data = { startEntry = startEntry, show = show } - return data + return { + localOnly = filters.localOnly, + enabledOnly = filters.enabledOnly, + byOrder = filters.byOrder, + category = selectedCategory, + sets = sets, + pickedSet = pickedSet, + } end function widget:SetConfigData(data) - startEntry = data.startEntry or startEntry - show = data.show or show - if show then - widgetHandler.textOwner = widget --widgetHandler:OwnText() - Spring.SDLStartTextInput() -- because: touch chobby's text edit field once and widget:TextInput is gone for the game, so we make sure its started! + if type(data) ~= "table" then + return end -end - -function widget:Shutdown() - Spring.SendCommands("bind f11 luaui selector") -- if this one is removed or crashes, then have the backup one take over. - cancelChatInput() - if WG.guishader then - WG.guishader.DeleteDlist("widgetselector") - WG.guishader.DeleteDlist("widgetselector2") - end - uiList = gl.DeleteList(uiList) - uiList2 = gl.DeleteList(uiList2) - gl.DeleteFont(font) - gl.DeleteFont(font2) - - widgetHandler.actionHandler:RemoveAction(self, "widgetselector") - widgetHandler.actionHandler:RemoveAction(self, "factoryreset") - widgetHandler.actionHandler:RemoveAction(self, "userwidgets") + 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 = {} + if type(data.sets) == "table" then + for _, saved in ipairs(data.sets) do + if type(saved) == "table" and type(saved.name) == "string" and type(saved.widgets) == "table" then + local names = {} + for name in pairs(saved.widgets) do + if type(name) == "string" then + names[name] = true + end + end + sets[#sets + 1] = { name = saved.name, widgets = names } + end + end + end + pickedSet = type(data.pickedSet) == "string" and data.pickedSet or nil + filters.enabledOnly = data.enabledOnly == true + filters.byOrder = data.byOrder == true + selectedCategory = type(data.category) == "string" and data.category or nil end From 5a82df300c64cc47c3e7662cd34477627159b018 Mon Sep 17 00:00:00 2001 From: Floris Date: Sat, 12 Sep 2026 21:35:07 +0200 Subject: [PATCH 2/3] widget selector: add 'Reset' button to next to widgets that clear their data (#9226) --- language/en/interface.json | 4 + luaui/Include/keybind_editor_view.lua | 184 +++++++++++++++-- luaui/Widgets/gui_keybind_info.lua | 10 +- luaui/Widgets/widget_selector.lua | 276 ++++++++++++++++++++------ 4 files changed, 397 insertions(+), 77 deletions(-) diff --git a/language/en/interface.json b/language/en/interface.json index e2bc0fbc847..7625bd73814 100644 --- a/language/en/interface.json +++ b/language/en/interface.json @@ -750,6 +750,10 @@ "enabledonly": "Enabled only", "byorder": "By load order", "order": "Load order", + "cleardata": "Reset", + "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.", "layer": "Layer", "sets": "Widget sets", "noset": "No set", diff --git a/luaui/Include/keybind_editor_view.lua b/luaui/Include/keybind_editor_view.lua index ea55cd25e56..dda9a828aac 100644 --- a/luaui/Include/keybind_editor_view.lua +++ b/luaui/Include/keybind_editor_view.lua @@ -121,9 +121,81 @@ local scroll = 0 -- to its top edge, so the thumb follows the cursor instead of jumping its middle to the -- press. It rides here rather than in a local of its own: this chunk is at Lua's ceiling of -- 200 locals, which is why the sizes above share `metrics` too. -local hover = { sb = 0, row = 0, zone = "", idx = 0, gk = "", ga = 0, gb = 0, btn = "", bar = 0, grab = 0, cat = 0 } -local dragging = false +local hover = + { sb = 0, row = 0, zone = "", idx = 0, gk = "", ga = 0, gb = 0, btn = "", bar = 0, grab = 0, cat = 0, drag = false } local dirty = false + +-- Blur behind whatever floats over the panel, and the floating content drawn back on top +-- of it. +-- +-- The panel's own backdrop is registered with InsertDlist, which is the *world* set: it +-- blurs the map behind the panel and leaves the UI alone. A popup has to blur UI - the +-- rows and buttons it covers - so it goes into the screen set instead. +-- +-- That set is drawn by gfx_guishader, which copies the screen as it stands and blurs it +-- inside those rects. widgetHandler walks DrawScreen in reverse layer order, so this +-- panel (-99990) draws well before guishader (-990000) and a popup of ours inside one of +-- those rects would be blurred along with what it covers. Handing the drawing to +-- insertRenderDlist gets it replayed after the blur, which is how gui_options keeps its +-- select list crisp. +-- +-- One table rather than a handful of locals, and for the same reason as `hover` above: +-- this chunk is at Lua's ceiling of 200. +local shade = { owner = nil, rects = {}, lists = {} } + +-- Only touched when the rect actually moves: every insert marks the stencil dirty, so +-- doing it per frame has it rebuilt per frame. +function shade.rect(name, x1, y1, x2, y2) + if not WG.guishader then + return + end + local was = shade.rects[name] + if x1 then + if not (was and was[1] == x1 and was[2] == y1 and was[3] == x2 and was[4] == y2) then + WG.guishader.InsertScreenRect(x1, y1, x2, y2, "keybindeditor_" .. name, shade.owner) + shade.rects[name] = { x1, y1, x2, y2 } + end + elseif was then + WG.guishader.RemoveScreenRect("keybindeditor_" .. name) + shade.rects[name] = nil + end +end + +function shade.drop(name) + local list = shade.lists[name] + if list then + if WG.guishader then + WG.guishader.removeRenderDlist(list) + end + gl.DeleteList(list) + shade.lists[name] = nil + end +end + +-- Rebuilt per frame: a modal carries a blinking caret and the picker lights the option +-- under the cursor, so there is nothing static to hold on to. +function shade.float(name, fn) + if not (WG.guishader and WG.guishader.insertRenderDlist) then + -- No blur will be drawn over it, so there is nothing to hand over. + fn() + return + end + shade.drop(name) + shade.lists[name] = gl.CreateList(fn) + WG.guishader.insertRenderDlist(shade.lists[name]) +end + +function shade.clear() + for name in pairs(shade.rects) do + if WG.guishader then + WG.guishader.RemoveScreenRect("keybindeditor_" .. name) + end + shade.rects[name] = nil + end + for name in pairs(shade.lists) do + shade.drop(name) + end +end ---@type table? local capturing @@ -1297,14 +1369,18 @@ function view.refresh() end -- Takes the panel rect from the host; every band and column is derived from it. -function view.setArea(x1, y1, x2, y2, s) +-- `wx1..wy2` is the window the area sits inside; without it a modal can only dim as far +-- as the area goes, leaving the panel's own border lit. Kept in `metrics` rather than a +-- local of its own, this chunk being at Lua's ceiling of 200. +function view.setArea(x1, y1, x2, y2, s, wx1, wy1, wx2, wy2) ensureControls() area.x1, area.y1, area.x2, area.y2 = x1, y1, x2, y2 + metrics.winX1, metrics.winY1 = wx1 or x1, wy1 or y1 + metrics.winX2, metrics.winY2 = wx2 or x2, wy2 or y2 scale = s or 1 rowHeight = floor(24 * scale) metrics.catRowHeight = floor(29 * scale) metrics.catBarW = math.max(3, floor(6 * scale)) - metrics.catBarW = math.max(3, floor(6 * scale)) -- Whole pixels throughout: a size or a corner landing on a fraction puts glyph and -- rectangle edges between pixels, which the renderer then blends across both. metrics.rowFs = floor(rowHeight * 0.55) @@ -1379,6 +1455,9 @@ function view.blur() end capturing = nil + -- Or the blur outlives the panel: guishader keeps drawing a rect nobody owns any more. + shade.clear() + -- Through cancel rather than dropped: a live modal is holding a rollback, and the picker -- names a profile that was never switched to until that runs. cancelDialog() @@ -1390,6 +1469,11 @@ function view.confirmClose(proceed) return guardDirty(proceed) end +-- The host widget, handed over so guishader can drop this panel's blur rects with it when +-- the widget goes away. Optional: with no owner the rects are simply always allowed. +function view.setOwner(w) + shade.owner = w +end -- Host hook for swapping the build menu when a profile implies one. function view.setMenuToggle(fn) menuToggle = fn @@ -2544,7 +2628,20 @@ local function drawCaptureModal(mx, my) local cs = metrics.csButton local cx = floor((bx1 + bx2) * 0.5) - RectRound(area.x1, area.y1, area.x2, area.y2, 0, 0, 0, 0, 0, { 0, 0, 0, 0.55 }) + -- The whole window, not the inset area inside it: a modal that leaves the panel's own + -- border lit does not read as covering it. + RectRound( + metrics.winX1, + metrics.winY1, + metrics.winX2, + metrics.winY2, + metrics.csPanel, + 1, + 1, + 1, + 1, + { 0, 0, 0, 0.55 } + ) UiElement(bx1, by1, bx2, by2, 1, 1, 1, 1, 1, 1, 1, 1, WG.FlowUI.clampedOpacity) local tfs = floor(rowHeight * 0.6) @@ -2670,7 +2767,20 @@ local function drawProfileDialog(mx, my) local tfs = floor(rowHeight * 0.6) local sfs = floor(rowHeight * 0.5) - RectRound(area.x1, area.y1, area.x2, area.y2, 0, 0, 0, 0, 0, { 0, 0, 0, 0.55 }) + -- The whole window, not the inset area inside it: a modal that leaves the panel's own + -- border lit does not read as covering it. + RectRound( + metrics.winX1, + metrics.winY1, + metrics.winX2, + metrics.winY2, + metrics.csPanel, + 1, + 1, + 1, + 1, + { 0, 0, 0, 0.55 } + ) UiElement(bx1, by1, bx2, by2, 1, 1, 1, 1, 1, 1, 1, 1, WG.FlowUI.clampedOpacity) -- Anything whose accept saves is green, anything destructive is red, wherever it @@ -2889,7 +2999,7 @@ local function panelSignature(mx, my) .. "|" .. h.cat .. "|" - .. (dragging and 1 or 0) + .. (hover.drag and 1 or 0) end -- Everything under the header controls and above the modals: the sidebar, the list or @@ -2922,7 +3032,7 @@ local function drawPanel() end flushText() - Scroller(barX1, lb, area.x2 - metrics.edgeInset, listTop, rowMetrics.totalH, base, h.bar == 1, dragging) + Scroller(barX1, lb, area.x2 - metrics.edgeInset, listTop, rowMetrics.totalH, base, h.bar == 1, hover.drag) end drawButtons(h.btn) @@ -2942,6 +3052,31 @@ end -- Paints the whole panel. The header controls and the modals draw live; the body is -- replayed from its display list until panelSignature says something in it moved. +-- Which of the popups is up, and where. Defined down here rather than beside the rest of +-- `shade`: it reads the popup state and geometry, none of which exists that far up. +function shade.update() + if capturing then + local bx1, by1, bx2, by2 = captureGeometry() + shade.rect("capture", bx1, by1, bx2, by2) + else + shade.rect("capture") + end + + if dialog then + local bx1, by1, bx2, by2 = dialogGeometry() + shade.rect("dialog", bx1, by1, bx2, by2) + else + shade.rect("dialog") + end + + -- The list the picker drops, which stands clear of the control and over the rows. + local opts = presetDropdown and presetDropdown:isOpen() and presetDropdown.optRects + if opts and opts[1] then + shade.rect("picker", opts[1].x1, opts[#opts].y1, opts[1].x2, opts[1].y2) + else + shade.rect("picker") + end +end function view.draw() if not font then view.init() @@ -2962,11 +3097,11 @@ function view.draw() gl.DepthTest(false) local rawMx, rawMy, lmb = spGetMouseState() - if dragging then + if hover.drag then if lmb then scrollFromY(rawMy) else - dragging = false + hover.drag = false end end @@ -2993,16 +3128,37 @@ function view.draw() registerTooltips() end - presetDropdown:draw() + -- Each of these covers UI rather than map, so it takes the blur with it and is drawn + -- back on top of it. See `shade` for why that is two steps and not one. + if presetDropdown:isOpen() then + shade.float("picker", function() + presetDropdown:draw() + end) + else + shade.drop("picker") + presetDropdown:draw() + end -- Real cursor: these are the overlay, so the hover is theirs to detect. if capturing then - drawCaptureModal(rawMx, rawMy) + shade.float("capture", function() + drawCaptureModal(rawMx, rawMy) + end) + else + shade.drop("capture") end if dialog then - drawProfileDialog(rawMx, rawMy) + shade.float("dialog", function() + drawProfileDialog(rawMx, rawMy) + end) + else + shade.drop("dialog") end + + -- After they have laid themselves out, so the blur behind one is the right size on + -- the frame it appears rather than the one after. + shade.update() end -- Scrolls so the thumb's top sits where the cursor has dragged it. The offset taken at @@ -3196,7 +3352,7 @@ function view.mousePress(x, y, button) -- is at Lua's ceiling of 200 locals and a function of its own would need a slot. local top, height = scrollerThumb() if top then - dragging = true + hover.drag = true if y <= top and y >= top - height then hover.grab = y - top else diff --git a/luaui/Widgets/gui_keybind_info.lua b/luaui/Widgets/gui_keybind_info.lua index 9d75f08f800..997d6367ae2 100644 --- a/luaui/Widgets/gui_keybind_info.lua +++ b/luaui/Widgets/gui_keybind_info.lua @@ -97,13 +97,21 @@ function widget:ViewResize() UiElement = WG.FlowUI.Draw.Element keybindEditor.init() + -- So guishader drops the popups' blur rects along with this widget. + keybindEditor.setOwner(widget) local pad = mathFloor(8 * widgetScale) + -- The inset area the panel lays out in, and after it the window it sits in: a modal + -- dims the whole window, border and all, not just the area inside it. keybindEditor.setArea( screenX + pad, screenY - screenHeight + pad, screenX + screenWidth - pad, screenY - pad, - widgetScale + widgetScale, + screenX, + screenY - screenHeight, + screenX + screenWidth, + screenY ) if keybinds then diff --git a/luaui/Widgets/widget_selector.lua b/luaui/Widgets/widget_selector.lua index 004d83ffeae..8bacf49ce91 100644 --- a/luaui/Widgets/widget_selector.lua +++ b/luaui/Widgets/widget_selector.lua @@ -210,7 +210,7 @@ local L = {} local show, showOnceMore local panelList, windowList, backgroundGuishader, panelSig local listTop, listBottom, listX1, listRight, descX1, barX1 = 0, 0, 0, 0, 0, 0 -local switchX1, orderX1, nameX1 = 0, 0, 0 +local switchX1, orderX1, nameX1, clearX1 = 0, 0, 0, 0 -- The sets block at the foot of the category column: a caption, the picker, and the -- two buttons that make and unmake a set. It lives there rather than in the header -- because the column already has the room and the header has none left. @@ -297,6 +297,7 @@ local dialogBox = {} ---@type string? local pressedRow local pressedButton = 0 +local pressedClear = false local hover = { sb = 0, row = 0, sw = 0, tog = 0, bar = 0, btn = "", dlg = "" } @@ -318,6 +319,7 @@ local uiBound = false local rebuildRows local setLayout +local refreshContent ---------------------------------------------------------------- -- Content @@ -338,6 +340,15 @@ local function stateOf(name, data) return 0 end +-- Whether the handler is holding anything this widget saved. An empty table is nothing +-- to clear: a widget with a GetConfigData that returns `{}` would otherwise offer a +-- button that does nothing. +local function hasConfigData(name) + local d = widgetHandler.configData[name] + + return type(d) == "table" and next(d) ~= nil +end + -- Which column a widget belongs in, from the prefix on its filename. local function groupOf(data) local base = data.basename or "" @@ -389,6 +400,7 @@ local function buildEntries() data = data, group = groupOf(data), state = stateOf(name, data), + hasConfig = hasConfigData(name), order = order[name], layer = layer[name], desc = desc, @@ -786,6 +798,30 @@ local function factoryReset() widgetHandler.__blankOutConfig = true reloadLuaUI() end +-- Waiting on the handler to actually let go of a widget. Enable and Disable are queued +-- and run after the callin that asked has returned, so the clear cannot happen on the +-- click itself; Update picks this up on the next frame, by which time the widget is gone. +local pendingClear + +-- Drops what the handler has saved under a widget's name. +-- +-- A running widget hands its settings back the instant it is removed, and SaveConfigData +-- asks every running widget for them again, so clearing one on the spot writes itself +-- straight back. The only way it sticks is with the widget already gone: switch it off, +-- drop the entry, switch it on, and it loads with nothing to read. +local function clearConfigData(name) + local known = widgetHandler.knownWidgets[name] + if known and known.active then + widgetHandler:DisableWidget(name) + pendingClear = { name = name, restart = true } + + return + end + + widgetHandler.configData[name] = nil + widgetHandler:SaveConfigData() + refreshContent() +end -- What the wrapper's OwnText/DisownText do, against the real handler's own field. local function ownText() @@ -1054,6 +1090,15 @@ setLayout = function() end end metrics.buttonFs = mathFloor(metrics.rowHeight * 0.55) + -- The clear-data button's column at the right end of the row. Reserved on every row + -- whether or not one is drawn there: a widget saves its first settings the moment it + -- is switched on, and a description that reflowed under the cursor reads worse than + -- the gap does. + metrics.clearFs = mathFloor(metrics.rowFs * 0.92) + metrics.clearH = mathFloor(metrics.rowHeight * 0.62) + metrics.clearW = font and (mathFloor(font:GetTextWidth(L.cleardata) * metrics.clearFs) + metrics.rowPad * 3) + or mathFloor(46 * s) + clearX1 = listRight - metrics.rowPad - metrics.clearW -- 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) @@ -1105,7 +1150,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 = listRight - descX1 - metrics.rowPad * 2 + local descW = clearX1 - descX1 - metrics.rowPad * 2 if row.isLocal then descW = descW - metrics.localTagW - metrics.rowPad end @@ -1138,7 +1183,12 @@ local function flushText() textQueue = {} end -local function drawRow(row, top, bottom, hovered, overSwitch) +local function drawButtonFace(r, fill) + local pair = look.gradients[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) fitRow(row) local fill = (row.state == 1 and look.activeFill) or (row.state == 0.5 and look.pendingFill) @@ -1168,7 +1218,23 @@ local function drawRow(row, top, bottom, hovered, overSwitch) 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, listRight - metrics.rowPad, ty, metrics.rowFs, "rov") + queueText(colorLocal .. L.islocal, clearX1 - metrics.rowPad, 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 + -- about the list rather than an action on one row of it. + if row.hasConfig then + local cy1 = ty - mathFloor(metrics.clearH * 0.5) + local r = { clearX1, cy1, clearX1 + metrics.clearW, cy1 + metrics.clearH } + drawButtonFace(r, overClear and look.dangerFillHover or look.buttonFill) + queueText( + (overClear and colorDanger or colorDim) .. L.cleardata, + mathFloor((r[1] + r[3]) * 0.5), + ty, + metrics.clearFs, + "cov" + ) end end @@ -1183,16 +1249,10 @@ local function drawRows() if bottom < listBottom then break end - drawRow(row, top, bottom, hover.row == i, hover.row == i and hover.sw == 1) + drawRow(row, top, bottom, hover.row == i, hover.row == i and hover.sw == 1, hover.row == i and hover.clr == 1) end end -local function drawButtonFace(r, fill) - local pair = look.gradients[fill] - - UiButton(r[1], r[2], r[3], r[4], 1, 1, 1, 1, 1, 1, 1, 1, nil, pair[1], pair[2]) -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() @@ -1344,7 +1404,10 @@ local function drawDialog(d) local sfs = mathFloor(metrics.rowHeight * 0.5) -- Everything behind it dims, so the modal is plainly the only thing that will answer. - RectRound(area.x1, area.y1, area.x2, area.y2, 0, 0, 0, 0, 0, look.scrim) + -- The whole window, not the inset area inside it: a modal that leaves the panel's own + -- border lit does not read as covering it. Rounded like the panel so it does not + -- square off its corners. + RectRound(screenX, screenY - screenHeight, screenX + screenWidth, screenY, elementCorner, 1, 1, 1, 1, look.scrim) UiElement(bx1, by1, bx2, by2, 1, 1, 1, 1, 1, 1, 1, 1, WG.FlowUI.clampedOpacity) -- With nothing typed there is nothing to save, so the accept is not drawn at all: a @@ -1551,8 +1614,16 @@ end -- from. Same signature, same picture, so the display list is replayed as it is. -- The accept button in a dialog appears the moment there is a name to save under, and -- it is painted into the baked panel, so whether the field is empty is part of this. +-- The row at a visible index, and whether it has anything to clear. Both the hit test +-- and the click ask this, and a row can scroll out from under the cursor between them. +local function rowClearable(i) + local row = i > 0 and rows[scroll + i] + + return (row and row.hasConfig) and row or nil +end + local function panelSignature(mx, my) - hover.sb, hover.row, hover.sw, hover.tog, hover.bar = 0, 0, 0, 0, 0 + hover.sb, hover.row, hover.sw, hover.tog, hover.bar, hover.clr = 0, 0, 0, 0, 0, 0 hover.btn, hover.dlg = "", "" if dialog then @@ -1578,9 +1649,12 @@ local function panelSignature(mx, my) elseif mx >= listX1 and mx <= listRight then hover.row = rowAt(my) or 0 -- The switch lights on its own, so it is plain that it is the thing being pointed - -- at rather than the row behind it. + -- at rather than the row behind it. The clear button at the other end the same, and + -- only on the rows that have one. if hover.row > 0 and mx >= switchX1 - metrics.rowPad and mx <= nameX1 - metrics.rowPad then hover.sw = 1 + elseif hover.row > 0 and mx >= clearX1 and rowClearable(hover.row) then + hover.clr = 1 end elseif mx >= barX1 and mx <= area.x2 then local top, height = scrollerThumb() @@ -1608,6 +1682,8 @@ local function panelSignature(mx, my) .. "|" .. hover.bar .. "|" + .. hover.clr + .. "|" .. hover.btn .. "|" .. hover.dlg @@ -1644,7 +1720,7 @@ end local function contentMoved() for i = 1, #entries do local e = entries[i] - if e.state ~= stateOf(e.name, e.data) then + if e.state ~= stateOf(e.name, e.data) or e.hasConfig ~= hasConfigData(e.name) then return true end end @@ -1662,7 +1738,7 @@ local function contentMoved() return false end -local function refreshContent() +refreshContent = function() buildContent() -- The picker follows: a set can be saved or forgotten between one build and the next. refreshSets() @@ -1747,9 +1823,32 @@ 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.cleardata = tr("cleardata", "Reset") + 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 + -- bake tostring(nil) into the sentence and every use of it would read "everything nil + -- has saved". clearDataWarning does the lookup instead, once it has a name. + L.cleardataWarnFallback = + "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." + L.cleardataRestartWarnFallback = + "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." L.layer = tr("layer", "Layer") end +-- What clearing this widget would do, in the words the confirmation uses. Looked up +-- with the name rather than taken from L: see the fallbacks above. The gsub covers the +-- other path, where the key is missing entirely and i18n hands the fallback back +-- untouched rather than interpolating it. +local function clearDataWarning(name, running) + local text = BAR.I18N( + "ui.widgetselector." .. (running and "cleardatarestartwarn" or "cleardatawarn"), + { name = name, default = running and L.cleardataRestartWarnFallback or L.cleardataWarnFallback } + ) + + return (text:gsub("%%{name}", name)) +end + local function buildButtons() buttons = { { id = "reload", label = L.reload }, @@ -1955,6 +2054,19 @@ function widget:LanguageChanged() end function widget:Update() + -- The disable asked for on the click has run by now, so the widget has already handed + -- its settings back and this is the one moment they can be dropped for good. + if pendingClear then + local p = pendingClear + pendingClear = nil + widgetHandler.configData[p.name] = nil + if p.restart then + widgetHandler:EnableWidget(p.name) + end + widgetHandler:SaveConfigData() + refreshContent() + end + if widgetHandler.knownChanged then widgetHandler.knownChanged = false refreshContent() @@ -2005,6 +2117,73 @@ function widget:Update() end end +-- What the cursor is over, said in words. Its own function rather than a block inside +-- DrawScreen: the clear button answers with something else entirely and bows out early, +-- and an early return in a draw callin would quietly skip whatever is added after it. +local function rowTooltip(row) + local d = row.data + -- The same three states the row is painted in, said in words: green is running, + -- amber is enabled but not running, red is off. + local stateColor, stateWord = "\255\255\160\160", L.stateOff + if row.state == 1 then + stateColor, stateWord = "\255\130\255\160", L.stateOn + elseif row.state == 0.5 then + stateColor, stateWord = "\255\255\240\160", L.statePending + end + 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 + local warn = clearDataWarning(row.name, row.state == 1) + WG.tooltip.ShowTooltip( + "widgetselector", + "\255\255\255\255" .. string.gsub(font:WrapText(warn, maxWidth), "[\n]", "\n\255\255\255\255"), + nil, + nil, + colorDanger .. L.cleardataTitle .. "\n" + ) + + return + end + local tip = stateColor .. stateWord .. "\n" + if d.desc and d.desc ~= "" then + tip = tip + .. "\255\255\255\255" + .. string.gsub(font:WrapText(d.desc, maxWidth), "[\n]", "\n\255\255\255\255") + .. "\n" + end + if d.author and d.author ~= "" then + tip = tip .. "\255\175\175\175" .. L.author .. ": " .. d.author .. "\n" + end + if row.order then + tip = tip + .. "\255\175\175\175" + .. L.order + .. ": " + .. row.order + .. " (" + .. L.layer + .. " " + .. tostring(row.layer) + .. ")" + .. "\n" + end + tip = tip + .. "\255\175\175\175" + .. L.file + .. ": " + .. (d.basename or "") + .. (row.isLocal and " (" .. L.islocal .. ")" or "") + .. "\n\255\130\130\130" + .. L.hint + WG.tooltip.ShowTooltip("widgetselector", tip, nil, nil, title) +end + function widget:DrawScreen() if not (show or showOnceMore) then deleteGuishader() @@ -2088,50 +2267,7 @@ function widget:DrawScreen() local row = not dialog and hover.row > 0 and rows[scroll + hover.row] if row and WG.tooltip then - local d = row.data - -- The same three states the row is painted in, said in words: green is running, - -- amber is enabled but not running, red is off. - local stateColor, stateWord = "\255\255\160\160", L.stateOff - if row.state == 1 then - stateColor, stateWord = "\255\130\255\160", L.stateOn - elseif row.state == 0.5 then - stateColor, stateWord = "\255\255\240\160", L.statePending - end - local title = stateColor .. row.name .. "\n" - - local maxWidth = WG.tooltip.getFontsize() * 90 - local tip = stateColor .. stateWord .. "\n" - if d.desc and d.desc ~= "" then - tip = tip - .. "\255\255\255\255" - .. string.gsub(font:WrapText(d.desc, maxWidth), "[\n]", "\n\255\255\255\255") - .. "\n" - end - if d.author and d.author ~= "" then - tip = tip .. "\255\175\175\175" .. L.author .. ": " .. d.author .. "\n" - end - if row.order then - tip = tip - .. "\255\175\175\175" - .. L.order - .. ": " - .. row.order - .. " (" - .. L.layer - .. " " - .. tostring(row.layer) - .. ")" - .. "\n" - end - tip = tip - .. "\255\175\175\175" - .. L.file - .. ": " - .. (d.basename or "") - .. (row.isLocal and " (" .. L.islocal .. ")" or "") - .. "\n\255\130\130\130" - .. L.hint - WG.tooltip.ShowTooltip("widgetselector", tip, nil, nil, title) + rowTooltip(row) end end end @@ -2387,12 +2523,28 @@ 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 if not release then pressedRow = overRow and overRow.name or nil pressedButton = button + pressedClear = onClear 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 then + if 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) + click() + end + elseif button == 1 then widgetHandler:ToggleWidget(overRow.name) click() @@ -2409,7 +2561,7 @@ local function mouseEvent(x, y, button, release) end end if release then - pressedRow, pressedButton = nil, 0 + pressedRow, pressedButton, pressedClear = nil, 0, false end return true From 41144778dc1d3cc0374e3ebd308337eb2a967ec2 Mon Sep 17 00:00:00 2001 From: Floris Date: Sat, 12 Sep 2026 23:41:42 +0200 Subject: [PATCH 3/3] widget selector/profile: added profiler + extracted profiling wrapping in include file (#9229) --- language/en/interface.json | 10 + luaui/Include/widget_profiling.lua | 474 ++++++++++++++++++++ luaui/Widgets/dbg_widget_profiler.lua | 406 +++-------------- luaui/Widgets/widget_selector.lua | 611 ++++++++++++++++++++++---- 4 files changed, 1060 insertions(+), 441 deletions(-) create mode 100644 luaui/Include/widget_profiling.lua diff --git a/language/en/interface.json b/language/en/interface.json index 7625bd73814..9aef3f21329 100644 --- a/language/en/interface.json +++ b/language/en/interface.json @@ -749,6 +749,16 @@ }, "enabledonly": "Enabled only", "byorder": "By load order", + "total": "total", + "profiler": "Cost", + "byload": "By cost", + "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.", + "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.", + "reloaddesc": "Loads every widget again from disk, keeping what is switched on. The quickest way to pick up a widget you have just edited.", + "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", "cleardatatitle": "Clear saved settings", diff --git a/luaui/Include/widget_profiling.lua b/luaui/Include/widget_profiling.lua new file mode 100644 index 00000000000..c84d4b28d02 --- /dev/null +++ b/luaui/Include/widget_profiling.lua @@ -0,0 +1,474 @@ +-- Per-widget CPU and memory measurement, shared by everything that wants to read it. +-- +-- There is no engine call for what one widget costs, so the only way to find out is to wrap +-- every callin of every widget and time what happens inside. That wrapping is global and it +-- does not nest: two independent hookers would each end up timing the other's wrapper, and +-- both sets of numbers would be wrong. So it lives here, once, behind a count of who wants +-- it - the first subscriber puts the hooks in and the last one takes them out again. +-- +-- VFS.Include runs the file afresh for each includer, which would hand every widget its own +-- copy of that count. The instance is therefore parked on WG, and every later include of +-- this file gets the one already running. +-- +-- Ported out of dbg_widget_profiler, which measured all of this itself before the widget +-- selector wanted the same numbers. + +if WG.widgetProfiling then + return WG.widgetProfiling +end + +local spGetTimer = Spring.GetTimer +local spDiffTimers = Spring.DiffTimers +local spGetLuaMemUsage = Spring.GetLuaMemUsage +local spGetFPS = Spring.GetFPS +local spGetConfigFloat = Spring.GetConfigFloat +local mathExp = math.exp +local mathMin = math.min +local stringFind = string.find +local stringSub = string.sub +local type = type +local pairs = pairs + +local highres +if Spring.GetTimerMicros and Spring.GetConfigInt("UseHighResTimer", 0) == 1 then + spGetTimer = Spring.GetTimerMicros + highres = true +end + +local M = {} + +-- How often the raw counters are turned into averages. +-- +-- The wrappers run whatever this is - they are the callins themselves - but the sweep that +-- turns their counters into averages walks every widget and all of its callins, and that +-- only happens on a tick. So this is worth asking for no faster than the reader needs: +-- subscribers say what they want and the fastest of them wins, since a slower reader is +-- satisfied by numbers arriving sooner than it asked. +local DEFAULT_TICK = 0.1 +local tick = DEFAULT_TICK +-- Set by hand through the profiler's action, which is an explicit instruction and beats +-- what the subscribers asked for. +local tickOverride +-- The window the ordering average is taken over, in seconds. Much longer than the smoothing +-- above on purpose: a list that reorders itself every time a widget has a busy frame cannot +-- be read at all. +local retainSortTime = 100 + +-- [owner] = how often that owner wants the numbers, in seconds. +local subscribers = {} +local subscriberCount = 0 +local hooked = false + +-- [name][callin] = { time since last sample, time since forever, space since last, space } +local callinStats = {} +local wrapped = {} +setmetatable(wrapped, { __mode = "k" }) +local inHook = false +local s = 0 +local startTimer +local deltaTime + +-- What each widget costs, as everything reading this sees it. Entries are updated in place +-- rather than rebuilt, since the sweep runs several times a second for the whole widget list. +-- +-- load smoothed share of wall clock, in percent +-- space smoothed allocation rate, in kB/s +-- avg load again over a much longer window, for anything that orders by cost +-- peakTime, peakSpace the callin that accounted for most of each +M.stats = {} +M.total = { load = 0, space = 0 } +M.mem = { lua = 0, global = 0, unsynced = 0, shared = 0 } +M.deltaTime = 0 +-- Counts up once per sample. Anything that builds something out of these numbers can +-- hold it until this moves, rather than rebuilding on every frame for figures that only +-- change ten times a second. +M.gen = 0 + +-- Per-callin detail, which only the widget being drilled into pays for. +local callinAverages = {} +local detailName + +local oldUpdateWidgetCallIn +local oldInsertWidget +local callInsList + +local function calcLoad(old, new, t) + if t and t > 0 then + local exptick = mathExp(-tick / t) + + return old * exptick + new * (1 - exptick) + end + + return new +end + +local function buildCallInsList(wh) + local list, n = {}, 0 + for name, e in pairs(wh) do + local i = stringFind(name, "List", nil, true) + if i and type(e) == "table" then + n = n + 1 + list[n] = stringSub(name, 1, i - 1) + end + end + + return list +end + +-- Keeps the handler's own ordering when a callin list is rebuilt: widgets sit in layer +-- order, and re-inserting one anywhere else would change the order call-ins run in. +local function arrayInsert(t, f, g) + if f then + local layer = g.whInfo.layer + local index = 1 + for i = 1, #t do + local v = t[i] + if v == g then + return + end + if layer >= v.whInfo.layer then + index = i + 1 + end + end + table.insert(t, index, g) + end +end + +local function arrayRemove(t, g) + for k = 1, #t do + if t[k] == g then + table.remove(t, k) + + return + end + end +end + +-- Wraps one callin of one widget. The timer is taken before the real function and read +-- after it, and the allocation counter with it; `inHook` keeps a callin that calls another +-- widget's callin from being counted twice. +local function hook(w, name) + local widgetName = w.whInfo.name + local realFunc = w[name] + w["_old" .. name] = realFunc + + -- Measuring the measurer only makes the measurement worse. + if widgetName == "Widget Profiler" then + return realFunc + end + + local stats = callinStats[widgetName] + if not stats then + stats = {} + callinStats[widgetName] = stats + end + stats[name] = stats[name] or { 0, 0, 0, 0 } + local c = stats[name] + + local t + + local helperFunc = function(...) + local dt = spDiffTimers(spGetTimer(), t, nil, highres) + local _, _, newS, _ = spGetLuaMemUsage() + local ds = newS - s + c[1] = c[1] + dt + c[2] = c[2] + dt + c[3] = c[3] + ds + c[4] = c[4] + ds + inHook = false + + return ... + end + + local hookFunc = function(...) + if inHook then + return realFunc(...) + end + + inHook = true + t = spGetTimer() + local _, _, newS, _ = spGetLuaMemUsage() + s = newS + + return helperFunc(realFunc(...)) + end + + wrapped[hookFunc] = true + + return hookFunc +end + +local function startHook() + local wh = widgetHandler + callInsList = callInsList or buildCallInsList(wh) + + for i = 1, #callInsList do + local callin = callInsList[i] + local list = wh[callin .. "List"] + if list then + for j = 1, #list do + list[j][callin] = hook(list[j], callin) + end + end + end + + -- A widget that gains or loses a callin later, and one that loads later, both have to + -- be wrapped too, or they measure as free. + oldUpdateWidgetCallIn = wh.UpdateWidgetCallInRaw + wh.UpdateWidgetCallInRaw = function(self, name, w) + local ciList = self[name .. "List"] + if ciList then + local func = w[name] + if type(func) == "function" then + if not wrapped[func] then + w[name] = hook(w, name) + end + arrayInsert(ciList, func, w) + else + arrayRemove(ciList, w) + end + self:UpdateCallIn(name) + else + Spring.Echo("UpdateWidgetCallIn: bad name: " .. name) + end + end + + oldInsertWidget = wh.InsertWidgetRaw + wh.InsertWidgetRaw = function(self, w) + if w == nil then + return + end + oldInsertWidget(self, w) + for i = 1, #callInsList do + local callin = callInsList[i] + if type(w[callin]) == "function" then + w[callin] = hook(w, callin) + end + end + end + + startTimer = spGetTimer() + hooked = true +end + +local function stopHook() + local wh = widgetHandler + local list = callInsList or buildCallInsList(wh) + + -- Every widget the handler holds, not only the ones still in a callin list: a widget + -- that dropped a callin after it was wrapped is no longer in that list, and leaving it + -- wrapped would have it measuring into a table nobody reads for the rest of the + -- session. That costs nothing while profiling runs once; this goes on and off as often + -- as a panel is opened. + for i = 1, #wh.widgets do + local w = wh.widgets[i] + for j = 1, #list do + local callin = list[j] + local old = w["_old" .. callin] + if old then + w[callin] = old + w["_old" .. callin] = nil + end + end + end + + if oldUpdateWidgetCallIn then + wh.UpdateWidgetCallInRaw = oldUpdateWidgetCallIn + oldUpdateWidgetCallIn = nil + end + if oldInsertWidget then + wh.InsertWidgetRaw = oldInsertWidget + oldInsertWidget = nil + end + + hooked = false + callinStats = {} + callinAverages = {} + for name in pairs(M.stats) do + M.stats[name] = nil + end + M.total.load, M.total.space = 0, 0 +end + +---------------------------------------------------------------- +-- API +---------------------------------------------------------------- + +-- How often everyone wanting the numbers needs them, which is as often as the most +-- impatient of them asked. +local function retick() + if tickOverride then + tick = tickOverride + + return + end + local want + for _, interval in pairs(subscribers) do + if not want or interval < want then + want = interval + end + end + tick = want or DEFAULT_TICK +end + +-- `owner` is any unique key; the widget table itself does. Subscribing twice from the same +-- owner counts once, so a panel can ask on every toggle without keeping track. `interval` +-- is how often that owner wants the numbers refreshed - a reader that updates a column a +-- player is glancing at does not need them as often as one drawing a live graph. +function M.subscribe(owner, interval) + if subscribers[owner] then + return + end + subscribers[owner] = tonumber(interval) or DEFAULT_TICK + subscriberCount = subscriberCount + 1 + retick() + if subscriberCount == 1 then + startHook() + end +end + +function M.unsubscribe(owner) + if not subscribers[owner] then + return + end + subscribers[owner] = nil + subscriberCount = subscriberCount - 1 + retick() + if subscriberCount == 0 then + stopHook() + end +end + +function M.subscribed() + return subscriberCount +end + +-- Whether this particular owner is one of them, which is how a panel restoring a saved +-- setting can tell whether it has acted on it yet. +function M.subscribes(owner) + return subscribers[owner] ~= nil +end + +function M.isHooked() + return hooked +end + +-- Sets the rate by hand, whatever the subscribers asked for; nil gives it back to them. +function M.setTick(seconds) + tickOverride = tonumber(seconds) + retick() + + return tick +end + +function M.getTick() + return tick +end + +-- Which widget's per-callin breakdown to keep. Only one at a time: the smoothing behind it +-- costs a table per callin, and nothing reads more than one at once. +function M.setDetail(name) + if detailName ~= name then + detailName = name + callinAverages = {} + end +end + +function M.callins(name) + return callinAverages[name] +end + +-- Turns the raw counters into averages, at most once per tick however often it is called - +-- several panels can ask on the same frame and only the first does the work. Answers true +-- when new numbers landed. +function M.sample() + if not hooked or not startTimer then + return false + end + + deltaTime = spDiffTimers(spGetTimer(), startTimer, nil, highres) + if deltaTime < tick then + return false + end + startTimer = spGetTimer() + M.deltaTime = deltaTime + + local averageTime = spGetConfigFloat("profiler_averagetime", 2) + -- The long-window average is in frames, and a frame is however long the tick or the + -- frame rate makes it. + local frames = mathMin(1 / tick, spGetFPS()) * retainSortTime + local framesMinusOne = frames - 1 + + local totalLoad, totalSpace = 0, 0 + + for name, callins in pairs(callinStats) do + local t, space = 0, 0 + local peakT, peakTName = 0, "-" + local peakS, peakSName = 0, "-" + + local detail + if name == detailName then + detail = callinAverages[name] + if not detail then + detail = {} + callinAverages[name] = detail + end + end + + for cname, c in pairs(callins) do + local c1, c2, c3, c4 = c[1], c[2], c[3], c[4] + t = t + c1 + if c2 > peakT then + peakT, peakTName = c2, cname + end + c[1] = 0 + + space = space + c3 + if c4 > peakS then + peakS, peakSName = c4, cname + end + c[3] = 0 + + if detail then + local relT = 100 * c1 / deltaTime + local relS = c3 / deltaTime + local prev = detail[cname] + if prev then + prev[1] = calcLoad(prev[1], relT, averageTime) + prev[2] = calcLoad(prev[2], relS, averageTime) + else + detail[cname] = { relT, relS } + end + end + end + + local entry = M.stats[name] + if not entry then + entry = { load = 100 * t / deltaTime, space = space / deltaTime } + entry.avg = entry.load * 0.7 + M.stats[name] = entry + end + + entry.load = calcLoad(entry.load, 100 * t / deltaTime, averageTime) + entry.space = calcLoad(entry.space, space / deltaTime, averageTime) + entry.avg = ((entry.avg * framesMinusOne) + entry.load) / frames + entry.share = t / deltaTime + entry.peakTime = peakTName + entry.peakSpace = peakSName + + totalLoad = totalLoad + entry.load + totalSpace = totalSpace + entry.space + end + + M.total.load, M.total.space = totalLoad, totalSpace + M.gen = M.gen + 1 + + local lm, _, gm, _, um, _, sm, _ = spGetLuaMemUsage() + M.mem.lua, M.mem.global, M.mem.unsynced, M.mem.shared = lm, gm, um, sm + + return true +end + +WG.widgetProfiling = M + +return M diff --git a/luaui/Widgets/dbg_widget_profiler.lua b/luaui/Widgets/dbg_widget_profiler.lua index 444dee0f523..e2c1aa89d91 100644 --- a/luaui/Widgets/dbg_widget_profiler.lua +++ b/luaui/Widgets/dbg_widget_profiler.lua @@ -24,8 +24,6 @@ local mathMin = math.min local mathRandom = math.random local mathExp = math.exp local tableSort = table.sort -local tableInsert = table.insert -local tableRemove = table.remove local stringChar = string.char local stringSub = string.sub local stringFind = string.find @@ -40,9 +38,6 @@ local type = type -- Localized Spring API for performance local spEcho = Spring.Echo -local spGetLuaMemUsage = Spring.GetLuaMemUsage -local spDiffTimers = Spring.DiffTimers -local spGetTimer = Spring.GetTimer local glText = gl.Text local glColor = gl.Color local glBeginText = gl.BeginText @@ -51,10 +46,12 @@ local glGetViewSizes = gl.GetViewSizes local glRect = gl.Rect local glGetTextWidth = gl.GetTextWidth -local usePrefixedNames = true +-- The measurement itself, which the widget selector reads too. Wrapping every callin is +-- global and does not nest, so exactly one thing may do it; this widget draws what that +-- one thing measures. +local profiling = VFS.Include("luaui/Include/widget_profiling.lua") -local tick = 0.1 -local retainSortTime = 100 +local usePrefixedNames = true local minPerc = 0.005 -- above this value, we fade in how red we mark a widget local maxPerc = 0.02 -- above this value, we mark a widget as red @@ -74,50 +71,22 @@ local prefixColor = { dbg = "\255\120\120\120", } -local s -local callinStats = {} -local highres - -local timeLoadAverages = {} -local spaceLoadAverages = {} -local startTimer - local userWidgets = {} -local oldUpdateWidgetCallIn -local oldInsertWidget - -local listOfHooks = {} -setmetatable(listOfHooks, { __mode = "k" }) -local inHook = false - -local lm, _, gm, _, um, _, sm, _ = spGetLuaMemUsage() +local sortedList = {} +-- Copied off the include on each sample, since the drawing reads them many times over. +local lm, gm, um, sm = 0, 0, 0, 0 local allOverTime = 0 -local allOverTimeSec = 0 -- currently unused local allOverSpace = 0 -local avgTLoad = {} -local sortedList = {} - --- Per-callin drill-down state (only populated for the currently selected widget) -local callinLoadAverages = {} -- [wname] = { [cname] = { tLoad, sLoad } } -local selectedWidget = nil -- wname (prefixed plainname) currently drilled into, or nil +local selectedWidget = nil -- name of the widget currently drilled into, or nil local clickableRows = {} -- reused each frame: { {x1, y1, x2, y2, plainname}, ... } local clickableRowCount = 0 -- how many entries of clickableRows are valid this frame local detailColour = "\255\255\255\255" -local deltaTime local redStrength = {} - local ColorString = BAR.Utilities.Color.ToString -if Spring.GetTimerMicros and Spring.GetConfigInt("UseHighResTimer", 0) == 1 then - spGetTimer = Spring.GetTimerMicros - highres = true -end - -spEcho("Profiler using highres timers", highres, Spring.GetConfigInt("UseHighResTimer", 0)) - local prefixedWnames = {} local widgetNameColors = {} -- Store RGB values for background tinting local function ConstructPrefixedName(ghInfo) @@ -150,34 +119,6 @@ local function ConstructPrefixedName(ghInfo) return prefixedWnames[gadgetName] end -local function ArrayInsert(t, f, g) - if f then - local layer = g.whInfo.layer - local index = 1 - local tLen = #t - for i = 1, tLen do - local v = t[i] - if v == g then - return -- already in the table - end - if layer >= v.whInfo.layer then - index = i + 1 - end - end - tableInsert(t, index, g) - end -end - -local function ArrayRemove(t, g) - local tLen = #t - for k = 1, tLen do - if t[k] == g then - tableRemove(t, k) - return -- Only one instance to remove - end - end -end - local function widgetprofilertickrateCmd(_, line) local token = {} local n = 0 @@ -186,9 +127,9 @@ local function widgetprofilertickrateCmd(_, line) token[n] = w end if token[1] then - tick = tonumber(token[1]) or tick + profiling.setTick(token[1]) end - spEcho("Setting widget profiler to tick=", tick) + spEcho("Setting widget profiler to tick=", profiling.getTick()) return true end @@ -202,181 +143,10 @@ function widget:Initialize() for name, wData in pairs(widgetHandler.knownWidgets) do userWidgets[name] = not wData.fromZip end -end - -local function IsHook(func) - return listOfHooks[func] -end - --- Cache CallInsList to avoid rebuilding it multiple times -local cachedCallInsList -local function BuildCallInsList(wh) - local CallInsList = {} - local CallInsListCount = 0 - for name, e in pairs(wh) do - local i = stringFind(name, "List", nil, true) - if i and type(e) == "table" then - CallInsListCount = CallInsListCount + 1 - CallInsList[CallInsListCount] = stringSub(name, 1, i - 1) - end - end - return CallInsList -end - -local wname2name = {} -local function Hook(w, name) - -- name is the callin - local widgetName = w.whInfo.name - - local wname = prefixedWnames[widgetName] or ConstructPrefixedName(w.whInfo) - wname2name[wname] = widgetName - - local realFunc = w[name] - w["_old" .. name] = realFunc - - if widgetName == "Widget Profiler" then - return realFunc -- don't profile the profilers callins (it works, but it is better that our DrawScreen call is unoptimized and expensive anyway!) - end - - local widgetCallinTime = callinStats[wname] or {} - callinStats[wname] = widgetCallinTime - widgetCallinTime[name] = widgetCallinTime[name] or { 0, 0, 0, 0 } - local c = widgetCallinTime[name] - - local t - - local helper_func = function(...) - local dt = spDiffTimers(spGetTimer(), t, nil, highres) - local _, _, new_s, _ = spGetLuaMemUsage() - local ds = new_s - s - c[1] = c[1] + dt - c[2] = c[2] + dt - c[3] = c[3] + ds - c[4] = c[4] + ds - inHook = nil - return ... - end - - local hook_func = function(...) - if inHook then - return realFunc(...) - end - - inHook = true - t = spGetTimer() - local _, _, new_s, _ = spGetLuaMemUsage() - s = new_s - return helper_func(realFunc(...)) - end - - listOfHooks[hook_func] = true - return hook_func -end - -local function StartHook() - spEcho("start profiling") - - local wh = widgetHandler - - -- Build and cache CallInsList - if not cachedCallInsList then - cachedCallInsList = BuildCallInsList(wh) - end - local CallInsList = cachedCallInsList - - --// hook all existing callins - for i = 1, #CallInsList do - local callin = CallInsList[i] - local callinGadgets = wh[callin .. "List"] - if callinGadgets then - for j = 1, #callinGadgets do - local w = callinGadgets[j] - w[callin] = Hook(w, callin) - end - end - end - - spEcho("hooked all callins") - - --// hook the UpdateCallin function - oldUpdateWidgetCallIn = wh.UpdateWidgetCallInRaw - wh.UpdateWidgetCallInRaw = function(self, name, w) - local listName = name .. "List" - local ciList = self[listName] - if ciList then - local func = w[name] - if type(func) == "function" then - if not IsHook(func) then - w[name] = Hook(w, name) - end - ArrayInsert(ciList, func, w) - else - ArrayRemove(ciList, w) - end - self:UpdateCallIn(name) - else - print("UpdateWidgetCallIn: bad name: " .. name) - end - end - - spEcho("hooked UpdateCallin") - - --// hook the InsertWidget function - oldInsertWidget = wh.InsertWidgetRaw - widgetHandler.InsertWidgetRaw = function(self, widget) - if widget == nil then - return - end - - oldInsertWidget(self, widget) - - for i = 1, #CallInsList do - local callin = CallInsList[i] - local func = widget[callin] - if type(func) == "function" then - widget[callin] = Hook(widget, callin) - end - end - end - spEcho("hooked InsertWidget") -end - -local function StopHook() - spEcho("stop profiling") - - local wh = widgetHandler - - -- Use cached CallInsList - local CallInsList = cachedCallInsList or BuildCallInsList(wh) - - --// unhook all existing callins - for i = 1, #CallInsList do - local callin = CallInsList[i] - local callinWidgets = wh[callin .. "List"] - if callinWidgets then - for j = 1, #callinWidgets do - local w = callinWidgets[j] - if w["_old" .. callin] then - w[callin] = w["_old" .. callin] - end - end - end - end - - spEcho("unhooked all callins") - - --// unhook the UpdateCallin and InsertWidget functions - wh.UpdateWidgetCallInRaw = oldUpdateWidgetCallIn - spEcho("unhooked UpdateCallin") - wh.InsertWidgetRaw = oldInsertWidget - spEcho("unhooked InsertWidget") -end - -function widget:Update() - widgetHandler:RemoveWidgetCallIn("Update", self) - StartHook() - startTimer = spGetTimer() + -- Being loaded is this widget's on switch, so this is where the hooks go in. The + -- include counts who wants them; the widget selector may already have asked. + profiling.subscribe(self) end function widget:Shutdown() @@ -385,7 +155,7 @@ function widget:Shutdown() elseif widgetHandler.actionHandler and widgetHandler.actionHandler.RemoveAction then widgetHandler.actionHandler:RemoveAction(self, "widgetprofilertickrate", "t") end - StopHook() + profiling.unsubscribe(self) end -- Click a widget row to drill into its per-callin breakdown; click it again to close. @@ -400,23 +170,15 @@ function widget:MousePress(mx, my, button) selectedWidget = nil else selectedWidget = r[5] - callinLoadAverages[r[5]] = {} -- start a fresh smoothing window end + -- Only one breakdown is smoothed at a time, and setting it starts a fresh window. + profiling.setDetail(selectedWidget) return true end end return false end -local function CalcLoad(old_load, new_load, t) - if t and t > 0 then - local exptick = mathExp(-tick / t) - return old_load * exptick + new_load * (1 - exptick) - else - return new_load - end -end - -- Precompute constants for GetRedColourStrings local colorScaleFactor = (255 - 64) / 255 local percRange = maxPerc - minPerc @@ -427,7 +189,7 @@ function GetRedColourStrings(v) local tTime = v.tTime local sLoad = v.sLoad local name = v.plainname - local u = mathExp(-deltaTime / 5) --magic colour changing rate + local u = mathExp(-profiling.deltaTime / 5) --magic colour changing rate local oneMinusU = 1 - u -- Clamp tTime @@ -586,7 +348,7 @@ end -- Drill-down view: every callin of the selected widget, sorted by cpu time local function DrawDetailPanel(x, y, fontSize, lineSpace, panelWidth) - local avgs = callinLoadAverages[selectedWidget] + local avgs = selectedWidget and profiling.callins(selectedWidget) if not avgs then return end @@ -634,13 +396,7 @@ local function DrawDetailPanel(x, y, fontSize, lineSpace, panelWidth) return ly end - glText( - title_colour .. "CALLIN BREAKDOWN " .. detailColour .. (wname2name[selectedWidget] or selectedWidget), - x, - line(1), - fontSize, - "no" - ) + glText(title_colour .. "CALLIN BREAKDOWN " .. detailColour .. selectedWidget, x, line(1), fontSize, "no") local hy = line() glText(totals_colour .. "time", timeColX, hy, fontSize, "no") @@ -675,105 +431,37 @@ local function DrawDetailPanel(x, y, fontSize, lineSpace, panelWidth) end function widget:DrawScreen() - if not next(callinStats) then - return - end - - local averageTime = Spring.GetConfigFloat("profiler_averagetime", 2) - - -- sort & count timing - deltaTime = spDiffTimers(spGetTimer(), startTimer, nil, highres) - if deltaTime >= tick then - startTimer = spGetTimer() + -- Whatever else is subscribed, the numbers are only recomputed on a tick; this + -- answers true on the frames a new set landed, which is when the list is rebuilt. + if profiling.sample() then sortedList = {} - allOverTime = 0 allOverSpace = 0 local n = 1 local sortByLoad = Spring.GetConfigInt("profiler_sort_by_load", 1) == 1 - -- Cache FPS and frame calculation - local frames = mathMin(1 / tick, Spring.GetFPS()) * retainSortTime - local framesMinusOne = frames - 1 - - for wname, callins in pairs(callinStats) do - local t = 0 -- would call it time, but protected - local cmax_t = 0 - local cmaxname_t = "-" - local space = 0 - local cmax_space = 0 - local cmaxname_space = "-" - - -- Only smooth the per-callin breakdown for the widget being drilled into, - -- so there is zero extra cost when nothing is selected. - local capture = wname == selectedWidget - local wCallinAvg - if capture then - wCallinAvg = callinLoadAverages[wname] - if not wCallinAvg then - wCallinAvg = {} - callinLoadAverages[wname] = wCallinAvg - end - end - - for cname, c in pairs(callins) do - local c1, c2, c3, c4 = c[1], c[2], c[3], c[4] - t = t + c1 - if c2 > cmax_t then - cmax_t = c2 - cmaxname_t = cname - end - c[1] = 0 - - space = space + c3 - if c4 > cmax_space then - cmax_space = c4 - cmaxname_space = cname - end - c[3] = 0 - - if capture then - local relT = 100 * c1 / deltaTime - local relS = c3 / deltaTime - local prev = wCallinAvg[cname] - if prev then - prev[1] = CalcLoad(prev[1], relT, averageTime) - prev[2] = CalcLoad(prev[2], relS, averageTime) - else - wCallinAvg[cname] = { relT, relS } - end - end - end - - local relTime = 100 * t / deltaTime - timeLoadAverages[wname] = CalcLoad(timeLoadAverages[wname] or relTime, relTime, averageTime) - - local relSpace = space / deltaTime - spaceLoadAverages[wname] = CalcLoad(spaceLoadAverages[wname] or relSpace, relSpace, averageTime) - - allOverTimeSec = allOverTimeSec + t - - local tLoad = timeLoadAverages[wname] - if not avgTLoad[wname] then - avgTLoad[wname] = tLoad * 0.7 - end - avgTLoad[wname] = ((avgTLoad[wname] * framesMinusOne) + tLoad) / frames - local sLoad = spaceLoadAverages[wname] - if not sortByLoad or avgTLoad[wname] >= 0.05 or sLoad >= 5 then -- only show heavy ones + for name, stat in pairs(profiling.stats) do + if not sortByLoad or stat.avg >= 0.05 or stat.space >= 5 then -- only show heavy ones sortedList[n] = { - name = wname2name[wname], - plainname = wname, - fullname = wname .. " \255\166\166\166(" .. cmaxname_t .. "," .. cmaxname_space .. ")", - tLoad = tLoad, - sLoad = sLoad, - tTime = t / deltaTime, - avgTLoad = avgTLoad[wname], + name = name, + plainname = name, + fullname = (prefixedWnames[name] or ConstructPrefixedName({ name = name, basename = name })) + .. " ­vvv(" + .. stat.peakTime + .. "," + .. stat.peakSpace + .. ")", + tLoad = stat.load, + sLoad = stat.space, + tTime = stat.share, + avgTLoad = stat.avg, } n = n + 1 end - allOverTime = allOverTime + tLoad - allOverSpace = allOverSpace + sLoad end + + allOverTime, allOverSpace = profiling.total.load, profiling.total.space + if sortByLoad then tableSort(sortedList, function(a, b) return a.avgTLoad > b.avgTLoad @@ -788,7 +476,7 @@ function widget:DrawScreen() for i = 1, sortedLen do GetRedColourStrings(sortedList[i]) end - lm, _, gm, _, um, _, sm, _ = spGetLuaMemUsage() + lm, gm, um, sm = profiling.mem.lua, profiling.mem.global, profiling.mem.unsynced, profiling.mem.shared end if not sortedList[1] then @@ -970,9 +658,17 @@ function widget:DrawScreen() ) j = j + 2 - glText(title_colour .. "Tick time: " .. tick .. "s", x, y - lineSpace * j, fontSize, "no") + glText(title_colour .. "Tick time: " .. profiling.getTick() .. "s", x, y - lineSpace * j, fontSize, "no") j = j + 1 - glText(title_colour .. "Smoothing time: " .. averageTime .. "s", x, y - lineSpace * j, fontSize, "no") + -- Read here rather than carried down from the sampling block, which moved into the + -- profiling include along with the smoothing it names. + glText( + title_colour .. "Smoothing time: " .. Spring.GetConfigFloat("profiler_averagetime", 2) .. "s", + x, + y - lineSpace * j, + fontSize, + "no" + ) glEndText() end diff --git a/luaui/Widgets/widget_selector.lua b/luaui/Widgets/widget_selector.lua index 8bacf49ce91..1578b775768 100644 --- a/luaui/Widgets/widget_selector.lua +++ b/luaui/Widgets/widget_selector.lua @@ -32,6 +32,10 @@ local Dropdown = VFS.Include("luaui/Include/keybind_dropdown.lua") local text = VFS.Include("luaui/Include/keybind_text.lua") local KEYSYMS = VFS.Include("luaui/Include/keybind_keysyms.lua") local Search = VFS.Include("luaui/Include/search.lua") +-- Wrapping every callin to time it is global and does not nest, so one thing owns it and +-- everything else reads what it measured. Subscribing is what puts the wrappers in, so +-- nothing is paid for until the column is switched on. +local profiling = VFS.Include("luaui/Include/widget_profiling.lua") -- Localized functions for performance local mathFloor = math.floor @@ -172,6 +176,23 @@ local colorText = "\255\235\235\235" local colorPending = "\255\255\210\135" local colorLocal = "\255\130\175\230" 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 +-- each column ever prints and what its width is measured from. +-- +-- One table rather than eight locals, for the same reason `metrics` and `look` are +-- tables: this chunk is at Lua's ceiling of 200. +local cost = { + cool = "\255\140\140\140", + warm = "\255\225\195\130", + hot = "\255\255\150\110", + cpuWarn = 0.5, + cpuHot = 2, + memWarn = 20, + memHot = 100, + sampleCpu = "99.9%", + sampleMem = "9999k", +} -- Filename prefixes, spelled out. Everything the game ships carries one; a widget with a -- prefix that is not here - which is most of what a player writes - falls into Other, so @@ -208,7 +229,7 @@ local OTHER = "other" local L = {} local show, showOnceMore -local panelList, windowList, backgroundGuishader, panelSig +local panelList, windowList, backgroundGuishader local listTop, listBottom, listX1, listRight, descX1, barX1 = 0, 0, 0, 0, 0, 0 local switchX1, orderX1, nameX1, clearX1 = 0, 0, 0, 0 -- The sets block at the foot of the category column: a caption, the picker, and the @@ -229,6 +250,10 @@ local switches = { { key = "localOnly" }, { key = "enabledOnly" }, { key = "byOrder" }, + { key = "profiler" }, + -- Only offered while the column it orders by is showing. `sub` is what keeps it out + -- of the header the rest of the time. + { key = "byLoad", sub = "profiler" }, } -- Every widget the panel can show, as rows; and the categories they fall into. @@ -252,7 +277,7 @@ local selectedCategory -- `localOnly` keeps the player's own files. `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 } +local filters = { localOnly = false, enabledOnly = false, byOrder = false, profiler = false, byLoad = false } ---@type table local searchBox ---@type table @@ -340,10 +365,23 @@ local function stateOf(name, data) return 0 end +-- The rolling check for anything that changed behind the panel's back, and where it has +-- got to. One table rather than four locals, for the same reason `metrics` and `cost` are +-- tables: this chunk is at Lua's ceiling of 200. `moved` is filled in further down, once +-- there is a row layout for it to read. +-- `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 = {} } + +-- `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 +-- replaced, so deciding whether to repaint costs nothing to collect either. + -- Whether the handler is holding anything this widget saved. An empty table is nothing -- to clear: a widget with a GetConfigData that returns `{}` would otherwise offer a -- button that does nothing. -local function hasConfigData(name) +function sweep.hasConfig(name) local d = widgetHandler.configData[name] return type(d) == "table" and next(d) ~= nil @@ -400,7 +438,7 @@ local function buildEntries() data = data, group = groupOf(data), state = stateOf(name, data), - hasConfig = hasConfigData(name), + hasConfig = sweep.hasConfig(name), order = order[name], layer = layer[name], desc = desc, @@ -503,6 +541,36 @@ local function sortEntries(a, b) return a.name < b.name end +-- Heaviest first, on exactly the figure the row shows. Ordering on some other average of +-- the same widget puts a row reading 0.4% above one reading 2.0%, and a list a reader +-- cannot check is worse than no order at all. What keeps it from reshuffling under the +-- 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) + local sa = profiling.stats[a.name] + local sb = profiling.stats[b.name] + if sa and sb then + if sa.load ~= sb.load then + return sa.load > sb.load + end + return a.name < b.name + end + if sa or sb then + return sa ~= nil + end + + return a.name < b.name +end + +local function rowOrder() + if filters.byLoad and filters.profiler then + return sortByLoad + end + + return filters.byOrder and sortByOrder or sortEntries +end + -- The rows the list shows: what the column, the search box and the filter toggle 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. @@ -543,13 +611,13 @@ rebuildRows = function() return a.score > b.score end - return (filters.byOrder and sortByOrder or sortEntries)(a.e, b.e) + return rowOrder()(a.e, b.e) end) for i = 1, #scored do rows[i] = scored[i].e end else - table.sort(rows, filters.byOrder and sortByOrder or sortEntries) + table.sort(rows, rowOrder()) end end @@ -731,8 +799,10 @@ local function applySet(name) local want = set.widgets[e.name] == true if want and e.state == 0 then widgetHandler:EnableWidget(e.name) + sweep.dirty = true elseif not want and e.state > 0 then widgetHandler:DisableWidget(e.name) + sweep.dirty = true end end end @@ -775,6 +845,7 @@ end local function disableAll() for i = 1, #entries do widgetHandler:DisableWidget(entries[i].name) + sweep.dirty = true end widgetHandler:SaveConfigData() end @@ -813,6 +884,7 @@ local function clearConfigData(name) local known = widgetHandler.knownWidgets[name] if known and known.active then widgetHandler:DisableWidget(name) + sweep.dirty = true pendingClear = { name = name, restart = true } return @@ -874,6 +946,26 @@ local function confirm(title, message, accept, danger, field, initial) end end +-- Turns the measurement on and off with the column that shows it. Subscribing is what puts +-- the wrappers into every widget's callins, so a player who never opens this column never +-- pays for it; unsubscribing takes them out again, unless the profiler widget is also up. +local function applyProfiling() + if filters.profiler then + -- A third as often as the profiler panel asks for. The sweep that turns the raw + -- counters into averages walks every widget and all of its callins, and a column being + -- glanced at down a list does not need it ten times a second - three is still quicker + -- than anyone reads a number, and the figures are smoothed over two seconds anyway, so + -- nothing is lost between samples. With the profiler panel up as well its faster rate + -- wins and both are satisfied. + profiling.subscribe(widget, 0.3) + else + profiling.unsubscribe(widget) + -- Nothing left to order by, so the sub-switch goes with it rather than sitting on + -- with a list it can no longer arrange. + filters.byLoad = false + end +end + local function buttonAction(id) if id == "reload" then reloadLuaUI() @@ -987,11 +1079,25 @@ setLayout = function() -- The switch owns a column at the head of the row, and the name starts after it. metrics.switchH = mathFloor(metrics.rowHeight * 0.46) metrics.switchW = mathFloor(metrics.switchH * 2.2) + + -- What each widget costs, ahead of the control that turns it off: the number is the + -- reason for reaching for the switch, so it is read first. Both columns are measured + -- 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) -- 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. metrics.switchGap = mathFloor((metrics.rowHeight - metrics.switchH) * 0.5) - switchX1 = listX1 + metrics.accentW + metrics.switchGap + metrics.cpuX1 = listX1 + metrics.accentW + metrics.switchGap + metrics.memX1 = metrics.cpuX1 + metrics.cpuW + metrics.rowPad + if filters.profiler then + switchX1 = metrics.memX1 + metrics.memW + metrics.rowPad * 2 + else + switchX1 = metrics.cpuX1 + end -- The rank gets a column of its own only while the list is in that order: a number -- nobody is reading is clutter, and the name is worth the room. orderX1 = switchX1 + metrics.switchW + metrics.rowPad * 2 @@ -1023,20 +1129,29 @@ setLayout = function() metrics.captionBleed = mathFloor(metrics.toggleFs * 0.2 + 0.5) local x2 = area.x2 - metrics.edgeInset + local lastSwitch for i = 1, #switches do local sw = switches[i] sw.label = L[sw.key] - local w = font and mathFloor(font:GetTextWidth(sw.label) * metrics.toggleFs) or mathFloor(90 * s) - sw.draw = { x2 - togW, togY1, x2, togY1 + togH } - -- The caption is part of the control: a switch this small is a poor click target on - -- its own, and the words beside it are what names the thing being switched. - sw.hit = { sw.draw[1] - metrics.rowPad * 2 - w - metrics.captionBleed, rowBottom, x2 + metrics.rowPad, rowTop } - x2 = sw.hit[1] - mathFloor(14 * s) + -- A sub-switch is only there while what it qualifies is on. No rect means it is not + -- drawn and cannot be hit, so nothing else has to know about it. + if sw.sub and not filters[sw.sub] then + sw.draw, sw.hit = nil, nil + else + local w = font and mathFloor(font:GetTextWidth(sw.label) * metrics.toggleFs) or mathFloor(90 * s) + sw.draw = { x2 - togW, togY1, x2, togY1 + togH } + -- The caption is part of the control: a switch this small is a poor click target on + -- its own, and the words beside it are what names the thing being switched. + sw.hit = + { sw.draw[1] - metrics.rowPad * 2 - w - metrics.captionBleed, rowBottom, x2 + metrics.rowPad, rowTop } + x2 = sw.hit[1] - mathFloor(14 * s) + lastSwitch = sw + end end -- Wider than the gaps inside a switch, so the last caption reads as belonging to the -- switch beside it rather than to the field it would otherwise sit against. - searchBox:setRect(listX1, rowBottom, switches[#switches].hit[1] - mathFloor(28 * s), rowTop, fs) + searchBox:setRect(listX1, rowBottom, lastSwitch.hit[1] - mathFloor(28 * s), rowTop, fs) -- The sets block, measured up from the foot of the category card. local setsPad = mathFloor(8 * s) @@ -1238,6 +1353,58 @@ local function drawRow(row, top, bottom, hovered, overSwitch, overClear) end end +-- The two cost columns, drawn live rather than baked into the panel list with the rest of +-- the row. They change several times a second, and re-baking three hundred rows at that +-- rate would cost more than the numbers are worth; everything else on the row is static +-- between hovers, so it stays in the list. +-- +-- 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 + return + end + + cost.font:Begin() + for i = 1, #rows - scroll do + local row = rows[scroll + i] + if not row then + break + end + local top = listTop - (i - 1) * metrics.rowHeight + if top - metrics.rowHeight < listBottom then + break + end + local stat = profiling.stats[row.name] + if stat then + local ty = mathFloor(top - metrics.rowHeight * 0.5) + -- Each figure is coloured by itself, not by some other average of the same widget: + -- a row reading 0.4% in the warning colour beside one reading 2.0% in the quiet one + -- says the colour means nothing. + 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( + (cpu >= cost.cpuHot and cost.hot or cpu >= cost.cpuWarn and cost.warm or cost.cool) + .. string.format("%.1f%%", cpu), + metrics.cpuX1 + metrics.cpuW, + ty, + metrics.loadFs, + "rov" + ) + cost.font: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, + ty, + metrics.loadFs, + "rov" + ) + end + end + cost.font:End() +end + local function drawRows() for i = 1, #rows - scroll do local row = rows[scroll + i] @@ -1369,7 +1536,9 @@ end local function drawHeader() for i = 1, #switches do local sw = switches[i] - drawSwitch(sw.draw, sw.hit, sw.label, filters[sw.key], hover.tog == i) + if sw.draw then + drawSwitch(sw.draw, sw.hit, sw.label, filters[sw.key], hover.tog == i) + end end end @@ -1511,7 +1680,6 @@ local function dropLists() if panelList then glDeleteList(panelList) panelList = nil - panelSig = nil end if windowList then glDeleteList(windowList) @@ -1622,7 +1790,7 @@ local function rowClearable(i) return (row and row.hasConfig) and row or nil end -local function panelSignature(mx, my) +local function panelChanged(mx, my) hover.sb, hover.row, hover.sw, hover.tog, hover.bar, hover.clr = 0, 0, 0, 0, 0, 0 hover.btn, hover.dlg = "", "" @@ -1662,45 +1830,55 @@ local function panelSignature(mx, my) hover.bar = 1 end end - for _, set in ipairs({ buttons, setButtons }) do - for _, b in ipairs(set) do - local r = b.rect - if r and math_isInRect(mx, my, r[1], r[2], r[3], r[4]) then - hover.btn = b.id - end + -- Written out rather than looped over a { buttons, setButtons } literal: that literal + -- is a table built and thrown away on every frame the panel is open. + for i = 1, #buttons do + local r = buttons[i].rect + if r and math_isInRect(mx, my, r[1], r[2], r[3], r[4]) then + hover.btn = buttons[i].id + end + end + for i = 1, #setButtons do + local r = setButtons[i].rect + if r and math_isInRect(mx, my, r[1], r[2], r[3], r[4]) then + hover.btn = setButtons[i].id end end end - return hover.sb - .. "|" - .. hover.row - .. "|" - .. hover.sw - .. "|" - .. hover.tog - .. "|" - .. hover.bar - .. "|" - .. hover.clr - .. "|" - .. hover.btn - .. "|" - .. hover.dlg - .. "|" - .. scroll - .. "|" - .. rowsGen - .. "|" - .. layoutGen - .. "|" - .. catScroll - .. "|" - .. (dragging and 1 or 0) - .. "|" - .. (dialog and 1 or 0) - .. "|" - .. (select(2, dialogName()) and 1 or 0) + -- Compared one value at a time against the last frame's rather than joined into a + -- string. This runs on every frame the panel is open, and a string built every frame + -- is a string collected every frame; the two tables here are filled in place and + -- never replaced. + local now, was = sweep.now, sweep.was + now[1] = hover.sb + now[2] = hover.row + now[3] = hover.sw + now[4] = hover.tog + now[5] = hover.bar + now[6] = hover.clr + now[7] = filters.profiler + now[8] = hover.btn + now[9] = hover.dlg + now[10] = scroll + now[11] = rowsGen + now[12] = layoutGen + now[13] = catScroll + now[14] = dragging + now[15] = dialog ~= nil + -- Only asked while a dialog is actually up: answering it trims the field with a gsub, + -- and a gsub on every frame the panel is open is a string on every frame. + now[16] = dialog ~= nil and select(2, dialogName()) or false + + local changed = false + for i = 1, 16 do + if was[i] ~= now[i] then + was[i] = now[i] + changed = true + end + end + + return changed end ---------------------------------------------------------------- @@ -1709,29 +1887,74 @@ end -- Has anything been switched on or off since the rows were built? -- --- Asking every frame the panel is open is what it takes. The handler queues Toggle, --- Enable and Disable and runs them once the callin that asked has returned, so the state --- cannot be read back on the click itself; and a widget can be switched from somewhere --- else entirely - the settings panel, a /luaui command, one erroring out on load - which --- nothing here would otherwise hear about. `knownChanged` does not cover it: the handler --- raises that only when a widget it has never seen registers. +-- Asking is what it takes. The handler queues Toggle, Enable and Disable and runs them +-- once the callin that asked has returned, so the state cannot be read back on the click +-- itself; and a widget can be switched from somewhere else entirely - the settings panel, +-- a /luaui command, one erroring out on load - which nothing here would otherwise hear +-- about. `knownChanged` does not cover it: the handler raises that only when a widget it +-- has never seen registers. -- --- A few hundred table lookups on a frame where a panel is being looked at. +-- Asking about all three hundred of them every frame, though, made this panel one of the +-- most expensive widgets in the game while it was open - it was ninety per cent of what +-- 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. +-- 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) +end + local function contentMoved() - for i = 1, #entries do - local e = entries[i] - if e.state ~= stateOf(e.name, e.data) or e.hasConfig ~= hasConfigData(e.name) then + -- Anything this panel asked for, first and without looking for it. + if sweep.dirty then + sweep.dirty = false + + 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 + local e = rows[scroll + i] + if not e then + break + end + if sweep.moved(e) then return true end end + -- Then a slice of the whole list, carrying on from where the last frame stopped. + local n = #entries + if n > 0 then + for _ = 1, (sweep.slice < n and sweep.slice or n) do + if sweep.at > n then + sweep.at = 1 + end + local e = entries[sweep.at] + sweep.at = sweep.at + 1 + if e and sweep.moved(e) then + return true + end + end + end + -- And the load order, which moves without any state changing: raising or lowering a -- widget only shifts it within the handler's list, and that is queued like the rest. - for i = 1, #widgetHandler.widgets do - local w = widgetHandler.widgets[i] - local e = w.whInfo and entryByName[w.whInfo.name] - if e and e.order ~= i then - return true + local live = widgetHandler.widgets + local m = #live + if m > 0 then + for _ = 1, (sweep.slice < m and sweep.slice or m) do + if sweep.order > m then + sweep.order = 1 + end + local w = live[sweep.order] + local e = w and w.whInfo and entryByName[w.whInfo.name] + if e and e.order ~= sweep.order then + return true + end + sweep.order = sweep.order + 1 end end @@ -1823,6 +2046,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.total = tr("total", "total") + L.profiler = tr("profiler", "Cost") + L.byLoad = tr("byload", "By cost") L.cleardata = tr("cleardata", "Reset") L.cleardataTitle = tr("cleardatatitle", "Clear saved settings") -- The fallbacks only. These two carry the widget's name, and i18n fills a %{...} in @@ -1834,6 +2060,45 @@ local function loadLabels() L.cleardataRestartWarnFallback = "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." L.layer = tr("layer", "Layer") + + -- What every control on the panel does, keyed the way the control names itself. The + -- 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." + ), + enabledOnly = tr( + "enabledonlydesc", + "Show only the widgets the config says to load - running or not - so what is off stays out of the way." + ), + 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." + ), + profiler = tr( + "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." + ), + byLoad = tr( + "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." + ), + reload = tr( + "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( + "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." + ), + saveset = L.saveSetWarn, + deleteset = L.deleteSetWarn, + } end -- What clearing this widget would do, in the words the confirmation uses. Looked up @@ -1874,6 +2139,11 @@ local function bindUi() end font = WG.fonts.getFont() + -- The monospaced face, for the cost columns alone. 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) elementCorner = WG.FlowUI.elementCorner RectRound = WG.FlowUI.Draw.RectRound UiElement = WG.FlowUI.Draw.Element @@ -2026,6 +2296,9 @@ end function widget:Shutdown() deleteGuishader() dropLists() + -- Or every widget in the game keeps a wrapper round every callin for the rest of the + -- session, measuring into a table nobody is left to read. + profiling.unsubscribe(widget) if WG.tooltip then WG.tooltip.RemoveTooltip("widgetselector") end @@ -2054,6 +2327,35 @@ function widget:LanguageChanged() end function widget:Update() + -- Kept up whether or not the panel is open: the averages are a running figure, and one + -- that starts from nothing every time the panel is opened would read as every widget + -- being free for the first few seconds. The include only does the work on a tick. + -- The column may have been switched on by the config, before there was a handler far + -- enough along to wrap. Anything that leaves the two disagreeing is settled here. + if filters.profiler ~= profiling.subscribes(widget) then + applyProfiling() + 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 + local over = show and hover.row > 0 and rows[scroll + hover.row] + profiling.setDetail(over and over.name or nil) + end + + if filters.profiler and profiling.sample() and filters.byLoad and show then + -- Ordering by cost means the list moves under the cursor, and a row that slides + -- away between aiming at it and clicking it is how the wrong widget gets switched + -- off. So while the cursor is anywhere over the list the order is held exactly as + -- it is, and catches up the moment the cursor leaves. The figures on each row keep + -- moving throughout: it is the order that is frozen, not the reading. + local mx, my = spGetMouseState() + if not math_isInRect(mx, my, listX1, listBottom, listRight, listTop) then + rebuildRows() + clampScroll() + end + end + -- The disable asked for on the click has run by now, so the widget has already handed -- its settings back and this is the one moment they can be dropped for good. if pendingClear then @@ -2062,6 +2364,7 @@ function widget:Update() widgetHandler.configData[p.name] = nil if p.restart then widgetHandler:EnableWidget(p.name) + sweep.dirty = true end widgetHandler:SaveConfigData() refreshContent() @@ -2117,10 +2420,84 @@ function widget:Update() end end --- What the cursor is over, said in words. Its own function rather than a block inside --- DrawScreen: the clear button answers with something else entirely and bows out early, --- and an early return in a draw callin would quietly skip whatever is added after it. -local function rowTooltip(row) +-- What the cursor is over, said in words: a switch or button over the panel, or failing +-- that the row under it. One function rather than two, and its own rather than a block +-- inside DrawScreen, where an early return would quietly skip whatever is added after it. +-- What the last tooltip was built from, and what it came out as. gui_tooltip wants the +-- strings on every frame the tooltip is up, and building them again each time is a wrapped, +-- substituted string per frame for a reading nobody changed - and gui_tooltip throws its +-- own display list away whenever the text it is handed differs. +-- +-- Compared field by field rather than through a key, since building a key would be the very +-- string this is avoiding. +local tipCache = {} + +function tipCache.same(a, b, c, d) + return tipCache.title ~= nil and tipCache.a == a and tipCache.b == b and tipCache.c == c and tipCache.d == d +end + +function tipCache.keep(a, b, c, d, title, text) + tipCache.a, tipCache.b, tipCache.c, tipCache.d, tipCache.title, tipCache.text = a, b, c, d, title, text +end + +local function showTooltip(row) + local caption, body + + if 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 + -- The user-widgets button says two different things depending on which way it is + -- pointing, so its wording is picked here rather than baked into the table. + if hover.btn == "userwidgets" then + caption = widgetHandler.allowUserWidgets and L.disallowUser or L.allowUser + body = widgetHandler.allowUserWidgets and L.disallowUserWarn or L.allowUserWarn + else + body = L.desc[hover.btn] + for _, b in ipairs(buttons) do + if b.id == hover.btn then + caption = b.label + end + end + caption = caption or L[hover.btn] + end + end + + if caption and body then + if not tipCache.same("control", caption, body, false) then + tipCache.keep( + "control", + caption, + body, + false, + colorTitle .. caption .. "\n", + "\255\255\255\255" + .. string.gsub(font:WrapText(body, WG.tooltip.getFontsize() * 90), "[\n]", "\n\255\255\255\255") + ) + end + WG.tooltip.ShowTooltip("widgetselector", tipCache.text, nil, nil, tipCache.title) + + return + end + + -- Nothing over the panel, so whatever row is under the cursor has it; with no row + -- either, nothing is being pointed at that has anything to say. + if not row then + 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. + -- 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 + WG.tooltip.ShowTooltip("widgetselector", tipCache.text, nil, nil, tipCache.title) + + return + end + local d = row.data -- The same three states the row is painted in, said in words: green is running, -- amber is enabled but not running, red is off. @@ -2139,14 +2516,18 @@ local function rowTooltip(row) -- 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 - local warn = clearDataWarning(row.name, row.state == 1) - WG.tooltip.ShowTooltip( - "widgetselector", - "\255\255\255\255" .. string.gsub(font:WrapText(warn, maxWidth), "[\n]", "\n\255\255\255\255"), - nil, - nil, - colorDanger .. L.cleardataTitle .. "\n" - ) + 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 @@ -2181,7 +2562,42 @@ local function rowTooltip(row) .. (row.isLocal and " (" .. L.islocal .. ")" or "") .. "\n\255\130\130\130" .. L.hint - WG.tooltip.ShowTooltip("widgetselector", tip, nil, nil, title) + -- 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 + -- proportional face, so the columns are padded to a fixed number of characters rather + -- than measured - digits are the same width in most faces, which is what carries it. + if filters.profiler then + local detail = profiling.callins(row.name) + if detail then + local list = {} + local sumT, sumS = 0, 0 + for cname, c in pairs(detail) do + sumT = sumT + c[1] + sumS = sumS + c[2] + -- A callin that costs nothing either way is noise; its cost still counts towards + -- the total, so that stays honest. + if c[1] >= 0.003 or c[2] >= 0.1 then + list[#list + 1] = { name = cname, t = c[1], s = c[2] } + end + end + table.sort(list, function(x, y) + return x.t > y.t + end) + + -- The heading sits at the head of its column rather than right-aligned with the + -- figures under it: a heading shorter than the numbers below would otherwise float + -- off to the right of them, which is what a reader sees as the column being indented. + tip = tip .. "\n" .. "\255\160\255\160" .. string.format("%-7s %-13s %s", "time", "allocs", "callin") + for i = 1, #list do + local e = list[i] + tip = tip .. "\n\255\175\175\175" .. string.format("%6.2f%% %9.1fkB/s %s", e.t, e.s, e.name) + end + tip = tip .. "\n\255\160\255\160" .. string.format("%6.2f%% %9.1fkB/s %s", sumT, sumS, L.total) + end + end + + tipCache.keep("row", row.name, row.state, (row.order or 0) + gen * 100000, title, tip) + WG.tooltip.ShowTooltip("widgetselector", tipCache.text, nil, nil, tipCache.title) end function widget:DrawScreen() @@ -2212,13 +2628,13 @@ function widget:DrawScreen() end end - local sig = panelSignature(show and mx or -1, show and my or -1) - if sig ~= panelSig then + -- Answers whether anything painted into the baked panel moved. It has to run even + -- when nothing did, since that is how it finds out. + if panelChanged(show and mx or -1, show and my or -1) or not panelList then if panelList then glDeleteList(panelList) end panelList = glCreateList(drawPanel) - panelSig = sig end if not windowList then @@ -2226,6 +2642,9 @@ function widget:DrawScreen() end glCallList(windowList) glCallList(panelList) + if show and not dialog then + drawCostColumns() + end -- 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 @@ -2265,9 +2684,8 @@ function widget:DrawScreen() if math_isInRect(mx, my, screenX, screenY - screenHeight, screenX + screenWidth, screenY) then Spring.SetMouseCursor("cursornormal") - local row = not dialog and hover.row > 0 and rows[scroll + hover.row] - if row and WG.tooltip then - rowTooltip(row) + if WG.tooltip and not dialog then + showTooltip(hover.row > 0 and rows[scroll + hover.row] or nil) end end end @@ -2490,6 +2908,14 @@ local function mouseEvent(x, y, button, release) end if hitSwitch then filters[hitSwitch] = not filters[hitSwitch] + -- Two ways of ordering the same list, so switching one on takes the other off + -- rather than leaving the header claiming both. + if hitSwitch == "byLoad" and filters.byLoad then + filters.byOrder = false + elseif hitSwitch == "byOrder" and filters.byOrder then + filters.byLoad = false + end + applyProfiling() -- The rank column appears and disappears with the sort, so the columns move. setLayout() -- The column counts what the filters leave, so they still say what clicking one @@ -2546,6 +2972,7 @@ local function mouseEvent(x, y, button, release) end elseif button == 1 then widgetHandler:ToggleWidget(overRow.name) + sweep.dirty = true click() elseif button == 2 or button == 3 then @@ -2553,8 +2980,10 @@ local function mouseEvent(x, y, button, release) if w then if button == 2 then widgetHandler:LowerWidget(w) + sweep.dirty = true else widgetHandler:RaiseWidget(w) + sweep.dirty = true end widgetHandler:SaveConfigData() end @@ -2588,6 +3017,8 @@ function widget:GetConfigData() localOnly = filters.localOnly, enabledOnly = filters.enabledOnly, byOrder = filters.byOrder, + profiler = filters.profiler, + byLoad = filters.byLoad, category = selectedCategory, sets = sets, pickedSet = pickedSet, @@ -2618,5 +3049,13 @@ function widget:SetConfigData(data) pickedSet = type(data.pickedSet) == "string" and data.pickedSet or nil filters.enabledOnly = data.enabledOnly == 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 + -- switch that quietly forgets itself every session is worse than one that costs + -- something, and the switch says plainly what it does. Acting on it waits for the + -- first Update: this runs while the handler is still loading widgets, which is no + -- time to start wrapping their callins. + filters.profiler = data.profiler == true + filters.byLoad = filters.profiler and data.byLoad == true selectedCategory = type(data.category) == "string" and data.category or nil end