From f6f15d96504758c4337d23dbb9fb28f73db27fab Mon Sep 17 00:00:00 2001 From: Floris Date: Thu, 10 Sep 2026 15:54:02 +0200 Subject: [PATCH 1/4] keybind editor: styling tweaks (#9180) --- luaui/Include/keybind_dropdown.lua | 11 +- luaui/Include/keybind_editbox.lua | 15 +- luaui/Include/keybind_editor_view.lua | 431 ++++++++++++++++++++------ luaui/Widgets/gui_flowui.lua | 74 +++++ luaui/Widgets/gui_keybind_info.lua | 2 +- 5 files changed, 418 insertions(+), 115 deletions(-) diff --git a/luaui/Include/keybind_dropdown.lua b/luaui/Include/keybind_dropdown.lua index 5fdb220e10a..3cb8c1dd585 100644 --- a/luaui/Include/keybind_dropdown.lua +++ b/luaui/Include/keybind_dropdown.lua @@ -110,7 +110,8 @@ local chevronX, chevronY, chevronH = 0, 0, 0 local function chevronVertices() gl.Vertex(chevronX - chevronH, chevronY) gl.Vertex(chevronX + chevronH, chevronY) - gl.Vertex(chevronX, chevronY - chevronH * 1.2) + -- Floored with the rest: a vertex between two pixels softens the whole glyph. + gl.Vertex(chevronX, floor(chevronY - chevronH * 1.2)) end function Dropdown:draw() @@ -129,7 +130,7 @@ function Dropdown:draw() -- makes both flicker. local arrowH = floor((y2 - y1) * 0.16) local arrowX = x2 - inset - arrowH - local arrowY = (y1 + y2) * 0.5 + arrowH * 0.5 + local arrowY = floor((y1 + y2) * 0.5 + arrowH * 0.5) gl.Color(1, 1, 1, self.open and 0.9 or 0.55) chevronX, chevronY, chevronH = arrowX, arrowY, arrowH gl.BeginEnd(GL.TRIANGLES, chevronVertices) @@ -150,7 +151,7 @@ function Dropdown:draw() font:Print( fittedLabel(fitted, 0, font, label, labelW, self.fontSize), x1 + inset, - (y1 + y2) * 0.5, + floor((y1 + y2) * 0.5), self.fontSize, "ov" ) @@ -160,7 +161,7 @@ function Dropdown:draw() local top = self.optRects[1].y2 local bottom = self.optRects[#self.optRects].y1 -- Rounded like the rest of the panel's inner elements. - local cs = WG.FlowUI.elementCorner * 0.66 + local cs = floor(WG.FlowUI.elementCorner * 0.66) R(x1, bottom, x2, top, cs, 1, 1, 1, 1, listFill) for i in ipairs(self.options) do @@ -178,7 +179,7 @@ function Dropdown:draw() font:Print( fittedLabel(fitted, i, font, optionLabel(opt), w, self.fontSize), r.x1 + inset, - (r.y1 + r.y2) * 0.5, + floor((r.y1 + r.y2) * 0.5), self.fontSize, "ov" ) diff --git a/luaui/Include/keybind_editbox.lua b/luaui/Include/keybind_editbox.lua index 59ffceb7a4b..aeba0231527 100644 --- a/luaui/Include/keybind_editbox.lua +++ b/luaui/Include/keybind_editbox.lua @@ -328,17 +328,18 @@ function Editbox:draw() local x1, y1, x2, y2 = self.rect[1], self.rect[2], self.rect[3], self.rect[4] -- Rounded like the rest of the panel's inner elements; the caret and selection sit -- inside the field by their own inset. - local cs = WG.FlowUI.elementCorner * 0.66 + -- Whole pixels: an edge on a fraction is blended across two of them and reads soft. + local cs = floor(WG.FlowUI.elementCorner * 0.66) local inset = floor((y2 - y1) * 0.18) local tx = x1 + self.pad - local ty = (y1 + y2) * 0.5 + local ty = floor((y1 + y2) * 0.5) R(x1, y1, x2, y2, cs, 1, 1, 1, 1, fieldFill) if self:hasSelection() then local a, b = self:selRange() - local sa = font:GetTextWidth(utf8.sub(self.text, 1, a)) * self.fontSize - local sb = font:GetTextWidth(utf8.sub(self.text, 1, b)) * self.fontSize + local sa = floor(font:GetTextWidth(utf8.sub(self.text, 1, a)) * self.fontSize) + local sb = floor(font:GetTextWidth(utf8.sub(self.text, 1, b)) * self.fontSize) gl.Color(0.4, 0.55, 0.85, 0.5) gl.Rect(tx + sa, y1 + inset, tx + sb, y2 - inset) gl.Color(1, 1, 1, 1) @@ -367,10 +368,10 @@ function Editbox:draw() if self.focused then -- Sharp bar rather than a rounded one, sized and placed off the font like chat's: -- a fixed span around the text's middle, so it does not stretch with the field. - local cx = tx + caretOffset(self, font) + local cx = floor(tx + caretOffset(self, font)) local cWidth = 1 + floor(self.fontSize / 14) - local cy1 = math.max(y1 + 1, ty - self.fontSize * 0.6) - local cy2 = math.min(y2 - 1, ty + self.fontSize * 0.64) + local cy1 = math.max(y1 + 1, floor(ty - self.fontSize * 0.6)) + local cy2 = math.min(y2 - 1, floor(ty + self.fontSize * 0.64)) gl.Color(cursorGrey, cursorGrey, cursorGrey, caretAlpha(self)) gl.Rect(cx, cy1, cx + cWidth, cy2) gl.Color(1, 1, 1, 1) diff --git a/luaui/Include/keybind_editor_view.lua b/luaui/Include/keybind_editor_view.lua index 30f495db49c..de8a87fd619 100644 --- a/luaui/Include/keybind_editor_view.lua +++ b/luaui/Include/keybind_editor_view.lua @@ -52,6 +52,30 @@ local metrics = { catInset = 4, -- Chips sit inside their row by this much, top and bottom. chipInset = 3, + -- A category heading stands taller than the bindings under it and is set larger, so it + -- reads as a divider rather than another row. + headerRowHeight = 32, + headerFs = 14, + catFs = 13, + -- The line along the bottom of a heading and of the selected category. + underlineH = 2, + -- Everything that sits against the panel's right edge - the header icons, the footer + -- buttons and the scrollbar - is held off it by this much, matching the inset the + -- header and footer already use vertically, so a button clears all three edges equally. + edgeInset = 4, + -- Clearance between the bottom of the list band and the footer, so the scrollbar does + -- not run down into the buttons. + footerGap = 8, + -- Clearance between the right edge of the rows and the scrollbar beside them. + listGap = 12, + -- How far the category card rises above the first entry in it. + cardLip = 5, + -- The panel title: its baseline below the top edge, and its size. + titleY = 17, + titleFs = 20, + -- How far the category column starts below the keybind rows beside it, to leave the + -- title room to breathe. + sidebarDrop = 8, -- Corner radii, taken from FlowUI's so the panel rounds like the rest of the UI. csSmall = 2, csButton = 3, @@ -109,11 +133,16 @@ local UiElement local Highlight ---@type function local UiButton +---@type function +local UiUnitFrame local colorAction = "\255\210\210\205" local colorKey = "\255\235\185\070" local colorText = "\255\235\235\235" local colorDim = "\255\160\160\160" +-- A button that cannot be pressed: dimmer than the dim used for ordinary secondary text, +-- since here it has to read as unavailable rather than merely quiet. +local colorFaded = "\255\115\115\115" local colorHeader = "\255\255\200\130" local colorDanger = "\255\235\090\090" -- SelectHighlight defaults to 0.35 and the rest of the UI stays near it. At 1 the @@ -124,9 +153,13 @@ local buttonFill = { 0.18, 0.18, 0.18, 1 } -- overlay washes a tinted button out to grey, so these brighten instead. local dangerFill = { 0.46, 0.10, 0.10, 1 } local dangerFillHover = { 0.66, 0.14, 0.14, 1 } +-- With nothing staged there is nothing to discard or save, so both footer buttons drop +-- most of their colour and go part transparent, sinking into the panel instead of sitting +-- on it as a slightly darker version of the live button. +local dangerFillMuted = { 0.17, 0.12, 0.12, 0.45 } local confirmFill = { 0.17, 0.38, 0.21, 1 } local confirmFillHover = { 0.24, 0.52, 0.29, 1 } -local confirmFillMuted = { 0.11, 0.20, 0.13, 1 } +local confirmFillMuted = { 0.12, 0.17, 0.13, 0.45 } local pillFill = { 0.22, 0.22, 0.22, 1 } local sheenTop = { 1, 1, 1, 0.05 } -- Fills and captions the list is painted with, in one table for the same reason as @@ -147,6 +180,14 @@ local look = { -- Rows, categories and grid cells hover with the same FlowUI highlight the settings -- list uses, at the strength it gives a plain row. rowHoverOpacity = 0.14, + -- Underlines are drawn as a thin bar that fades upward out of the bottom edge, each in + -- the hue of the text above it: warm under a category heading, plain under the selected + -- category in the column. + headerLine = { 1, 0.78, 0.51, 0.4 }, + headerLineFade = { 1, 0.78, 0.51, 0 }, + -- Border strength for a tile that is only being shown, not offered. FlowUI's own + -- default for a live one is 0.1. + idleBorder = 0.02, removeHot = colorDanger .. "x", removeCold = colorDim .. "x", plusText = colorText .. "+", @@ -190,8 +231,10 @@ local headerButtons = { { id = "edit", icon = "LuaUI/Images/keybinds/edit.png", tooltipId = "keybind_edit" }, } +-- Discarding is destructive and saving is not, so the two footer buttons are coloured for +-- what they do rather than left to read alike. local footerButtons = { - { id = "reset" }, + { id = "reset", fill = dangerFill, fillHover = dangerFillHover, fillMuted = dangerFillMuted }, { id = "save", fill = confirmFill, fillHover = confirmFillHover, fillMuted = confirmFillMuted }, } @@ -277,17 +320,87 @@ end ---------------------------------------------------------------- local function listBottom() - return area.y1 + footerH + return area.y1 + footerH + metrics.footerGap end -- Whole rows the band can paint. -local function visibleRows() - return math.max(1, floor((listTop - listBottom()) / rowHeight)) +-- A category heading is taller than the bindings under it, so a row's position is a sum of +-- what is above it rather than its index times one height. The running total is stamped +-- onto the rows, which rebuildRows replaces wholesale, and redone when the layout moves. +local rowMetrics = { gen = -1, rows = -1, totalH = 0 } + +local function rowHeightOf(row) + return row.type == "header" and metrics.headerRowHeight or rowHeight +end + +local function ensureRowMetrics() + if rowMetrics.gen == layoutGen and rowMetrics.rows == rowsGen then + return + end + + local off = 0 + for i = 1, #rows do + local row = rows[i] + row.off = off + off = off + rowHeightOf(row) + end + rowMetrics.totalH = off + rowMetrics.gen, rowMetrics.rows = layoutGen, rowsGen +end + +-- Pixels of content above the first painted row. +local function scrollOffset() + ensureRowMetrics() + local first = rows[scroll + 1] + + return first and first.off or 0 end --- Furthest offset that still fills the band. +-- Furthest offset that still fills the band. Walked from the end, so it does not depend on +-- where the list is scrolled to now. local function maxScroll() - return math.max(0, #rows - visibleRows()) + ensureRowMetrics() + local band = listTop - listBottom() + local used = 0 + local i = #rows + while i > 0 do + local h = rowHeightOf(rows[i]) + if used + h > band then + break + end + used = used + h + i = i - 1 + end + + return i +end + +-- The painted row under y, as its offset from the first painted one, plus the edges it was +-- painted with. Every hover test, click and the panel signature go through this, so none of +-- them can disagree with what was drawn. nil when y is outside the band or past the last +-- whole row the band can hold. +local function rowAt(y) + ensureRowMetrics() + local lb = listBottom() + if y > listTop or y <= lb then + return nil + end + + local base = scrollOffset() + for i = scroll + 1, #rows do + local top = listTop - (rows[i].off - base) + local bottom = top - rowHeightOf(rows[i]) + if bottom < lb then + break + end + -- Half-open on the shared edge: rows stack, so one row's top is the next one's + -- bottom and a closed test would put the cursor in both. + if y <= top and y > bottom then + return i - scroll, top, bottom + end + end + + return nil end local function clampScroll() @@ -889,6 +1002,7 @@ local function guardDirty(proceed, onCancel) end, middle = { label = L.discard, + danger = true, action = function() discardStaged() proceed() @@ -921,8 +1035,9 @@ local function startReset() title = L.reset, message = L.resetConfirm, -- Named for what it does. Without this it falls back to the generic "Accept", which - -- says nothing about the edits being thrown away. + -- says nothing about the edits being thrown away, and coloured for it. acceptLabel = L.discard, + danger = true, accept = function() discardStaged() end, @@ -1027,26 +1142,27 @@ local function layoutHeader() local rowTop = area.y2 - floor(4 * scale) local rowBottom = area.y2 - headerH + floor(4 * scale) local presetW = floor(240 * scale) - local btnFs = (rowTop - rowBottom) * 0.5 + local btnFs = floor((rowTop - rowBottom) * 0.5) -- Right to left: the edit dialog opener, duplicate, then the picker they act on. local iconW = rowTop - rowBottom local editW, dupW = iconW, iconW - local editX1 = area.x2 - editW + local rightEdge = area.x2 - metrics.edgeInset + local editX1 = rightEdge - editW local dupX1 = editX1 - gap - dupW local pickerX1 = dupX1 - gap - presetW headerButtons[1].rect = { dupX1, rowBottom, dupX1 + dupW, rowTop } - headerButtons[2].rect = { editX1, rowBottom, area.x2, rowTop } + headerButtons[2].rect = { editX1, rowBottom, rightEdge, rowTop } presetDropdown:setRect(pickerX1, rowBottom, pickerX1 + presetW, rowTop, btnFs) searchBox:setRect(listX1, rowBottom, pickerX1 - gap, rowTop, btnFs) local fTop = area.y1 + footerH - floor(4 * scale) local fBottom = area.y1 + floor(4 * scale) - local fFs = (fTop - fBottom) * 0.5 + local fFs = floor((fTop - fBottom) * 0.5) local fPad = floor(14 * scale) local bfs = floor(rowHeight * 0.55) - local x2 = area.x2 + local x2 = area.x2 - metrics.edgeInset for i = #footerButtons, 1, -1 do local b = footerButtons[i] local label = L[b.id] or b.id @@ -1057,7 +1173,7 @@ local function layoutHeader() if font then local fitted = text.fit(font, label, w - metrics.rowPad * 2, bfs) b.textOn = colorText .. fitted - b.textOff = colorDim .. fitted + b.textOff = colorFaded .. fitted end end @@ -1082,7 +1198,7 @@ local function dialogGeometry() local bh = floor(28 * scale) local pad = floor(16 * scale) local btnY1 = by1 + pad - local bfs = bh * 0.5 + local bfs = floor(bh * 0.5) local bpad = floor(14 * scale) local cancelW = labelWidth(L.cancel, bfs, bpad) @@ -1126,9 +1242,10 @@ local function fitCategories() end local labelW = sidebarW - metrics.sidePad * 2 - local fs = metrics.catRowHeight * 0.55 + -- Fitted at the size they are actually drawn at, so a label is not shortened for a + -- size the column never uses. for _, c in ipairs(categories) do - local fitted = text.fit(font, c.label, labelW, fs) + local fitted = text.fit(font, c.label, labelW, metrics.catFs) c.textSel = colorAction .. fitted c.textDim = colorDim .. fitted end @@ -1146,6 +1263,7 @@ function view.init() UiElement = WG.FlowUI.Draw.Element Highlight = WG.FlowUI.Draw.SelectHighlight UiButton = WG.FlowUI.Draw.Button + UiUnitFrame = WG.FlowUI.Draw.UnitFrame ensureControls() end @@ -1169,19 +1287,32 @@ function view.setArea(x1, y1, x2, y2, s) scale = s or 1 rowHeight = floor(24 * scale) metrics.catRowHeight = floor(29 * scale) - metrics.rowFs = rowHeight * 0.55 + -- 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) + metrics.headerRowHeight = floor(rowHeight * 1.35) + -- The heading was set at 0.95 of a row's size; 13% up from there. + metrics.headerFs = floor(metrics.rowFs * 0.95 * 1.13) + metrics.catFs = floor(metrics.catRowHeight * 0.55 * 0.85) + metrics.underlineH = math.max(1, floor(2 * scale)) metrics.rowPad = floor(6 * scale) metrics.sidePad = floor(12 * scale) metrics.catInset = floor(4 * scale) metrics.chipInset = floor(3 * scale) + -- Set before layoutHeader below, which places the header and footer buttons against it. + metrics.edgeInset = floor(4 * scale) + metrics.footerGap = floor(8 * scale) + metrics.listGap = floor(12 * scale) + metrics.cardLip = floor(5 * scale) + metrics.titleY = floor(17 * scale) + metrics.sidebarDrop = floor(8 * scale) + metrics.titleFs = floor(rowHeight * 0.85) -- Rounded like the settings panel's inner elements, which take a share of this too. local corner = WG.FlowUI.elementCorner - metrics.csPanel = corner - metrics.csButton = corner * 0.8 - metrics.csSmall = corner * 0.66 - - local pad = floor(6 * scale) + metrics.csPanel = floor(corner) + metrics.csButton = floor(corner * 0.8) + metrics.csSmall = floor(corner * 0.66) sidebarW = floor(240 * scale) listX1 = area.x1 + sidebarW + floor(12 * scale) @@ -1189,8 +1320,13 @@ function view.setArea(x1, y1, x2, y2, s) layoutHeader() listTop = area.y2 - headerH - floor(4 * scale) - listRight = area.x2 - floor(12 * scale) - pad - barX1 = listRight + floor(4 * scale) + -- The scrollbar owns a column of its own: its right edge lines up with the buttons + -- above it, and the list stops a clear gap short of it rather than running up against + -- it. That gap matches the one the bar keeps from the panel edge on its other side, so + -- the bar sits in a channel rather than hugging the rows. + local barW = floor(14 * scale) + barX1 = area.x2 - metrics.edgeInset - barW + listRight = barX1 - metrics.listGap keyAreaX1 = listX1 + floor((listRight - listX1) * 0.45) -- Shortened here rather than in the draw loop: the column width and the font size are @@ -1621,6 +1757,17 @@ local function modPrefix() return prefix end +-- Whether the capture is holding something worth committing. The Accept button is shown +-- only when this is true and acts only when this is true, so a button that is not on screen +-- cannot be clicked. Seeded means the modal is still showing the binding it was opened on +-- and nothing has been pressed yet: accepting that would rewrite a keyset to itself, so +-- there is nothing to offer and the way out is Cancel. +local function captureCanAccept() + local c = capturing + + return c ~= nil and #c.elems > 0 and not c.seeded +end + -- Scancode to keyset symbol, refusing modifier keys so they cannot bind alone. local function pressSym(scanCode) local sym = scanCode and spGetScanSymbol(scanCode) @@ -1657,7 +1804,7 @@ end local function chipMetrics(display, fs, pad, rightGap, chipArea) local tw = font:GetTextWidth(display) * fs if pad + tw + rightGap <= chipArea then - return display, fs, pad + tw + rightGap + return display, fs, floor(pad + tw + rightGap) end -- Keep inner positive, since a tiny share can drive it negative and the fit would then @@ -1666,7 +1813,7 @@ local function chipMetrics(display, fs, pad, rightGap, chipArea) local chipFs = math.max(floor(fs * 0.75), floor(fs * inner / math.max(1, tw))) local disp = text.fit(font, display, inner, chipFs) - return disp, chipFs, pad + font:GetTextWidth(disp) * chipFs + rightGap + return disp, chipFs, floor(pad + font:GetTextWidth(disp) * chipFs + rightGap) end -- Chain tokens over two lines at most; the second truncates rather than a third appearing. @@ -1805,7 +1952,7 @@ local function rowLayout(row) local m = mets[i] m.textKey = colorKey .. m.disp m.textHover = colorText .. m.disp - m.removeCx = m.removeX1 + rightGap * 0.5 + m.removeCx = floor(m.removeX1 + rightGap * 0.5) end lay.mets = mets lay.cx = cx @@ -1890,8 +2037,14 @@ end -- Rows are laid out from the top of the list band down, so the column lines up with the -- keybind rows beside it. +-- The category column starts below where the keybind rows do, 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 + local function categoryRect(i) - local top = listTop - (i - 1) * metrics.catRowHeight + local top = sidebarTop() - (i - 1) * metrics.catRowHeight return area.x1, top - metrics.catRowHeight, area.x1 + sidebarW, top end @@ -1899,11 +2052,12 @@ 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) - if x < area.x1 or x > area.x1 + sidebarW or y > listTop or y <= listBottom() then + local top = sidebarTop() + if x < area.x1 or x > area.x1 + sidebarW or y > top or y <= listBottom() then return nil end - local i = floor((listTop - y) / metrics.catRowHeight) + 1 + local i = floor((top - y) / metrics.catRowHeight) + 1 if not categories[i] then return nil end @@ -1936,7 +2090,9 @@ end -- is picked, which is where Back and Next page live. Sized to roughly what the menu -- occupies in game - 0.2125 of screen width over four columns - rather than stretched. local function gridGeometry() - local headH = rowHeight + -- The heading here is the list's heading, so it takes the taller heading row rather + -- than an ordinary one. + local headH = metrics.headerRowHeight local top = listTop - headH - floor(8 * scale) local bottom = listBottom() + floor(8 * scale) local strip = floor(rowHeight * 1.2) @@ -1979,14 +2135,39 @@ local function drawButtonFace(r, base) UiButton(r[1], r[2], r[3], r[4], 1, 1, 1, 1, 1, 1, 1, 1, nil, pair[1], pair[2]) end +-- The band a category heading sits on: the sheen, the line closing it off underneath, and +-- the caption. Shared, so the grid view's heading is the same object as the list's rather +-- than a second one that has to be kept looking like it. +local function drawHeaderBand(top, bottom, caption) + RectRound(listX1, bottom, listRight, top - metrics.csSmall, metrics.csSmall, 1, 1, 0, 0, sheenTop, sheenTop) + -- Underline: a thin bar fading up out of the bottom edge, so the heading closes off the + -- block above it rather than floating in the middle of the list. + RectRound( + listX1, + bottom, + listRight, + bottom + metrics.underlineH, + 0, + 0, + 0, + 0, + 0, + look.headerLine, + look.headerLineFade + ) + queueText(caption, listX1 + metrics.rowPad, floor((top + bottom) * 0.5), metrics.headerFs, "ov") +end + -- The category column: its own card under the title, then one entry per category, with -- hoverIdx the entry under the cursor. local function drawSidebar(hoverIdx) + -- Derived from the first category rather than measured from the panel top, so the card + -- keeps its lip above the entries wherever the column starts. RectRound( area.x1, area.y1, area.x1 + sidebarW, - area.y2 - floor(33 * scale), + sidebarTop() + metrics.cardLip, metrics.csPanel, 1, 1, @@ -1995,46 +2176,55 @@ local function drawSidebar(hoverIdx) look.sidebarFill, look.sidebarFillTop ) - queueText(L.titleText, area.x1 + metrics.sidePad, area.y2 - floor(17 * scale), floor(rowHeight * 0.85), "ov") + queueText(L.titleText, area.x1 + metrics.sidePad, area.y2 - metrics.titleY, metrics.titleFs, "ov") -- 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 fs = metrics.catRowHeight * 0.55 local lb = listBottom() for i, c in ipairs(categories) do local x1, y1, x2, y2 = categoryRect(i) if y1 >= lb then 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) + 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) end - queueText((selected and c.textSel or c.textDim) or c.label, x1 + metrics.sidePad, (y1 + y2) * 0.5, fs*0.85, "ov") + 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") end end end -- Label left, key right, sized like a category button. Used for every pill in this view. local function drawGridPill(x1, y1, x2, y2, label, key, fs, pad, hovered, dim) - RectRound(x1, y1, x2, y2, metrics.csSmall, 1, 1, 1, 1, pillFill, pillFill) + -- Through FlowUI's Button, the way gui_gridmenu draws these same category and page + -- buttons: the cells above them are unit slots and carry a tile frame, so these need + -- the raised button face to not read as more of the same. + local pair = look.gradients[pillFill] + UiButton(x1, y1, x2, y2, 1, 1, 1, 1, 1, 1, 1, 1, nil, pair[1], pair[2]) if hovered and not dim then - Highlight(x1, y1, x2, y2, metrics.csSmall, look.rowHoverOpacity, look.white) + Highlight(x1, y1, x2, y2, metrics.csButton, hoverOpacity, look.white) end - -- Key is right aligned and gets only the width it needs, so the label keeps the rest - -- and stays readable. + -- Key is right aligned and gets only the width it needs; the label is centred in + -- whatever is left over. With no key bound that is the whole button, which is why an + -- unbound category reads as a plain centred caption rather than one pushed to the left. local keyW = floor(font:GetTextWidth(key) * fs) + local lx1 = x1 + pad * 2 + local lx2 = x2 - pad * 2 - (keyW > 0 and keyW + pad * 2 or 0) + local ty = floor((y1 + y2) * 0.5) queueText( - (dim and colorDim or colorAction) .. text.fit(font, label, (x2 - x1) - keyW - pad * 5, fs), - x1 + pad * 2, - (y1 + y2) * 0.5, + (dim and colorDim or colorAction) .. text.fit(font, label, lx2 - lx1, fs), + floor((lx1 + lx2) * 0.5), + ty, fs, - "ov" + "cov" ) - queueText((dim and colorDim or colorKey) .. key, x2 - pad * 2, (y1 + y2) * 0.5, fs, "rov") + queueText((dim and colorDim or colorKey) .. key, x2 - pad * 2, ty, fs, "rov") end -- The raw keyset a grid action carries, for seeding a rebind. nil when it has none. @@ -2113,9 +2303,15 @@ local function drawGridMenu(zone, zoneA, zoneB) local keyFs = floor(cell * 0.2) local stripFs = floor(strip * 0.45) - -- Same header the list puts above a category, so the two views read alike. - RectRound(listX1, listTop - headH, listRight, listTop, 0, 0, 0, 0, 0, sheenTop, sheenTop) - queueText(colorHeader .. gridGroup.title, listX1 + metrics.rowPad, listTop - headH * 0.5, metrics.rowFs * 0.95, "ov") + -- The same heading the list puts above a category, drawn by the same code. + drawHeaderBand(listTop, listTop - headH, colorHeader .. gridGroup.title) + + -- These stand in for the build menu's unit tiles, so they take the same frame FlowUI + -- puts around a unit picture, minus the picture. Every cell is the same size, so the + -- corner is derived once and shared with the fill under it; left to itself the frame + -- would derive its own and the two would not quite line up. + local cellInner = cell - pad * 2 + local frameCs = math.max(1, floor(cellInner * 0.024)) for pass = 1, 2 do local gx = (pass == 1) and x1 or x2 @@ -2124,12 +2320,13 @@ local function drawGridMenu(zone, zoneA, zoneB) local cx1, cy1, cx2, cy2 = gridCellRect(row, col, gx, gridBottom, cell) -- Solid enough to read against the panel on its own, so the cells need no -- container or outline behind them. - RectRound(cx1 + pad, cy1 + pad, cx2 - pad, cy2 - pad, metrics.csSmall, 1, 1, 1, 1, pillFill, pillFill) + RectRound(cx1 + pad, cy1 + pad, cx2 - pad, cy2 - pad, frameCs, 1, 1, 1, 1, pillFill, pillFill) -- Only the first grid carries the build keys; the second is the category view, -- whose cells hold the same bindings and would just repeat them. if pass == 1 then + -- Under the frame, so the hover lifts the tile without softening its edge. if zone == "cell" and zoneA == row and zoneB == col then - Highlight(cx1 + pad, cy1 + pad, cx2 - pad, cy2 - pad, metrics.csSmall, look.rowHoverOpacity, look.white) + Highlight(cx1 + pad, cy1 + pad, cx2 - pad, cy2 - pad, frameCs, look.rowHoverOpacity, look.white) end queueText( colorKey .. gridKeyText(gridKeyActions[row][col]), @@ -2139,6 +2336,13 @@ local function drawGridMenu(zone, zoneA, zoneB) "ro" ) end + -- Last, so the border and shine sit over the fill and the hover rather than + -- under them. Plain: a group icon would name a group these cells do not have. + -- The second grid is the same keys seen from the category view and binds + -- nothing, so its frames are drawn faint: it is there to show the layout, not + -- to be clicked, and a full-strength frame invites the click. + local border = (pass == 1) and nil or look.idleBorder + UiUnitFrame(cx1 + pad, cy1 + pad, cx2 - pad, cy2 - pad, frameCs, 1, 1, 1, 1, nil, border) end end end @@ -2221,13 +2425,12 @@ end -- One list row. hovered says the cursor is on it; zone and zoneIdx are then which chip -- or button of it, in rowZone's terms. local function drawRow(row, top, bottom, hovered, zone, zoneIdx) - local cyc = (top + bottom) * 0.5 + local cyc = floor((top + bottom) * 0.5) local lay = rowLayout(row) local fs = metrics.rowFs if row.type == "header" then - RectRound(listX1, bottom, listRight, top, 0, 0, 0, 0, 0, sheenTop, sheenTop) - queueText(lay.text, listX1 + metrics.rowPad, cyc, fs * 0.95, "ov") + drawHeaderBand(top, bottom, lay.text) return end @@ -2259,7 +2462,7 @@ local function drawRow(row, top, bottom, hovered, zone, zoneIdx) local cx = lay.cx local overAdd = zone == "add" RectRound(cx, c1, cx + lay.addW, c2, metrics.csSmall, 1, 1, 1, 1, overAdd and look.addFillHover or look.addFill) - queueText(overAdd and look.plusTextHover or look.plusText, (cx + cx + lay.addW) * 0.5, cyc, fs, "cov") + queueText(overAdd and look.plusTextHover or look.plusText, floor(cx + lay.addW * 0.5), cyc, fs, "cov") end end @@ -2268,20 +2471,11 @@ end local function drawCaptureModal(mx, my) local bx1, by1, bx2, by2, ok, cancel = captureGeometry() local cs = metrics.csButton - local cx = (bx1 + bx2) * 0.5 + 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 }) UiElement(bx1, by1, bx2, by2, 1, 1, 1, 1, 1, 1, 1, 1, WG.FlowUI.clampedOpacity) - drawButtonFace(cancel, buttonFill) - drawButtonFace(ok, buttonFill) - if isInRect(mx, my, cancel[1], cancel[2], cancel[3], cancel[4]) then - Highlight(cancel[1], cancel[2], cancel[3], cancel[4], cs, hoverOpacity, { 1, 1, 1 }) - end - if isInRect(mx, my, ok[1], ok[2], ok[3], ok[4]) then - Highlight(ok[1], ok[2], ok[3], ok[4], cs, hoverOpacity, { 1, 1, 1 }) - end - local tfs = floor(rowHeight * 0.6) local sfs = floor(rowHeight * 0.5) local bigfs = floor(rowHeight * 0.95) @@ -2291,7 +2485,23 @@ local function drawCaptureModal(mx, my) -- is part-way through a replacement - and letting go puts the original back. local heldRaw = modPrefix() local held = heldRaw ~= "" and keybindModel.displayKeyset(heldRaw, working.layout) or "" + -- What the big line in the middle shows: the keyset formed so far, which includes the + -- one the modal opened on. Whether that is worth committing is a separate question. local hasChain = #capturing.elems > 0 and not (capturing.seeded and held ~= "") + local canAccept = captureCanAccept() + + drawButtonFace(cancel, buttonFill) + if isInRect(mx, my, cancel[1], cancel[2], cancel[3], cancel[4]) then + Highlight(cancel[1], cancel[2], cancel[3], cancel[4], cs, hoverOpacity, look.white) + end + -- Absent until there is a change to accept, rather than present and dead: a greyed + -- button invites a click that does nothing. Green like the other commits, and + -- brightening its own fill on hover, which the white overlay would wash out. + if canAccept then + local overOk = isInRect(mx, my, ok[1], ok[2], ok[3], ok[4]) + drawButtonFace(ok, overOk and confirmFillHover or confirmFill) + end + local chainStr if hasChain then chainStr = keybindModel.displayKeyset(chainRaw(), working.layout) @@ -2329,7 +2539,7 @@ local function drawCaptureModal(mx, my) -- Bar draining over the chain window: time left to extend before it resets. The -- track is always drawn so the modal does not gain a row the moment a key lands. local barW = floor((bx2 - bx1) * 0.5) - local barX = cx - barW * 0.5 + local barX = floor(cx - barW * 0.5) local barY = by1 + floor(88 * scale) local barH = floor(4 * scale) RectRound(barX, barY, barX + barW, barY + barH, floor(2 * scale), 1, 1, 1, 1, { 1, 1, 1, 0.1 }) @@ -2366,24 +2576,26 @@ local function drawCaptureModal(mx, my) "cov" ) for li = 1, #chainLines do - local ly = chainCy + (#chainLines - 1) * lineStep * 0.5 - (li - 1) * lineStep + local ly = floor(chainCy + (#chainLines - 1) * lineStep * 0.5 - (li - 1) * lineStep) font:Print((hasContent and colorKey or colorDim) .. chainLines[li], cx, ly, chainFs, "cov") end - font:Print(colorText .. L.cancel, (cancel[1] + cancel[3]) * 0.5, (cancel[2] + cancel[4]) * 0.5, sfs, "cov") font:Print( - (hasChain and colorText or colorDim) .. L.accept, - (ok[1] + ok[3]) * 0.5, - (ok[2] + ok[4]) * 0.5, + colorText .. L.cancel, + floor((cancel[1] + cancel[3]) * 0.5), + floor((cancel[2] + cancel[4]) * 0.5), sfs, "cov" ) + if canAccept then + font:Print(colorText .. L.accept, floor((ok[1] + ok[3]) * 0.5), floor((ok[2] + ok[4]) * 0.5), sfs, "cov") + end font:End() end local function drawProfileDialog(mx, my) local bx1, by1, bx2, by2, ok, cancel, field, discard, messageLines, messageStep = dialogGeometry() local cs = metrics.csButton - local cx = (bx1 + bx2) * 0.5 + local cx = floor((bx1 + bx2) * 0.5) local tfs = floor(rowHeight * 0.6) local sfs = floor(rowHeight * 0.5) @@ -2423,14 +2635,14 @@ local function drawProfileDialog(mx, my) if dialog.middle then font:Print( colorText .. dialog.middle.label, - (discard[1] + discard[3]) * 0.5, - (discard[2] + discard[4]) * 0.5, + floor((discard[1] + discard[3]) * 0.5), + floor((discard[2] + discard[4]) * 0.5), sfs, "cov" ) end if messageLines then - local top = (field[2] + field[4]) * 0.5 + (#messageLines - 1) * messageStep * 0.5 + local top = floor((field[2] + field[4]) * 0.5 + (#messageLines - 1) * messageStep * 0.5) for i = 1, #messageLines do font:Print( colorDim .. text.fit(font, messageLines[i], bx2 - bx1 - floor(32 * scale), sfs), @@ -2441,8 +2653,20 @@ local function drawProfileDialog(mx, my) ) end end - font:Print(colorText .. L.cancel, (cancel[1] + cancel[3]) * 0.5, (cancel[2] + cancel[4]) * 0.5, sfs, "cov") - font:Print(colorText .. acceptLabelFor(dialog), (ok[1] + ok[3]) * 0.5, (ok[2] + ok[4]) * 0.5, sfs, "cov") + font:Print( + colorText .. L.cancel, + floor((cancel[1] + cancel[3]) * 0.5), + floor((cancel[2] + cancel[4]) * 0.5), + sfs, + "cov" + ) + font:Print( + colorText .. acceptLabelFor(dialog), + floor((ok[1] + ok[3]) * 0.5), + floor((ok[2] + ok[4]) * 0.5), + sfs, + "cov" + ) font:End() if not dialog.message then @@ -2488,8 +2712,8 @@ local function drawButtons(hotId) end queueText( (enabled and b.textOn or b.textOff) or L[b.id], - (r[1] + r[3]) * 0.5, - (r[2] + r[4]) * 0.5, + floor((r[1] + r[3]) * 0.5), + floor((r[2] + r[4]) * 0.5), bfs, "cov" ) @@ -2517,16 +2741,14 @@ local function panelSignature(mx, my) local kind, a, b = gridZone(mx, my) h.gk, h.ga, h.gb = kind or "", a or 0, b or 0 end - elseif mx >= listX1 and mx <= listRight and my <= listTop and my > listBottom() then - -- Half-open on the shared edge: rows stack, so one row's top is the next one's - -- bottom and a closed test would put the cursor in both. - local r = floor((listTop - my) / rowHeight) + 1 - local row = r <= visibleRows() and rows[scroll + r] or nil + elseif mx >= listX1 and mx <= listRight then + local r, top, bottom = rowAt(my) + local row = r and rows[scroll + r] if row then h.row = r if row.type == "editable" then - local top = listTop - (r - 1) * rowHeight - local zone, idx = rowZone(rowLayout(row), mx, my, top - rowHeight + metrics.chipInset, top - metrics.chipInset) + local c1, c2 = bottom + metrics.chipInset, top - metrics.chipInset + local zone, idx = rowZone(rowLayout(row), mx, my, c1, c2) h.zone, h.idx = zone or "", idx or 0 end end @@ -2580,19 +2802,26 @@ local function drawPanel() drawGridMenu(h.gk, h.ga, h.gb) flushText() else - local rowCount = visibleRows() - for r = 1, rowCount do + -- Whole rows only: the band can end mid-row, and a row painted across the footer + -- would be clipped by nothing. + local base = scrollOffset() + local lb = listBottom() + for r = 1, #rows - scroll do local row = rows[scroll + r] if not row then break end - local top = listTop - (r - 1) * rowHeight + local top = listTop - (row.off - base) + local bottom = top - rowHeightOf(row) + if bottom < lb then + break + end local hovered = h.row == r - drawRow(row, top, top - rowHeight, hovered, hovered and h.zone or "", h.idx) + drawRow(row, top, bottom, hovered, hovered and h.zone or "", h.idx) end flushText() - Scroller(barX1, listBottom(), area.x2, listTop, #rows * rowHeight, scroll * rowHeight) + Scroller(barX1, lb, area.x2 - metrics.edgeInset, listTop, rowMetrics.totalH, base) end drawButtons(h.btn) @@ -2718,7 +2947,7 @@ end -- Returns true when the click landed in the column, selected or not, so it never falls -- through to the list behind it. local function sidebarPress(x, y) - if x < area.x1 or x > area.x1 + sidebarW or y < listBottom() or y > listTop then + if x < area.x1 or x > area.x1 + sidebarW or y < listBottom() or y > sidebarTop() then return false end @@ -2779,12 +3008,10 @@ function view.mousePress(x, y, button) if capturing then local bx1, by1, bx2, by2, ok, cancel = captureGeometry() if button == 1 then - if isInRect(x, y, ok[1], ok[2], ok[3], ok[4]) then - if #capturing.elems > 0 then - commitCapture(chainRaw()) - else - capturing = nil - end + -- Only while Accept is actually on screen; where it would be is otherwise just + -- part of the modal and swallows the click. + if captureCanAccept() and isInRect(x, y, ok[1], ok[2], ok[3], ok[4]) then + commitCapture(chainRaw()) elseif (isInRect(x, y, cancel[1], cancel[2], cancel[3], cancel[4])) or x < bx1 @@ -2861,10 +3088,10 @@ function view.mousePress(x, y, button) end if isInRect(x, y, listX1, listBottom(), listRight, listTop) then - -- The band can end in a partial row that draw never paints, so clamp to the - -- painted count or a click in that strip would edit an unseen row. - local r = floor((listTop - y) / rowHeight) + 1 - local row = r <= visibleRows() and rows[scroll + r] or nil + -- Through the same lookup the drawing uses, so the band's last partial row - which + -- is never painted - cannot be clicked either. + local r = rowAt(y) + local row = r and rows[scroll + r] if row and row.type == "editable" then local kind, raw = hitTestRow(row, x) if kind then diff --git a/luaui/Widgets/gui_flowui.lua b/luaui/Widgets/gui_flowui.lua index 2ccd4da494b..f424ac3494d 100644 --- a/luaui/Widgets/gui_flowui.lua +++ b/luaui/Widgets/gui_flowui.lua @@ -2842,6 +2842,80 @@ WG.FlowUI.Draw.Unit = function( gl.PopMatrix() end +---Draws the frame of a unit tile on its own: the outline, depth gradient, top shine and +---feathered border that `Unit` lays over a unit picture, with no picture under it. For a +---tile that should read as a unit slot without naming a unit, such as an empty build slot +---or a preview of the grid menu. Draw the tile's own background first; this only frames +---it. Repeated identical draws are served from the same display list cache `Unit` uses. +---@param px number Left +---@param py number Bottom +---@param sx number Right +---@param sy number Top +---@param cs number? Corner size. Defaults to a size derived from the tile width +---@param tl number? Enable the top-left chamfered corner. Defaults to `1` +---@param tr number? Enable the top-right chamfered corner. Defaults to `1` +---@param br number? Enable the bottom-right chamfered corner. Defaults to `1` +---@param bl number? Enable the bottom-left chamfered corner. Defaults to `1` +---@param borderSize number? Defaults to a size derived from the tile width +---@param borderOpacity number? Defaults to `0.1` +---@param groupTexture string? Group icon drawn in a corner +WG.FlowUI.Draw.UnitFrame = function(px, py, sx, sy, cs, tl, tr, br, bl, borderSize, borderOpacity, groupTexture) + local width = sx - px + local height = sy - py + -- Same defaults Unit derives, so a frame drawn on its own matches one drawn over a + -- picture at the same size. + local resolvedBorderSize = borderSize ~= nil and borderSize + or mathMin(mathMax(1, mathFloor(width * 0.024)), mathFloor((WG.FlowUI.vsy * 0.0015) + 0.5)) + local resolvedCs = cs ~= nil and cs or mathMax(1, mathFloor(width * 0.024)) + local resolvedBorderOpacity = borderOpacity or 0.1 + + if + width <= 0 + or height <= 0 + or width ~= width + or height ~= height + or resolvedCs ~= resolvedCs + or resolvedBorderSize ~= resolvedBorderSize + or resolvedBorderOpacity ~= resolvedBorderOpacity + then + return + end + + local record = GetUnitFrameRecord( + width, + height, + resolvedCs, + tl, + tr, + br, + bl, + resolvedBorderSize, + resolvedBorderOpacity, + groupTexture + ) + if record and record.list then + gl.PushMatrix() + gl.Translate(px, py, 0) + gl.CallList(record.list) + gl.PopMatrix() + else + DrawUnitFrame( + px, + py, + sx, + sy, + resolvedCs, + tl, + tr, + br, + bl, + resolvedBorderSize, + resolvedBorderOpacity, + groupTexture + ) + end +end + ---Draws a vertical scrollbar. ---@param px number Left ---@param py number Bottom diff --git a/luaui/Widgets/gui_keybind_info.lua b/luaui/Widgets/gui_keybind_info.lua index e04b8d305fe..b23007e5df0 100644 --- a/luaui/Widgets/gui_keybind_info.lua +++ b/luaui/Widgets/gui_keybind_info.lua @@ -24,7 +24,7 @@ local doUpdate local vsx, vsy = spGetViewGeometry() -local screenHeightOrg = 640 +local screenHeightOrg = 610 local screenWidthOrg = 1100 local screenHeight = screenHeightOrg local screenWidth = screenWidthOrg From 85921a73485f6a2e746ffa5e5750d1d3c31caa45 Mon Sep 17 00:00:00 2001 From: Floris Date: Thu, 10 Sep 2026 16:06:17 +0200 Subject: [PATCH 2/4] widget auto reloader: monitor the include files widgets use as well (#9181) --- luaui/Widgets/dbg_widget_auto_reloader.lua | 283 ++++++++++++++++++--- 1 file changed, 247 insertions(+), 36 deletions(-) diff --git a/luaui/Widgets/dbg_widget_auto_reloader.lua b/luaui/Widgets/dbg_widget_auto_reloader.lua index 44a62ea4982..0878ce13412 100644 --- a/luaui/Widgets/dbg_widget_auto_reloader.lua +++ b/luaui/Widgets/dbg_widget_auto_reloader.lua @@ -7,7 +7,7 @@ local widget = widget ---@type Widget function widget:GetInfo() return { name = "Widget Auto Reloader", - desc = "Reloads all widgets that have changed after the mouse returned to the game window", + desc = "Reloads all widgets that have changed after the mouse returned to the game window, including widgets whose VFS.Include'd files changed", author = "Beherith, Floris", date = "2024.03.12", license = "GNU GPL v2", @@ -24,18 +24,179 @@ local spEcho = Spring.Echo local widgetContents = {} -- maps widgetname to raw code local widgetFilesNames = {} -- maps widgetname to filename local widgetDependents = {} -- maps widgetname to {dependentName1, ...} +local widgetIncludes = {} -- maps widgetname to { [includeKey] = path } of the files it VFS.Includes directly +local includeFiles = {} -- maps includeKey (lowercased path) to { path, contents, includes = { [includeKey] = path }, changed } local mouseOffscreen = select(6, spGetMouseState()) -function widget:Initialize() - local widgets = widgetHandler.widgets - for _, widget in pairs(widgets) do - local whInfo = widget.whInfo +--------------------------------------------------------------------------------------------------- +-- Include tracking +-- +-- A widget's own file doesn't change when a file it VFS.Includes is edited, so the widget sources are +-- scanned for VFS.Include calls and the included files are tracked as well (recursively, included +-- files include further files). A widget is reloaded when its own file or any file in its include +-- closure changed. Paths built from a variable (luaShaderDir .. "x.lua") are resolved when that +-- variable is assigned a string literal somewhere in the same file; fully dynamic paths are skipped. +--------------------------------------------------------------------------------------------------- + +local function StripComments(source) + source = source:gsub("%-%-%[(=*)%[.-%]%1%]", "") -- block comments + source = source:gsub("%-%-[^\r\n]*", "") -- line comments + return source +end + +-- first string literal assigned to `name` in this source, e.g. local luaShaderDir = "LuaUI/Include/" +local function ResolveVariable(source, name) + return source:match("%f[%w_]" .. name .. "%s*=%s*\"([^\"\r\n]*)\"") + or source:match("%f[%w_]" .. name .. "%s*=%s*'([^'\r\n]*)'") +end + +-- parses the first argument of a VFS.Include call, `pos` pointing just after the function name +-- returns the path, or nil plus the identifier that could not be resolved +local function ParseIncludeArgument(source, pos) + local s = source:match("^%s*[%(,]%s*()", pos) -- VFS.Include(...) or pcall(VFS.Include, ...) + if not s then + return nil + end + local path = "" + while true do + local quote = source:sub(s, s) + local term + if quote == '"' or quote == "'" then + local e = source:find(quote, s + 1, true) + if not e then + return nil + end + term = source:sub(s + 1, e - 1) + s = e + 1 + else + local ident, e = source:match("^([%a_][%w_]*)()", s) + if not ident then + return nil + end + term = ResolveVariable(source, ident) + if not term then + return nil, ident + end + s = e + end + path = path .. term + local afterConcat = source:match("^%s*%.%.%s*()", s) + if not afterConcat then + return path + end + s = afterConcat + end +end + +-- returns { [includeKey] = path } for every statically resolvable VFS.Include target in the source, +-- plus the list of identifiers whose include path could not be resolved +local function ScanIncludes(source) + local includes, unresolved = {}, {} + source = StripComments(source) + local pos = 1 + while true do + local s, e = source:find("VFS%.Include", pos) + if not s then + break + end + pos = e + 1 + local path, ident = ParseIncludeArgument(source, pos) + if path then + includes[path:lower()] = path + elseif ident then + unresolved[#unresolved + 1] = ident + end + end + return includes, unresolved +end + +local function RegisterIncludeFile(key, path) + if includeFiles[key] then + return + end + local contents = VFS.LoadFile(path) + if not contents then + return -- not readable (e.g. a wrongly resolved dynamic path) + end + local entry = { path = path, contents = contents, includes = {} } + includeFiles[key] = entry -- registered before recursing: guards against include cycles + entry.includes = ScanIncludes(contents) + for childKey, childPath in pairs(entry.includes) do + RegisterIncludeFile(childKey, childPath) + end +end + +local function RegisterWidgetIncludes(widgetName, contents) + local includes = ScanIncludes(contents) + widgetIncludes[widgetName] = includes + for key, path in pairs(includes) do + RegisterIncludeFile(key, path) + end +end + +-- walks the widget's include closure; returns the path of the first changed file, if any +local function FindChangedInclude(widgetName) + local includes = widgetIncludes[widgetName] + if not includes then + return nil + end + local visited, stack = {}, {} + for key in pairs(includes) do + stack[#stack + 1] = key + end + while #stack > 0 do + local key = stack[#stack] + stack[#stack] = nil + if not visited[key] then + visited[key] = true + local entry = includeFiles[key] + if entry then + if entry.changed then + return entry.path + end + for childKey in pairs(entry.includes) do + stack[#stack + 1] = childKey + end + end + end + end + return nil +end + +local function CheckIncludeForChanges(key) + local entry = includeFiles[key] + local newContents = VFS.LoadFile(entry.path) + if newContents == nil or newContents == entry.contents then + return + end + entry.contents = newContents + entry.includes = ScanIncludes(newContents) + for childKey, childPath in pairs(entry.includes) do + RegisterIncludeFile(childKey, childPath) + end + local chunk, err = loadstring(newContents, entry.path) + if chunk == nil then + spEcho("Failed to load: " .. entry.path .. " (" .. err .. ")") + return -- the including widgets are reloaded once the file compiles again + end + entry.changed = true -- consumed by the widget checks of the same sweep +end + +--------------------------------------------------------------------------------------------------- + +local function RefreshWidgetList() + for _, w in pairs(widgetHandler.widgets) do + local whInfo = w.whInfo widgetFilesNames[whInfo.name] = whInfo.filename if not widgetContents[whInfo.name] then - widgetContents[whInfo.name] = VFS.LoadFile(whInfo.filename) + local contents = VFS.LoadFile(whInfo.filename) + widgetContents[whInfo.name] = contents + if contents then + RegisterWidgetIncludes(whInfo.name, contents) + end end - if widget.GetInfo then - local info = widget:GetInfo() + if w.GetInfo then + local info = w:GetInfo() if info.dependents then widgetDependents[whInfo.name] = info.dependents end @@ -43,58 +204,112 @@ function widget:Initialize() end end -local function ReloadWidget(widgetName) - spEcho("Reloading widget: " .. widgetName) +function widget:Initialize() + RefreshWidgetList() + local includeCount = 0 + for _ in pairs(includeFiles) do + includeCount = includeCount + 1 + end + spEcho("Widget Auto Reloader: tracking " .. includeCount .. " included files") +end + +local function ReloadWidget(widgetName, reason) + spEcho("Reloading widget: " .. widgetName .. " (" .. reason .. ")") widgetHandler:DisableWidget(widgetName) widgetHandler:EnableWidget(widgetName) end local function CheckForChanges(widgetName, fileName) local newContents = VFS.LoadFile(fileName) + local reason if newContents ~= widgetContents[widgetName] then widgetContents[widgetName] = newContents + if newContents == nil then + return + end + RegisterWidgetIncludes(widgetName, newContents) local chunk, err = loadstring(newContents, fileName) - if not mouseOffscreen and chunk == nil then + if chunk == nil then spEcho("Failed to load: " .. fileName .. " (" .. err .. ")") - return nil + return end - ReloadWidget(widgetName) - local deps = widgetDependents[widgetName] - if deps then - for i = 1, #deps do - local depName = deps[i] - if widgetHandler:FindWidget(depName) then - spEcho("Reloading dependent widget: " .. depName .. " (of " .. widgetName .. ")") - ReloadWidget(depName) - end + reason = "file changed" + else + local changedInclude = FindChangedInclude(widgetName) + if not changedInclude then + return + end + reason = changedInclude .. " changed" + end + if not widgetHandler:FindWidget(widgetName) then + return -- disabled meanwhile; enabling it by hand loads the file fresh anyway + end + ReloadWidget(widgetName, reason) + local deps = widgetDependents[widgetName] + if deps then + for i = 1, #deps do + local depName = deps[i] + if widgetHandler:FindWidget(depName) then + ReloadWidget(depName, "dependent of " .. widgetName) end end end end local lastUpdate = Spring.GetTimer() -local updateQueue = {} +local updateQueue = {} -- included files first, then widgets, so widgets see the include changes of the same sweep +local queueIndex = 1 local lastQueueRun = lastUpdate local gameFrameHappened = false local minimumQueueRate = 1 / 30 +local function StartSweep() + RefreshWidgetList() + updateQueue = {} + queueIndex = 1 + for key in pairs(includeFiles) do + updateQueue[#updateQueue + 1] = { includeKey = key } + end + for widgetName, fileName in pairs(widgetFilesNames) do + updateQueue[#updateQueue + 1] = { widgetName = widgetName, fileName = fileName } + end +end + +local function FinishSweep() + for _, entry in pairs(includeFiles) do + entry.changed = nil + end + updateQueue = {} + queueIndex = 1 +end + +local function ProcessQueueItem(item) + if item.includeKey then + tracy.ZoneBeginN("Widget Auto Reloader:" .. item.includeKey) + CheckIncludeForChanges(item.includeKey) + else + tracy.ZoneBeginN("Widget Auto Reloader:" .. item.widgetName) + CheckForChanges(item.widgetName, item.fileName) + end + tracy.ZoneEnd() +end + function widget:GameFrame() gameFrameHappened = true end function widget:Update() - local widgetName, fileName = next(updateQueue) local now = Spring.GetTimer() - if widgetName and (not gameFrameHappened or Spring.DiffTimers(now, lastQueueRun) >= minimumQueueRate) then + if queueIndex <= #updateQueue and (not gameFrameHappened or Spring.DiffTimers(now, lastQueueRun) >= minimumQueueRate) then lastQueueRun = now - local startTime = now -- 2 ms budget per frame - while widgetName and (Spring.DiffTimers(Spring.GetTimer(), startTime, true) < 2.0) do - tracy.ZoneBeginN("Widget Auto Reloader:" .. widgetName) - CheckForChanges(widgetName, fileName) - updateQueue[widgetName] = nil - widgetName, fileName = next(updateQueue) - tracy.ZoneEnd() + while queueIndex <= #updateQueue and Spring.DiffTimers(Spring.GetTimer(), now, true) < 2.0 do + local item = updateQueue[queueIndex] + queueIndex = queueIndex + 1 + ProcessQueueItem(item) + end + if queueIndex > #updateQueue then + FinishSweep() end end gameFrameHappened = false @@ -108,10 +323,6 @@ function widget:Update() mouseOffscreen = select(6, spGetMouseState()) if not mouseOffscreen and prevMouseOffscreen then - widget:Initialize() - updateQueue = {} - for widgetName, fileName in pairs(widgetFilesNames) do - updateQueue[widgetName] = fileName - end + StartSweep() end end From f9928343b73f26a1bf1ffb5c8a39ef76c824a22a Mon Sep 17 00:00:00 2001 From: Robert Burnham Date: Thu, 10 Sep 2026 09:35:15 -0500 Subject: [PATCH 3/4] Fix keybind editor migration, duplicate names and meta capture (#9182) Three fixes in the in-game keybind editor: a first run that leaves you with almost no keybinds, a save dialog that quietly renumbers the name you typed, and key capture that binds the fake meta key to itself. Profiles now always write a fakemeta line, `none` included, because unbindall leaves the engine's fake meta key alone and a profile that named none was picking up whatever the last one set. AI disclosure: written with assistance from Claude Code. --- luaui/Include/keybind_editor_view.lua | 49 +++++++++++++++++++-------- luaui/Include/keybind_profiles.lua | 18 +++++++--- 2 files changed, 48 insertions(+), 19 deletions(-) diff --git a/luaui/Include/keybind_editor_view.lua b/luaui/Include/keybind_editor_view.lua index de8a87fd619..6f9b225e900 100644 --- a/luaui/Include/keybind_editor_view.lua +++ b/luaui/Include/keybind_editor_view.lua @@ -888,19 +888,28 @@ local function middleDialog() end end +-- A name already in use would be renumbered on the way into the store, handing back a +-- profile nobody asked for. +local function dialogName() + if not dialog or dialog.message then + return "", false + end + + local name = nameBox:getText():gsub("^%s+", ""):gsub("%s+$", "") + local taken = name ~= dialog.allow and (profiles.get(name) ~= nil or profiles.isBuiltin(name) ~= nil) + + return name, name == "" or taken +end + -- Confirmation path; only a dialog with a name field has text to read. local function acceptDialog() - local name = dialog and not dialog.message and nameBox:getText():gsub("^%s+", ""):gsub("%s+$", "") or "" - local d = closeDialog() - if not d then + local name, blocked = dialogName() + if blocked then return end - -- An empty name would make the profile unselectable, so treat it as a cancel. - if not d.message and name == "" then - if d.cancel then - d.cancel() - end + local d = closeDialog() + if not d then return end @@ -1064,6 +1073,7 @@ local function startEdit() openDialog({ title = L.editTitle, initial = name, + allow = name, accept = function(newName) profiles.rename(name, newName) refreshPicker() @@ -1713,6 +1723,8 @@ local function startCapture(action, label, oldRaws) end end + local fakeMeta = activeFakeMeta() + capturing = { action = action, label = label, @@ -1727,6 +1739,7 @@ local function startCapture(action, label, oldRaws) -- Matches the engine's KeyChainTimeout default; BAR ships a tighter 333ms. timeout = 750, any = actionUsesAny(action, oldRaw), + fakeMetaCode = fakeMeta and Spring.GetKeyCode(fakeMeta) or nil, } end @@ -1768,8 +1781,13 @@ local function captureCanAccept() return c ~= nil and #c.elems > 0 and not c.seeded end --- Scancode to keyset symbol, refusing modifier keys so they cannot bind alone. -local function pressSym(scanCode) +-- Scancode to keyset symbol, refusing modifier keys and the stand-in Meta key so they +-- cannot bind alone; the latter is held down while the player picks what goes with it. +local function pressSym(key, scanCode) + if capturing and key == capturing.fakeMetaCode then + return nil + end + local sym = scanCode and spGetScanSymbol(scanCode) if not sym or sym == "" then return nil @@ -2604,10 +2622,11 @@ local function drawProfileDialog(mx, my) -- Anything whose accept saves is green, anything destructive is red, wherever it -- appears; a tinted button brightens on hover instead of taking the white overlay. - local acceptSaves = dialog.save or (not dialog.message and not dialog.danger) + local _, blocked = dialogName() + local acceptSaves = not blocked and (dialog.save or (not dialog.message and not dialog.danger)) local buttons = { { r = cancel }, - { r = ok, danger = dialog.danger, confirm = acceptSaves }, + { r = ok, danger = not blocked and dialog.danger, confirm = acceptSaves, inert = blocked }, } if dialog.middle then buttons[#buttons + 1] = { r = discard, danger = dialog.middle.danger } @@ -2619,7 +2638,7 @@ local function drawProfileDialog(mx, my) local lift = (b.danger and dangerFillHover) or (b.confirm and confirmFillHover) local fill = base and (hovered and lift or base) drawButtonFace(r, fill or buttonFill) - if not fill and hovered then + if not fill and hovered and not b.inert then Highlight(r[1], r[2], r[3], r[4], cs, hoverOpacity, { 1, 1, 1 }) end end @@ -2661,7 +2680,7 @@ local function drawProfileDialog(mx, my) "cov" ) font:Print( - colorText .. acceptLabelFor(dialog), + (blocked and colorDim or colorText) .. acceptLabelFor(dialog), floor((ok[1] + ok[3]) * 0.5), floor((ok[2] + ok[4]) * 0.5), sfs, @@ -3139,7 +3158,7 @@ function view.keyPress(key, scanCode) capturing = nil else -- Skip auto-repeat; only the initial press adds an element (release clears pressed). - local sym = pressSym(scanCode) + local sym = pressSym(key, scanCode) if sym and not capturing.pressed[scanCode] then capturing.pressed[scanCode] = true appendChain({ sym = sym, mods = modPrefix() }) diff --git a/luaui/Include/keybind_profiles.lua b/luaui/Include/keybind_profiles.lua index 8919a77bf10..b5fe77c4e20 100644 --- a/luaui/Include/keybind_profiles.lua +++ b/luaui/Include/keybind_profiles.lua @@ -130,13 +130,15 @@ local function generatedName(text) return (name ~= nil and name ~= "") and name or nil end --- uikeys.txt only needs the bind lines; keyreload clears and sets fakemeta itself. +-- A whole keymap: keyreload clears the bindings before it loads, but not the meta key. local function toBindFile(profile) local out = { GENERATED_PREFIX .. tostring(profile.name) } - -- One token only: anything longer emits a fakemeta directive the engine cannot parse. - if profile.fakeMeta and profile.fakeMeta ~= "" and not profile.fakeMeta:find("%s") then - out[#out + 1] = "fakemeta " .. profile.fakeMeta + -- One token only: anything longer emits a directive the engine cannot parse; "none" clears. + local fakeMeta = profile.fakeMeta + if not fakeMeta or fakeMeta == "" or fakeMeta:find("%s") then + fakeMeta = "none" end + out[#out + 1] = "fakemeta " .. fakeMeta -- The store is writable by the player and by other surfaces, so a malformed entry is -- reachable here. Dropping one costs a keybind; letting it through takes the whole -- hotkey loader down with it. @@ -459,6 +461,14 @@ local function migrate() end M.save() + + -- A keyload naming a retired preset resolves to that profile's bindings here and to + -- nothing engine-side, so hand it the store rather than the file the store came from. + local active = M.getActive() + local file = active and M.materialize(active) + if file then + Spring.SetConfigString("KeybindingFile", file) + end end -- Reads the store once, migrating an older layout on the way in. From c82c12df7c679ba0e83f1969373a1e6b10450736 Mon Sep 17 00:00:00 2001 From: efrec Date: Thu, 10 Sep 2026 11:36:40 -0400 Subject: [PATCH 4/4] fix: startbox spec from leaks a zlib stub into later specs (#9189) ## Work done The spec replaced VFS.ZlibDecompress with an identity function at file scope and never restored it. Busted runs every spec file in one Lua state and sorts the file list, so spec/luaui/Include/mission_options_spec.lua ran after it with the helper's ZlibCompress paired against the _identity_ ZlibDecompress, and every decode returned nil. Encode through the helper's ZlibCompress and drop the override. --- spec/luarules/startbox_utilities_spec.lua | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/spec/luarules/startbox_utilities_spec.lua b/spec/luarules/startbox_utilities_spec.lua index 274a6889c43..25fb8fb53a1 100644 --- a/spec/luarules/startbox_utilities_spec.lua +++ b/spec/luarules/startbox_utilities_spec.lua @@ -1,5 +1,5 @@ -- Arrangement resolution and the whole-map fill for allyteams the arrangement does not --- reach. Modoptions are base64 of raw JSON here, with the zlib step stubbed out. +-- reach. Modoptions go through the spec helper's zlib and base64 stubs. local base64 = VFS.Include("common/luaUtilities/base64.lua") @@ -53,9 +53,6 @@ local savedSpring = { Game.mapSizeX, Game.mapSizeZ = MAP_SIZE_X, MAP_SIZE_Z _G.Json = VFS.Include("common/luaUtilities/json.lua") -VFS.ZlibDecompress = function(data) - return data -end local function setUpGame(numAllyTeams, modoptions) local allyTeamList = {} @@ -83,7 +80,7 @@ local function setUpGame(numAllyTeams, modoptions) Spring.GetModOptions = function() local encoded = {} for key, json in pairs(modoptions) do - encoded[key] = base64.Encode(json) + encoded[key] = base64.Encode(VFS.ZlibCompress(json)) end return encoded