diff --git a/gamedata/unitdefs_post.lua b/gamedata/unitdefs_post.lua index 22c1dc96823..519321f16e5 100644 --- a/gamedata/unitdefs_post.lua +++ b/gamedata/unitdefs_post.lua @@ -228,6 +228,54 @@ local function createScavengerUnitDefs() end end +-- A tweak that fails is only reported here, in the defs environment, which has no way to +-- reach LuaUI except through the defs it produces. Each failure is stashed on the +-- commander defs - the ones present in every game - so the game info panel can say a tweak +-- was not applied rather than listing it as a setting that took effect. +local tweakFailures = {} +local tweakErrorCarriers = { "armcom", "corcom", "legcom" } + +local function recordTweakFailure(name, message) + -- Tab between the option and its message, newline between records: a Lua error message + -- carries neither, so the panel can split them apart again. + tweakFailures[#tweakFailures + 1] = name .. "\t" .. (string.gsub(tostring(message), "%s+", " ")) +end + +local function publishTweakFailures() + if #tweakFailures == 0 then + return + end + + local text = table.concat(tweakFailures, "\n") + for _, name in ipairs(tweakErrorCarriers) do + local unitDef = UnitDefs[name] + if unitDef then + unitDef.customparams = unitDef.customparams or {} + unitDef.customparams.tweak_errors = text + end + end +end + +-- What a tweakunits overwrote, so the game info panel can say what a value used to be +-- rather than only what it is now. This is the one case where the before is knowable +-- cheaply: the tweak is a table, so the paths it sets are the paths to read first. +-- +-- It rides on the unit's own customparams because that is where per-unit data belongs and +-- because the defs are the only thing this environment can hand to LuaUI at all. +local function recordOverwritten(unitDef, tweak, path, out) + for key, value in pairs(tweak) do + local here = path == "" and tostring(key) or (path .. "." .. tostring(key)) + local current = unitDef and unitDef[key] + if type(value) == "table" then + -- A path that opens a sub-table is not a value anyone set; its leaves are. + recordOverwritten(type(current) == "table" and current or nil, value, here, out) + elseif type(current) ~= "table" then + -- Empty means there was nothing there before, which the panel reads as new. + out[#out + 1] = here .. "\t" .. (current == nil and "" or tostring(current)) + end + end +end + local function preProcessTweakOptions() local modOptions = {} if BAR.GetModOptionsCopy then @@ -270,6 +318,7 @@ local function preProcessTweakOptions() local postfunc, err = loadstring(postsFuncStr) if err then Spring.Echo("Error parsing modoption", name, "from string", postsFuncStr, "Error: " .. err) + recordTweakFailure(name, err) else Spring.Echo("Loading " .. name .. " modoption") Spring.Echo(postsFuncStr) @@ -279,11 +328,13 @@ local function preProcessTweakOptions() shouldNormalizeUnitDefs = true -- tweakdefs can add or denormalize units else Spring.Echo("Error executing tweakdef", name, postsFuncStr, "Error :" .. result) + recordTweakFailure(name, result) end end end else Spring.Echo("Error parsing and decoding tweakdef", name, modOptions[name], "Error :" .. postsFuncStr) + recordTweakFailure(name, postsFuncStr) end else local success, tweakunits = pcall(BAR.Utilities.CustomKeyToUsefulTable, modOptions[name]) @@ -293,13 +344,24 @@ local function preProcessTweakOptions() for unitName, ud in pairs(UnitDefs) do if tweakunits[unitName] then Spring.Echo("Loading tweakunits for " .. unitName) - table.mergeInPlace(ud, system.lowerkeys(tweakunits[unitName]), true) + local lowered = system.lowerkeys(tweakunits[unitName]) + local overwritten = {} + recordOverwritten(ud, lowered, "", overwritten) + table.mergeInPlace(ud, lowered, true) normalizeUnitDef(ud) -- tweakunits can set required tables to nil + if #overwritten > 0 then + -- Appended, not replaced: a later slot can set a path an earlier one + -- already did, and the first record is the one that predates them all. + local was = ud.customparams.tweaked_from + ud.customparams.tweaked_from = (was and was .. "\n" or "") + .. table.concat(overwritten, "\n") + end end end end else Spring.Echo("Failed to parse modoption", name, "with value", modOptions[name]) + recordTweakFailure(name, tweakunits) end end end @@ -354,3 +416,4 @@ postProcessAllUnitDefs() postProcessRegularUnitDefs() postProcessScavengerUnitDefs() exportYardmaps() +publishTweakFailures() diff --git a/language/en/interface.json b/language/en/interface.json index fdd9323247a..2ff8fae345a 100644 --- a/language/en/interface.json +++ b/language/en/interface.json @@ -385,18 +385,32 @@ }, "gameInfo": { "title": "Game info", + "all": "All", + "search": "Search...", + "changedOnly": "Changed only", + "game": "Game", + "version": "Version", + "mutator": "Mutator", "engine": "Engine", "engineVersionError": "engine version error", - "decodefailed": "decode failed", + "decodefailed": "could not be decoded", + "tweakFailed": "this tweak was NOT applied", + "was": "was", + "wasNew": "new", + "copyHint": "Drag to select these lines, Ctrl+A for all of them, Ctrl+C to copy.", + "map": "Map", + "mapInfo": "Map info", + "mapSettings": "Map settings", "size": "Size", "gravity": "Gravity", "hardness": "Hardness", "tidalStrength": "Tidal speed", "windStrength": "Wind speed", "waterDamage": "Water damage", - "raptorOptions": "Raptor options", - "adjustedSettings": "Adjusted settings", - "settings": "Settings", + "startPositions": "Start positions", + "startPosFixed": "Fixed", + "startPosRandom": "Random", + "startPosChoose": "Chosen in game", "range": "Range", "default": "Default", "reclaimableMetal": "Reclaimable Metal", diff --git a/luaui/Include/keybind_editbox.lua b/luaui/Include/keybind_editbox.lua index aeba0231527..e18d4f7442e 100644 --- a/luaui/Include/keybind_editbox.lua +++ b/luaui/Include/keybind_editbox.lua @@ -1,4 +1,5 @@ --- Single-line text input for the keybind editor's search field. +-- Single-line text input, written for the keybind editor's search field and shared with +-- the game info panel's. -- Active only while focused, so it is safe to host alongside game input. local utf8 = VFS.Include("common/luaUtilities/utf8.lua") diff --git a/luaui/Include/keybind_text.lua b/luaui/Include/keybind_text.lua index 599f16c2573..b73fafdb4b8 100644 --- a/luaui/Include/keybind_text.lua +++ b/luaui/Include/keybind_text.lua @@ -1,6 +1,6 @@ --- Text measuring shared by the keybind editor's controls, so a label clipped in one --- control clips the same way in the next. The font is passed in because each control --- draws with its own. +-- Text measuring shared by the keybind editor's controls and the game info panel's rows, +-- so a label clipped in one control clips the same way in the next. The font is passed in +-- because each control draws with its own. local utf8 = VFS.Include("common/luaUtilities/utf8.lua") diff --git a/luaui/Include/lua_source.lua b/luaui/Include/lua_source.lua new file mode 100644 index 00000000000..247aa061678 --- /dev/null +++ b/luaui/Include/lua_source.lua @@ -0,0 +1,361 @@ +-- Pretty-prints a Lua snippet for display. +-- +-- Tweakdefs reach the game base64-encoded and minified: one enormous line, statements run +-- together with semicolons and every space squeezed out. Listing that raw shows the first +-- eighty characters of it and nothing else, so it is tokenized here and laid out again - +-- one statement per line, indented by block depth, with the spacing Lua is normally +-- written with. +-- +-- A formatter, not a parser: it never has to decide whether the source is valid, only +-- where a reader would have put the line breaks. Anything it cannot make sense of is +-- passed through as tokens rather than dropped. + +local M = {} + +local KEYWORDS = {} +for word in + string.gmatch( + "and break do else elseif end false for function if in local nil not or repeat return then true until while", + "%S+" + ) +do + KEYWORDS[word] = true +end + +-- Opens a block: what follows it is indented, on its own line. +local OPENERS = { ["do"] = true, ["then"] = true, ["repeat"] = true } +-- Closes one: it goes back out a level and starts a line of its own. +local CLOSERS = { ["end"] = true, ["until"] = true, ["else"] = true, ["elseif"] = true } + +-- Both sides get a space. `=` is in here too: it is not an operator in Lua, but it reads +-- like one and every style writes it spaced. +local BINARY = {} +for op in string.gmatch("+ - * / % ^ .. == ~= < > <= >= =", "%S+") do + BINARY[op] = true +end + +-- After one of these a `-` is a sign rather than a subtraction, so it sticks to what +-- follows it. +local PREFIX = { ["("] = true, ["["] = true, ["{"] = true, [","] = true, [";"] = true } + +local CLOSING = { [")"] = true, ["]"] = true, ["}"] = true } + +-- Punctuation rather than arithmetic. It is the most common token in any source, so it is +-- reported apart from the operators: tinting all of it the same is what turns highlighting +-- into noise. +local PUNCT = {} +for _, mark in ipairs({ ",", ";", ".", ":", "(", ")", "[", "]", "{", "}" }) do + PUNCT[mark] = true +end + +-- Keywords that can only begin a statement. Minified source runs statements together with +-- nothing between them, and one of these arriving after a finished expression is the only +-- sign of where the next one starts. +local STARTERS = {} +for word in string.gmatch("local if for while repeat return function break", "%S+") do + STARTERS[word] = true +end + +---------------------------------------------------------------- +-- Tokens +---------------------------------------------------------------- + +-- The end of a long bracket opened at `from`, or the end of the source when it is never +-- closed. Shared by long strings and long comments, which differ only in what precedes. +local function longBracketEnd(src, from, eq) + local close = "]" .. eq .. "]" + local at = string.find(src, close, from, true) + + return at and (at + #close - 1) or #src +end + +local function readString(src, i) + local quote = string.sub(src, i, i) + local j = i + 1 + local n = #src + while j <= n do + local c = string.sub(src, j, j) + if c == "\\" then + j = j + 2 + elseif c == quote then + return j + elseif c == "\n" then + -- Unterminated: stop at the line rather than swallowing the rest of the file. + return j - 1 + else + j = j + 1 + end + end + + return n +end + +local function readNumber(src, i) + return string.match(src, "^0[xX]%x+", i) + or string.match(src, "^%d+%.?%d*[eE][-+]?%d+", i) + or string.match(src, "^%.%d+[eE][-+]?%d+", i) + or string.match(src, "^%d+%.?%d*", i) + or string.match(src, "^%.%d+", i) + or string.sub(src, i, i) +end + +-- The source as a flat list of { k = kind, s = text }; whitespace is dropped, since the +-- layout below decides all of it again. +local function tokenize(src) + local toks = {} + local i, n = 1, #src + while i <= n do + local c = string.sub(src, i, i) + if string.find(c, "%s") then + i = i + 1 + elseif string.sub(src, i, i + 1) == "--" then + local eq = string.match(src, "^%-%-%[(=*)%[", i) + local stop + if eq then + stop = longBracketEnd(src, i + 4 + #eq, eq) + else + stop = (string.find(src, "\n", i, true) or (n + 1)) - 1 + end + toks[#toks + 1] = { k = "comment", s = string.sub(src, i, stop) } + i = stop + 1 + elseif string.match(src, "^%[=*%[", i) then + local eq = string.match(src, "^%[(=*)%[", i) + local stop = longBracketEnd(src, i + 2 + #eq, eq) + toks[#toks + 1] = { k = "string", s = string.sub(src, i, stop) } + i = stop + 1 + elseif c == '"' or c == "'" then + local stop = readString(src, i) + toks[#toks + 1] = { k = "string", s = string.sub(src, i, stop) } + i = stop + 1 + elseif string.find(c, "%d") or (c == "." and string.find(string.sub(src, i + 1, i + 1), "%d")) then + local s = readNumber(src, i) + toks[#toks + 1] = { k = "number", s = s } + i = i + #s + elseif string.find(c, "[%a_]") then + local s = string.match(src, "^[%a_][%w_]*", i) + toks[#toks + 1] = { k = KEYWORDS[s] and "keyword" or "name", s = s } + i = i + #s + else + local s = string.match(src, "^%.%.%.", i) + or string.match(src, "^[=~<>]=", i) + or string.match(src, "^%.%.", i) + or c + toks[#toks + 1] = { k = "op", s = s } + i = i + #s + end + end + + return toks +end + +---------------------------------------------------------------- +-- Spacing +---------------------------------------------------------------- + +local function isWord(t) + return t.k == "name" or t.k == "number" or t.k == "keyword" or t.k == "string" +end + +-- Whether a token can be the last one of a statement. What follows such a token is either +-- more of the same expression or, if it is a STARTER, the next statement. +local function endsStatement(t) + if not t then + return false + end + + return t.k == "name" or t.k == "number" or t.k == "string" or CLOSING[t.s] or t.s == "..." +end + +-- Whether a space belongs between two tokens. Only readability is at stake except for one +-- case that is not optional: two words run together become a different word. +local function spaced(a, b, prevOfA) + if not a then + return false + end + + local as, bs = a.s, b.s + + -- Nothing is ever pushed away from what it closes or qualifies. + if bs == "," or bs == ";" or bs == ")" or bs == "]" or bs == "." or bs == ":" then + return false + end + if as == "." or as == ":" or as == "(" or as == "[" or as == "#" then + return false + end + -- A separator's whole job is to hold things apart. + if as == "," then + return true + end + -- A call or an index hangs off the thing it applies to, and a function hugs its own + -- parameter list; everywhere else the bracket is a grouping and wants air. + if bs == "(" or bs == "[" then + return not (as == "function" or a.k == "name" or CLOSING[as]) + end + -- An empty table stays empty; anything else gets room inside its braces. + if bs == "}" then + return as ~= "{" + end + if as == "{" then + return true + end + -- Sign, not arithmetic. + if + (as == "-" or as == "+") + and (prevOfA == nil or PREFIX[prevOfA.s] or BINARY[prevOfA.s] or prevOfA.k == "keyword") + then + return false + end + if BINARY[as] or BINARY[bs] then + return true + end + if a.k == "keyword" or b.k == "keyword" then + return true + end + if isWord(a) and isWord(b) then + return true + end + if CLOSING[as] and (isWord(b) or bs == "{" or bs == "#") then + return true + end + + return false +end + +---------------------------------------------------------------- +-- Layout +---------------------------------------------------------------- + +-- A comment is handed over as its own words, so a long one wraps like prose instead of +-- being cut off at the column. +local function commentParts(str) + local parts = {} + for word in string.gmatch(str, "%S+") do + parts[#parts + 1] = { s = (#parts > 0 and " " or "") .. word, k = "comment" } + end + + return parts +end + +-- A name with a call directly after it reads as the thing being done rather than a value, +-- and colouring it apart is most of what makes a wall of source scannable. Lua's two +-- sugared call forms - `f"str"` and `f{...}` - count as well. +local function markCalls(toks) + for i = 1, #toks - 1 do + local t, next = toks[i], toks[i + 1] + if t.k == "name" and (next.s == "(" or next.s == "{" or next.k == "string") then + t.call = true + end + end +end + +---Lays a Lua snippet out as display lines. +--- +---A part is one token with whatever space belongs in front of it, and the kind it was +---lexed as, so the caller can both break lines between parts and colour them. Kinds are +---`comment`, `string`, `number`, `keyword`, `call`, `name`, `op` and `punct`. +---@param src string +---@return table lines Each `{ depth = , parts = { { s = , k = } } }` +function M.format(src) + local toks = tokenize(src) + markCalls(toks) + local lines = {} + local parts = {} + local depth, lineDepth = 0, 0 + local prev, prevPrev + -- ( and [ nesting, and the nesting each open function's parameter list closes at, so + -- the body starts on a line of its own without a parser to say where it begins. + local paren = 0 + local fnParen = {} + local fnPending = false + + local function flush() + if #parts > 0 then + lines[#lines + 1] = { depth = lineDepth, parts = parts } + parts = {} + end + lineDepth = depth + prev, prevPrev = nil, nil + end + + local function push(t) + local kind = t.k + if t.call then + kind = "call" + elseif kind == "op" and PUNCT[t.s] then + kind = "punct" + end + parts[#parts + 1] = { s = (spaced(prev, t, prevPrev) and " " or "") .. t.s, k = kind } + prevPrev, prev = prev, t + end + + for i = 1, #toks do + local t = toks[i] + local s = t.s + + -- Nothing separates two statements written side by side, so a keyword that can only + -- open one, arriving where the line so far already reads as finished, is the break. + -- Only outside brackets: the same words appear inside expressions. + if STARTERS[s] and paren == 0 and endsStatement(prev) then + flush() + end + + if t.k == "comment" then + flush() + -- A long comment carries its own line breaks; each becomes a line here. + for line in string.gmatch(s .. "\n", "([^\n]*)\n") do + if string.find(line, "%S") then + lines[#lines + 1] = { depth = depth, parts = commentParts(line) } + end + end + lineDepth = depth + elseif s == ";" then + -- The separator itself is what the line break now says, so it is dropped. + flush() + elseif CLOSERS[s] then + depth = math.max(0, depth - 1) + flush() + lineDepth = depth + push(t) + if s == "end" then + flush() + elseif s == "else" then + depth = depth + 1 + flush() + end + -- `elseif` is left open: its own `then` puts the level back. + -- `until` keeps its condition beside it. + elseif OPENERS[s] then + push(t) + depth = depth + 1 + flush() + else + if s == "function" then + fnPending = true + end + + if s == "(" or s == "[" or s == "{" then + paren = paren + 1 + if s == "(" and fnPending then + fnParen[#fnParen + 1] = paren + fnPending = false + end + push(t) + elseif CLOSING[s] then + push(t) + if s == ")" and fnParen[#fnParen] == paren then + fnParen[#fnParen] = nil + depth = depth + 1 + flush() + end + paren = math.max(0, paren - 1) + else + push(t) + end + end + end + flush() + + return lines +end + +return M diff --git a/luaui/Widgets/gui_changelog_info.lua b/luaui/Widgets/gui_changelog_info.lua index 976af9bc1ce..ef18ebb3b93 100644 --- a/luaui/Widgets/gui_changelog_info.lua +++ b/luaui/Widgets/gui_changelog_info.lua @@ -558,6 +558,12 @@ local function mouseEvent(x, y, button, release) return false end + -- A press on a top bar button is the top bar's to handle: it closes the open windows + -- and opens the one that was clicked. Closing (and consuming) here would swallow it. + if WG.topbar and WG.topbar.buttonAt and WG.topbar.buttonAt(x, y) then + return false + end + if math_isInRect(x, y, screenX, screenY - screenHeight, screenX + screenWidth, screenY) then if not release and button == 1 then local i = sidebarIndexAt(x, y) diff --git a/luaui/Widgets/gui_gameinfo.lua b/luaui/Widgets/gui_gameinfo.lua index c5a26fa5156..b48efab9b6f 100644 --- a/luaui/Widgets/gui_gameinfo.lua +++ b/luaui/Widgets/gui_gameinfo.lua @@ -12,50 +12,253 @@ function widget:GetInfo() } end +-- The panel is laid out the way the keybind editor lays out its own: the same inset area, +-- the title in the top-left corner over a darker card holding the category column, a +-- search field and a filter toggle in the header band, and a 14 px scrollbar standing in +-- its own channel against the right edge. +-- +-- Everything shown is built once into blocks of rows, each block belonging to a category. +-- The column, the search box and the "changed only" toggle only pick which of those rows +-- the list shows, so nothing is decoded or measured again while they are used. + +-- Shared with the keybind editor, which is where both were written for. +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") +-- 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 +-- /luaui reload rather than costing it only the formatting. +local okSource, LuaSource = pcall(VFS.Include, "luaui/Include/lua_source.lua") +if not okSource then + LuaSource = nil +end + -- Localized functions for performance local mathFloor = math.floor local mathMax = math.max +local mathMin = math.min local tableInsert = table.insert -- Localized Spring API for performance local spGetViewGeometry = Spring.GetViewGeometry - -local titlecolor = "\255\255\205\100" -local keycolor = "" -local valuecolor = "\255\255\255\255" -local valuegreycolor = "\255\180\180\180" -local separator = "::" - -local font, font2, loadedFontSize, mainDList, titleRect, backgroundGuishader, show -local maxLines = 22 +local spGetMouseState = Spring.GetMouseState +local spIsGUIHidden = Spring.IsGUIHidden +local glCreateList = gl.CreateList +local glCallList = gl.CallList +local glDeleteList = gl.DeleteList +local glColor = gl.Color +local glTexture = gl.Texture local math_isInRect = math.isInRect -local raptorsEnabled = BAR.Utilities.Gametype.IsRaptors() +local playSounds = true +local buttonclick = "LuaUI/Sounds/buildbar_waypoint.wav" -local content = "" +local screenHeightOrg = 610 +local screenWidthOrg = 1100 +local screenHeight = screenHeightOrg +local screenWidth = screenWidthOrg --- hover tooltips: filled while the content is built, translated into screen areas while the textarea is drawn -local lineTooltips = {} -- [content line number] = { title = , text = } -local contentLineCount = 0 -local hoverRows = {} -- list of { bottom, top, tooltip } -local hoverAreaLeft, hoverAreaRight +local vsx, vsy = spGetViewGeometry() +local widgetScale = (vsy / 1080) +local screenX = mathFloor((vsx * 0.5) - (screenWidth / 2)) +local screenY = mathFloor((vsy * 0.5) + (screenHeight / 2)) + +---@type function +local RectRound +---@type function +local UiElement +---@type function +local UiScroller +---@type function +local Highlight +---@type function +local UiToggle +---@type function +local UiUnit +local elementCorner +local font, fontMono + +local area = { x1 = 0, y1 = 0, x2 = 0, y2 = 0 } +-- Sizes derived from the scale, in one table rather than a local each, the way the +-- keybind editor holds its own: this chunk is close to Lua's limit of 200 locals. +local metrics = { + rowHeight = 24, + catRowHeight = 29, + rowFs = 13, + -- A category heading stands taller than the rows under it and is set larger, so it + -- reads as a divider rather than another row. + headerRowHeight = 32, + headerFs = 14, + catFs = 13, + -- Decoded tweaks are source, so they get the monospaced face on a tighter row. + codeRowHeight = 19, + codeFs = 12, + -- A tweaked unit gets its picture beside it, spanning the three code rows its block takes + -- at its shortest. What it gives up to the gaps above and below is what keeps two of them + -- apart when a unit had only one parameter changed; more comes off the top than the + -- bottom, so the picture sits against the fields it describes rather than the block above. + iconSize = 45, + iconTop = 6, + iconBottom = 3, + -- A search shows the lines it matched and nothing around them, so each one names its + -- unit and shows it small. This is the most of a name that column keeps. + tinyIcon = 15, + ownerChars = 22, + rowPad = 6, + sidePad = 12, + catInset = 4, + -- The line along the bottom of a heading. + underlineH = 2, + -- The bar marking a row whose value is not the default one. + accentW = 3, + -- Everything that sits against the panel's right edge - the toggle and the scrollbar - + -- is held off it by this much. + edgeInset = 4, + -- The band the title, the search field and the filter toggle share, and the gap below. + headerH = 34, + headerGap = 4, + -- Clearance between the right edge of the rows and the scrollbar beside them. + listGap = 12, + -- How far the category card rises above the first entry in it. + cardLip = 5, + -- The panel title: its baseline below the top edge, and its size. + titleY = 17, + titleFs = 20, + -- How far the category column starts below the rows beside it, to leave the title room. + sidebarDrop = 8, + sidebarW = 240, + barW = 14, + -- Rows the wheel moves per notch. + wheelRows = 3, + -- Corner radii, taken from FlowUI's so the panel rounds like the rest of the UI. + csSmall = 2, + csPanel = 4, +} +local look = { + -- The category column sits on its own darker card, so it reads apart from the list. + sidebarFill = { 0, 0, 0, 0.24 }, + sidebarFillTop = { 0, 0, 0, 0.16 }, + selectedFill = { 1, 1, 1, 0.13 }, + -- Brighter than the category column's own selection: this one is over source that is + -- already coloured, and has to read through it. + codeSelectedFill = { 0.45, 0.62, 0.95, 0.28 }, + white = { 1, 1, 1 }, + -- Rows and categories hover with the same FlowUI highlight the settings list uses, at + -- the strength it gives a plain row. + rowHoverOpacity = 0.14, + -- An adjusted option is marked three ways at once - a warm band across the row, a bar + -- down its left edge and warm text - so it is still obvious in a list scrolled past + -- quickly, where a colour change alone reads as noise. + changedFill = { 1, 0.8, 0.45, 0.08 }, + changedAccent = { 1, 0.78, 0.35, 0.7 }, + -- The same three marks in the colour of something that did not work. + failedFill = { 0.92, 0.35, 0.35, 0.1 }, + failedAccent = { 0.92, 0.35, 0.35, 0.8 }, + -- Underline under a category heading: a thin bar fading up out of the bottom edge, in + -- the hue of the caption above it. + headerLine = { 1, 0.78, 0.51, 0.4 }, + headerLineFade = { 1, 0.78, 0.51, 0 }, + failedLine = { 0.92, 0.35, 0.35, 0.5 }, + failedLineFade = { 0.92, 0.35, 0.35, 0 }, + sheenTop = { 1, 1, 1, 0.05 }, +} +local colorTitle = "\255\235\235\235" +local colorHeader = "\255\255\200\130" +-- Names and values at their default, and the same pair once the value is not the default: +-- both halves of the row brighten, so the row reads as adjusted from either column. +local colorName = "\255\185\183\180" +local colorValue = "\255\145\145\145" +local colorNameOn = "\255\240\240\240" +local colorValueOn = "\255\255\205\100" +-- Facts read off the map and the engine rather than settings, so they take neither the +-- muted look of a default nor the warm one of a change. +local colorInfo = "\255\205\202\199" +local colorSelected = "\255\210\210\205" +local colorDim = "\255\160\160\160" +local colorCode = "\255\170\185\200" +-- A tweak the game could not load. Not a shade of the warm "adjusted" colour: this is not +-- a setting that took effect, and it must not read like one. +local colorDanger = "\255\235\090\090" +-- Decoded tweaks are source, and source is far easier to scan in colour. Keyed by the kind +-- lua_source lexed each token as; a kind with no entry here reads as a plain name. +local codeColors = { + comment = "\255\125\140\155", + keyword = "\255\198\146\234", + string = "\255\180\215\140", + number = "\255\240\165\125", + call = "\255\130\170\245", + op = "\255\135\185\205", + -- Commas, dots and brackets are most of any source; tinting them like the arithmetic + -- turns the highlighting into noise, so they stay close to the plain text. + punct = "\255\140\148\156", +} +-- The zoom the build menu shows an idle cell at, so a unit reads the same in both. +local iconZoom = 0.0375 + +local L = {} + +local show, showOnceMore +local panelList, windowList, backgroundGuishader, panelSig +local listTop, listBottom, listX1, listRight, valueX1, barX1 = 0, 0, 0, 0, 0, 0 +local toggleHit, toggleDraw = {}, {} + +-- Every row the panel can show, grouped into blocks; a block is one heading and the rows +-- under it, and belongs to one category. +local blocks = {} +local categories = {} +local categoryIndex = {} +-- What the list shows right now: the rows the column, the search box and the toggle left. +local rows = {} +-- Bumped by rebuildRows, so the baked panel knows the list behind it changed. +local rowsGen = 0 +-- Bumped by setLayout. A cached row layout carries the value it was built against and is +-- measured again when it moves, without every row being walked at resize. +local layoutGen = 0 +local rowMetrics = { gen = -1, rows = -1, totalH = 0 } +local scroll = 0 +local dragging = false +-- 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 +local selecting = false +-- Keys taken for a copy. The engine runs a bound action on release as well as on press, so +-- consuming only the press would still fire whatever else the shortcut is bound to. +local consumedKeys = {} +-- Selection is held as the category's key, never its translated title, so a language +-- change cannot strand it against titles that have all moved. nil is the All entry. +---@type string? +local selectedCategory +local changedOnly = false +---@type table +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 } + +-- 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 +-- found. +local ownsInput = false +local fieldHasInput = false +local textInputStarted = false +-- Keys already down when the field took focus. Their release has to be let through, or +-- whatever they started stays stuck on once the field starts swallowing releases. +local heldAtFocus = {} + +local rebuildRows + +---------------------------------------------------------------- +-- The game's own settings +---------------------------------------------------------------- --- appends text to the content, optionally attaching a tooltip to every line the text spans -local function addContent(text, tooltip) - content = content .. text - local _, addedLines = string.gsub(text, "\n", "") - if tooltip then - for line = contentLineCount + 1, contentLineCount + mathMax(addedLines, 1) do - lineTooltips[line] = tooltip - end - end - contentLineCount = contentLineCount + addedLines -end +local raptorsEnabled = BAR.Utilities.Gametype.IsRaptors() +local scavengersEnabled = BAR.Utilities.Gametype.IsScavengers() local tidal = Game.tidal local map_tidal = Spring.GetModOptions().map_tidal -local reclaimable_metal = 0 -local reclaimable_energy = 0 +local reclaimableMetal = 0 +local reclaimableEnergy = 0 if map_tidal == "unchanged" then elseif map_tidal == "low" then @@ -70,17 +273,47 @@ if Spring.GetTidal then tidal = Spring.GetTidal() end --- modoptions -local defaultModoptions = VFS.Include("modoptions.lua") -local modoptionsDefault = {} -for key, value in pairs(defaultModoptions) do - modoptionsDefault[value.key] = - { name = value.name, desc = value.desc, def = value.def, min = value.min, max = value.max } +-- The gamemode sections only describe the gamemode that is running: outside it their +-- options are inert, and listing sixty of them buries the ones that do something. +local function sectionApplies(section) + if section == "raptor_defense_options" then + return raptorsEnabled + end + if section == "scav_defense_options" then + return scavengersEnabled + end + return true +end + +-- modoptions.lua carries the options, the sections they are grouped under, and the +-- defaults every value is compared against. +local optionDefs = {} +local sectionOrder = {} +local sectionNames = {} +for _, o in ipairs(VFS.Include("modoptions.lua")) do + if o.type == "section" then + sectionOrder[#sectionOrder + 1] = o.key + sectionNames[o.key] = o.name + elseif o.key ~= "sub_header" then + -- A key can be declared by two sections - raptors and scavengers both carry + -- initialbox - and the one whose gamemode is running is the one that owns it. + if not optionDefs[o.key] or sectionApplies(o.section) then + optionDefs[o.key] = { + name = o.name, + desc = o.desc, + def = o.def, + min = o.min, + max = o.max, + section = o.section, + } + end + end end local modoptions = BAR.GetModOptionsCopy() --- options that aren't worth listing: modoptions.lua layout helpers and values fed by spads/lobby +-- options that aren't worth listing: modoptions.lua layout helpers and values fed by +-- spads/lobby local ignoredModoptions = { sub_header = true, dummyboolfeelfreetotouch = true, @@ -89,727 +322,1967 @@ local function isIgnoredModoption(key) return ignoredModoptions[key] or string.sub(key, 1, 5) == "date_" end -local changedModoptions = {} -local unchangedModoptions = {} -local changedRaptorModoptions = {} -local unchangedRaptorModoptions = {} -for key, value in pairs(modoptions) do - if string.sub(key, 1, 8) == "raptor_" then - if raptorsEnabled then - if value == modoptionsDefault[key].def then - unchangedRaptorModoptions[key] = tostring(value) - else - changedRaptorModoptions[key] = tostring(value) - end - end - end -end - -local function stringifyDefTable(t, path, pathAddition) - if not path then - path = {} - end - path = table.copy(path) - if pathAddition then - path[#path + 1] = pathAddition - end - if #path > 10 then - return "..." - end - local text = "" - local depthSpacing = "" - for i = 1, #path, 1 do - depthSpacing = depthSpacing .. " " - end - for k, v in pairs(t) do - if type(v) == "table" then - text = text .. "\n" .. valuegreycolor .. depthSpacing .. tostring(k) .. " = {" - text = text .. stringifyDefTable(v, path, k) - text = text .. "\n" .. valuegreycolor .. depthSpacing .. "}" - else - text = text .. "\n" .. valuegreycolor .. depthSpacing .. tostring(k) .. " = " .. tostring(v) - end - end - return text -end - --- tweakdefs are lua snippets, so the units they touch are found by matching known unitdef names -local function countTweakedUnitDefs(text) - local count = 0 - local counted = {} - for word in string.gmatch(text, "[%a_][%w_]*") do - if not counted[word] and UnitDefNames[word] then - counted[word] = true - count = count + 1 +-- What puts an option in the Map category instead of the section it was declared under. +-- Matched on the key, so an option added later lands there without this list growing. +local mapOptionMarkers = { "map", "startpos", "startbox", "start_boxes" } +local function isMapOption(key) + for i = 1, #mapOptionMarkers do + if string.find(key, mapOptionMarkers[i], 1, true) then + return true end end - return count + return false end --- every unitdef that got tweaked counts as a change on top of the tweakdefs/tweakunits option itself -local changedModoptionsCount = 0 - -for key, value in pairs(modoptions) do - if not isIgnoredModoption(key) then - if modoptionsDefault[key] and value == modoptionsDefault[key].def then - unchangedModoptions[key] = tostring(value) - else - changedModoptionsCount = changedModoptionsCount + 1 - if not string.find(key, "tweakunits") and not string.find(key, "tweakdefs") then - changedModoptions[key] = tostring(value) - else - if string.find(key, "tweakdefs") then - local decodeSuccess, postsFuncStr = pcall(string.base64Decode, value) - changedModoptions[key] = "\n" - .. ( - decodeSuccess and postsFuncStr - or "\255\255\100\100 - " .. BAR.I18N("ui.gameInfo.decodefailed") .. " - " - ) - if decodeSuccess then - changedModoptionsCount = changedModoptionsCount + countTweakedUnitDefs(postsFuncStr) - end - else - local dataRaw = string.gsub(value, "_", "=") - local decodeSuccess, postsFuncStr = pcall(string.base64Decode, dataRaw) - local success, tweaks = pcall(BAR.Utilities.SafeLuaTableParser, postsFuncStr) - - if success and type(tweaks) == "table" then - local text = "" - for name, ud in pairs(tweaks) do - if UnitDefNames[name] then - changedModoptionsCount = changedModoptionsCount + 1 - text = text .. "\n" .. valuecolor .. name .. valuegreycolor .. " = {" - text = text .. stringifyDefTable(ud, {}, name) - text = text .. "\n" .. "}" - end - end - changedModoptions[key] = text - else - changedModoptions[key] = tostring(value) - if decodeSuccess then - changedModoptionsCount = changedModoptionsCount + countTweakedUnitDefs(postsFuncStr) - end - end - end - end - end +-- The numbered tweak slots (tweakdefs, tweakdefs1 .. tweakdefs29) each get their own +-- block, so a game using several of them shows which snippet came from where. +local function tweakSlot(key) + local kind, n = string.match(key, "^(tweak[du]%a+)(%d*)$") + if kind ~= "tweakdefs" and kind ~= "tweakunits" then + return nil end + return kind, tonumber(n) or 0 end -local function stripColorCodes(text) - local stripped = string.gsub(text, "\255...", "") - return stripped +local function stripColorCodes(str) + return (string.gsub(str, "\255...", "")) end --- modoption names/descriptions live in language//interface.json under the 'modoptions' namespace, --- the (english) texts inside modoptions.lua are the fallback for options that aren't translated yet +-- modoption names/descriptions live in language//interface.json under the +-- 'modoptions' namespace, the (english) texts inside modoptions.lua are the fallback for +-- options that aren't translated yet local function getModoptionName(key) - local default = modoptionsDefault[key] and modoptionsDefault[key].name + local default = optionDefs[key] and optionDefs[key].name default = default and stripColorCodes(default) or key -- a few names are multi line (lobby layout), here they are shown on a single row - local name = string.gsub(BAR.I18N("modoptions." .. key .. ".name", { default = default }), "%s*\n%s*", " ") - return name + return (string.gsub(BAR.I18N("modoptions." .. key .. ".name", { default = default }), "%s*\n%s*", " ")) end -local function getModoptionDesc(key) - local default = modoptionsDefault[key] and modoptionsDefault[key].desc or "" - return BAR.I18N("modoptions." .. key .. ".desc", { default = stripColorCodes(default) }) +local function getSectionName(key) + local default = sectionNames[key] and stripColorCodes(sectionNames[key]) or key + return BAR.I18N("modoptions." .. key .. ".name", { default = default }) end -local function appendTooltipLine(text, line) - return (text ~= "" and text .. "\n" or "") .. line +local function appendTooltipLine(str, line) + return (str ~= "" and str .. "\n" or "") .. line end --- description, allowed range, and (when the option isn't at its default) the value it normally has +-- description, allowed range, and (when the option isn't at its default) the value it +-- normally has local function getModoptionTooltipText(key, showDefault) - local text = getModoptionDesc(key) - local option = modoptionsDefault[key] + local option = optionDefs[key] + local str = BAR.I18N("modoptions." .. key .. ".desc", { default = option and stripColorCodes(option.desc or "") or "" }) if option then if option.min and option.max then - text = appendTooltipLine( - text, - valuegreycolor - .. BAR.I18N("ui.gameInfo.range") - .. ": " - .. valuecolor - .. option.min - .. " - " - .. option.max + str = appendTooltipLine( + str, + colorDim .. BAR.I18N("ui.gameInfo.range") .. ": " .. colorTitle .. option.min .. " - " .. option.max ) end if showDefault and option.def ~= nil then - text = appendTooltipLine( - text, - valuegreycolor .. BAR.I18N("ui.gameInfo.default") .. ": " .. valuecolor .. tostring(option.def) + str = appendTooltipLine( + str, + colorDim .. BAR.I18N("ui.gameInfo.default") .. ": " .. colorTitle .. tostring(option.def) ) end end - return (text ~= "" and text .. "\n\n" or "") .. valuegreycolor .. key + return (str ~= "" and str .. "\n\n" or "") .. colorDim .. key end --- turns a key -> value table into a list of displayable rows, sorted by the name they are displayed with -local function getModoptionRows(modoptionValues, showDefault) - local rows = {} - for key, value in pairs(modoptionValues) do - local name = getModoptionName(key) - tableInsert(rows, { - key = key, - value = value, - name = name, - tooltip = { - title = name, - text = getModoptionTooltipText(key, showDefault), - }, - }) - end - table.sort(rows, function(a, b) - local aName, bName = string.lower(a.name), string.lower(b.name) - if aName == bName then - return a.key < b.key - end - return aName < bName - end) - return rows +-- Lua's reserved words. A field named after one cannot be written as a bare name. +local reservedWords = "and break do else elseif end false for function if in local nil not or " + .. "repeat return then true until while" +local luaKeywords = {} +for word in string.gmatch(reservedWords, "%S+") do + luaKeywords[word] = true end -local screenHeightOrg = 540 -local screenWidthOrg = 540 -local screenHeight = screenHeightOrg -local screenWidth = screenWidthOrg +-- A value written the way Lua would have it, not the way it prints. Tweakunits is rebuilt +-- from a parsed table rather than listed as it arrived, and what is rebuilt here is meant +-- to be copied back into a file - so a string needs its quotes, and whatever is inside them +-- needs escaping. +local function luaValue(v) + if type(v) ~= "string" then + return tostring(v) + end -local startLine = 1 + local str = string.gsub(v, "\\", "\\\\") + str = string.gsub(str, '"', '\\"') + -- A literal newline or tab would also break the one-line-per-field layout. + str = string.gsub(str, "\n", "\\n") + str = string.gsub(str, "\r", "\\r") + str = string.gsub(str, "\t", "\\t") -local vsx, vsy = spGetViewGeometry() -local screenX = (vsx * 0.5) - (screenWidth / 2) -local screenY = (vsy * 0.5) + (screenHeight / 2) + return '"' .. str .. '"' +end -local glCreateList = gl.CreateList -local glCallList = gl.CallList -local glDeleteList = gl.DeleteList +local function valueKind(v) + if type(v) == "number" then + return "number" + elseif type(v) == "boolean" then + return "keyword" + end -local widgetScale = (vsy / 1080) + return "string" +end -local fileLines = {} -local totalFileLines = 0 +-- The key of a field: a bare name where Lua allows one, in brackets where it does not - +-- a number, or anything that is not a plain identifier. +local function addKeyParts(k, parts) + if type(k) == "string" and string.find(k, "^[%a_][%w_]*$") and not luaKeywords[k] then + parts[#parts + 1] = { s = k, k = "name" } -local showOnceMore = false -- used because of GUI shader delay + return + end -local RectRound, UiElement, UiScroller, elementCorner + parts[#parts + 1] = { s = "[", k = "punct" } + parts[#parts + 1] = { s = luaValue(k), k = valueKind(k) } + parts[#parts + 1] = { s = "]", k = "punct" } +end -function widget:ViewResize() - vsx, vsy = spGetViewGeometry() - widgetScale = (vsy / 1080) +-- What a tweakunits overwrote on this unit: the paths it set, against the values that were +-- there before. gamedata/unitdefs_post.lua records it while it still can - the before only +-- exists in the defs environment, and only until the tweak is applied. +local function overwrittenFor(name) + local def = UnitDefNames[name] + local recorded = def and def.customParams and def.customParams.tweaked_from + if not recorded then + return nil + end - screenHeight = mathFloor(screenHeightOrg * widgetScale) - screenWidth = mathFloor(screenWidthOrg * widgetScale) + local out = {} + for line in string.gmatch(recorded, "[^\n]+") do + local path, value = string.match(line, "^([^\t]*)\t(.*)$") + -- First wins: the slots were applied in order, so the earliest record for a path is + -- the one that predates all of them. + if path and out[path] == nil then + out[path] = value + end + end - screenX = mathFloor((vsx * 0.5) - (screenWidth / 2)) - screenY = mathFloor((vsy * 0.5) + (screenHeight / 2)) + return out +end - font, loadedFontSize = WG.fonts.getFont() - font2 = WG.fonts.getFont(2) +-- What a value used to be, and how far it moved when both ends are numbers. The percentage +-- is of the old value, so a metalcost going 380 to 520 reads as +37%. Left off when it +-- rounds to nothing, where it would say less than the two numbers already do, and when the +-- old value was zero, which nothing can be a percentage of. +local function changeNote(old, new) + if old == "" then + return L.wasNew + end - elementCorner = WG.FlowUI.elementCorner + local note = L.was .. " " .. old + local before = tonumber(old) + if before and before ~= 0 and type(new) == "number" then + local pct = (new - before) / before * 100 + -- Away from zero on a half, so a drop and a rise of the same size round alike. + pct = pct >= 0 and mathFloor(pct + 0.5) or -mathFloor(-pct + 0.5) + if pct ~= 0 then + note = note .. string.format(" (%+d%%)", pct) + end + end - RectRound = WG.FlowUI.Draw.RectRound - UiElement = WG.FlowUI.Draw.Element - UiScroller = WG.FlowUI.Draw.Scroller + return note +end - if mainDList then - gl.DeleteList(mainDList) +-- Flattens a tweakunits def table into one source line per field, in the shape +-- lua_source.format hands back - parts carrying their own kind - so both kinds of tweak +-- lay out and colour through the same code. Every field is separated the way Lua needs it: +-- what is listed here is source someone can take away, not a readout of it. +local function collectDefLines(t, lines, depth, path, was) + if depth > 10 then + lines[#lines + 1] = { depth = depth, parts = { { s = "...", k = "comment" } } } + return + end + for k, v in pairs(t) do + local parts = {} + addKeyParts(k, parts) + parts[#parts + 1] = { s = " = ", k = "op" } + -- Lowercased, because that is how the defs pass had the keys when it read the values + -- this is about to be compared against. + local here = string.lower(tostring(k)) + if path ~= "" then + here = path .. "." .. here + end + if type(v) == "table" then + parts[#parts + 1] = { s = "{", k = "punct" } + lines[#lines + 1] = { depth = depth, parts = parts } + collectDefLines(v, lines, depth + 1, here, was) + lines[#lines + 1] = { depth = depth, parts = { { s = "},", k = "punct" } } } + else + parts[#parts + 1] = { s = luaValue(v), k = valueKind(v) } + parts[#parts + 1] = { s = ",", k = "punct" } + -- Said as a comment, so the line still reads as the source it is and still copies + -- as source. An empty record means there was nothing there before. + local old = was and was[here] + if old then + parts[#parts + 1] = { s = " -- " .. changeNote(old, v), k = "comment" } + end + lines[#lines + 1] = { depth = depth, parts = parts } + end end - mainDList = gl.CreateList(DrawWindow) end --- remembers the screen area of a drawn line so the tooltip can be shown when hovering over it -local function addHoverRow(lineKey, y, j, numLines, rowHeight, baselineOffset) - local tooltip = lineTooltips[lineKey] - if tooltip then - hoverRows[#hoverRows + 1] = { - y - (rowHeight * (j + numLines - 1)) - baselineOffset, - y - (rowHeight * (j - 1)) - baselineOffset, - tooltip, - } +-- A tweak that compiles can still throw while the game applies it, and that happens in the +-- defs environment, which is built before LuaUI exists and can say nothing to it except +-- through the defs it produces. gamedata/unitdefs_post.lua leaves what went wrong on the +-- commander defs, and this is where it is picked back up. +local function loadDefsTweakFailures() + local failures = {} + for _, name in ipairs({ "armcom", "corcom", "legcom" }) do + local def = UnitDefNames and UnitDefNames[name] + local recorded = def and def.customParams and def.customParams.tweak_errors + if recorded then + for line in string.gmatch(recorded, "[^\n]+") do + local key, message = string.match(line, "^([^\t]+)\t(.*)$") + if key then + failures[key] = message + end + end + + return failures + end end + + return failures end -function DrawTextarea(x, y, width, height, scrollbar) - local scrollbarOffsetTop = 0 -- note: won't add the offset to the bottom, only to top - local scrollbarOffsetBottom = 0 -- note: won't add the offset to the top, only to bottom - local scrollbarMargin = 14 * widgetScale - local scrollbarWidth = 8 * widgetScale - local scrollbarPosWidth = 4 * widgetScale +local defsTweakFailures = loadDefsTweakFailures() - local fontSizeTitle = 18 * widgetScale - local fontSizeLine = 15.5 * widgetScale - local lineSeparator = 2 * widgetScale +-- Tabs draw as nothing, so tab-indented source would come out flush left. The formatter +-- decides all the indentation anyway; this only matters for the tabs inside string +-- literals and comments, which it passes through. +local function untab(str) + return (string.gsub(str, "\t", " ")) +end - local fontColorLine = { 0.8, 0.77, 0.74, 1 } - local fontColorCommand = { 0.9, 0.6, 0.2, 1 } +-- A tweak is whatever the lobby put in the script, so the formatter is never trusted to +-- cope with it: anything it cannot read is listed as it arrived rather than taking the +-- panel down with it. The same path runs when the formatter is not there at all, which is +-- what a /luaui reload sees until the game is started again. +local function formatSource(str) + str = untab(str) + + if LuaSource then + local ok, lines = pcall(LuaSource.format, str) + if ok then + return lines + end + end - local textRightOffset = scrollbar and scrollbarMargin + scrollbarWidth + scrollbarWidth or 0 - maxLines = mathFloor(height / (lineSeparator + fontSizeTitle)) + local raw = {} + for _, line in ipairs(string.lines(str)) do + if string.find(line, "%S") then + raw[#raw + 1] = { depth = 0, parts = { { s = line, k = "name" } } } + end + end - -- textarea scrollbar - if scrollbar then - if totalFileLines > maxLines or startLine > 1 then - -- only show scroll above X lines - local scrollbarTop = y - scrollbarOffsetTop - scrollbarMargin - local scrollbarBottom = y - scrollbarOffsetBottom - height + scrollbarMargin + return raw +end - UiScroller( - mathFloor(x + width - scrollbarMargin - scrollbarWidth), - mathFloor(scrollbarBottom - (scrollbarWidth - scrollbarPosWidth)), - mathFloor(x + width - scrollbarMargin), - mathFloor(scrollbarTop + (scrollbarWidth - scrollbarPosWidth)), - (#fileLines - 1) * (lineSeparator + fontSizeTitle), - (startLine - 1) * (lineSeparator + fontSizeTitle) - ) +-- The decoded contents of one tweak slot, laid out, and what went wrong with it if +-- anything did. tweakdefs is a lua snippet, which is pretty-printed; tweakunits is a lua +-- table, which reads better rebuilt than printed as it was written. +-- +-- A tweak that does not decode, or does not compile, is broken in a way the game will not +-- have applied - so it is said so, plainly, rather than left looking like a setting that +-- took effect. +local function decodeTweak(kind, value) + if kind == "tweakdefs" then + local ok, decoded = pcall(string.base64Decode, value) + if not ok then + return {}, BAR.I18N("ui.gameInfo.decodefailed") end + -- Compiled, never run: this is the same load the game does before applying the + -- snippet, so a snippet that fails here is one the game rejected too. + local _, compileError = loadstring(decoded, "tweakdefs") + + return formatSource(decoded), compileError end - -- draw textarea - if content then - hoverRows = {} - hoverAreaLeft = x - hoverAreaRight = x + width - textRightOffset + local ok, decoded = pcall(string.base64Decode, (string.gsub(value, "_", "="))) + if not ok then + return {}, BAR.I18N("ui.gameInfo.decodefailed") + end - font:Begin() - local lineKey = startLine - local j = 1 - while j < maxLines + 1 do - -- maxlines is not exact, just a failsafe - if (lineSeparator + fontSizeTitle) * j > height then - break - end - if fileLines[lineKey] == nil then - break + local parsed, tweaks = pcall(BAR.Utilities.SafeLuaTableParser, decoded) + if parsed and type(tweaks) == "table" then + local lines = {} + for name, ud in pairs(tweaks) do + if UnitDefNames[name] then + local from = #lines + 1 + lines[#lines + 1] = { + depth = 0, + -- What the picture beside this block is of. + unitDefID = UnitDefNames[name].id, + parts = { { s = name, k = "call" }, { s = " = ", k = "op" }, { s = "{", k = "punct" } }, + } + collectDefLines(ud, lines, 1, "", overwrittenFor(name)) + -- Comma included: each unit is a field of the tweakunits table, and a listing + -- someone copies has to be the source it came from rather than a look at it. + lines[#lines + 1] = { depth = 0, parts = { { s = "},", k = "punct" } } } + + -- Every line of the block remembers whose it is, not only the one that opens + -- it: a search keeps the lines that matched and throws the rest away, and a + -- `metalcost = 520` on its own says nothing about which unit it belongs to. + for i = from, #lines do + lines[i].ownerUnitDefID = UnitDefNames[name].id + lines[i].ownerName = name + end end + end + return lines + end - local numLines - local line = fileLines[lineKey] - local separatorStart, separatorEnd = string.find(line, separator, 1, true) - if separatorStart and separatorEnd then - local cmd = string.sub(line, 1, separatorStart - 1) - local descr = string.sub(line, separatorEnd + 1) - local cmdLines, descrLines - cmd, cmdLines = font:WrapText( - cmd, - ((screenWidth * 0.58) - (26 * widgetScale)) * (loadedFontSize / fontSizeLine) - ) - descr, descrLines = font:WrapText( - descr, - (width - scrollbarMargin - scrollbarWidth - 250 - textRightOffset) - * 0.65 - * (loadedFontSize / fontSizeLine) - ) - numLines = mathMax(cmdLines, descrLines) - if (lineSeparator + fontSizeTitle) * (j + numLines - 1) > height then - break - end + -- Not a table after all, so it is treated as a snippet and checked like one. + local _, compileError = loadstring(decoded, "tweakunits") - addHoverRow(lineKey, y, j, numLines, lineSeparator + fontSizeTitle, fontSizeLine * 0.25) + return formatSource(decoded), compileError or (not parsed and tostring(tweaks) or nil) +end - font:SetTextColor(fontColorCommand) - font:Print(cmd, x + (18 * widgetScale), y - (lineSeparator + fontSizeTitle) * j, fontSizeLine, "n") +---------------------------------------------------------------- +-- Content +---------------------------------------------------------------- - font:SetTextColor(fontColorLine) - font:Print(descr, x + (screenWidth * 0.58), y - (lineSeparator + fontSizeTitle) * j, fontSizeLine, "n") - j = j + (numLines - 1) - else - -- line - font:SetTextColor(fontColorLine) - line = "" .. line - line, numLines = - font:WrapText(line, (width - scrollbarMargin - scrollbarWidth) * (loadedFontSize / fontSizeLine)) - if (lineSeparator + fontSizeTitle) * (j + numLines - 1) > height then - break - end - font:Print(line, x + (18 * widgetScale), y - (lineSeparator + fontSizeTitle) * j, fontSizeLine, "n") - j = j + (numLines - 1) - end +-- An error message, as the words it is made of, so a long one wraps rather than being cut +-- off at the column. +local function messageParts(str) + local parts = {} + for word in string.gmatch(tostring(str), "%S+") do + parts[#parts + 1] = { s = (#parts > 0 and " " or "") .. word, k = "name" } + end - j = j + 1 - lineKey = lineKey + 1 - end - font:End() + return parts +end + +-- Lights the column entry a broken tweak sits under, so a failure is visible without +-- having to open the category to find it. +local function markCategoryFailed(key) + local category = categoryIndex[key] + if category then + category.failed = true + category.fitGen = nil end end -function DrawWindow() - -- title - local titleFontSize = 18 * widgetScale - titleRect = { - screenX, - screenY, - mathFloor( - screenX + (font2:GetTextWidth(BAR.I18N("ui.gameInfo.title")) * titleFontSize) + (titleFontSize * 1.5) - ), - mathFloor(screenY + (titleFontSize * 1.7)), +-- Counts adjustments against the category a block belongs to, so the column can say how +-- much of what it lists is not the way the game ships. +local function countChanges(block, n) + local category = categoryIndex[block.category] + if category then + category.changes = category.changes + n + end +end + +local function addBlock(categoryKey, categoryLabel, title) + local block = { + category = categoryKey, + title = title, + titleLower = string.lower(title), + header = { type = "header", name = title }, + entries = {}, } + blocks[#blocks + 1] = block - UiElement( - screenX, - screenY - screenHeight, - screenX + screenWidth, - screenY, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - WG.FlowUI.clampedOpacity - ) - gl.Color(0, 0, 0, WG.FlowUI.clampedOpacity) - RectRound(titleRect[1], titleRect[2], titleRect[3], titleRect[4], elementCorner, 1, 1, 0, 0) - - font2:Begin() - font2:SetTextColor(1, 1, 1, 1) - font2:SetOutlineColor(0, 0, 0, 0.4) - font2:Print( - BAR.I18N("ui.gameInfo.title"), - screenX + (titleFontSize * 0.75), - screenY + (8 * widgetScale), - titleFontSize, - "on" - ) - font2:End() + if not categoryIndex[categoryKey] then + local category = { key = categoryKey, label = categoryLabel, lower = string.lower(categoryLabel), changes = 0 } + categoryIndex[categoryKey] = category + categories[#categories + 1] = category + end - -- textarea - DrawTextarea(screenX, screenY - (8 * widgetScale), screenWidth, screenHeight - (24 * widgetScale), 1) + return block end -local function getHoveredTooltip(x, y) - if not hoverAreaLeft or x < hoverAreaLeft or x > hoverAreaRight then - return nil +-- A fact read off the map or the engine: never "adjusted", so it carries no default to +-- compare against and drops out under the changed-only filter. +local function addInfo(block, name, value, tooltip) + value = tostring(value) + block.entries[#block.entries + 1] = { + type = "info", + name = name, + value = value, + nameLower = string.lower(name), + search = string.lower(name .. " " .. value), + tooltip = tooltip, + } +end + +local function addOption(block, key, value) + local option = optionDefs[key] + -- An option modoptions.lua does not declare has no default to compare against, so it + -- counts as adjusted: it was set by something outside the game's own list. + local changed = not (option and value == option.def) + if changed then + countChanges(block, 1) end - for i = 1, #hoverRows do - local row = hoverRows[i] - if y >= row[1] and y <= row[2] then - return row[3] + + local name = getModoptionName(key) + value = tostring(value) + block.entries[#block.entries + 1] = { + type = "option", + name = name, + value = value, + changed = changed, + nameLower = string.lower(name), + search = string.lower(name .. " " .. key .. " " .. value), + tooltip = { title = name, text = getModoptionTooltipText(key, changed) }, + } +end + +local function sortEntries(block) + table.sort(block.entries, function(a, b) + if a.nameLower == b.nameLower then + return a.name < b.name end + return a.nameLower < b.nameLower + end) +end + +local function startPosLabel() + if Game.startPosType == 0 then + return BAR.I18N("ui.gameInfo.startPosFixed") + elseif Game.startPosType == 1 then + return BAR.I18N("ui.gameInfo.startPosRandom") end + return BAR.I18N("ui.gameInfo.startPosChoose") end -function widget:DrawScreen() - -- draw the help - if not mainDList then - mainDList = gl.CreateList(DrawWindow) +-- What the map itself says, as opposed to the options set over it. +local function buildMapInfo(block) + -- The description can run to a paragraph, so it is the row's tooltip rather than a + -- value shortened to nothing beside the name. + addInfo( + block, + L.map, + Game.mapName, + Game.mapDescription and Game.mapDescription ~= "" and { title = Game.mapName, text = Game.mapDescription } + or nil + ) + addInfo(block, BAR.I18N("ui.gameInfo.size"), Game.mapX .. " x " .. Game.mapY) + addInfo(block, BAR.I18N("ui.gameInfo.startPositions"), startPosLabel()) + addInfo(block, BAR.I18N("ui.gameInfo.gravity"), Game.gravity) + addInfo(block, BAR.I18N("ui.gameInfo.hardness"), Game.mapHardness) + addInfo(block, BAR.I18N("ui.gameInfo.tidalStrength"), tidal) + addInfo( + block, + BAR.I18N("ui.gameInfo.windStrength"), + Game.windMin == Game.windMax and Game.windMin or (Game.windMin .. " - " .. Game.windMax) + ) + addInfo(block, BAR.I18N("ui.gameInfo.waterDamage"), Game.waterDamage) + addInfo(block, BAR.I18N("ui.gameInfo.reclaimableMetal"), reclaimableMetal) + addInfo(block, BAR.I18N("ui.gameInfo.reclaimableEnergy"), reclaimableEnergy) +end + +-- Splits every modoption that is set into the block it belongs to, then hands each block +-- its rows. Options are claimed in order of how specific their home is - the map, then a +-- tweak slot, then the section they were declared under - so none can appear twice. +local function buildContent() + blocks = {} + categories = {} + categoryIndex = {} + + -- The All entry stands for no filter at all, so it is the one category with no key and + -- is placed by hand rather than by a block asking for it. + categories[1] = { label = L.all } + + local gameBlock = addBlock("game", L.game, L.game) + addInfo(gameBlock, L.game, Game.gameName) + addInfo(gameBlock, BAR.I18N("ui.gameInfo.version"), Game.gameVersion) + addInfo(gameBlock, BAR.I18N("ui.gameInfo.mutator"), Game.gameMutator) + addInfo( + gameBlock, + BAR.I18N("ui.gameInfo.engine"), + (Game and Game.version) or (Engine and Engine.version) or BAR.I18N("ui.gameInfo.engineVersionError") + ) + + local mapInfoBlock = addBlock("map", L.map, L.mapInfo) + buildMapInfo(mapInfoBlock) + local mapSettingsBlock = addBlock("map", L.map, L.mapSettings) + + -- The section modoptions.lua titles "Other" is the same bucket as the one leftovers go + -- to, so it takes them rather than a second entry of the same name appearing beside it. + local otherSection + for _, key in ipairs(sectionOrder) do + if getSectionName(key) == L.other then + otherSection = key + end end - if show or showOnceMore then - -- draw the panel - glCallList(mainDList) - if WG.guishader then - if backgroundGuishader then - backgroundGuishader = glDeleteList(backgroundGuishader) - end - backgroundGuishader = glCreateList(function() - -- background - RectRound(screenX, screenY - screenHeight, screenX + screenWidth, screenY, elementCorner, 0, 1, 1, 1) - -- title - RectRound(titleRect[1], titleRect[2], titleRect[3], titleRect[4], elementCorner, 1, 1, 0, 0) - end) - WG.guishader.InsertDlist(backgroundGuishader, "gameinfo") - end - showOnceMore = false - - local x, y, pressed = Spring.GetMouseState() - if - math_isInRect(x, y, screenX, screenY - screenHeight, screenX + screenWidth, screenY) - or math_isInRect(x, y, titleRect[1], titleRect[2], titleRect[3], titleRect[4]) - then - Spring.SetMouseCursor("cursornormal") - - if WG.tooltip then - local tooltip = getHoveredTooltip(x, y) - if tooltip then - WG.tooltip.ShowTooltip("gameinfo", tooltip.text, nil, nil, tooltip.title) + local sectionKeys = {} + local tweakSlots = {} + local leftovers = {} + for key, value in pairs(modoptions) do + if not isIgnoredModoption(key) then + local option = optionDefs[key] + if option and not sectionApplies(option.section) then + -- another gamemode's option, inert here + elseif isMapOption(key) then + addOption(mapSettingsBlock, key, value) + else + local kind, slot = tweakSlot(key) + if kind then + -- Sixty of these are declared and all but a handful are empty, so a slot + -- earns a block only once something has been put in it. + if value ~= "" and not (option and value == option.def) then + tweakSlots[#tweakSlots + 1] = { kind = kind, slot = slot, key = key, value = value } + end + elseif option and sectionNames[option.section] and option.section ~= otherSection then + local keys = sectionKeys[option.section] + if not keys then + keys = {} + sectionKeys[option.section] = keys + end + keys[#keys + 1] = key + else + leftovers[#leftovers + 1] = key end end end - else - if backgroundGuishader then - if WG.guishader then - WG.guishader.RemoveDlist("gameinfo") + end + + sortEntries(mapSettingsBlock) + + -- Sections in the order modoptions.lua declares them, so the column reads the way the + -- lobby's own tabs do rather than in whatever order the values came back in. + for _, section in ipairs(sectionOrder) do + local keys = sectionKeys[section] + if keys then + local title = getSectionName(section) + local block = addBlock(section, title, title) + for _, key in ipairs(keys) do + addOption(block, key, modoptions[key]) end - backgroundGuishader = glDeleteList(backgroundGuishader) + sortEntries(block) end end -end -function widget:MouseWheel(up, value) - if show then - local addLines = value * -3 -- direction is retarded + -- The bucket for everything unclaimed, before the tweaks: those are a thing apart, and + -- they belong at the bottom of the column rather than in the middle of the sections. + if #leftovers > 0 then + local block = addBlock("other", L.other, L.other) + for _, key in ipairs(leftovers) do + addOption(block, key, modoptions[key]) + end + sortEntries(block) + end - startLine = startLine + addLines - if startLine > totalFileLines - maxLines then - startLine = totalFileLines - maxLines + -- The order the game applies them in, which gamedata/unitdefs_post.lua sorts for on + -- purpose: tweakunits first, then tweakdefs over the top of it, so a tweakdefs can + -- fine-tune what a tweakunits set. Reading them in that order is reading what happened. + -- Within a kind it is slot order, not key order: "tweakdefs10" sorts before + -- "tweakdefs2" as a string. + table.sort(tweakSlots, function(a, b) + if a.kind == b.kind then + return a.slot < b.slot end - if startLine < 1 then - startLine = 1 + return a.kind == "tweakunits" + end) + for _, tweak in ipairs(tweakSlots) do + local label = tweak.kind == "tweakdefs" and L.tweakDefs or L.tweakUnits + local block = addBlock(tweak.kind, label, getModoptionName(tweak.key)) + -- Kept as source lines rather than turned into rows here: how much of a line fits + -- is a question about the column, so layoutCodeBlocks answers it once that exists. + local source, failure = decodeTweak(tweak.kind, tweak.value) + block.source = source + -- A tweakdefs slot counts as the one thing that was changed however long it runs; a + -- tweakunits slot counts per unit it names, which is what a reader is counting. + local units = 0 + for _, line in ipairs(source) do + if line.unitDefID then + units = units + 1 + end end - - if mainDList then - glDeleteList(mainDList) + countChanges(block, mathMax(1, units)) + -- What the game reported when it tried to apply the tweak outranks anything worked + -- out from the source here: it is what actually happened rather than what should. + failure = defsTweakFailures[tweak.key] or failure + if failure then + -- Said at every level the eye lands on: the column entry, the heading, and a + -- line of its own carrying what the game actually reported. + markCategoryFailed(tweak.kind) + block.header.failed = true + block.header.name = block.title .. " - " .. L.tweakFailed + tableInsert(source, 1, { depth = 0, failed = true, parts = messageParts(failure) }) end + end - mainDList = gl.CreateList(DrawWindow) - return true - else - return false + -- All stands for every category at once, so its count is theirs added up. Summed here + -- rather than counted alongside them: it is the same adjustments, not more of them. + local total = 0 + for i = 2, #categories do + total = total + categories[i].changes end + categories[1].changes = total end -function widget:MousePress(x, y, button) - return mouseEvent(x, y, button, false) -end +---------------------------------------------------------------- +-- The row list +---------------------------------------------------------------- -function widget:MouseRelease(x, y, button) - return mouseEvent(x, y, button, true) +local function rowHeightOf(row) + if row.type == "header" then + return metrics.headerRowHeight + elseif row.type == "code" then + return metrics.codeRowHeight + end + return metrics.rowHeight end -function mouseEvent(x, y, button, release) - if Spring.IsGUIHidden() then - return false +-- A row's position is a sum of what is above it rather than its index times one height: +-- headings and code lines are not the height of an ordinary row. The running total is +-- stamped onto the rows, and redone when the list or the layout changes. +local function ensureRowMetrics() + if rowMetrics.gen == layoutGen and rowMetrics.rows == rowsGen then + return end - if show then - -- on window - if - math_isInRect(x, y, screenX, screenY - screenHeight, screenX + screenWidth, screenY) - or math_isInRect(x, y, titleRect[1], titleRect[2], titleRect[3], titleRect[4]) - then - return true - else - show = false - end + local off = 0 + for i = 1, #rows do + rows[i].off = off + off = off + rowHeightOf(rows[i]) end + rowMetrics.totalH = off + rowMetrics.gen, rowMetrics.rows = layoutGen, rowsGen end -function toggle() - local newShow = not show - if newShow and WG.topbar then - WG.topbar.hideWindows() - end - show = newShow +-- Pixels of content above the first painted row. +local function scrollOffset() + ensureRowMetrics() + local first = rows[scroll + 1] - return true + return first and first.off or 0 end -local function refreshContent() - content = "" - lineTooltips = {} - contentLineCount = 0 - - addContent( - titlecolor - .. Game.gameName - .. valuegreycolor - .. " (" - .. Game.gameMutator - .. ") " - .. titlecolor - .. Game.gameVersion - .. "\n" - ) - addContent( - keycolor - .. BAR.I18N("ui.gameInfo.engine") - .. separator - .. valuegreycolor - .. ((Game and Game.version) or (Engine and Engine.version) or BAR.I18N("ui.gameInfo.engineVersionError")) - .. "\n" - ) - addContent("\n") - - -- map info - addContent(titlecolor .. Game.mapName .. "\n") - addContent(valuegreycolor .. Game.mapDescription .. "\n") - addContent( - keycolor - .. BAR.I18N("ui.gameInfo.size") - .. separator - .. valuegreycolor - .. Game.mapX - .. valuegreycolor - .. " x " - .. valuegreycolor - .. Game.mapY - .. "\n" - ) - addContent(keycolor .. BAR.I18N("ui.gameInfo.gravity") .. separator .. valuegreycolor .. Game.gravity .. "\n") - addContent( - keycolor - .. BAR.I18N("ui.gameInfo.hardness") - .. separator - .. valuegreycolor - .. Game.mapHardness - .. keycolor - .. "\n" - ) - addContent( - keycolor .. BAR.I18N("ui.gameInfo.tidalStrength") .. separator .. valuegreycolor .. tidal .. keycolor .. "\n" - ) - addContent( - keycolor - .. BAR.I18N("ui.gameInfo.reclaimableMetal") - .. separator - .. valuegreycolor - .. reclaimable_metal - .. keycolor - .. "\n" - ) - addContent( - keycolor - .. BAR.I18N("ui.gameInfo.reclaimableEnergy") - .. separator - .. valuegreycolor - .. reclaimable_energy - .. keycolor - .. "\n" - ) +-- Furthest offset that still fills the band. Walked from the end, so it does not depend +-- on where the list is scrolled to now. +local function maxScroll() + ensureRowMetrics() + local band = listTop - listBottom + local used = 0 + local i = #rows + while i > 0 do + local h = rowHeightOf(rows[i]) + if used + h > band then + break + end + used = used + h + i = i - 1 + end - if Game.windMin == Game.windMax then - addContent( - keycolor - .. BAR.I18N("ui.gameInfo.windStrength") - .. separator - .. valuegreycolor - .. Game.windMin - .. valuegreycolor - .. "\n" - ) - else - addContent( - keycolor - .. BAR.I18N("ui.gameInfo.windStrength") - .. separator - .. valuegreycolor - .. Game.windMin - .. valuegreycolor - .. " - " - .. valuegreycolor - .. Game.windMax - .. "\n" - ) + return i +end + +local function clampScroll() + local top = maxScroll() + if scroll > top then + scroll = top end - local vcolor - if Game.waterDamage == 0 then - vcolor = valuegreycolor - else - vcolor = valuecolor + if scroll < 0 then + scroll = 0 end - addContent( - keycolor .. BAR.I18N("ui.gameInfo.waterDamage") .. separator .. vcolor .. Game.waterDamage .. keycolor .. "\n" - ) - addContent("\n") - if raptorsEnabled then - -- filter raptor modoptions - addContent(titlecolor .. BAR.I18N("ui.gameInfo.raptorOptions") .. "\n") - for _, params in ipairs(getModoptionRows(changedRaptorModoptions, true)) do - addContent(keycolor .. params.name .. separator .. valuecolor .. params.value .. "\n", params.tooltip) - end - for _, params in ipairs(getModoptionRows(unchangedRaptorModoptions)) do - addContent(keycolor .. params.name .. separator .. valuegreycolor .. params.value .. "\n", params.tooltip) - end - addContent("\n") +end + +-- The painted row under y, as its offset from the first painted one. Every hover test and +-- the panel signature go through this, so neither can disagree with what was drawn. nil +-- when y is outside the band or past the last whole row the band can hold. +local function rowAt(y) + ensureRowMetrics() + if y > listTop or y <= listBottom then + return nil end - local changedRows = getModoptionRows(changedModoptions, true) - if #changedRows > 0 then - addContent(titlecolor .. BAR.I18N("ui.gameInfo.adjustedSettings") .. "\n") - for _, params in ipairs(changedRows) do - addContent(keycolor .. params.name .. separator .. valuecolor .. params.value .. "\n", params.tooltip) + + local base = scrollOffset() + for i = scroll + 1, #rows do + local top = listTop - (rows[i].off - base) + local bottom = top - rowHeightOf(rows[i]) + if bottom < listBottom then + break end - addContent("\n") - end - local unchangedRows = getModoptionRows(unchangedModoptions) - if #unchangedRows > 0 then - addContent(titlecolor .. BAR.I18N("ui.gameInfo.settings") .. "\n") - for _, params in ipairs(unchangedRows) do - addContent(keycolor .. params.name .. separator .. valuegreycolor .. params.value .. "\n", params.tooltip) + -- Half-open on the shared edge: rows stack, so one row's top is the next one's + -- bottom and a closed test would put the cursor in both. + if y <= top and y > bottom then + return i - scroll end end - -- store changelog into array - fileLines = string.lines(content) - totalFileLines = #fileLines + return nil end -local function closeInfoHandler() - if show then - show = false +local function setScroll(n) + scroll = n + clampScroll() +end - return true +-- Indent per block level, and the extra step a line that had to be broken carries, so a +-- continuation is never mistaken for a statement of its own. +local codeIndent = " " +local codeContinue = " " + +-- One formatted line as its own text, indent and all, with nothing wrapped. This is what a +-- copy hands over: the column's wrapping is a fact about the panel, not about the source, +-- and source pasted back in pieces is not the code it came from. +local function sourceText(line) + local parts = {} + for i, part in ipairs(line.parts) do + parts[i] = i == 1 and (string.gsub(part.s, "^ ", "")) or part.s end + + return string.rep(codeIndent, mathMin(line.depth, 12)) .. table.concat(parts) end +---------------------------------------------------------------- +-- Selecting source +---------------------------------------------------------------- -local spGetAllFeatures = Spring.GetAllFeatures -local spGetFeatureResources = Spring.GetFeatureResources -local spGetFeatureTeam = Spring.GetFeatureTeam -local spGetGaiaTeamID = Spring.GetGaiaTeamID -local gaiaTeamId = spGetGaiaTeamID() +local function clearSelection() + selFrom, selTo, selecting = 0, 0, false +end -function widget:GamePreload() - for _, featureID in ipairs(spGetAllFeatures()) do - local metal, _, energy = spGetFeatureResources(featureID) - if spGetFeatureTeam(featureID) == gaiaTeamId then - reclaimable_metal = reclaimable_metal + metal - reclaimable_energy = reclaimable_energy + energy - end +-- The selected span, low end first, or nil when nothing is selected. +local function selectionRange() + if selFrom == 0 then + return nil end - refreshContent() + return mathMin(selFrom, selTo), mathMax(selFrom, selTo) end -function widget:Initialize() - refreshContent() - - widgetHandler:AddAction("customgameinfo", toggle, nil, "p") - widgetHandler:AddAction("customgameinfo_close", closeInfoHandler, nil, "p") +-- Puts the selected source on the clipboard, a whole line at a time and in the order it is +-- listed. Answers whether there was anything to copy. +local function copySelection() + local from, to = selectionRange() + if not from then + return false + end - WG.gameinfo = {} - WG.gameinfo.toggle = function(state) - local newShow = state - if newShow == nil then - newShow = not show + local out = {} + local lastBlock, lastLine + for i = from, to do + local row = rows[i] + -- One entry per source line: a line the column wrapped is several rows here, and all + -- of them point back at the one line they came from. + if row and row.srcBlock and (row.srcBlock ~= lastBlock or row.srcLine ~= lastLine) then + lastBlock, lastLine = row.srcBlock, row.srcLine + out[#out + 1] = sourceText(row.srcBlock.source[row.srcLine]) end - if newShow and WG.topbar then - WG.topbar.hideWindows() - end - show = newShow end - WG.gameinfo.isvisible = function() - return show + + if #out == 0 then + return false end - -- amount of modoptions that aren't set to their default value - WG.gameinfo.getChangedModoptionsCount = function() - return changedModoptionsCount + + Spring.SetClipboard(table.concat(out, "\n")) + + return true +end + +-- Every row of source the list is showing. In a tweak category that is the whole snippet, +-- which is what someone reaching for select-all is after. +local function selectAllCode() + local first, last + for i = 1, #rows do + if rows[i].type == "code" then + first = first or i + last = i + end + end + + if not first then + return false + end + + selFrom, selTo, selecting = first, last, false + + 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. +local function scrollFromY(y) + local f = (listTop - y) / mathMax(1, listTop - listBottom) + if f < 0 then + f = 0 + elseif f > 1 then + f = 1 + end + setScroll(mathFloor(f * maxScroll() + 0.5)) +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. +rebuildRows = function() + rows = {} + rowsGen = rowsGen + 1 + -- 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 "" + 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 + 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 entry.unitDefID then + shownOwner = entry.ownerUnitDefID + entry.needsOwner = nil + else + entry.needsOwner = (entry.ownerLabel and entry.ownerUnitDefID ~= shownOwner) or nil + end + rows[#rows + 1] = entry + end + end + if #rows > first then + tableInsert(rows, first + 1, block.header) + end + end + end + + clampScroll() +end + +---------------------------------------------------------------- +-- Layout +---------------------------------------------------------------- + +-- The category column starts below where the rows do, so the title above it is not +-- crowded by the first entry. Everything in the column measures from here. +local function sidebarTop() + return listTop - metrics.sidebarDrop +end + +local function categoryRect(i) + local top = sidebarTop() - (i - 1) * metrics.catRowHeight + + return area.x1, top - metrics.catRowHeight, area.x1 + metrics.sidebarW, top +end + +-- The category entry under x,y, or nil. Half-open on the shared edge, like the rows, so +-- one point never lands in two entries. +local function sidebarIndexAt(x, y) + local top = sidebarTop() + if x < area.x1 or x > area.x1 + metrics.sidebarW or y > top or y <= listBottom then + return nil + end + + local i = mathFloor((top - y) / metrics.catRowHeight) + 1 + if not categories[i] then + return nil + end + + local _, y1 = categoryRect(i) + if y1 < listBottom then + return nil + end + + return i +end + +-- Rebuilds every rect against the panel size. Whole pixels throughout, so glyph and +-- rectangle edges do not land between pixels. +local function setLayout() + local s = widgetScale + local pad = mathFloor(8 * s) + area.x1 = screenX + pad + area.y1 = screenY - screenHeight + pad + area.x2 = screenX + screenWidth - pad + area.y2 = screenY - pad + + metrics.rowHeight = mathFloor(24 * s) + metrics.catRowHeight = mathFloor(29 * s) + metrics.rowFs = mathFloor(metrics.rowHeight * 0.55) + metrics.headerRowHeight = mathFloor(metrics.rowHeight * 1.35) + metrics.headerFs = mathFloor(metrics.rowFs * 0.95 * 1.13) + metrics.catFs = mathFloor(metrics.catRowHeight * 0.55 * 0.85) + metrics.codeRowHeight = mathFloor(metrics.rowHeight * 0.78) + metrics.codeFs = mathFloor(metrics.codeRowHeight * 0.62) + metrics.iconTop = mathMax(1, mathFloor(6 * s)) + metrics.iconBottom = mathMax(1, mathFloor(3 * s)) + metrics.iconSize = metrics.codeRowHeight * 3 - metrics.iconTop - metrics.iconBottom + -- Small enough to sit on a single row, with the same air above and below it. + metrics.tinyIcon = metrics.codeRowHeight - mathMax(2, mathFloor(4 * s)) + metrics.underlineH = mathMax(1, mathFloor(2 * s)) + metrics.accentW = mathMax(2, mathFloor(3 * s)) + metrics.rowPad = mathFloor(6 * s) + metrics.sidePad = mathFloor(12 * s) + metrics.catInset = mathFloor(4 * s) + metrics.edgeInset = mathFloor(4 * s) + metrics.headerH = mathFloor(34 * s) + metrics.headerGap = mathFloor(4 * s) + metrics.listGap = mathFloor(12 * s) + metrics.cardLip = mathFloor(5 * s) + metrics.titleY = mathFloor(17 * s) + metrics.titleFs = mathFloor(metrics.rowHeight * 0.85) + metrics.sidebarDrop = mathFloor(8 * s) + metrics.sidebarW = mathFloor(240 * s) + metrics.barW = mathFloor(14 * s) + -- Rounded like the settings panel's inner elements, which take a share of this too. + metrics.csPanel = mathFloor(elementCorner) + metrics.csSmall = mathFloor(elementCorner * 0.66) + + listX1 = area.x1 + metrics.sidebarW + metrics.listGap + listTop = area.y2 - metrics.headerH - metrics.headerGap + listBottom = area.y1 + metrics.edgeInset + -- The scrollbar owns a column of its own: its right edge lines up with the toggle + -- above it, and the rows stop a clear gap short of it rather than running up against + -- it, so the bar sits in a channel rather than hugging them. + barX1 = area.x2 - metrics.edgeInset - metrics.barW + listRight = barX1 - metrics.listGap + 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) + local togW = mathFloor(38 * s) + local togH = mathFloor((rowTop - rowBottom) * 0.62) + 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 } + -- 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) + + layoutGen = layoutGen + 1 + clampScroll() +end + + +-- A unit's name, cut to the column reserved for it and padded out to it, so the source +-- beside it starts at the same place on every line however long the names are. +local function ownerLabel(name, width) + if #name > width - 2 then + name = string.sub(name, 1, width - 4) .. ".." end + return name .. string.rep(" ", width - #name) +end + +-- Greedy fill of one formatted source line into the code column: broken between tokens +-- where it can be, and mid-token where a single one is wider than the column, so nothing +-- is lost to a ".." at the edge. +local function wrapSource(line, budget, out, gutter, block, srcLine, ownerWidth) + -- Capped: source the formatter could not balance (an unclosed `do`, say) would + -- otherwise indent until there is no column left to write in. + local indent = string.rep(codeIndent, mathMin(line.depth, 12)) + local cont = indent .. codeContinue + -- Only the row the block opens on carries the picture; the rows it wraps onto must not + -- draw a second one over the first. + local unitDefID = line.unitDefID + local label = (line.ownerName and (ownerWidth or 0) > 0) and ownerLabel(line.ownerName, ownerWidth) or nil + -- Two copies of the line: what it measures as, and what it draws as. Colour codes are + -- four bytes the renderer never draws, so counting them against the column would wrap + -- the source far short of the edge. + local plain, painted = indent, indent + local empty = true + local lastColor + + local function emit() + out[#out + 1] = { + type = "code", + name = painted, + plain = plain, + failed = line.failed, + unitDefID = unitDefID, + gutter = gutter, + -- Whose block this line is inside, and its name ready to put in front of the line + -- on the rows where a search has left nothing else to say so. + ownerUnitDefID = line.ownerUnitDefID, + ownerLabel = label, + -- Where this row came from, so a copy can hand back whole source lines rather than + -- the pieces the column had to break them into. + srcBlock = block, + srcLine = srcLine, + -- The slot is only listed at all because it is set, so its lines are part of what + -- was adjusted and stay under the changed-only filter. + changed = true, + search = string.lower(plain), + } + unitDefID = nil + plain, painted = cont, cont + empty, lastColor = true, nil + end + + local function add(str, color) + plain = plain .. str + -- Only where it changes: a run of the same kind is one code, not one per token. + painted = painted .. (color ~= lastColor and color or "") .. str + lastColor = color + empty = false + end + + for _, part in ipairs(line.parts) do + local color = codeColors[part.k] or colorCode + local piece = empty and (string.gsub(part.s, "^ ", "")) or part.s + if not empty and #plain + #piece > budget then + emit() + piece = (string.gsub(part.s, "^ ", "")) + end + + -- Still over on its own - a long string, or a path inside a comment - so it is cut + -- where it lands rather than left to be clipped. + while #plain + #piece > budget do + local take = budget - #plain + if take < 1 then + break + end + -- Never mid-character: half a UTF-8 sequence draws as a replacement glyph. + while take > 1 and string.find(string.sub(piece, take + 1, take + 1), "^[\128-\191]") do + take = take - 1 + end + add(string.sub(piece, 1, take), color) + piece = string.sub(piece, take + 1) + emit() + end + + add(piece, color) + end + + if not empty then + emit() + end +end + +-- Code blocks become rows here rather than at build time, since how much of a line fits is +-- a question about the panel's width. The code face is monospaced, so that is a count of +-- characters rather than a measurement per line. +local function layoutCodeBlocks() + if not fontMono then + return + end + + local charW = mathMax(1, fontMono:GetTextWidth("0") * metrics.codeFs) + local column = listRight - listX1 - metrics.rowPad * 4 + + for _, block in ipairs(blocks) do + if block.source then + -- Tweakunits name the units they change, so those blocks get a gutter down their + -- left for the pictures and the source moves over to make room. A tweak that + -- named nothing the game knows has no pictures to show and keeps the full width. + local gutter = 0 + -- Room for the longest name in the block, since a search can put any of them in + -- front of a line. Reserved whether or not one is showing: the column then stays + -- where it is while a search is typed, instead of the source reflowing per key. + local owner = 0 + for _, line in ipairs(block.source) do + if line.unitDefID then + gutter = metrics.iconSize + end + if line.ownerName then + owner = mathMax(owner, mathMin(#line.ownerName, metrics.ownerChars)) + end + end + if owner > 0 then + owner = owner + 2 + end + + local budget = mathMax(24, mathFloor((column - gutter) / charW) - owner) + block.entries = {} + for i, line in ipairs(block.source) do + wrapSource(line, budget, block.entries, gutter, block, i, owner) + end + end + end +end + +-- Category labels, shortened to the column and carrying their colour codes, so the +-- sidebar draws them as they are. Redone when the column resizes. +local function fitCategories() + for _, c in ipairs(categories) do + -- How much of what the category lists is not the way the game ships, in the colour an + -- adjusted value takes in the list itself. The count is never shortened and the label + -- gives way to it: half a number says nothing, while a shortened name still does. + local count = (c.changes or 0) > 0 and tostring(c.changes) or nil + c.countText = count and (colorValueOn .. count) or nil + local countW = count and (mathFloor(font:GetTextWidth(count) * metrics.catFs) + metrics.rowPad) or 0 + + local fitted = text.fit(font, c.label, metrics.sidebarW - metrics.sidePad * 2 - countW, metrics.catFs) + -- A category holding a tweak the game refused stays red whether it is the selected + -- one or not: it is a warning, not a state of the column. + c.textSel = (c.failed and colorDanger or colorSelected) .. fitted + c.textDim = (c.failed and colorDanger or colorDim) .. fitted + c.fitGen = layoutGen + end +end + +-- A row's text, shortened to the columns it is drawn in and coloured for whether its +-- value is the default one. Cached against the layout, so a resize measures it again and +-- a scroll does not. +local function fitRow(row) + -- The owner prefix comes and goes with the search while the layout stands still, so it + -- is part of what the cached text was built for. + local owner = row.needsOwner and true or false + if row.fitGen == layoutGen and row.fitOwner == owner then + return + end + row.fitGen, row.fitOwner = layoutGen, owner + + if row.type == "header" then + row.fitName = (row.failed and colorDanger or colorHeader) + .. text.fit(font, row.name, listRight - listX1 - metrics.rowPad * 2, metrics.headerFs) + elseif row.type == "code" then + -- Already coloured token by token, and already cut to the column, when the source was + -- wrapped in this same layout pass. A failure overrides the highlighting: it has to + -- read as something that did not work, not as source that did. + row.fitName = row.failed and (colorDanger .. row.plain) or row.name + if owner then + -- The column this goes in was taken out of the wrap budget, so the source beside + -- it still ends where every other line of the block does. + row.fitName = codeColors.call .. row.ownerLabel .. row.fitName + end + else + -- Map and engine facts are neither adjusted nor left at a default, so they take the + -- plain reading colour rather than either of the two the options are told apart by. + local valueColor = colorInfo + if row.type == "option" then + valueColor = row.changed and colorValueOn or colorValue + end + row.fitName = (row.changed and colorNameOn or colorName) + .. text.fit(font, row.name, valueX1 - listX1 - metrics.rowPad * 3, metrics.rowFs) + row.fitValue = valueColor + .. text.fit(font, row.value, listRight - valueX1 - metrics.rowPad * 2, metrics.rowFs) + end +end + +---------------------------------------------------------------- +-- Drawing +---------------------------------------------------------------- + +-- Geometry drawn between font:Begin and font:End interleaves with the font's batched +-- glyphs and makes both flicker. The list alternates shapes and text row by row, so it +-- queues here and flushes once all the shapes are down. Held flat and refilled in place, +-- since a table per string per frame is hundreds of allocations a second. +local pending = { {}, {} } +local pendingCount = { 0, 0 } + +-- The unit pictures are not baked with the rest of the panel: they bind a texture apiece, +-- and a display list is the wrong place for that. The rows note where each one goes while +-- the panel is baked, and the draw below replays those positions live - which is correct +-- for as long as the baked panel is, since anything that moves a row rebuilds both. +local pendingIcons = {} +local iconCount = 0 + +local function queueIcon(unitDefID, x, y, size) + local at = iconCount * 4 + pendingIcons[at + 1] = x + pendingIcons[at + 2] = y + pendingIcons[at + 3] = unitDefID + -- Its own size: a block opens with a picture three rows tall, while a line a search + -- pulled out of one carries a picture small enough to sit on that line. + pendingIcons[at + 4] = size + iconCount = iconCount + 1 +end + +-- Drawn the way the build menu draws its own cells, at the zoom it uses for an idle one. +local function drawIcons() + if iconCount == 0 then + return + end + + -- A picture is three rows tall and hangs off a row that can be the last one the band had + -- room for, so the band is what it is allowed to paint in. + gl.Scissor(listX1, listBottom, area.x2 - listX1, listTop - listBottom) + for i = 0, iconCount - 1 do + local at = i * 4 + local x, y, size = pendingIcons[at + 1], pendingIcons[at + 2], pendingIcons[at + 4] + -- Before every one of them, the way the build menu does it: the frame Draw.Unit lays + -- over the picture is a gradient, and it leaves its last colour behind. Setting white + -- once outside the loop leaves every picture after the first modulated by that. + glColor(1, 1, 1, 1) + UiUnit( + x, + y - size, + x + size, + y, + nil, + 1, + 1, + 1, + 1, + iconZoom, + nil, + nil, + "#" .. pendingIcons[at + 3] + ) + end + gl.Scissor(false) + glColor(1, 1, 1, 1) +end + +local function queueText(str, x, y, size, opts, which) + local n = pendingCount[which] + local at = n * 5 + local list = pending[which] + list[at + 1] = str + list[at + 2] = x + list[at + 3] = y + list[at + 4] = size + list[at + 5] = opts + pendingCount[which] = n + 1 +end + +local function flushText(which, face) + local n = pendingCount[which] + if n == 0 then + return + end + + local list = pending[which] + face:Begin() + for i = 0, n - 1 do + local at = i * 5 + face:Print(list[at + 1], list[at + 2], list[at + 3], list[at + 4], list[at + 5]) + end + face:End() + + pendingCount[which] = 0 +end + +-- The band a category heading sits on: the sheen and the line closing it off underneath, +-- so the heading closes off the block above it rather than floating in the middle. +local function drawHeaderBand(top, bottom, caption, failed) + RectRound(listX1, bottom, listRight, top - metrics.csSmall, metrics.csSmall, 1, 1, 0, 0, look.sheenTop, look.sheenTop) + RectRound( + listX1, + bottom, + listRight, + bottom + metrics.underlineH, + 0, + 0, + 0, + 0, + 0, + failed and look.failedLine or look.headerLine, + failed and look.failedLineFade or look.headerLineFade + ) + queueText(caption, listX1 + metrics.rowPad, mathFloor((top + bottom) * 0.5), metrics.headerFs, "ov", 1) +end + +local function drawRow(row, top, bottom, hovered, selected) + fitRow(row) + local cy = mathFloor((top + bottom) * 0.5) + + if row.type == "header" then + drawHeaderBand(top, bottom, row.fitName, row.failed) + return + end + + if row.type == "code" then + if row.failed then + RectRound(listX1, bottom, listRight, top, metrics.csSmall, 1, 1, 1, 1, look.failedFill) + RectRound(listX1, bottom, listX1 + metrics.accentW, top, 0, 0, 0, 0, 0, look.failedAccent) + end + if selected then + -- Stops short of the pictures. The gutter belongs to them, and a band running + -- under one would read as though the unit were selected rather than the lines + -- beside it; flush against the picture it would read as part of its frame, so it + -- keeps the same gap from it that it leaves before the source. + local gutter = row.gutter or 0 + RectRound( + listX1 + (gutter > 0 and metrics.rowPad * 2 + gutter or 0), + bottom, + listRight, + top, + 0, + 0, + 0, + 0, + 0, + look.codeSelectedFill + ) + end + if row.unitDefID then + queueIcon(row.unitDefID, listX1 + metrics.rowPad, top - metrics.iconTop, metrics.iconSize) + elseif row.needsOwner then + -- Against the right of the gutter, so it reads as belonging to the name it is in + -- front of rather than floating out at the panel edge. + local size = metrics.tinyIcon + queueIcon( + row.ownerUnitDefID, + listX1 + metrics.rowPad + (row.gutter or 0) - size, + mathFloor(cy + size * 0.5), + size + ) + end + queueText(row.fitName, listX1 + metrics.rowPad * 3 + (row.gutter or 0), cy, metrics.codeFs, "ov", 2) + + return + end + + if row.changed then + RectRound(listX1, bottom, listRight, top, metrics.csSmall, 1, 1, 1, 1, look.changedFill) + RectRound(listX1, bottom, listX1 + metrics.accentW, top, 0, 0, 0, 0, 0, look.changedAccent) + end + if hovered then + Highlight(listX1, bottom, listRight, top, metrics.csSmall, look.rowHoverOpacity, look.white) + end + + -- Indented past the accent bar and under the heading above them, so the block reads as + -- a group rather than as rows that happen to follow a caption. + queueText(row.fitName, listX1 + metrics.rowPad * 2, cy, metrics.rowFs, "ov", 1) + queueText(row.fitValue, valueX1, cy, metrics.rowFs, "ov", 1) +end + +-- Whole rows only: the band can end mid-row, and a row painted below it would be clipped +-- by nothing. +local function drawRows() + local base = scrollOffset() + local selLow, selHigh = selectionRange() + for i = 1, #rows - scroll do + local row = rows[scroll + i] + if not row then + break + end + local top = listTop - (row.off - base) + local bottom = top - rowHeightOf(row) + if bottom < listBottom then + break + end + drawRow(row, top, bottom, hover.row == i, selLow and scroll + i >= selLow and scroll + i <= selHigh) + end +end + +-- The category column: its own card under the title, then one entry per category. +local function drawSidebar() + -- Derived from the first category rather than measured from the panel top, so the card + -- keeps its lip above the entries wherever the column starts. + RectRound( + area.x1, + area.y1, + area.x1 + metrics.sidebarW, + sidebarTop() + metrics.cardLip, + metrics.csPanel, + 1, + 1, + 1, + 1, + look.sidebarFill, + look.sidebarFillTop + ) + queueText(L.titleText, area.x1 + metrics.sidePad, area.y2 - metrics.titleY, metrics.titleFs, "ov", 1) + + if categories[1] and categories[1].fitGen ~= layoutGen then + fitCategories() + end + + for i, c in ipairs(categories) do + local x1, y1, x2, y2 = categoryRect(i) + if y1 < listBottom then + break + end + local selected = selectedCategory == c.key + if selected then + RectRound(x1 + metrics.catInset, y1, x2 - metrics.catInset, y2, metrics.csSmall, 1, 1, 1, 1, look.selectedFill) + elseif i == hover.sb then + Highlight(x1 + metrics.catInset, y1, x2 - metrics.catInset, y2, metrics.csSmall, look.rowHoverOpacity, look.white) + end + local ty = mathFloor((y1 + y2) * 0.5) + queueText(selected and c.textSel or c.textDim, x1 + metrics.sidePad, ty, metrics.catFs, "ov", 1) + if c.countText then + queueText(c.countText, x2 - metrics.sidePad, ty, metrics.catFs, "rov", 1) + end + end +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() + if hover.tog == 1 then + Highlight( + toggleHit[1] - metrics.rowPad, + toggleHit[2], + toggleHit[3] + metrics.rowPad, + toggleHit[4], + metrics.csSmall, + look.rowHoverOpacity, + look.white + ) + end + UiToggle(toggleDraw[1], toggleDraw[2], toggleDraw[3], toggleDraw[4], changedOnly) + queueText( + (changedOnly and colorSelected or colorDim) .. L.changedOnly, + toggleDraw[1] - metrics.rowPad, + mathFloor((toggleHit[2] + toggleHit[4]) * 0.5), + mathFloor(metrics.rowFs * 1.05), + "rov", + 1 + ) +end + +-- Everything inside the panel: the column, the header controls, the list and the +-- scroller. Baked and replayed until the cursor, the list or the screen moves. +local function drawPanel() + iconCount = 0 + drawSidebar() + drawHeader() + drawRows() + + local base = scrollOffset() + if rowMetrics.totalH > 0 then + UiScroller(barX1, listBottom, area.x2 - metrics.edgeInset, listTop, rowMetrics.totalH, base) + end + + flushText(1, font) + flushText(2, fontMono) + + -- The toggle's glow leaves its own tint as the current colour, and this list is + -- replayed every frame, so the leak would reach whoever draws next. + glColor(1, 1, 1, 1) +end + +local function drawWindow() + UiElement( + screenX, + screenY - screenHeight, + screenX + screenWidth, + screenY, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + WG.FlowUI.clampedOpacity + ) +end + +local function dropLists() + if panelList then + glDeleteList(panelList) + panelList = nil + panelSig = nil + end + if windowList then + glDeleteList(windowList) + windowList = nil + end +end + +local function deleteGuishader() + if backgroundGuishader ~= nil then + if WG.guishader then + WG.guishader.DeleteDlist("gameinfo") + else + glDeleteList(backgroundGuishader) + end + backgroundGuishader = nil + end +end + +-- Reads the hover state and answers a signature of everything the baked panel is painted +-- from. Same signature, same picture, so the display list is replayed as it is. +local function panelSignature(mx, my) + hover.sb = sidebarIndexAt(mx, my) or 0 + hover.row = 0 + hover.tog = 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 + end + + return hover.sb + .. "|" .. hover.row + .. "|" .. hover.tog + .. "|" .. scroll + .. "|" .. rowsGen + .. "|" .. layoutGen + .. "|" .. selFrom + .. "|" .. selTo +end + +---------------------------------------------------------------- +-- Callins +---------------------------------------------------------------- + +local function refreshContent() + buildContent() + layoutCodeBlocks() + rebuildRows() +end + +local function loadLabels() + L.title = BAR.I18N("ui.gameInfo.title") + L.all = BAR.I18N("ui.gameInfo.all") + L.other = BAR.I18N("categories.other") + L.search = BAR.I18N("ui.gameInfo.search") + L.changedOnly = BAR.I18N("ui.gameInfo.changedOnly") + L.tweakFailed = BAR.I18N("ui.gameInfo.tweakFailed") + L.was = BAR.I18N("ui.gameInfo.was") + L.wasNew = BAR.I18N("ui.gameInfo.wasNew") + -- No title: it would only name the panel the hint is already inside, where an option's + -- tooltip has a name of its own worth heading. + L.copyHint = { text = BAR.I18N("ui.gameInfo.copyHint") } + L.game = BAR.I18N("ui.gameInfo.game") + L.map = BAR.I18N("ui.gameInfo.map") + L.mapInfo = BAR.I18N("ui.gameInfo.mapInfo") + L.mapSettings = BAR.I18N("ui.gameInfo.mapSettings") + L.tweakDefs = getModoptionName("tweakdefs") + L.tweakUnits = getModoptionName("tweakunits") + L.titleText = colorTitle .. L.title +end + +function widget:ViewResize() + vsx, vsy = spGetViewGeometry() + widgetScale = (vsy / 1080) + + screenHeight = mathFloor(screenHeightOrg * widgetScale) + screenWidth = mathFloor(screenWidthOrg * widgetScale) + screenX = mathFloor((vsx * 0.5) - (screenWidth / 2)) + screenY = mathFloor((vsy * 0.5) + (screenHeight / 2)) + + font = WG.fonts.getFont() + -- Decoded tweaks are source, so they take the monospaced face the changelog gives code. + fontMono = WG.fonts.getFont(3) + elementCorner = WG.FlowUI.elementCorner + + RectRound = WG.FlowUI.Draw.RectRound + UiElement = WG.FlowUI.Draw.Element + UiScroller = WG.FlowUI.Draw.Scroller + Highlight = WG.FlowUI.Draw.SelectHighlight + UiToggle = WG.FlowUI.Draw.Toggle + UiUnit = WG.FlowUI.Draw.Unit + + if not searchBox then + searchBox = Editbox.new({ placeholder = L.search, onChange = function() + setScroll(0) + rebuildRows() + end }) + end + + setLayout() + -- The code column moved, so how much of a source line fits it did too. + layoutCodeBlocks() + rebuildRows() + dropLists() + deleteGuishader() +end + +function widget:DrawScreen() + if not (show or showOnceMore) then + deleteGuishader() + return + end + + -- Pinned rather than assumed: widgets on lower layers draw first and leave blending, + -- colour and the texture wherever they finished. + glTexture(false) + glColor(1, 1, 1, 1) + + local mx, my, lmb = spGetMouseState() + if dragging then + if lmb then + scrollFromY(my) + else + dragging = false + end + end + + if selecting then + if lmb then + -- Only over source: dragging off the end of a snippet holds the selection where it + -- was rather than reaching into whatever block follows. + local r = rowAt(my) + local row = r and rows[scroll + r] + if row and row.type == "code" then + selTo = scroll + r + end + else + selecting = false + end + end + + local sig = panelSignature(show and mx or -1, show and my or -1) + if sig ~= panelSig then + if panelList then + glDeleteList(panelList) + end + panelList = glCreateList(drawPanel) + panelSig = sig + end + + if not windowList then + windowList = glCreateList(drawWindow) + end + glCallList(windowList) + glCallList(panelList) + drawIcons() + if show then + searchBox:draw() + end + + if WG.guishader and backgroundGuishader == nil then + backgroundGuishader = glCreateList(function() + RectRound(screenX, screenY - screenHeight, screenX + screenWidth, screenY, elementCorner, 1, 1, 1, 1) + end) + WG.guishader.InsertDlist(backgroundGuishader, "gameinfo") + end + showOnceMore = false + + if math_isInRect(mx, my, screenX, screenY - screenHeight, screenX + screenWidth, screenY) then + Spring.SetMouseCursor("cursornormal") + + local row = hover.row > 0 and rows[scroll + hover.row] + -- Source says how to take it somewhere else, since nothing about a row of it looks + -- like something you could drag across. + local tip = row and (row.tooltip or (row.type == "code" and L.copyHint)) + if tip and WG.tooltip then + WG.tooltip.ShowTooltip("gameinfo", tip.text, nil, nil, tip.title) + end + end +end + +local function closePanel() + show = false + clearSelection() + if searchBox then + searchBox:blur() + end + if WG.tooltip then + WG.tooltip.RemoveTooltip("gameinfo") + end +end + +-- Keys are only taken while the search field has focus. This is a panel to read, not one +-- to edit in, so leaving it open must not cost the player their hotkeys - unlike the +-- keybind editor, which has to swallow everything because it is editing the binds. +function widget:KeyPress(key) + if not show then + return false + 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 + end + + -- Copy and select-all are taken only while there is source selected, which is also the + -- only time this panel owns the keyboard. Both are bound to something in the shipped + -- profiles - Ctrl+C focuses the commander - so without that ownership the action handler + -- would have had them first. + local _, ctrl = Spring.GetModKeyState() + if ctrl and (key == KEYSYMS.C or key == KEYSYMS.A) then + local took = key == KEYSYMS.C and copySelection() or (key == KEYSYMS.A and selectAllCode()) + if took then + consumedKeys[key] = true + + return true + 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 + +-- The engine runs bound actions on release as well as on press, so a release has to be +-- swallowed wherever its press was. A key already down when the field took focus is let +-- through instead, or whatever it started stays stuck on. +function widget:KeyRelease(key) + -- A shortcut taken on the way down has to be taken on the way up too, or the action it + -- was bound to fires its release half anyway. + if consumedKeys[key] then + consumedKeys[key] = nil + + return true + end + + if not show or not searchBox:isFocused() then + return false + end + + if heldAtFocus[key] then + heldAtFocus[key] = nil + + return false + end + + return true +end + +function widget:TextInput(utf8char) + if show and searchBox:isFocused() then + return searchBox:textInput(utf8char) + end + + return false +end + +-- Swallowed across the whole panel, not just the list: a wheel that gets through zooms +-- the camera behind it. +function widget:MouseWheel(up, _value) + if not show then + return false + end + + local x, y = spGetMouseState() + if not math_isInRect(x, y, screenX, screenY - screenHeight, screenX + screenWidth, screenY) then + return false + end + + setScroll(scroll + (up and -metrics.wheelRows or metrics.wheelRows)) + + return true +end + +local function selectCategory(key) + if selectedCategory == key then + return + end + selectedCategory = key + setScroll(0) + rebuildRows() + if playSounds then + Spring.PlaySoundFile(buttonclick, 0.6, "ui") + end +end + +-- Clicks inside the panel go to the field, the toggle, the column or the scrollbar; a +-- press outside closes it. +local function mouseEvent(x, y, button, release) + if spIsGUIHidden() then + return false + end + + if not show then + return false + end + + -- A press on a top bar button is the top bar's to handle: it closes the open windows + -- and opens the one that was clicked. Closing (and consuming) here would swallow it. + if WG.topbar and WG.topbar.buttonAt and WG.topbar.buttonAt(x, y) then + return false + end + + if math_isInRect(x, y, screenX, screenY - screenHeight, screenX + screenWidth, screenY) then + if not release and button == 1 then + if searchBox:mousePress(x, y) then + clearSelection() + else + searchBox:blur() + + local i = sidebarIndexAt(x, y) + if math_isInRect(x, y, toggleHit[1], toggleHit[2], toggleHit[3], toggleHit[4]) then + clearSelection() + changedOnly = not changedOnly + setScroll(0) + rebuildRows() + if playSounds then + Spring.PlaySoundFile(buttonclick, 0.6, "ui") + end + elseif i then + selectCategory(categories[i].key) + elseif math_isInRect(x, y, barX1, listBottom, area.x2, listTop) then + -- 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) + 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. + local r = rowAt(y) + local row = r and rows[scroll + r] + if row and row.type == "code" then + selFrom, selTo = scroll + r, scroll + r + selecting = true + else + clearSelection() + end + end + end + end + + return true + elseif not release then + -- Only a press outside closes. A release out here belongs to a drag that started + -- on the scrollbar. + showOnceMore = true -- show once more because the guishader lags behind + closePanel() + + return true + end +end + +function widget:MousePress(x, y, button) + return mouseEvent(x, y, button, false) +end + +function widget:MouseRelease(x, y, button) + return mouseEvent(x, y, button, true) +end + +-- Holds input ownership while the search field has focus, or while source is selected. +-- Text ownership is what puts this panel ahead of actionHandler, which otherwise runs +-- before every widget: it would fire the keybinds being typed over, and it has Ctrl+C. +function widget:Update() + if show and searchBox and (searchBox:isFocused() or selectionRange()) then + if not fieldHasInput then + fieldHasInput = true + heldAtFocus = Spring.GetPressedKeys and Spring.GetPressedKeys() or {} + ownsInput = widgetHandler:OwnText() + + -- Chat has to be asked to let go, and toggling its input flag is the only public + -- way to make it cancel. The flag goes straight back because gui_chat persists it, + -- and a config save while the field is focused would leave chat input dead next + -- launch. + if not ownsInput and WG.chat and WG.chat.isInputActive and WG.chat.isInputActive() then + WG.chat.setHandleInput(false) + WG.chat.setHandleInput(true) + ownsInput = widgetHandler:OwnText() + end + elseif not ownsInput then + ownsInput = widgetHandler:OwnText() + end + + -- Only once it is ours. Starting SDL text input for a field we never took, then + -- stopping it again on the way out, is what kills that field. + if ownsInput and not textInputStarted then + textInputStarted = true + if Spring.SDLStartTextInput then + Spring.SDLStartTextInput() + end + end + elseif fieldHasInput then + fieldHasInput = false + if ownsInput then + ownsInput = false + widgetHandler:DisownText() + end + if textInputStarted then + textInputStarted = false + if Spring.SDLStopTextInput then + Spring.SDLStopTextInput() + end + end + end +end + +local spGetAllFeatures = Spring.GetAllFeatures +local spGetFeatureResources = Spring.GetFeatureResources +local spGetFeatureTeam = Spring.GetFeatureTeam +local gaiaTeamId = Spring.GetGaiaTeamID() + +function widget:GamePreload() + for _, featureID in ipairs(spGetAllFeatures()) do + local metal, _, energy = spGetFeatureResources(featureID) + if spGetFeatureTeam(featureID) == gaiaTeamId then + reclaimableMetal = reclaimableMetal + metal + reclaimableEnergy = reclaimableEnergy + energy + end + end + + refreshContent() +end + +-- Single way in and out, so the action, the top bar and a keybind all open the panel the +-- same way: opening hands the other windows to the top bar to close first. +local function setShown(wanted) + if not wanted then + closePanel() + + return + end + + if not show and WG.topbar then + WG.topbar.hideWindows() + end + show = true +end + +function widget:Initialize() + loadLabels() widget:ViewResize() + refreshContent() + + widgetHandler:AddAction("customgameinfo", function() + setShown(not show) + + return true + end, nil, "p") + + widgetHandler:AddAction("customgameinfo_close", function() + if show then + setShown(false) + + return true + end + end, nil, "p") + + WG.gameinfo = {} + WG.gameinfo.toggle = function(state) + if state == nil then + state = not show + end + setShown(state) + end + WG.gameinfo.isvisible = function() + return show + end + -- amount of modoptions that aren't set to their default value + WG.gameinfo.getChangedModoptionsCount = function() + -- The same total the column's All entry shows, so the badge and the panel can never + -- disagree about how much of this game is not the way it ships. + return categories[1] and categories[1].changes or 0 + end end function widget:Shutdown() - if mainDList then - glDeleteList(mainDList) - mainDList = nil + if searchBox then + searchBox:blur() end - if WG.guishader then - WG.guishader.RemoveDlist("gameinfo") + widgetHandler:DisownText() + if ownsInput then + ownsInput = false + if Spring.SDLStopTextInput then + Spring.SDLStopTextInput() + end end + dropLists() + deleteGuishader() if WG.tooltip then WG.tooltip.RemoveTooltip("gameinfo") end - if backgroundGuishader then - glDeleteList(backgroundGuishader) - end end function widget:LanguageChanged() + loadLabels() + if searchBox then + -- The field keeps the coloured placeholder it was built with; both have to go for + -- the new language's wording to be the one drawn. + searchBox.placeholder = L.search + searchBox.placeholderShown = nil + end refreshContent() widget:ViewResize() end diff --git a/luaui/Widgets/gui_keybind_info.lua b/luaui/Widgets/gui_keybind_info.lua index b23007e5df0..4dffe22de4d 100644 --- a/luaui/Widgets/gui_keybind_info.lua +++ b/luaui/Widgets/gui_keybind_info.lua @@ -226,6 +226,12 @@ local function mouseEvent(x, y, button, release) end if show then + -- A press on a top bar button is the top bar's to handle: it closes the open windows + -- and opens the one that was clicked. Closing (and consuming) here would swallow it. + if WG.topbar and WG.topbar.buttonAt and WG.topbar.buttonAt(x, y) then + return false + end + if math_isInRect(x, y, screenX, screenY - screenHeight, screenX + screenWidth, screenY) then if not release then keybindEditor.mousePress(x, y, button) diff --git a/luaui/Widgets/gui_mission_info.lua b/luaui/Widgets/gui_mission_info.lua index a865598b784..3ba0b08905b 100644 --- a/luaui/Widgets/gui_mission_info.lua +++ b/luaui/Widgets/gui_mission_info.lua @@ -423,6 +423,12 @@ function mouseEvent(x, y, button, release) return end if show then + -- A press on a top bar button is the top bar's to handle: it closes the open windows + -- and opens the one that was clicked. Closing (and consuming) here would swallow it. + if WG.topbar and WG.topbar.buttonAt and WG.topbar.buttonAt(x, y) then + return false + end + -- Inside main panel: consume the click if math_isInRect(x, y, screenX, screenY - screenHeight, screenX + screenWidth, screenY) then return true diff --git a/luaui/Widgets/gui_options.lua b/luaui/Widgets/gui_options.lua index c2cc1ddb403..e72d8037f93 100644 --- a/luaui/Widgets/gui_options.lua +++ b/luaui/Widgets/gui_options.lua @@ -2488,6 +2488,12 @@ function mouseEvent(mx, my, button, release) end if show then + -- A press on a top bar button is the top bar's to handle: it closes the open windows + -- and opens the one that was clicked. Closing (and consuming) here would swallow it. + if WG.topbar and WG.topbar.buttonAt and WG.topbar.buttonAt(mx, my) then + return false + end + local windowClick = (math_isInRect(mx, my, windowRect[1], windowRect[2], windowRect[3], windowRect[4])) local titleClick = (titleRect and math_isInRect(mx, my, titleRect[1], titleRect[2], titleRect[3], titleRect[4])) local chatinputClick = ( diff --git a/luaui/Widgets/gui_scavenger_info.lua b/luaui/Widgets/gui_scavenger_info.lua index 81e3f8d6079..f5f5847678a 100644 --- a/luaui/Widgets/gui_scavenger_info.lua +++ b/luaui/Widgets/gui_scavenger_info.lua @@ -292,6 +292,12 @@ function mouseEvent(x, y, button, release) end if show then + -- A press on a top bar button is the top bar's to handle: it closes the open windows + -- and opens the one that was clicked. Closing (and consuming) here would swallow it. + if WG.topbar and WG.topbar.buttonAt and WG.topbar.buttonAt(x, y) then + return false + end + -- on window if math_isInRect(x, y, screenX, screenY - screenHeight, screenX + screenWidth, screenY) then return true diff --git a/luaui/Widgets/gui_teamstats.lua b/luaui/Widgets/gui_teamstats.lua index 4b6274b6283..cbea9bf4e55 100644 --- a/luaui/Widgets/gui_teamstats.lua +++ b/luaui/Widgets/gui_teamstats.lua @@ -499,6 +499,12 @@ function widget:MouseRelease(mx, my, button) end function mouseEvent(mx, my, button, release) + -- A press on a top bar button is the top bar's to handle: it closes the open windows + -- and opens the one that was clicked. Closing (and consuming) here would swallow it. + if WG.topbar and WG.topbar.buttonAt and WG.topbar.buttonAt(mx, my) then + return false + end + local boxType = isAbove({ x = mx, y = my }, guiData) if not boxType and guiData.mainPanel.visible then if release then diff --git a/luaui/Widgets/gui_top_bar.lua b/luaui/Widgets/gui_top_bar.lua index 0aa3699cadd..765766976f1 100644 --- a/luaui/Widgets/gui_top_bar.lua +++ b/luaui/Widgets/gui_top_bar.lua @@ -437,7 +437,7 @@ local function updateButtons() addButton("stats", BAR.I18N("ui.topbar.button.stats")) end -- only shown when settings differ from their default, the amount of them is put in the badge - if WG.gameinfo and not isSinglePlayer then + if WG.gameinfo and (not isSinglePlayer or BAR.Utilities.ShowDevUI()) then local changedCount = WG.gameinfo.getChangedModoptionsCount and WG.gameinfo.getChangedModoptionsCount() or 0 if changedCount > 0 then addButton("info", BAR.I18N("ui.topbar.button.info"), tostring(changedCount)) @@ -3174,24 +3174,34 @@ function widget:MouseMove(x, y) adjustSliders(x, y) end +-- Second return value: the window refused to close (the keybind editor raises a guard +-- when there are unsaved edits) and is still up. local function closeWindow(name) if WG[name] ~= nil and WG[name].isvisible() then WG[name].toggle(false) - return true + return true, WG[name].isvisible() == true end - return false + return false, false end local function hideWindows() local closedWindow = false - closedWindow = closeWindow("options") or closedWindow - closedWindow = closeWindow("scavengerinfo") or closedWindow - closedWindow = closeWindow("missioninfo") or closedWindow - closedWindow = closeWindow("keybinds") or closedWindow - closedWindow = closeWindow("changelog") or closedWindow - closedWindow = closeWindow("gameinfo") or closedWindow - closedWindow = closeWindow("teamstats") or closedWindow - closedWindow = closeWindow("widgetselector") or closedWindow + local stillOpen = false + + local function hide(name) + local closed, blocked = closeWindow(name) + closedWindow = closed or closedWindow + stillOpen = blocked or stillOpen + end + + hide("options") + hide("scavengerinfo") + hide("missioninfo") + hide("keybinds") + hide("changelog") + hide("gameinfo") + hide("teamstats") + hide("widgetselector") if showQuitscreen then closedWindow = true end @@ -3208,7 +3218,7 @@ local function hideWindows() graphsWindowVisible = false end - return closedWindow + return closedWindow, stillOpen end local function toggleWindow(name) @@ -3216,13 +3226,33 @@ local function toggleWindow(name) if WG[name] ~= nil then isvisible = WG[name].isvisible() end - hideWindows() + local _, stillOpen = hideWindows() + -- Opening another window on top of a window that refused to close would bury its guard. + if stillOpen then + return isvisible + end if WG[name] ~= nil and isvisible ~= true then WG[name].toggle() end return isvisible end +-- Which menu button, if any, sits under these screen coords. +-- Exposed as WG.topbar.buttonAt so the window widgets (which sit on a lower layer and +-- therefore see the click first) can tell a click on the top bar apart from a click that +-- dismisses them: closing themselves there would eat the click meant to open another window. +local function buttonAt(x, y) + if not buttonsArea.buttons then + return nil + end + for name, pos in pairs(buttonsArea.buttons) do + if mathIsInRect(x, y, pos[1], pos[2], pos[3], pos[4]) then + return name + end + end + return nil +end + local function applyButtonAction(button) if playSounds then Spring.PlaySoundFile(leftclick, 0.8, "ui") @@ -3255,6 +3285,7 @@ local function applyButtonAction(button) elseif button == "options" then toggleWindow("options") elseif button == "save" then + hideWindows() if isSinglePlayer and cfg.allowSavegame and WG.savegame then local time = os.date("%Y%m%d_%H%M%S") Spring.SendCommands("savegame " .. time) @@ -3458,13 +3489,10 @@ function widget:MousePress(x, y, button) end end - if buttonsArea.buttons then - for button, pos in pairs(buttonsArea.buttons) do - if mathIsInRect(x, y, pos[1], pos[2], pos[3], pos[4]) then - applyButtonAction(button) - return true - end - end + local clickedButton = buttonAt(x, y) + if clickedButton then + applyButtonAction(clickedButton) + return true end else if showQuitscreen and quitscreenArea then @@ -3598,6 +3626,10 @@ function widget:Initialize() hideWindows() end + WG.topbar.buttonAt = function(x, y) + return buttonAt(x, y) + end + WG.topbar.setAutoHideButtons = function(value) refreshUi = true autoHideButtons = value diff --git a/luaui/Widgets/unit_stateprefs.lua b/luaui/Widgets/unit_stateprefs.lua index 56f1ee182a1..b489f373d73 100644 --- a/luaui/Widgets/unit_stateprefs.lua +++ b/luaui/Widgets/unit_stateprefs.lua @@ -89,6 +89,7 @@ end local clearSound = "LuaUI/Sounds/switchoff.wav" local CMDTYPE_ICON_MODE = CMDTYPE.ICON_MODE +local CMD_STOCKPILE = CMD.STOCKPILE local isRecordPressed = false local isClearPressed = false local spawnInitialFrame = Game.spawnInitialFrame @@ -123,6 +124,72 @@ local function GetCmdOpts(alt, ctrl, meta, shift, right) return opts end +-- Match unit_stockpile_limit: adjust engine queue toward a desired stock+queued target. +local function setUnitStockpileTarget(unitID, target) + local stock, queued = Spring.GetUnitStockpile(unitID) + if stock == nil or queued == nil then + return + end + local count = stock + queued - target + while count < 0 do + if count <= -100 then + Spring.GiveOrderToUnit(unitID, CMD_STOCKPILE, {}, { "ctrl", "shift" }) + count = count + 100 + elseif count <= -20 then + Spring.GiveOrderToUnit(unitID, CMD_STOCKPILE, {}, { "ctrl" }) + count = count + 20 + elseif count <= -5 then + Spring.GiveOrderToUnit(unitID, CMD_STOCKPILE, {}, { "shift" }) + count = count + 5 + else + Spring.GiveOrderToUnit(unitID, CMD_STOCKPILE, {}, 0) + count = count + 1 + end + end + while count > 0 do + if count >= 100 then + Spring.GiveOrderToUnit(unitID, CMD_STOCKPILE, {}, { "ctrl", "shift", "right" }) + count = count - 100 + elseif count >= 20 then + Spring.GiveOrderToUnit(unitID, CMD_STOCKPILE, {}, { "ctrl", "right" }) + count = count - 20 + elseif count >= 5 then + Spring.GiveOrderToUnit(unitID, CMD_STOCKPILE, {}, { "shift", "right" }) + count = count - 5 + else + Spring.GiveOrderToUnit(unitID, CMD_STOCKPILE, {}, { "right" }) + count = count - 1 + end + end +end + +-- Snapshot current desired stockpile. Block the click so record/clear binds (often ctrl/alt) +-- do not also change the queue via stockpile modifiers. +local function handleStockpilePref(unitID, name) + local prefs = unitSet[name] + if isClearPressed then + if prefs and prefs[CMD_STOCKPILE] ~= nil then + prefs[CMD_STOCKPILE] = nil + pruneUnitPrefs(name) + spEcho("State pref removed: " .. name .. ", Stockpile") + end + return true + end + + local stock, queued = Spring.GetUnitStockpile(unitID) + if stock == nil then + return true + end + local target = stock + (queued or 0) + if not prefs or prefs[CMD_STOCKPILE] ~= target then + prefs = prefs or {} + prefs[CMD_STOCKPILE] = target + unitSet[name] = prefs + spEcho("State pref changed: " .. name .. ", Stockpile " .. target) + end + return true +end + local function recordUserFirestateChanged(unitID, userState) if priorUserFirestateFunction then priorUserFirestateFunction(unitID, userState) @@ -205,6 +272,21 @@ function widget:CommandNotify(cmdID, cmdParams, cmdOpts) return false end + local selectedUnits = spGetSelectedUnits() + + -- Stockpile is CMDTYPE.ICON (not ICON_MODE); snapshot current stock+queued as the pref. + if cmdID == CMD_STOCKPILE then + local block = false + for i = 1, #selectedUnits do + local unitID = selectedUnits[i] + local name = unitName[spGetUnitDefID(unitID)] + if handleStockpilePref(unitID, name) then + block = true + end + end + return block + end + local index = Spring.GetCmdDescIndex(cmdID) local command = Spring.GetActiveCmdDesc(index) -- need to filter only state commands! @@ -212,7 +294,6 @@ function widget:CommandNotify(cmdID, cmdParams, cmdOpts) return end - local selectedUnits = spGetSelectedUnits() for i = 1, #selectedUnits do local unitID = selectedUnits[i] local unitDefID = spGetUnitDefID(unitID) @@ -244,10 +325,12 @@ function widget:UnitFinished(unitID, unitDefID, unitTeam) if unitTeam == Spring.GetLocalTeamID() then for cmdID, cmdParam in pairs(prefs or {}) do if cmdID == 115 then - return - end -- we're skipping "repeat" command here for now - if cmdID == CMD.FIRE_STATE then + -- skip "repeat" for now + elseif cmdID == CMD.FIRE_STATE then WG.firestate.setFirestateForUnits(cmdParam, { unitID }, { userInitiated = false }) + elseif cmdID == CMD_STOCKPILE then + -- stockpile_limit sets desired to max on UnitCreated; adjust down/up to the pref + setUnitStockpileTarget(unitID, cmdParam) else Spring.GiveOrderToUnit(unitID, cmdID, { cmdParam }, cmdOpts) end diff --git a/recoil-lua-library b/recoil-lua-library index e81c769a8f0..791fea96efc 160000 --- a/recoil-lua-library +++ b/recoil-lua-library @@ -1 +1 @@ -Subproject commit e81c769a8f0a2ccae1c1508e1cec131f788c0aef +Subproject commit 791fea96efc93782ac239165f15b232d5804423a