diff --git a/language/en/interface.json b/language/en/interface.json index 5a4cef7c8a6..c2f375dd798 100644 --- a/language/en/interface.json +++ b/language/en/interface.json @@ -345,6 +345,19 @@ "title": "Keybinds", "disclaimer": "These keybinds are set by default. If you remove/replace hotkey widgets, or use your own uikeys, they might stop working!", "howtochangekeybinds":"To change them: in Settings/Control tab, set Keybindings to Custom to create the BAR/data/uikeys.txt file.\nEdit this file and type /keyreload in chat to reload them.", + "editor": { + "search": "Search...", + "resetToPreset": "Reset to preset", + "other": "Other", + "pressKey": "press a key... (Esc to cancel)", + "unsaved": "unsaved changes", + "save": "Save", + "discard": "Discard", + "reset": "Reset", + "cancel": "Cancel", + "resetConfirm": "Reset ALL keybinds to %{preset}?", + "resetHint": "Takes effect when you Save." + }, "chat": { "title": "Chat", "send": "Send chat message", diff --git a/luaui/Include/keybind_editor_view.lua b/luaui/Include/keybind_editor_view.lua new file mode 100644 index 00000000000..2a94d58f5a6 --- /dev/null +++ b/luaui/Include/keybind_editor_view.lua @@ -0,0 +1,709 @@ +-- Interactive view for the in-game keybind editor, hosted as the first tab of +-- the Keybind/Mouse Info panel. Immediate-mode, drawn live every frame. +-- +-- Staging model: all edits mutate an in-memory working copy only; the engine's +-- real bindings are untouched until Save. Save applies the working copy +-- wholesale (unbindall + rebind) and persists; Discard reseeds from the +-- untouched engine, so discarded edits never took effect. + +local keybindModel = VFS.Include("luaui/Include/keybind_model.lua") +local keyConfig = VFS.Include("luaui/configs/keyboard_layouts.lua") +local catalog = VFS.Include("luaui/configs/keybind_catalog.lua") +local Editbox = VFS.Include("luaui/Include/ui_editbox.lua") +local Dropdown = VFS.Include("luaui/Include/ui_dropdown.lua") + +local view = {} + +local floor = math.floor +local spGetMouseState = Spring.GetMouseState +local spGetModKeyState = Spring.GetModKeyState +local spSendCommands = Spring.SendCommands + +local area = { x1 = 0, y1 = 0, x2 = 0, y2 = 0 } +local scale = 1 +local rowHeight = 22 +local listTop = 0 +local barX1 = 0 +local listRight = 0 +local keyAreaX1 = 0 +local footerH = 0 + +local rModalOk, rModalCancel, modalBox = {}, {}, {} +local rSave, rDiscard = {}, {} + +local working -- display copy of the current bindings; edits mutate it, Save replays the deltas +local resolvedCatalog -- catalog with i18n labels resolved once (rebuilt on language change) +local L = {} -- editor UI strings, resolved once alongside the catalog +local rows = {} -- flat display list: { type=header|editable|info, ... } +local scroll = 0 +local dragging = false +local dirty = false +local pendingOps = {} -- ordered bind/unbind deltas to apply on Save +local pendingPreset -- staged "reset to preset" target {label,file}, or nil +local confirmPreset +local capturing -- { action, oldRaw } +local lastClickTime, lastClickId + +local resetOptions = { + { label = "Grid", file = keyConfig.keybindingPresets["Grid"] }, + { label = "Legacy", file = keyConfig.keybindingPresets["Legacy"] }, +} + +local font +local RectRound, Scroller + +local colorAction = "\255\210\210\205" +local colorKey = "\255\235\185\070" +local colorText = "\255\235\235\235" +local colorDim = "\255\160\160\160" +local colorHeader = "\255\255\200\130" + +local searchBox, resetDropdown, menuToggle +local scrollFromY + +local function inRect(r, x, y) + return r.x1 and x >= r.x1 and x <= r.x2 and y >= r.y1 and y <= r.y2 +end + +local function listBottom() + return dirty and (area.y1 + footerH) or area.y1 +end + +local function visibleRows() + return math.max(1, floor((listTop - listBottom()) / rowHeight)) +end + +local function maxScroll() + return math.max(0, #rows - visibleRows()) +end + +local function clampScroll() + if scroll < 0 then scroll = 0 end + if scroll > maxScroll() then scroll = maxScroll() end +end + +local function disp(raw) + return keybindModel.displayKeyset(raw, working.layout) +end + +-- Resolve the catalog's i18n labels (and their lowercased search forms) once, so +-- searching - which rebuilds rows on every keystroke - never re-runs Spring.I18N. +-- Rebuilt via view.refresh(), which the host's LanguageChanged callin already calls. +local function buildResolvedCatalog() + resolvedCatalog = {} + for _, group in ipairs(catalog) do + local title = Spring.I18N(group.category) + local g = { title = title, titleLower = title:lower(), items = {} } + for _, item in ipairs(group.items) do + local label = Spring.I18N(item.label) + g.items[#g.items + 1] = { + action = item.action, + actionLower = item.action and item.action:lower(), + label = label, + labelLower = label:lower(), + keyText = item.keyLabel and Spring.I18N(item.keyLabel) or "", + } + end + resolvedCatalog[#resolvedCatalog + 1] = g + end + + L.other = Spring.I18N('ui.keybinds.editor.other') + L.pressKey = Spring.I18N('ui.keybinds.editor.pressKey') + L.unsaved = Spring.I18N('ui.keybinds.editor.unsaved') + L.save = Spring.I18N('ui.keybinds.editor.save') + L.discard = Spring.I18N('ui.keybinds.editor.discard') + L.reset = Spring.I18N('ui.keybinds.editor.reset') + L.cancel = Spring.I18N('ui.keybinds.editor.cancel') + L.resetHint = Spring.I18N('ui.keybinds.editor.resetHint') +end + +local function rebuildRows() + if not resolvedCatalog then + buildResolvedCatalog() + end + + rows = {} + local q = searchBox and searchBox:getText():lower() or "" + local catalogActions = {} + + for _, group in ipairs(resolvedCatalog) do + local categoryMatch = q ~= "" and group.titleLower:find(q, 1, true) + local groupRows = {} + for _, item in ipairs(group.items) do + if item.action then + catalogActions[item.action] = true + end + if q == "" or categoryMatch or item.labelLower:find(q, 1, true) + or (item.actionLower and item.actionLower:find(q, 1, true)) then + groupRows[#groupRows + 1] = item + end + end + + if #groupRows > 0 then + rows[#rows + 1] = { type = "header", text = group.title } + for _, item in ipairs(groupRows) do + if item.action then + rows[#rows + 1] = { type = "editable", action = item.action, label = item.label } + else + rows[#rows + 1] = { type = "info", label = item.label, keyText = item.keyText } + end + end + end + end + + local otherMatch = q ~= "" and ("other"):find(q, 1, true) + local others = {} + for action in pairs(working.byAction) do + if not catalogActions[action] and (q == "" or otherMatch or action:lower():find(q, 1, true)) then + others[#others + 1] = action + end + end + + if #others > 0 then + table.sort(others) + rows[#rows + 1] = { type = "header", text = L.other } + for _, action in ipairs(others) do + rows[#rows + 1] = { type = "editable", action = action, label = action } + end + end + + clampScroll() +end + +local function seedWorkingFromEngine() + local model = keybindModel.build() + working = { byAction = {}, layout = model.layout } + for _, entry in ipairs(model.actions) do + local copy = {} + for _, k in ipairs(entry.keysets) do + copy[#copy + 1] = { raw = k.raw, display = k.display } + end + working.byAction[entry.action] = copy + end +end + +local function ensureControls() + if searchBox then + return + end + + searchBox = Editbox.new({ placeholder = Spring.I18N('ui.keybinds.editor.search'), onChange = rebuildRows }) + resetDropdown = Dropdown.new({ + label = Spring.I18N('ui.keybinds.editor.resetToPreset'), + options = resetOptions, + onSelect = function(opt) confirmPreset = opt end, + }) +end + +function view.init() + font = WG['fonts'].getFont() + RectRound = WG.FlowUI.Draw.RectRound + Scroller = WG.FlowUI.Draw.Scroller + ensureControls() +end + +function view.refresh() + ensureControls() + seedWorkingFromEngine() + resolvedCatalog = nil -- re-resolve labels (covers language change via the host's LanguageChanged) + pendingOps = {} + pendingPreset = nil + dirty = false + rebuildRows() +end + +function view.setArea(x1, y1, x2, y2, s) + ensureControls() + area.x1, area.y1, area.x2, area.y2 = x1, y1, x2, y2 + scale = s or 1 + rowHeight = floor(22 * scale) + footerH = floor(36 * scale) + + local pad = floor(6 * scale) + local gap = floor(8 * scale) + local headerH = floor(34 * scale) + local rowTop = area.y2 - floor(4 * scale) + local rowBottom = area.y2 - headerH + floor(4 * scale) + local resetW = floor(160 * scale) + local btnFs = (rowTop - rowBottom) * 0.5 + + resetDropdown:setRect(area.x2 - resetW, rowBottom, area.x2, rowTop, btnFs) + searchBox:setRect(area.x1, rowBottom, area.x2 - resetW - gap, rowTop, btnFs) + + listTop = area.y2 - headerH - floor(4 * scale) + listRight = area.x2 - floor(12 * scale) - pad + barX1 = listRight + floor(4 * scale) + keyAreaX1 = area.x1 + floor((listRight - area.x1) * 0.45) + + local boxW, boxH = floor(380 * scale), floor(150 * scale) + local cx, cy = (area.x1 + area.x2) / 2, (area.y1 + area.y2) / 2 + modalBox = { x1 = cx - boxW / 2, y1 = cy - boxH / 2, x2 = cx + boxW / 2, y2 = cy + boxH / 2 } + local bw, bh = floor(120 * scale), floor(32 * scale) + rModalCancel = { x1 = modalBox.x2 - gap - bw, y1 = modalBox.y1 + gap, x2 = modalBox.x2 - gap, y2 = modalBox.y1 + gap + bh } + rModalOk = { x1 = rModalCancel.x1 - gap - bw, y1 = modalBox.y1 + gap, x2 = rModalCancel.x1 - gap, y2 = modalBox.y1 + gap + bh } + + local fbw = floor(90 * scale) + rSave = { x1 = area.x2 - pad - fbw, y1 = area.y1 + floor(4 * scale), x2 = area.x2 - pad, y2 = area.y1 + footerH - floor(4 * scale) } + rDiscard = { x1 = rSave.x1 - gap - fbw, y1 = rSave.y1, x2 = rSave.x1 - gap, y2 = rSave.y2 } + + clampScroll() +end + +function view.blur() + if searchBox then searchBox:blur() end + if resetDropdown then resetDropdown:close() end + capturing = nil + confirmPreset = nil +end + +function view.setMenuToggle(fn) + menuToggle = fn +end + +-- True while the editor needs first crack at keypresses (search focus / key +-- capture), so the host can claim widgetHandler.textOwner and stop keys from +-- leaking to bound actions. +function view.wantsTextOwner() + return (searchBox and searchBox:isFocused()) or capturing ~= nil +end + +-- Edits update the working copy (for display) and record the bind/unbind delta. +-- Nothing touches the engine until Save replays the deltas. Recording deltas - +-- rather than unbind-all + rebind-everything on Save - leaves bindings we never +-- edited (chains, fakemeta, engine defaults) untouched. +local function rebindKeyset(action, oldRaw, newKeyset) + local ks = working.byAction[action] + if ks then + for _, k in ipairs(ks) do + if k.raw == oldRaw then + k.raw = newKeyset + k.display = disp(newKeyset) + break + end + end + end + pendingOps[#pendingOps + 1] = { op = "unbind", keyset = oldRaw, action = action } + pendingOps[#pendingOps + 1] = { op = "bind", keyset = newKeyset, action = action } + dirty = true + rebuildRows() +end + +local function addKeyset(action, newKeyset) + if not working.byAction[action] then + working.byAction[action] = {} + end + local ks = working.byAction[action] + ks[#ks + 1] = { raw = newKeyset, display = disp(newKeyset) } + pendingOps[#pendingOps + 1] = { op = "bind", keyset = newKeyset, action = action } + dirty = true + rebuildRows() +end + +local function removeKeyset(action, raw) + local ks = working.byAction[action] + if ks then + for i = #ks, 1, -1 do + if ks[i].raw == raw then + table.remove(ks, i) + end + end + end + pendingOps[#pendingOps + 1] = { op = "unbind", keyset = raw, action = action } + dirty = true + rebuildRows() +end + +-- The engine allows one key to drive several actions (BAR relies on it for +-- context-dependent stacks, e.g. backspace = mutesound + edit_backspace), so a +-- new binding is added without disturbing other actions on the same keyset. +local function commitCapture(keyset) + local c = capturing + capturing = nil + + if c.oldRaw then + rebindKeyset(c.action, c.oldRaw, keyset) + else + addKeyset(c.action, keyset) + end +end + +local function stageReset(opt) + -- Load the preset in the engine to capture its TRUE keyset (including the + -- engine defaults the file itself doesn't list), snapshot it into the + -- working copy, then revert - so the staged preview matches what Save will + -- actually produce, and nothing stays applied. + local currentFile = Spring.GetConfigString("KeybindingFile", "uikeys.txt") + if not VFS.FileExists(currentFile) then + currentFile = keyConfig.keybindingLayoutFiles[1] -- grid fallback, matches cmd_bar_hotkeys + end + spSendCommands("keyreload " .. opt.file) + seedWorkingFromEngine() + spSendCommands("keyreload " .. currentFile) + + pendingOps = {} -- a reset replaces any prior staged edits + pendingPreset = opt + dirty = true + scroll = 0 + rebuildRows() +end + +local function save() + -- A staged reset is applied by loading the preset through the engine (faithful + -- to its real keyset - chains, fakemeta, defaults), then replaying any edits + -- made after the reset. A plain edit session just replays its deltas against + -- the live bindings, leaving everything it never touched intact. + if pendingPreset then + Spring.SetConfigString("KeybindingFile", pendingPreset.file) + spSendCommands("keyreload " .. pendingPreset.file) + if menuToggle then + menuToggle(pendingPreset.label) + end + end + + for _, o in ipairs(pendingOps) do + spSendCommands(o.op .. " " .. o.keyset .. " " .. o.action) + end + + spSendCommands("keysave uikeys.txt") + Spring.SetConfigString("KeybindingFile", "uikeys.txt") + + pendingOps = {} + pendingPreset = nil + dirty = false + + if WG['bar_hotkeys'] and WG['bar_hotkeys'].reloadBindings then + WG['bar_hotkeys'].reloadBindings() + else + view.refresh() + end +end + +local function discard() + view.refresh() +end + +local function keysetFromPress(scanCode) + local sym = scanCode and Spring.GetScanSymbol and Spring.GetScanSymbol(scanCode) + if not sym or sym == "" then + return nil + end + if sym:find("ctrl") or sym:find("alt") or sym:find("shift") or sym:find("meta") or sym:find("gui") then + return nil + end + + local alt, ctrl, _, shift = spGetModKeyState() + local prefix = "" + if alt then prefix = prefix .. "Alt+" end + if ctrl then prefix = prefix .. "Ctrl+" end + if shift then prefix = prefix .. "Shift+" end + + return prefix .. sym +end + +local function fitText(text, maxWidth, size) + if maxWidth <= 0 or font:GetTextWidth(text) * size <= maxWidth then + return text + end + while #text > 1 and font:GetTextWidth(text .. "..") * size > maxWidth do + text = text:sub(1, #text - 1) + end + return text .. ".." +end + +local function drawButton(r, label, hovered, fs, tint) + tint = tint or { 0, 0, 0 } + RectRound(r.x1, r.y1, r.x2, r.y2, floor(3 * scale), 1, 1, 1, 1, { tint[1], tint[2], tint[3], hovered and 0.7 or 0.45 }) + font:Print(colorText .. label, (r.x1 + r.x2) / 2, (r.y1 + r.y2) / 2, fs, "cov") +end + +local function drawRow(row, top, bottom, mx, my, fs, pad) + local cyc = (top + bottom) * 0.5 + + if row.type == "header" then + RectRound(area.x1, bottom, listRight, top, 0, 0, 0, 0, 0, { 1, 1, 1, 0.05 }, { 1, 1, 1, 0.05 }) + font:Print(colorHeader .. row.text, area.x1 + pad, cyc, fs * 0.95, "ov") + return + end + + local capturingThis = capturing and capturing.action == row.action + local hovered = mx >= area.x1 and mx <= listRight and my <= top and my > bottom + if capturingThis then + RectRound(area.x1, bottom, listRight, top, 0, 0, 0, 0, 0, { 0.9, 0.7, 0.2, 0.18 }, { 0.9, 0.7, 0.2, 0.18 }) + elseif hovered then + RectRound(area.x1, bottom, listRight, top, 0, 0, 0, 0, 0, { 1, 1, 1, 0.06 }, { 1, 1, 1, 0.06 }) + end + + font:Print(colorAction .. fitText(row.label, keyAreaX1 - (area.x1 + pad) - pad, fs), area.x1 + pad, cyc, fs, "ov") + + if row.type == "info" then + font:Print(colorDim .. row.keyText, listRight - pad, cyc, fs, "ovr") + return + end + + if capturingThis then + font:Print("\255\255\230\120" .. L.pressKey, keyAreaX1, cyc, fs, "ov") + return + end + + local c1, c2 = bottom + floor(3 * scale), top - floor(3 * scale) + local cx = keyAreaX1 + local glyphW = floor(fs * 0.9) + local addW = floor(fs + pad * 2) + local chipLimit = listRight - addW - floor(8 * scale) -- reserve room so "+" always fits + + for _, ks in ipairs(working.byAction[row.action] or {}) do + local tw = font:GetTextWidth(ks.display) * fs + local removeZone = pad + glyphW + local chipW = pad + tw + removeZone + if cx + chipW > chipLimit then + break + end + + local removeX1 = cx + chipW - removeZone + local overRemove = mx >= removeX1 and mx <= cx + chipW and my >= c1 and my <= c2 + local overBody = mx >= cx and mx < removeX1 and my >= c1 and my <= c2 + + RectRound(cx, c1, cx + chipW, c2, floor(3 * scale), 1, 1, 1, 1, { 0, 0, 0, overBody and 0.5 or 0.35 }) + font:Print(colorKey .. ks.display, cx + pad, cyc, fs, "ov") + font:Print((overRemove and "\255\235\090\090" or colorDim) .. "x", removeX1 + removeZone * 0.5, cyc, fs, "cov") + + cx = cx + chipW + floor(6 * scale) + end + + local overAdd = mx >= cx and mx <= cx + addW and my >= c1 and my <= c2 + RectRound(cx, c1, cx + addW, c2, floor(3 * scale), 1, 1, 1, 1, { 0.2, 0.45, 0.25, overAdd and 0.6 or 0.4 }) + font:Print(colorText .. "+", (cx + cx + addW) * 0.5, cyc, fs, "cov") +end + +function view.draw() + if not font then view.init() end + if not working then view.refresh() end + + local mx, my, lmb = spGetMouseState() + if dragging then + if lmb then scrollFromY(my) else dragging = false end + end + + local rowCount = visibleRows() + local fs = rowHeight * 0.55 + local pad = floor(6 * scale) + local lb = listBottom() + + font:Begin() + for r = 1, rowCount do + local row = rows[scroll + r] + if not row then break end + local top = listTop - (r - 1) * rowHeight + drawRow(row, top, top - rowHeight, mx, my, fs, pad) + end + font:End() + + Scroller(barX1, lb, area.x2, listTop, #rows * rowHeight, scroll * rowHeight) + + searchBox:draw() + resetDropdown:draw() + + if dirty then + local btnFs = (rSave.y2 - rSave.y1) * 0.45 + RectRound(area.x1, area.y1, area.x2, area.y1 + footerH, 0, 0, 0, 0, 0, { 0.1, 0.1, 0.1, 0.9 }, { 0.1, 0.1, 0.1, 0.9 }) + font:Begin() + font:Print("\255\255\220\120" .. L.unsaved, area.x1 + pad, area.y1 + footerH * 0.5, btnFs, "ov") + drawButton(rDiscard, L.discard, inRect(rDiscard, mx, my), btnFs, { 0.3, 0.1, 0.1 }) + drawButton(rSave, L.save, inRect(rSave, mx, my), btnFs, { 0.15, 0.35, 0.18 }) + font:End() + end + + if confirmPreset then + local btnFs = (rModalOk.y2 - rModalOk.y1) * 0.45 + RectRound(area.x1, area.y1, area.x2, area.y2, 0, 0, 0, 0, 0, { 0, 0, 0, 0.6 }, { 0, 0, 0, 0.6 }) + RectRound(modalBox.x1, modalBox.y1, modalBox.x2, modalBox.y2, floor(4 * scale), 1, 1, 1, 1, { 0.13, 0.13, 0.13, 0.97 }) + local mfs = 15 * scale + local mcx = (modalBox.x1 + modalBox.x2) / 2 + font:Begin() + font:Print(colorText .. Spring.I18N('ui.keybinds.editor.resetConfirm', { preset = confirmPreset.label }), mcx, modalBox.y2 - floor(46 * scale), mfs, "cov") + font:Print(colorDim .. L.resetHint, mcx, modalBox.y2 - floor(74 * scale), mfs * 0.85, "cov") + drawButton(rModalOk, L.reset, inRect(rModalOk, mx, my), btnFs, { 0.15, 0.3, 0.4 }) + drawButton(rModalCancel, L.cancel, inRect(rModalCancel, mx, my), btnFs) + font:End() + end +end + +scrollFromY = function(y) + local lb = listBottom() + local f = (listTop - y) / math.max(1, listTop - lb) + if f < 0 then f = 0 elseif f > 1 then f = 1 end + scroll = floor(f * maxScroll() + 0.5) + clampScroll() +end + +function view.mouseWheel(up, value) + local mx, my = spGetMouseState() + if confirmPreset or not (mx >= area.x1 and mx <= area.x2 and my >= listBottom() and my <= listTop) then + return false + end + scroll = scroll + (up and -3 or 3) + clampScroll() + return true +end + +-- Resolve which zone of an editable row a click hit. Mirrors drawRow's chip +-- layout (kept in sync) so we hit-test on demand instead of storing per-frame +-- rects. Returns kind ("rebind"/"remove"/"add") and the keyset, or nil. +local function hitTestRow(rowAction, x) + local pad = floor(6 * scale) + local fs = rowHeight * 0.55 + local glyphW = floor(fs * 0.9) + local addW = floor(fs + pad * 2) + local chipLimit = listRight - addW - floor(8 * scale) + local cx = keyAreaX1 + + for _, ks in ipairs(working.byAction[rowAction] or {}) do + local chipW = pad + font:GetTextWidth(ks.display) * fs + pad + glyphW + if cx + chipW > chipLimit then + break + end + local removeX1 = cx + chipW - pad - glyphW + if x >= cx and x < removeX1 then + return "rebind", ks.raw + elseif x >= removeX1 and x <= cx + chipW then + return "remove", ks.raw + end + cx = cx + chipW + floor(6 * scale) + end + + if x >= cx and x <= cx + addW then + return "add" + end +end + +local function handleZone(kind, action, raw) + if kind == "remove" then + removeKeyset(action, raw) + elseif kind == "add" then + capturing = { action = action } + elseif kind == "rebind" then + local id = action .. "|" .. tostring(raw) + local now = Spring.GetTimer and Spring.GetTimer() + if now and lastClickId == id and lastClickTime and Spring.DiffTimers(now, lastClickTime) < 0.4 then + capturing = { action = action, oldRaw = raw } + lastClickTime = nil + else + lastClickId = id + lastClickTime = now + end + end +end + +function view.mousePress(x, y, button) + if not (x >= area.x1 and x <= area.x2 and y >= area.y1 and y <= area.y2) then + return false + end + + if confirmPreset then + if inRect(rModalOk, x, y) then + stageReset(confirmPreset) + confirmPreset = nil + elseif inRect(rModalCancel, x, y) then + confirmPreset = nil + end + return true + end + + local ddWasOpen = resetDropdown:isOpen() + if resetDropdown:mousePress(x, y) then + searchBox:blur() + capturing = nil + return true + end + if ddWasOpen then + return true + end + + if searchBox:mousePress(x, y) then + capturing = nil + return true + end + searchBox:blur() + + if dirty then + if inRect(rSave, x, y) then save() return true end + if inRect(rDiscard, x, y) then discard() return true end + end + + if x >= barX1 and x <= area.x2 and y >= listBottom() and y <= listTop then + dragging = true + scrollFromY(y) + return true + end + + if x >= area.x1 and x <= listRight and y >= area.y1 and y <= listTop then + local r = floor((listTop - y) / rowHeight) + 1 + local row = rows[scroll + r] + if row and row.type == "editable" then + local kind, raw = hitTestRow(row.action, x) + if kind then + handleZone(kind, row.action, raw) + end + end + return true + end + + return true +end + +function view.textInput(char) + if searchBox and searchBox:isFocused() then + return searchBox:textInput(char) + end + return false +end + +function view.keyPress(key, scanCode) + if capturing then + if key == 27 then + capturing = nil + else + local keyset = keysetFromPress(scanCode) + if keyset then + commitCapture(keyset) + end + end + return true + end + + if confirmPreset then + if key == 27 then confirmPreset = nil end + return true + end + + if resetDropdown and resetDropdown:isOpen() and key == 27 then + resetDropdown:close() + return true + end + + if searchBox and searchBox:isFocused() then + return searchBox:keyPress(key) + end + + return false +end + +-- Fallback for engine keys whose press never reaches LuaUI (cameraflip, volume, +-- unit commands): capture on release. Only fires while still capturing - a normal +-- key already captured on its press. Modifiers are read at release time, so this +-- path records the combo only if the modifier is still held when the key is +-- released; that ambiguity is inherent (we never saw the press). +function view.keyRelease(key, scanCode) + if not capturing then + return false + end + + local keyset = keysetFromPress(scanCode) + if keyset then + commitCapture(keyset) + end + + return true +end + +return view diff --git a/luaui/Include/keybind_model.lua b/luaui/Include/keybind_model.lua new file mode 100644 index 00000000000..f7b6af4260a --- /dev/null +++ b/luaui/Include/keybind_model.lua @@ -0,0 +1,65 @@ +-- Read model for the in-game keybind editor. +-- Source of truth is Spring.GetKeyBindings(); we normalize each binding, group +-- by action, and build a keyset->actions index used for conflict detection. + +local keyConfig = VFS.Include("luaui/configs/keyboard_layouts.lua") + +-- Synonymous key names that should read the same however they were bound +-- (e.g. the file keysym "enter" vs the scancode-based "return" from capture). +local keyNameAlias = { enter = "return" } + +local function displayKeyset(raw, layout) + local mods, key = raw:match("^(.-)([^+]*)$") + if key and keyNameAlias[key:lower()] then + raw = mods .. keyNameAlias[key:lower()] + end + + return keyConfig.sanitizeKey(raw, layout):gsub("%+", " + ") +end + +-- A bound action is identified by the full command string passed to /bind: +-- command plus its space-separated args (.extra) - exactly what bind/unbind +-- expect. This includes "chain", whose .extra is the sequence; dropping it would +-- collapse every chain into one id and lose the sequence on rebind. +local function actionId(b) + if b.extra and b.extra ~= "" then + return b.command .. " " .. b.extra + end + + return b.command +end + +local function build() + local layout = Spring.GetConfigString("KeyboardLayout", "qwerty") + local bindings = Spring.GetKeyBindings() or {} + + local byAction = {} + local order = {} + + for _, b in ipairs(bindings) do + local id = actionId(b) + local raw = b.boundWith + + local entry = byAction[id] + if not entry then + entry = { action = id, command = b.command, keysets = {} } + byAction[id] = entry + order[#order + 1] = id + end + entry.keysets[#entry.keysets + 1] = { raw = raw, display = displayKeyset(raw, layout) } + end + + table.sort(order) + + local actions = {} + for i = 1, #order do + actions[i] = byAction[order[i]] + end + + return { actions = actions, layout = layout } +end + +return { + build = build, + displayKeyset = displayKeyset, +} diff --git a/luaui/Include/ui_dropdown.lua b/luaui/Include/ui_dropdown.lua new file mode 100644 index 00000000000..45dd7c55be6 --- /dev/null +++ b/luaui/Include/ui_dropdown.lua @@ -0,0 +1,116 @@ +-- Reusable select/dropdown control styled with FlowUI's Selector visuals to +-- match the Settings menu. Instance-based: Dropdown.new{...}. Options drop +-- below the button; onSelect(option) fires on choice. + +local Dropdown = {} +Dropdown.__index = Dropdown + +local floor = math.floor + +local colorText = "\255\235\235\235" + +local function getFont() + return WG['fonts'].getFont() +end + +function Dropdown.new(opts) + opts = opts or {} + + local self = setmetatable({}, Dropdown) + self.label = opts.label or "" + self.options = opts.options or {} + self.onSelect = opts.onSelect + self.open = false + self.rect = { 0, 0, 0, 0 } + self.optRects = {} + self.fontSize = 14 + + return self +end + +function Dropdown:setRect(x1, y1, x2, y2, fontSize) + self.rect = { x1, y1, x2, y2 } + self.fontSize = fontSize or (y2 - y1) * 0.5 + + local optH = floor(y2 - y1) + self.optRects = {} + for i = 1, #self.options do + self.optRects[i] = { x1 = x1, y1 = y1 - i * optH, x2 = x2, y2 = y1 - (i - 1) * optH } + end +end + +function Dropdown:isOpen() + return self.open +end + +function Dropdown:close() + self.open = false +end + +local function optionLabel(opt) + if type(opt) == "table" then + return opt.label or tostring(opt.value) + end + + return tostring(opt) +end + +function Dropdown:draw() + local font = getFont() + local Selector = WG.FlowUI.Draw.Selector + local Highlight = WG.FlowUI.Draw.SelectHighlight + local R = WG.FlowUI.Draw.RectRound + local mx, my = Spring.GetMouseState() + local x1, y1, x2, y2 = self.rect[1], self.rect[2], self.rect[3], self.rect[4] + + Selector(x1, y1, x2, y2) + + font:Begin() + font:Print(colorText .. self.label, (x1 + x2) * 0.5, (y1 + y2) * 0.5, self.fontSize, "cov") + font:End() + + if self.open and #self.optRects > 0 then + local top = self.optRects[1].y2 + local bottom = self.optRects[#self.optRects].y1 + local cs = floor((y2 - y1) * 0.1) + R(x1, bottom, x2, top, cs, 1, 1, 1, 1, { 0.09, 0.09, 0.09, 0.96 }) + + font:Begin() + for i, opt in ipairs(self.options) do + local r = self.optRects[i] + if mx >= r.x1 and mx <= r.x2 and my >= r.y1 and my <= r.y2 then + Highlight(r.x1, r.y1, r.x2, r.y2, cs, 1, { 1, 1, 1 }) + end + font:Print(colorText .. optionLabel(opt), r.x1 + floor((y2 - y1) * 0.3), (r.y1 + r.y2) * 0.5, self.fontSize, "ov") + end + font:End() + end +end + +-- Returns true if the press was consumed (button toggle or option pick). +function Dropdown:mousePress(x, y) + if self.open then + for i, r in ipairs(self.optRects) do + if x >= r.x1 and x <= r.x2 and y >= r.y1 and y <= r.y2 then + self.open = false + if self.onSelect then + self.onSelect(self.options[i]) + end + + return true + end + end + end + + local b = self.rect + if x >= b[1] and x <= b[3] and y >= b[2] and y <= b[4] then + self.open = not self.open + return true + end + + self.open = false + + return false +end + +return Dropdown diff --git a/luaui/Include/ui_editbox.lua b/luaui/Include/ui_editbox.lua new file mode 100644 index 00000000000..f115dcc6b86 --- /dev/null +++ b/luaui/Include/ui_editbox.lua @@ -0,0 +1,295 @@ +-- Reusable single-line text input with selection and editing shortcuts. +-- Instance-based: Editbox.new{...} per field. Selection / ctrl+a / ctrl+arrow +-- word-jump / shift+arrow / ctrl+backspace / mouse-drag select, UTF-8 aware. +-- Active only while focused, so it is safe to host alongside game input. + +local utf8 = VFS.Include('common/luaUtilities/utf8.lua') + +local Editbox = {} +Editbox.__index = Editbox + +local floor = math.floor + +local colorText = "\255\235\235\235" +local colorDim = "\255\160\160\160" + +local function getFont() + return WG['fonts'].getFont() +end + +function Editbox.new(opts) + opts = opts or {} + + local self = setmetatable({}, Editbox) + self.text = opts.text or "" + self.caret = utf8.len(self.text) + self.selAnchor = nil + self.focused = false + self.dragging = false + self.placeholder = opts.placeholder or "" + self.maxChars = opts.maxChars or 127 + self.onChange = opts.onChange + self.rect = { 0, 0, 0, 0 } + self.fontSize = 14 + self.pad = 6 + + return self +end + +function Editbox:setRect(x1, y1, x2, y2, fontSize, pad) + self.rect = { x1, y1, x2, y2 } + self.fontSize = fontSize or (y2 - y1) * 0.5 + self.pad = pad or floor((y2 - y1) * 0.2) +end + +function Editbox:getText() + return self.text +end + +function Editbox:setText(t) + self.text = t or "" + self.caret = utf8.len(self.text) + self.selAnchor = nil + + if self.onChange then + self.onChange(self.text) + end +end + +function Editbox:focus() + if not self.focused then + self.focused = true + if Spring.SDLStartTextInput then + Spring.SDLStartTextInput() + end + end +end + +function Editbox:blur() + if self.focused then + self.focused = false + self.dragging = false + if Spring.SDLStopTextInput then + Spring.SDLStopTextInput() + end + end +end + +function Editbox:isFocused() + return self.focused +end + +function Editbox:hasSelection() + return self.selAnchor ~= nil and self.selAnchor ~= self.caret +end + +function Editbox:selRange() + return math.min(self.selAnchor, self.caret), math.max(self.selAnchor, self.caret) +end + +function Editbox:deleteSelection() + if not self:hasSelection() then + return false + end + + local a, b = self:selRange() + self.text = utf8.sub(self.text, 1, a) .. utf8.sub(self.text, b + 1) + self.caret = a + self.selAnchor = nil + + return true +end + +function Editbox:setCaret(pos, extend) + if extend then + if not self.selAnchor then + self.selAnchor = self.caret + end + else + self.selAnchor = nil + end + + local len = utf8.len(self.text) + if pos < 0 then pos = 0 elseif pos > len then pos = len end + self.caret = pos +end + +function Editbox:prevWord() + local pos = self.caret + while pos > 0 and utf8.sub(self.text, pos, pos):match("%s") do pos = pos - 1 end + while pos > 0 and not utf8.sub(self.text, pos, pos):match("%s") do pos = pos - 1 end + + return pos +end + +function Editbox:nextWord() + local len = utf8.len(self.text) + local pos = self.caret + while pos < len and not utf8.sub(self.text, pos + 1, pos + 1):match("%s") do pos = pos + 1 end + while pos < len and utf8.sub(self.text, pos + 1, pos + 1):match("%s") do pos = pos + 1 end + + return pos +end + +function Editbox:indexFromX(x) + local font = getFont() + local relX = x - (self.rect[1] + self.pad) + + if relX <= 0 then + return 0 + end + + local n = utf8.len(self.text) + for i = 1, n do + local w = font:GetTextWidth(utf8.sub(self.text, 1, i)) * self.fontSize + if w >= relX then + local wPrev = font:GetTextWidth(utf8.sub(self.text, 1, i - 1)) * self.fontSize + if (relX - wPrev) < (w - relX) then + return i - 1 + end + + return i + end + end + + return n +end + +function Editbox:textInput(char) + if not self.focused then + return false + end + + self:deleteSelection() + + if utf8.len(self.text) >= self.maxChars then + return true + end + + self.text = utf8.sub(self.text, 1, self.caret) .. char .. utf8.sub(self.text, self.caret + 1) + self.caret = self.caret + 1 + self.selAnchor = nil + + if self.onChange then + self.onChange(self.text) + end + + return true +end + +function Editbox:keyPress(key) + if not self.focused then + return false + end + + local _, ctrl, _, shift = Spring.GetModKeyState() + local changed = false + + if ctrl and key == 97 then -- ctrl+a + self.selAnchor = 0 + self.caret = utf8.len(self.text) + elseif key == 27 or key == 13 then -- escape / enter + self:blur() + elseif key == 8 then -- backspace + if not self:deleteSelection() then + if ctrl then + local p = self:prevWord() + if p < self.caret then + self.text = utf8.sub(self.text, 1, p) .. utf8.sub(self.text, self.caret + 1) + self.caret = p + end + elseif self.caret > 0 then + self.text = utf8.sub(self.text, 1, self.caret - 1) .. utf8.sub(self.text, self.caret + 1) + self.caret = self.caret - 1 + end + end + changed = true + elseif key == 127 then -- delete + if not self:deleteSelection() then + if self.caret < utf8.len(self.text) then + self.text = utf8.sub(self.text, 1, self.caret) .. utf8.sub(self.text, self.caret + 2) + end + end + changed = true + elseif key == 276 then -- left + self:setCaret(ctrl and self:prevWord() or self.caret - 1, shift) + elseif key == 275 then -- right + self:setCaret(ctrl and self:nextWord() or self.caret + 1, shift) + elseif key == 278 then -- home + self:setCaret(0, shift) + elseif key == 279 then -- end + self:setCaret(utf8.len(self.text), shift) + end + + if changed and self.onChange then + self.onChange(self.text) + end + + return true +end + +function Editbox:mousePress(x, y) + if x < self.rect[1] or x > self.rect[3] or y < self.rect[2] or y > self.rect[4] then + return false + end + + local _, _, _, shift = Spring.GetModKeyState() + local idx = self:indexFromX(x) + + self:focus() + self:setCaret(idx, shift) + if not shift then + self.selAnchor = idx + end + self.dragging = true + + return true +end + +local function update(self) + if self.dragging then + local mx, _, lmb = Spring.GetMouseState() + if lmb then + self.caret = self:indexFromX(mx) + else + self.dragging = false + end + end +end + +function Editbox:draw() + update(self) + + local font = getFont() + local R = WG.FlowUI.Draw.RectRound + local x1, y1, x2, y2 = self.rect[1], self.rect[2], self.rect[3], self.rect[4] + local cs = floor((y2 - y1) * 0.18) + local tx = x1 + self.pad + local ty = (y1 + y2) * 0.5 + + R(x1, y1, x2, y2, cs, 1, 1, 1, 1, { 0, 0, 0, 0.35 }) + + if self:hasSelection() then + local a, b = self:selRange() + local sa = font:GetTextWidth(utf8.sub(self.text, 1, a)) * self.fontSize + local sb = font:GetTextWidth(utf8.sub(self.text, 1, b)) * self.fontSize + gl.Color(0.4, 0.55, 0.85, 0.5) + gl.Rect(tx + sa, y1 + cs, tx + sb, y2 - cs) + gl.Color(1, 1, 1, 1) + end + + font:Begin() + if self.text == "" and not self.focused then + font:Print(colorDim .. self.placeholder, tx, ty, self.fontSize, "ov") + else + font:Print(colorText .. self.text, tx, ty, self.fontSize, "ov") + end + font:End() + + if self.focused then + local cw = font:GetTextWidth(utf8.sub(self.text, 1, self.caret)) * self.fontSize + R(tx + cw, y1 + cs, tx + cw + math.max(1, floor(cs * 0.5)), y2 - cs, 0, 0, 0, 0, 0, { 1, 1, 1, 0.85 }) + end +end + +return Editbox diff --git a/luaui/Widgets/gui_keybind_info.lua b/luaui/Widgets/gui_keybind_info.lua index d21d289a07e..97a1551659f 100644 --- a/luaui/Widgets/gui_keybind_info.lua +++ b/luaui/Widgets/gui_keybind_info.lua @@ -22,6 +22,7 @@ local mathMax = math.max local spGetViewGeometry = Spring.GetViewGeometry local keyConfig = VFS.Include("luaui/configs/keyboard_layouts.lua") +local keybindEditor = VFS.Include("luaui/Include/keybind_editor_view.lua") local currentLayout local keybindsText @@ -158,13 +159,14 @@ local function drawWindow(activetab) font2:End() - if activetab ~= "Keybindings" and keybindsimages[activetab] then + if keybindsimages[activetab] then gl.Color(1,1,1,1) gl.Texture(0, ":l:"..keybindsimages[activetab]) local zoom = 0.05 gl.TexRect(screenX,screenY - screenHeight, screenX + screenWidth, screenY, 0 + 0.02, 1 - zoom, 1 - 0.02 , 0 + zoom) gl.Texture(0, false) - else + elseif activetab ~= "Keybindings" then + -- the "Keybindings" tab is the interactive editor, drawn live in DrawScreen local entriesPerColumn = mathCeil(#keybindsText / 3) local entries1 = {} @@ -200,6 +202,8 @@ local function refreshText() actionHotkeys = VFS.Include("luaui/Include/action_hotkeys.lua") currentLayout = Spring.GetConfigString("KeyboardLayout", "qwerty") + keybindEditor.refresh() + keybindsText = { { type = lineType.title, text = Spring.I18N('ui.keybinds.chat.title') }, { type = lineType.key, key = getActionHotkey('chat'), text = Spring.I18N('ui.keybinds.chat.send') }, @@ -322,6 +326,11 @@ function widget:ViewResize() RectRound = WG.FlowUI.Draw.RectRound UiElement = WG.FlowUI.Draw.Element + keybindEditor.init() + local pad = mathFloor(8 * widgetScale) + local tabStripH = mathFloor(30 * widgetScale) + keybindEditor.setArea(screenX + pad, screenY - screenHeight + pad, screenX + screenWidth - pad, screenY - tabStripH, widgetScale) + if keybinds then gl.DeleteList(keybinds) end @@ -360,6 +369,9 @@ function widget:DrawScreen() if show or showOnceMore then gl.Texture(false) -- some other widget left it on glCallList(keybinds) + if lasttab == "Keybindings" then + keybindEditor.draw() + end if WG['guishader'] and backgroundGuishader == nil then backgroundGuishader = glCreateList(function() -- background @@ -389,13 +401,37 @@ function widget:DrawScreen() end end -function widget:KeyPress(key) +function widget:KeyPress(key, mods, isRepeat, label, unicode, scanCode) + if show and lasttab == "Keybindings" and keybindEditor.keyPress(key, scanCode) then + return true + end + if key == 27 then -- ESC show = false + keybindEditor.blur() end end +-- Some engine actions (cameraflip, volume, ...) execute on key-down without +-- routing the press through LuaUI, so capture can't see the press. We still +-- get the release, so fall back to capturing on release. +function widget:KeyRelease(key, mods, label, unicode, scanCode, actions) + if show and lasttab == "Keybindings" then + return keybindEditor.keyRelease(key, scanCode) + end + + return false +end + +function widget:TextInput(utf8char) + if show and lasttab == "Keybindings" then + return keybindEditor.textInput(utf8char) + end + + return false +end + local function mouseEvent(x, y, button, release) if Spring.IsGUIHidden() then return false @@ -404,6 +440,9 @@ local function mouseEvent(x, y, button, release) if show then -- on window if math_isInRect(x, y, screenX, screenY - screenHeight, screenX + screenWidth, screenY) then + if not release and lasttab == "Keybindings" then + keybindEditor.mousePress(x, y, button) + end return true else for tab, tabrect in pairs(tabrects) do @@ -411,7 +450,8 @@ local function mouseEvent(x, y, button, release) if keybinds then gl.DeleteList(keybinds) end - lasstab = tab + lasttab = tab + keybindEditor.blur() keybinds = gl.CreateList(drawWindow, tab) if backgroundGuishader ~= nil then if WG['guishader'] then @@ -427,6 +467,7 @@ local function mouseEvent(x, y, button, release) if release or not release then showOnceMore = show -- show once more because the guishader lags behind, though this will not fully fix it show = false + keybindEditor.blur() end end end @@ -440,9 +481,46 @@ function widget:MouseRelease(x, y, button) return mouseEvent(x, y, button, true) end +function widget:MouseWheel(up, value) + if show and lasttab == "Keybindings" then + return keybindEditor.mouseWheel(up, value) + end + + return false +end + +function widget:Update() + local want = show and lasttab == "Keybindings" and keybindEditor.wantsTextOwner() + if want then + widgetHandler.textOwner = widget + elseif widgetHandler.textOwner == widget then + widgetHandler.textOwner = nil + end +end + function widget:Initialize() refreshText() + widgetHandler:AddAction("keybindeditor", function() + lasttab = "Keybindings" + show = true + doUpdate = true + return true + end, nil, "t") + + keybindEditor.setMenuToggle(function(label) + if not (widgetHandler.DisableWidget and widgetHandler.EnableWidget) then + return + end + if label == "Grid" then + widgetHandler:DisableWidget('Build menu') + widgetHandler:EnableWidget('Grid menu') + elseif label == "Legacy" then + widgetHandler:DisableWidget('Grid menu') + widgetHandler:EnableWidget('Build menu') + end + end) + WG['keybinds'] = {} WG['keybinds'].toggle = function(state) if state ~= nil then @@ -450,6 +528,9 @@ function widget:Initialize() else show = not show end + if not show then + keybindEditor.blur() + end end WG['keybinds'].isvisible = function() return show @@ -462,6 +543,10 @@ function widget:Initialize() end function widget:Shutdown() + keybindEditor.blur() + if widgetHandler.textOwner == widget then + widgetHandler.textOwner = nil + end if keybinds then glDeleteList(keybinds) keybinds = nil diff --git a/luaui/configs/keybind_catalog.lua b/luaui/configs/keybind_catalog.lua new file mode 100644 index 00000000000..006ca5c651d --- /dev/null +++ b/luaui/configs/keybind_catalog.lua @@ -0,0 +1,129 @@ +-- Hand-curated catalog of commands for the in-game keybind editor, lifted from +-- the original Keybind/Mouse Info "Keybindings" tab so categorisation + labels +-- match what players already know. +-- +-- Entry kinds: +-- { action = "", label = "" } editable (chips + rebind) +-- { label = "", keyLabel = "" } informational, read-only +-- +-- action strings are the bindable form (command + space-separated args), i.e. +-- exactly what `/bind ` expects and what Spring.GetKeyBindings +-- reports as command(+extra). Anything bound but not listed here is shown by the +-- editor under an "Other" section, so nothing is ever hidden. + +return { + { category = "ui.keybinds.chat.title", items = { + { action = "chat", label = "ui.keybinds.chat.send" }, + { label = "ui.keybinds.chat.allies", keyLabel = "ui.keybinds.chat.alliesKey" }, + { label = "ui.keybinds.chat.spectators", keyLabel = "ui.keybinds.chat.spectatorsKey" }, + { label = "ui.keybinds.chat.ignore", keyLabel = "ui.keybinds.chat.ignoreKey" }, + } }, + + { category = "ui.keybinds.menus.title", items = { + { action = "options", label = "ui.keybinds.menus.settings" }, + { action = "sharedialog", label = "ui.keybinds.menus.share" }, + } }, + + { category = "ui.keybinds.camera.title", items = { + { label = "ui.keybinds.camera.zoom", keyLabel = "ui.keybinds.camera.zoomKey" }, + { label = "ui.keybinds.camera.pan", keyLabel = "ui.keybinds.camera.panKey" }, + { label = "ui.keybinds.camera.tilt", keyLabel = "ui.keybinds.camera.tiltKey" }, + { label = "ui.keybinds.camera.drag", keyLabel = "ui.keybinds.camera.dragKey" }, + { action = "cameraflip", label = "ui.keybinds.camera.flip" }, + } }, + + { category = "ui.keybinds.cameraModes.title", items = { + { action = "viewspring", label = "ui.keybinds.cameraModes.change" }, + { label = "ui.keybinds.cameraModes.fullscreen", keyLabel = "ui.keybinds.cameraModes.fullscreenKey" }, + { action = "toggleoverview", label = "ui.keybinds.cameraModes.overview" }, + { action = "togglelos", label = "ui.keybinds.cameraModes.los" }, + { action = "showelevation", label = "ui.keybinds.cameraModes.heightmap" }, + { action = "showpathtraversability", label = "ui.keybinds.cameraModes.traversability" }, + { action = "lastmsgpos", label = "ui.keybinds.cameraModes.mapmarks" }, + { action = "showmetalmap", label = "ui.keybinds.cameraModes.resourceSpots" }, + { action = "hideinterface", label = "ui.keybinds.cameraModes.interface" }, + } }, + + { category = "ui.keybinds.sound.title", items = { + { label = "ui.keybinds.sound.volume", keyLabel = "ui.keybinds.sound.volumeKey" }, + { action = "mutesound", label = "ui.keybinds.sound.mute" }, + } }, + + { category = "ui.keybinds.selection.title", items = { + { label = "ui.keybinds.selection.units", keyLabel = "ui.keybinds.selection.unitsKey" }, + } }, + + { category = "ui.keybinds.issueContextOrders.title", items = { + { label = "ui.keybinds.issueContextOrders.order", keyLabel = "ui.keybinds.issueContextOrders.orderKey" }, + { label = "ui.keybinds.issueContextOrders.formationOrder", keyLabel = "ui.keybinds.issueContextOrders.formationOrderKey" }, + } }, + + { category = "ui.keybinds.orders.title", items = { + { label = "ui.keybinds.orders.default", keyLabel = "ui.keybinds.orders.defaultKey" }, + { action = "move", label = "ui.keybinds.orders.move" }, + { action = "attack", label = "ui.keybinds.orders.attack" }, + { action = "settarget", label = "ui.keybinds.orders.setTarget" }, + { action = "repair", label = "ui.keybinds.orders.repair" }, + { action = "reclaim", label = "ui.keybinds.orders.reclaim" }, + { action = "resurrect", label = "ui.keybinds.orders.resurrect" }, + { action = "fight", label = "ui.keybinds.orders.fight" }, + { action = "patrol", label = "ui.keybinds.orders.patrol" }, + { action = "wantcloak", label = "ui.keybinds.orders.cloak" }, + { action = "stop", label = "ui.keybinds.orders.stop" }, + { action = "wait", label = "ui.keybinds.orders.wait" }, + { action = "canceltarget", label = "ui.keybinds.orders.cancelTarget" }, + { action = "manualfire", label = "ui.keybinds.orders.dGun" }, + { action = "selfd", label = "ui.keybinds.orders.selfDestruct" }, + } }, + + { category = "ui.keybinds.issueOrders.title", items = { + { label = "ui.keybinds.issueOrders.order", keyLabel = "ui.keybinds.issueOrders.orderKey" }, + { label = "ui.keybinds.issueOrders.revert", keyLabel = "ui.keybinds.issueOrders.revertKey" }, + { label = "ui.keybinds.issueOrders.formation", keyLabel = "ui.keybinds.issueOrders.formationKey" }, + } }, + + { category = "ui.keybinds.queues.title", items = { + { label = "ui.keybinds.queues.append", keyLabel = "ui.keybinds.queues.appendKey" }, + { action = "commandinsert", label = "ui.keybinds.queues.prepend" }, + } }, + + { category = "ui.keybinds.buildOrders.title", items = { + { label = "ui.keybinds.buildOrders.selectTile", keyLabel = "ui.keybinds.buildOrders.selectTileKey" }, + { label = "ui.keybinds.buildOrders.metal", keyLabel = "ui.keybinds.buildOrders.metalKey" }, + { label = "ui.keybinds.buildOrders.energy", keyLabel = "ui.keybinds.buildOrders.energyKey" }, + { label = "ui.keybinds.buildOrders.intel", keyLabel = "ui.keybinds.buildOrders.intelKey" }, + { label = "ui.keybinds.buildOrders.factories", keyLabel = "ui.keybinds.buildOrders.factoriesKey" }, + { action = "buildfacing_inc", label = "ui.keybinds.buildOrders.rotate" }, + } }, + + { category = "ui.keybinds.issueBuildOrders.title", items = { + { label = "ui.keybinds.issueBuildOrders.order", keyLabel = "ui.keybinds.issueBuildOrders.orderKey" }, + { label = "ui.keybinds.issueBuildOrders.deselect", keyLabel = "ui.keybinds.issueBuildOrders.deselect" }, + { label = "ui.keybinds.issueBuildOrders.line", keyLabel = "ui.keybinds.issueBuildOrders.lineKey" }, + { label = "ui.keybinds.issueBuildOrders.grid", keyLabel = "ui.keybinds.issueBuildOrders.gridKey" }, + { action = "buildspacing_inc", label = "ui.keybinds.issueBuildOrders.spacingUp" }, + { action = "buildspacing_dec", label = "ui.keybinds.issueBuildOrders.spacingDown" }, + } }, + + { category = "ui.keybinds.massSelect.title", items = { + { action = "select AllMap++_ClearSelection_SelectAll+", label = "ui.keybinds.massSelect.all" }, + { action = "select AllMap+_Builder_Idle+_ClearSelection_SelectOne+", label = "ui.keybinds.massSelect.builders" }, + { label = "ui.keybinds.massSelect.createGroup", keyLabel = "ui.keybinds.massSelect.createGroupKey" }, + { label = "ui.keybinds.massSelect.createAutoGroup", keyLabel = "ui.keybinds.massSelect.createAutoGroupKey" }, + { action = "remove_from_autogroup", label = "ui.keybinds.massSelect.removeAutoGroup" }, + { label = "ui.keybinds.massSelect.group", keyLabel = "ui.keybinds.massSelect.groupKey" }, + { action = "select AllMap+_InPrevSel+_ClearSelection_SelectAll+", label = "ui.keybinds.massSelect.sameType" }, + { action = "select PrevSelection+_Not_Building_Not_RelativeHealth_60+_ClearSelection_SelectAll+", label = "ui.keybinds.massSelect.damaged" }, + } }, + + { category = "ui.keybinds.drawing.title", items = { + { label = "ui.keybinds.drawing.mapmark", keyLabel = "ui.keybinds.drawing.mapmarkKey" }, + { label = "ui.keybinds.drawing.draw", keyLabel = "ui.keybinds.drawing.drawKey" }, + { label = "ui.keybinds.drawing.erase", keyLabel = "ui.keybinds.drawing.eraseKey" }, + } }, + + { category = "ui.keybinds.console.title", items = { + { label = "ui.keybinds.console.erase", keyLabel = "ui.keybinds.console.eraseKey" }, + { label = "ui.keybinds.console.pause", keyLabel = "ui.keybinds.console.pauseKey" }, + } }, +}