From 2af2ee166c6a8c0464c24f21c33ceef3e1ff0fdb Mon Sep 17 00:00:00 2001 From: eun-ice Date: Sat, 12 Sep 2026 05:22:03 -0600 Subject: [PATCH 1/4] Fix enemy fire-state display in godmode (#9200) ### Work done Fix the order menu showing an enemy unit as "Fire at will" again after changing its fire state in godmode and reselecting it, even when the unit received the command. `Spring.GetUnitStates` can return no value for enemy units because godmode control does not grant allied read access. The shared fire-state reader previously converted that missing value to "Fire at will". Only when `Spring.IsGodModeEnabled()` is true, it now falls back to the unit's fire-state command description and returns nil if neither source is available. Custom fire-state rules parameters retain priority. With godmode disabled, the previous behavior is preserved, including the default for unavailable states; no command descriptions are queried by the fallback. #### Test steps - [x] Reproduced the incorrect fallback with mocked Spring APIs before the fix; nine Lua checks cover own/enemy units, all four engine fire states, custom Defend priority, and missing/invalid units. - [x] 120 additional mocked checks compare godmode-disabled results against the implementation before this PR, covering valid/invalid units, both modoption settings, available/missing engine states and custom rules states. All match, and the fallback never queries command descriptions without godmode. - [x] `stylua --check modules/custom_firestate_defs.lua` and `git diff --check` pass. - [ ] In a local game with cheats and godmode enabled, select an enemy unit, change its fire state, deselect and reselect it. Verify the menu keeps showing the actual state. Repeat with left/right click and an own-team unit, then with godmode disabled. In-game validation is still pending. ### AI / LLM usage statement OpenAI Codex investigated the bug, implemented the change, ran the mocked checks, and prepared this PR. --- modules/custom_firestate_defs.lua | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/modules/custom_firestate_defs.lua b/modules/custom_firestate_defs.lua index 3231dbf3f7e..f7e0bf8732d 100644 --- a/modules/custom_firestate_defs.lua +++ b/modules/custom_firestate_defs.lua @@ -91,7 +91,18 @@ function customFirestateDefs.getUnitUserFirestate(unitID) return rulesState end end - return customFirestateDefs.fromEngineFirestate(select(1, Spring.GetUnitStates(unitID, false))) + local engineState = Spring.GetUnitStates(unitID, false) + if engineState == nil and Spring.IsGodModeEnabled() then + -- Godmode permits controlling enemies without granting GetUnitStates read access. + local cmdIndex = Spring.FindUnitCmdDesc(unitID, CMD.FIRE_STATE) + local cmdDescs = cmdIndex and Spring.GetUnitCmdDescs(unitID, cmdIndex, cmdIndex) + local cmdDesc = cmdDescs and cmdDescs[1] + engineState = cmdDesc and cmdDesc.params and tonumber(cmdDesc.params[1]) + if engineState == nil then + return nil + end + end + return customFirestateDefs.fromEngineFirestate(engineState) end function customFirestateDefs.stateLabel(cmd) From fd3aa82b9b2ee6a2010245145057ef2b4545aecc Mon Sep 17 00:00:00 2001 From: Floris Date: Sat, 12 Sep 2026 15:54:50 +0200 Subject: [PATCH 2/4] adjusted scrollbar behavior for gameinfo, keybinds, changelog (#9219) --- luaui/Include/keybind_editor_view.lua | 75 ++++++++++++--- luaui/Widgets/gui_changelog_info.lua | 69 ++++++++++++-- luaui/Widgets/gui_flowui.lua | 106 ++++++++++++++------- luaui/Widgets/gui_gameinfo.lua | 128 +++++++++++++++++++++----- 4 files changed, 302 insertions(+), 76 deletions(-) diff --git a/luaui/Include/keybind_editor_view.lua b/luaui/Include/keybind_editor_view.lua index 6f9b225e900..9932282371e 100644 --- a/luaui/Include/keybind_editor_view.lua +++ b/luaui/Include/keybind_editor_view.lua @@ -101,7 +101,6 @@ local otherCategoryKey = generatedOtherKey ---@type table? local gridGroup local listRight = 0 -local keyAreaX1 = 0 ---@type table local working @@ -1337,7 +1336,7 @@ function view.setArea(x1, y1, x2, y2, s) local barW = floor(14 * scale) barX1 = area.x2 - metrics.edgeInset - barW listRight = barX1 - metrics.listGap - keyAreaX1 = listX1 + floor((listRight - listX1) * 0.45) + metrics.keyAreaX1 = listX1 + floor((listRight - listX1) * 0.45) -- Shortened here rather than in the draw loop: the column width and the font size are -- both settled by now, and this runs on a resize where the loop runs every frame. @@ -1903,7 +1902,7 @@ local function layoutRowChips(action, fs, pad, rightGap, chipArea, gap) local n = #groups local mets = {} if n == 0 then - return mets, keyAreaX1 + return mets, metrics.keyAreaX1 end local total = 0 @@ -1921,7 +1920,7 @@ local function layoutRowChips(action, fs, pad, rightGap, chipArea, gap) end end - local cx = keyAreaX1 + local cx = metrics.keyAreaX1 for i = 1, n do mets[i].x = cx mets[i].removeX1 = cx + mets[i].w - rightGap @@ -1939,7 +1938,7 @@ local function rowChipBand(action, fs, pad) local rightGap = pad + floor(fs * 0.9) local addW = floor(fs + pad * 2) -- Room reserved on the right so "+" always fits. - local chipArea = listRight - addW - floor(8 * scale) - keyAreaX1 + local chipArea = listRight - addW - floor(8 * scale) - metrics.keyAreaX1 local mets, cx = layoutRowChips(action, fs, pad, rightGap, chipArea, gap) return mets, cx, addW, rightGap @@ -1963,7 +1962,7 @@ local function rowLayout(row) lay.arrow = look.arrow lay.arrowX = listX1 + metrics.rowPad * 5 + floor(font:GetTextWidth(row.label) * metrics.rowFs) + metrics.rowPad * 2 else - local labelW = keyAreaX1 - (listX1 + metrics.rowPad) - metrics.rowPad + local labelW = metrics.keyAreaX1 - (listX1 + metrics.rowPad) - metrics.rowPad lay.text = colorAction .. text.fit(font, row.label, labelW, metrics.rowFs) local mets, cx, addW, rightGap = rowChipBand(row.action, metrics.rowFs, metrics.rowPad) for i = 1, #mets do @@ -2744,7 +2743,25 @@ end -- What the cursor is over, in the terms the panel paints hover with. Refilled in place -- each frame rather than allocated. -local hover = { sb = 0, row = 0, zone = "", idx = 0, gk = "", ga = 0, gb = 0, btn = "" } +-- `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. +local function scrollerThumb() + return WG.FlowUI.Draw.ScrollerGeometry( + barX1, + listBottom(), + area.x2 - metrics.edgeInset, + listTop, + rowMetrics.totalH, + scrollOffset() + ) +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. @@ -2754,6 +2771,16 @@ local function panelSignature(mx, my) h.row, h.zone, h.idx = 0, "", 0 h.gk, h.ga, h.gb = "", 0, 0 h.btn = "" + h.bar = 0 + + -- Over the thumb itself, which lights it. The track either side is not part of this: + -- only the thumb is something to take hold of. + if mx >= barX1 and mx <= area.x2 - metrics.edgeInset then + local top, height = scrollerThumb() + if top and my <= top and my >= top - height then + h.bar = 1 + end + end if gridGroup then if isInRect(mx, my, listX1, listBottom(), area.x2, listTop) then @@ -2808,6 +2835,10 @@ local function panelSignature(mx, my) .. (dirty and 1 or 0) .. "|" .. (activeIsOwn() and 1 or 0) + .. "|" + .. h.bar + .. "|" + .. (dragging and 1 or 0) end -- Everything under the header controls and above the modals: the sidebar, the list or @@ -2840,7 +2871,7 @@ local function drawPanel() end flushText() - Scroller(barX1, lb, area.x2 - metrics.edgeInset, listTop, rowMetrics.totalH, base) + Scroller(barX1, lb, area.x2 - metrics.edgeInset, listTop, rowMetrics.totalH, base, h.bar == 1, dragging) end drawButtons(h.btn) @@ -2923,9 +2954,16 @@ function view.draw() end end +-- Scrolls so the thumb's top sits where the cursor has dragged it. The offset taken at +-- the grab is what 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. scrollFromY = function(y) - local lb = listBottom() - local f = (listTop - y) / math.max(1, listTop - lb) + local _, _, trackTop, travel = scrollerThumb() + if not travel or travel <= 0 then + return + end + + local f = (trackTop - (y - hover.grab)) / travel if f < 0 then f = 0 elseif f > 1 then @@ -3097,8 +3135,21 @@ function view.mousePress(x, y, button) end if not gridGroup and isInRect(x, y, barX1, listBottom(), area.x2, listTop) then - dragging = true - scrollFromY(y) + -- Taking 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. Inline because this chunk + -- 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 + if y <= top and y >= top - height then + hover.grab = y - top + else + hover.grab = -floor(height * 0.5) + scrollFromY(y) + end + end + return true end diff --git a/luaui/Widgets/gui_changelog_info.lua b/luaui/Widgets/gui_changelog_info.lua index ec3e4891027..a11f66b7c4d 100644 --- a/luaui/Widgets/gui_changelog_info.lua +++ b/luaui/Widgets/gui_changelog_info.lua @@ -66,6 +66,8 @@ local UiElement ---@type function local UiScroller ---@type function +local UiScrollerAt +---@type function local Highlight local elementCorner local font, fontBold, fontMono @@ -137,6 +139,11 @@ local startRow = 1 -- The highest startRow that still fills the band. local maxStart = 1 local dragging = false +-- Where the 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 wherever the press landed. +local dragGrab = 0 +-- Lit while the cursor is on the thumb, and lit further while it is held. +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 @@ -195,10 +202,26 @@ local function setStartRow(n, chosen) selectedIdx = chosen or versionAt(startRow) end --- Cursor height in the band mapped straight onto the scroll range, as the keybind --- editor's bar does: the top of the bar is the start, the bottom the end. +-- Where the text currently sits, in the pixels the scrollbar is drawn against. +local function scrollPos() + return rows[startRow] and (rowTop[startRow] + rows[startRow].pad) or 0 +end + +-- The thumb, where it is now. Nil when the whole text fits and no bar is drawn. +local function scrollerThumb() + return UiScrollerAt(barX1, listBottom, area.x2 - metrics.edgeInset, listTop, totalH, scrollPos()) +end + +-- Scrolls so the thumb's top sits where the cursor has dragged it. The offset taken at the +-- grab is what keeps this relative: the thumb moves with the cursor rather than centring +-- itself on it, so taking hold of it does not shift the text before the drag begins. local function scrollFromY(y) - local f = (listTop - y) / mathMax(1, listTop - listBottom) + local _, _, trackTop, travel = scrollerThumb() + if not travel or travel <= 0 then + return + end + + local f = (trackTop - (y - dragGrab)) / travel if f < 0 then f = 0 elseif f > 1 then @@ -207,6 +230,24 @@ local function scrollFromY(y) setStartRow(1 + mathFloor(f * (maxStart - 1) + 0.5)) end +-- Takes hold of the bar. On the thumb that is a grab and the text 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 + dragGrab = -mathFloor(height * 0.5) + scrollFromY(y) + end +end + -- The space a row takes below its text box: the gap to the next block. The last row -- on a page may let that gap spill past the band, since nothing is drawn in it. local function rowTail(row) @@ -446,8 +487,7 @@ local function drawPanel() Markdown.draw(rows, startRow, lastRowFrom(startRow), listX1, listTop, ctx) end - local pos = rows[startRow] and (rowTop[startRow] + rows[startRow].pad) or 0 - UiScroller(barX1, listBottom, area.x2 - metrics.edgeInset, listTop, totalH, pos) + UiScroller(barX1, listBottom, area.x2 - metrics.edgeInset, listTop, totalH, scrollPos(), barHover, dragging) end function widget:ViewResize() @@ -468,6 +508,7 @@ function widget:ViewResize() RectRound = WG.FlowUI.Draw.RectRound UiElement = WG.FlowUI.Draw.Element UiScroller = WG.FlowUI.Draw.Scroller + UiScrollerAt = WG.FlowUI.Draw.ScrollerGeometry Highlight = WG.FlowUI.Draw.SelectHighlight titleText = colorText .. BAR.I18N("ui.changelog.title") @@ -497,6 +538,21 @@ function widget:DrawScreen() hoverIdx = show and sidebarIndexAt(mx, my) or nil + -- Only the thumb, not the track: it is the part that can be taken hold of, so it is the + -- part that lights up. + local wasHovered, wasDragging = barHover, dragging + barHover = false + if show and mx >= barX1 and mx <= area.x2 then + local top, height = scrollerThumb() + barHover = top ~= nil and my <= top and my >= top - height + end + + -- The bar is painted into the panel list, so a change in how it is lit is a change to + -- what that list holds. + if panelList and (barHover ~= wasHovered or dragging ~= wasDragging) then + panelList = glDeleteList(panelList) + end + if not panelList then panelList = glCreateList(drawPanel) end @@ -574,8 +630,7 @@ local function mouseEvent(x, y, button, release) end elseif math_isInRect(x, y, barX1, listBottom, area.x2, listTop) then -- The strip between the bar and the panel edge stays grabbable too. - dragging = true - scrollFromY(y) + grabScroller(y) end end diff --git a/luaui/Widgets/gui_flowui.lua b/luaui/Widgets/gui_flowui.lua index f424ac3494d..31da1e84640 100644 --- a/luaui/Widgets/gui_flowui.lua +++ b/luaui/Widgets/gui_flowui.lua @@ -2916,6 +2916,42 @@ WG.FlowUI.Draw.UnitFrame = function(px, py, sx, sy, cs, tl, tr, br, bl, borderSi end end +---Where a scrollbar's thumb sits, for a bar drawn with these bounds and this content. +--- +---Shared with `Scroller` so a panel hit-testing the thumb can never disagree with what was +---drawn: grabbing the thumb has to move the view by how far the thumb is dragged, while a +---press on the track either side of it is the one that jumps. +---@param px number Left +---@param py number Bottom +---@param sx number Right +---@param sy number Top +---@param contentHeight number Height of the scrolled content, in pixels +---@param position number? Current scroll position. Defaults to `0` +---@return number? top Top edge of the thumb, or nil when the content fits and none is drawn +---@return number? height Height of the thumb +---@return number? trackTop Where the thumb's top sits at position `0` +---@return number? travel How far down from `trackTop` the thumb's top can move +WG.FlowUI.Draw.ScrollerGeometry = function(px, py, sx, sy, contentHeight, position) + local width = sx - px + local padding = mathFloor((width * 0.25) + 0.5) + local trackHeight = (sy - py) - padding - padding + + if not contentHeight or contentHeight <= 0 or trackHeight <= 0 then + return nil + end + + local fraction = trackHeight / contentHeight + if fraction >= 1 then + return nil + end + + local thumbHeight = mathFloor((fraction * trackHeight) + 0.5) + local trackTop = sy - padding + local top = trackTop - mathFloor((trackHeight * ((position or 0) / contentHeight)) + 0.5) + + return top, thumbHeight, trackTop, trackHeight - thumbHeight +end + ---Draws a vertical scrollbar. ---@param px number Left ---@param py number Bottom @@ -2923,39 +2959,33 @@ end ---@param sy number Top ---@param contentHeight number Height of the scrolled content, in pixels ---@param position number? Current scroll position. Defaults to `0` -WG.FlowUI.Draw.Scroller = function(px, py, sx, sy, contentHeight, position) +---@param hovered boolean? Cursor is over the thumb +---@param active boolean? The thumb is being dragged +WG.FlowUI.Draw.Scroller = function(px, py, sx, sy, contentHeight, position, hovered, active) + local top, thumbHeight = WG.FlowUI.Draw.ScrollerGeometry(px, py, sx, sy, contentHeight, position) + if not top then + return + end + local width = sx - px - local height = sy - py local padding = mathFloor((width * 0.25) + 0.5) - local sliderAreaHeight = height - padding - padding - local sliderHeight = sliderAreaHeight / contentHeight - - if sliderHeight < 1 then - position = position or 0 - sliderHeight = mathFloor((sliderHeight * sliderAreaHeight) + 0.5) - local sliderPos = sy - padding - mathFloor((sliderAreaHeight * (position / contentHeight)) + 0.5) - - -- background - WG.FlowUI.Draw.RectRound(px, py, sx, sy, width * 0.2, 1, 1, 1, 1, { 0, 0, 0, 0.2 }) - - -- slider - local cs = (width - padding - padding) * 0.2 - if cs > sliderHeight * 0.5 then - cs = sliderHeight * 0.5 - end - WG.FlowUI.Draw.RectRound( - px + padding, - sliderPos - sliderHeight, - sx - padding, - sliderPos, - cs, - 1, - 1, - 1, - 1, - { 1, 1, 1, 0.16 } - ) + + -- background + WG.FlowUI.Draw.RectRound(px, py, sx, sy, width * 0.2, 1, 1, 1, 1, { 0, 0, 0, 0.2 }) + + -- slider, lit while the cursor is on it and lit further while it is being dragged, so + -- it reads as something to take hold of rather than a mark of where you are + local cs = (width - padding - padding) * 0.2 + if cs > thumbHeight * 0.5 then + cs = thumbHeight * 0.5 end + local alpha = 0.16 + if active then + alpha = 0.38 + elseif hovered then + alpha = 0.26 + end + WG.FlowUI.Draw.RectRound(px + padding, top - thumbHeight, sx - padding, top, cs, 1, 1, 1, 1, { 1, 1, 1, alpha }) end ---Draws a toggle switch. @@ -2964,11 +2994,16 @@ end ---@param sx number Right ---@param sy number Top ---@param state number? `0`, `0.5` or `1`. Defaults to `0` -WG.FlowUI.Draw.Toggle = function(px, py, sx, sy, state) +---@param hovered boolean? Cursor is over the switch, which lights it +WG.FlowUI.Draw.Toggle = function(px, py, sx, sy, state, hovered) local height = sy - py local width = sx - px local cs = height * 0.1 local edgeWidth = mathMax(1, mathFloor(height * 0.1)) + -- A hover plate laid over the whole row reads as the row lighting up rather than the + -- switch: the switch has a plate of its own, and at those opacities it barely moves. + -- So the switch brightens itself, and the light its knob gives off with it. + local lit = hovered and 2.4 or 1 -- faint dark outline edge WG.FlowUI.Draw.RectRound( @@ -2984,7 +3019,7 @@ WG.FlowUI.Draw.Toggle = function(px, py, sx, sy, state) { 0, 0, 0, 0.05 } ) -- top - WG.FlowUI.Draw.RectRound(px, py, sx, sy, cs, 1, 1, 1, 1, { 0.5, 0.5, 0.5, 0.12 }, { 1, 1, 1, 0.12 }) + WG.FlowUI.Draw.RectRound(px, py, sx, sy, cs, 1, 1, 1, 1, { 0.5, 0.5, 0.5, 0.12 * lit }, { 1, 1, 1, 0.12 * lit }) -- highlight gl.Blending(GL.SRC_ALPHA, GL.ONE) @@ -3000,7 +3035,7 @@ WG.FlowUI.Draw.Toggle = function(px, py, sx, sy, state) 1, 1, { 1, 1, 1, 0 }, - { 1, 1, 1, 0.035 } + { 1, 1, 1, 0.035 * lit } ) -- bottom WG.FlowUI.Draw.RectRound( @@ -3013,7 +3048,7 @@ WG.FlowUI.Draw.Toggle = function(px, py, sx, sy, state) 1, 1, 1, - { 1, 1, 1, 0.025 }, + { 1, 1, 1, 0.025 * lit }, { 1, 1, 1, 0 } ) gl.Blending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) @@ -3038,6 +3073,9 @@ WG.FlowUI.Draw.Toggle = function(px, py, sx, sy, state) end WG.FlowUI.Draw.SliderKnob(x, y, radius, color) + if hovered then + glowMult = glowMult * 1.8 + end if glowMult > 0 then local boolGlow = radius * 1.75 gl.Blending(GL.SRC_ALPHA, GL.ONE) diff --git a/luaui/Widgets/gui_gameinfo.lua b/luaui/Widgets/gui_gameinfo.lua index f4cd3dee2cb..95deade83ab 100644 --- a/luaui/Widgets/gui_gameinfo.lua +++ b/luaui/Widgets/gui_gameinfo.lua @@ -71,6 +71,8 @@ local UiElement ---@type function local UiScroller ---@type function +local UiScrollerAt +---@type function local Highlight ---@type function local UiToggle @@ -218,6 +220,10 @@ local layoutGen = 0 local rowMetrics = { gen = -1, rows = -1, totalH = 0 } local scroll = 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 +-- the press landed. +local dragGrab = 0 -- Rows of decoded tweak the cursor has dragged over, as indices into `rows`. Source is the -- one thing in here worth taking somewhere else, so it is the one thing that selects. local selFrom, selTo = 0, 0 @@ -234,7 +240,7 @@ local changedOnly = false local searchBox -- What the cursor is over, in the terms the baked panel is painted with. Refilled in -- place each frame rather than allocated. -local hover = { sb = 0, row = 0, tog = 0 } +local hover = { sb = 0, row = 0, tog = 0, bar = 0 } -- Input ownership is taken once when the search field takes focus and given back when it -- loses it, rather than every frame, so chat's handling is restored exactly as it was @@ -1096,10 +1102,21 @@ local function selectAllCode() return true end --- Cursor height in the band mapped straight onto the scroll range, as the keybind --- editor's bar does: the top of the bar is the start, the bottom the end. +-- The thumb, where it is now. Nil when everything fits and no bar is drawn. +local function scrollerThumb() + return UiScrollerAt(barX1, listBottom, area.x2 - metrics.edgeInset, listTop, rowMetrics.totalH, scrollOffset()) +end + +-- Scrolls so the thumb's top sits where the cursor has dragged it. The offset taken at the +-- grab is what keeps this relative: the thumb moves with the cursor rather than centring +-- itself on it, so taking hold of it does not shift the view before the drag begins. local function scrollFromY(y) - local f = (listTop - y) / mathMax(1, listTop - listBottom) + local _, _, trackTop, travel = scrollerThumb() + if not travel or travel <= 0 then + return + end + + local f = (trackTop - (y - dragGrab)) / travel if f < 0 then f = 0 elseif f > 1 then @@ -1108,6 +1125,24 @@ local function scrollFromY(y) setScroll(mathFloor(f * maxScroll() + 0.5)) end +-- Takes hold of the bar. On the thumb that is a grab, and the view stays where it is; on +-- the track either side of it the thumb jumps to the cursor first and is then dragged from +-- its middle, which is what a press on empty 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 + dragGrab = -mathFloor(height * 0.5) + scrollFromY(y) + end +end + -- Rebuilds the list from the blocks, honouring the category column, the search box and -- the changed-only toggle. A block whose heading matches the search shows all of its rows, -- so hunting for a category by name works the way hunting for an option does. @@ -1239,7 +1274,6 @@ local function setLayout() valueX1 = listX1 + mathFloor((listRight - listX1) * 0.55) -- The header band: the search field takes the width the filter toggle leaves it. - local gap = mathFloor(8 * s) local rowTop = area.y2 - mathFloor(4 * s) local rowBottom = area.y2 - metrics.headerH + mathFloor(4 * s) local fs = mathFloor((rowTop - rowBottom) * 0.5) @@ -1248,10 +1282,27 @@ local function setLayout() local togY = mathFloor((rowTop + rowBottom) * 0.5) local togX2 = area.x2 - metrics.edgeInset toggleDraw = { togX2 - togW, togY - mathFloor(togH * 0.5), togX2, togY - mathFloor(togH * 0.5) + togH } - local labelW = font and mathFloor(font:GetTextWidth(L.changedOnly) * fs) or mathFloor(90 * s) - -- The caption is part of the control: a toggle this small is a poor click target on - -- its own, and the words beside it are what names the thing being switched. - toggleHit = { togX2 - togW - gap - labelW, rowBottom, togX2, rowTop } + -- Measured at the size it is drawn at, not at the header's: the rect below is built off + -- this, and a caption measured at one size and drawn at another puts it out by whatever + -- the two happen to differ by. + metrics.toggleFs = mathFloor(metrics.rowFs * 1.05) + local labelW = font and mathFloor(font:GetTextWidth(L.changedOnly) * metrics.toggleFs) or mathFloor(90 * s) + -- Outlined text spreads past the box it is measured in: gui_fonthandler builds the faces + -- with an outline of 0.22 * 0.9 of the em, so the caption's first glyph already sits that + -- much left of where its advance box starts. The toggle at the other end has no such + -- bleed, so matching the two boxes does not read as matching - this buys the caption side + -- back the room its outline took. + metrics.captionBleed = mathFloor(metrics.toggleFs * 0.2 + 0.5) + -- The caption is part of the control: a toggle this small is a poor click target on its + -- own, and the words beside it are what names the thing being switched. This is also what + -- the hover paints, so it keeps the same room in front of the caption as it does after + -- the toggle, rather than opening wider on one side than the other. + toggleHit = { + toggleDraw[1] - metrics.rowPad * 2 - labelW - metrics.captionBleed, + rowBottom, + togX2 + metrics.rowPad, + rowTop, + } -- Wider than the gaps inside the control, so the caption reads as belonging to the -- toggle beside it rather than to the field it would otherwise sit against. searchBox:setRect(listX1, rowBottom, toggleHit[1] - mathFloor(28 * s), rowTop, fs) @@ -1700,23 +1751,26 @@ end -- The filter toggle and its caption. The search field draws itself, live, so its caret -- can blink without the panel being baked again every frame. local function drawHeader() + -- The plate goes behind the switch and the switch lights itself, rather than the plate + -- being laid over it: 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. if hover.tog == 1 then Highlight( - toggleHit[1] - metrics.rowPad, + toggleHit[1], toggleHit[2], - toggleHit[3] + metrics.rowPad, + toggleHit[3], toggleHit[4], metrics.csSmall, look.rowHoverOpacity, look.white ) end - UiToggle(toggleDraw[1], toggleDraw[2], toggleDraw[3], toggleDraw[4], changedOnly) + UiToggle(toggleDraw[1], toggleDraw[2], toggleDraw[3], toggleDraw[4], changedOnly, hover.tog == 1) queueText( (changedOnly and colorSelected or colorDim) .. L.changedOnly, toggleDraw[1] - metrics.rowPad, mathFloor((toggleHit[2] + toggleHit[4]) * 0.5), - mathFloor(metrics.rowFs * 1.05), + metrics.toggleFs, "rov", 1 ) @@ -1732,7 +1786,16 @@ local function drawPanel() local base = scrollOffset() if rowMetrics.totalH > 0 then - UiScroller(barX1, listBottom, area.x2 - metrics.edgeInset, listTop, rowMetrics.totalH, base) + UiScroller( + barX1, + listBottom, + area.x2 - metrics.edgeInset, + listTop, + rowMetrics.totalH, + base, + hover.bar == 1, + dragging + ) end flushText(1, font) @@ -1790,21 +1853,40 @@ local function panelSignature(mx, my) hover.sb = sidebarIndexAt(mx, my) or 0 hover.row = 0 hover.tog = 0 + hover.bar = 0 if toggleHit[1] and math_isInRect(mx, my, toggleHit[1], toggleHit[2], toggleHit[3], toggleHit[4]) then hover.tog = 1 elseif mx >= listX1 and mx <= listRight then hover.row = rowAt(my) or 0 + elseif mx >= barX1 and mx <= area.x2 then + -- The thumb itself, not the track: it is the part that can be taken hold of, so it + -- is the part that lights up. + local top, height = scrollerThumb() + if top and my <= top and my >= top - height then + hover.bar = 1 + end end return hover.sb - .. "|" .. hover.row - .. "|" .. hover.tog - .. "|" .. scroll - .. "|" .. rowsGen - .. "|" .. layoutGen - .. "|" .. selFrom - .. "|" .. selTo + .. "|" + .. hover.row + .. "|" + .. hover.tog + .. "|" + .. hover.bar + .. "|" + .. scroll + .. "|" + .. rowsGen + .. "|" + .. layoutGen + .. "|" + .. selFrom + .. "|" + .. selTo + .. "|" + .. (dragging and 1 or 0) end ---------------------------------------------------------------- @@ -1855,6 +1937,7 @@ function widget:ViewResize() RectRound = WG.FlowUI.Draw.RectRound UiElement = WG.FlowUI.Draw.Element UiScroller = WG.FlowUI.Draw.Scroller + UiScrollerAt = WG.FlowUI.Draw.ScrollerGeometry Highlight = WG.FlowUI.Draw.SelectHighlight UiToggle = WG.FlowUI.Draw.Toggle UiUnit = WG.FlowUI.Draw.Unit @@ -2106,8 +2189,7 @@ local function mouseEvent(x, y, button, release) -- The strip between the bar and the panel edge stays grabbable too. The -- selection survives it: scrolling to reach more of the source is part of -- selecting it, not a change of mind. - dragging = true - scrollFromY(y) + grabScroller(y) elseif math_isInRect(x, y, listX1, listBottom, listRight, listTop) then -- Source is the only thing here worth taking elsewhere, so it is the only -- thing that selects; a press on any other row puts the selection down. From 2b4b413aad636d5da4a666a08aa4bc53dd8e0f02 Mon Sep 17 00:00:00 2001 From: Floris Date: Sat, 12 Sep 2026 16:56:41 +0200 Subject: [PATCH 3/4] extracted search algo's into new include file (#9220) --- luaui/Include/keybind_editor_view.lua | 30 +-- luaui/Include/search.lua | 298 ++++++++++++++++++++++++++ luaui/Widgets/gui_gameinfo.lua | 12 +- luaui/Widgets/gui_options.lua | 152 ++----------- luaui/Widgets/widget_selector.lua | 147 ++----------- 5 files changed, 346 insertions(+), 293 deletions(-) create mode 100644 luaui/Include/search.lua diff --git a/luaui/Include/keybind_editor_view.lua b/luaui/Include/keybind_editor_view.lua index 9932282371e..fc5bc22f434 100644 --- a/luaui/Include/keybind_editor_view.lua +++ b/luaui/Include/keybind_editor_view.lua @@ -16,6 +16,7 @@ local keyConfig = VFS.Include("luaui/configs/keyboard_layouts.lua") local catalog = keybindConfig.load("common/configs/keybind_catalog.json") or {} local Editbox = VFS.Include("luaui/Include/keybind_editbox.lua") local Dropdown = VFS.Include("luaui/Include/keybind_dropdown.lua") +local Search = VFS.Include("luaui/Include/search.lua") local profiles = VFS.Include("luaui/Include/keybind_profiles.lua") local KEYSYMS = VFS.Include("luaui/Include/keybind_keysyms.lua") @@ -29,7 +30,6 @@ local spGetModKeyState = Spring.GetModKeyState local spGetTimer = Spring.GetTimer local spDiffTimers = Spring.DiffTimers local isInRect = math.isInRect -local spGetScanSymbol = Spring.GetScanSymbol local glColor = gl.Color local glTexture = gl.Texture local glTexRect = gl.TexRect @@ -579,7 +579,7 @@ local function rebuildRows() return end - local query = searchBox and searchBox:getText():lower() or "" + local query = Search.query(searchBox and searchBox:getText()) local catalogActions = {} local otherGroupEnd @@ -597,7 +597,9 @@ local function rebuildRows() -- Non-selected groups are still walked: they have to claim their actions or the -- leftovers below would sweep them all into Other. local inCategory = not selectedCategory or group.category == selectedCategory - local categoryMatch = query ~= "" and group.titleLower:find(query, 1, true) + -- A group whose own title matches keeps every row under it, so searching for a + -- category's name shows the category rather than emptying it. + local categoryMatch = Search.claims(query, group.titleLower) local groupRows = {} for _, item in ipairs(group.items) do -- An empty prefix would claim every bound action, so treat it as no prefix. @@ -648,10 +650,9 @@ local function rebuildRows() local row, col = arg:match("^%s*(%S+)%s+(%S+)") local label = item.label and prefixRowLabel(item.label, arg, row, col) or action if - query == "" - or categoryMatch - or action:lower():find(query, 1, true) - or label:lower():find(query, 1, true) + categoryMatch + or Search.matches(query, action:lower()) + or Search.matches(query, label:lower()) then groupRows[#groupRows + 1] = { type = "editable", action = action, label = label } end @@ -663,10 +664,9 @@ local function rebuildRows() catalogActions[item.action] = true end if - query == "" - or categoryMatch - or item.labelLower:find(query, 1, true) - or (item.actionLower and item.actionLower:find(query, 1, true)) + categoryMatch + or Search.matches(query, item.labelLower) + or Search.matches(query, item.actionLower) then groupRows[#groupRows + 1] = { type = "editable", action = item.action, label = item.label } end @@ -691,10 +691,10 @@ local function rebuildRows() end end - local otherMatch = query ~= "" and L.otherLower:find(query, 1, true) + local otherMatch = Search.claims(query, L.otherLower) local others = {} for action in pairs(working.byAction) do - if not catalogActions[action] and (query == "" or otherMatch or action:lower():find(query, 1, true)) then + if not catalogActions[action] and (otherMatch or Search.matches(query, action:lower())) then others[#others + 1] = action end end @@ -1787,7 +1787,9 @@ local function pressSym(key, scanCode) return nil end - local sym = scanCode and spGetScanSymbol(scanCode) + -- 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 sym = scanCode and Spring.GetScanSymbol(scanCode) if not sym or sym == "" then return nil end diff --git a/luaui/Include/search.lua b/luaui/Include/search.lua new file mode 100644 index 00000000000..ef7f70fe925 --- /dev/null +++ b/luaui/Include/search.lua @@ -0,0 +1,298 @@ +-- Shared search and filtering for the panels with a search box: the widget selector, the +-- settings, the game info and the keybind editor. +-- +-- There are two kinds of search here, and the difference is deliberate: +-- +-- * Ranked. `query` then `score`, and the caller sorts by what comes back. A list of +-- things you are hunting for by name - widgets, settings - where the best answer +-- should rise to the top. +-- * Plain. `query` then `matches`, and the caller keeps its own order. A list you are +-- reading rather than hunting through - the game's settings as they were authored, +-- the keybinds grouped the way they are taught - where reshuffling the rows under the +-- cursor as each letter is typed loses the reader their place. +-- +-- Both are here so a panel can pick the one it wants, not so the four panels can be made +-- to search alike. +-- +-- Haystacks are handed in already lowercased. Every panel here builds its rows once and +-- searches them on each keystroke, so lowercasing belongs with the row, not with the +-- search; `normalize` is for the panels whose text only exists in a coloured form. + +local M = {} + +local stringByte = string.byte +local stringFind = string.find +local stringLower = string.lower +local stringGsub = string.gsub +local mathMax = math.max +local mathMin = math.min + +local EMPTY = {} + +---------------------------------------------------------------- +-- The query +---------------------------------------------------------------- + +-- What was typed, worked out once per keystroke rather than once per item. The words and +-- the spaceless form are only built when something is actually being searched for. +-- +-- Fields: `text` lowercased, `words` the whitespace-separated parts, `joined` those parts +-- run together (what the fuzzy pass matches against), `empty` when nothing was typed. +---@param text string? +---@return table query +function M.query(text) + if not text or text == "" then + return { text = "", words = EMPTY, joined = "", empty = true } + end + + local lower = stringLower(text) + local words = {} + for word in lower:gmatch("%S+") do + words[#words + 1] = word + end + + if #words == 0 then + return { text = "", words = EMPTY, joined = "", empty = true } + end + + return { text = lower, words = words, joined = (stringGsub(lower, "%s+", "")), empty = false } +end + +-- Strips the inline colour codes from a label so it can be searched or lowercased. Text +-- that is stored coloured only: prefer keeping an uncoloured copy on the row where you +-- can, since this runs over every item on every keystroke otherwise. +---@param text string? +---@return string +function M.normalize(text) + if not text or text == "" then + return "" + end + -- \255 takes three bytes of colour after it; \008 is the reset. + text = stringGsub(text, "\255...", "") + text = stringGsub(text, "\008", "") + text = stringGsub(text, "%s%s+", " ") + + return stringLower(text:match("^%s*(.-)%s*$") or text) +end + +---------------------------------------------------------------- +-- Plain, order-preserving +---------------------------------------------------------------- + +-- Does this row survive the filter? An empty query keeps everything, which is what makes +-- this the whole test at a call site rather than half of one. +---@param query table From `M.query` +---@param haystack string? Already lowercased +---@return boolean +function M.matches(query, haystack) + if query.empty then + return true + end + + return haystack ~= nil and stringFind(haystack, query.text, 1, true) ~= nil +end + +-- Did this heading itself match? A category, group or block whose own title matches keeps +-- everything under it, so searching for a section's name shows the section rather than +-- emptying it. Unlike `matches` an empty query is not a match: nothing is being searched +-- for, so nothing is being claimed. +---@param query table From `M.query` +---@param haystack string? Already lowercased +---@return boolean +function M.claims(query, haystack) + if query.empty then + return false + end + + return haystack ~= nil and stringFind(haystack, query.text, 1, true) ~= nil +end + +---------------------------------------------------------------- +-- Ranked +---------------------------------------------------------------- + +-- How well `query` reads as a subsequence of `target`: every query character has to appear +-- in order, and the score says how tightly. Runs together, at word starts and near the +-- front all count for more; gaps count against. `0` means it does not match at all. +-- +-- Both `query` and `target` are lowercased, and `query` is the spaceless form. +---@param query string +---@param target string +---@return number +function M.fuzzy(query, target) + local qi = 1 + local qlen = #query + local tlen = #target + if qlen == 0 then + return 0 + end + if qlen > tlen then + return 0 + end + + ---@type number + local score = 0 + local consecutive = 0 + local prevMatched = false + ---@type number? + local firstMatchPos = nil + local lastMatchPos = 0 + + for ti = 1, tlen do + if qi > qlen then + break + end + local tc = stringByte(target, ti) + local qc = stringByte(query, qi) + if tc == qc then + if not firstMatchPos then + firstMatchPos = ti + end + qi = qi + 1 + -- Gap penalty: penalize distance from previous match + if lastMatchPos > 0 then + local gap = ti - lastMatchPos - 1 + if gap > 0 then + score = score - gap * 0.5 + end + end + lastMatchPos = ti + -- Consecutive character bonus + if prevMatched then + consecutive = consecutive + 1 + score = score + 3 + consecutive + else + consecutive = 0 + score = score + 1 + end + -- Word boundary bonus: char after space, underscore, or start of string + if ti == 1 then + score = score + 5 + else + local prev = stringByte(target, ti - 1) + if prev == 32 or prev == 95 or prev == 45 then -- space, underscore, dash + score = score + 4 + end + end + prevMatched = true + else + prevMatched = false + consecutive = 0 + end + end + + if qi <= qlen then + return 0 -- not all query chars matched + end + + -- Bonus for matching near the start + if firstMatchPos then + score = score + mathMax(0, 6 - firstMatchPos) + end + + -- Normalize: prefer shorter targets (tighter matches) + score = score + mathMax(0, 3 - (tlen - qlen) * 0.1) + + return score +end + +-- How well one item answers the query, as three tiers that never overlap, so a whole-word +-- hit always outranks a scattered one however pretty the latter scores: +-- +-- 300+ the query appears whole in a `primary` field +-- 100-299 every word appears somewhere; 200 when they are all in the first field +-- 1-99 the query reads as a subsequence of a `primary` field +-- +-- `primary` is what the item is called - its name, and whatever else names it, such as an +-- id. The first entry is the one the multi-word tier counts as a name hit. `secondary` is +-- everything else worth finding it by, a description or an author: enough to satisfy the +-- multi-word tier, never enough to match on its own. +-- +-- Both are arrays of lowercased strings, read and not kept, so a caller can fill one pair +-- of tables outside its loop and rewrite them per item rather than allocating. +-- +-- Returns `0` when the item does not match at all. +---@param query table From `M.query` +---@param primary string[] +---@param secondary string[]? +---@return number +function M.score(query, primary, secondary) + if query.empty then + return 0 + end + + local text = query.text + + -- Tier 1: the query, whole, in one of the fields the item is named by. Earlier in a + -- shorter field is a better answer than later in a longer one. + for i = 1, #primary do + local field = primary[i] + local at = field ~= "" and stringFind(field, text, 1, true) + if at then + return 300 + mathMax(0, 50 - at) + mathMax(0, 20 - #field) + end + end + + local words = query.words + + -- Tier 2: every word found somewhere. All of them in the name beats some of them + -- landing in a description. + if #words > 1 then + local first = primary[1] or "" + local nameMatches = 0 + ---@type number + local posSum = 0 + local all = true + for i = 1, #words do + local word = words[i] + local inName = first ~= "" and stringFind(first, word, 1, true) + local found = inName + if not found and secondary then + for j = 1, #secondary do + local field = secondary[j] + if field ~= "" and stringFind(field, word, 1, true) then + found = true + break + end + end + end + if not found then + all = false + break + end + if inName then + nameMatches = nameMatches + 1 + posSum = posSum + inName + end + end + if all then + local base = (nameMatches == #words) and 200 or 100 + + return base + mathMax(0, 50 - posSum / #words) + end + end + + -- Tier 3: a subsequence, which is loose enough that it needs a few characters to go on + -- and a floor under how well it has to read before it counts at all. + local joined = query.joined + if #joined >= 3 then + ---@type number + local best = 0 + for i = 1, #primary do + local field = primary[i] + if field ~= "" then + local s = M.fuzzy(joined, field) + if s > best then + best = s + end + end + end + if best >= #joined * 2 then + return mathMin(99, best) + end + end + + return 0 +end + +return M diff --git a/luaui/Widgets/gui_gameinfo.lua b/luaui/Widgets/gui_gameinfo.lua index 95deade83ab..43ca8bd3f6e 100644 --- a/luaui/Widgets/gui_gameinfo.lua +++ b/luaui/Widgets/gui_gameinfo.lua @@ -25,6 +25,7 @@ end local Editbox = VFS.Include("luaui/Include/keybind_editbox.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") -- Tweaks arrive minified, as one enormous line; this lays them out again. Wanted rather -- than required: the engine lists the game's files once at start, so a file added since is -- invisible until the next one, and a hard include would take the whole panel down on a @@ -1152,20 +1153,19 @@ rebuildRows = function() -- The selection is a span of row numbers, and these are about to be different rows. clearSelection() - local query = searchBox and string.lower(searchBox:getText()) or "" + local query = Search.query(searchBox and searchBox:getText()) for _, block in ipairs(blocks) do if not selectedCategory or block.category == selectedCategory then - local blockMatch = query ~= "" and string.find(block.titleLower, query, 1, true) ~= nil + -- A block whose own heading matches keeps every row under it, so searching for a + -- section's name shows the section rather than emptying it. + local blockMatch = Search.claims(query, block.titleLower) local first = #rows -- The unit whose opening line is the last one shown. A line still under that one -- needs no introduction; a line whose opening was filtered away has to name the -- unit itself, since on its own it is a value with nothing to belong to. local shownOwner for _, entry in ipairs(block.entries) do - if - (not changedOnly or entry.changed) - and (query == "" or blockMatch or string.find(entry.search, query, 1, true)) - then + if (not changedOnly or entry.changed) and (blockMatch or Search.matches(query, entry.search)) then if entry.unitDefID then shownOwner = entry.ownerUnitDefID entry.needsOwner = nil diff --git a/luaui/Widgets/gui_options.lua b/luaui/Widgets/gui_options.lua index 099b7db6d3c..1c571cb2145 100644 --- a/luaui/Widgets/gui_options.lua +++ b/luaui/Widgets/gui_options.lua @@ -413,6 +413,7 @@ local function detectWater() end local utf8 = VFS.Include("common/luaUtilities/utf8.lua") +local Search = VFS.Include("luaui/Include/search.lua") --local textInputDlist, consoleCmdDlist, textCursorRect local updateTextInputDlist = true local showTextInput = true @@ -2838,85 +2839,6 @@ function loadAllWidgetData() end end --- Fuzzy subsequence match: characters of query appear in order within target. --- Returns a score > 0 on match, or 0 on no match. --- Bonuses: consecutive chars, word boundary matches, start-of-string match. --- Penalties: large gaps between matched characters. -local function fuzzyScore(query, target) - local qi = 1 - local qlen = #query - local tlen = #target - if qlen == 0 then - return 0 - end - if qlen > tlen then - return 0 - end - - local score = 0 - local consecutive = 0 - local prevMatched = false - local firstMatchPos = nil - local lastMatchPos = 0 - - for ti = 1, tlen do - if qi > qlen then - break - end - local tc = string.byte(target, ti) - local qc = string.byte(query, qi) - if tc == qc then - if not firstMatchPos then - firstMatchPos = ti - end - qi = qi + 1 - -- Gap penalty: penalize distance from previous match - if lastMatchPos > 0 then - local gap = ti - lastMatchPos - 1 - if gap > 0 then - score = score - gap * 0.5 - end - end - lastMatchPos = ti - -- Consecutive character bonus - if prevMatched then - consecutive = consecutive + 1 - score = score + 3 + consecutive - else - consecutive = 0 - score = score + 1 - end - -- Word boundary bonus: char after space, underscore, or start of string - if ti == 1 then - score = score + 5 - else - local prev = string.byte(target, ti - 1) - if prev == 32 or prev == 95 or prev == 45 then -- space, underscore, dash - score = score + 4 - end - end - prevMatched = true - else - prevMatched = false - consecutive = 0 - end - end - - if qi <= qlen then - return 0 -- not all query chars matched - end - - -- Bonus for matching near the start - if firstMatchPos then - score = score + math.max(0, 6 - firstMatchPos) - end - - -- Normalize: prefer shorter targets (tighter matches) - score = score + math.max(0, 3 - (tlen - qlen) * 0.1) - - return score -end - -- Efficiently filters options without rebuilding the entire options table. -- Priority: exact substring > multi-word AND > fuzzy subsequence. -- Within each tier, results are further ranked by match quality. @@ -2924,14 +2846,9 @@ end -- and the group label above it are also included for context. function applyFilter() if inputText and inputText ~= "" and inputMode == "" then - local lowerInput = string.lower(inputText) - - -- Split input into words - local queryWords = {} - for word in lowerInput:gmatch("%S+") do - queryWords[#queryWords + 1] = word - end - if #queryWords == 0 then + local query = Search.query(inputText) + -- Nothing but whitespace is not something anyone is searching for. + if query.empty then options = unfilteredOptions rebuildOptionIdIndex() if windowList then @@ -2941,9 +2858,6 @@ function applyFilter() return end - -- Strip spaces for fuzzy matching (single continuous query) - local queryNoSpaces = lowerInput:gsub("%s+", "") - -- Sub-option prefixes after processing: basic uses widgetOptionColor, -- dev uses devMainOptionColor..devOptionColor, advanced uses advMainOptionColor..advOptionColor local subPrefixes = { @@ -2984,6 +2898,9 @@ function applyFilter() end local matched = {} + -- Filled once and rewritten per option rather than allocated for each of them: a + -- keystroke walks every setting there is. + local primary, secondary = { "", "" }, { "", "" } for i, option in ipairs(unfilteredOptions) do if option.name and option.name ~= "" and option.type and option.type ~= "label" then @@ -2998,56 +2915,11 @@ function applyFilter() local lowerDesc = option.description and option.description ~= "" and string.lower(option.description) or "" - local score = 0 - - -- Tier 1: Exact substring match in name or id (score 300+) - local exactPos = string.find(lowerName, lowerInput, nil, true) - if exactPos then - score = 300 + math.max(0, 50 - exactPos) + math.max(0, 20 - #lowerName) - else - local idPos = string.find(lowerId, lowerInput, nil, true) - if idPos then - score = 300 + math.max(0, 50 - idPos) + math.max(0, 20 - #lowerId) - end - end - - -- Tier 2: Multi-word AND matching (score 100-299) - if score == 0 and #queryWords > 1 then - local allWordsMatch = true - local nameMatches = 0 - local posSum = 0 - for _, word in ipairs(queryWords) do - local inName = string.find(lowerName, word, nil, true) - local inDesc = string.find(lowerDesc, word, nil, true) - local inId = string.find(lowerId, word, nil, true) - if not inName and not inDesc and not inId then - allWordsMatch = false - break - end - if inName then - nameMatches = nameMatches + 1 - posSum = posSum + inName - end - end - if allWordsMatch then - local base = (nameMatches == #queryWords) and 200 or 100 - score = base + math.max(0, 50 - posSum / #queryWords) - end - end - - -- Tier 3: Fuzzy subsequence matching on name or id (score 1-99) - -- Requires at least 3 characters to avoid too many false positives - if score == 0 and #queryNoSpaces >= 3 then - local nameScore = fuzzyScore(queryNoSpaces, lowerName) - local idScore = fuzzyScore(queryNoSpaces, lowerId) - local bestScore = math.max(nameScore, idScore) - -- Require a minimum quality: score must be at least 2 per query char - local minThreshold = #queryNoSpaces * 2 - if bestScore >= minThreshold then - score = math.min(99, bestScore) - end - end - + -- Named by what it is called and by its id; found, but not on their own, by its + -- description and again its id. + primary[1], primary[2] = lowerName, lowerId + secondary[1], secondary[2] = lowerDesc, lowerId + local score = Search.score(query, primary, secondary) if score > 0 then matched[#matched + 1] = { option = option, score = score, index = i } end diff --git a/luaui/Widgets/widget_selector.lua b/luaui/Widgets/widget_selector.lua index 6b998374ead..017a2dc9849 100644 --- a/luaui/Widgets/widget_selector.lua +++ b/luaui/Widgets/widget_selector.lua @@ -133,6 +133,7 @@ local buttonHeight = 24 local buttonTop = 40 -- offset between top of buttons and bottom of widget local utf8 = VFS.Include("common/luaUtilities/utf8.lua") +local Search = VFS.Include("luaui/Include/search.lua") local textInputDlist local uiList local updateTextInputDlist = true @@ -499,82 +500,6 @@ function widget:MouseWheel(up, value) return true end --- Fuzzy subsequence match: characters of query appear in order within target. --- Returns a score > 0 on match, or 0 on no match. --- Bonuses: consecutive chars, word boundary matches, start-of-string match. --- Penalties: large gaps between matched characters. -local function fuzzyScore(query, target) - local qi = 1 - local qlen = #query - local tlen = #target - if qlen == 0 then - return 0 - end - if qlen > tlen then - return 0 - end - - local score = 0 - local consecutive = 0 - local prevMatched = false - local firstMatchPos = nil - local lastMatchPos = 0 - - for ti = 1, tlen do - if qi > qlen then - break - end - local tc = string.byte(target, ti) - local qc = string.byte(query, qi) - if tc == qc then - if not firstMatchPos then - firstMatchPos = ti - end - qi = qi + 1 - -- Gap penalty - if lastMatchPos > 0 then - local gap = ti - lastMatchPos - 1 - if gap > 0 then - score = score - gap * 0.5 - end - end - lastMatchPos = ti - -- Consecutive character bonus - if prevMatched then - consecutive = consecutive + 1 - score = score + 3 + consecutive - else - consecutive = 0 - score = score + 1 - end - -- Word boundary bonus - if ti == 1 then - score = score + 5 - else - local prev = string.byte(target, ti - 1) - if prev == 32 or prev == 95 or prev == 45 then - score = score + 4 - end - end - prevMatched = true - else - prevMatched = false - consecutive = 0 - end - end - - if qi <= qlen then - return 0 - end - - if firstMatchPos then - score = score + math.max(0, 6 - firstMatchPos) - end - score = score + math.max(0, 3 - (tlen - qlen) * 0.1) - - return score -end - local function SortWidgetListFunc(nd1, nd2) --does nd1 come before nd2? -- widget profiler on top @@ -603,71 +528,27 @@ function UpdateList(force) --maxWidth = 0 widgetsList = {} fullWidgetsList = {} - local lowerInput = inputText and inputText ~= "" and string.lower(inputText) or nil - local queryWords, queryNoSpaces - if lowerInput then - queryWords = {} - for word in lowerInput:gmatch("%S+") do - queryWords[#queryWords + 1] = word - end - queryNoSpaces = lowerInput:gsub("%s+", "") - end - local scoredList = lowerInput and {} or nil + 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 not lowerInput then + if query.empty then fullWidgetsList[#fullWidgetsList + 1] = { name, data } local width = fontSize * font:GetTextWidth(name) if width > maxWidth then maxWidth = width end else - local lowerName = string.lower(name) - local lowerDesc = data.desc and string.lower(data.desc) or "" - local lowerBase = data.basename and string.lower(data.basename) or "" - local lowerAuthor = data.author and string.lower(data.author) or "" - local score = 0 - - -- Tier 1: Exact substring in name (score 300+) - local exactPos = string.find(lowerName, lowerInput, nil, true) - if exactPos then - score = 300 + math.max(0, 50 - exactPos) + math.max(0, 20 - #lowerName) - end - - -- Tier 2: Multi-word AND matching (score 100-299) - if score == 0 and #queryWords > 1 then - local allMatch = true - local nameMatches = 0 - local posSum = 0 - for _, word in ipairs(queryWords) do - local inName = string.find(lowerName, word, nil, true) - local inOther = string.find(lowerDesc, word, nil, true) - or string.find(lowerBase, word, nil, true) - or string.find(lowerAuthor, word, nil, true) - if not inName and not inOther then - allMatch = false - break - end - if inName then - nameMatches = nameMatches + 1 - posSum = posSum + inName - end - end - if allMatch then - local base = (nameMatches == #queryWords) and 200 or 100 - score = base + math.max(0, 50 - posSum / #queryWords) - end - end - - -- Tier 3: Fuzzy subsequence on name only (score 1-99, min 3 chars) - if score == 0 and #queryNoSpaces >= 3 then - local nameScore = fuzzyScore(queryNoSpaces, lowerName) - local minThreshold = #queryNoSpaces * 2 - if nameScore >= minThreshold then - score = math.min(99, nameScore) - end - end - + 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) From d1c0b233a587b2d07413864e0b6e0e6b53d0bca3 Mon Sep 17 00:00:00 2001 From: Floris Date: Sat, 12 Sep 2026 16:57:14 +0200 Subject: [PATCH 4/4] gameinfo/keybind editor: refinements (#9221) --- luaui/Include/keybind_dropdown.lua | 14 ++++++++--- luaui/Include/keybind_editbox.lua | 24 ++++++++++++++---- luaui/Include/keybind_editor_view.lua | 32 +++++++++++++++++++----- luaui/Include/keybind_text.lua | 33 +++++++++++++++++++++++++ luaui/Widgets/gui_gameinfo.lua | 34 ++++++++++++++------------ luaui/images/keybinds/duplicate.png | Bin 2005 -> 1001 bytes 6 files changed, 107 insertions(+), 30 deletions(-) diff --git a/luaui/Include/keybind_dropdown.lua b/luaui/Include/keybind_dropdown.lua index 3cb8c1dd585..f42e1c4fcee 100644 --- a/luaui/Include/keybind_dropdown.lua +++ b/luaui/Include/keybind_dropdown.lua @@ -13,6 +13,9 @@ local colorText = "\255\235\235\235" -- SelectHighlight defaults to 0.35 and the rest of the UI stays near it. At 1 the -- overlay is opaque and swallows the option label under it. local hoverOpacity = 0.25 +-- Lighter for the control itself than for a row of the open list: one says the cursor +-- is on it, the other says this is the option a click would take. +local controlHoverOpacity = 0.14 local white = { 1, 1, 1 } local listFill = { 0.09, 0.09, 0.09, 0.96 } @@ -124,6 +127,9 @@ function Dropdown:draw() local inset = floor((y2 - y1) * 0.3) Selector(x1, y1, x2, y2) + if mx >= x1 and mx <= x2 and my >= y1 and my <= y2 then + Highlight(x1, y1, x2, y2, floor(WG.FlowUI.elementCorner * 0.66), controlHoverOpacity, white) + end -- Chevron in the gap already reserved at the right edge, so the control reads as a -- select rather than a button. Drawn before the text: geometry inside a font batch @@ -151,9 +157,9 @@ function Dropdown:draw() font:Print( fittedLabel(fitted, 0, font, label, labelW, self.fontSize), x1 + inset, - floor((y1 + y2) * 0.5), + text.baseline(font, y1, y2, self.fontSize), self.fontSize, - "ov" + "o" ) font:End() @@ -179,9 +185,9 @@ function Dropdown:draw() font:Print( fittedLabel(fitted, i, font, optionLabel(opt), w, self.fontSize), r.x1 + inset, - floor((r.y1 + r.y2) * 0.5), + text.baseline(font, r.y1, r.y2, self.fontSize), self.fontSize, - "ov" + "o" ) end font:End() diff --git a/luaui/Include/keybind_editbox.lua b/luaui/Include/keybind_editbox.lua index e18d4f7442e..77b97cb682c 100644 --- a/luaui/Include/keybind_editbox.lua +++ b/luaui/Include/keybind_editbox.lua @@ -5,6 +5,7 @@ local utf8 = VFS.Include("common/luaUtilities/utf8.lua") local KEYSYMS = VFS.Include("luaui/Include/keybind_keysyms.lua") +local text = VFS.Include("luaui/Include/keybind_text.lua") local Editbox = {} Editbox.__index = Editbox @@ -20,6 +21,11 @@ local colorDim = "\255\160\160\160" local cursorBlinkDuration = 1 local cursorGrey = 0.7 +-- What the panels light a row with under the cursor. The field takes the same, so it +-- reads as something you can click into rather than a plate with text on it. +local hoverOpacity = 0.14 +local white = { 1, 1, 1 } + -- Font is fetched per draw; it does not exist when this file is included. local function getFont() return WG["fonts"].getFont() @@ -55,7 +61,7 @@ end function Editbox:setRect(x1, y1, x2, y2, fontSize, pad) self.rect = { x1, y1, x2, y2 } self.fontSize = fontSize or (y2 - y1) * 0.5 - self.pad = pad or floor((y2 - y1) * 0.2) + self.pad = pad or floor((y2 - y1) * 0.3) end function Editbox:getText() @@ -333,10 +339,18 @@ function Editbox:draw() local cs = floor(WG.FlowUI.elementCorner * 0.66) local inset = floor((y2 - y1) * 0.18) local tx = x1 + self.pad - local ty = floor((y1 + y2) * 0.5) + -- The middle of the field, for the caret and the selection, which are box-shaped and + -- want the box; and the baseline the text is drawn from, which wants the font. + local cy = floor((y1 + y2) * 0.5) + local ty = text.baseline(font, y1, y2, self.fontSize) R(x1, y1, x2, y2, cs, 1, 1, 1, 1, fieldFill) + local mx, my = Spring.GetMouseState() + if mx >= x1 and mx <= x2 and my >= y1 and my <= y2 then + WG.FlowUI.Draw.SelectHighlight(x1, y1, x2, y2, cs, hoverOpacity, white) + end + if self:hasSelection() then local a, b = self:selRange() local sa = floor(font:GetTextWidth(utf8.sub(self.text, 1, a)) * self.fontSize) @@ -363,7 +377,7 @@ function Editbox:draw() end font:Begin() - font:Print(shown, tx, ty, self.fontSize, "ov") + font:Print(shown, tx, ty, self.fontSize, "o") font:End() if self.focused then @@ -371,8 +385,8 @@ function Editbox:draw() -- a fixed span around the text's middle, so it does not stretch with the field. local cx = floor(tx + caretOffset(self, font)) local cWidth = 1 + floor(self.fontSize / 14) - local cy1 = math.max(y1 + 1, floor(ty - self.fontSize * 0.6)) - local cy2 = math.min(y2 - 1, floor(ty + self.fontSize * 0.64)) + local cy1 = math.max(y1 + 1, floor(cy - self.fontSize * 0.6)) + local cy2 = math.min(y2 - 1, floor(cy + 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 fc5bc22f434..766dd3614fa 100644 --- a/luaui/Include/keybind_editor_view.lua +++ b/luaui/Include/keybind_editor_view.lua @@ -2709,10 +2709,16 @@ local function drawButtons(hotId) local fill = b.fill and ((not enabled and b.fillMuted) or (hovered and b.fillHover) or b.fill) drawButtonFace(r, fill or buttonFill) + -- The face lights under the cursor the way a row or the search field does. A + -- tinted button is the exception: it would lose its colour under the 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, hoverOpacity, look.white) + end + if b.icon then - -- Square inset so the 64x64 art keeps its aspect inside a wider button. - -- Hover only lifts the tint, matching the search box and picker, which - -- carry no hover treatment of their own. + -- Square inset so the 64x64 art keeps its aspect inside a wider button. The + -- icon brightens with the face, so the whole button reads as one control. local inset = floor((r[4] - r[2]) * 0.22) local side = (r[4] - r[2]) - inset * 2 local ix = floor((r[1] + r[3] - side) * 0.5) @@ -2727,9 +2733,6 @@ local function drawButtons(hotId) glTexture(false) glColor(1, 1, 1, 1) else - if hovered and not fill then - Highlight(r[1], r[2], r[3], r[4], metrics.csButton, hoverOpacity, look.white) - end queueText( (enabled and b.textOn or b.textOff) or L[b.id], floor((r[1] + r[3]) * 0.5), @@ -3227,6 +3230,23 @@ function view.keyPress(key, scanCode) 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. + if key == KEYSYMS.ESCAPE then + if searchBox and searchBox:getText() ~= "" then + -- Focus stays, so the next thing typed starts a new search. + searchBox:setText("") + + return true + end + if searchBox then + searchBox:blur() + end + + return false + end + if searchBox and searchBox:isFocused() then return searchBox:keyPress(key) end diff --git a/luaui/Include/keybind_text.lua b/luaui/Include/keybind_text.lua index b73fafdb4b8..363092cd558 100644 --- a/luaui/Include/keybind_text.lua +++ b/luaui/Include/keybind_text.lua @@ -6,6 +6,39 @@ local utf8 = VFS.Include("common/luaUtilities/utf8.lua") local M = {} +local mathFloor = math.floor + +-- Body height per font, in em. Asked of the font once: it does not change with the size +-- the text is drawn at, and `baseline` is called for every label on every frame. +local bodyHeight = setmetatable({}, { __mode = "k" }) + +-- Where a line's baseline goes for the text to sit centred in a box, whatever it says. +-- +-- Printing with "v" centres the glyphs the string happens to have: "Search..." has no +-- descenders and so centres on its capitals, while "Legacy (2)" centres on a box that +-- reaches below the baseline, which lifts everything you actually read. Two controls +-- side by side then disagree with each other. +-- +-- Centred on the font's x-height instead, so every label sits alike. A UI label is +-- mostly lowercase, and that band is where the eye puts the middle of a line: centring +-- the capitals leaves the text reading low, because little of it reaches that high. +-- Ascenders and descenders then sit above and below, the way type intends. +-- +-- Print with "o" rather than "ov": with no vertical option the y is the baseline. +function M.baseline(font, y1, y2, size) + local body = bodyHeight[font] + if not body then + -- A lowercase x sits on the baseline and reaches neither above nor below the band, + -- so the height of its ink is the x-height. + body = font:GetTextHeight("x") + bodyHeight[font] = body + end + + -- Rounded, not floored: a whole pixel keeps the glyphs off a fraction, but always + -- taking the lower one leaves every label sitting up to a pixel low. + return mathFloor((y1 + y2) * 0.5 - size * body * 0.5 + 0.5) +end + -- Shortens text until it draws inside maxWidth, marking the cut with "..". function M.fit(font, text, maxWidth, size) -- Callers derive the width by subtracting, so it can come through negative. Returning diff --git a/luaui/Widgets/gui_gameinfo.lua b/luaui/Widgets/gui_gameinfo.lua index 43ca8bd3f6e..e5e24b52367 100644 --- a/luaui/Widgets/gui_gameinfo.lua +++ b/luaui/Widgets/gui_gameinfo.lua @@ -2050,9 +2050,26 @@ function widget:KeyPress(key) return false end + -- Escape, before the field gets a look at it: it undoes the most recent thing first + -- and closes the panel only when there is nothing left to undo. The selection is the + -- thing most recently picked up, and closing over it would throw away what was about + -- to be copied. A search comes next: the list being read is the one the search made, + -- and the first Escape is asking for that back rather than for the panel to go. + if key == 27 then + if selectionRange() then + clearSelection() + elseif searchBox:getText() ~= "" then + -- Focus stays, so the next thing typed starts a new search. + searchBox:setText("") + else + showOnceMore = true + closePanel() + end + + return true + end + if searchBox:isFocused() then - -- Escape in the field drops the focus rather than closing the panel out from under - -- whoever was typing; the next one closes it. searchBox:keyPress(key) return true @@ -2072,19 +2089,6 @@ function widget:KeyPress(key) end end - if key == 27 then - -- Escape puts the selection down first: it is the thing most recently picked up, and - -- closing the panel over it would throw away what was about to be copied. - if selectionRange() then - clearSelection() - else - showOnceMore = true - closePanel() - end - - return true - end - return false end diff --git a/luaui/images/keybinds/duplicate.png b/luaui/images/keybinds/duplicate.png index 144feba261fd5d5deb1ddbc9a125b0b103f77547..e7fcec0afb77316c630a4fa9688406f2fad227a3 100644 GIT binary patch delta 980 zcmV;_11tR159tSxBYy+1Nkl~8jcFx=g_%buAt zXJ+@GnFZ#|88nj15Xo$|6cHi10LcZCt_8cIwyUm_#D#DoGJm$?Gm*WNcD3b2L^jN> z92O0dy7DA%0JsZa8Guh^uV@~?7XU9sWIc4hY~6wu@+4Q;K5JT@I^CfWf+3PCR)9T{ zyCgLlMw!ys?EbSdJc`SfvxYW7-&zG@<1V9R=*WEMT| zD=NPVU=8h{4qqmzzjrL^M#&vOQ%kDnt@3JAJwF)>kO!^o*lcSfd>4@?MJ3k+t}N3+ z?txX9=6|Tkpp+?zIoz8g?wQ2asFY+8!2JPdWX7&U1wl)CK8PEAjlLN4e3MEw5=rPd~DWGeB=*p~ca_mVIKQh8n{9aer z1sWkztCs*gi>BTKnh(okS5}6aB!swXgliSos(%Ci*nRkAQ*fqUSjDT>w1K=cp z*CO(xOip|BpD++gq1ByL{u6-vwrOL0+#ZA8>JWb8q|$vIz%3Ec{Q;BL?6Mp5kV3TZzbL*aAZB7a%E^CI#x~tcsHeRO;cOX z&x#Z?EW|Z^w571HGTa8VsQ3%uf=Sv&H%z#aJT;)L{M`NzdK-3}BZaB19dmxH=Ns(` zQkEHgP>?qvE#=i)%&^GB>9TuQp&H+q{&x6X(pq@c0j|`EzHRiltWo0000Vu@I=Vh0i+0s(>`N{A+tVLZ0`dRVyXQ>E2z+dXcN1U^!$t^WFQ z>OFONtLl7k9oKOk*Kr;Hzr!7MlXK%gV4jUJ4&Hm{?-vEYIe&LE`L=DRn}GR_1*>h_ zivV);(t7V{+ZN{>-g{zPYU$J@$GOU|$DbkZ=7E_)(gskE^P}IX8*v^a3EN7i87n z27U(oRRFjnCXvgkXCjDdMk-*mvZA`Osy%n)JzmTq1Akru?g5_?ZF>#)Ch!yQ{o{*k zfbqtUfp>sE=Xw7AcDr@Po>c?9S#57@KAM$o&EHbDjT~*Zs;2ZMIKLNi9Arw=F;JpCIn2V|gAPFJdgf~lE zP9;fpC4befMaT;Hk7(TkV10=w5DtZZ4R3AR-T-heb?m&W5uboYoT(gNSHK}~QgL*% z3viNF9A+ioOC=gT6Af_Uv1g|;#-|w3M2rl`(=?^7>ruJMv6>UkL6gs@0kxRI9GI`y zYv;X>eFFChsMjoAKLo@O0F4~pkesPCpb!(tRDb)95uy)>b7%mD<@u*ciBC||HBgG* z34jYBMDIOCQ8>duo#7f7PLj&C5~!EUWwYIG8Hy(1z=~khaF+@SK=PZSsgr{DwxhF9Kf|M)~0oc?$#KaUqc)90-@^G>1}rKx#6@_vLs?1euAz zt=xs!On~^!iS_`(``%&qzGod9+MTI}cf!AxTLS?f0+f%_8bBV$wD!{E)D>r0D z{y-Q^OqqMi_;0IFxlF!16#!!bTEmEAO&~E5Vg(cz3WME>ay%u!7eO_IrW!@YhQZcm zRO=H>C>c@` zbPHI<7?qQgWm#;R=I9Qty+?YEuBYmhO2(%IraW2loySkb1WKUpRbF}{UPD_ln*j%zLxuSRB++HS8$YruRy-)uIU9~;3x5Q3t|(zMLI zOj=84&IlOTH9hYMvH7P|Xn+1Cz_xOaYRBvBTJpaGevzhW9N`0W)gQ?>PX}PT-LhOR z-&?I#KkYOzohg=t&I)(?hWh(H@EsBQS5o9C_jq~jNEeI6qUh@h98p(HWkIrIs@Z$H z!%v<(!8uoTLgN^w_jg(CcY*hub3;QhvYMvynP>uIlA&{sQqt38hskse z7CCDge+O8E5Jq>0&owa(Z#gjA2f!)7*sXWnL)5?2j5bZvr*4g%W2ime6ez3-==o6b zqPyDN=Y{vzW}kuA27l|b1RxlmfbRJ@XDLlLX&M0SKE**F5H7U(ExSg=Cu58scFfNi zN?h-$`FA&Ec|*dSDW*E1+HbIHtGx;QAx+cuKHx6_z$4&qz;}VK2^cxMCZ}$-yDI{H zWOj*Y@2@iZl@_LM_`KP`p;1N|<$d5Y^4@=xW%+C8Tyt>%oPTpB7SugTHNW?r_rk#>@sq$lh2hFUl58l(4sSLFHT;=4c$sma*w|8tTLM3YZg^_>YI zcMU+=r7BGZx)sf;db9<3_BD1p&VCNOv!}&&nQbt$*^HtnJmB&?Z)DeaUs0sb)?H`g zC7l#I?rE)QZ9%b2l4SDE7XQ=#F>=m-im)4*hR9I&_r6fJFQ2gV_`3SnaUIuj9oKOk az2m=*_fxIb2N-Gq0000