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 6f9b225e900..766dd3614fa 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 @@ -101,7 +101,6 @@ local otherCategoryKey = generatedOtherKey ---@type table? local gridGroup local listRight = 0 -local keyAreaX1 = 0 ---@type table local working @@ -580,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 @@ -598,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. @@ -649,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 @@ -664,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 @@ -692,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 @@ -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. @@ -1788,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 @@ -1903,7 +1904,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 +1922,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 +1940,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 +1964,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 @@ -2708,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) @@ -2726,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), @@ -2744,7 +2748,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 +2776,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 +2840,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 +2876,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 +2959,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 +3140,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 @@ -3174,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/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_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..e5e24b52367 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 @@ -71,6 +72,8 @@ local UiElement ---@type function local UiScroller ---@type function +local UiScrollerAt +---@type function local Highlight ---@type function local UiToggle @@ -218,6 +221,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 +241,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 +1103,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 +1126,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. @@ -1117,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 @@ -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 @@ -1967,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 @@ -1989,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 @@ -2106,8 +2193,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. 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) diff --git a/luaui/images/keybinds/duplicate.png b/luaui/images/keybinds/duplicate.png index 144feba261f..e7fcec0afb7 100644 Binary files a/luaui/images/keybinds/duplicate.png and b/luaui/images/keybinds/duplicate.png differ 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)