diff --git a/.github/workflows/rapid-build.yml b/.github/workflows/rapid-build.yml index b2ced92bd40..9b57f9341c0 100644 --- a/.github/workflows/rapid-build.yml +++ b/.github/workflows/rapid-build.yml @@ -37,10 +37,10 @@ jobs: run: | if [ -z "${PR_OR_BRANCH//[0-9]/}" ]; then commit="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_OR_BRANCH" --jq .head.sha)" - echo "branch=pr:$PR_OR_BRANCH" >> "$GITHUB_OUTPUT" + echo "branch=pr-$PR_OR_BRANCH" >> "$GITHUB_OUTPUT" else commit="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$PR_OR_BRANCH" --jq .object.sha)" - echo "branch=br:$PR_OR_BRANCH" >> "$GITHUB_OUTPUT" + echo "branch=br-$PR_OR_BRANCH" >> "$GITHUB_OUTPUT" fi echo "commit=$commit" >> "$GITHUB_OUTPUT" - uses: beyond-all-reason/rapid-hosting/action@main diff --git a/common/configs/keybind_catalog.json b/common/configs/keybind_catalog.json new file mode 100644 index 00000000000..ff827413aa4 --- /dev/null +++ b/common/configs/keybind_catalog.json @@ -0,0 +1,859 @@ +[ + { + "hidden": [ + "attack_range_dec", + "buildmenu_pregame_deselect", + "chat", + "chatswitchally", + "chatswitchspec", + "cloak", + "customgameinfo_close", + "dynamicsky", + "edit_backspace", + "edit_complete", + "edit_delete", + "edit_end", + "edit_escape", + "edit_home", + "edit_next_char", + "edit_next_line", + "edit_next_word", + "edit_prev_char", + "edit_prev_line", + "edit_prev_word", + "edit_return", + "losradar", + "luaui selector", + "movereset", + "moverotate", + "moveslow", + "movetilt", + "pastetext", + "quitforce", + "quitmenu", + "quitmessage", + "reloadforce", + "selectbox_any", + "selectbox_append", + "selectbox_deselect", + "selectbox_mobile", + "selectloop_add", + "selectloop_invert", + "teamstatus_close", + "toggle_allied_upgrade", + "togglecammode", + "togglelos", + "toggleoverview", + "track", + "trackmode", + "viewfps", + "viewfree", + "viewrot", + "viewspring", + "viewta" + ] + }, + { + "category": "categories.selection", + "items": [ + { + "action": "selectcomm append", + "label": "actions.selection.commAppend" + }, + { + "action": "selectcomm focus", + "label": "actions.selection.commFocus" + }, + { + "action": "select AllMap++_ClearSelection_SelectAll+", + "label": "actions.massSelect.all" + }, + { + "action": "select AllMap+_Builder_Idle+_ClearSelection_SelectOne+", + "label": "actions.massSelect.builders" + }, + { + "action": "select AllMap+_InPrevSel+_ClearSelection_SelectAll+", + "label": "actions.massSelect.sameType" + }, + { + "action": "select Visible+_InPrevSel+_ClearSelection_SelectAll+", + "label": "actions.massSelect.sameTypeVisible" + }, + { + "action": "select PrevSelection+_Not_Building_Not_RelativeHealth_60+_ClearSelection_SelectAll+", + "label": "actions.massSelect.damaged" + }, + { + "action": "select PrevSelection++_ClearSelection_SelectPart_50+", + "label": "actions.massSelect.half" + }, + { + "action": "select AllMap+_Transport_Idle+_ClearSelection_SelectAll+", + "label": "actions.massSelect.idleTransports" + }, + { + "action": "select Visible+_Waiting+_ClearSelection_SelectAll+", + "label": "actions.massSelect.waitingVisible" + }, + { + "action": "select AllMap++_ClearSelection_SelectNum_0+", + "label": "actions.massSelect.deselectAll" + }, + { + "action": "selectbox_idle", + "label": "actions.selection.boxIdle", + "alwaysModifier": "any" + }, + { + "action": "selectbox_same", + "label": "actions.selection.boxSame", + "alwaysModifier": "any" + } + ] + }, + { + "category": "categories.orders", + "items": [ + { + "action": "move", + "label": "commands.move", + "alwaysModifier": "shift" + }, + { + "action": "attack", + "label": "commands.attack", + "alwaysModifier": "shift" + }, + { + "action": "settarget", + "label": "commands.settarget", + "alwaysModifier": "shift" + }, + { + "action": "repair", + "label": "commands.repair", + "alwaysModifier": "shift" + }, + { + "action": "reclaim", + "label": "commands.reclaim", + "alwaysModifier": "shift" + }, + { + "action": "resurrect", + "label": "commands.resurrect", + "alwaysModifier": "shift" + }, + { + "action": "fight", + "label": "commands.fight", + "alwaysModifier": "shift" + }, + { + "action": "patrol", + "label": "commands.patrol", + "alwaysModifier": "shift" + }, + { + "action": "wantcloak", + "label": "actions.orders.cloak", + "alwaysModifier": "any" + }, + { + "action": "stop", + "label": "commands.stop", + "alwaysModifier": "shift" + }, + { + "action": "wait", + "label": "commands.wait" + }, + { + "action": "wait queued", + "label": "actions.orders.waitQueued" + }, + { + "action": "canceltarget", + "label": "commands.canceltarget" + }, + { + "action": "manualfire", + "label": "commands.manualfire", + "alwaysModifier": "shift" + }, + { + "action": "selfd", + "label": "actions.orders.selfDestruct" + }, + { + "action": "selfd queued", + "label": "actions.orders.selfDestructQueued" + }, + { + "action": "areaattack", + "label": "commands.areaattack", + "alwaysModifier": "shift" + }, + { + "action": "guard", + "label": "commands.guard", + "alwaysModifier": "shift" + }, + { + "action": "capture", + "label": "commands.capture", + "alwaysModifier": "shift" + }, + { + "action": "restore", + "label": "commands.restore", + "alwaysModifier": "shift" + }, + { + "action": "settargetnoground", + "label": "actions.orders.setTargetNoGround", + "alwaysModifier": "shift" + }, + { + "action": "loadunits", + "label": "commands.loadunits", + "alwaysModifier": "shift" + }, + { + "action": "unloadunits", + "label": "commands.unloadunits", + "alwaysModifier": "shift" + }, + { + "action": "gatherwait", + "label": "actions.orders.gatherWait", + "alwaysModifier": "shift" + }, + { + "action": "manuallaunch", + "label": "commands.manuallaunch", + "alwaysModifier": "shift" + }, + { + "action": "stopproduction", + "label": "commands.stopproduction", + "alwaysModifier": "shift" + } + ] + }, + { + "category": "categories.queues", + "items": [ + { + "action": "commandinsert prepend_between", + "label": "actions.queues.prepend", + "alwaysModifier": "any" + }, + { + "action": "command_skip_current", + "label": "actions.queues.skipCurrent" + }, + { + "action": "command_cancel_last", + "label": "actions.queues.cancelLast" + } + ] + }, + { + "category": "categories.unitStates", + "items": [ + { + "action": "onoff", + "label": "actions.unitStates.onoffToggle" + }, + { + "action": "onoff 0", + "label": "actions.unitStates.onoffOff" + }, + { + "action": "onoff 1", + "label": "actions.unitStates.onoffOn" + }, + { + "action": "repeat 0", + "label": "actions.unitStates.repeatOff" + }, + { + "action": "repeat 1", + "label": "actions.unitStates.repeatOn" + }, + { + "prefix": "trajectory_toggle ", + "label": "actions.unitStates.trajectory", + "members": [ + "0", + "1", + "2" + ] + }, + { + "action": "firestate 0", + "label": "actions.unitStates.fireHold" + }, + { + "action": "firestate 1", + "label": "actions.unitStates.fireReturn" + }, + { + "action": "firestate 2", + "label": "actions.unitStates.fireAtWill" + }, + { + "action": "movestate 0", + "label": "actions.unitStates.moveHold" + }, + { + "action": "movestate 1", + "label": "actions.unitStates.moveManeuver" + }, + { + "action": "movestate 2", + "label": "actions.unitStates.moveRoam" + } + ] + }, + { + "category": "categories.controlGroups", + "items": [ + { + "prefix": "group select ", + "label": "actions.controlGroups.select", + "members": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ] + }, + { + "prefix": "group focus ", + "label": "actions.controlGroups.focus", + "members": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ] + }, + { + "prefix": "group set ", + "label": "actions.controlGroups.assign", + "members": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ] + }, + { + "prefix": "group add ", + "label": "actions.controlGroups.add", + "members": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ] + }, + { + "prefix": "group selectadd ", + "label": "actions.controlGroups.selectAdd", + "members": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ] + }, + { + "prefix": "group selecttoggle ", + "label": "actions.controlGroups.selectToggle", + "members": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ] + }, + { + "action": "group unset", + "label": "actions.controlGroups.clear" + }, + { + "prefix": "add_to_autogroup ", + "label": "actions.controlGroups.addAuto", + "members": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ] + }, + { + "action": "remove_from_autogroup", + "label": "actions.massSelect.removeAutoGroup" + }, + { + "action": "remove_one_unit_from_group", + "label": "actions.controlGroups.removeOne" + }, + { + "prefix": "load_autogroup_preset ", + "label": "actions.controlGroups.preset", + "members": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ] + } + ] + }, + { + "category": "categories.buildHotkeys", + "items": [ + { + "action": "areamex", + "label": "commands.areamex" + }, + { + "prefix": "buildunit_", + "label": "actions.buildHotkeys.unit", + "unit": true + }, + { + "action": "buildfacing inc", + "label": "actions.buildOrders.rotate", + "alwaysModifier": "shift" + }, + { + "action": "buildfacing dec", + "label": "actions.buildOrders.rotateBack", + "alwaysModifier": "shift" + }, + { + "action": "buildspacing inc", + "label": "actions.issueBuildOrders.spacingUp", + "alwaysModifier": "shift" + }, + { + "action": "buildspacing dec", + "label": "actions.issueBuildOrders.spacingDown", + "alwaysModifier": "shift" + }, + { + "action": "buildsplit", + "label": "actions.buildHotkeys.split", + "alwaysModifier": "any" + }, + { + "action": "factoryqueuemode", + "label": "actions.factory.queueMode" + }, + { + "prefix": "factoryguard ", + "label": "commands.factoryguard", + "members": [ + "0", + "1" + ] + } + ] + }, + { + "category": "categories.gridMenu", + "items": [ + { + "action": "gridmenu_category 1", + "label": "ui.buildMenu.category_econ", + "alwaysModifier": "shift" + }, + { + "action": "gridmenu_category 2", + "label": "ui.buildMenu.category_combat", + "alwaysModifier": "shift" + }, + { + "action": "gridmenu_category 3", + "label": "ui.buildMenu.category_utility", + "alwaysModifier": "shift" + }, + { + "action": "gridmenu_category 4", + "label": "ui.buildMenu.category_production", + "alwaysModifier": "shift" + }, + { + "prefix": "gridmenu_key", + "label": "actions.gridMenu.buildKey", + "alwaysModifier": "any", + "members": [ + " 1 1", + " 1 2", + " 1 3", + " 1 4", + " 2 1", + " 2 2", + " 2 3", + " 2 4", + " 3 1", + " 3 2", + " 3 3", + " 3 4" + ] + }, + { + "action": "gridmenu_next_page", + "label": "actions.gridMenu.nextPage" + }, + { + "action": "gridmenu_cycle_builder", + "label": "actions.gridMenu.cycleBuilder" + } + ], + "layout": "grid" + }, + { + "category": "categories.blueprints", + "items": [ + { + "action": "blueprint_create", + "label": "commands.blueprint_create" + }, + { + "action": "blueprint_place", + "label": "commands.blueprint_place" + }, + { + "action": "blueprint_delete", + "label": "actions.blueprints.delete" + }, + { + "action": "blueprint_next", + "label": "actions.blueprints.next" + }, + { + "action": "blueprint_prev", + "label": "actions.blueprints.prev" + }, + { + "prefix": "blueprint_" + } + ] + }, + { + "category": "categories.camera", + "items": [ + { + "action": "cameraflip", + "label": "actions.camera.flip" + }, + { + "action": "moveforward", + "label": "actions.camera.moveForward", + "alwaysModifier": "any" + }, + { + "action": "moveback", + "label": "actions.camera.moveBack", + "alwaysModifier": "any" + }, + { + "action": "moveleft", + "label": "actions.camera.moveLeft", + "alwaysModifier": "any" + }, + { + "action": "moveright", + "label": "actions.camera.moveRight", + "alwaysModifier": "any" + }, + { + "action": "moveup", + "label": "actions.camera.moveUp", + "alwaysModifier": "any" + }, + { + "action": "movedown", + "label": "actions.camera.moveDown", + "alwaysModifier": "any" + }, + { + "action": "movefast", + "label": "actions.camera.moveFast", + "alwaysModifier": "any" + }, + { + "prefix": "set_camera_anchor ", + "label": "actions.camera.setAnchor", + "members": [ + "1", + "2", + "3", + "4" + ] + }, + { + "prefix": "focus_camera_anchor ", + "label": "actions.camera.jumpAnchor", + "members": [ + "1", + "2", + "3", + "4" + ] + }, + { + "action": "lastmsgpos", + "label": "actions.cameraModes.mapmarks" + }, + { + "action": "cycleselected next", + "label": "actions.camera.cycleSelectedNext" + }, + { + "action": "cycleselected prev", + "label": "actions.camera.cycleSelectedPrev" + }, + { + "action": "pip1_switch", + "label": "actions.camera.pipSwitch" + }, + { + "action": "pip1_copy", + "label": "actions.camera.pipCopy" + }, + { + "action": "pip1_track", + "label": "actions.camera.pipTrack" + } + ] + }, + { + "category": "categories.mapViews", + "items": [ + { + "action": "showelevation", + "label": "actions.cameraModes.heightmap" + }, + { + "action": "showpathtraversability", + "label": "actions.cameraModes.traversability" + }, + { + "action": "showmetalmap", + "label": "actions.cameraModes.resourceSpots" + } + ] + }, + { + "category": "categories.interfaceDisplay", + "items": [ + { + "action": "options", + "label": "actions.interfaceDisplay.settings" + }, + { + "action": "hideinterface", + "label": "actions.cameraModes.interface" + }, + { + "action": "unit_stats", + "label": "actions.interfaceDisplay.unitStats" + }, + { + "action": "savegame", + "label": "actions.interfaceDisplay.saveGame" + }, + { + "action": "screenshot png", + "label": "actions.interfaceDisplay.screenshot" + }, + { + "action": "attack_range_inc", + "label": "actions.interfaceDisplay.firingRange" + }, + { + "action": "chain force forcestart | say !cv forcestart", + "label": "actions.gameControl.voteForcestart" + } + ] + }, + { + "category": "categories.drawing", + "items": [ + { + "action": "drawinmap", + "label": "actions.drawing.drawInMap", + "alwaysModifier": "any" + }, + { + "action": "drawlabel", + "label": "actions.drawing.drawLabel" + }, + { + "action": "clearmapmarks", + "label": "actions.console.erase" + } + ] + }, + { + "category": "categories.sound", + "items": [ + { + "action": "mutesound", + "label": "actions.sound.mute" + }, + { + "action": "snd_volume_increase", + "label": "actions.sound.volumeUp" + }, + { + "action": "snd_volume_decrease", + "label": "actions.sound.volumeDown" + } + ] + }, + { + "category": "categories.gameControl", + "items": [ + { + "action": "pause", + "label": "actions.console.pause", + "alwaysModifier": "any" + }, + { + "action": "increasespeed", + "label": "actions.gameControl.increaseSpeed" + }, + { + "action": "decreasespeed", + "label": "actions.gameControl.decreaseSpeed" + }, + { + "prefix": "specteam ", + "label": "actions.spectating.spectate", + "members": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8" + ] + } + ] + }, + { + "category": "categories.other", + "items": [ + { + "action": "selectloop", + "label": "actions.selection.loopActivate", + "alwaysModifier": "any" + }, + { + "prefix": "factory_preset load ", + "label": "actions.factory.loadPreset", + "members": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ] + }, + { + "prefix": "factory_preset save ", + "label": "actions.factory.savePreset", + "members": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ] + }, + { + "action": "factory_preset_show", + "label": "actions.factory.showPresets", + "alwaysModifier": "any" + }, + { + "action": "fov_inc 5", + "label": "actions.camera.fovIncrease" + }, + { + "action": "fov_dec 5", + "label": "actions.camera.fovDecrease" + } + ] + } +] diff --git a/common/configs/keybind_catalog.schema.json b/common/configs/keybind_catalog.schema.json new file mode 100644 index 00000000000..db1c86f1480 --- /dev/null +++ b/common/configs/keybind_catalog.schema.json @@ -0,0 +1,71 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/beyond-all-reason/Beyond-All-Reason/master/common/configs/keybind_catalog.schema.json", + "title": "Keybind catalog", + "description": "Ordered, cross-surface catalog of keybindable commands grouped into categories. Shared by the in-game editor and external lobbies. Holds i18n keys and bind-action ids, not resolved strings or actual key bindings.", + "type": "array", + "minItems": 1, + "items": { + "oneOf": [ + { + "type": "object", + "description": "Category of keybindable commands.", + "required": ["category", "items"], + "additionalProperties": false, + "properties": { + "category": { + "type": "string", + "description": "i18n key for the category title." + }, + "items": { + "type": "array", + "items": { "$ref": "#/definitions/item" } + }, + "layout": { "type": "string", "enum": ["grid"], "description": "Renders this category as the grid menu's own 3x4 layout instead of a flat list, so the keys read the way they sit on screen." } + } + }, + { + "type": "object", + "description": "Actions bound but intentionally never shown, matched by exact id (not prefix).", + "required": ["hidden"], + "additionalProperties": false, + "properties": { + "hidden": { + "type": "array", + "items": { "type": "string", "description": "Exact action id to suppress." } + } + } + } + ] + }, + "definitions": { + "item": { + "oneOf": [ + { + "type": "object", + "description": "Editable binding the user can rebind.", + "required": ["action", "label"], + "additionalProperties": false, + "properties": { + "action": { "type": "string", "description": "Bind command (command + space-separated args), exactly as passed to /bind and reported by GetKeyBindings." }, + "label": { "type": "string", "description": "i18n key for the display label." }, + "alwaysModifier": { "type": "string", "enum": ["any", "shift"], "description": "Modifier this action always tolerates, so a surface neither shows it nor lets the player pick it. \"any\" binds with the engine's Any+ qualifier and fires whatever is held. \"shift\" has no engine equivalent, so the binding is written twice, bare and Shift+, and both halves move together. Fixed per action rather than chosen." } + } + }, + { + "type": "object", + "description": "Prefix group: claims every bound action whose id starts with this prefix. With a label the arg after the prefix is interpolated as %{n} (or its two whitespace-split tokens as %{row}/%{col}); without one, rows show the raw id. With unit=true the arg is resolved to a unit's translated name.", + "required": ["prefix"], + "additionalProperties": false, + "properties": { + "prefix": { "type": "string", "description": "Action id prefix." }, + "label": { "type": "string", "description": "i18n key for the display label, interpolated per matched action." }, + "unit": { "type": "boolean", "description": "When true, the arg after the prefix is a unit codename resolved to its translated human name." }, + "members": { "type": "array", "items": { "type": "string" }, "description": "The args this family covers, appended to the prefix to form each action. Listing them makes the rows exist whether or not anything is bound, so unbinding one leaves it there to bind again. Omit for families that cannot be enumerated (buildunit_ is per unit) and they are discovered from what is bound." }, + "alwaysModifier": { "type": "string", "enum": ["any"], "description": "Modifier this action always tolerates, so a surface neither shows it nor lets the player pick it. \"any\" binds with the engine's Any+ qualifier and fires whatever is held. Fixed per action rather than chosen." } + } + } + ] + } + } +} diff --git a/common/configs/keybind_defaults.json b/common/configs/keybind_defaults.json new file mode 100644 index 00000000000..2d7e7ba2943 --- /dev/null +++ b/common/configs/keybind_defaults.json @@ -0,0 +1,6054 @@ +{ + "version": 1, + "priority": [], + "profiles": [ + { + "name": "Grid", + "binds": [ + { + "keyset": "esc", + "action": "select AllMap++_ClearSelection_SelectNum_0+" + }, + { + "keyset": "esc", + "action": "quitmessage" + }, + { + "keyset": "Shift+esc", + "action": "quitmenu" + }, + { + "keyset": "Ctrl+Shift+esc", + "action": "quitforce" + }, + { + "keyset": "Alt+Shift+esc", + "action": "reloadforce" + }, + { + "keyset": "Any+escape", + "action": "edit_escape" + }, + { + "keyset": "Any+pause", + "action": "pause" + }, + { + "keyset": "esc", + "action": "teamstatus_close" + }, + { + "keyset": "esc", + "action": "customgameinfo_close" + }, + { + "keyset": "esc", + "action": "buildmenu_pregame_deselect" + }, + { + "keyset": "Any+sc_z", + "action": "selectbox_same" + }, + { + "keyset": "Any+space", + "action": "selectbox_idle" + }, + { + "keyset": "Any+shift", + "action": "selectbox_append" + }, + { + "keyset": "Any+shift", + "action": "selectbox_any" + }, + { + "keyset": "Any+ctrl", + "action": "selectbox_deselect" + }, + { + "keyset": "Any+alt", + "action": "selectbox_mobile" + }, + { + "keyset": "Any+space", + "action": "selectloop" + }, + { + "keyset": "Any+ctrl", + "action": "selectloop_invert" + }, + { + "keyset": "Any+shift", + "action": "selectloop_add" + }, + { + "keyset": "Any+space", + "action": "buildsplit" + }, + { + "keyset": "Any+space", + "action": "commandinsert prepend_between" + }, + { + "keyset": "alt+sc_.", + "action": "attack_range_inc" + }, + { + "keyset": "alt+sc_comma", + "action": "attack_range_dec" + }, + { + "keyset": "Any+enter", + "action": "chat" + }, + { + "keyset": "Alt+ctrl+sc_a", + "action": "chatswitchally" + }, + { + "keyset": "Alt+ctrl+sc_s", + "action": "chatswitchspec" + }, + { + "keyset": "Any+tab", + "action": "edit_complete" + }, + { + "keyset": "Any+backspace", + "action": "edit_backspace" + }, + { + "keyset": "Any+delete", + "action": "edit_delete" + }, + { + "keyset": "Any+home", + "action": "edit_home" + }, + { + "keyset": "Alt+left", + "action": "edit_home" + }, + { + "keyset": "Any+end", + "action": "edit_end" + }, + { + "keyset": "Alt+right", + "action": "edit_end" + }, + { + "keyset": "Any+up", + "action": "edit_prev_line" + }, + { + "keyset": "Any+down", + "action": "edit_next_line" + }, + { + "keyset": "Any+left", + "action": "edit_prev_char" + }, + { + "keyset": "Any+right", + "action": "edit_next_char" + }, + { + "keyset": "Ctrl+left", + "action": "edit_prev_word" + }, + { + "keyset": "Ctrl+right", + "action": "edit_next_word" + }, + { + "keyset": "Any+enter", + "action": "edit_return" + }, + { + "keyset": "Ctrl+v", + "action": "pastetext" + }, + { + "keyset": "Any+up", + "action": "moveforward" + }, + { + "keyset": "Any+down", + "action": "moveback" + }, + { + "keyset": "Any+right", + "action": "moveright" + }, + { + "keyset": "Any+left", + "action": "moveleft" + }, + { + "keyset": "Any+pageup", + "action": "moveup" + }, + { + "keyset": "Any+pagedown", + "action": "movedown" + }, + { + "keyset": "Any+alt", + "action": "movereset" + }, + { + "keyset": "Any+alt", + "action": "moverotate" + }, + { + "keyset": "Any+ctrl", + "action": "movetilt" + }, + { + "keyset": "ctrl+sc_o", + "action": "fov_dec 5" + }, + { + "keyset": "ctrl+sc_p", + "action": "fov_inc 5" + }, + { + "keyset": "sc_numpad1", + "action": "fov_dec 5" + }, + { + "keyset": "sc_numpad7", + "action": "fov_inc 5" + }, + { + "keyset": "Meta+ctrl+tab", + "action": "pip1_copy" + }, + { + "keyset": "Meta+tab", + "action": "pip1_switch" + }, + { + "keyset": "Alt+sc_t", + "action": "pip1_track" + }, + { + "keyset": "Any+alt", + "action": "toggle_allied_upgrade" + }, + { + "keyset": "sc_z", + "action": "gridmenu_category 1" + }, + { + "keyset": "sc_x", + "action": "gridmenu_category 2" + }, + { + "keyset": "sc_c", + "action": "gridmenu_category 3" + }, + { + "keyset": "sc_v", + "action": "gridmenu_category 4" + }, + { + "keyset": "Shift+sc_z", + "action": "gridmenu_category 1" + }, + { + "keyset": "Shift+sc_x", + "action": "gridmenu_category 2" + }, + { + "keyset": "Shift+sc_c", + "action": "gridmenu_category 3" + }, + { + "keyset": "Shift+sc_v", + "action": "gridmenu_category 4" + }, + { + "keyset": "Any+sc_z", + "action": "gridmenu_key 1 1" + }, + { + "keyset": "Any+sc_x", + "action": "gridmenu_key 1 2" + }, + { + "keyset": "Any+sc_c", + "action": "gridmenu_key 1 3" + }, + { + "keyset": "Any+sc_v", + "action": "gridmenu_key 1 4" + }, + { + "keyset": "Any+sc_a", + "action": "gridmenu_key 2 1" + }, + { + "keyset": "Any+sc_s", + "action": "gridmenu_key 2 2" + }, + { + "keyset": "Any+sc_d", + "action": "gridmenu_key 2 3" + }, + { + "keyset": "Any+sc_f", + "action": "gridmenu_key 2 4" + }, + { + "keyset": "Any+sc_q", + "action": "gridmenu_key 3 1" + }, + { + "keyset": "Any+sc_w", + "action": "gridmenu_key 3 2" + }, + { + "keyset": "Any+sc_e", + "action": "gridmenu_key 3 3" + }, + { + "keyset": "Any+sc_r", + "action": "gridmenu_key 3 4" + }, + { + "keyset": "sc_b", + "action": "gridmenu_next_page" + }, + { + "keyset": "sc_.", + "action": "gridmenu_cycle_builder" + }, + { + "keyset": "sc_.", + "action": "cycleselected next" + }, + { + "keyset": "sc_comma", + "action": "cycleselected prev" + }, + { + "keyset": "1", + "action": "specteam 0" + }, + { + "keyset": "2", + "action": "specteam 1" + }, + { + "keyset": "3", + "action": "specteam 2" + }, + { + "keyset": "4", + "action": "specteam 3" + }, + { + "keyset": "5", + "action": "specteam 4" + }, + { + "keyset": "6", + "action": "specteam 5" + }, + { + "keyset": "7", + "action": "specteam 6" + }, + { + "keyset": "8", + "action": "specteam 7" + }, + { + "keyset": "9", + "action": "specteam 8" + }, + { + "keyset": "Alt+0", + "action": "add_to_autogroup 0" + }, + { + "keyset": "Alt+1", + "action": "add_to_autogroup 1" + }, + { + "keyset": "Alt+2", + "action": "add_to_autogroup 2" + }, + { + "keyset": "Alt+3", + "action": "add_to_autogroup 3" + }, + { + "keyset": "Alt+4", + "action": "add_to_autogroup 4" + }, + { + "keyset": "Alt+5", + "action": "add_to_autogroup 5" + }, + { + "keyset": "Alt+6", + "action": "add_to_autogroup 6" + }, + { + "keyset": "Alt+7", + "action": "add_to_autogroup 7" + }, + { + "keyset": "Alt+8", + "action": "add_to_autogroup 8" + }, + { + "keyset": "Alt+9", + "action": "add_to_autogroup 9" + }, + { + "keyset": "Shift+Alt+0", + "action": "load_autogroup_preset 0" + }, + { + "keyset": "Shift+Alt+1", + "action": "load_autogroup_preset 1" + }, + { + "keyset": "Shift+Alt+2", + "action": "load_autogroup_preset 2" + }, + { + "keyset": "Shift+Alt+3", + "action": "load_autogroup_preset 3" + }, + { + "keyset": "Shift+Alt+4", + "action": "load_autogroup_preset 4" + }, + { + "keyset": "Shift+Alt+5", + "action": "load_autogroup_preset 5" + }, + { + "keyset": "Shift+Alt+6", + "action": "load_autogroup_preset 6" + }, + { + "keyset": "Shift+Alt+7", + "action": "load_autogroup_preset 7" + }, + { + "keyset": "Shift+Alt+8", + "action": "load_autogroup_preset 8" + }, + { + "keyset": "Shift+Alt+9", + "action": "load_autogroup_preset 9" + }, + { + "keyset": "0,0", + "action": "group focus 0" + }, + { + "keyset": "1,1", + "action": "group focus 1" + }, + { + "keyset": "2,2", + "action": "group focus 2" + }, + { + "keyset": "3,3", + "action": "group focus 3" + }, + { + "keyset": "4,4", + "action": "group focus 4" + }, + { + "keyset": "5,5", + "action": "group focus 5" + }, + { + "keyset": "6,6", + "action": "group focus 6" + }, + { + "keyset": "7,7", + "action": "group focus 7" + }, + { + "keyset": "8,8", + "action": "group focus 8" + }, + { + "keyset": "9,9", + "action": "group focus 9" + }, + { + "keyset": "0", + "action": "group select 0" + }, + { + "keyset": "1", + "action": "group select 1" + }, + { + "keyset": "2", + "action": "group select 2" + }, + { + "keyset": "3", + "action": "group select 3" + }, + { + "keyset": "4", + "action": "group select 4" + }, + { + "keyset": "5", + "action": "group select 5" + }, + { + "keyset": "6", + "action": "group select 6" + }, + { + "keyset": "7", + "action": "group select 7" + }, + { + "keyset": "8", + "action": "group select 8" + }, + { + "keyset": "9", + "action": "group select 9" + }, + { + "keyset": "Ctrl+0", + "action": "group set 0" + }, + { + "keyset": "Ctrl+1", + "action": "group set 1" + }, + { + "keyset": "Ctrl+2", + "action": "group set 2" + }, + { + "keyset": "Ctrl+3", + "action": "group set 3" + }, + { + "keyset": "Ctrl+4", + "action": "group set 4" + }, + { + "keyset": "Ctrl+5", + "action": "group set 5" + }, + { + "keyset": "Ctrl+6", + "action": "group set 6" + }, + { + "keyset": "Ctrl+7", + "action": "group set 7" + }, + { + "keyset": "Ctrl+8", + "action": "group set 8" + }, + { + "keyset": "Ctrl+9", + "action": "group set 9" + }, + { + "keyset": "Shift+0", + "action": "group selectadd 0" + }, + { + "keyset": "Shift+1", + "action": "group selectadd 1" + }, + { + "keyset": "Shift+2", + "action": "group selectadd 2" + }, + { + "keyset": "Shift+3", + "action": "group selectadd 3" + }, + { + "keyset": "Shift+4", + "action": "group selectadd 4" + }, + { + "keyset": "Shift+5", + "action": "group selectadd 5" + }, + { + "keyset": "Shift+6", + "action": "group selectadd 6" + }, + { + "keyset": "Shift+7", + "action": "group selectadd 7" + }, + { + "keyset": "Shift+8", + "action": "group selectadd 8" + }, + { + "keyset": "Shift+9", + "action": "group selectadd 9" + }, + { + "keyset": "Ctrl+Shift+0", + "action": "group add 0" + }, + { + "keyset": "Ctrl+Shift+1", + "action": "group add 1" + }, + { + "keyset": "Ctrl+Shift+2", + "action": "group add 2" + }, + { + "keyset": "Ctrl+Shift+3", + "action": "group add 3" + }, + { + "keyset": "Ctrl+Shift+4", + "action": "group add 4" + }, + { + "keyset": "Ctrl+Shift+5", + "action": "group add 5" + }, + { + "keyset": "Ctrl+Shift+6", + "action": "group add 6" + }, + { + "keyset": "Ctrl+Shift+7", + "action": "group add 7" + }, + { + "keyset": "Ctrl+Shift+8", + "action": "group add 8" + }, + { + "keyset": "Ctrl+Shift+9", + "action": "group add 9" + }, + { + "keyset": "Ctrl+Alt+0", + "action": "group selecttoggle 0" + }, + { + "keyset": "Ctrl+Alt+1", + "action": "group selecttoggle 1" + }, + { + "keyset": "Ctrl+Alt+2", + "action": "group selecttoggle 2" + }, + { + "keyset": "Ctrl+Alt+3", + "action": "group selecttoggle 3" + }, + { + "keyset": "Ctrl+Alt+4", + "action": "group selecttoggle 4" + }, + { + "keyset": "Ctrl+Alt+5", + "action": "group selecttoggle 5" + }, + { + "keyset": "Ctrl+Alt+6", + "action": "group selecttoggle 6" + }, + { + "keyset": "Ctrl+Alt+7", + "action": "group selecttoggle 7" + }, + { + "keyset": "Ctrl+Alt+8", + "action": "group selecttoggle 8" + }, + { + "keyset": "Ctrl+Alt+9", + "action": "group selecttoggle 9" + }, + { + "keyset": "meta+alt+0", + "action": "factory_preset save 0" + }, + { + "keyset": "meta+alt+1", + "action": "factory_preset save 1" + }, + { + "keyset": "meta+alt+2", + "action": "factory_preset save 2" + }, + { + "keyset": "meta+alt+3", + "action": "factory_preset save 3" + }, + { + "keyset": "meta+alt+4", + "action": "factory_preset save 4" + }, + { + "keyset": "meta+alt+5", + "action": "factory_preset save 5" + }, + { + "keyset": "meta+alt+6", + "action": "factory_preset save 6" + }, + { + "keyset": "meta+alt+7", + "action": "factory_preset save 7" + }, + { + "keyset": "meta+alt+8", + "action": "factory_preset save 8" + }, + { + "keyset": "meta+alt+9", + "action": "factory_preset save 9" + }, + { + "keyset": "meta+0", + "action": "factory_preset load 0" + }, + { + "keyset": "meta+1", + "action": "factory_preset load 1" + }, + { + "keyset": "meta+2", + "action": "factory_preset load 2" + }, + { + "keyset": "meta+3", + "action": "factory_preset load 3" + }, + { + "keyset": "meta+4", + "action": "factory_preset load 4" + }, + { + "keyset": "meta+5", + "action": "factory_preset load 5" + }, + { + "keyset": "meta+6", + "action": "factory_preset load 6" + }, + { + "keyset": "meta+7", + "action": "factory_preset load 7" + }, + { + "keyset": "meta+8", + "action": "factory_preset load 8" + }, + { + "keyset": "meta+9", + "action": "factory_preset load 9" + }, + { + "keyset": "any+sc_space", + "action": "factory_preset_show" + }, + { + "keyset": "Alt+sc_=", + "action": "increasespeed" + }, + { + "keyset": "Alt+sc_-", + "action": "decreasespeed" + }, + { + "keyset": "Alt+numpad+", + "action": "increasespeed" + }, + { + "keyset": "Alt+numpad-", + "action": "decreasespeed" + }, + { + "keyset": "sc_[", + "action": "buildfacing inc" + }, + { + "keyset": "Shift+sc_[", + "action": "buildfacing inc" + }, + { + "keyset": "sc_]", + "action": "buildfacing dec" + }, + { + "keyset": "Shift+sc_]", + "action": "buildfacing dec" + }, + { + "keyset": "Alt+sc_z", + "action": "buildspacing inc" + }, + { + "keyset": "Shift+Alt+sc_z", + "action": "buildspacing inc" + }, + { + "keyset": "Alt+sc_x", + "action": "buildspacing dec" + }, + { + "keyset": "Shift+Alt+sc_x", + "action": "buildspacing dec" + }, + { + "keyset": "sc_a", + "action": "attack" + }, + { + "keyset": "Shift+sc_a", + "action": "attack" + }, + { + "keyset": "Ctrl+sc_a", + "action": "areaattack" + }, + { + "keyset": "Ctrl+Shift+sc_a", + "action": "areaattack" + }, + { + "keyset": "Ctrl+sc_b", + "action": "selfd" + }, + { + "keyset": "Ctrl+Shift+sc_b", + "action": "selfd queued" + }, + { + "keyset": "sc_d", + "action": "manualfire" + }, + { + "keyset": "Shift+sc_d", + "action": "manualfire" + }, + { + "keyset": "sc_d", + "action": "manuallaunch" + }, + { + "keyset": "Shift+sc_d", + "action": "manuallaunch" + }, + { + "keyset": "sc_e", + "action": "reclaim" + }, + { + "keyset": "Shift+sc_e", + "action": "reclaim" + }, + { + "keyset": "sc_f", + "action": "fight" + }, + { + "keyset": "Shift+sc_f", + "action": "fight" + }, + { + "keyset": "Alt+sc_f", + "action": "chain force forcestart | say !cv forcestart" + }, + { + "keyset": "sc_g", + "action": "stopproduction" + }, + { + "keyset": "Shift+sc_g", + "action": "stopproduction" + }, + { + "keyset": "sc_g", + "action": "stop" + }, + { + "keyset": "Shift+sc_g", + "action": "stop" + }, + { + "keyset": "sc_h", + "action": "patrol" + }, + { + "keyset": "Shift+sc_h", + "action": "patrol" + }, + { + "keyset": "sc_i", + "action": "unit_stats" + }, + { + "keyset": "sc_j", + "action": "loadunits" + }, + { + "keyset": "Shift+sc_j", + "action": "loadunits" + }, + { + "keyset": "sc_k", + "action": "cloak" + }, + { + "keyset": "Shift+sc_k", + "action": "cloak" + }, + { + "keyset": "sc_k", + "action": "wantcloak" + }, + { + "keyset": "Any+sc_k", + "action": "wantcloak" + }, + { + "keyset": "sc_m", + "action": "restore" + }, + { + "keyset": "Shift+sc_m", + "action": "restore" + }, + { + "keyset": "sc_n", + "action": "command_skip_current" + }, + { + "keyset": "Ctrl+sc_n", + "action": "command_cancel_last" + }, + { + "keyset": "sc_o", + "action": "guard" + }, + { + "keyset": "Shift+sc_o", + "action": "guard" + }, + { + "keyset": "sc_p", + "action": "gatherwait" + }, + { + "keyset": "Shift+sc_p", + "action": "gatherwait" + }, + { + "keyset": "sc_r", + "action": "repair" + }, + { + "keyset": "Shift+sc_r", + "action": "repair" + }, + { + "keyset": "sc_s", + "action": "settarget" + }, + { + "keyset": "Shift+sc_s", + "action": "settarget" + }, + { + "keyset": "Alt+sc_s", + "action": "settargetnoground" + }, + { + "keyset": "Shift+Alt+sc_s", + "action": "settargetnoground" + }, + { + "keyset": "Ctrl+sc_s", + "action": "canceltarget" + }, + { + "keyset": "sc_u", + "action": "unloadunits" + }, + { + "keyset": "Shift+sc_u", + "action": "unloadunits" + }, + { + "keyset": "sc_w", + "action": "resurrect" + }, + { + "keyset": "Shift+sc_w", + "action": "resurrect" + }, + { + "keyset": "sc_w", + "action": "capture" + }, + { + "keyset": "Shift+sc_w", + "action": "capture" + }, + { + "keyset": "sc_y", + "action": "wait" + }, + { + "keyset": "Shift+sc_y", + "action": "wait queued" + }, + { + "keyset": "sc_b,sc_b", + "action": "onoff 0" + }, + { + "keyset": "sc_b", + "action": "onoff 1" + }, + { + "keyset": "Shift+sc_b,Shift+sc_b", + "action": "onoff 0" + }, + { + "keyset": "Shift+sc_b", + "action": "onoff 1" + }, + { + "keyset": "sc_b,sc_b,sc_b", + "action": "trajectory_toggle 1" + }, + { + "keyset": "sc_b,sc_b", + "action": "trajectory_toggle 0" + }, + { + "keyset": "sc_b", + "action": "trajectory_toggle 2" + }, + { + "keyset": "Shift+sc_b,Shift+sc_b,Shift+sc_b", + "action": "trajectory_toggle 1" + }, + { + "keyset": "Shift+sc_b,Shift+sc_b", + "action": "trajectory_toggle 0" + }, + { + "keyset": "Shift+sc_b", + "action": "trajectory_toggle 2" + }, + { + "keyset": "sc_l,sc_l,sc_l", + "action": "firestate 1" + }, + { + "keyset": "sc_l,sc_l", + "action": "firestate 0" + }, + { + "keyset": "sc_l", + "action": "firestate 2" + }, + { + "keyset": "Shift+sc_l,Shift+sc_l,Shift+sc_l", + "action": "firestate 1" + }, + { + "keyset": "Shift+sc_l,Shift+sc_l", + "action": "firestate 0" + }, + { + "keyset": "Shift+sc_l", + "action": "firestate 2" + }, + { + "keyset": "sc_;,sc_;,sc_;", + "action": "movestate 1" + }, + { + "keyset": "sc_;,sc_;", + "action": "movestate 0" + }, + { + "keyset": "sc_;", + "action": "movestate 2" + }, + { + "keyset": "Shift+sc_;,Shift+sc_;,Shift+sc_;", + "action": "movestate 1" + }, + { + "keyset": "Shift+sc_;,Shift+sc_;", + "action": "movestate 0" + }, + { + "keyset": "Shift+sc_;", + "action": "movestate 2" + }, + { + "keyset": "sc_t,sc_t", + "action": "repeat 0" + }, + { + "keyset": "sc_t", + "action": "repeat 1" + }, + { + "keyset": "Shift+sc_t,Shift+sc_t", + "action": "repeat 0" + }, + { + "keyset": "Shift+sc_t", + "action": "repeat 1" + }, + { + "keyset": "Ctrl+sc_g,Ctrl+sc_g", + "action": "factoryguard 0" + }, + { + "keyset": "Ctrl+sc_g", + "action": "factoryguard 1" + }, + { + "keyset": "Alt+sc_o", + "action": "cameraflip" + }, + { + "keyset": "Ctrl+f7", + "action": "hideinterface" + }, + { + "keyset": "Any+f5", + "action": "lastmsgpos" + }, + { + "keyset": "Any+f6", + "action": "showpathtraversability" + }, + { + "keyset": "Any+f7", + "action": "showmetalmap" + }, + { + "keyset": "Any+f8", + "action": "showelevation" + }, + { + "keyset": "f10", + "action": "options" + }, + { + "keyset": "f11", + "action": "luaui selector" + }, + { + "keyset": "Any+f12", + "action": "screenshot png" + }, + { + "keyset": "Ctrl+sc_`", + "action": "group unset" + }, + { + "keyset": "Alt+sc_`", + "action": "remove_from_autogroup" + }, + { + "keyset": "sc_`,sc_`", + "action": "drawlabel" + }, + { + "keyset": "sc_`", + "action": "drawinmap" + }, + { + "keyset": "Ctrl+sc_e", + "action": "select AllMap++_ClearSelection_SelectAll+" + }, + { + "keyset": "Ctrl+tab", + "action": "select AllMap+_Builder_Idle+_ClearSelection_SelectOne+" + }, + { + "keyset": "Shift+tab", + "action": "selectcomm append" + }, + { + "keyset": "tab", + "action": "selectcomm focus" + }, + { + "keyset": "sc_q", + "action": "select Visible+_InPrevSel+_ClearSelection_SelectAll+" + }, + { + "keyset": "Ctrl+sc_q", + "action": "select PrevSelection++_ClearSelection_SelectPart_50+" + }, + { + "keyset": "Ctrl+sc_w", + "action": "select AllMap+_InPrevSel+_ClearSelection_SelectAll+" + }, + { + "keyset": "Ctrl+sc_r", + "action": "select AllMap+_Transport_Idle+_ClearSelection_SelectAll+" + }, + { + "keyset": "Ctrl+sc_y", + "action": "select Visible+_Waiting+_ClearSelection_SelectAll+" + }, + { + "keyset": "Alt+sc_q", + "action": "select PrevSelection+_Not_Building_Not_RelativeHealth_60+_ClearSelection_SelectAll+" + }, + { + "keyset": "numpad2", + "action": "moveback" + }, + { + "keyset": "numpad6", + "action": "moveright" + }, + { + "keyset": "numpad4", + "action": "moveleft" + }, + { + "keyset": "numpad8", + "action": "moveforward" + }, + { + "keyset": "numpad9", + "action": "moveup" + }, + { + "keyset": "numpad3", + "action": "movedown" + }, + { + "keyset": "numpad1", + "action": "movefast" + }, + { + "keyset": "backspace", + "action": "mutesound" + }, + { + "keyset": "numpad+", + "action": "snd_volume_increase" + }, + { + "keyset": "sc_=", + "action": "snd_volume_increase" + }, + { + "keyset": "sc_-", + "action": "snd_volume_decrease" + }, + { + "keyset": "numpad-", + "action": "snd_volume_decrease" + }, + { + "keyset": "Ctrl+F1", + "action": "set_camera_anchor 1" + }, + { + "keyset": "Ctrl+F2", + "action": "set_camera_anchor 2" + }, + { + "keyset": "Ctrl+F3", + "action": "set_camera_anchor 3" + }, + { + "keyset": "Ctrl+F4", + "action": "set_camera_anchor 4" + }, + { + "keyset": "F1", + "action": "focus_camera_anchor 1" + }, + { + "keyset": "F2", + "action": "focus_camera_anchor 2" + }, + { + "keyset": "F3", + "action": "focus_camera_anchor 3" + }, + { + "keyset": "F4", + "action": "focus_camera_anchor 4" + }, + { + "keyset": "Alt+sc_b", + "action": "blueprint_place" + }, + { + "keyset": "Alt+sc_t", + "action": "blueprint_create" + }, + { + "keyset": "Alt+sc_y", + "action": "blueprint_delete" + }, + { + "keyset": "Alt+sc_[", + "action": "blueprint_prev" + }, + { + "keyset": "Alt+sc_]", + "action": "blueprint_next" + }, + { + "keyset": "Alt+sc_g", + "action": "factoryqueuemode" + } + ] + }, + { + "name": "Grid (60% Keyboard)", + "binds": [ + { + "keyset": "esc", + "action": "select AllMap++_ClearSelection_SelectNum_0+" + }, + { + "keyset": "esc", + "action": "quitmessage" + }, + { + "keyset": "Shift+esc", + "action": "quitmenu" + }, + { + "keyset": "Ctrl+Shift+esc", + "action": "quitforce" + }, + { + "keyset": "Alt+Shift+esc", + "action": "reloadforce" + }, + { + "keyset": "Any+escape", + "action": "edit_escape" + }, + { + "keyset": "Any+pause", + "action": "pause" + }, + { + "keyset": "esc", + "action": "teamstatus_close" + }, + { + "keyset": "esc", + "action": "customgameinfo_close" + }, + { + "keyset": "esc", + "action": "buildmenu_pregame_deselect" + }, + { + "keyset": "Any+sc_z", + "action": "selectbox_same" + }, + { + "keyset": "Any+space", + "action": "selectbox_idle" + }, + { + "keyset": "Any+shift", + "action": "selectbox_append" + }, + { + "keyset": "Any+shift", + "action": "selectbox_any" + }, + { + "keyset": "Any+ctrl", + "action": "selectbox_deselect" + }, + { + "keyset": "Any+alt", + "action": "selectbox_mobile" + }, + { + "keyset": "Any+space", + "action": "selectloop" + }, + { + "keyset": "Any+ctrl", + "action": "selectloop_invert" + }, + { + "keyset": "Any+shift", + "action": "selectloop_add" + }, + { + "keyset": "Any+space", + "action": "buildsplit" + }, + { + "keyset": "Any+space", + "action": "commandinsert prepend_between" + }, + { + "keyset": "alt+sc_.", + "action": "attack_range_inc" + }, + { + "keyset": "alt+sc_comma", + "action": "attack_range_dec" + }, + { + "keyset": "Any+enter", + "action": "chat" + }, + { + "keyset": "Alt+ctrl+sc_a", + "action": "chatswitchally" + }, + { + "keyset": "Alt+ctrl+sc_s", + "action": "chatswitchspec" + }, + { + "keyset": "Any+tab", + "action": "edit_complete" + }, + { + "keyset": "Any+backspace", + "action": "edit_backspace" + }, + { + "keyset": "Any+delete", + "action": "edit_delete" + }, + { + "keyset": "Any+home", + "action": "edit_home" + }, + { + "keyset": "Alt+left", + "action": "edit_home" + }, + { + "keyset": "Any+end", + "action": "edit_end" + }, + { + "keyset": "Alt+right", + "action": "edit_end" + }, + { + "keyset": "Any+up", + "action": "edit_prev_line" + }, + { + "keyset": "Any+down", + "action": "edit_next_line" + }, + { + "keyset": "Any+left", + "action": "edit_prev_char" + }, + { + "keyset": "Any+right", + "action": "edit_next_char" + }, + { + "keyset": "Ctrl+left", + "action": "edit_prev_word" + }, + { + "keyset": "Ctrl+right", + "action": "edit_next_word" + }, + { + "keyset": "Any+enter", + "action": "edit_return" + }, + { + "keyset": "Ctrl+v", + "action": "pastetext" + }, + { + "keyset": "Any+up", + "action": "moveforward" + }, + { + "keyset": "Any+down", + "action": "moveback" + }, + { + "keyset": "Any+right", + "action": "moveright" + }, + { + "keyset": "Any+left", + "action": "moveleft" + }, + { + "keyset": "Any+pageup", + "action": "moveup" + }, + { + "keyset": "Any+pagedown", + "action": "movedown" + }, + { + "keyset": "Any+alt", + "action": "movereset" + }, + { + "keyset": "Any+alt", + "action": "moverotate" + }, + { + "keyset": "Any+ctrl", + "action": "movetilt" + }, + { + "keyset": "ctrl+sc_o", + "action": "fov_dec 5" + }, + { + "keyset": "ctrl+sc_p", + "action": "fov_inc 5" + }, + { + "keyset": "sc_numpad1", + "action": "fov_dec 5" + }, + { + "keyset": "sc_numpad7", + "action": "fov_inc 5" + }, + { + "keyset": "Meta+ctrl+tab", + "action": "pip1_copy" + }, + { + "keyset": "Meta+tab", + "action": "pip1_switch" + }, + { + "keyset": "Alt+sc_t", + "action": "pip1_track" + }, + { + "keyset": "Any+alt", + "action": "toggle_allied_upgrade" + }, + { + "keyset": "sc_z", + "action": "gridmenu_category 1" + }, + { + "keyset": "sc_x", + "action": "gridmenu_category 2" + }, + { + "keyset": "sc_c", + "action": "gridmenu_category 3" + }, + { + "keyset": "sc_v", + "action": "gridmenu_category 4" + }, + { + "keyset": "Shift+sc_z", + "action": "gridmenu_category 1" + }, + { + "keyset": "Shift+sc_x", + "action": "gridmenu_category 2" + }, + { + "keyset": "Shift+sc_c", + "action": "gridmenu_category 3" + }, + { + "keyset": "Shift+sc_v", + "action": "gridmenu_category 4" + }, + { + "keyset": "Any+sc_z", + "action": "gridmenu_key 1 1" + }, + { + "keyset": "Any+sc_x", + "action": "gridmenu_key 1 2" + }, + { + "keyset": "Any+sc_c", + "action": "gridmenu_key 1 3" + }, + { + "keyset": "Any+sc_v", + "action": "gridmenu_key 1 4" + }, + { + "keyset": "Any+sc_a", + "action": "gridmenu_key 2 1" + }, + { + "keyset": "Any+sc_s", + "action": "gridmenu_key 2 2" + }, + { + "keyset": "Any+sc_d", + "action": "gridmenu_key 2 3" + }, + { + "keyset": "Any+sc_f", + "action": "gridmenu_key 2 4" + }, + { + "keyset": "Any+sc_q", + "action": "gridmenu_key 3 1" + }, + { + "keyset": "Any+sc_w", + "action": "gridmenu_key 3 2" + }, + { + "keyset": "Any+sc_e", + "action": "gridmenu_key 3 3" + }, + { + "keyset": "Any+sc_r", + "action": "gridmenu_key 3 4" + }, + { + "keyset": "sc_b", + "action": "gridmenu_next_page" + }, + { + "keyset": "sc_.", + "action": "gridmenu_cycle_builder" + }, + { + "keyset": "sc_.", + "action": "cycleselected next" + }, + { + "keyset": "sc_comma", + "action": "cycleselected prev" + }, + { + "keyset": "1", + "action": "specteam 0" + }, + { + "keyset": "2", + "action": "specteam 1" + }, + { + "keyset": "3", + "action": "specteam 2" + }, + { + "keyset": "4", + "action": "specteam 3" + }, + { + "keyset": "5", + "action": "specteam 4" + }, + { + "keyset": "6", + "action": "specteam 5" + }, + { + "keyset": "7", + "action": "specteam 6" + }, + { + "keyset": "8", + "action": "specteam 7" + }, + { + "keyset": "9", + "action": "specteam 8" + }, + { + "keyset": "Alt+0", + "action": "add_to_autogroup 0" + }, + { + "keyset": "Alt+1", + "action": "add_to_autogroup 1" + }, + { + "keyset": "Alt+2", + "action": "add_to_autogroup 2" + }, + { + "keyset": "Alt+3", + "action": "add_to_autogroup 3" + }, + { + "keyset": "Alt+4", + "action": "add_to_autogroup 4" + }, + { + "keyset": "Alt+5", + "action": "add_to_autogroup 5" + }, + { + "keyset": "Alt+6", + "action": "add_to_autogroup 6" + }, + { + "keyset": "Alt+7", + "action": "add_to_autogroup 7" + }, + { + "keyset": "Alt+8", + "action": "add_to_autogroup 8" + }, + { + "keyset": "Alt+9", + "action": "add_to_autogroup 9" + }, + { + "keyset": "Shift+Alt+0", + "action": "load_autogroup_preset 0" + }, + { + "keyset": "Shift+Alt+1", + "action": "load_autogroup_preset 1" + }, + { + "keyset": "Shift+Alt+2", + "action": "load_autogroup_preset 2" + }, + { + "keyset": "Shift+Alt+3", + "action": "load_autogroup_preset 3" + }, + { + "keyset": "Shift+Alt+4", + "action": "load_autogroup_preset 4" + }, + { + "keyset": "Shift+Alt+5", + "action": "load_autogroup_preset 5" + }, + { + "keyset": "Shift+Alt+6", + "action": "load_autogroup_preset 6" + }, + { + "keyset": "Shift+Alt+7", + "action": "load_autogroup_preset 7" + }, + { + "keyset": "Shift+Alt+8", + "action": "load_autogroup_preset 8" + }, + { + "keyset": "Shift+Alt+9", + "action": "load_autogroup_preset 9" + }, + { + "keyset": "0,0", + "action": "group focus 0" + }, + { + "keyset": "1,1", + "action": "group focus 1" + }, + { + "keyset": "2,2", + "action": "group focus 2" + }, + { + "keyset": "3,3", + "action": "group focus 3" + }, + { + "keyset": "4,4", + "action": "group focus 4" + }, + { + "keyset": "5,5", + "action": "group focus 5" + }, + { + "keyset": "6,6", + "action": "group focus 6" + }, + { + "keyset": "7,7", + "action": "group focus 7" + }, + { + "keyset": "8,8", + "action": "group focus 8" + }, + { + "keyset": "9,9", + "action": "group focus 9" + }, + { + "keyset": "0", + "action": "group select 0" + }, + { + "keyset": "1", + "action": "group select 1" + }, + { + "keyset": "2", + "action": "group select 2" + }, + { + "keyset": "3", + "action": "group select 3" + }, + { + "keyset": "4", + "action": "group select 4" + }, + { + "keyset": "5", + "action": "group select 5" + }, + { + "keyset": "6", + "action": "group select 6" + }, + { + "keyset": "7", + "action": "group select 7" + }, + { + "keyset": "8", + "action": "group select 8" + }, + { + "keyset": "9", + "action": "group select 9" + }, + { + "keyset": "Ctrl+0", + "action": "group set 0" + }, + { + "keyset": "Ctrl+1", + "action": "group set 1" + }, + { + "keyset": "Ctrl+2", + "action": "group set 2" + }, + { + "keyset": "Ctrl+3", + "action": "group set 3" + }, + { + "keyset": "Ctrl+4", + "action": "group set 4" + }, + { + "keyset": "Ctrl+5", + "action": "group set 5" + }, + { + "keyset": "Ctrl+6", + "action": "group set 6" + }, + { + "keyset": "Ctrl+7", + "action": "group set 7" + }, + { + "keyset": "Ctrl+8", + "action": "group set 8" + }, + { + "keyset": "Ctrl+9", + "action": "group set 9" + }, + { + "keyset": "Shift+0", + "action": "group selectadd 0" + }, + { + "keyset": "Shift+1", + "action": "group selectadd 1" + }, + { + "keyset": "Shift+2", + "action": "group selectadd 2" + }, + { + "keyset": "Shift+3", + "action": "group selectadd 3" + }, + { + "keyset": "Shift+4", + "action": "group selectadd 4" + }, + { + "keyset": "Shift+5", + "action": "group selectadd 5" + }, + { + "keyset": "Shift+6", + "action": "group selectadd 6" + }, + { + "keyset": "Shift+7", + "action": "group selectadd 7" + }, + { + "keyset": "Shift+8", + "action": "group selectadd 8" + }, + { + "keyset": "Shift+9", + "action": "group selectadd 9" + }, + { + "keyset": "Ctrl+Shift+0", + "action": "group add 0" + }, + { + "keyset": "Ctrl+Shift+1", + "action": "group add 1" + }, + { + "keyset": "Ctrl+Shift+2", + "action": "group add 2" + }, + { + "keyset": "Ctrl+Shift+3", + "action": "group add 3" + }, + { + "keyset": "Ctrl+Shift+4", + "action": "group add 4" + }, + { + "keyset": "Ctrl+Shift+5", + "action": "group add 5" + }, + { + "keyset": "Ctrl+Shift+6", + "action": "group add 6" + }, + { + "keyset": "Ctrl+Shift+7", + "action": "group add 7" + }, + { + "keyset": "Ctrl+Shift+8", + "action": "group add 8" + }, + { + "keyset": "Ctrl+Shift+9", + "action": "group add 9" + }, + { + "keyset": "Ctrl+Alt+0", + "action": "group selecttoggle 0" + }, + { + "keyset": "Ctrl+Alt+1", + "action": "group selecttoggle 1" + }, + { + "keyset": "Ctrl+Alt+2", + "action": "group selecttoggle 2" + }, + { + "keyset": "Ctrl+Alt+3", + "action": "group selecttoggle 3" + }, + { + "keyset": "Ctrl+Alt+4", + "action": "group selecttoggle 4" + }, + { + "keyset": "Ctrl+Alt+5", + "action": "group selecttoggle 5" + }, + { + "keyset": "Ctrl+Alt+6", + "action": "group selecttoggle 6" + }, + { + "keyset": "Ctrl+Alt+7", + "action": "group selecttoggle 7" + }, + { + "keyset": "Ctrl+Alt+8", + "action": "group selecttoggle 8" + }, + { + "keyset": "Ctrl+Alt+9", + "action": "group selecttoggle 9" + }, + { + "keyset": "Alt+sc_=", + "action": "increasespeed" + }, + { + "keyset": "Alt+sc_-", + "action": "decreasespeed" + }, + { + "keyset": "Alt+numpad+", + "action": "increasespeed" + }, + { + "keyset": "Alt+numpad-", + "action": "decreasespeed" + }, + { + "keyset": "sc_[", + "action": "buildfacing inc" + }, + { + "keyset": "Shift+sc_[", + "action": "buildfacing inc" + }, + { + "keyset": "sc_]", + "action": "buildfacing dec" + }, + { + "keyset": "Shift+sc_]", + "action": "buildfacing dec" + }, + { + "keyset": "Alt+sc_z", + "action": "buildspacing inc" + }, + { + "keyset": "Shift+Alt+sc_z", + "action": "buildspacing inc" + }, + { + "keyset": "Alt+sc_x", + "action": "buildspacing dec" + }, + { + "keyset": "Shift+Alt+sc_x", + "action": "buildspacing dec" + }, + { + "keyset": "sc_a", + "action": "attack" + }, + { + "keyset": "Shift+sc_a", + "action": "attack" + }, + { + "keyset": "Ctrl+sc_a", + "action": "areaattack" + }, + { + "keyset": "Ctrl+Shift+sc_a", + "action": "areaattack" + }, + { + "keyset": "Ctrl+sc_b", + "action": "selfd" + }, + { + "keyset": "Ctrl+Shift+sc_b", + "action": "selfd queued" + }, + { + "keyset": "sc_d", + "action": "manualfire" + }, + { + "keyset": "Shift+sc_d", + "action": "manualfire" + }, + { + "keyset": "sc_d", + "action": "manuallaunch" + }, + { + "keyset": "Shift+sc_d", + "action": "manuallaunch" + }, + { + "keyset": "sc_e", + "action": "reclaim" + }, + { + "keyset": "Shift+sc_e", + "action": "reclaim" + }, + { + "keyset": "sc_f", + "action": "fight" + }, + { + "keyset": "Shift+sc_f", + "action": "fight" + }, + { + "keyset": "Alt+sc_f", + "action": "chain force forcestart | say !cv forcestart" + }, + { + "keyset": "sc_g", + "action": "stopproduction" + }, + { + "keyset": "Shift+sc_g", + "action": "stopproduction" + }, + { + "keyset": "sc_g", + "action": "stop" + }, + { + "keyset": "Shift+sc_g", + "action": "stop" + }, + { + "keyset": "sc_h", + "action": "patrol" + }, + { + "keyset": "Shift+sc_h", + "action": "patrol" + }, + { + "keyset": "sc_i", + "action": "unit_stats" + }, + { + "keyset": "sc_j", + "action": "loadunits" + }, + { + "keyset": "Shift+sc_j", + "action": "loadunits" + }, + { + "keyset": "sc_k", + "action": "cloak" + }, + { + "keyset": "Shift+sc_k", + "action": "cloak" + }, + { + "keyset": "sc_k", + "action": "wantcloak" + }, + { + "keyset": "Any+sc_k", + "action": "wantcloak" + }, + { + "keyset": "sc_m", + "action": "restore" + }, + { + "keyset": "Shift+sc_m", + "action": "restore" + }, + { + "keyset": "sc_n", + "action": "command_skip_current" + }, + { + "keyset": "Ctrl+sc_n", + "action": "command_cancel_last" + }, + { + "keyset": "sc_p", + "action": "gatherwait" + }, + { + "keyset": "Shift+sc_p", + "action": "gatherwait" + }, + { + "keyset": "sc_o", + "action": "guard" + }, + { + "keyset": "Shift+sc_o", + "action": "guard" + }, + { + "keyset": "sc_r", + "action": "repair" + }, + { + "keyset": "Shift+sc_r", + "action": "repair" + }, + { + "keyset": "sc_s", + "action": "settarget" + }, + { + "keyset": "Shift+sc_s", + "action": "settarget" + }, + { + "keyset": "Alt+sc_s", + "action": "settargetnoground" + }, + { + "keyset": "Shift+Alt+sc_s", + "action": "settargetnoground" + }, + { + "keyset": "Ctrl+sc_s", + "action": "canceltarget" + }, + { + "keyset": "sc_u", + "action": "unloadunits" + }, + { + "keyset": "Shift+sc_u", + "action": "unloadunits" + }, + { + "keyset": "sc_w", + "action": "resurrect" + }, + { + "keyset": "Shift+sc_w", + "action": "resurrect" + }, + { + "keyset": "sc_w", + "action": "capture" + }, + { + "keyset": "Shift+sc_w", + "action": "capture" + }, + { + "keyset": "sc_y", + "action": "wait" + }, + { + "keyset": "Shift+sc_y", + "action": "wait queued" + }, + { + "keyset": "sc_b,sc_b", + "action": "onoff 0" + }, + { + "keyset": "sc_b", + "action": "onoff 1" + }, + { + "keyset": "Shift+sc_b,Shift+sc_b", + "action": "onoff 0" + }, + { + "keyset": "Shift+sc_b", + "action": "onoff 1" + }, + { + "keyset": "sc_b,sc_b,sc_b", + "action": "trajectory_toggle 1" + }, + { + "keyset": "sc_b,sc_b", + "action": "trajectory_toggle 0" + }, + { + "keyset": "sc_b", + "action": "trajectory_toggle 2" + }, + { + "keyset": "Shift+sc_b,Shift+sc_b,Shift+sc_b", + "action": "trajectory_toggle 1" + }, + { + "keyset": "Shift+sc_b,Shift+sc_b", + "action": "trajectory_toggle 0" + }, + { + "keyset": "Shift+sc_b", + "action": "trajectory_toggle 2" + }, + { + "keyset": "sc_l,sc_l,sc_l", + "action": "firestate 1" + }, + { + "keyset": "sc_l,sc_l", + "action": "firestate 0" + }, + { + "keyset": "sc_l", + "action": "firestate 2" + }, + { + "keyset": "Shift+sc_l,Shift+sc_l,Shift+sc_l", + "action": "firestate 1" + }, + { + "keyset": "Shift+sc_l,Shift+sc_l", + "action": "firestate 0" + }, + { + "keyset": "Shift+sc_l", + "action": "firestate 2" + }, + { + "keyset": "sc_;,sc_;,sc_;", + "action": "movestate 1" + }, + { + "keyset": "sc_;,sc_;", + "action": "movestate 0" + }, + { + "keyset": "sc_;", + "action": "movestate 2" + }, + { + "keyset": "Shift+sc_;,Shift+sc_;,Shift+sc_;", + "action": "movestate 1" + }, + { + "keyset": "Shift+sc_;,Shift+sc_;", + "action": "movestate 0" + }, + { + "keyset": "Shift+sc_;", + "action": "movestate 2" + }, + { + "keyset": "sc_t,sc_t", + "action": "repeat 0" + }, + { + "keyset": "sc_t", + "action": "repeat 1" + }, + { + "keyset": "Shift+sc_t,Shift+sc_t", + "action": "repeat 0" + }, + { + "keyset": "Shift+sc_t", + "action": "repeat 1" + }, + { + "keyset": "Ctrl+sc_g,Ctrl+sc_g", + "action": "factoryguard 0" + }, + { + "keyset": "Ctrl+sc_g", + "action": "factoryguard 1" + }, + { + "keyset": "Alt+sc_o", + "action": "cameraflip" + }, + { + "keyset": "Ctrl+meta+7", + "action": "hideinterface" + }, + { + "keyset": "meta+5", + "action": "lastmsgpos" + }, + { + "keyset": "meta+6", + "action": "showpathtraversability" + }, + { + "keyset": "meta+7", + "action": "showmetalmap" + }, + { + "keyset": "meta+8", + "action": "showelevation" + }, + { + "keyset": "f10", + "action": "options" + }, + { + "keyset": "f11", + "action": "luaui selector" + }, + { + "keyset": "Any+f12", + "action": "screenshot png" + }, + { + "keyset": "Ctrl+meta+sc_q", + "action": "group unset" + }, + { + "keyset": "Alt+sc_q", + "action": "remove_from_autogroup" + }, + { + "keyset": "meta+sc_q,meta+sc_q", + "action": "drawlabel" + }, + { + "keyset": "meta+sc_q", + "action": "drawinmap" + }, + { + "keyset": "Ctrl+sc_e", + "action": "select AllMap++_ClearSelection_SelectAll+" + }, + { + "keyset": "Ctrl+tab", + "action": "select AllMap+_Builder_Idle+_ClearSelection_SelectOne+" + }, + { + "keyset": "Shift+tab", + "action": "selectcomm append" + }, + { + "keyset": "tab", + "action": "selectcomm focus" + }, + { + "keyset": "sc_q", + "action": "select Visible+_InPrevSel+_ClearSelection_SelectAll+" + }, + { + "keyset": "Ctrl+sc_q", + "action": "select PrevSelection++_ClearSelection_SelectPart_50+" + }, + { + "keyset": "Ctrl+sc_w", + "action": "select AllMap+_InPrevSel+_ClearSelection_SelectAll+" + }, + { + "keyset": "Ctrl+sc_r", + "action": "select AllMap+_Transport_Idle+_ClearSelection_SelectAll+" + }, + { + "keyset": "Ctrl+sc_y", + "action": "select Visible+_Waiting+_ClearSelection_SelectAll+" + }, + { + "keyset": "Ctrl+Alt+sc_q", + "action": "select PrevSelection+_Not_Building_Not_RelativeHealth_60+_ClearSelection_SelectAll+" + }, + { + "keyset": "numpad2", + "action": "moveback" + }, + { + "keyset": "numpad6", + "action": "moveright" + }, + { + "keyset": "numpad4", + "action": "moveleft" + }, + { + "keyset": "numpad8", + "action": "moveforward" + }, + { + "keyset": "numpad9", + "action": "moveup" + }, + { + "keyset": "numpad3", + "action": "movedown" + }, + { + "keyset": "numpad1", + "action": "movefast" + }, + { + "keyset": "backspace", + "action": "mutesound" + }, + { + "keyset": "numpad+", + "action": "snd_volume_increase" + }, + { + "keyset": "sc_=", + "action": "snd_volume_increase" + }, + { + "keyset": "sc_-", + "action": "snd_volume_decrease" + }, + { + "keyset": "numpad-", + "action": "snd_volume_decrease" + }, + { + "keyset": "Ctrl+meta+1", + "action": "set_camera_anchor 1" + }, + { + "keyset": "Ctrl+meta+2", + "action": "set_camera_anchor 2" + }, + { + "keyset": "Ctrl+meta+3", + "action": "set_camera_anchor 3" + }, + { + "keyset": "Ctrl+meta+4", + "action": "set_camera_anchor 4" + }, + { + "keyset": "meta+1", + "action": "focus_camera_anchor 1" + }, + { + "keyset": "meta+2", + "action": "focus_camera_anchor 2" + }, + { + "keyset": "meta+3", + "action": "focus_camera_anchor 3" + }, + { + "keyset": "meta+4", + "action": "focus_camera_anchor 4" + }, + { + "keyset": "Alt+sc_b", + "action": "blueprint_place" + }, + { + "keyset": "Alt+sc_t", + "action": "blueprint_create" + }, + { + "keyset": "Alt+sc_y", + "action": "blueprint_delete" + }, + { + "keyset": "Alt+sc_[", + "action": "blueprint_prev" + }, + { + "keyset": "Alt+sc_]", + "action": "blueprint_next" + } + ] + }, + { + "name": "Legacy", + "binds": [ + { + "keyset": "esc", + "action": "select AllMap++_ClearSelection_SelectNum_0+" + }, + { + "keyset": "esc", + "action": "quitmessage" + }, + { + "keyset": "Shift+esc", + "action": "quitmenu" + }, + { + "keyset": "Ctrl+Shift+esc", + "action": "quitforce" + }, + { + "keyset": "Alt+Shift+esc", + "action": "reloadforce" + }, + { + "keyset": "Any+escape", + "action": "edit_escape" + }, + { + "keyset": "Any+pause", + "action": "pause" + }, + { + "keyset": "esc", + "action": "teamstatus_close" + }, + { + "keyset": "esc", + "action": "customgameinfo_close" + }, + { + "keyset": "esc", + "action": "buildmenu_pregame_deselect" + }, + { + "keyset": "Any+sc_z", + "action": "selectbox_same" + }, + { + "keyset": "Any+space", + "action": "selectbox_idle" + }, + { + "keyset": "Any+shift", + "action": "selectbox_append" + }, + { + "keyset": "Any+shift", + "action": "selectbox_any" + }, + { + "keyset": "Any+ctrl", + "action": "selectbox_deselect" + }, + { + "keyset": "Any+alt", + "action": "selectbox_mobile" + }, + { + "keyset": "Any+space", + "action": "selectloop" + }, + { + "keyset": "Any+ctrl", + "action": "selectloop_invert" + }, + { + "keyset": "Any+shift", + "action": "selectloop_add" + }, + { + "keyset": "Any+space", + "action": "buildsplit" + }, + { + "keyset": "Any+space", + "action": "commandinsert prepend_between" + }, + { + "keyset": "alt+sc_.", + "action": "attack_range_inc" + }, + { + "keyset": "alt+sc_comma", + "action": "attack_range_dec" + }, + { + "keyset": "Any+enter", + "action": "chat" + }, + { + "keyset": "Alt+ctrl+sc_a", + "action": "chatswitchally" + }, + { + "keyset": "Alt+ctrl+sc_s", + "action": "chatswitchspec" + }, + { + "keyset": "Any+tab", + "action": "edit_complete" + }, + { + "keyset": "Any+backspace", + "action": "edit_backspace" + }, + { + "keyset": "Any+delete", + "action": "edit_delete" + }, + { + "keyset": "Any+home", + "action": "edit_home" + }, + { + "keyset": "Alt+left", + "action": "edit_home" + }, + { + "keyset": "Any+end", + "action": "edit_end" + }, + { + "keyset": "Alt+right", + "action": "edit_end" + }, + { + "keyset": "Any+up", + "action": "edit_prev_line" + }, + { + "keyset": "Any+down", + "action": "edit_next_line" + }, + { + "keyset": "Any+left", + "action": "edit_prev_char" + }, + { + "keyset": "Any+right", + "action": "edit_next_char" + }, + { + "keyset": "Ctrl+left", + "action": "edit_prev_word" + }, + { + "keyset": "Ctrl+right", + "action": "edit_next_word" + }, + { + "keyset": "Any+enter", + "action": "edit_return" + }, + { + "keyset": "Ctrl+v", + "action": "pastetext" + }, + { + "keyset": "Any+up", + "action": "moveforward" + }, + { + "keyset": "Any+down", + "action": "moveback" + }, + { + "keyset": "Any+right", + "action": "moveright" + }, + { + "keyset": "Any+left", + "action": "moveleft" + }, + { + "keyset": "Any+pageup", + "action": "moveup" + }, + { + "keyset": "Any+pagedown", + "action": "movedown" + }, + { + "keyset": "Any+alt", + "action": "movereset" + }, + { + "keyset": "Any+alt", + "action": "moverotate" + }, + { + "keyset": "Any+ctrl", + "action": "movetilt" + }, + { + "keyset": "ctrl+sc_o", + "action": "fov_dec 5" + }, + { + "keyset": "ctrl+sc_p", + "action": "fov_inc 5" + }, + { + "keyset": "sc_numpad1", + "action": "fov_dec 5" + }, + { + "keyset": "sc_numpad7", + "action": "fov_inc 5" + }, + { + "keyset": "Meta+ctrl+tab", + "action": "pip1_copy" + }, + { + "keyset": "Meta+tab", + "action": "pip1_switch" + }, + { + "keyset": "Alt+sc_t", + "action": "pip1_track" + }, + { + "keyset": "Any+alt", + "action": "toggle_allied_upgrade" + }, + { + "keyset": "1", + "action": "specteam 0" + }, + { + "keyset": "2", + "action": "specteam 1" + }, + { + "keyset": "3", + "action": "specteam 2" + }, + { + "keyset": "4", + "action": "specteam 3" + }, + { + "keyset": "5", + "action": "specteam 4" + }, + { + "keyset": "6", + "action": "specteam 5" + }, + { + "keyset": "7", + "action": "specteam 6" + }, + { + "keyset": "8", + "action": "specteam 7" + }, + { + "keyset": "9", + "action": "specteam 8" + }, + { + "keyset": "Alt+0", + "action": "add_to_autogroup 0" + }, + { + "keyset": "Alt+1", + "action": "add_to_autogroup 1" + }, + { + "keyset": "Alt+2", + "action": "add_to_autogroup 2" + }, + { + "keyset": "Alt+3", + "action": "add_to_autogroup 3" + }, + { + "keyset": "Alt+4", + "action": "add_to_autogroup 4" + }, + { + "keyset": "Alt+5", + "action": "add_to_autogroup 5" + }, + { + "keyset": "Alt+6", + "action": "add_to_autogroup 6" + }, + { + "keyset": "Alt+7", + "action": "add_to_autogroup 7" + }, + { + "keyset": "Alt+8", + "action": "add_to_autogroup 8" + }, + { + "keyset": "Alt+9", + "action": "add_to_autogroup 9" + }, + { + "keyset": "Shift+Alt+0", + "action": "load_autogroup_preset 0" + }, + { + "keyset": "Shift+Alt+1", + "action": "load_autogroup_preset 1" + }, + { + "keyset": "Shift+Alt+2", + "action": "load_autogroup_preset 2" + }, + { + "keyset": "Shift+Alt+3", + "action": "load_autogroup_preset 3" + }, + { + "keyset": "Shift+Alt+4", + "action": "load_autogroup_preset 4" + }, + { + "keyset": "Shift+Alt+5", + "action": "load_autogroup_preset 5" + }, + { + "keyset": "Shift+Alt+6", + "action": "load_autogroup_preset 6" + }, + { + "keyset": "Shift+Alt+7", + "action": "load_autogroup_preset 7" + }, + { + "keyset": "Shift+Alt+8", + "action": "load_autogroup_preset 8" + }, + { + "keyset": "Shift+Alt+9", + "action": "load_autogroup_preset 9" + }, + { + "keyset": "0,0", + "action": "group focus 0" + }, + { + "keyset": "1,1", + "action": "group focus 1" + }, + { + "keyset": "2,2", + "action": "group focus 2" + }, + { + "keyset": "3,3", + "action": "group focus 3" + }, + { + "keyset": "4,4", + "action": "group focus 4" + }, + { + "keyset": "5,5", + "action": "group focus 5" + }, + { + "keyset": "6,6", + "action": "group focus 6" + }, + { + "keyset": "7,7", + "action": "group focus 7" + }, + { + "keyset": "8,8", + "action": "group focus 8" + }, + { + "keyset": "9,9", + "action": "group focus 9" + }, + { + "keyset": "0", + "action": "group select 0" + }, + { + "keyset": "1", + "action": "group select 1" + }, + { + "keyset": "2", + "action": "group select 2" + }, + { + "keyset": "3", + "action": "group select 3" + }, + { + "keyset": "4", + "action": "group select 4" + }, + { + "keyset": "5", + "action": "group select 5" + }, + { + "keyset": "6", + "action": "group select 6" + }, + { + "keyset": "7", + "action": "group select 7" + }, + { + "keyset": "8", + "action": "group select 8" + }, + { + "keyset": "9", + "action": "group select 9" + }, + { + "keyset": "Ctrl+0", + "action": "group set 0" + }, + { + "keyset": "Ctrl+1", + "action": "group set 1" + }, + { + "keyset": "Ctrl+2", + "action": "group set 2" + }, + { + "keyset": "Ctrl+3", + "action": "group set 3" + }, + { + "keyset": "Ctrl+4", + "action": "group set 4" + }, + { + "keyset": "Ctrl+5", + "action": "group set 5" + }, + { + "keyset": "Ctrl+6", + "action": "group set 6" + }, + { + "keyset": "Ctrl+7", + "action": "group set 7" + }, + { + "keyset": "Ctrl+8", + "action": "group set 8" + }, + { + "keyset": "Ctrl+9", + "action": "group set 9" + }, + { + "keyset": "Shift+0", + "action": "group selectadd 0" + }, + { + "keyset": "Shift+1", + "action": "group selectadd 1" + }, + { + "keyset": "Shift+2", + "action": "group selectadd 2" + }, + { + "keyset": "Shift+3", + "action": "group selectadd 3" + }, + { + "keyset": "Shift+4", + "action": "group selectadd 4" + }, + { + "keyset": "Shift+5", + "action": "group selectadd 5" + }, + { + "keyset": "Shift+6", + "action": "group selectadd 6" + }, + { + "keyset": "Shift+7", + "action": "group selectadd 7" + }, + { + "keyset": "Shift+8", + "action": "group selectadd 8" + }, + { + "keyset": "Shift+9", + "action": "group selectadd 9" + }, + { + "keyset": "Ctrl+Shift+0", + "action": "group add 0" + }, + { + "keyset": "Ctrl+Shift+1", + "action": "group add 1" + }, + { + "keyset": "Ctrl+Shift+2", + "action": "group add 2" + }, + { + "keyset": "Ctrl+Shift+3", + "action": "group add 3" + }, + { + "keyset": "Ctrl+Shift+4", + "action": "group add 4" + }, + { + "keyset": "Ctrl+Shift+5", + "action": "group add 5" + }, + { + "keyset": "Ctrl+Shift+6", + "action": "group add 6" + }, + { + "keyset": "Ctrl+Shift+7", + "action": "group add 7" + }, + { + "keyset": "Ctrl+Shift+8", + "action": "group add 8" + }, + { + "keyset": "Ctrl+Shift+9", + "action": "group add 9" + }, + { + "keyset": "Ctrl+Alt+0", + "action": "group selecttoggle 0" + }, + { + "keyset": "Ctrl+Alt+1", + "action": "group selecttoggle 1" + }, + { + "keyset": "Ctrl+Alt+2", + "action": "group selecttoggle 2" + }, + { + "keyset": "Ctrl+Alt+3", + "action": "group selecttoggle 3" + }, + { + "keyset": "Ctrl+Alt+4", + "action": "group selecttoggle 4" + }, + { + "keyset": "Ctrl+Alt+5", + "action": "group selecttoggle 5" + }, + { + "keyset": "Ctrl+Alt+6", + "action": "group selecttoggle 6" + }, + { + "keyset": "Ctrl+Alt+7", + "action": "group selecttoggle 7" + }, + { + "keyset": "Ctrl+Alt+8", + "action": "group selecttoggle 8" + }, + { + "keyset": "Ctrl+Alt+9", + "action": "group selecttoggle 9" + }, + { + "keyset": "meta+alt+0", + "action": "factory_preset save 0" + }, + { + "keyset": "meta+alt+1", + "action": "factory_preset save 1" + }, + { + "keyset": "meta+alt+2", + "action": "factory_preset save 2" + }, + { + "keyset": "meta+alt+3", + "action": "factory_preset save 3" + }, + { + "keyset": "meta+alt+4", + "action": "factory_preset save 4" + }, + { + "keyset": "meta+alt+5", + "action": "factory_preset save 5" + }, + { + "keyset": "meta+alt+6", + "action": "factory_preset save 6" + }, + { + "keyset": "meta+alt+7", + "action": "factory_preset save 7" + }, + { + "keyset": "meta+alt+8", + "action": "factory_preset save 8" + }, + { + "keyset": "meta+alt+9", + "action": "factory_preset save 9" + }, + { + "keyset": "meta+0", + "action": "factory_preset load 0" + }, + { + "keyset": "meta+1", + "action": "factory_preset load 1" + }, + { + "keyset": "meta+2", + "action": "factory_preset load 2" + }, + { + "keyset": "meta+3", + "action": "factory_preset load 3" + }, + { + "keyset": "meta+4", + "action": "factory_preset load 4" + }, + { + "keyset": "meta+5", + "action": "factory_preset load 5" + }, + { + "keyset": "meta+6", + "action": "factory_preset load 6" + }, + { + "keyset": "meta+7", + "action": "factory_preset load 7" + }, + { + "keyset": "meta+8", + "action": "factory_preset load 8" + }, + { + "keyset": "meta+9", + "action": "factory_preset load 9" + }, + { + "keyset": "any+sc_space", + "action": "factory_preset_show" + }, + { + "keyset": "Shift+backspace", + "action": "togglecammode" + }, + { + "keyset": "Ctrl+backspace", + "action": "togglecammode" + }, + { + "keyset": "Any+tab", + "action": "toggleoverview" + }, + { + "keyset": "Alt+sc_=", + "action": "increasespeed" + }, + { + "keyset": "Alt+sc_-", + "action": "decreasespeed" + }, + { + "keyset": "Alt+numpad+", + "action": "increasespeed" + }, + { + "keyset": "Alt+numpad-", + "action": "decreasespeed" + }, + { + "keyset": "sc_[", + "action": "buildfacing inc" + }, + { + "keyset": "Shift+sc_[", + "action": "buildfacing inc" + }, + { + "keyset": "sc_]", + "action": "buildfacing dec" + }, + { + "keyset": "Shift+sc_]", + "action": "buildfacing dec" + }, + { + "keyset": "Alt+sc_z", + "action": "buildspacing inc" + }, + { + "keyset": "Shift+Alt+sc_z", + "action": "buildspacing inc" + }, + { + "keyset": "Alt+sc_x", + "action": "buildspacing dec" + }, + { + "keyset": "Shift+Alt+sc_x", + "action": "buildspacing dec" + }, + { + "keyset": "sc_a", + "action": "attack" + }, + { + "keyset": "Shift+sc_a", + "action": "attack" + }, + { + "keyset": "Alt+sc_a", + "action": "areaattack" + }, + { + "keyset": "Alt+Shift+sc_a", + "action": "areaattack" + }, + { + "keyset": "sc_d", + "action": "manualfire" + }, + { + "keyset": "Shift+sc_d", + "action": "manualfire" + }, + { + "keyset": "sc_d", + "action": "manuallaunch" + }, + { + "keyset": "Shift+sc_d", + "action": "manuallaunch" + }, + { + "keyset": "Ctrl+sc_d", + "action": "selfd" + }, + { + "keyset": "Ctrl+Shift+sc_d", + "action": "selfd queued" + }, + { + "keyset": "sc_e", + "action": "reclaim" + }, + { + "keyset": "Shift+sc_e", + "action": "reclaim" + }, + { + "keyset": "sc_f", + "action": "fight" + }, + { + "keyset": "Shift+sc_f", + "action": "fight" + }, + { + "keyset": "Alt+sc_f", + "action": "chain force forcestart | say !cv forcestart" + }, + { + "keyset": "sc_g", + "action": "guard" + }, + { + "keyset": "Shift+sc_g", + "action": "guard" + }, + { + "keyset": "sc_j", + "action": "canceltarget" + }, + { + "keyset": "sc_k", + "action": "cloak" + }, + { + "keyset": "Shift+sc_k", + "action": "cloak" + }, + { + "keyset": "sc_k", + "action": "wantcloak" + }, + { + "keyset": "Any+sc_k", + "action": "wantcloak" + }, + { + "keyset": "sc_l", + "action": "loadunits" + }, + { + "keyset": "Shift+sc_l", + "action": "loadunits" + }, + { + "keyset": "sc_m", + "action": "move" + }, + { + "keyset": "Shift+sc_m", + "action": "move" + }, + { + "keyset": "sc_n", + "action": "command_skip_current" + }, + { + "keyset": "Ctrl+sc_n", + "action": "command_cancel_last" + }, + { + "keyset": "sc_p", + "action": "patrol" + }, + { + "keyset": "Shift+sc_p", + "action": "patrol" + }, + { + "keyset": "sc_q,sc_q", + "action": "drawlabel" + }, + { + "keyset": "sc_q", + "action": "drawinmap" + }, + { + "keyset": "sc_r", + "action": "repair" + }, + { + "keyset": "Shift+sc_r", + "action": "repair" + }, + { + "keyset": "Ctrl+sc_r", + "action": "resurrect" + }, + { + "keyset": "sc_s", + "action": "stop" + }, + { + "keyset": "Shift+sc_s", + "action": "stop" + }, + { + "keyset": "Ctrl+sc_s", + "action": "stopproduction" + }, + { + "keyset": "sc_u", + "action": "unloadunits" + }, + { + "keyset": "Shift+sc_u", + "action": "unloadunits" + }, + { + "keyset": "sc_w", + "action": "wait" + }, + { + "keyset": "Shift+sc_w", + "action": "wait queued" + }, + { + "keyset": "sc_x", + "action": "onoff" + }, + { + "keyset": "Shift+sc_x", + "action": "onoff" + }, + { + "keyset": "Any+sc_l", + "action": "togglelos" + }, + { + "keyset": "Ctrl+sc_t", + "action": "trackmode" + }, + { + "keyset": "Any+sc_t", + "action": "track" + }, + { + "keyset": "Any+ctrl", + "action": "moveslow" + }, + { + "keyset": "Any+shift", + "action": "movefast" + }, + { + "keyset": "Ctrl+f1", + "action": "viewfps" + }, + { + "keyset": "Ctrl+f2", + "action": "viewta" + }, + { + "keyset": "Ctrl+f3", + "action": "viewspring" + }, + { + "keyset": "Ctrl+f4", + "action": "viewrot" + }, + { + "keyset": "Ctrl+f5", + "action": "viewfree" + }, + { + "keyset": "Any+f1", + "action": "showelevation" + }, + { + "keyset": "Any+f2", + "action": "showpathtraversability" + }, + { + "keyset": "Any+f3", + "action": "lastmsgpos" + }, + { + "keyset": "Any+f4", + "action": "showmetalmap" + }, + { + "keyset": "Any+f5", + "action": "hideinterface" + }, + { + "keyset": "Any+f6", + "action": "mutesound" + }, + { + "keyset": "Any+f7", + "action": "dynamicsky" + }, + { + "keyset": "f10", + "action": "options" + }, + { + "keyset": "f11", + "action": "luaui selector" + }, + { + "keyset": "Any+f12", + "action": "screenshot png" + }, + { + "keyset": "Ctrl+Shift+f8", + "action": "savegame" + }, + { + "keyset": "sc_`,sc_`", + "action": "drawlabel" + }, + { + "keyset": "sc_`", + "action": "drawinmap" + }, + { + "keyset": "Ctrl+sc_a", + "action": "select AllMap++_ClearSelection_SelectAll+" + }, + { + "keyset": "Ctrl+sc_b", + "action": "select AllMap+_Builder_Idle+_ClearSelection_SelectOne+" + }, + { + "keyset": "Ctrl+sc_c", + "action": "selectcomm focus" + }, + { + "keyset": "Ctrl+sc_v", + "action": "select AllMap+_Not_Builder_InPrevSel_Not_InHotkeyGroup+_SelectAll+" + }, + { + "keyset": "Ctrl+sc_w", + "action": "select AllMap+_Not_Aircraft_Weapons+_ClearSelection_SelectAll+" + }, + { + "keyset": "Ctrl+sc_x", + "action": "select AllMap+_InPrevSel_Not_InHotkeyGroup+_SelectAll+" + }, + { + "keyset": "Ctrl+sc_z", + "action": "select AllMap+_InPrevSel+_ClearSelection_SelectAll+" + }, + { + "keyset": "sc_z", + "action": "buildunit_armmex" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_armmex" + }, + { + "keyset": "sc_z", + "action": "buildunit_armamex" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_armamex" + }, + { + "keyset": "sc_z", + "action": "buildunit_cormex" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_cormex" + }, + { + "keyset": "sc_z", + "action": "buildunit_legmex" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_legmex" + }, + { + "keyset": "sc_z", + "action": "buildunit_legmext15" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_legmext15" + }, + { + "keyset": "sc_z", + "action": "buildunit_corexp" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_corexp" + }, + { + "keyset": "sc_z", + "action": "buildunit_armmoho" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_armmoho" + }, + { + "keyset": "sc_z", + "action": "buildunit_cormoho" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_cormoho" + }, + { + "keyset": "sc_z", + "action": "buildunit_legmoho" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_legmoho" + }, + { + "keyset": "sc_z", + "action": "buildunit_cormexp" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_cormexp" + }, + { + "keyset": "sc_z", + "action": "buildunit_coruwmme" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_coruwmme" + }, + { + "keyset": "sc_z", + "action": "buildunit_armuwmme" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_armuwmme" + }, + { + "keyset": "sc_z", + "action": "areamex" + }, + { + "keyset": "Shift+sc_z", + "action": "areamex" + }, + { + "keyset": "Ctrl+Alt+sc_z", + "action": "areamex" + }, + { + "keyset": "sc_x", + "action": "buildunit_armsolar" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armsolar" + }, + { + "keyset": "sc_x", + "action": "buildunit_armwin" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armwin" + }, + { + "keyset": "sc_x", + "action": "buildunit_corsolar" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_corsolar" + }, + { + "keyset": "sc_x", + "action": "buildunit_corwin" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_corwin" + }, + { + "keyset": "sc_x", + "action": "buildunit_legsolar" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_legsolar" + }, + { + "keyset": "sc_x", + "action": "buildunit_legwin" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_legwin" + }, + { + "keyset": "sc_x", + "action": "buildunit_armadvsol" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armadvsol" + }, + { + "keyset": "sc_x", + "action": "buildunit_coradvsol" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_coradvsol" + }, + { + "keyset": "sc_x", + "action": "buildunit_legadvsol" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_legadvsol" + }, + { + "keyset": "sc_x", + "action": "buildunit_armfus" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armfus" + }, + { + "keyset": "sc_x", + "action": "buildunit_armmmkr" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armmmkr" + }, + { + "keyset": "sc_x", + "action": "buildunit_corfus" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_corfus" + }, + { + "keyset": "sc_x", + "action": "buildunit_cormmkr" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_cormmkr" + }, + { + "keyset": "sc_x", + "action": "buildunit_legfus" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_legfus" + }, + { + "keyset": "sc_x", + "action": "buildunit_legadveconv" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_legadveconv" + }, + { + "keyset": "sc_x", + "action": "buildunit_armtide" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armtide" + }, + { + "keyset": "sc_x", + "action": "buildunit_cortide" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_cortide" + }, + { + "keyset": "sc_x", + "action": "buildunit_legtide" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_legtide" + }, + { + "keyset": "sc_x", + "action": "buildunit_armuwfus" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armuwfus" + }, + { + "keyset": "sc_x", + "action": "buildunit_coruwfus" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_coruwfus" + }, + { + "keyset": "sc_x", + "action": "buildunit_armuwmmm" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armuwmmm" + }, + { + "keyset": "sc_x", + "action": "buildunit_coruwmmm" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_coruwmmm" + }, + { + "keyset": "sc_c", + "action": "buildunit_armllt" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armllt" + }, + { + "keyset": "sc_c", + "action": "buildunit_armrad" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armrad" + }, + { + "keyset": "sc_c", + "action": "buildunit_corllt" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corllt" + }, + { + "keyset": "sc_c", + "action": "buildunit_leglht" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_leglht" + }, + { + "keyset": "sc_c", + "action": "buildunit_corrad" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corrad" + }, + { + "keyset": "sc_c", + "action": "buildunit_legrad" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_legrad" + }, + { + "keyset": "sc_c", + "action": "buildunit_corrl" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corrl" + }, + { + "keyset": "sc_c", + "action": "buildunit_legrl" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_legrl" + }, + { + "keyset": "sc_c", + "action": "buildunit_armrl" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armrl" + }, + { + "keyset": "sc_c", + "action": "buildunit_armpb" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armpb" + }, + { + "keyset": "sc_c", + "action": "buildunit_armflak" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armflak" + }, + { + "keyset": "sc_c", + "action": "buildunit_corvipe" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corvipe" + }, + { + "keyset": "sc_c", + "action": "buildunit_corflak" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corflak" + }, + { + "keyset": "sc_c", + "action": "buildunit_legapopupdef" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_legapopupdef" + }, + { + "keyset": "sc_c", + "action": "buildunit_legflak" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_legflak" + }, + { + "keyset": "sc_c", + "action": "buildunit_armgplat" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armgplat" + }, + { + "keyset": "sc_c", + "action": "buildunit_corgplat" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corgplat" + }, + { + "keyset": "sc_c", + "action": "buildunit_armtl" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armtl" + }, + { + "keyset": "sc_c", + "action": "buildunit_cortl" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_cortl" + }, + { + "keyset": "sc_c", + "action": "buildunit_legtl" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_legtl" + }, + { + "keyset": "sc_c", + "action": "buildunit_armsonar" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armsonar" + }, + { + "keyset": "sc_c", + "action": "buildunit_corsonar" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corsonar" + }, + { + "keyset": "sc_c", + "action": "buildunit_armfrad" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armfrad" + }, + { + "keyset": "sc_c", + "action": "buildunit_corfrad" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corfrad" + }, + { + "keyset": "sc_c", + "action": "buildunit_legfrad" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_legfrad" + }, + { + "keyset": "sc_c", + "action": "buildunit_armfrt" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armfrt" + }, + { + "keyset": "sc_c", + "action": "buildunit_corfrt" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corfrt" + }, + { + "keyset": "sc_c", + "action": "buildunit_legfrl" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_legfrl" + }, + { + "keyset": "sc_v", + "action": "buildunit_armnanotc" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_armnanotc" + }, + { + "keyset": "sc_v", + "action": "buildunit_armnanotcplat" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_armnanotcplat" + }, + { + "keyset": "sc_v", + "action": "buildunit_cornanotcplat" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_cornanotcplat" + }, + { + "keyset": "sc_v", + "action": "buildunit_armlab" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_armlab" + }, + { + "keyset": "sc_v", + "action": "buildunit_armvp" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_armvp" + }, + { + "keyset": "sc_v", + "action": "buildunit_armap" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_armap" + }, + { + "keyset": "sc_v", + "action": "buildunit_cornanotc" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_cornanotc" + }, + { + "keyset": "sc_v", + "action": "buildunit_corlab" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_corlab" + }, + { + "keyset": "sc_v", + "action": "buildunit_corvp" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_corvp" + }, + { + "keyset": "sc_v", + "action": "buildunit_corap" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_corap" + }, + { + "keyset": "sc_v", + "action": "buildunit_legnanotc" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_legnanotc" + }, + { + "keyset": "sc_v", + "action": "buildunit_leglab" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_leglab" + }, + { + "keyset": "sc_v", + "action": "buildunit_legvp" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_legvp" + }, + { + "keyset": "sc_v", + "action": "buildunit_legap" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_legap" + }, + { + "keyset": "sc_v", + "action": "buildunit_armsy" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_armsy" + }, + { + "keyset": "sc_v", + "action": "buildunit_corsy" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_corsy" + }, + { + "keyset": "sc_v", + "action": "buildunit_legsy" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_legsy" + }, + { + "keyset": "numpad2", + "action": "moveback" + }, + { + "keyset": "numpad6", + "action": "moveright" + }, + { + "keyset": "numpad4", + "action": "moveleft" + }, + { + "keyset": "numpad8", + "action": "moveforward" + }, + { + "keyset": "numpad9", + "action": "moveup" + }, + { + "keyset": "numpad3", + "action": "movedown" + }, + { + "keyset": "numpad1", + "action": "movefast" + }, + { + "keyset": "numpad+", + "action": "snd_volume_increase" + }, + { + "keyset": "sc_=", + "action": "snd_volume_increase" + }, + { + "keyset": "sc_-", + "action": "snd_volume_decrease" + }, + { + "keyset": "numpad-", + "action": "snd_volume_decrease" + }, + { + "keyset": "Any+space", + "action": "unit_stats" + }, + { + "keyset": "Ctrl+Shift+sc_o", + "action": "cameraflip" + }, + { + "keyset": "Alt+sc_y", + "action": "settarget" + }, + { + "keyset": "Shift+Alt+sc_y", + "action": "settarget" + }, + { + "keyset": "sc_y", + "action": "settargetnoground" + }, + { + "keyset": "Shift+sc_y", + "action": "settargetnoground" + }, + { + "keyset": "Ctrl+sc_`", + "action": "group unset" + }, + { + "keyset": "Alt+sc_`", + "action": "remove_from_autogroup" + }, + { + "keyset": "Alt+sc_b", + "action": "blueprint_place" + }, + { + "keyset": "Alt+sc_c", + "action": "blueprint_create" + }, + { + "keyset": "Alt+sc_d", + "action": "blueprint_delete" + }, + { + "keyset": "Alt+sc_[", + "action": "blueprint_prev" + }, + { + "keyset": "Alt+sc_]", + "action": "blueprint_next" + }, + { + "keyset": "Alt+sc_g", + "action": "factoryqueuemode" + }, + { + "keyset": "sc_.", + "action": "cycleselected next" + }, + { + "keyset": "sc_comma", + "action": "cycleselected prev" + } + ] + }, + { + "name": "Legacy (60% Keyboard)", + "binds": [ + { + "keyset": "esc", + "action": "select AllMap++_ClearSelection_SelectNum_0+" + }, + { + "keyset": "esc", + "action": "quitmessage" + }, + { + "keyset": "Shift+esc", + "action": "quitmenu" + }, + { + "keyset": "Ctrl+Shift+esc", + "action": "quitforce" + }, + { + "keyset": "Alt+Shift+esc", + "action": "reloadforce" + }, + { + "keyset": "Any+escape", + "action": "edit_escape" + }, + { + "keyset": "Any+pause", + "action": "pause" + }, + { + "keyset": "esc", + "action": "teamstatus_close" + }, + { + "keyset": "esc", + "action": "customgameinfo_close" + }, + { + "keyset": "esc", + "action": "buildmenu_pregame_deselect" + }, + { + "keyset": "Any+sc_z", + "action": "selectbox_same" + }, + { + "keyset": "Any+space", + "action": "selectbox_idle" + }, + { + "keyset": "Any+shift", + "action": "selectbox_append" + }, + { + "keyset": "Any+shift", + "action": "selectbox_any" + }, + { + "keyset": "Any+ctrl", + "action": "selectbox_deselect" + }, + { + "keyset": "Any+alt", + "action": "selectbox_mobile" + }, + { + "keyset": "Any+space", + "action": "selectloop" + }, + { + "keyset": "Any+ctrl", + "action": "selectloop_invert" + }, + { + "keyset": "Any+shift", + "action": "selectloop_add" + }, + { + "keyset": "Any+space", + "action": "buildsplit" + }, + { + "keyset": "Any+space", + "action": "commandinsert prepend_between" + }, + { + "keyset": "alt+sc_.", + "action": "attack_range_inc" + }, + { + "keyset": "alt+sc_comma", + "action": "attack_range_dec" + }, + { + "keyset": "Any+enter", + "action": "chat" + }, + { + "keyset": "Alt+ctrl+sc_a", + "action": "chatswitchally" + }, + { + "keyset": "Alt+ctrl+sc_s", + "action": "chatswitchspec" + }, + { + "keyset": "Any+tab", + "action": "edit_complete" + }, + { + "keyset": "Any+backspace", + "action": "edit_backspace" + }, + { + "keyset": "Any+delete", + "action": "edit_delete" + }, + { + "keyset": "Any+home", + "action": "edit_home" + }, + { + "keyset": "Alt+left", + "action": "edit_home" + }, + { + "keyset": "Any+end", + "action": "edit_end" + }, + { + "keyset": "Alt+right", + "action": "edit_end" + }, + { + "keyset": "Any+up", + "action": "edit_prev_line" + }, + { + "keyset": "Any+down", + "action": "edit_next_line" + }, + { + "keyset": "Any+left", + "action": "edit_prev_char" + }, + { + "keyset": "Any+right", + "action": "edit_next_char" + }, + { + "keyset": "Ctrl+left", + "action": "edit_prev_word" + }, + { + "keyset": "Ctrl+right", + "action": "edit_next_word" + }, + { + "keyset": "Any+enter", + "action": "edit_return" + }, + { + "keyset": "Ctrl+v", + "action": "pastetext" + }, + { + "keyset": "Any+up", + "action": "moveforward" + }, + { + "keyset": "Any+down", + "action": "moveback" + }, + { + "keyset": "Any+right", + "action": "moveright" + }, + { + "keyset": "Any+left", + "action": "moveleft" + }, + { + "keyset": "Any+pageup", + "action": "moveup" + }, + { + "keyset": "Any+pagedown", + "action": "movedown" + }, + { + "keyset": "Any+alt", + "action": "movereset" + }, + { + "keyset": "Any+alt", + "action": "moverotate" + }, + { + "keyset": "Any+ctrl", + "action": "movetilt" + }, + { + "keyset": "ctrl+sc_o", + "action": "fov_dec 5" + }, + { + "keyset": "ctrl+sc_p", + "action": "fov_inc 5" + }, + { + "keyset": "sc_numpad1", + "action": "fov_dec 5" + }, + { + "keyset": "sc_numpad7", + "action": "fov_inc 5" + }, + { + "keyset": "Meta+ctrl+tab", + "action": "pip1_copy" + }, + { + "keyset": "Meta+tab", + "action": "pip1_switch" + }, + { + "keyset": "Alt+sc_t", + "action": "pip1_track" + }, + { + "keyset": "Any+alt", + "action": "toggle_allied_upgrade" + }, + { + "keyset": "1", + "action": "specteam 0" + }, + { + "keyset": "2", + "action": "specteam 1" + }, + { + "keyset": "3", + "action": "specteam 2" + }, + { + "keyset": "4", + "action": "specteam 3" + }, + { + "keyset": "5", + "action": "specteam 4" + }, + { + "keyset": "6", + "action": "specteam 5" + }, + { + "keyset": "7", + "action": "specteam 6" + }, + { + "keyset": "8", + "action": "specteam 7" + }, + { + "keyset": "9", + "action": "specteam 8" + }, + { + "keyset": "Alt+0", + "action": "add_to_autogroup 0" + }, + { + "keyset": "Alt+1", + "action": "add_to_autogroup 1" + }, + { + "keyset": "Alt+2", + "action": "add_to_autogroup 2" + }, + { + "keyset": "Alt+3", + "action": "add_to_autogroup 3" + }, + { + "keyset": "Alt+4", + "action": "add_to_autogroup 4" + }, + { + "keyset": "Alt+5", + "action": "add_to_autogroup 5" + }, + { + "keyset": "Alt+6", + "action": "add_to_autogroup 6" + }, + { + "keyset": "Alt+7", + "action": "add_to_autogroup 7" + }, + { + "keyset": "Alt+8", + "action": "add_to_autogroup 8" + }, + { + "keyset": "Alt+9", + "action": "add_to_autogroup 9" + }, + { + "keyset": "Shift+Alt+0", + "action": "load_autogroup_preset 0" + }, + { + "keyset": "Shift+Alt+1", + "action": "load_autogroup_preset 1" + }, + { + "keyset": "Shift+Alt+2", + "action": "load_autogroup_preset 2" + }, + { + "keyset": "Shift+Alt+3", + "action": "load_autogroup_preset 3" + }, + { + "keyset": "Shift+Alt+4", + "action": "load_autogroup_preset 4" + }, + { + "keyset": "Shift+Alt+5", + "action": "load_autogroup_preset 5" + }, + { + "keyset": "Shift+Alt+6", + "action": "load_autogroup_preset 6" + }, + { + "keyset": "Shift+Alt+7", + "action": "load_autogroup_preset 7" + }, + { + "keyset": "Shift+Alt+8", + "action": "load_autogroup_preset 8" + }, + { + "keyset": "Shift+Alt+9", + "action": "load_autogroup_preset 9" + }, + { + "keyset": "0,0", + "action": "group focus 0" + }, + { + "keyset": "1,1", + "action": "group focus 1" + }, + { + "keyset": "2,2", + "action": "group focus 2" + }, + { + "keyset": "3,3", + "action": "group focus 3" + }, + { + "keyset": "4,4", + "action": "group focus 4" + }, + { + "keyset": "5,5", + "action": "group focus 5" + }, + { + "keyset": "6,6", + "action": "group focus 6" + }, + { + "keyset": "7,7", + "action": "group focus 7" + }, + { + "keyset": "8,8", + "action": "group focus 8" + }, + { + "keyset": "9,9", + "action": "group focus 9" + }, + { + "keyset": "0", + "action": "group select 0" + }, + { + "keyset": "1", + "action": "group select 1" + }, + { + "keyset": "2", + "action": "group select 2" + }, + { + "keyset": "3", + "action": "group select 3" + }, + { + "keyset": "4", + "action": "group select 4" + }, + { + "keyset": "5", + "action": "group select 5" + }, + { + "keyset": "6", + "action": "group select 6" + }, + { + "keyset": "7", + "action": "group select 7" + }, + { + "keyset": "8", + "action": "group select 8" + }, + { + "keyset": "9", + "action": "group select 9" + }, + { + "keyset": "Ctrl+0", + "action": "group set 0" + }, + { + "keyset": "Ctrl+1", + "action": "group set 1" + }, + { + "keyset": "Ctrl+2", + "action": "group set 2" + }, + { + "keyset": "Ctrl+3", + "action": "group set 3" + }, + { + "keyset": "Ctrl+4", + "action": "group set 4" + }, + { + "keyset": "Ctrl+5", + "action": "group set 5" + }, + { + "keyset": "Ctrl+6", + "action": "group set 6" + }, + { + "keyset": "Ctrl+7", + "action": "group set 7" + }, + { + "keyset": "Ctrl+8", + "action": "group set 8" + }, + { + "keyset": "Ctrl+9", + "action": "group set 9" + }, + { + "keyset": "Shift+0", + "action": "group selectadd 0" + }, + { + "keyset": "Shift+1", + "action": "group selectadd 1" + }, + { + "keyset": "Shift+2", + "action": "group selectadd 2" + }, + { + "keyset": "Shift+3", + "action": "group selectadd 3" + }, + { + "keyset": "Shift+4", + "action": "group selectadd 4" + }, + { + "keyset": "Shift+5", + "action": "group selectadd 5" + }, + { + "keyset": "Shift+6", + "action": "group selectadd 6" + }, + { + "keyset": "Shift+7", + "action": "group selectadd 7" + }, + { + "keyset": "Shift+8", + "action": "group selectadd 8" + }, + { + "keyset": "Shift+9", + "action": "group selectadd 9" + }, + { + "keyset": "Ctrl+Shift+0", + "action": "group add 0" + }, + { + "keyset": "Ctrl+Shift+1", + "action": "group add 1" + }, + { + "keyset": "Ctrl+Shift+2", + "action": "group add 2" + }, + { + "keyset": "Ctrl+Shift+3", + "action": "group add 3" + }, + { + "keyset": "Ctrl+Shift+4", + "action": "group add 4" + }, + { + "keyset": "Ctrl+Shift+5", + "action": "group add 5" + }, + { + "keyset": "Ctrl+Shift+6", + "action": "group add 6" + }, + { + "keyset": "Ctrl+Shift+7", + "action": "group add 7" + }, + { + "keyset": "Ctrl+Shift+8", + "action": "group add 8" + }, + { + "keyset": "Ctrl+Shift+9", + "action": "group add 9" + }, + { + "keyset": "Ctrl+Alt+0", + "action": "group selecttoggle 0" + }, + { + "keyset": "Ctrl+Alt+1", + "action": "group selecttoggle 1" + }, + { + "keyset": "Ctrl+Alt+2", + "action": "group selecttoggle 2" + }, + { + "keyset": "Ctrl+Alt+3", + "action": "group selecttoggle 3" + }, + { + "keyset": "Ctrl+Alt+4", + "action": "group selecttoggle 4" + }, + { + "keyset": "Ctrl+Alt+5", + "action": "group selecttoggle 5" + }, + { + "keyset": "Ctrl+Alt+6", + "action": "group selecttoggle 6" + }, + { + "keyset": "Ctrl+Alt+7", + "action": "group selecttoggle 7" + }, + { + "keyset": "Ctrl+Alt+8", + "action": "group selecttoggle 8" + }, + { + "keyset": "Ctrl+Alt+9", + "action": "group selecttoggle 9" + }, + { + "keyset": "Shift+backspace", + "action": "togglecammode" + }, + { + "keyset": "Ctrl+backspace", + "action": "togglecammode" + }, + { + "keyset": "Any+tab", + "action": "toggleoverview" + }, + { + "keyset": "Alt+sc_=", + "action": "increasespeed" + }, + { + "keyset": "Alt+sc_-", + "action": "decreasespeed" + }, + { + "keyset": "Alt+numpad+", + "action": "increasespeed" + }, + { + "keyset": "Alt+numpad-", + "action": "decreasespeed" + }, + { + "keyset": "sc_[", + "action": "buildfacing inc" + }, + { + "keyset": "Shift+sc_[", + "action": "buildfacing inc" + }, + { + "keyset": "sc_]", + "action": "buildfacing dec" + }, + { + "keyset": "Shift+sc_]", + "action": "buildfacing dec" + }, + { + "keyset": "Alt+sc_z", + "action": "buildspacing inc" + }, + { + "keyset": "Shift+Alt+sc_z", + "action": "buildspacing inc" + }, + { + "keyset": "Alt+sc_x", + "action": "buildspacing dec" + }, + { + "keyset": "Shift+Alt+sc_x", + "action": "buildspacing dec" + }, + { + "keyset": "sc_a", + "action": "attack" + }, + { + "keyset": "Shift+sc_a", + "action": "attack" + }, + { + "keyset": "Alt+sc_a", + "action": "areaattack" + }, + { + "keyset": "Alt+Shift+sc_a", + "action": "areaattack" + }, + { + "keyset": "sc_d", + "action": "manualfire" + }, + { + "keyset": "Shift+sc_d", + "action": "manualfire" + }, + { + "keyset": "sc_d", + "action": "manuallaunch" + }, + { + "keyset": "Shift+sc_d", + "action": "manuallaunch" + }, + { + "keyset": "Ctrl+sc_d", + "action": "selfd" + }, + { + "keyset": "Ctrl+Shift+sc_d", + "action": "selfd queued" + }, + { + "keyset": "sc_e", + "action": "reclaim" + }, + { + "keyset": "Shift+sc_e", + "action": "reclaim" + }, + { + "keyset": "sc_f", + "action": "fight" + }, + { + "keyset": "Shift+sc_f", + "action": "fight" + }, + { + "keyset": "Alt+sc_f", + "action": "chain force forcestart | say !cv forcestart" + }, + { + "keyset": "sc_g", + "action": "guard" + }, + { + "keyset": "Shift+sc_g", + "action": "guard" + }, + { + "keyset": "sc_j", + "action": "canceltarget" + }, + { + "keyset": "sc_k", + "action": "cloak" + }, + { + "keyset": "Shift+sc_k", + "action": "cloak" + }, + { + "keyset": "sc_k", + "action": "wantcloak" + }, + { + "keyset": "Any+sc_k", + "action": "wantcloak" + }, + { + "keyset": "sc_l", + "action": "loadunits" + }, + { + "keyset": "Shift+sc_l", + "action": "loadunits" + }, + { + "keyset": "sc_m", + "action": "move" + }, + { + "keyset": "Shift+sc_m", + "action": "move" + }, + { + "keyset": "sc_n", + "action": "command_skip_current" + }, + { + "keyset": "Ctrl+sc_n", + "action": "command_cancel_last" + }, + { + "keyset": "sc_p", + "action": "patrol" + }, + { + "keyset": "Shift+sc_p", + "action": "patrol" + }, + { + "keyset": "sc_q,sc_q", + "action": "drawlabel" + }, + { + "keyset": "sc_q", + "action": "drawinmap" + }, + { + "keyset": "sc_r", + "action": "repair" + }, + { + "keyset": "Shift+sc_r", + "action": "repair" + }, + { + "keyset": "Ctrl+sc_r", + "action": "resurrect" + }, + { + "keyset": "sc_s", + "action": "stop" + }, + { + "keyset": "Shift+sc_s", + "action": "stop" + }, + { + "keyset": "Ctrl+sc_s", + "action": "stopproduction" + }, + { + "keyset": "sc_u", + "action": "unloadunits" + }, + { + "keyset": "Shift+sc_u", + "action": "unloadunits" + }, + { + "keyset": "sc_w", + "action": "wait" + }, + { + "keyset": "Shift+sc_w", + "action": "wait queued" + }, + { + "keyset": "sc_x", + "action": "onoff" + }, + { + "keyset": "Shift+sc_x", + "action": "onoff" + }, + { + "keyset": "Any+sc_l", + "action": "togglelos" + }, + { + "keyset": "Ctrl+sc_t", + "action": "trackmode" + }, + { + "keyset": "Any+sc_t", + "action": "track" + }, + { + "keyset": "Any+ctrl", + "action": "moveslow" + }, + { + "keyset": "Any+shift", + "action": "movefast" + }, + { + "keyset": "Ctrl+meta+1", + "action": "viewfps" + }, + { + "keyset": "Ctrl+meta+2", + "action": "viewta" + }, + { + "keyset": "Ctrl+meta+3", + "action": "viewspring" + }, + { + "keyset": "Ctrl+meta+4", + "action": "viewrot" + }, + { + "keyset": "Ctrl+meta+5", + "action": "viewfree" + }, + { + "keyset": "meta+1", + "action": "showelevation" + }, + { + "keyset": "meta+2", + "action": "showpathtraversability" + }, + { + "keyset": "meta+3", + "action": "lastmsgpos" + }, + { + "keyset": "meta+4", + "action": "showmetalmap" + }, + { + "keyset": "meta+5", + "action": "hideinterface" + }, + { + "keyset": "meta+6", + "action": "mutesound" + }, + { + "keyset": "meta+7", + "action": "dynamicsky" + }, + { + "keyset": "meta+8", + "action": "screenshot png" + }, + { + "keyset": "Ctrl+Shift+f8", + "action": "savegame" + }, + { + "keyset": "Ctrl+sc_a", + "action": "select AllMap++_ClearSelection_SelectAll+" + }, + { + "keyset": "Ctrl+sc_b", + "action": "select AllMap+_Builder_Idle+_ClearSelection_SelectOne+" + }, + { + "keyset": "Ctrl+sc_c", + "action": "selectcomm focus" + }, + { + "keyset": "Ctrl+sc_v", + "action": "select AllMap+_Not_Builder_InPrevSel_Not_InHotkeyGroup+_SelectAll+" + }, + { + "keyset": "Ctrl+sc_w", + "action": "select AllMap+_Not_Aircraft_Weapons+_ClearSelection_SelectAll+" + }, + { + "keyset": "Ctrl+sc_x", + "action": "select AllMap+_InPrevSel_Not_InHotkeyGroup+_SelectAll+" + }, + { + "keyset": "Ctrl+sc_z", + "action": "select AllMap+_InPrevSel+_ClearSelection_SelectAll+" + }, + { + "keyset": "sc_z", + "action": "buildunit_armmex" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_armmex" + }, + { + "keyset": "sc_z", + "action": "buildunit_armamex" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_armamex" + }, + { + "keyset": "sc_z", + "action": "buildunit_cormex" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_cormex" + }, + { + "keyset": "sc_z", + "action": "buildunit_legmex" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_legmex" + }, + { + "keyset": "sc_z", + "action": "buildunit_legmext15" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_legmext15" + }, + { + "keyset": "sc_z", + "action": "buildunit_corexp" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_corexp" + }, + { + "keyset": "sc_z", + "action": "buildunit_armmoho" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_armmoho" + }, + { + "keyset": "sc_z", + "action": "buildunit_cormoho" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_cormoho" + }, + { + "keyset": "sc_z", + "action": "buildunit_legmoho" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_legmoho" + }, + { + "keyset": "sc_z", + "action": "buildunit_cormexp" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_cormexp" + }, + { + "keyset": "sc_z", + "action": "buildunit_coruwmme" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_coruwmme" + }, + { + "keyset": "sc_z", + "action": "buildunit_armuwmme" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_armuwmme" + }, + { + "keyset": "sc_z", + "action": "areamex" + }, + { + "keyset": "Shift+sc_z", + "action": "areamex" + }, + { + "keyset": "Ctrl+Alt+sc_z", + "action": "areamex" + }, + { + "keyset": "sc_x", + "action": "buildunit_armsolar" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armsolar" + }, + { + "keyset": "sc_x", + "action": "buildunit_armwin" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armwin" + }, + { + "keyset": "sc_x", + "action": "buildunit_corsolar" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_corsolar" + }, + { + "keyset": "sc_x", + "action": "buildunit_corwin" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_corwin" + }, + { + "keyset": "sc_x", + "action": "buildunit_legsolar" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_legsolar" + }, + { + "keyset": "sc_x", + "action": "buildunit_legwin" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_legwin" + }, + { + "keyset": "sc_x", + "action": "buildunit_armadvsol" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armadvsol" + }, + { + "keyset": "sc_x", + "action": "buildunit_coradvsol" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_coradvsol" + }, + { + "keyset": "sc_x", + "action": "buildunit_legadvsol" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_legadvsol" + }, + { + "keyset": "sc_x", + "action": "buildunit_armfus" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armfus" + }, + { + "keyset": "sc_x", + "action": "buildunit_armmmkr" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armmmkr" + }, + { + "keyset": "sc_x", + "action": "buildunit_corfus" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_corfus" + }, + { + "keyset": "sc_x", + "action": "buildunit_cormmkr" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_cormmkr" + }, + { + "keyset": "sc_x", + "action": "buildunit_legfus" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_legfus" + }, + { + "keyset": "sc_x", + "action": "buildunit_legadveconv" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_legadveconv" + }, + { + "keyset": "sc_x", + "action": "buildunit_armtide" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armtide" + }, + { + "keyset": "sc_x", + "action": "buildunit_cortide" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_cortide" + }, + { + "keyset": "sc_x", + "action": "buildunit_legtide" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_legtide" + }, + { + "keyset": "sc_x", + "action": "buildunit_armuwfus" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armuwfus" + }, + { + "keyset": "sc_x", + "action": "buildunit_coruwfus" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_coruwfus" + }, + { + "keyset": "sc_x", + "action": "buildunit_armuwmmm" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armuwmmm" + }, + { + "keyset": "sc_x", + "action": "buildunit_coruwmmm" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_coruwmmm" + }, + { + "keyset": "sc_c", + "action": "buildunit_armllt" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armllt" + }, + { + "keyset": "sc_c", + "action": "buildunit_armrad" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armrad" + }, + { + "keyset": "sc_c", + "action": "buildunit_corllt" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corllt" + }, + { + "keyset": "sc_c", + "action": "buildunit_leglht" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_leglht" + }, + { + "keyset": "sc_c", + "action": "buildunit_corrad" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corrad" + }, + { + "keyset": "sc_c", + "action": "buildunit_legrad" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_legrad" + }, + { + "keyset": "sc_c", + "action": "buildunit_corrl" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corrl" + }, + { + "keyset": "sc_c", + "action": "buildunit_legrl" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_legrl" + }, + { + "keyset": "sc_c", + "action": "buildunit_armrl" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armrl" + }, + { + "keyset": "sc_c", + "action": "buildunit_armpb" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armpb" + }, + { + "keyset": "sc_c", + "action": "buildunit_armflak" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armflak" + }, + { + "keyset": "sc_c", + "action": "buildunit_corvipe" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corvipe" + }, + { + "keyset": "sc_c", + "action": "buildunit_corflak" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corflak" + }, + { + "keyset": "sc_c", + "action": "buildunit_legapopupdef" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_legapopupdef" + }, + { + "keyset": "sc_c", + "action": "buildunit_legflak" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_legflak" + }, + { + "keyset": "sc_c", + "action": "buildunit_armgplat" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armgplat" + }, + { + "keyset": "sc_c", + "action": "buildunit_corgplat" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corgplat" + }, + { + "keyset": "sc_c", + "action": "buildunit_armtl" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armtl" + }, + { + "keyset": "sc_c", + "action": "buildunit_cortl" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_cortl" + }, + { + "keyset": "sc_c", + "action": "buildunit_legtl" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_legtl" + }, + { + "keyset": "sc_c", + "action": "buildunit_armsonar" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armsonar" + }, + { + "keyset": "sc_c", + "action": "buildunit_corsonar" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corsonar" + }, + { + "keyset": "sc_c", + "action": "buildunit_armfrad" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armfrad" + }, + { + "keyset": "sc_c", + "action": "buildunit_corfrad" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corfrad" + }, + { + "keyset": "sc_c", + "action": "buildunit_legfrad" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_legfrad" + }, + { + "keyset": "sc_c", + "action": "buildunit_armfrt" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armfrt" + }, + { + "keyset": "sc_c", + "action": "buildunit_corfrt" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corfrt" + }, + { + "keyset": "sc_c", + "action": "buildunit_legfrl" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_legfrl" + }, + { + "keyset": "sc_v", + "action": "buildunit_armnanotc" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_armnanotc" + }, + { + "keyset": "sc_v", + "action": "buildunit_armnanotcplat" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_armnanotcplat" + }, + { + "keyset": "sc_v", + "action": "buildunit_cornanotcplat" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_cornanotcplat" + }, + { + "keyset": "sc_v", + "action": "buildunit_armlab" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_armlab" + }, + { + "keyset": "sc_v", + "action": "buildunit_armvp" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_armvp" + }, + { + "keyset": "sc_v", + "action": "buildunit_armap" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_armap" + }, + { + "keyset": "sc_v", + "action": "buildunit_cornanotc" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_cornanotc" + }, + { + "keyset": "sc_v", + "action": "buildunit_corlab" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_corlab" + }, + { + "keyset": "sc_v", + "action": "buildunit_corvp" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_corvp" + }, + { + "keyset": "sc_v", + "action": "buildunit_corap" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_corap" + }, + { + "keyset": "sc_v", + "action": "buildunit_legnanotc" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_legnanotc" + }, + { + "keyset": "sc_v", + "action": "buildunit_leglab" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_leglab" + }, + { + "keyset": "sc_v", + "action": "buildunit_legvp" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_legvp" + }, + { + "keyset": "sc_v", + "action": "buildunit_legap" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_legap" + }, + { + "keyset": "sc_v", + "action": "buildunit_armsy" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_armsy" + }, + { + "keyset": "sc_v", + "action": "buildunit_corsy" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_corsy" + }, + { + "keyset": "sc_v", + "action": "buildunit_legsy" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_legsy" + }, + { + "keyset": "numpad2", + "action": "moveback" + }, + { + "keyset": "numpad6", + "action": "moveright" + }, + { + "keyset": "numpad4", + "action": "moveleft" + }, + { + "keyset": "numpad8", + "action": "moveforward" + }, + { + "keyset": "numpad9", + "action": "moveup" + }, + { + "keyset": "numpad3", + "action": "movedown" + }, + { + "keyset": "numpad1", + "action": "movefast" + }, + { + "keyset": "numpad+", + "action": "snd_volume_increase" + }, + { + "keyset": "sc_=", + "action": "snd_volume_increase" + }, + { + "keyset": "sc_-", + "action": "snd_volume_decrease" + }, + { + "keyset": "numpad-", + "action": "snd_volume_decrease" + }, + { + "keyset": "Any+space", + "action": "unit_stats" + }, + { + "keyset": "Ctrl+Shift+sc_o", + "action": "cameraflip" + }, + { + "keyset": "Alt+sc_y", + "action": "settarget" + }, + { + "keyset": "Shift+Alt+sc_y", + "action": "settarget" + }, + { + "keyset": "sc_y", + "action": "settargetnoground" + }, + { + "keyset": "Shift+sc_y", + "action": "settargetnoground" + }, + { + "keyset": "Ctrl+meta+sc_q", + "action": "group unset" + }, + { + "keyset": "Alt+sc_q", + "action": "remove_from_autogroup" + }, + { + "keyset": "Alt+sc_b", + "action": "blueprint_place" + }, + { + "keyset": "Alt+sc_c", + "action": "blueprint_create" + }, + { + "keyset": "Alt+sc_d", + "action": "blueprint_delete" + }, + { + "keyset": "Alt+sc_[", + "action": "blueprint_prev" + }, + { + "keyset": "Alt+sc_]", + "action": "blueprint_next" + }, + { + "keyset": "f11", + "action": "luaui selector" + }, + { + "keyset": "sc_.", + "action": "cycleselected next" + }, + { + "keyset": "sc_comma", + "action": "cycleselected prev" + } + ] + } + ] +} diff --git a/common/configs/keybind_defaults.schema.json b/common/configs/keybind_defaults.schema.json new file mode 100644 index 00000000000..c5b8e72158a --- /dev/null +++ b/common/configs/keybind_defaults.schema.json @@ -0,0 +1,66 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/beyond-all-reason/Beyond-All-Reason/master/common/configs/keybind_defaults.schema.json", + "title": "Shipped keybind profiles", + "description": "The read-only keybind profiles the game ships. Each profile is a whole keymap, never a delta.", + "type": "object", + "required": ["version", "profiles"], + "additionalProperties": false, + "definitions": { + "profile": { + "type": "object", + "required": ["name", "binds"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Display name, and the id a surface stores as the active selection. Unique across both lists." + }, + "fakeMeta": { + "type": "string", + "description": "Key to treat as the Meta modifier, if the profile wants one." + }, + "binds": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["keyset", "action"], + "additionalProperties": false, + "properties": { + "keyset": { + "type": "string", + "minLength": 1, + "description": "Keyset exactly as /bind expects it, including any modifier prefix and comma-separated chain." + }, + "action": { + "type": "string", + "minLength": 1, + "description": "Bind command plus its space-separated arguments." + } + } + } + } + } + } + }, + "properties": { + "version": { + "type": "integer", + "minimum": 1, + "description": "Format version of this file." + }, + "priority": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "description": "Action prefixes, highest priority first. Two actions on one key are tried in the order they were bound, so bind files are emitted with these actions first; anything unlisted keeps the order it already had." + }, + "profiles": { + "type": "array", + "minItems": 1, + "description": "Selectable profiles, ordered; the first is what a surface falls back to when nothing valid is selected.", + "items": { "$ref": "#/definitions/profile" } + } + } +} diff --git a/common/configs/keybinds.README.md b/common/configs/keybinds.README.md new file mode 100644 index 00000000000..ff9e8ac9860 --- /dev/null +++ b/common/configs/keybinds.README.md @@ -0,0 +1,161 @@ +# Shared keybind contract + +These files are the cross-surface source of truth for keybind editing, so the in-game +editor, Chobby, and the new lobby can each build their own UI without duplicating the +data or the rules. They hold *data and rules only* - no rendering, no engine calls. + +## Files + +| File | What it is | Schema | +|---|---|---| +| `keybind_catalog.json` | Ordered categories of keybindable commands, with i18n label keys and bind-action ids. | `keybind_catalog.schema.json` | +| `keybind_defaults.json` | The keybind profiles the game ships, each a complete keymap. | `keybind_defaults.schema.json` | + +Both are validated in CI by `spec/common/keybind_catalog_spec.lua`: each file against its schema, +profile names unique across the shipped set, every purely modifier-only action marked +read-only, and every action command written in lower case. + +They are separate because a catalog entry is per action while a binding is per action *and* +profile. The four shipped profiles share only 207 of the 381 actions they bind between them, +so there is no single default keyset to hang off a catalog row - merging the two would mean +every row carrying a keyset-per-profile map, which is this file re-expressed inside the +catalog. The sets differ both ways as well: 41 bound actions have no catalog entry - 25 of +those are listed as hidden on purpose, the other 16 surface under "Other" - and 8 catalog +entries are bound in no profile. Adding a bindable +action usually means touching both - the catalog for where it appears, a profile for what +it is bound to out of the box. + +A profile is a whole keymap, never a delta - applying one replaces everything, so +there is no base layer to reason about. The shipped profiles carry their bindings +inline rather than pointing at bind files, so a consumer reads one shape whether the +profile came from this file or from the player's own. + +Every shipped profile is selectable and read-only; editing one forks a copy under a name +the player chooses. + +## Catalog item kinds + +Everything the catalog lists is rebindable. An action a player cannot change - one bound to +a bare modifier, or handled by a widget that ships disabled - belongs in `hidden` or nowhere, +not in a category as a row that does nothing. + +Each category's `items` entry is one of: + +- `{ "action": "", "label": "" }` - one rebindable action. +- `{ "prefix": "" }` - claims every bound action whose id starts with the + prefix (for numbered families like `group select 0`, `group select 1`, ...). An optional + `"label"` is interpolated per matched action with the arg after the prefix as `%{n}` (or its + two whitespace-split tokens as `%{row}`/`%{col}`); an optional `"unit": true` resolves that + arg from a unit codename to its translated name. + +`action` is the bind command exactly as `/bind` expects and `GetKeyBindings` reports it +(command plus space-separated args, e.g. `select AllMap++_ClearSelection_SelectAll+`). The +command is lower case: the engine lower-cases it when parsing a bind line, so a capitalised +id matches nothing. + +A prefix entry may list `"members"`: the args the family covers, appended to the prefix to +form each action. Listing them makes those rows exist whether or not anything is bound, so +unbinding one leaves it there to bind again. Families that cannot be enumerated - `buildunit_` +is per unit - list none and are discovered from what is bound instead. + +An entry may carry `"alwaysModifier"`, naming a modifier the action always tolerates so no +surface shows it or lets the player pick it: + +- `"any"` binds with the engine's `Any+` qualifier and fires whatever is held. The engine + forces this for its own stateful commands (`drawinmap`, `move*`) regardless. +- `"shift"` has no engine equivalent, so the binding is written twice, bare and `Shift+`, + and both halves move together. Such an action holds exactly one key, not a list. + +A category may carry `"layout": "grid"`, drawn as the grid menu's own 3x4 arrangement rather +than a flat list so the keys read the way they sit on screen. + +A single leading `{ "hidden": ["", ...] }` entry (not a category) lists actions +that are bound but never shown - matched by exact id, not prefix, so a future action can't be +suppressed by coincidence - so they surface neither as a row nor under "Other". + +## Ordering + +Two orderings, meaning different things. + +**Catalog order is presentation.** Where an item sits decides where it appears in an +editor and nothing else. Reorganize freely. + +**Bind order in `keybind_defaults.json` is precedence.** The array is written out as bind +lines in order, the engine stamps each with an incrementing index, and two actions on one +keyset are tried in that order - first to succeed wins. Position matters only against +other binds on the same keyset; where a bind sits in the file overall does not. + +When adding a bind to a shipped profile: + +- On a keyset nothing else uses, position is free. Put it next to related binds. +- On a keyset that already carries an action, the earlier entry gets first refusal. Place + it above only if it should win. +- Do not reorder existing binds to tidy the file. That silently changes precedence. + +A handler that declines (returns falsy) does not hold the key - the next action on that +keyset is tried. Ordering only settles contests between handlers that would both succeed, +so "the wrong thing fires" is not automatically an ordering problem. + +`priority` is the override for when the natural order is wrong: action prefixes, highest +first, applied when a profile is written out. It is a stable sort, so listing an action +moves that action and leaves everything else where it was. It ships empty, because preset +order already encodes the intended precedence - add to it only for a case you can point at. + +## The config contract (behavior each surface implements) + +Structure lives in the schemas; these are the operations, which a schema can't express. +Every surface answers the same questions from the same facts. + +The player's own profiles live in `LuaUI/Config/keybind_profiles.json`, in the same +shape as the shipped ones plus an `active` field naming the selected profile. That file +is per-install rather than shared, but its format is the contract - a surface that can +read one can read the other. + +- **Which profile are we on?** Read `active` from the player's profile store. If it names + nothing that exists in either file, fall back to the first shipped profile. +- **Apply a profile.** Write its binds out as `bind ` lines (plus a + leading `fakemeta ` if it has one), point the engine config string `KeybindingFile` + at that file, and reload. Reloading clears the keymap first, which is why a profile has + to define every binding it wants. +- **Edit a binding.** Only in the player's own profiles. Shipped profiles are read-only, + so the first edit made while one is selected forks it into a copy and edits that. +- **Create / rename / delete.** Names are the identity, so they must stay unique across + both files; disambiguate rather than overwrite. Deleting the active profile means + falling back and applying whatever is left. + +### Same rules, different plumbing + +| Operation | In-game (LuaUI) | Lobby (Chobby / web) | +|---|---|---| +| Read the profile set | `VFS.LoadFile` both JSON files | read the same two files | +| Apply a profile | write `uikeys.txt`, `Spring.SetConfigString`, `keyreload` | write `uikeys.txt` and the config value the game reads on launch | +| Persist an edit | snapshot `Spring.GetKeyBindings` back into the active profile | rewrite the profile entry directly | + +## i18n + +The catalog carries i18n *keys*, not resolved strings. The ones it names live in +`language/en/commands.json`, in three namespaces: `commands` for things that also appear on +the command card (names and tooltips), `actions` for everything else a player can bind, and +`categories` for the group titles. The loader globs every json in `language//`, so the +namespaces merge into one lookup and Transifex picks the file up from the directory filter. + +Nothing is written twice. A row whose action *is* a command points at the `commands` entry +rather than repeating the string, which is why the wording there follows the command card. +Two lookups still leave the file: the grid menu's category names stay in `ui.buildMenu.*` +where that menu owns them, and `buildunit_` rows resolve `units.names.*` out of `units.json`. + +The editor's own UI - buttons, dialogs, prompts - is not vocabulary and stays in +`interface.json` under `ui.keybinds.editor.*`. A surface that builds its own UI needs the +three namespaces above and none of that. + +## Not covered yet + +- A widget/mod action-declaration API, so widgets register their own bindable actions + (with label + category + description) into the catalog at runtime instead of only being + editable when already bound. +- Command descriptions / tooltips. The engine ships per-command descriptions in the shared + `cmd.*` i18n namespace (in `interface.json`, localized like everything else), so a future + iteration can show them by resolving `cmd.` (falling back to `cmd.._description` + for the few structured commands, and `cmd.luarules.` for gadget commands) at display + time - no catalog change needed, since the catalog already carries the command per row. Widget/mod + actions have no `cmd.*` entry, so their descriptions depend on the declaration API above. diff --git a/language/de/commands.json b/language/de/commands.json new file mode 100644 index 00000000000..c3ad409b6bf --- /dev/null +++ b/language/de/commands.json @@ -0,0 +1,132 @@ +{ + "commands": { + "move": "Bewegen", + "move_tooltip": "Bewege eine Einheit zu einer Position oder folge anderen Einheiten", + "stop": "Stoppen", + "stop_tooltip": "Breche die aktuell ausgeführte Aktion der Einheit ab", + "attack": "Angreifen", + "attack_tooltip": "Greife eine Einheit oder Bodenposition an", + "areaattack": "Flächenangriff", + "areaattack_tooltip": "Greife alles in einem Umkreis an (Klicken und Ziehen)", + "manualfire": "D-Gun", + "manualfire_tooltip": "Feuere die mächtige Desintegrations-Kanone des Kommandanten", + "manuallaunch": "Abschuss", + "manuallaunch_tooltip": "Schieße eine Rakete auf ein Ziel", + "patrol": "Patrouillieren", + "patrol_tooltip": "Einen oder mehrere Patrouillen-Wegpunkte ablaufen", + "fight": "Kämpfen", + "fight_tooltip": "Befehle Einheiten anzugreifen, während sie sich zu einer Position bewegen", + "resurrect": "Wiederbeleben", + "resurrect_tooltip": "Belebe Wracks wieder, damit sie erneut zu Einheiten werden (Klicke und ziehe für Bereichsauswahl)", + "guard": "Beschützen", + "guard_tooltip": "Eine andere Einheit vor feindlichen Einheiten schützen, die sie angreifen", + "wait": "Warten", + "wait_tooltip": "Eine Einheit/Fabrik beim Abarbeiten der Befehlswarteschlange anhalten", + "repair": "Reparieren", + "repair_tooltip": "Eine beschädigte Einheit reparieren", + "reclaim": "Zerlegen", + "reclaim_tooltip": "Metall/Energie aus Wracks oder Besonderheiten (Bäume/Steine) gewinnen", + "restore": "Wiederherstellen", + "restore_tooltip": "Einen Teil der Karte zur Originalhöhe wiederherstellen", + "capture": "Einnehmen", + "capture_tooltip": "Konvertiert Einheiten, die einem Feind (oder Verbündeten) gehören", + "settarget": "Ziel festlegen", + "settarget_tooltip": "Setzt das Prioritätsziel (Ziel wird priorisiert, wenn es in Reichweite ist)", + "canceltarget": "Ziel löschen", + "canceltarget_tooltip": "Löscht das Prioritätsziel", + "areamex": "Gebiets-Metallextraktion", + "areamex_tooltip": "Klicke und ziehe einen Bereich, in dem automatisch auf allen verfügbaren Metallquellen Extraktoren gebaut werden sollen", + "loadunits": "Einheiten Aufladen", + "loadunits_tooltip": "Lädt eine oder mehrere Einheiten aus dem Bereich in den Transporter auf", + "unloadunits": "Einheiten Absetzen", + "unloadunits_tooltip": "Setzt eine oder mehrere Einheiten aus dem Bereich aus dem Transporter ab", + "stockpile": "Vorrat %{stockpileStatus}", + "stockpile_tooltip": "[ Anzahl im Vorrat ] / [ Angestrebte Anzahl ]", + "stopproduction": "Warteschlange leeren", + "stopproduction_tooltip": "Leert die Warteschlange für alle Einheiten in den ausgewählten Fabriken", + "morph": "Verbessern", + "morph_tooltip": "Auf die nächste Technologiestufe aufwerten (zweiter Klick bricht ab)", + "Fire at will": "Feuer frei", + "Hold fire": "Feuer einstellen", + "Return fire": "Zurückschießen", + "firestate_tooltip": "Setzt die Bedingungen, unter denen Einheiten auf Feinde schießen sollen (ohne expliziten Angriffsbefehl)", + "Hold pos": "Stellung halten", + "Maneuver": "Manövrieren", + "Roam": "Frei bewegen", + "movestate_tooltip": "Setzt die Beschränkung dafür, wie weit sich eine Einheit bewegen darf, um Gegner anzugreifen", + "Repeat on": "Wiederholen an", + "Repeat off": "Wiederholen aus", + "repeat_tooltip": "Wiederholt die Befehlswarteschlange", + "Low Prio": "Niedrige Priorität", + "High Prio": "Hohe Priorität", + "priority_tooltip": "Bestimmt die Ressourcenzuteilung dieses Arbeiters, wenn Mangel herrscht", + "Decloaked": "Sichtbar", + "Cloaked": "Getarnt", + "wantcloak_tooltip": "Sichtbarkeitszustand", + " On ": "An", + " Off ": "Aus", + "onoff_tooltip": "Aktivitätszustand: Schaltet eine Einheit an/aus", + " Fly ": "Fliegen", + "Land": "Landen", + "idlemode_tooltip": "Bestimmt, was Flugzeuge tun, wenn sie untätig sind", + "apLandAt_tooltip": "Bestimmt, was Flugzeuge tun, wenn sie die Fabrik verlassen", + "Low traj": "Direktes Feuer", + "High traj": "Indirektes Feuer", + "trajectory_tooltip": "Setzt den Feuermodus im Artilleriezustand (Flache/Steile Flugbahn)", + "hound_weapon_plasma": "Schwere Plasmakanonea", + "hound_weapon_gauss": "Gauß-Kanone", + "hound_weapon_toggle_tooltip": "Wechselt zwischen Gauss-Kanone und schwerer Plasmakanone", + "customOnOff": { + "lowTrajectory": "Direktes Feuer", + "highTrajectory": "Indirektes Feuer", + "trajectory_tooltip": "Schaltet den Artillerie-Abschusswinkel zwischen flacher und steiler Flugbahn um" + } + }, + "categories": { + "selection": "Einheiten auswählen", + "orders": "Auswahlbefehle", + "queues": "Befehle einreihen", + "camera": "Kamerabewegung", + "drawing": "Zeichnen", + "sound": "Sound" + }, + "actions": { + "massSelect": { + "all": "Alle Einheiten auswählen", + "builders": "Alle Bauarbeiter auswählen", + "sameType": "Alle Einheiten des selben Typs wie die Ausgewählte auswählen", + "removeAutoGroup": "Einheitstyp aus Autogruppe entfernen" + }, + "orders": { + "cloak": "Tarnen", + "selfDestruct": "Selbstzerstörung" + }, + "queues": { + "prepend": "Befehl am Anfang der Warteschlange einfügen" + }, + "buildOrders": { + "rotate": "Gebäudeausrichtung ändern" + }, + "issueBuildOrders": { + "spacingUp": "Bauabstand erhöhen", + "spacingDown": "Bauabstand verringern" + }, + "camera": { + "flip": "Kamera umdrehen" + }, + "cameraModes": { + "mapmarks": "Durch Kartenmarkierungen springen", + "heightmap": "Höhenkarte anzeigen", + "traversability": "Passierbarkeit anzeigen (für ausgewählte Einheit)", + "resourceSpots": "Metallkarte anzeigen", + "interface": "Benutzeroberfläche verstecken" + }, + "console": { + "erase": "Alle Zeichnungen und Marker wegradieren", + "pause": "Pause" + }, + "sound": { + "mute": "Stummschaltung umschalten" + } + } +} diff --git a/language/de/interface.json b/language/de/interface.json index 983bc874843..775397a7d1f 100644 --- a/language/de/interface.json +++ b/language/de/interface.json @@ -139,90 +139,7 @@ "disabled": "%{textColor}%{unit}%{warnColor} (deaktiviert)" }, "orderMenu": { - "hotkeyTooltip": "%{highlightColor}%{hotkey}%{textColor} - %{tooltip}", - "move": "Bewegen", - "move_tooltip": "Bewege eine Einheit zu einer Position oder folge anderen Einheiten", - "stop": "Stoppen", - "stop_tooltip": "Breche die aktuell ausgeführte Aktion der Einheit ab", - "attack": "Angreifen", - "attack_tooltip": "Greife eine Einheit oder Bodenposition an", - "areaattack": "Flächenangriff", - "areaattack_tooltip": "Greife alles in einem Umkreis an (Klicken und Ziehen)", - "manualfire": "D-Gun", - "manualfire_tooltip": "Feuere die mächtige Desintegrations-Kanone des Kommandanten", - "manuallaunch": "Abschuss", - "manuallaunch_tooltip": "Schieße eine Rakete auf ein Ziel", - "patrol": "Patrouillieren", - "patrol_tooltip": "Einen oder mehrere Patrouillen-Wegpunkte ablaufen", - "fight": "Kämpfen", - "fight_tooltip": "Befehle Einheiten anzugreifen, während sie sich zu einer Position bewegen", - "resurrect": "Wiederbeleben", - "resurrect_tooltip": "Belebe Wracks wieder, damit sie erneut zu Einheiten werden (Klicke und ziehe für Bereichsauswahl)", - "guard": "Beschützen", - "guard_tooltip": "Eine andere Einheit vor feindlichen Einheiten schützen, die sie angreifen", - "wait": "Warten", - "wait_tooltip": "Eine Einheit/Fabrik beim Abarbeiten der Befehlswarteschlange anhalten", - "repair": "Reparieren", - "repair_tooltip": "Eine beschädigte Einheit reparieren", - "reclaim": "Zerlegen", - "reclaim_tooltip": "Metall/Energie aus Wracks oder Besonderheiten (Bäume/Steine) gewinnen", - "restore": "Wiederherstellen", - "restore_tooltip": "Einen Teil der Karte zur Originalhöhe wiederherstellen", - "capture": "Einnehmen", - "capture_tooltip": "Konvertiert Einheiten, die einem Feind (oder Verbündeten) gehören", - "settarget": "Ziel festlegen", - "settarget_tooltip": "Setzt das Prioritätsziel (Ziel wird priorisiert, wenn es in Reichweite ist)", - "canceltarget": "Ziel löschen", - "canceltarget_tooltip": "Löscht das Prioritätsziel", - "areamex": "Gebiets-Metallextraktion", - "areamex_tooltip": "Klicke und ziehe einen Bereich, in dem automatisch auf allen verfügbaren Metallquellen Extraktoren gebaut werden sollen", - "loadunits": "Einheiten Aufladen", - "loadunits_tooltip": "Lädt eine oder mehrere Einheiten aus dem Bereich in den Transporter auf", - "unloadunits": "Einheiten Absetzen", - "unloadunits_tooltip": "Setzt eine oder mehrere Einheiten aus dem Bereich aus dem Transporter ab", - "stockpile": "Vorrat %{stockpileStatus}", - "stockpile_tooltip": "[ Anzahl im Vorrat ] / [ Angestrebte Anzahl ]", - "stopproduction": "Warteschlange leeren", - "stopproduction_tooltip": "Leert die Warteschlange für alle Einheiten in den ausgewählten Fabriken", - "morph": "Verbessern", - "morph_tooltip": "Auf die nächste Technologiestufe aufwerten (zweiter Klick bricht ab)", - - "Fire at will": "Feuer frei", - "Hold fire": "Feuer einstellen", - "Return fire": "Zurückschießen", - "firestate_tooltip": "Setzt die Bedingungen, unter denen Einheiten auf Feinde schießen sollen (ohne expliziten Angriffsbefehl)", - "Hold pos": "Stellung halten", - "Maneuver": "Manövrieren", - "Roam": "Frei bewegen", - "movestate_tooltip": "Setzt die Beschränkung dafür, wie weit sich eine Einheit bewegen darf, um Gegner anzugreifen", - "Repeat on": "Wiederholen an", - "Repeat off": "Wiederholen aus", - "repeat_tooltip": "Wiederholt die Befehlswarteschlange", - "Low Prio": "Niedrige Priorität", - "High Prio": "Hohe Priorität", - "priority_tooltip": "Bestimmt die Ressourcenzuteilung dieses Arbeiters, wenn Mangel herrscht", - "Decloaked": "Sichtbar", - "Cloaked": "Getarnt", - "wantcloak_tooltip": "Sichtbarkeitszustand", - " On ": "An", - " Off ": "Aus", - "onoff_tooltip": "Aktivitätszustand: Schaltet eine Einheit an/aus", - " Fly ": "Fliegen", - "Land": "Landen", - "idlemode_tooltip": "Bestimmt, was Flugzeuge tun, wenn sie untätig sind", - "apLandAt_tooltip": "Bestimmt, was Flugzeuge tun, wenn sie die Fabrik verlassen", - "Low traj": "Direktes Feuer", - "High traj": "Indirektes Feuer", - "trajectory_tooltip": "Setzt den Feuermodus im Artilleriezustand (Flache/Steile Flugbahn)", - "hound_weapon_plasma": "Schwere Plasmakanonea", - "hound_weapon_gauss": "Gauß-Kanone", - "hound_weapon_toggle_tooltip": "Wechselt zwischen Gauss-Kanone und schwerer Plasmakanone", - - "customOnOff": { - "lowTrajectory": "Direktes Feuer", - "highTrajectory": "Indirektes Feuer", - "trajectory_tooltip": "Schaltet den Artillerie-Abschusswinkel zwischen flacher und steiler Flugbahn um" - } + "hotkeyTooltip": "%{highlightColor}%{hotkey}%{textColor} - %{tooltip}" }, "idleBuilders": { "name": "Untätige Arbeiter", @@ -245,7 +162,7 @@ "keybinds": { "title": "Tastenbelegungen", "disclaimer": "Diese Tastenbelegungen sind standardmäßig gesetzt. Wenn du Hotkey-Widgets entfernst/ersetzt oder deine eigenen UIKeys verwendest, funktionieren sie möglicherweise nicht mehr!", - "howtochangekeybinds":"Zum Ändern: in Einstellungen/Steuerung Tastenbelegungen auf \"Benutzerdefiniert\" setzen, um die Datei BAR/data/uikeys.txt zu erstellen.\nBearbeite diese Datei und wähle \"Benutzerdefiniert\" wieder aus, um erneut zu laden.", + "howtochangekeybinds": "Zum Ändern: in Einstellungen/Steuerung Tastenbelegungen auf \"Benutzerdefiniert\" setzen, um die Datei BAR/data/uikeys.txt zu erstellen.\nBearbeite diese Datei und wähle \"Benutzerdefiniert\" wieder aus, um erneut zu laden.", "chat": { "title": "Chat", "send": "Chatnachricht senden", @@ -264,7 +181,6 @@ "share": "Einheiten/Ressourcen teilen" }, "camera": { - "title": "Kamerabewegung", "zoomKey": "Mausrad", "zoom": "Kamera zoomen", "panKey": "Pfeiltasten / Maus am Bildschirmrand", @@ -272,8 +188,7 @@ "tiltKey": "STRG + Mausrad", "tilt": "Kamerawinkel ändern", "dragKey": "Mittlere Maustaste (+ Ziehen)", - "drag": "Kamera ziehen", - "flip": "Kamera umdrehen" + "drag": "Kamera ziehen" }, "cameraModes": { "title": "Kameramodi", @@ -282,21 +197,13 @@ "fullscreenKey": "ALT + Backspace", "fullscreen": "Vollbild umschalten", "overview": "Überblickende Kamera umschalten", - "los": "Sichtlinienansicht umschalten", - "heightmap": "Höhenkarte anzeigen", - "traversability": "Passierbarkeit anzeigen (für ausgewählte Einheit)", - "mapmarks": "Durch Kartenmarkierungen springen", - "resourceSpots": "Metallkarte anzeigen", - "interface": "Benutzeroberfläche verstecken" + "los": "Sichtlinienansicht umschalten" }, "sound": { - "title": "Sound", "volumeKey": "-/+", - "volume": "Lautstärke ändern", - "mute": "Stummschaltung umschalten" + "volume": "Lautstärke ändern" }, "selection": { - "title": "Einheiten auswählen", "unitsKey": "Linke Maustaste (+ Ziehen)", "units": "Einheiten aus- oder abwählen" }, @@ -308,23 +215,8 @@ "formationOrder": "Formationsbefehl an Einheit(en) geben" }, "orders": { - "title": "Auswahlbefehle", "defaultKey": "(nichts)", - "default": "Standardbefehl (meist Bewegen)", - "move": "Bewegen", - "attack": "Angreifen", - "stop": "Stoppen (löscht Befehlswarteschlange)", - "repair": "Reparieren", - "reclaim": "Zerlegen", - "resurrect": "Wiederbeleben", - "patrol": "Patroullieren", - "fight": "Kämpfen", - "setTarget": "Prioritätsziel setzen", - "cancelTarget": "Prioritätsziel löschen", - "wait": "Warten (Aktuellen Befehl pausieren)", - "cloak": "Tarnen", - "dGun": "Manuelles Feuern (D-Gun)", - "selfDestruct": "Selbstzerstörung" + "default": "Standardbefehl (meist Bewegen)" }, "issueOrders": { "title": "Ausgewählte Befehle geben", @@ -336,11 +228,9 @@ "formation": "Formationsbefehl an Einheit(en) geben" }, "queues": { - "title": "Befehle einreihen", "append": "Befehl am Ende der Warteschlange anfügen", "appendKey": "Umschalttaste + (irgendein Befehl)", - "prependKey": "Leertaste + (irgendein Befehl)", - "prepend": "Befehl am Anfang der Warteschlange einfügen" + "prependKey": "Leertaste + (irgendein Befehl)" }, "buildOrders": { "title": "Bauaufträge auswählen", @@ -354,8 +244,7 @@ "intel": "Durch Radar/Verteidigung/etc. schalten", "factoriesKey": "V", "factories": "Durch Fabriken schalten", - "rotateKey": "ü und +", - "rotate": "Gebäudeausrichtung ändern" + "rotateKey": "ü und +" }, "issueBuildOrders": { "title": "Bauaufträge erteilen", @@ -368,29 +257,22 @@ "gridKey": "Umschalttaste + ALT + (Bauauftrag)", "grid": "Als Rechteck bauen", "spacingUpKey": "ALT + Y", - "spacingUp": "Bauabstand erhöhen", - "spacingDownKey": "ALT + X", - "spacingDown": "Bauabstand verringern" + "spacingDownKey": "ALT + X" }, "massSelect": { "title": "Gruppenauswahl", "allKey": "STRG + A", - "all": "Alle Einheiten auswählen", "buildersKey": "STRG + B", - "builders": "Alle Bauarbeiter auswählen", "createGroupKey": "STRG + (Nummer)", "createGroup": "Einheiten zu Gruppe hinzufügen (Nummer=1,2,...)", "createAutoGroupKey": "ALT + (Nummer)", "createAutoGroup": "Einheitstyp zu Autogruppe hinzufügen (Nummer=1,2,...)", "removeAutoGroupKey": "ALT + ^", - "removeAutoGroup": "Einheitstyp aus Autogruppe entfernen", "groupKey": "(Nummer)", "group": "Alle der Gruppe (Nummer) zugewiesenen Einheiten auswählen", - "sameTypeKey": "STRG + Y", - "sameType": "Alle Einheiten des selben Typs wie die Ausgewählte auswählen" + "sameTypeKey": "STRG + Y" }, "drawing": { - "title": "Zeichnen", "mapmarkKey": "^ + Doppelklick", "mapmark": "Kartenmarkierung platzieren", "drawKey": "^ + Linke Maustaste ziehen", @@ -401,9 +283,7 @@ "console": { "title": "Konsolenbefehle", "eraseKey": "/clearmapmarks", - "erase": "Alle Zeichnungen und Marker wegradieren", - "pauseKey": "/pause", - "pause": "Pause" + "pauseKey": "/pause" } }, "chat": { @@ -471,7 +351,7 @@ "heightTitle": "Höhe", "heightmap": "[%{keyset}] Zeigt jede Höhenstufe in einer anderen Farbe", "pathingTitle": "Passierbarkeit", - "pathing":"[%{keyset}] Zeigt an, wohin die ausgewählte Einheit sich bewegen kann, Grün: okay, Rot: problematisch, Lila: unerreichbar", + "pathing": "[%{keyset}] Zeigt an, wohin die ausgewählte Einheit sich bewegen kann, Grün: okay, Rot: problematisch, Lila: unerreichbar", "resourcesTitle": "Ressourcen", "resources": "[%{keyset}] Hebt Metalladern in Grün und geothermische Quellen in Gelb hervor. Besetzte Metalladern werden in Rot angezeigt." }, @@ -594,7 +474,7 @@ }, "moveAttackNotify": { "underAttack": "%{unit} wird angegriffen!", - "cantMove":"%{unit}: Kann Ziel nicht erreichen!" + "cantMove": "%{unit}: Kann Ziel nicht erreichen!" }, "unitShare": { "received": "Du hast %{count}neue Einheit(en)* erhalten", diff --git a/language/en/commands.json b/language/en/commands.json new file mode 100644 index 00000000000..eafd3c19a8c --- /dev/null +++ b/language/en/commands.json @@ -0,0 +1,275 @@ +{ + "commands": { + "move": "Move", + "move_tooltip": "Move a unit towards a position or follow other units", + "stop": "Stop", + "stop_tooltip": "Cancel the unit's current actions", + "attack": "Attack", + "attack_tooltip": "Attack a unit or ground position", + "areaattack": "Area Attack", + "areaattack_tooltip": "Area attack everything within a circle (click-drag)", + "manualfire": "D-Gun", + "manualfire_tooltip": "Fire the powerful commander Disintegrator-gun", + "manuallaunch": "Launch", + "manuallaunch_tooltip": "Launch a missile at a target", + "patrol": "Patrol", + "patrol_tooltip": "Patrol along one or more waypoints", + "fight": "Fight", + "fight_tooltip": "Order units to take action while moving to a position", + "resurrect": "Resurrect", + "resurrect_tooltip": "Revive wrecks to become units again (click-drag for area)", + "guard": "Guard", + "guard_tooltip": "Guard another unit against enemy units attacking it", + "factoryguard": "Factory Guard", + "factoryguard_tooltip": "Builders produced by this factory will automatically guard it", + "wait": "Wait", + "wait_tooltip": "Pause a unit/factory on processing command/build queues", + "repair": "Repair", + "repair_tooltip": "Repair a damaged unit", + "reclaim": "Reclaim", + "reclaim_tooltip": "Suck metal/energy from wrecks or features (trees/stones)", + "restore": "Restore", + "restore_tooltip": "Restore an area of the map to its original height", + "capture": "Capture", + "capture_tooltip": "Convert units that belong to the enemy (or ally)", + "settarget": "Set Target", + "settarget_tooltip": "Set a prioritized target (prioritizes targeting when target in range)", + "canceltarget": "Clear Target", + "canceltarget_tooltip": "Removes the priority target", + "areamex": "Area Mex", + "areamex_tooltip": "Click-drag an area to auto queue metal extractors for all available metal spots", + "loadunits": "Load units", + "loadunits_tooltip": "Load unit or multiple units within an area in the transport", + "unloadunits": "Unload units", + "unloadunits_tooltip": "Unload unit or multiple units within an area in the transport", + "stockpile": "Stockpile %{stockpileStatus}", + "stockpile_tooltip": "[ stockpiled number ] / [ target stockpile number ]", + "stopproduction": "Clear Queue", + "stopproduction_tooltip": "Clear build queue and quotas for all units on selected factories", + "morph": "Upgrade", + "morph_tooltip": "Upgrade to next Tech-level (second click to cancel)", + "Spawning Disabled": "Spawning disabled", + "Spawning Enabled": "Spawning enabled", + "sellunit": "Sell Unit", + "sellunit_tooltip": "Toggle currently selected units for sale, allies will be able to buy them", + "For Sale": "For Sale", + "Not For Sale": "Not For Sale", + "Fire at will": "Fire at will", + "Hold fire": "Hold fire", + "Return fire": "Return fire", + "Defend": "Defend", + "Fire at all": "Fire At All", + "firestate_hold_fire_descr": "Don't acquire targets without orders", + "firestate_return_fire_descr": "Return fire when attacked", + "firestate_defend_descr": "Fire upon enemies when they individually become a threat", + "firestate_fire_at_will_descr": "Fire upon enemies within range", + "firestate_fire_at_all_descr": "Fire upon anything belonging to the enemy within range", + "firestate_tooltip": "Set under what conditions a unit should start firing at enemies (without explicit attack order).", + "Hold pos": "Hold pos", + "Maneuver": "Maneuver", + "Roam": "Roam", + "movestate_tooltip": "Set how far out of its way a unit should move to attack enemies", + "bomber_targeting": "Targeting", + "bomber_targeting_tooltip": "Hold Fire: Only attack with an explicit order. Fire at will: Idle bombers will seek targets within range.", + "Repeat on": "Repeat On", + "Repeat off": "Repeat Off", + "repeat_tooltip": "Repeat unit command queue", + "Low Prio": "Low Priority", + "High Prio": "High Priority", + "priority_tooltip": "Assigns resources to use for this builder when not having enough for all", + "Decloaked": "Visible", + "Cloaked": "Cloaked", + "wantcloak_tooltip": "Visibility state", + " On ": "On", + " Off ": "Off", + "onoff_tooltip": "Active state: turn a unit on/off", + " Fly ": "Fly", + "Land": "Land", + "idlemode_tooltip": "Sets what aircraft do when idle", + "aplandat_tooltip": "Sets what aircraft do when leaving air factory", + "csspawning_tooltip": "Sets the spawning state of the carrier", + "Low traj": "High Traj", + "High traj": "High Traj", + "trajectory_toggle_tooltip": "Switch artillery firing angle between low, high and automatic trajectory", + "trajectory_low": "Low Trajectory", + "trajectory_high": "High Trajectory", + "trajectory_auto": "Auto Trajectory", + "hound_weapon_plasma": "Heavy Plasma", + "hound_weapon_gauss": "Gauss Cannon", + "hound_weapon_toggle_tooltip": "Sets weapon to a slow plasma projectile with a large aoe, or a fast projectile gauss weapon", + "blueprint_place": "Place Blueprint", + "blueprint_place_tooltip": "Place a saved blueprint", + "blueprint_create": "Save Blueprint", + "blueprint_create_tooltip": "Save the selected units into a new blueprint, in the order they were selected", + "factoryqueuemode_normal": "Queue Mode", + "factoryqueuemode_quota": "Quota Mode", + "factoryqueuemode_tooltip": "Queue: Build each queued unit once\nQuota: Maintain a minimum quota of each unit on the battlefield", + "customOnOff": { + "lowTrajectory": "Low Trajectory", + "highTrajectory": "High Trajectory", + "trajectory_tooltip": "Switch artillery firing angle between low and high trajectory", + "gauss_tooltip": "Switches between Gauss Cannon and Heavy Plasma Cannon" + }, + "quicksharetotarget": "Share Unit", + "quicksharetotarget_tooltip": "Share unit to target player." + }, + "actions": { + "selection": { + "commAppend": "Add commander to selection", + "commFocus": "Select and go to commander", + "boxIdle": "Box select: idle units only", + "boxSame": "Box select: matching unit types", + "loopActivate": "Loop select: activate" + }, + "massSelect": { + "all": "Select all units", + "builders": "Select all constructors", + "sameType": "Select all units of same type as selected", + "sameTypeVisible": "Select same type as current, on screen", + "damaged": "Select damaged units", + "half": "Select half of current selection", + "idleTransports": "Select all idle transports", + "waitingVisible": "Select waiting units, on screen", + "deselectAll": "Deselect all", + "removeAutoGroup": "Remove unit type from autogroup" + }, + "orders": { + "cloak": "Cloak", + "waitQueued": "Wait (queued)", + "selfDestruct": "Self-destruct", + "selfDestructQueued": "Self-destruct (queued)", + "setTargetNoGround": "Set unit target", + "gatherWait": "Gather wait" + }, + "queues": { + "prepend": "Add order to start of order queue", + "skipCurrent": "Skip current command", + "cancelLast": "Cancel last command" + }, + "unitStates": { + "onoffToggle": "Toggle unit on/off", + "onoffOff": "Turn unit off", + "onoffOn": "Turn unit on", + "repeatOff": "Repeat off", + "repeatOn": "Repeat on", + "trajectory": "Trajectory %{n}", + "fireHold": "Fire state: Hold fire", + "fireReturn": "Fire state: Return fire", + "fireAtWill": "Fire state: Fire at will", + "moveHold": "Move state: Hold position", + "moveManeuver": "Move state: Maneuver", + "moveRoam": "Move state: Roam" + }, + "controlGroups": { + "select": "Select control group %{n}", + "focus": "Select control group %{n} and focus camera", + "assign": "Assign selection to control group %{n}", + "add": "Add selection to control group %{n}", + "selectAdd": "Add control group %{n} to selection", + "selectToggle": "Toggle control group %{n} in selection", + "clear": "Remove selection from control groups", + "addAuto": "Add unit type to auto-group %{n}", + "removeOne": "Remove closest unit from its group", + "preset": "Load auto-group preset %{n}" + }, + "buildHotkeys": { + "unit": "Build %{n}", + "split": "Split build orders between builders" + }, + "buildOrders": { + "rotate": "Rotate building placement", + "rotateBack": "Rotate building placement (reverse)" + }, + "issueBuildOrders": { + "spacingUp": "Increase build spacing", + "spacingDown": "Decrease build spacing" + }, + "factory": { + "queueMode": "Toggle factory repeat mode", + "loadPreset": "Load factory preset %{n}", + "savePreset": "Save factory preset %{n}", + "showPresets": "Show factory presets" + }, + "gridMenu": { + "buildKey": "Row %{row}, column %{col}", + "nextPage": "Next page", + "cycleBuilder": "Cycle builder" + }, + "blueprints": { + "delete": "Delete blueprint", + "next": "Next blueprint", + "prev": "Previous blueprint" + }, + "camera": { + "flip": "Flip camera", + "moveForward": "Move camera forward", + "moveBack": "Move camera back", + "moveLeft": "Move camera left", + "moveRight": "Move camera right", + "moveUp": "Move camera up", + "moveDown": "Move camera down", + "moveFast": "Move camera faster", + "setAnchor": "Set camera anchor %{n}", + "jumpAnchor": "Jump to camera anchor %{n}", + "pipSwitch": "Swap camera with picture-in-picture", + "pipCopy": "Copy camera to picture-in-picture", + "pipTrack": "Track selection in picture-in-picture", + "fovIncrease": "Increase field of view", + "fovDecrease": "Decrease field of view", + "cycleSelectedNext": "Cycle camera to next selected unit", + "cycleSelectedPrev": "Cycle camera to previous selected unit" + }, + "cameraModes": { + "mapmarks": "Cycle through map marks", + "heightmap": "Show height map", + "traversability": "Show passability (for selected unit)", + "resourceSpots": "Show metal map", + "interface": "Hide GUI" + }, + "interfaceDisplay": { + "settings": "Settings", + "unitStats": "Unit stats", + "saveGame": "Save game", + "screenshot": "Take screenshot", + "firingRange": "Display firing range" + }, + "gameControl": { + "voteForcestart": "Vote forcestart", + "increaseSpeed": "Increase game speed", + "decreaseSpeed": "Decrease game speed" + }, + "drawing": { + "drawInMap": "Draw", + "drawLabel": "Place label" + }, + "console": { + "erase": "Erase all drawings and markers", + "pause": "Pause" + }, + "sound": { + "mute": "Toggle mute", + "volumeUp": "Increase volume", + "volumeDown": "Decrease volume" + }, + "spectating": { + "spectate": "Spectate team %{n}" + } + }, + "categories": { + "selection": "Selecting units", + "orders": "Orders", + "queues": "Order modifiers", + "unitStates": "Unit states", + "controlGroups": "Control groups", + "buildHotkeys": "Build", + "gridMenu": "Grid menu", + "blueprints": "Blueprints", + "camera": "Camera", + "mapViews": "Map views", + "interfaceDisplay": "Interface", + "drawing": "Drawing", + "sound": "Sound", + "gameControl": "Game", + "other": "Other" + } +} diff --git a/language/en/interface.json b/language/en/interface.json index f54671a5323..fdd9323247a 100644 --- a/language/en/interface.json +++ b/language/en/interface.json @@ -189,6 +189,7 @@ "tooltipctrl": "CTRL-click to continuously clear" }, "buildMenu": { + "back": "Back", "category_econ": "Economy", "category_combat": "Combat", "category_utility": "Utility", @@ -206,120 +207,7 @@ "areamex_tooltip": "Tip: Select this extractor twice to use area mex command" }, "orderMenu": { - "hotkeyTooltip": "%{highlightColor}%{hotkey}%{textColor} - %{tooltip}", - "move": "Move", - "move_tooltip": "Move a unit towards a position or follow other units", - "stop": "Stop", - "stop_tooltip": "Cancel the unit's current actions", - "attack": "Attack", - "attack_tooltip": "Attack a unit or ground position", - "areaattack": "Area Attack", - "areaattack_tooltip": "Area attack everything within a circle (click-drag)", - "manualfire": "D-Gun", - "manualfire_tooltip": "Fire the powerful commander Disintegrator-gun", - "manuallaunch": "Launch", - "manuallaunch_tooltip": "Launch a missile at a target", - "patrol": "Patrol", - "patrol_tooltip": "Patrol along one or more waypoints", - "fight": "Fight", - "fight_tooltip": "Order units to take action while moving to a position", - "resurrect": "Resurrect", - "resurrect_tooltip": "Revive wrecks to become units again (click-drag for area)", - "guard": "Guard", - "guard_tooltip": "Guard another unit against enemy units attacking it", - "factoryguard": "Factory Guard", - "factoryguard_tooltip": "Builders produced by this factory will automatically guard it", - "wait": "Wait", - "wait_tooltip": "Pause a unit/factory on processing command/build queues", - "repair": "Repair", - "repair_tooltip": "Repair a damaged unit", - "reclaim": "Reclaim", - "reclaim_tooltip": "Suck metal/energy from wrecks or features (trees/stones)", - "restore": "Restore", - "restore_tooltip": "Restore an area of the map to its original height", - "capture": "Capture", - "capture_tooltip": "Convert units that belong to the enemy (or ally)", - "settarget": "Set Target", - "settarget_tooltip": "Set a prioritized target (prioritizes targeting when target in range)", - "canceltarget": "Clear Target", - "canceltarget_tooltip": "Removes the priority target", - "areamex": "Area Mex", - "areamex_tooltip": "Click-drag an area to auto queue metal extractors for all available metal spots", - "loadunits": "Load units", - "loadunits_tooltip": "Load unit or multiple units within an area in the transport", - "unloadunits": "Unload units", - "unloadunits_tooltip": "Unload unit or multiple units within an area in the transport", - "stockpile": "Stockpile %{stockpileStatus}", - "stockpile_tooltip": "[ stockpiled number ] / [ target stockpile number ]", - "stopproduction": "Clear Queue", - "stopproduction_tooltip": "Clear build queue and quotas for all units on selected factories", - "morph": "Upgrade", - "morph_tooltip": "Upgrade to next Tech-level (second click to cancel)", - "Spawning Disabled": "Spawning disabled", - "Spawning Enabled": "Spawning enabled", - "sellunit": "Sell Unit", - "sellunit_tooltip": "Toggle currently selected units for sale, allies will be able to buy them", - "For Sale": "For Sale", - "Not For Sale": "Not For Sale", - - "Fire at will": "Fire at will", - "Hold fire": "Hold fire", - "Return fire": "Return fire", - "Defend": "Defend", - "Fire at all": "Fire At All", - "firestate_hold_fire_descr": "Don't acquire targets without orders", - "firestate_return_fire_descr": "Return fire when attacked", - "firestate_defend_descr": "Fire upon enemies when they individually become a threat", - "firestate_fire_at_will_descr": "Fire upon enemies within range", - "firestate_fire_at_all_descr": "Fire upon anything belonging to the enemy within range", - "firestate_tooltip": "Set under what conditions a unit should start firing at enemies (without explicit attack order).", - "Hold pos": "Hold pos", - "Maneuver": "Maneuver", - "Roam": "Roam", - "movestate_tooltip": "Set how far out of its way a unit should move to attack enemies", - "bomber_targeting": "Targeting", - "bomber_targeting_tooltip": "Hold Fire: Only attack with an explicit order. Fire at will: Idle bombers will seek targets within range.", - "Repeat on": "Repeat On", - "Repeat off": "Repeat Off", - "repeat_tooltip": "Repeat unit command queue", - "Low Prio": "Low Priority", - "High Prio": "High Priority", - "priority_tooltip": "Assigns resources to use for this builder when not having enough for all", - "Decloaked": "Visible", - "Cloaked": "Cloaked", - "wantcloak_tooltip": "Visibility state", - " On ": "On", - " Off ": "Off", - "onoff_tooltip": "Active state: turn a unit on/off", - " Fly ": "Fly", - "Land": "Land", - "idlemode_tooltip": "Sets what aircraft do when idle", - "aplandat_tooltip": "Sets what aircraft do when leaving air factory", - "csspawning_tooltip": "Sets the spawning state of the carrier", - "Low traj": "High Traj", - "High traj": "High Traj", - "trajectory_toggle_tooltip": "Switch artillery firing angle between low, high and automatic trajectory", - "trajectory_low": "Low Trajectory", - "trajectory_high": "High Trajectory", - "trajectory_auto": "Auto Trajectory", - "hound_weapon_plasma": "Heavy Plasma", - "hound_weapon_gauss": "Gauss Cannon", - "hound_weapon_toggle_tooltip": "Sets weapon to a slow plasma projectile with a large aoe, or a fast projectile gauss weapon", - "blueprint_place": "Place Blueprint", - "blueprint_place_tooltip": "Place a saved blueprint", - "blueprint_create": "Save Blueprint", - "blueprint_create_tooltip": "Save the selected units into a new blueprint, in the order they were selected", - "factoryqueuemode_normal": "Queue Mode", - "factoryqueuemode_quota": "Quota Mode", - "factoryqueuemode_tooltip": "Queue: Build each queued unit once\nQuota: Maintain a minimum quota of each unit on the battlefield", - "customOnOff": { - "lowTrajectory": "Low Trajectory", - "highTrajectory": "High Trajectory", - "trajectory_tooltip": "Switch artillery firing angle between low and high trajectory", - "gauss_tooltip": "Switches between Gauss Cannon and Heavy Plasma Cannon" - }, - "quicksharetotarget": "Share Unit", - "quicksharetotarget_tooltip": "Share unit to target player." + "hotkeyTooltip": "%{highlightColor}%{hotkey}%{textColor} - %{tooltip}" }, "idleBuilders": { "name": "Idle Builders", @@ -350,158 +238,28 @@ }, "keybinds": { "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.", - "chat": { - "title": "Chat", - "send": "Send chat message", - "alliesKey": "alt + enter", - "allies": "Send chat message to allies", - "spectatorsKey": "shift + enter", - "spectators": "Send chat message to spectators", - "ignoreKey": "ctrl + left click on name", - "ignore": "Ignore player" - }, - "menus": { - "title": "Menus", - "settings": "Settings", - "share": "Share units / resources" - }, - "camera": { - "title": "Camera movement", - "zoomKey": "scrollwheel", - "zoom": "Zoom camera", - "panKey": "arrow keys / mouse at screen edge", - "pan": "Move camera", - "tiltKey": "ctrl + scrollwheel", - "tilt": "Change camera angle", - "dragKey": "middle click (+ drag)", - "drag": "Drag camera", - "flip": "Flip camera" - }, - "cameraModes": { - "title": "Camera modes", - "changeKey": "ctrl + f1,2,3,4,5", - "change": "Change camera type", - "fullscreenKey": "alt + backspace", - "fullscreen": "Toggle fullscreen", - "overview": "Toggle overview camera", - "los": "Toggle LOS view", - "heightmap": "Show height map", - "traversability": "Show passability (for selected unit)", - "mapmarks": "Cycle through map marks", - "resourceSpots": "Show metal map", - "interface": "Hide GUI" - }, - "sound": { - "title": "Sound", - "volumeKey": "-/+", - "volume": "Change volume", - "mute": "Toggle mute" - }, - "selection": { - "title": "Selecting units", - "unitsKey": "left mouse (+ drag)", - "units": "Select or deselect units" - }, - "issueContextOrders": { - "title": "Giving orders", - "orderKey": "right mouse (single click)", - "order": "Give order to unit(s)", - "formationOrderKey": "right mouse (drag)", - "formationOrder": "Give formation order to unit(s)" - }, - "orders": { - "title": "Selecting orders", - "defaultKey": "(none)", - "default": "default order (usually move)", - "move": "move", - "attack": "attack", - "stop": "stop (clears order queue)", - "repair": "repair", - "reclaim": "reclaim", - "resurrect": "resurrect", - "patrol": "patrol", - "fight": "fight", - "setTarget": "set priority target", - "cancelTarget": "cancel priority target", - "wait": "wait (pause current command)", - "cloak": "cloak", - "dGun": "manual fire (dgun)", - "selfDestruct": "self-destruct" - }, - "issueOrders": { - "title": "Giving selected orders", - "orderKey": "left mouse (single click)", - "order": "Give order to unit(s)", - "revert": "Revert to default order", - "revertKey": "right mouse (single click)", - "formationKey": "right mouse + drag", - "formation": "Give formation order to unit(s)" - }, - "queues": { - "title": "Queueing orders", - "append": "Add order to end of order queue", - "appendKey": "shift + (some order)", - "prepend": "Add order to start of order queue" - }, - "buildOrders": { - "title": "Selecting build orders", - "selectTileKey": "(mouse)", - "selectTile": "Select from units build-menu", - "metalKey": "z", - "metal": "Cycle through mexes", - "energyKey": "x", - "energy": "Cycle through energy production", - "intelKey": "c", - "intel": "Cycle through radar/defence/etc", - "factoriesKey": "v", - "factories": "Cycle through factories", - "rotate": "Change facing of buildings" - }, - "issueBuildOrders": { - "title": "Giving build orders", - "orderKey": "left mouse", - "order": "Give build order", - "deselectKey": "right mouse", - "deselect": "De-select build order", - "lineKey": "shift + (build order)", - "line": "Build in a line", - "gridKey": "shift + alt + (build order)", - "grid": "Build in a square", - "spacingUp": "Increase build spacing", - "spacingDown": "Decrease build spacing" - }, - "massSelect": { - "title": "Group selection", - "all": "Select all units", - "builders": "Select all constructors", - "createGroupKey": "ctrl + (num)", - "createGroup": "Add units to group (num=1,2,..)", - "createAutoGroupKey": "alt + (num)", - "createAutoGroup": "Add unit type to autogroup (num=1,2,..)", - "removeAutoGroupKey": "alt + `", - "removeAutoGroup": "Remove unit type from autogroup", - "groupKey": "(num)", - "group": "Select all units assigned to group (num)", - "sameType": "Select all units of same type as selected", - "damaged": "Select damaged units" - }, - "drawing": { - "title": "Drawing", - "mapmarkKey": "` + dbl click", - "mapmark": "Place map mark", - "drawKey": "` + drag left mouse", - "draw": "Draw on map", - "eraseKey": "` + drag right mouse", - "erase": "Erase drawings and markers" - }, - "console": { - "title": "Console commands", - "eraseKey": "/clearmapmarks", - "erase": "Erase all drawings and markers", - "pauseKey": "/pause", - "pause": "Pause" + "editor": { + "allCategories": "All", + "search": "Search...", + "pressKey": "Press a key or mouse button...", + "newProfile": "New Profile", + "duplicate": "Duplicate", + "edit": "Edit", + "delete": "Delete", + "save": "Save", + "reset": "Discard Changes", + "resetConfirm": "Discard your unsaved keybind changes?", + "saveTitle": "Save as new profile", + "discard": "Discard", + "unsavedTitle": "Unsaved changes", + "unsavedMessage": "Your keybind changes have not been saved yet.", + "duplicateTitle": "Duplicate profile", + "editTitle": "Edit profile", + "deleteConfirm": "Delete \"%{name}\"? This cannot be undone.", + "applyFailedTitle": "Could not apply profile", + "applyFailedMessage": "\"%{name}\" could not be written to disk, so your keybinds are unchanged. Check that the game folder is writable.", + "accept": "Accept", + "cancel": "Cancel" } }, "blueprint": { @@ -609,7 +367,7 @@ "heightTitle": "Height", "heightmap": "[%{keyset}] Displays a different color for every height level", "pathingTitle": "Traversability", - "pathing":"[%{keyset}] Shows where the selected unit can move, Green: okay, Red: problematic, Purple: can't move", + "pathing": "[%{keyset}] Shows where the selected unit can move, Green: okay, Red: problematic, Purple: can't move", "resourcesTitle": "Resources", "resources": "[%{keyset}] Highlights metal spots in green and geothermal vents in yellow.\n Occupied metal spots are shown in red." }, @@ -654,14 +412,14 @@ "airWave1": "Flying Raptors, incoming!", "airWave2": "%{unitCount} Raptors!", "queenIsAngry1": { - "one" : "The Queen is here!", - "other" : "The Queens are here!" + "one": "The Queen is here!", + "other": "The Queens are here!" }, "queenIsAngry2": "Prepare yourself for the final battle!", "queenResistant": "The Queen is becoming resistant to %{unit} attacks!", - "resistanceUnits":{ - "one" : "The Queen is becoming resistant to:", - "other" : "The Queens are becoming resistant to:" + "resistanceUnits": { + "one": "The Queen is becoming resistant to:", + "other": "The Queens are becoming resistant to:" }, "wave1": "Wave %{waveNumber}", "wave2": "%{unitCount} Raptors!", @@ -674,25 +432,25 @@ "queenAngerAggression": "Player Aggression: +%{value}%%/s", "queenAngerEco": "Economy: +%{value}%%/s", "queenETA": { - "one" : "Queen arrives in: %{time}.", - "other" : "%{count} queens arrive in: %{time}" + "one": "Queen arrives in: %{time}.", + "other": "%{count} queens arrive in: %{time}" }, "queenHealth": { - "one" : "Queen Health: %{health}%%", - "other" : "Queen Healths: %{health}%%" + "one": "Queen Health: %{health}%%", + "other": "Queen Healths: %{health}%%" }, "queensKilled": "Queens Killed: %{nKilled}/%{nTotal}", "queenResistantToList": { - "one" : "Queen is resistant to:", - "other" : "Queens are resistant to:" + "one": "Queen is resistant to:", + "other": "Queens are resistant to:" }, "queenStaggerActive": { - "one" : "Queen is Staggered!", - "other" : "Queens are Staggered!" + "one": "Queen is Staggered!", + "other": "Queens are Staggered!" }, "queenStaggerPercentage": { - "one" : "Queen Stagger: %{value}%%", - "other" : "Queens Stagger: %{value}%%" + "one": "Queen Stagger: %{value}%%", + "other": "Queens Stagger: %{value}%%" }, "gracePeriod": "Grace Period: %{time}", "burrowCount": "Burrows: %{count}", @@ -716,14 +474,14 @@ "firstWave1": "The Scavengers have arrived!", "firstWave2": "Get ready to defend yourself!", "bossIsAngry1": { - "one" : "The Boss is here!", - "other" : "The Bosses are here!" + "one": "The Boss is here!", + "other": "The Bosses are here!" }, "bossIsAngry2": "Prepare yourself for the final battle!", "bossResistant": "The Boss is becoming resistant to %{unit} attacks!", "resistanceUnits": { - "one" : "The Boss is becoming resistant to:", - "other" : "The Bosses are becoming resistant to:" + "one": "The Boss is becoming resistant to:", + "other": "The Bosses are becoming resistant to:" }, "wave1": "Wave %{waveNumber}", "wave2": "%{unitCount} Scavs!", @@ -736,17 +494,17 @@ "bossAngerAggression": "Player Aggression: +%{value}%%/s", "bossAngerEco": "Economy: +%{value}%%/s", "bossETA": { - "one" : "Boss arrives in: %{time}.", - "other" : "%{count} bosses arrive in: %{time}" + "one": "Boss arrives in: %{time}.", + "other": "%{count} bosses arrive in: %{time}" }, "bossHealth": { - "one" : "Boss Health: %{health}%%", - "other" : "Boss Healths: %{health}%%" + "one": "Boss Health: %{health}%%", + "other": "Boss Healths: %{health}%%" }, "bossesKilled": "Bosses Killed: %{nKilled}/%{nTotal}", "bossResistantToList": { - "one" : "Boss is resistant to:", - "other" : "Bosses are resistant to:" + "one": "Boss is resistant to:", + "other": "Bosses are resistant to:" }, "bossStaggerActive": { "one" : "Boss is Staggered!", @@ -880,7 +638,7 @@ }, "moveAttackNotify": { "underAttack": "%{unit} is being attacked!", - "cantMove":"%{unit}: Can't reach destination!" + "cantMove": "%{unit}: Can't reach destination!" }, "unitShare": { "shared": "shared %{units} to %{name}", @@ -1521,8 +1279,6 @@ "gridmenu_shiftkeymodifier_descr": "When holding down shift key and using hotkeys to queue units, queue this many units.", "keylayout": "Keyboard Layout", "keylayout_descr": "Set the keyboard layout", - "keybindings": "Keybind Preset", - "keybindings_descr": "Set the keybind preset. Buildmenu will automatically change to match preset, unless custom is chosen", "buildmenu": "Build menu", "buildmenu_bottom": "bottom position", "buildmenu_bottom_descr": "Relocate the buildmenu to the bottom of the screen", @@ -1980,11 +1736,11 @@ "unallocatedBudget": "Quick Start enabled: use your Base Budget first" } }, - "cmd":{ - "cheat":"Allow commands for cheating", - "give":"Give units (needs /cheat)", - "globallos":"See everything (needs /cheat)", - "godmode":"God mode (needs /cheat)", + "cmd": { + "cheat": "Allow commands for cheating", + "give": "Give units (needs /cheat)", + "globallos": "See everything (needs /cheat)", + "godmode": "God mode (needs /cheat)", "advmapshading": "Control advanced map shading mode", "advmodelshading": "Control advanced model shading mode", "aicontrol": "Creates a new instance of a Skirmish AI, to let it control a specific team", @@ -2014,6 +1770,14 @@ "debugapihighlightunit": "Toggle debug output for the Highlight Unit GL4 API widget", "debugapiunittracker": "[draw|level] Toggle Unit Tracker GL4 debug draw or set debug level", "debughealthbars": "Toggle debug mode for Health Bars GL4", + "debugtargetpriority": { + "_description": "Debug a selected unit's autotarget priority values.", + "on": "Show priorities", + "off": "Hide priorities", + "lines": "Show the line through targets in selection order", + "details": "Show the priority factor breakdown, plus rank, chase pick", + "weapon": "[n] Pick the selected unit's weapon number to debug" + }, "decalsgl4skipdraw": "Toggle Decals GL4 rendering on/off (debug)", "decalsgl4stats": "Print Decals GL4 runtime stats", "defrange": "Toggle defense range overlays", @@ -2220,7 +1984,7 @@ "AnimationMT": "Enable multithreaded execution of animation ticks", "AtiHacks": "Enables graphics drivers workarounds for users with AMD proprietary drivers.\n -1:=runtime detect, 0:=off, 1:=on", "AtiSwapRBFix": "No description available", - "AutoAddBuiltUnitsToFactoryGroup": "Controls whether or not units built by factories will inherit that factory\u0027s unit group.", + "AutoAddBuiltUnitsToFactoryGroup": "Controls whether or not units built by factories will inherit that factory's unit group.", "AutoAddBuiltUnitsToSelectedGroup": "No description available", "AutohostIP": "No description available", "AutohostPort": "Which port should the engine listen on for Autohost interface connections.", @@ -2243,12 +2007,12 @@ "CameraMoveSlowMult": "The multiplier applied to speed when camera is in moveslow state.", "CamFrameTimeCorrection": "Sets whether the camera interpolation factor should be the inverse of fps or last draw frame time (0 = lastdrawframetime, 1 = fpsinv)", "CamFreeAngVelTime": "No description available", - "CamFreeAutoTilt": "When free camera is locked, AutoTilt will point the camera in the direction of the ground\u0027s slope", + "CamFreeAutoTilt": "When free camera is locked, AutoTilt will point the camera in the direction of the ground's slope", "CamFreeEnabled": "No description available", "CamFreeFOV": "No description available", "CamFreeGoForward": "No description available", "CamFreeGravity": "When free camera is locked, Gravity will be used if you jump off of a ground ramp.", - "CamFreeGroundOffset": "Determines ground handling for the free camera.\n0 - the camera can move anywhere,\n\u003c 0 - the camera is always offset from the ground height by -CamFreeGroundOffset\n\u003e 0 - the camera can be \"locked\" to the ground by using SHIFT UP_ARROW. (and will use CamFreeGroundOffset as the offset). To release the lock, simply press SHIFT DOWN_ARROW.", + "CamFreeGroundOffset": "Determines ground handling for the free camera.\n0 - the camera can move anywhere,\n< 0 - the camera is always offset from the ground height by -CamFreeGroundOffset\n> 0 - the camera can be \"locked\" to the ground by using SHIFT UP_ARROW. (and will use CamFreeGroundOffset as the offset). To release the lock, simply press SHIFT DOWN_ARROW.", "CamFreeInvertAlt": "No description available", "CamFreeScrollSpeed": "No description available", "CamFreeSlide": "No description available", @@ -2295,7 +2059,7 @@ "DumpGameStateOnDesync": "Enable writing clientgamestate and servergamestate dumps when a desync is detected", "EdgeMoveDynamic": "If EdgeMove scrolling speed should fade with edge distance.", "EdgeMoveWidth": "The width (in percent of screen size) of the EdgeMove scrolling area.", - "ExtraTextureUpdateRate": "EXTREME CPU-HEAVY ON MEDIUM/BIG MAPS! DON\u0027T CHANGE DEFAULT!", + "ExtraTextureUpdateRate": "EXTREME CPU-HEAVY ON MEDIUM/BIG MAPS! DON'T CHANGE DEFAULT!", "FeatureDrawDistance": "Maximum distance at which features will be drawn.", "FeatureFadeDistance": "Distance at which features will begin to fade from view.", "FontFile": "Sets the font of Spring engine text.", @@ -2322,7 +2086,7 @@ "GLContextMajorVersion": "No description available", "GLContextMinorVersion": "No description available", "GrassDetail": "Sets how detailed the engine rendered grass will be on any given map.", - "GroundDecals": "Controls whether ground decals underneath buildings, unit tracks \u0026 footprints as well as ground scars from explosions will be rendered.", + "GroundDecals": "Controls whether ground decals underneath buildings, unit tracks & footprints as well as ground scars from explosions will be rendered.", "GroundDetail": "Controls how detailed the map geometry will be. On lowered settings, cliffs may appear to be jagged or \"melting\".", "GroundLODScaleReflection": "No description available", "GroundLODScaleRefraction": "No description available", @@ -2360,7 +2124,7 @@ "LODScaleRefraction": "No description available", "LODScaleShadow": "No description available", "LogClientData": "No description available", - "LogFlushLevel": "Flush the logfile when a message\u0027s level exceeds this value. ERROR is flushed by default, WARNING is not.", + "LogFlushLevel": "Flush the logfile when a message's level exceeds this value. ERROR is flushed by default, WARNING is not.", "LogRepeatLimit": "Allow at most this many consecutive identical messages to be logged.", "LogSections": "Comma-separated list of enabled logsections, see infolog.txt / console output for possible values.", "LuaGarbageCollectionMemLoadMult": "How much the amount of Lua memory in use increases the rate of garbage collection.", @@ -2405,7 +2169,7 @@ "MouseDragScrollThreshold": "No description available", "MouseDragSelectionThreshold": "Distance in pixels which the mouse must be dragged to trigger a selection box.", "MouseRelativeModeWarp": "No description available", - "MSAALevel": "Enables multisample anti-aliasing; \u0027level\u0027 is the number of samples used.", + "MSAALevel": "Enables multisample anti-aliasing; 'level' is the number of samples used.", "name": "Sets your name in the game. Since this is overridden by lobbies with your lobby username when playing, it usually only comes up when viewing replays or starting the engine directly for testing purposes.", "NetworkLossFactor": "No description available", "NetworkTimeout": "Number of seconds before connection to game server is considered lost.", @@ -2419,7 +2183,7 @@ "PathingThreadCount": "No description available", "PitchAdjust": "Adjusts sound pitch proportional to [if set to 1, the square root of] game speed. Set to 2 for linear scaling.", "PreloadModels": "The engine will preload all models", - "RapidTagResolutionOrder": "\u0027;\u0027 separated list of domains, preference order for resolving package from rapid tags", + "RapidTagResolutionOrder": "';' separated list of domains, preference order for resolving package from rapid tags", "ReconnectTimeout": "No description available", "RendererHash": "No description available", "ROAM": "Use ROAM for terrain mesh rendering: 0 to disable, 1=VBO mode to enable.", @@ -2465,10 +2229,10 @@ "SoftParticles": "Soften up CEG particles on clipping edges", "Sound": "Enables (OpenAL) or disables sound.", "SourcePort": "No description available", - "SpeedControl": "Sets how server adjusts speed according to player\u0027s load (CPU), 1: use average, 2: use highest", + "SpeedControl": "Sets how server adjusts speed according to player's load (CPU), 1: use average, 2: use highest", "SplashScreenDir": "No description available", - "SpringData": "List of additional data-directories, separated by \u0027;\u0027 on Windows and \u0027:\u0027 on other OSs", - "SpringDataRoot": "Optional custom data-directory content root (\u0027base\u0027, \u0027maps\u0027, ...) to scan for archives", + "SpringData": "List of additional data-directories, separated by ';' on Windows and ':' on other OSs", + "SpringDataRoot": "Optional custom data-directory content root ('base', 'maps', ...) to scan for archives", "SSMFTexAniso": "No description available", "StoreDefaultSettings": "springsettings.cfg will save the settings values, if they match the implicit defaults and were set by a user explicitly", "TCPAllowConnect": "No description available", @@ -2484,7 +2248,7 @@ "UnitIconDist": "No description available", "UnitIconFadeStart": "No description available", "UnitIconFadeVanish": "No description available", - "UnitIconsAsUI": "Draw unit icons like it is an UI element and not like unit\u0027s LOD.", + "UnitIconsAsUI": "Draw unit icons like it is an UI element and not like unit's LOD.", "UnitIconScaleUI": "No description available", "UnitIconsHideWithUI": "Hide unit icons when UI is hidden.", "UnitLodDist": "No description available", @@ -2566,7 +2330,7 @@ "reloadcegs": "Reloads CEG scripts (needs /cheat)", "reloadcob": "Reloads COB scripts (needs /cheat)", "skip": "Fast-forwards to a given frame, or stops fast-forwarding", - "luarules":{ + "luarules": { "loadmissiles": "Load missiles into all stockpile units", "givecat": { "_description": "[name] Select units by (multiple) category filters (add 'no' in front of filter to exclude)", @@ -3909,4 +3673,4 @@ } } -} \ No newline at end of file +} diff --git a/language/es/commands.json b/language/es/commands.json new file mode 100644 index 00000000000..293a74a77f5 --- /dev/null +++ b/language/es/commands.json @@ -0,0 +1,154 @@ +{ + "commands": { + "move": "Mover", + "move_tooltip": "Mueve a una unidad a una posición, o sigue a otras unidades", + "stop": "Alto", + "stop_tooltip": "Cancela las acciones actuales de las unidades", + "attack": "Atacar", + "attack_tooltip": "Ataca a una unidad o zona del terreno", + "areaattack": "Ataque en área", + "areaattack_tooltip": "Dibuja un círculo y ataca a todo lo que esté dentro de él (clic + arrastrar)", + "manualfire": "Dgun", + "manualfire_tooltip": "Dispara el arma más poderosa del comandante: el cañón desintegrador", + "manuallaunch": "Lanzar", + "manuallaunch_tooltip": "Lanza un misil al objetivo", + "patrol": "Patrullar", + "patrol_tooltip": "Patrulla a lo largo de uno o más puntos de ruta", + "fight": "Luchar", + "fight_tooltip": "Ordena a las unidades que ataquen mientras se mueven a una posición", + "resurrect": "Resucitar", + "resurrect_tooltip": "Reconstruye restos y los convierte en unidades (clic + arrastrar en una zona)", + "guard": "Proteger", + "guard_tooltip": "Protege a otra unidad frente a los enemigos", + "factoryguard": "Proteger fábrica", + "factoryguard_tooltip": "Los constructores creados en esta fábrica la protegerán automáticamente", + "wait": "Esperar", + "wait_tooltip": "Pausa el procesado de órdenes/colas de construcción de una unidad/fábrica", + "repair": "Reparar", + "repair_tooltip": "Repara una unidad dañada", + "reclaim": "Reclamar", + "reclaim_tooltip": "Obtén metal/energía de escombros o de otros objetos (árboles/rocas)", + "restore": "Restaurar", + "restore_tooltip": "Devuelve una zona del mapa a su altura original", + "capture": "Capturar", + "capture_tooltip": "Convierte a las unidades que pertenecen al enemigo (o aliado)", + "settarget": "Establecer Objetivo", + "settarget_tooltip": "Establece un objetivo prioritario (priorizado cuando esté dentro del alcance)", + "canceltarget": "Desmarcar objetivo", + "canceltarget_tooltip": "Desmarca el objetivo prioritario", + "areamex": "Ext. de metal en área", + "areamex_tooltip": "Haz clic y arrastra para crear un área para automatizar la extracción de metal de los extractores de todos los nodos disponibles", + "loadunits": "Cargar unidades", + "loadunits_tooltip": "Carga una unidad o unidades dentro del área marcada en el transporte", + "unloadunits": "Descargar unidades", + "unloadunits_tooltip": "Descarga una unidad o unidades dentro del área marcada del transporte", + "stockpile": "Reservas %{stockpileStatus}", + "stockpile_tooltip": "[ cantidad de reservas ] / [ cantidad de reservas objetivo ]", + "stopproduction": "Borrar cola", + "stopproduction_tooltip": "Borra la cola de construcción y cuotas de todas las unidades en las fábricas seleccionadas.", + "morph": "Mejorar", + "morph_tooltip": "Mejora al siguiente nivel tecnológico (clic adicional para cancelar)", + "Spawning Disabled": "Desactivar producción", + "Spawning Enabled": "Producción activada", + "sellunit": "Vender unidad", + "sellunit_tooltip": "Habilita las unidades seleccionadas para su venta, de esa forma los aliados podrán comprarlas. ", + "For Sale": "A la venta", + "Not For Sale": "No está a la venta", + "Fire at will": "Auto Ataque", + "Hold fire": "Alto el fuego", + "Return fire": "Devolver fuego", + "firestate_tooltip": "Ajusta las condiciones bajo las que una unidad empezará a disparar a un enemigo (sin haber recibido una orden de ataque directa)", + "Hold pos": "Mantener posición", + "Maneuver": "Maniobrar", + "Roam": "Deambular", + "movestate_tooltip": "Ajusta la distancia máxima a la que una unidad se moverá para atacar a un enemigo", + "Repeat on": "Repetir ON", + "Repeat off": "Repetir OFF", + "repeat_tooltip": "Repite la cola de órdenes de la unidad", + "Low Prio": "Baja Prioridad", + "High Prio": "Alta Prioridad", + "priority_tooltip": "Asigna recursos para el uso de este constructor cuando no haya suficientes para todas las tareas", + "Decloaked": "Visible", + "Cloaked": "Camuflado", + "wantcloak_tooltip": "Estado de Visibilidad", + " On ": "Encendido", + " Off ": "Apagado", + "onoff_tooltip": "Estado activo: enciende/apaga una unidad", + " Fly ": "Volar", + "Land": "Aterrizar", + "idlemode_tooltip": "Configura la acción de una nave cuando no tiene órdenes asignadas", + "apLandAt_tooltip": "Configura la acción de una nave cuando despega de una fábrica aérea", + "csSpawning_tooltip": "Configura el estado de aparición del transporte", + "Low traj": "Trayectoria Baja", + "High traj": "Trayectoria Alta", + "trajectory_toggle_tooltip": "Cambia el ángulo de disparo de la artillería entre trayectoria baja, alta o automática.", + "trajectory_low": "Trayectoria baja", + "trajectory_high": "Trayectoria alta", + "trajectory_auto": "Autotrayectoria", + "hound_weapon_plasma": "Plasma pesado", + "hound_weapon_gauss": "Cañón Gauss", + "hound_weapon_toggle_tooltip": "Cambia entre un proyectil de plasma lento con una gran área de efecto, o un proyectil Gauss de alta velocidad.", + "blueprint_place": "Colocar plano", + "blueprint_place_tooltip": "Coloca un plano guardado", + "blueprint_create": "Guardar plano", + "blueprint_create_tooltip": "Guarda las unidades seleccionadas en un nuevo plano (en el mismo orden de selección)", + "factoryqueuemode_normal": "Modo Cola", + "factoryqueuemode_quota": "Modo Cuota", + "factoryqueuemode_tooltip": "Cola: Construye todas las unidades en cola una vez.\nCuota: Mantén una cantidad mínima de cada unidad en el campo de batalla.", + "customOnOff": { + "lowTrajectory": "Trayectoria baja", + "highTrajectory": "Trayectoria alta", + "trajectory_tooltip": "Cambia el ángulo de disparo de la artillería entre trayectoria baja o alta", + "gauss_tooltip": "Alterna entre el cañón Gauss y el cañón de plasma pesado." + }, + "quicksharetotarget": "Compartir unidad", + "quicksharetotarget_tooltip": "Comparte la unidad con el jugador objetivo." + }, + "categories": { + "selection": "Selección de unidades", + "orders": "Selección de órdenes", + "queues": "Secuencia de órdenes", + "camera": "Movimiento de cámara", + "drawing": "Dibujado", + "sound": "Sonido" + }, + "actions": { + "massSelect": { + "all": "Seleccionar todas las unidades", + "builders": "Seleccionar todos los constructores", + "sameType": "Seleccionar todas las unidades del mismo tipo seleccionado", + "removeAutoGroup": "Eliminar tipo de unidad del autogrupo" + }, + "orders": { + "cloak": "Camuflar", + "selfDestruct": "autodestruir" + }, + "queues": { + "prepend": "Añadir orden al inicio de sec. de órdenes" + }, + "buildOrders": { + "rotate": "Cambiar orientación de edificios" + }, + "issueBuildOrders": { + "spacingUp": "Aumentar espacio de construcción", + "spacingDown": "Disminuir espacio de construcción" + }, + "camera": { + "flip": "Girar cámara" + }, + "cameraModes": { + "mapmarks": "Revisar marcas del mapa", + "heightmap": "Mostrar mapa topográfico", + "traversability": "Mostrar transitabilidad (unidad seleccionada)", + "resourceSpots": "Mostrar mapa de metales", + "interface": "Ocultar IU general" + }, + "console": { + "erase": "Borra todos tus dibujos y marcas", + "pause": "Pausa" + }, + "sound": { + "mute": "Activar / Desactivar silenciado" + } + } +} diff --git a/language/es/interface.json b/language/es/interface.json index 752af9fb44b..eca3ff441d5 100644 --- a/language/es/interface.json +++ b/language/es/interface.json @@ -67,7 +67,6 @@ "tooManyConverters2Tooltip": "Deja de construir conversores de energía y aumenta tu producción energética." } }, - "playersList": { "spectators": "Espectadores %{amount}", "enemies": "Enemigos %{amount}", @@ -167,111 +166,7 @@ "areamex_tooltip": "Consejo: Haz clic sobre este extractor dos veces para usar el comando \"Construir extractores de metal en la zona\"" }, "orderMenu": { - "hotkeyTooltip": "%{highlightColor}%{hotkey}%{textColor} - %{tooltip}", - "move": "Mover", - "move_tooltip": "Mueve a una unidad a una posición, o sigue a otras unidades", - "stop": "Alto", - "stop_tooltip": "Cancela las acciones actuales de las unidades", - "attack": "Atacar", - "attack_tooltip": "Ataca a una unidad o zona del terreno", - "areaattack": "Ataque en área", - "areaattack_tooltip": "Dibuja un círculo y ataca a todo lo que esté dentro de él (clic + arrastrar)", - "manualfire": "Dgun", - "manualfire_tooltip": "Dispara el arma más poderosa del comandante: el cañón desintegrador", - "manuallaunch": "Lanzar", - "manuallaunch_tooltip": "Lanza un misil al objetivo", - "patrol": "Patrullar", - "patrol_tooltip": "Patrulla a lo largo de uno o más puntos de ruta", - "fight": "Luchar", - "fight_tooltip": "Ordena a las unidades que ataquen mientras se mueven a una posición", - "resurrect": "Resucitar", - "resurrect_tooltip": "Reconstruye restos y los convierte en unidades (clic + arrastrar en una zona)", - "guard": "Proteger", - "guard_tooltip": "Protege a otra unidad frente a los enemigos", - "factoryguard": "Proteger fábrica", - "factoryguard_tooltip": "Los constructores creados en esta fábrica la protegerán automáticamente", - "wait": "Esperar", - "wait_tooltip": "Pausa el procesado de órdenes/colas de construcción de una unidad/fábrica", - "repair": "Reparar", - "repair_tooltip": "Repara una unidad dañada", - "reclaim": "Reclamar", - "reclaim_tooltip": "Obtén metal/energía de escombros o de otros objetos (árboles/rocas)", - "restore": "Restaurar", - "restore_tooltip": "Devuelve una zona del mapa a su altura original", - "capture": "Capturar", - "capture_tooltip": "Convierte a las unidades que pertenecen al enemigo (o aliado)", - "settarget": "Establecer Objetivo", - "settarget_tooltip": "Establece un objetivo prioritario (priorizado cuando esté dentro del alcance)", - "canceltarget": "Desmarcar objetivo", - "canceltarget_tooltip": "Desmarca el objetivo prioritario", - "areamex": "Ext. de metal en área", - "areamex_tooltip": "Haz clic y arrastra para crear un área para automatizar la extracción de metal de los extractores de todos los nodos disponibles", - "loadunits": "Cargar unidades", - "loadunits_tooltip": "Carga una unidad o unidades dentro del área marcada en el transporte", - "unloadunits": "Descargar unidades", - "unloadunits_tooltip": "Descarga una unidad o unidades dentro del área marcada del transporte", - "stockpile": "Reservas %{stockpileStatus}", - "stockpile_tooltip": "[ cantidad de reservas ] / [ cantidad de reservas objetivo ]", - "stopproduction": "Borrar cola", - "stopproduction_tooltip": "Borra la cola de construcción y cuotas de todas las unidades en las fábricas seleccionadas.", - "morph": "Mejorar", - "morph_tooltip": "Mejora al siguiente nivel tecnológico (clic adicional para cancelar)", - "Spawning Disabled": "Desactivar producción", - "Spawning Enabled": "Producción activada", - "sellunit": "Vender unidad", - "sellunit_tooltip": "Habilita las unidades seleccionadas para su venta, de esa forma los aliados podrán comprarlas. ", - "For Sale": "A la venta", - "Not For Sale": "No está a la venta", - - "Fire at will": "Auto Ataque", - "Hold fire": "Alto el fuego", - "Return fire": "Devolver fuego", - "firestate_tooltip": "Ajusta las condiciones bajo las que una unidad empezará a disparar a un enemigo (sin haber recibido una orden de ataque directa)", - "Hold pos": "Mantener posición", - "Maneuver": "Maniobrar", - "Roam": "Deambular", - "movestate_tooltip": "Ajusta la distancia máxima a la que una unidad se moverá para atacar a un enemigo", - "Repeat on": "Repetir ON", - "Repeat off": "Repetir OFF", - "repeat_tooltip": "Repite la cola de órdenes de la unidad", - "Low Prio": "Baja Prioridad", - "High Prio": "Alta Prioridad", - "priority_tooltip": "Asigna recursos para el uso de este constructor cuando no haya suficientes para todas las tareas", - "Decloaked": "Visible", - "Cloaked": "Camuflado", - "wantcloak_tooltip": "Estado de Visibilidad", - " On ": "Encendido", - " Off ": "Apagado", - "onoff_tooltip": "Estado activo: enciende/apaga una unidad", - " Fly ": "Volar", - "Land": "Aterrizar", - "idlemode_tooltip": "Configura la acción de una nave cuando no tiene órdenes asignadas", - "apLandAt_tooltip": "Configura la acción de una nave cuando despega de una fábrica aérea", - "csSpawning_tooltip": "Configura el estado de aparición del transporte", - "Low traj": "Trayectoria Baja", - "High traj": "Trayectoria Alta", - "trajectory_toggle_tooltip": "Cambia el ángulo de disparo de la artillería entre trayectoria baja, alta o automática.", - "trajectory_low": "Trayectoria baja", - "trajectory_high": "Trayectoria alta", - "trajectory_auto": "Autotrayectoria", - "hound_weapon_plasma": "Plasma pesado", - "hound_weapon_gauss": "Cañón Gauss", - "hound_weapon_toggle_tooltip": "Cambia entre un proyectil de plasma lento con una gran área de efecto, o un proyectil Gauss de alta velocidad.", - "blueprint_place": "Colocar plano", - "blueprint_place_tooltip": "Coloca un plano guardado", - "blueprint_create": "Guardar plano", - "blueprint_create_tooltip": "Guarda las unidades seleccionadas en un nuevo plano (en el mismo orden de selección)", - "factoryqueuemode_normal": "Modo Cola", - "factoryqueuemode_quota": "Modo Cuota", - "factoryqueuemode_tooltip": "Cola: Construye todas las unidades en cola una vez.\nCuota: Mantén una cantidad mínima de cada unidad en el campo de batalla.", - "customOnOff": { - "lowTrajectory": "Trayectoria baja", - "highTrajectory": "Trayectoria alta", - "trajectory_tooltip": "Cambia el ángulo de disparo de la artillería entre trayectoria baja o alta", - "gauss_tooltip": "Alterna entre el cañón Gauss y el cañón de plasma pesado." - }, - "quicksharetotarget": "Compartir unidad", - "quicksharetotarget_tooltip": "Comparte la unidad con el jugador objetivo." + "hotkeyTooltip": "%{highlightColor}%{hotkey}%{textColor} - %{tooltip}" }, "idleBuilders": { "name": "Constructores inactivos", @@ -294,7 +189,7 @@ "keybinds": { "title": "Atajos de teclado", "disclaimer": "Estos son los atajos de teclado por defecto. Si eliminas o sustituyes algún widget de acceso directo, o si usas tus propios atajos, ¡podrían dejar de funcionar!", - "howtochangekeybinds":"Para modificarlos: en Ajustes/Pestaña \"Controles\", ajusta los atajos de teclado a \"Personalizados\" para crear un archivo BAR/data/uikeys.txt.\nEdita este archivo y escribe /keyreload en el chat para volverlos a cargar en el juego.", + "howtochangekeybinds": "Para modificarlos: en Ajustes/Pestaña \"Controles\", ajusta los atajos de teclado a \"Personalizados\" para crear un archivo BAR/data/uikeys.txt.\nEdita este archivo y escribe /keyreload en el chat para volverlos a cargar en el juego.", "chat": { "title": "Chat", "send": "Enviar mensaje de chat", @@ -311,7 +206,6 @@ "share": "Compartir unidades / recursos" }, "camera": { - "title": "Movimiento de cámara", "zoomKey": "Rueda de ratón", "zoom": "Acercar / alejar cámara", "panKey": "Flechas, cursor a borde de pantalla", @@ -319,8 +213,7 @@ "tiltKey": "CTRL + Rueda de ratón", "tilt": "Cambiar ángulo de cámara", "dragKey": "Clic central (+ arrastrar)", - "drag": "Arrastrar cámara", - "flip": "Girar cámara" + "drag": "Arrastrar cámara" }, "cameraModes": { "title": "Modos de cámara", @@ -329,21 +222,13 @@ "fullscreenKey": "Alt + Borrar", "fullscreen": "Cambiar a pantalla completa", "overview": "Ver cámara general", - "los": "Activar modo Campo de Visión", - "heightmap": "Mostrar mapa topográfico", - "traversability": "Mostrar transitabilidad (unidad seleccionada)", - "mapmarks": "Revisar marcas del mapa", - "resourceSpots": "Mostrar mapa de metales", - "interface": "Ocultar IU general" + "los": "Activar modo Campo de Visión" }, "sound": { - "title": "Sonido", "volumeKey": "-/+", - "volume": "Cambiar volumen", - "mute": "Activar / Desactivar silenciado" + "volume": "Cambiar volumen" }, "selection": { - "title": "Selección de unidades", "unitsKey": "Clic izquierdo (+ arrastrar)", "units": "Elegir o deseleccionar unidades" }, @@ -355,23 +240,8 @@ "formationOrder": "Dar orden de formación a unidad(es)" }, "orders": { - "title": "Selección de órdenes", "defaultKey": "(ninguna)", - "default": "Orden por defecto (estándar: moverse)", - "move": "mover", - "attack": "atacar", - "stop": "Detener (vacía la cola de órdenes)", - "repair": "reparar", - "reclaim": "reclamar", - "resurrect": "resucitar", - "patrol": "Patrullar", - "fight": "luchar", - "setTarget": "Marcar objetivo prioritario", - "cancelTarget": "Desmarcar objetivo prioritario", - "wait": "Esperar (pausa la orden actual)", - "cloak": "Camuflar", - "dGun": "disparo manual (cañón D)", - "selfDestruct": "autodestruir" + "default": "Orden por defecto (estándar: moverse)" }, "issueOrders": { "title": "Asignación de órdenes seleccionadas", @@ -383,11 +253,9 @@ "formation": "Dar orden de formación a unidad(es)" }, "queues": { - "title": "Secuencia de órdenes", "append": "Añadir orden al final de sec. de órdenes", "appendKey": "Mayús + (Orden)", - "prependKey": "Espacio + (Orden)", - "prepend": "Añadir orden al inicio de sec. de órdenes" + "prependKey": "Espacio + (Orden)" }, "buildOrders": { "title": "Selección de órdenes de construcción", @@ -401,8 +269,7 @@ "intel": "Cambiar entre radar/defensas/etc.", "factoriesKey": "v", "factories": "Cambiar entre fábricas", - "rotateKey": "[ ´ , + ] ", - "rotate": "Cambiar orientación de edificios" + "rotateKey": "[ ´ , + ] " }, "issueBuildOrders": { "title": "Asignación de órdenes de construcción", @@ -415,29 +282,22 @@ "gridKey": "Mayús + Alt + (Construir)", "grid": "Construir en cuadrado", "spacingUpKey": "Alt + Z", - "spacingUp": "Aumentar espacio de construcción", - "spacingDownKey": "Alt + X", - "spacingDown": "Disminuir espacio de construcción" + "spacingDownKey": "Alt + X" }, "massSelect": { "title": "Selección de grupos", "allKey": "CTRL + A", - "all": "Seleccionar todas las unidades", "buildersKey": "CTRL + B", - "builders": "Seleccionar todos los constructores", "createGroupKey": "CTRL + (Num)", "createGroup": "Añadir unidades al grupo (num=1,2,..)", "createAutoGroupKey": "Alt + (Num)", "createAutoGroup": "Añadir tipo de unidad al autogrupo (num=1,2,..)", "removeAutoGroupKey": "Alt + °", - "removeAutoGroup": "Eliminar tipo de unidad del autogrupo", "groupKey": "(num)", "group": "Seleccionar todas las unidades asignadas al grupo (num)", - "sameTypeKey": "CTRL + Z", - "sameType": "Seleccionar todas las unidades del mismo tipo seleccionado" + "sameTypeKey": "CTRL + Z" }, "drawing": { - "title": "Dibujado", "mapmarkKey": "` + doble clic", "mapmark": "Colocar marca de mapa", "drawKey": "` + Arrastrar clic izquierdo", @@ -448,9 +308,7 @@ "console": { "title": "Comandos de consola", "eraseKey": "/clearmapmarks", - "erase": "Borra todos tus dibujos y marcas", - "pauseKey": "/pause", - "pause": "Pausa" + "pauseKey": "/pause" } }, "chat": { @@ -532,7 +390,7 @@ "heightTitle": "Altura", "heightmap": "[%{keyset}] Muestra un color diferente para cada nivel de altura", "pathingTitle": "Transitabilidad", - "pathing":"[%{keyset}] Muestra dónde se puede mover la unidad seleccionada, Verde: bien, Rojo: problemático, Púrpura: no se puede mover", + "pathing": "[%{keyset}] Muestra dónde se puede mover la unidad seleccionada, Verde: bien, Rojo: problemático, Púrpura: no se puede mover", "resourcesTitle": "Recursos", "resources": "[%{keyset}] Resalta las manchas metálicas en verde y los respiraderos geotérmicos en amarillo.\n Los puntos metálicos ocupados se muestran en rojo." }, @@ -573,14 +431,14 @@ "airWave1": "¡Raptores voladores! ¡Ya llegan!", "airWave2": "¡%{unitCount} raptores!", "queenIsAngry1": { - "one" : "¡Ha llegado la reina!", - "other" : "¡Han llegado las reinas!" + "one": "¡Ha llegado la reina!", + "other": "¡Han llegado las reinas!" }, "queenIsAngry2": "¡Prepárate para la batalla final!", "queenResistant": "¡La reina se está volviendo resistente a los ataques de %{unit}!", - "resistanceUnits":{ - "one" : "La reina se está volviendo resistente a:", - "other" : "Las reinas se están volviendo resistentes a:" + "resistanceUnits": { + "one": "La reina se está volviendo resistente a:", + "other": "Las reinas se están volviendo resistentes a:" }, "wave1": "Oleada %{waveNumber}", "wave2": "¡%{unitCount} raptores!", @@ -593,17 +451,17 @@ "queenAngerAggression": "Agresión de jugador: +%{value}%%/s", "queenAngerEco": "Economía: +%{value}%%/s", "queenETA": { - "one" : "La reina llegará en: %{time}.", - "other" : "%{count} reinas llegarán en: %{time}" + "one": "La reina llegará en: %{time}.", + "other": "%{count} reinas llegarán en: %{time}" }, "queenHealth": { - "one" : "Salud de reina: %{health}%%", - "other" : "Salud de reinas: %{health}%%" + "one": "Salud de reina: %{health}%%", + "other": "Salud de reinas: %{health}%%" }, "queensKilled": "Reinas abatidas: %{nKilled}/%{nTotal}", "queenResistantToList": { - "one" : "La reina es resistente a:", - "other" : "Las reinas son resistentes a:" + "one": "La reina es resistente a:", + "other": "Las reinas son resistentes a:" }, "gracePeriod": "Periodo de gracia: %{time}", "burrowCount": "Madrigueras: %{count}", @@ -627,14 +485,14 @@ "firstWave1": "¡Los Carroñeros han llegado!", "firstWave2": "¡Prepárate para defenderte!", "bossIsAngry1": { - "one" : "¡Ha llegado el Jefe!", - "other" : "¡Han llegado los jefes!" + "one": "¡Ha llegado el Jefe!", + "other": "¡Han llegado los jefes!" }, "bossIsAngry2": "¡Prepárate para la batalla final!", "bossResistant": "¡El Jefe se está volviendo resistente a los ataques de %{unit}!", "resistanceUnits": { - "one" : "El Jefe se está volviendo resistente a:", - "other" : "Los jefes se están volviendo resistentes a:" + "one": "El Jefe se está volviendo resistente a:", + "other": "Los jefes se están volviendo resistentes a:" }, "wave1": "Oleada %{waveNumber}", "wave2": "%{unitCount} Carroñeros!", @@ -647,17 +505,17 @@ "bossAngerAggression": "Agresión de jugador: +%{value}%%/s", "bossAngerEco": "Economía: +%{value}%%/s", "bossETA": { - "one" : "El Jefe llegará en: %{time}.", - "other" : "%{count} jefes llegan en: %{time}" + "one": "El Jefe llegará en: %{time}.", + "other": "%{count} jefes llegan en: %{time}" }, "bossHealth": { - "one" : "Salud del Jefe: %{health}%%", - "other" : "Saludes de jefe: %{health}%%" + "one": "Salud del Jefe: %{health}%%", + "other": "Saludes de jefe: %{health}%%" }, "bossesKilled": "Jefes eliminados: %{nKilled}/%{nTotal}", "bossResistantToList": { - "one" : "El Jefe es resistente a:", - "other" : "Los jefes son resistentes a:" + "one": "El Jefe es resistente a:", + "other": "Los jefes son resistentes a:" }, "gracePeriod": "Periodo de gracia: %{time}", "burrowCount": "Madrigueras: %{count}", @@ -743,7 +601,7 @@ }, "moveAttackNotify": { "underAttack": "¡La unidad %{unit} está siendo atacada!", - "cantMove":"%{unit}: ¡No puedo llegar a mi destino!" + "cantMove": "%{unit}: ¡No puedo llegar a mi destino!" }, "unitShare": { "shared": "compartida %{units} a%{name}", @@ -940,15 +798,15 @@ "metalIncome_title": "Generación de metal", "metalIncome_tooltip": "Cantidad de metal generada por segundo", "energyConversionMetalIncome_title": "Conversión de metal", - "energyConversionMetalIncome_tooltip": "Cantidad de metal generada por conversión de energía" , + "energyConversionMetalIncome_tooltip": "Cantidad de metal generada por conversión de energía", "energyIncome_title": "Generación de energía", "energyIncome_tooltip": "Cantidad de energía generada por segundo", "buildPower_title": "Constructividad", "buildPower_tooltip": "Constructividad asignable", "metalProduced_title": "Metal producido", - "metalProduced_tooltip": "Cantidad total de metal producido" , + "metalProduced_tooltip": "Cantidad total de metal producido", "energyProduced_title": "Energía producida", - "energyProduced_tooltip": "Cantidad total de energía producida" , + "energyProduced_tooltip": "Cantidad total de energía producida", "metalExcess_title": "Exceso de metal", "metalExcess_tooltip": "Cantidad total de exceso de metal", "energyExcess_title": "Exceso de energía", diff --git a/language/ru/commands.json b/language/ru/commands.json new file mode 100644 index 00000000000..faee9ca23a4 --- /dev/null +++ b/language/ru/commands.json @@ -0,0 +1,154 @@ +{ + "commands": { + "move": "Двигаться", + "move_tooltip": "Передвижение на позицию или следовать за другими юнитами", + "stop": "Стоп", + "stop_tooltip": "Отменить текущие действия юнитов", + "attack": "Атака", + "attack_tooltip": "Атака юнита или наземной области", + "areaattack": "Атака Зоны", + "areaattack_tooltip": "Атака всего что находится в зоне круга (зажать+тянуть)", + "manualfire": "Д-Пушка", + "manualfire_tooltip": "Выстрелить из мощной Дезинтеграторной пушки Командира", + "manuallaunch": "Запуск", + "manuallaunch_tooltip": "Запуск ракеты по цели", + "patrol": "Патруль", + "patrol_tooltip": "Патрулировать вдоль одной или более точек", + "fight": "Бой", + "fight_tooltip": "Юниты будут останавливаться и атаковать противников в радиусе действия, пока перемещаются на позицию", + "resurrect": "Реанимировать", + "resurrect_tooltip": "Восстанавливает боевую единицу из обломков (зажать+тянуть для восст. всех в области)", + "guard": "Охранять", + "guard_tooltip": "Охранять другое подразделение от атакующих его вражеских подразделений", + "factoryguard": "Помощь Заводу", + "factoryguard_tooltip": "Строители из этого завода будут автоматически получать приказ охраны на него", + "wait": "Ждать", + "wait_tooltip": "Ставит на Паузу выполнение текущих команд и строительство", + "repair": "Ремонт", + "repair_tooltip": "Отремонтировать повреждённый юнит", + "reclaim": "Утилизация", + "reclaim_tooltip": "Собрать металл/энергию из обломков или окружения (деревьев/камней)", + "restore": "Восстановить", + "restore_tooltip": "Восстановить область карты до исходной высоты", + "capture": "Захват", + "capture_tooltip": "Взять под контроль юниты, принадлежащие противнику (или союзнику)", + "settarget": "Задать Цель", + "settarget_tooltip": "Задать приоритет для атаки (если цель находится в пределах досягаемости)", + "canceltarget": "Снять Цель", + "canceltarget_tooltip": "Удаляет приоритетную цель", + "areamex": "Зона \"МЭкс\"", + "areamex_tooltip": "Зажмите+Тяните, чтобы построить Металлоэкстракторы на всех доступных Металлических жилах", + "loadunits": "Погрузка", + "loadunits_tooltip": "Погрузить одного или несколько юнитов в области на транспорт", + "unloadunits": "Выгрузка", + "unloadunits_tooltip": "Выгрузить одного или несколько юнитов в область из транспорта", + "stockpile": "Запас %{stockpileStatus}", + "stockpile_tooltip": "[ накоплено ] / [ макс. запас ]", + "stopproduction": "Очистить очередь", + "stopproduction_tooltip": "Очистить очередь строительства и квоту для всех юнитов в выбранных фабриках", + "morph": "Улучшить", + "morph_tooltip": "Улучшить до следующего технического уровня (второе нажатие для отмены)", + "Spawning Disabled": "Спавн отключен", + "Spawning Enabled": "Спавн включен", + "sellunit": "Продать юнит", + "sellunit_tooltip": "Выставляет выделенных юнитов на продажу для покупки союзниками", + "For Sale": "Продаётся", + "Not For Sale": "Не продаётся", + "Fire at will": "По готовности", + "Hold fire": "Не стрелять", + "Return fire": "Ответный", + "firestate_tooltip": "Определяет, когда юнит должен открыть огонь по врагам (без прямого приказа атаки)", + "Hold pos": "Удержание", + "Maneuver": "Маневры", + "Roam": "Бродить", + "movestate_tooltip": "Определяет, как далеко от своей позиции отряд может отойти для атаки", + "Repeat on": "Повтор вкл", + "Repeat off": "Повтор выкл", + "repeat_tooltip": "Повторить выполнение команд для юнита", + "Low Prio": "Низк. Приор.", + "High Prio": "Выс. Приор.", + "priority_tooltip": "Определяет использование ресурсов этим конструктором при дефиците ресурсов", + "Decloaked": "Видимый", + "Cloaked": "Маскировка", + "wantcloak_tooltip": "Состояние маскировки", + " On ": "Вкл", + " Off ": "Выкл", + "onoff_tooltip": "Состояние активности: включен/выключен", + " Fly ": "Полёт", + "Land": "Посадка", + "idlemode_tooltip": "Определяет поведение самолёта при отсутствии приказов", + "apLandAt_tooltip": "Определяет поведение самолёта после покидания Завода", + "csSpawning_tooltip": "Определяет состояние спавна авианосца", + "Low traj": "Выс. траект.", + "High traj": "Выс. траект.", + "trajectory_toggle_tooltip": "Переключение угла стрельбы между низкой, высокой и автоматической траекторией", + "trajectory_low": "Низк. траектория", + "trajectory_high": "Выс. траектория", + "trajectory_auto": "Авто. Траектория ", + "hound_weapon_plasma": "Плазма-пушка", + "hound_weapon_gauss": "Гаусса-пушка", + "hound_weapon_toggle_tooltip": "Переключает орудие между медленными плазменными снарядами с широким радиусом и быстрой гаусс-пушкой", + "blueprint_place": "Чертеж", + "blueprint_place_tooltip": "Разместить сохраненный чертеж", + "blueprint_create": "Сохранить Чертеж", + "blueprint_create_tooltip": "Сохранить выбранные юниты в новый чертеж, в порядке их выделения", + "factoryqueuemode_normal": "Режим Очереди", + "factoryqueuemode_quota": "Режим Квоты", + "factoryqueuemode_tooltip": "Очередь: Построить каждого выбранного юнита единожды\nКвота: Поддерживать заданное количество юнитов на поле", + "customOnOff": { + "lowTrajectory": "Низк. траектория", + "highTrajectory": "Выс. траектория", + "trajectory_tooltip": "Переключить режим ведения огня: Навесная стрельба или Прямой наводкой", + "gauss_tooltip": "Переключает между Гаусс-пушкой и тяжёлой плазмо-пушкой" + }, + "quicksharetotarget": "Передать Юнит", + "quicksharetotarget_tooltip": "Передать юнита выбранному игроку." + }, + "categories": { + "selection": "Выбор юнитов", + "orders": "Выбор приказов", + "queues": "Очередь приказов", + "camera": "Движение камеры", + "drawing": "Рисование", + "sound": "Звук" + }, + "actions": { + "massSelect": { + "all": "Выбрать все юниты", + "builders": "Выбрать всех строителей", + "sameType": "Выбрать все юниты того же типа, что и выбранный", + "removeAutoGroup": "Удалить тип юнита из автогрупп" + }, + "orders": { + "cloak": "Маскировка", + "selfDestruct": "Самоуничтожение" + }, + "queues": { + "prepend": "Добавить приказ в начало очереди" + }, + "buildOrders": { + "rotate": "Вращать строение" + }, + "issueBuildOrders": { + "spacingUp": "Увеличить интервал между строениями", + "spacingDown": "Уменьшить интервал между строениями" + }, + "camera": { + "flip": "Отразить камеру" + }, + "cameraModes": { + "mapmarks": "Листать отметки на карте", + "heightmap": "Показать карту высот", + "traversability": "Показать проходимость (для выбранного юнита)", + "resourceSpots": "Показать карту металла", + "interface": "Скрыть интерфейс" + }, + "console": { + "erase": "Стереть все рисунки и отметки", + "pause": "Пауза" + }, + "sound": { + "mute": "Вкл/Откл Звук" + } + } +} diff --git a/language/ru/interface.json b/language/ru/interface.json index 0520653d4f2..888a1e85a99 100644 --- a/language/ru/interface.json +++ b/language/ru/interface.json @@ -67,7 +67,6 @@ "tooManyConverters2Tooltip": "Прекратите строить Преобразователи Энергии и увеличьте её производство." } }, - "playersList": { "spectators": "Наблюдатели %{amount}", "enemies": "Противники %{amount}", @@ -167,111 +166,7 @@ "areamex_tooltip": "Совет: выберите этот экстрактор дважды, чтобы использовать команду Зона МЭкс" }, "orderMenu": { - "hotkeyTooltip": "%{highlightColor}%{hotkey}%{textColor} - %{tooltip}", - "move": "Двигаться", - "move_tooltip": "Передвижение на позицию или следовать за другими юнитами", - "stop": "Стоп", - "stop_tooltip": "Отменить текущие действия юнитов", - "attack": "Атака", - "attack_tooltip": "Атака юнита или наземной области", - "areaattack": "Атака Зоны", - "areaattack_tooltip": "Атака всего что находится в зоне круга (зажать+тянуть)", - "manualfire": "Д-Пушка", - "manualfire_tooltip": "Выстрелить из мощной Дезинтеграторной пушки Командира", - "manuallaunch": "Запуск", - "manuallaunch_tooltip": "Запуск ракеты по цели", - "patrol": "Патруль", - "patrol_tooltip": "Патрулировать вдоль одной или более точек", - "fight": "Бой", - "fight_tooltip": "Юниты будут останавливаться и атаковать противников в радиусе действия, пока перемещаются на позицию", - "resurrect": "Реанимировать", - "resurrect_tooltip": "Восстанавливает боевую единицу из обломков (зажать+тянуть для восст. всех в области)", - "guard": "Охранять", - "guard_tooltip": "Охранять другое подразделение от атакующих его вражеских подразделений", - "factoryguard": "Помощь Заводу", - "factoryguard_tooltip": "Строители из этого завода будут автоматически получать приказ охраны на него", - "wait": "Ждать", - "wait_tooltip": "Ставит на Паузу выполнение текущих команд и строительство", - "repair": "Ремонт", - "repair_tooltip": "Отремонтировать повреждённый юнит", - "reclaim": "Утилизация", - "reclaim_tooltip": "Собрать металл/энергию из обломков или окружения (деревьев/камней)", - "restore": "Восстановить", - "restore_tooltip": "Восстановить область карты до исходной высоты", - "capture": "Захват", - "capture_tooltip": "Взять под контроль юниты, принадлежащие противнику (или союзнику)", - "settarget": "Задать Цель", - "settarget_tooltip": "Задать приоритет для атаки (если цель находится в пределах досягаемости)", - "canceltarget": "Снять Цель", - "canceltarget_tooltip": "Удаляет приоритетную цель", - "areamex": "Зона \"МЭкс\"", - "areamex_tooltip": "Зажмите+Тяните, чтобы построить Металлоэкстракторы на всех доступных Металлических жилах", - "loadunits": "Погрузка", - "loadunits_tooltip": "Погрузить одного или несколько юнитов в области на транспорт", - "unloadunits": "Выгрузка", - "unloadunits_tooltip": "Выгрузить одного или несколько юнитов в область из транспорта", - "stockpile": "Запас %{stockpileStatus}", - "stockpile_tooltip": "[ накоплено ] / [ макс. запас ]", - "stopproduction": "Очистить очередь", - "stopproduction_tooltip": "Очистить очередь строительства и квоту для всех юнитов в выбранных фабриках", - "morph": "Улучшить", - "morph_tooltip": "Улучшить до следующего технического уровня (второе нажатие для отмены)", - "Spawning Disabled": "Спавн отключен", - "Spawning Enabled": "Спавн включен", - "sellunit": "Продать юнит", - "sellunit_tooltip": "Выставляет выделенных юнитов на продажу для покупки союзниками", - "For Sale": "Продаётся", - "Not For Sale": "Не продаётся", - - "Fire at will": "По готовности", - "Hold fire": "Не стрелять", - "Return fire": "Ответный", - "firestate_tooltip": "Определяет, когда юнит должен открыть огонь по врагам (без прямого приказа атаки)", - "Hold pos": "Удержание", - "Maneuver": "Маневры", - "Roam": "Бродить", - "movestate_tooltip": "Определяет, как далеко от своей позиции отряд может отойти для атаки", - "Repeat on": "Повтор вкл", - "Repeat off": "Повтор выкл", - "repeat_tooltip": "Повторить выполнение команд для юнита", - "Low Prio": "Низк. Приор.", - "High Prio": "Выс. Приор.", - "priority_tooltip": "Определяет использование ресурсов этим конструктором при дефиците ресурсов", - "Decloaked": "Видимый", - "Cloaked": "Маскировка", - "wantcloak_tooltip": "Состояние маскировки", - " On ": "Вкл", - " Off ": "Выкл", - "onoff_tooltip": "Состояние активности: включен/выключен", - " Fly ": "Полёт", - "Land": "Посадка", - "idlemode_tooltip": "Определяет поведение самолёта при отсутствии приказов", - "apLandAt_tooltip": "Определяет поведение самолёта после покидания Завода", - "csSpawning_tooltip": "Определяет состояние спавна авианосца", - "Low traj": "Выс. траект.", - "High traj": "Выс. траект.", - "trajectory_toggle_tooltip": "Переключение угла стрельбы между низкой, высокой и автоматической траекторией", - "trajectory_low": "Низк. траектория", - "trajectory_high": "Выс. траектория", - "trajectory_auto": "Авто. Траектория ", - "hound_weapon_plasma": "Плазма-пушка", - "hound_weapon_gauss": "Гаусса-пушка", - "hound_weapon_toggle_tooltip": "Переключает орудие между медленными плазменными снарядами с широким радиусом и быстрой гаусс-пушкой", - "blueprint_place": "Чертеж", - "blueprint_place_tooltip": "Разместить сохраненный чертеж", - "blueprint_create": "Сохранить Чертеж", - "blueprint_create_tooltip": "Сохранить выбранные юниты в новый чертеж, в порядке их выделения", - "factoryqueuemode_normal": "Режим Очереди", - "factoryqueuemode_quota": "Режим Квоты", - "factoryqueuemode_tooltip": "Очередь: Построить каждого выбранного юнита единожды\nКвота: Поддерживать заданное количество юнитов на поле", - "customOnOff": { - "lowTrajectory": "Низк. траектория", - "highTrajectory": "Выс. траектория", - "trajectory_tooltip": "Переключить режим ведения огня: Навесная стрельба или Прямой наводкой", - "gauss_tooltip": "Переключает между Гаусс-пушкой и тяжёлой плазмо-пушкой" - }, - "quicksharetotarget": "Передать Юнит", - "quicksharetotarget_tooltip": "Передать юнита выбранному игроку." + "hotkeyTooltip": "%{highlightColor}%{hotkey}%{textColor} - %{tooltip}" }, "idleBuilders": { "name": "Незанятые строители", @@ -294,7 +189,7 @@ "keybinds": { "title": "Назначения клавиш", "disclaimer": "Эти \"быстрые клавиши\" установлены по умолчанию. Если вы удалите / замените виджеты \"быстрых клавиш\" или используете произвольный uikeys.txt, они могут перестать работать!", - "howtochangekeybinds":"Чтобы изменить их: в разделе Настройки/Управление установите Назначения клавиш на Пользовательские, чтобы создать файл BAR/data/uikeys.txt.\nОтредактируйте этот файл и введите /keyreload в чате, чтобы перезагрузить их.", + "howtochangekeybinds": "Чтобы изменить их: в разделе Настройки/Управление установите Назначения клавиш на Пользовательские, чтобы создать файл BAR/data/uikeys.txt.\nОтредактируйте этот файл и введите /keyreload в чате, чтобы перезагрузить их.", "chat": { "title": "Чат", "send": "Отправить сообщение в общий чат", @@ -311,7 +206,6 @@ "share": "Поделиться юнитами / ресурсами" }, "camera": { - "title": "Движение камеры", "zoomKey": "колесо прокрутки", "zoom": "Масштаб камеры", "panKey": "Клавиши-стрелки / курсор на границе экрана", @@ -319,8 +213,7 @@ "tiltKey": "ctrl + колесо прокрутки", "tilt": "Смена угла камеры", "dragKey": "колёсико мыши (+ тянуть)", - "drag": "Перетащить камеру", - "flip": "Отразить камеру" + "drag": "Перетащить камеру" }, "cameraModes": { "title": "Режимы камеры", @@ -329,21 +222,13 @@ "fullscreenKey": "alt + backspace", "fullscreen": "Полноэкранный режим", "overview": "Переключение обзорной камеры", - "los": "Переключить режим \"Область обзора\"", - "heightmap": "Показать карту высот", - "traversability": "Показать проходимость (для выбранного юнита)", - "mapmarks": "Листать отметки на карте", - "resourceSpots": "Показать карту металла", - "interface": "Скрыть интерфейс" + "los": "Переключить режим \"Область обзора\"" }, "sound": { - "title": "Звук", "volumeKey": "-/+", - "volume": "Изменить громкость", - "mute": "Вкл/Откл Звук" + "volume": "Изменить громкость" }, "selection": { - "title": "Выбор юнитов", "unitsKey": "ЛКМ (+ тянуть)", "units": "Выделить или снять выделение" }, @@ -355,23 +240,8 @@ "formationOrder": "Задать Построение юнитам" }, "orders": { - "title": "Выбор приказов", "defaultKey": "(нет)", - "default": "Приказ по умолчанию (Передвижение)", - "move": "Движение", - "attack": "Атака", - "stop": "Стоп (очистить очередь приказов)", - "repair": "Ремонт", - "reclaim": "Утилизация", - "resurrect": "реанимировать", - "patrol": "Патруль", - "fight": "Бой", - "setTarget": "Установить приоритетную цель", - "cancelTarget": "Отменить приоритет цели", - "wait": "Ждать (приостановить текущий приказ)", - "cloak": "Маскировка", - "dGun": "Ручной огонь (Д-пушка)", - "selfDestruct": "Самоуничтожение" + "default": "Приказ по умолчанию (Передвижение)" }, "issueOrders": { "title": "Отдача выбранного приказа", @@ -383,11 +253,9 @@ "formation": "Задать Построение юнитам" }, "queues": { - "title": "Очередь приказов", "append": "Добавить приказ в конец очереди", "appendKey": "shift + (приказ)", - "prependKey": "space + (приказ)", - "prepend": "Добавить приказ в начало очереди" + "prependKey": "space + (приказ)" }, "buildOrders": { "title": "Горячие клавиши для Строительства", @@ -401,8 +269,7 @@ "intel": "Листать радар/турели/т.д.", "factoriesKey": "v", "factories": "Листать Заводы", - "rotateKey": "] и [", - "rotate": "Вращать строение" + "rotateKey": "] и [" }, "issueBuildOrders": { "title": "Отдача приказов Строительства", @@ -415,29 +282,22 @@ "gridKey": "shift + alt + (приказ постройки)", "grid": "Строить матрицей", "spacingUpKey": "alt+z", - "spacingUp": "Увеличить интервал между строениями", - "spacingDownKey": "alt+x", - "spacingDown": "Уменьшить интервал между строениями" + "spacingDownKey": "alt+x" }, "massSelect": { "title": "Выбор групп", "allKey": "ctrl + a", - "all": "Выбрать все юниты", "buildersKey": "ctrl + b", - "builders": "Выбрать всех строителей", "createGroupKey": "ctrl + (0-9)", "createGroup": "Добавить юниты в группу (№=1,2,..)", "createAutoGroupKey": "alt + (0-9)", "createAutoGroup": "Добавить тип юнита в автогруппу (№=1,2,..)", "removeAutoGroupKey": "alt + ` ", - "removeAutoGroup": "Удалить тип юнита из автогрупп", "groupKey": "(0-9)", "group": "Выбрать все юниты, назначенные в группу (№=1,2,..)", - "sameTypeKey": "ctrl + z", - "sameType": "Выбрать все юниты того же типа, что и выбранный" + "sameTypeKey": "ctrl + z" }, "drawing": { - "title": "Рисование", "mapmarkKey": "` (тильда) + двойное нажатие ЛКМ", "mapmark": "Поставить метку на карте", "drawKey": "` (тильда) + потянуть с ЛКМ", @@ -448,9 +308,7 @@ "console": { "title": "Консольные команды", "eraseKey": "/clearmapmarks", - "erase": "Стереть все рисунки и отметки", - "pauseKey": "/pause", - "pause": "Пауза" + "pauseKey": "/pause" } }, "chat": { @@ -532,7 +390,7 @@ "heightTitle": "Высота", "heightmap": "[%{keyset}] Отображает разные цвета для каждого уровня высоты", "pathingTitle": "Проходимость", - "pathing":"[%{keyset}] Показывает где юниты могут передвигаться, Зеленый: ок, Красный: проблематично, Фиолетовый: не может двигаться", + "pathing": "[%{keyset}] Показывает где юниты могут передвигаться, Зеленый: ок, Красный: проблематично, Фиолетовый: не может двигаться", "resourcesTitle": "Ресурсы", "resources": "[%{keyset}] Выделяет месторождения металла зеленым цветом и геотермальные источники желтым цветом.\nЗанятые месторождения металла показаны красным цветом." }, @@ -573,14 +431,14 @@ "airWave1": "Летающие Рапторы наступают!", "airWave2": "%{unitCount} Рапторов!", "queenIsAngry1": { - "one" : "Королева прибыла!", - "other" : "Королевы уже здесь!" + "one": "Королева прибыла!", + "other": "Королевы уже здесь!" }, "queenIsAngry2": "Готовьтесь к последней битве!", "queenResistant": "Королева стала устойчива к атакам%{unit}!", - "resistanceUnits":{ - "one" : "Королева становится устойчивой к:", - "other" : "Королевы становятся устойчивы к:" + "resistanceUnits": { + "one": "Королева становится устойчивой к:", + "other": "Королевы становятся устойчивы к:" }, "wave1": "Волна %{waveNumber}", "wave2": "%{unitCount} Рапторов!", @@ -593,17 +451,17 @@ "queenAngerAggression": "Агрессия игроков: +%{value}%%/с", "queenAngerEco": "Экономика: +%{value}%%/с", "queenETA": { - "one" : "Королева прибывает через: %{time}.", - "other" : "%{count} Королев прибывает через: %{time}" + "one": "Королева прибывает через: %{time}.", + "other": "%{count} Королев прибывает через: %{time}" }, "queenHealth": { - "one" : "Здоровье Королевы: %{health}%%", - "other" : "Здоровье Королев: %{health}%%" + "one": "Здоровье Королевы: %{health}%%", + "other": "Здоровье Королев: %{health}%%" }, "queensKilled": "Королев Убито: %{nKilled}/%{nTotal}", "queenResistantToList": { - "one" : "Королева устойчива к:", - "other" : "Королевы устойчивы к:" + "one": "Королева устойчива к:", + "other": "Королевы устойчивы к:" }, "gracePeriod": "Период затишья: %{time}", "burrowCount": "Норы: %{count}", @@ -627,14 +485,14 @@ "firstWave1": "Мусорщики прибыли!", "firstWave2": "Готовьтесь защищаться!", "bossIsAngry1": { - "one" : "Босс уже здесь!", - "other" : "Боссы уже здесь!" + "one": "Босс уже здесь!", + "other": "Боссы уже здесь!" }, "bossIsAngry2": "Готовьтесь к финальной битве!", "bossResistant": "Босс становится устойчивым к атакам %{unit}!", "resistanceUnits": { - "one" : "Босс становится устойчивым к:", - "other" : "Боссы становятся устойчивы к:" + "one": "Босс становится устойчивым к:", + "other": "Боссы становятся устойчивы к:" }, "wave1": "Волна %{waveNumber}", "wave2": "%{unitCount} Мусорщики!", @@ -647,17 +505,17 @@ "bossAngerAggression": "Агрессия игроков: +%{value}%%/с", "bossAngerEco": "Экономика: +%{value}%%/с", "bossETA": { - "one" : "Босс прибывает через: %{time}.", - "other" : "%{count} Боссов прибывает через: %{time}" + "one": "Босс прибывает через: %{time}.", + "other": "%{count} Боссов прибывает через: %{time}" }, "bossHealth": { - "one" : "Здоровье Босса: %{health}%%", - "other" : "Здоровье Боссов: %{health}%%" + "one": "Здоровье Босса: %{health}%%", + "other": "Здоровье Боссов: %{health}%%" }, "bossesKilled": "Боссов Убито: %{nKilled}/%{nTotal}", "bossResistantToList": { - "one" : "Босс устойчив к:", - "other" : "Боссы устойчивы к:" + "one": "Босс устойчив к:", + "other": "Боссы устойчивы к:" }, "gracePeriod": "Период затишья: %{time}", "burrowCount": "Нор: %{count}", @@ -743,7 +601,7 @@ }, "moveAttackNotify": { "underAttack": "%{unit} был атакован!", - "cantMove":"%{unit}: Невозможно добраться до цели!" + "cantMove": "%{unit}: Невозможно добраться до цели!" }, "unitShare": { "shared": "Поделился(-ась) %{units} с %{name} ", @@ -940,15 +798,15 @@ "metalIncome_title": "Доход Металла", "metalIncome_tooltip": "Доход металла в секунду", "energyConversionMetalIncome_title": "Преобразование Металла", - "energyConversionMetalIncome_tooltip": "Доход металла от преобразования энергии" , + "energyConversionMetalIncome_tooltip": "Доход металла от преобразования энергии", "energyIncome_title": "Доход Энергии", "energyIncome_tooltip": "Доход энергии в секунду", "buildPower_title": "Произв. Мощность", "buildPower_tooltip": "Имеющаяся производственная мощность", "metalProduced_title": "Металла произведено", - "metalProduced_tooltip": "Общий доход металла" , + "metalProduced_tooltip": "Общий доход металла", "energyProduced_title": "Энергии произведено", - "energyProduced_tooltip": "Общий доход энергии" , + "energyProduced_tooltip": "Общий доход энергии", "metalExcess_title": "Излишки Металла", "metalExcess_tooltip": "Общие излишки металла", "energyExcess_title": "Излишки Энергии", diff --git a/language/zh/commands.json b/language/zh/commands.json new file mode 100644 index 00000000000..b43a49d0713 --- /dev/null +++ b/language/zh/commands.json @@ -0,0 +1,154 @@ +{ + "commands": { + "move": "移动", + "move_tooltip": "将此单位移向指定位置或跟随指定单位", + "stop": "停止", + "stop_tooltip": "取消单位当前的行动", + "attack": "攻击", + "attack_tooltip": "攻击指定单位或地面", + "areaattack": "区域攻击", + "areaattack_tooltip": "区域攻击圆圈内的所有东西(点击-拖动)", + "manualfire": "裂解炮", + "manualfire_tooltip": "发射强大的指挥官裂解炮", + "manuallaunch": "发射", + "manuallaunch_tooltip": "向指定目标发射一枚导弹", + "patrol": "巡逻", + "patrol_tooltip": "沿着一个或多个指定目标点进行巡逻", + "fight": "战斗指令", + "fight_tooltip": "命令单位移动到指定位置并采取行动", + "resurrect": "复活", + "resurrect_tooltip": "复活残骸,使其重新投入战斗(点击拖动区域)", + "guard": "保护", + "guard_tooltip": "保护指定单位不受敌方单位的攻击", + "factoryguard": "协助工厂", + "factoryguard_tooltip": "工厂生产的建造者会自动协助工厂生产。", + "wait": "等待", + "wait_tooltip": "暂停指定单位/工厂的命令/建造队列", + "repair": "维修", + "repair_tooltip": "修复受伤的指定单位", + "reclaim": "回收", + "reclaim_tooltip": "从指定残骸或自然资源(树木/石头)中吸取金属/电力", + "restore": "重置", + "restore_tooltip": "将地图的指定区域重置到原来的高度", + "capture": "俘获", + "capture_tooltip": "俘获属于敌人(或盟友)的指定单位", + "settarget": "设定目标", + "settarget_tooltip": "设置优先目标(当目标在范围内时,优先选择目标)", + "canceltarget": "清理目标", + "canceltarget_tooltip": "移除优先级目标", + "areamex": "采矿区", + "areamex_tooltip": "点击拖动指定区域,为所有可用的金属点自动建造金属提炼器。", + "loadunits": "装载单位", + "loadunits_tooltip": "在指定的区域内装载单位或多个单位", + "unloadunits": "卸载单位", + "unloadunits_tooltip": "在指定的区域内卸下单位或多个单位", + "stockpile": "储存 %{stockpileStatus}", + "stockpile_tooltip": "[ stockpiled number ] / [ target stockpile number ]", + "stopproduction": "清除队列", + "stopproduction_tooltip": "清除指定工厂的所有单位的建造队列", + "morph": "升级", + "morph_tooltip": "升级到下一个科技级别(点两下可取消)", + "Spawning Disabled": "不自动生产", + "Spawning Enabled": "自动生产", + "sellunit": "出售单位", + "sellunit_tooltip": "切换让选择的单位可以出售,盟友将能够购买这些单位", + "For Sale": "待售", + "Not For Sale": "非卖品", + "Fire at will": "随意开火", + "Hold fire": "停火", + "Return fire": "回击", + "firestate_tooltip": "设置此单位在什么条件下应该开始向敌人开火(没有明确的攻击命令)", + "Hold pos": "保持位置", + "Maneuver": "灵活机动", + "Roam": "漫游", + "movestate_tooltip": "设置此单位在攻击敌人时应该移动多远的距离", + "Repeat on": "开启重复执行", + "Repeat off": "关闭重复执行", + "repeat_tooltip": "重复单位指令队列", + "Low Prio": "低优先级", + "High Prio": "高优先级", + "priority_tooltip": "当没有足够的资源给所有的建造者时,分配资源给这个建造者使用.", + "Decloaked": "隐形关闭", + "Cloaked": "隐形", + "wantcloak_tooltip": "隐形状态", + " On ": "开启", + " Off ": "关闭", + "onoff_tooltip": "当前状态:打开/关闭此单位", + " Fly ": "飞行", + "Land": "着陆", + "idlemode_tooltip": "设置飞机空闲时的动作", + "apLandAt_tooltip": "设定飞机离开飞行器工厂时的动作", + "csSpawning_tooltip": "设置航母单位生产模式", + "Low traj": "低抛", + "High traj": "高抛", + "trajectory_toggle_tooltip": "在低弹道、高弹道和自动弹道之间切换火炮射击角度", + "trajectory_low": "低弹道", + "trajectory_high": "高弹道", + "trajectory_auto": "自动调整弹道", + "hound_weapon_plasma": "重型等离子火炮", + "hound_weapon_gauss": "电磁炮弹", + "hound_weapon_toggle_tooltip": "把武器设置为 慢弹速但爆炸范围大的等离子炮 或 弹道快的电磁炮。", + "blueprint_place": "放置蓝图", + "blueprint_place_tooltip": "放置已保存的蓝图", + "blueprint_create": "保存蓝图", + "blueprint_create_tooltip": "将选中的单位按选择顺序保存为新的蓝图", + "factoryqueuemode_normal": "队列模式", + "factoryqueuemode_quota": "配额模式", + "factoryqueuemode_tooltip": "队列模式:只建造队列中的单位一次\n配额模式: 保证战场上至少有配额数量的单位", + "customOnOff": { + "lowTrajectory": "低弹道", + "highTrajectory": "高弹道", + "trajectory_tooltip": "在低弹道和高弹道之间切换炮台射击角度", + "gauss_tooltip": "在电磁炮 和 等离子炮 之间切换" + }, + "quicksharetotarget": "分享单位", + "quicksharetotarget_tooltip": "分享单位给目标玩家" + }, + "categories": { + "selection": "选择单位", + "orders": "选定后指令", + "queues": "队列指令", + "camera": "镜头", + "drawing": "地图标注", + "sound": "声音" + }, + "actions": { + "massSelect": { + "all": "选择所有单位", + "builders": "选择所有建造单位", + "sameType": "选择所有同种单位", + "removeAutoGroup": "从自动编队中移除兵种" + }, + "orders": { + "cloak": "隐形开启", + "selfDestruct": "自毁" + }, + "queues": { + "prepend": "把指令加到队列开头" + }, + "buildOrders": { + "rotate": "改变建筑朝向" + }, + "issueBuildOrders": { + "spacingUp": "增加建筑间隔", + "spacingDown": "减少建筑间隔" + }, + "camera": { + "flip": "反转镜头" + }, + "cameraModes": { + "mapmarks": "按地图标记循环移动", + "heightmap": "切换等高线地图", + "traversability": "显示可移动区域 (指定单位)", + "resourceSpots": "显示金属矿点地图", + "interface": "隐藏图形用户界面" + }, + "console": { + "erase": "清除所有涂鸦和标记", + "pause": "暂停" + }, + "sound": { + "mute": "静音" + } + } +} diff --git a/language/zh/interface.json b/language/zh/interface.json index ae7563246ee..10c0e0a2f51 100644 --- a/language/zh/interface.json +++ b/language/zh/interface.json @@ -166,111 +166,7 @@ "areamex_tooltip": "提示:双击金属采集器可以使用 划范围建造采集器 的命令" }, "orderMenu": { - "hotkeyTooltip": "%{highlightColor}%{hotkey}%{textColor} - %{tooltip}", - "move": "移动", - "move_tooltip": "将此单位移向指定位置或跟随指定单位", - "stop": "停止", - "stop_tooltip": "取消单位当前的行动", - "attack": "攻击", - "attack_tooltip": "攻击指定单位或地面", - "areaattack": "区域攻击", - "areaattack_tooltip": "区域攻击圆圈内的所有东西(点击-拖动)", - "manualfire": "裂解炮", - "manualfire_tooltip": "发射强大的指挥官裂解炮", - "manuallaunch": "发射", - "manuallaunch_tooltip": "向指定目标发射一枚导弹", - "patrol": "巡逻", - "patrol_tooltip": "沿着一个或多个指定目标点进行巡逻", - "fight": "战斗指令", - "fight_tooltip": "命令单位移动到指定位置并采取行动", - "resurrect": "复活", - "resurrect_tooltip": "复活残骸,使其重新投入战斗(点击拖动区域)", - "guard": "保护", - "guard_tooltip": "保护指定单位不受敌方单位的攻击", - "factoryguard": "协助工厂", - "factoryguard_tooltip": "工厂生产的建造者会自动协助工厂生产。", - "wait": "等待", - "wait_tooltip": "暂停指定单位/工厂的命令/建造队列", - "repair": "维修", - "repair_tooltip": "修复受伤的指定单位", - "reclaim": "回收", - "reclaim_tooltip": "从指定残骸或自然资源(树木/石头)中吸取金属/电力", - "restore": "重置", - "restore_tooltip": "将地图的指定区域重置到原来的高度", - "capture": "俘获", - "capture_tooltip": "俘获属于敌人(或盟友)的指定单位", - "settarget": "设定目标", - "settarget_tooltip": "设置优先目标(当目标在范围内时,优先选择目标)", - "canceltarget": "清理目标", - "canceltarget_tooltip": "移除优先级目标", - "areamex": "采矿区", - "areamex_tooltip": "点击拖动指定区域,为所有可用的金属点自动建造金属提炼器。", - "loadunits": "装载单位", - "loadunits_tooltip": "在指定的区域内装载单位或多个单位", - "unloadunits": "卸载单位", - "unloadunits_tooltip": "在指定的区域内卸下单位或多个单位", - "stockpile": "储存 %{stockpileStatus}", - "stockpile_tooltip": "[ stockpiled number ] / [ target stockpile number ]", - "stopproduction": "清除队列", - "stopproduction_tooltip": "清除指定工厂的所有单位的建造队列", - "morph": "升级", - "morph_tooltip": "升级到下一个科技级别(点两下可取消)", - "Spawning Disabled": "不自动生产", - "Spawning Enabled": "自动生产", - "sellunit": "出售单位", - "sellunit_tooltip": "切换让选择的单位可以出售,盟友将能够购买这些单位", - "For Sale": "待售", - "Not For Sale": "非卖品", - - "Fire at will": "随意开火", - "Hold fire": "停火", - "Return fire": "回击", - "firestate_tooltip": "设置此单位在什么条件下应该开始向敌人开火(没有明确的攻击命令)", - "Hold pos": "保持位置", - "Maneuver": "灵活机动", - "Roam": "漫游", - "movestate_tooltip": "设置此单位在攻击敌人时应该移动多远的距离", - "Repeat on": "开启重复执行", - "Repeat off": "关闭重复执行", - "repeat_tooltip": "重复单位指令队列", - "Low Prio": "低优先级", - "High Prio": "高优先级", - "priority_tooltip": "当没有足够的资源给所有的建造者时,分配资源给这个建造者使用.", - "Decloaked": "隐形关闭", - "Cloaked": "隐形", - "wantcloak_tooltip": "隐形状态", - " On ": "开启", - " Off ": "关闭", - "onoff_tooltip": "当前状态:打开/关闭此单位", - " Fly ": "飞行", - "Land": "着陆", - "idlemode_tooltip": "设置飞机空闲时的动作", - "apLandAt_tooltip": "设定飞机离开飞行器工厂时的动作", - "csSpawning_tooltip": "设置航母单位生产模式", - "Low traj": "低抛", - "High traj": "高抛", - "trajectory_toggle_tooltip": "在低弹道、高弹道和自动弹道之间切换火炮射击角度", - "trajectory_low": "低弹道", - "trajectory_high": "高弹道", - "trajectory_auto": "自动调整弹道", - "hound_weapon_plasma": "重型等离子火炮", - "hound_weapon_gauss": "电磁炮弹", - "hound_weapon_toggle_tooltip": "把武器设置为 慢弹速但爆炸范围大的等离子炮 或 弹道快的电磁炮。", - "blueprint_place": "放置蓝图", - "blueprint_place_tooltip": "放置已保存的蓝图", - "blueprint_create": "保存蓝图", - "blueprint_create_tooltip": "将选中的单位按选择顺序保存为新的蓝图", - "factoryqueuemode_normal": "队列模式", - "factoryqueuemode_quota": "配额模式", - "factoryqueuemode_tooltip": "队列模式:只建造队列中的单位一次\n配额模式: 保证战场上至少有配额数量的单位", - "customOnOff": { - "lowTrajectory": "低弹道", - "highTrajectory": "高弹道", - "trajectory_tooltip": "在低弹道和高弹道之间切换炮台射击角度", - "gauss_tooltip": "在电磁炮 和 等离子炮 之间切换" - }, - "quicksharetotarget": "分享单位", - "quicksharetotarget_tooltip": "分享单位给目标玩家" + "hotkeyTooltip": "%{highlightColor}%{hotkey}%{textColor} - %{tooltip}" }, "idleBuilders": { "name": "闲置建造单位", @@ -293,7 +189,7 @@ "keybinds": { "title": "快捷键", "disclaimer": "如果你改动了快捷键插件,或使用自己的快捷键插件,可能导致上述快捷键无效!", - "howtochangekeybinds":"如果要改变它们:在 设置/控制 栏目,将键位绑定设置为 自定义, 来创建 BAR/data/uikeys.txt file。\n编辑这个文件,然后在聊天框中输入 /keyreload 来加载自定义键位。", + "howtochangekeybinds": "如果要改变它们:在 设置/控制 栏目,将键位绑定设置为 自定义, 来创建 BAR/data/uikeys.txt file。\n编辑这个文件,然后在聊天框中输入 /keyreload 来加载自定义键位。", "chat": { "title": "聊天", "send": "发送消息", @@ -310,7 +206,6 @@ "share": "共享 单位 / 资源" }, "camera": { - "title": "镜头", "zoomKey": "滚轮", "zoom": "拉近/拉远", "panKey": "方向键 / 鼠标指向屏幕边缘", @@ -318,8 +213,7 @@ "tiltKey": "ctrl + 滚轮", "tilt": "镜头角度", "dragKey": "中键(拖动)", - "drag": "拖动镜头", - "flip": "反转镜头" + "drag": "拖动镜头" }, "cameraModes": { "title": "镜头模式", @@ -328,21 +222,13 @@ "fullscreenKey": "alt + 退格", "fullscreen": "切换全屏", "overview": "切换成俯视缩略图", - "los": "切换视野范围显示", - "heightmap": "切换等高线地图", - "traversability": "显示可移动区域 (指定单位)", - "mapmarks": "按地图标记循环移动", - "resourceSpots": "显示金属矿点地图", - "interface": "隐藏图形用户界面" + "los": "切换视野范围显示" }, "sound": { - "title": "声音", "volumeKey": "-/+", - "volume": "修改音量", - "mute": "静音" + "volume": "修改音量" }, "selection": { - "title": "选择单位", "unitsKey": "左键 (+ 选取)", "units": "选择或取消选择单位" }, @@ -354,23 +240,8 @@ "formationOrder": "给单位(组)发出编队指令" }, "orders": { - "title": "选定后指令", "defaultKey": "(none)", - "default": "默认命令(一般为移动)", - "move": "移动", - "attack": "攻击", - "stop": "停止 (取消指令队列)", - "repair": "维修", - "reclaim": "回收", - "resurrect": "复活", - "patrol": "巡逻", - "fight": "战斗", - "setTarget": "设置优先攻击目标", - "cancelTarget": "取消优先目标", - "wait": "等待 (暂停当前命令)", - "cloak": "隐形开启", - "dGun": "手动发射 (裂解炮)", - "selfDestruct": "自毁" + "default": "默认命令(一般为移动)" }, "issueOrders": { "title": "下达选择的命令", @@ -382,11 +253,9 @@ "formation": "下达列队命令" }, "queues": { - "title": "队列指令", "append": "把指令加到队列结尾", "appendKey": "shift + (指令)", - "prependKey": "space + (指令)", - "prepend": "把指令加到队列开头" + "prependKey": "space + (指令)" }, "buildOrders": { "title": "选择建造指令", @@ -400,8 +269,7 @@ "intel": "循环指定 雷达/防御/其它 建筑", "factoriesKey": "v", "factories": "循环指定生产工厂", - "rotateKey": "[ 与 ]", - "rotate": "改变建筑朝向" + "rotateKey": "[ 与 ]" }, "issueBuildOrders": { "title": "下达建造指令", @@ -414,29 +282,22 @@ "gridKey": "shift + alt + (建造指令)", "grid": "建成方阵", "spacingUpKey": "alt+z", - "spacingUp": "增加建筑间隔", - "spacingDownKey": "alt+x", - "spacingDown": "减少建筑间隔" + "spacingDownKey": "alt+x" }, "massSelect": { "title": "编队选择", "allKey": "ctrl + a", - "all": "选择所有单位", "buildersKey": "ctrl + b", - "builders": "选择所有建造单位", "createGroupKey": "ctrl + (编号)", "createGroup": "向编队中增加单位 (编号=1,2,..)", "createAutoGroupKey": "alt + (编号)", "createAutoGroup": "向自动编队中增加兵种 (编号=1,2,..)", "removeAutoGroupKey": "alt + `", - "removeAutoGroup": "从自动编队中移除兵种", "groupKey": "(编号)", "group": "选择编队中所有单位 (编号)", - "sameTypeKey": "ctrl + z", - "sameType": "选择所有同种单位" + "sameTypeKey": "ctrl + z" }, "drawing": { - "title": "地图标注", "mapmarkKey": "` + 左键双击", "mapmark": "地图标记", "drawKey": "` + 左键拖动", @@ -447,9 +308,7 @@ "console": { "title": "控制台", "eraseKey": "/clearmapmarks", - "erase": "清除所有涂鸦和标记", - "pauseKey": "/pause", - "pause": "暂停" + "pauseKey": "/pause" } }, "chat": { @@ -530,7 +389,7 @@ "heightTitle": "等高线地图", "heightmap": "[%{keyset}] 为每个地形高度标以不同颜色。", "pathingTitle": "地形通过性地图", - "pathing":"[%{keyset}] 为已选择的单位显示其地形通过性,绿色:OK,红色:艰难,紫色:无法移动", + "pathing": "[%{keyset}] 为已选择的单位显示其地形通过性,绿色:OK,红色:艰难,紫色:无法移动", "resourcesTitle": "资源图", "resources": "[%{keyset}] 高亮显示 绿色:金属矿,黄色:地热喷口. \n红色:已占领金属矿." }, @@ -571,14 +430,14 @@ "airWave1": "飞行虫群来袭!", "airWave2": "%{unitCount}只虫子!", "queenIsAngry1": { - "one" : "女王来袭!", - "other" : "女王来袭!" + "one": "女王来袭!", + "other": "女王来袭!" }, "queenIsAngry2": "准备好迎接最后的战斗吧!", "queenResistant": "女王开始对 %{unit} 的攻击产生耐受!", - "resistanceUnits":{ - "one" : "女王开始对此单位产生耐受:", - "other" : "女王开始对此单位产生耐受:" + "resistanceUnits": { + "one": "女王开始对此单位产生耐受:", + "other": "女王开始对此单位产生耐受:" }, "wave1": "第%{waveNumber}波", "wave2": "%{unitCount}只虫子!", @@ -591,17 +450,17 @@ "queenAngerAggression": "玩家攻击性:%{value}%%/s", "queenAngerEco": "经济:+%{value}%%/每秒", "queenETA": { - "one" : "%{time}后虫后降临", - "other" : "%{time}秒后%{count}只虫后降临" + "one": "%{time}后虫后降临", + "other": "%{time}秒后%{count}只虫后降临" }, "queenHealth": { - "one" : "虫后血量: %{health}%%", - "other" : "虫后血量: %{health}%%" + "one": "虫后血量: %{health}%%", + "other": "虫后血量: %{health}%%" }, "queensKilled": "虫后击杀:%{nKilled}/%{nTotal}", "queenResistantToList": { - "one" : "虫后对此有抵抗性:", - "other" : "虫后对此有抵抗性:" + "one": "虫后对此有抵抗性:", + "other": "虫后对此有抵抗性:" }, "gracePeriod": "和平期: %{time}", "burrowCount": "巢穴数量: %{count}", @@ -625,14 +484,14 @@ "firstWave1": "拾荒者已登陆!", "firstWave2": "准备好保卫自己吧!", "bossIsAngry1": { - "one" : "BOSS已抵达战场!", - "other" : "BOSS已抵达战场!" + "one": "BOSS已抵达战场!", + "other": "BOSS已抵达战场!" }, "bossIsAngry2": "准备好迎接最后的战斗吧!", "bossResistant": "BOSS开始对 %{unit} 的攻击产生耐受!", "resistanceUnits": { - "one" : "BOSS开始对此单位产生耐受:", - "other" : "BOSS开始对此单位产生耐受:" + "one": "BOSS开始对此单位产生耐受:", + "other": "BOSS开始对此单位产生耐受:" }, "wave1": "第%{waveNumber}波", "wave2": "%{unitCount}个拾荒者!", @@ -645,17 +504,17 @@ "bossAngerAggression": "玩家攻击性:%{value}%%/s", "bossAngerEco": "经济:+%{value}%%/每秒", "bossETA": { - "one" : "BOSS在%{time}秒后到达。", - "other" : "%{time}秒后%{count}个BOSS降临" + "one": "BOSS在%{time}秒后到达。", + "other": "%{time}秒后%{count}个BOSS降临" }, "bossHealth": { - "one" : "BOSS生命值:%{health}%%", - "other" : "BOSS生命值:%{health}%%" + "one": "BOSS生命值:%{health}%%", + "other": "BOSS生命值:%{health}%%" }, "bossesKilled": "BOSS击杀:%{nKilled}/%{nTotal}", "bossResistantToList": { - "one" : "BOSS开始对此单位产生耐受:", - "other" : "BOSS对此有抵抗性:" + "one": "BOSS开始对此单位产生耐受:", + "other": "BOSS对此有抵抗性:" }, "gracePeriod": "和平期: %{time}", "burrowCount": "巢穴数量: %{count}", @@ -735,7 +594,7 @@ }, "moveAttackNotify": { "underAttack": "%{unit} 正在被攻击!", - "cantMove":"%{unit}: 无法到达目的地!" + "cantMove": "%{unit}: 无法到达目的地!" }, "unitShare": { "shared": "分享了%{units}至%{name}", @@ -932,15 +791,15 @@ "metalIncome_title": "金属收入", "metalIncome_tooltip": "每秒金属收入", "energyConversionMetalIncome_title": "金属转换", - "energyConversionMetalIncome_tooltip": "通过能量转换获得的金属收入" , + "energyConversionMetalIncome_tooltip": "通过能量转换获得的金属收入", "energyIncome_title": "能量收入", "energyIncome_tooltip": "每秒能量收入", "buildPower_title": "建造能力", "buildPower_tooltip": "可用建造能力", "metalProduced_title": "已生产金属", - "metalProduced_tooltip": "已生产金属总量" , + "metalProduced_tooltip": "已生产金属总量", "energyProduced_title": "已生产能量", - "energyProduced_tooltip": "已生产能量总数" , + "energyProduced_tooltip": "已生产能量总数", "metalExcess_title": "金属溢出", "metalExcess_tooltip": "金属溢出总数", "energyExcess_title": "能量溢出", diff --git a/luaui/Include/keybind_config.lua b/luaui/Include/keybind_config.lua new file mode 100644 index 00000000000..bc7e33684a2 --- /dev/null +++ b/luaui/Include/keybind_config.lua @@ -0,0 +1,22 @@ +-- Reader for the shared keybind JSON configs in common/configs. +-- +-- A malformed file costs the caller its data, never the LuaUI session: Json.decode raises +-- on bad input, and an unguarded decode at include time takes the widget down with it. +-- Callers do their own shape checks; this only guarantees a table or nil. + +local Json = Json or VFS.Include("common/luaUtilities/json.lua") + +local M = {} + +function M.load(path) + local ok, decoded = pcall(Json.decode, VFS.LoadFile(path)) + if ok and type(decoded) == "table" then + return decoded + end + + Spring.Echo("[keybinds] Error: could not load " .. path) + + return nil +end + +return M diff --git a/luaui/Include/keybind_dropdown.lua b/luaui/Include/keybind_dropdown.lua new file mode 100644 index 00000000000..002ed3ddbcb --- /dev/null +++ b/luaui/Include/keybind_dropdown.lua @@ -0,0 +1,210 @@ +-- Select control for the keybind editor's preset picker. +-- Uses FlowUI's Selector visuals to match the Settings look. Shows the current +-- selection; onSelect(option, index) fires when a choice is picked. + +local text = VFS.Include("luaui/Include/keybind_text.lua") + +local Dropdown = {} +Dropdown.__index = Dropdown + +local floor = math.floor + +local colorText = "\255\235\235\235" +-- SelectHighlight defaults to 0.35 and the rest of the UI stays near it. At 1 the +-- overlay is opaque and swallows the option label under it. +local hoverOpacity = 0.25 +local white = { 1, 1, 1 } +local listFill = { 0.09, 0.09, 0.09, 0.96 } + +-- Font is fetched per draw; it does not exist when this file is included. +local function getFont() + return WG["fonts"].getFont() +end + +-- Options may be plain strings or { label = ... } records. +local function optionLabel(opt) + if type(opt) == "table" then + return opt.label or tostring(opt.value) + end + + return tostring(opt) +end + +-- A select: closed it shows the selection, open it overlays its options. +function Dropdown.new(opts) + opts = opts or {} + + local self = setmetatable({}, Dropdown) + self.options = opts.options or {} + self.onSelect = opts.onSelect + self.selected = opts.selected or 1 + self.placeholder = opts.placeholder + self.open = false + self.rect = { 0, 0, 0, 0 } + self.optRects = {} + self.fontSize = 14 + + return self +end + +-- Placement, plus the option rects the open list will use. +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 + +-- Swaps the options, closing the list and keeping the selection in range. +function Dropdown:setOptions(options) + -- Closed as well as rebuilt: a refresh while the list is down would otherwise leave it + -- open over a different set of options than the one it was opened on. + self.open = false + self.options = options or {} + if self.selected > #self.options then + self.selected = 1 + end + -- Fitted captions belong to the old options. + self.optFitted = nil + + local r = self.rect + self:setRect(r[1], r[2], r[3], r[4], self.fontSize) +end + +-- The caption shortened to its box, measured once per text and width rather than per +-- frame. The result is kept on the cache table under the key given. +local function fittedLabel(cache, key, font, label, w, fs) + local hit = cache[key] + if hit and hit.label == label and hit.w == w then + return hit.text + end + + local fitted = colorText .. text.fit(font, label, w, fs) + cache[key] = { label = label, w = w, text = fitted } + + return fitted +end + +-- Moves the selection without notifying the owner, for syncing from outside. +function Dropdown:setSelected(i) + if i and self.options[i] then + self.selected = i + end +end + +function Dropdown:isOpen() + return self.open +end + +function Dropdown:close() + self.open = false +end + +-- Chevron corners, held as upvalues so the vertex callback can be built once rather +-- than closing over fresh geometry on every draw. +local chevronX, chevronY, chevronH = 0, 0, 0 +local function chevronVertices() + gl.Vertex(chevronX - chevronH, chevronY) + gl.Vertex(chevronX + chevronH, chevronY) + gl.Vertex(chevronX, chevronY - chevronH * 1.2) +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] + local inset = floor((y2 - y1) * 0.3) + + Selector(x1, y1, x2, y2) + + -- Chevron in the gap already reserved at the right edge, so the control reads as a + -- select rather than a button. Drawn before the text: geometry inside a font batch + -- makes both flicker. + local arrowH = floor((y2 - y1) * 0.16) + local arrowX = x2 - inset - arrowH + local arrowY = (y1 + y2) * 0.5 + arrowH * 0.5 + gl.Color(1, 1, 1, self.open and 0.9 or 0.55) + chevronX, chevronY, chevronH = arrowX, arrowY, arrowH + gl.BeginEnd(GL.TRIANGLES, chevronVertices) + gl.Color(1, 1, 1, 1) + + local fitted = self.optFitted + if not fitted then + fitted = {} + self.optFitted = fitted + end + + font:Begin() + local current = self.options[self.selected] + local label = self.placeholder or (current and optionLabel(current) or "") + -- A profile name is free text and can outrun the control, which is fixed width so the + -- header does not reflow every time the selection changes. + local labelW = (arrowX - arrowH) - (x1 + inset) - inset * 2 + font:Print(fittedLabel(fitted, 0, font, label, labelW, self.fontSize), x1 + inset, (y1 + y2) * 0.5, self.fontSize, "ov") + font:End() + + if self.open and #self.optRects > 0 then + local top = self.optRects[1].y2 + local bottom = self.optRects[#self.optRects].y1 + -- Rounded like the rest of the panel's inner elements. + local cs = WG.FlowUI.elementCorner * 0.66 + R(x1, bottom, x2, top, cs, 1, 1, 1, 1, listFill) + + for i in ipairs(self.options) do + ---@type table + 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, hoverOpacity, white) + end + end + + font:Begin() + for i, opt in ipairs(self.options) do + local r = self.optRects[i] + local w = (r.x2 - inset) - (r.x1 + inset) + font:Print( + fittedLabel(fitted, i, font, optionLabel(opt), w, self.fontSize), + r.x1 + inset, + (r.y1 + r.y2) * 0.5, + self.fontSize, + "ov" + ) + end + font:End() + end +end + +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 + self.selected = i + if self.onSelect then + self.onSelect(self.options[i], 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/keybind_editbox.lua b/luaui/Include/keybind_editbox.lua new file mode 100644 index 00000000000..fa3d2993d01 --- /dev/null +++ b/luaui/Include/keybind_editbox.lua @@ -0,0 +1,329 @@ +-- Single-line text input for the keybind editor's search field. +-- Active only while focused, so it is safe to host alongside game input. + +local utf8 = VFS.Include("common/luaUtilities/utf8.lua") + +local KEYSYMS = VFS.Include("luaui/Include/keybind_keysyms.lua") + +local Editbox = {} +Editbox.__index = Editbox + +local floor = math.floor + +local colorText = "\255\235\235\235" +local colorDim = "\255\160\160\160" + +-- Font is fetched per draw; it does not exist when this file is included. +local function getFont() + return WG["fonts"].getFont() +end + +-- Single-line text field with a caret, selection and word motion. +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 + +-- Replaces the contents, caret to the 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 + +-- SDL text input is owned by the panel, not by this field: blurring the search box to +-- click a keybind must not stop text events while the editor is still open. +function Editbox:focus() + self.focused = true +end + +-- Gives up focus and any drag in progress. +function Editbox:blur() + self.focused = false + self.dragging = false +end + +function Editbox:isFocused() + return self.focused +end + +function Editbox:hasSelection() + return self.selAnchor ~= nil and self.selAnchor ~= self.caret +end + +-- The highlighted range, low end first. +function Editbox:selRange() + return math.min(self.selAnchor, self.caret), math.max(self.selAnchor, self.caret) +end + +-- Removes the highlighted range, caret left where it began. +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 + +-- Moves the caret, growing the selection when the caller asks to extend. +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 + +-- Takes a typed character, replacing any selection. +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 + +-- Editing and motion keys; printable characters arrive through textInput instead. +function Editbox:keyPress(key) + if not self.focused then + return false + end + + local _, ctrl, _, shift = Spring.GetModKeyState() + local changed = false + + if ctrl and key == KEYSYMS.A then + self.selAnchor = 0 + self.caret = utf8.len(self.text) + elseif key == KEYSYMS.ESCAPE or key == KEYSYMS.RETURN then + self:blur() + elseif key == KEYSYMS.BACKSPACE then + 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 == KEYSYMS.DELETE then + 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 == KEYSYMS.LEFT then + self:setCaret(ctrl and self:prevWord() or self.caret - 1, shift) + elseif key == KEYSYMS.RIGHT then + self:setCaret(ctrl and self:nextWord() or self.caret + 1, shift) + elseif key == KEYSYMS.HOME then + self:setCaret(0, shift) + elseif key == KEYSYMS.END then + self:setCaret(utf8.len(self.text), shift) + end + + if changed and self.onChange then + self.onChange(self.text) + end + + return true +end + +-- Click to place the caret, or start a drag selection. +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 + +-- Recomputes caret and selection pixel offsets after the text or rect changes. +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 + +-- Held rather than built per draw: a colour table a frame is an allocation a frame. +local fieldFill = { 0, 0, 0, 0.35 } +local caretFill = { 1, 1, 1, 0.85 } + +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] + -- Rounded like the rest of the panel's inner elements; the caret and selection sit + -- inside the field by their own inset. + local cs = WG.FlowUI.elementCorner * 0.66 + local inset = 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, fieldFill) + + if self:hasSelection() then + local a, b = self:selRange() + local sa = font:GetTextWidth(utf8.sub(self.text, 1, a)) * self.fontSize + local sb = font:GetTextWidth(utf8.sub(self.text, 1, b)) * self.fontSize + gl.Color(0.4, 0.55, 0.85, 0.5) + gl.Rect(tx + sa, y1 + inset, tx + sb, y2 - inset) + gl.Color(1, 1, 1, 1) + end + + -- The coloured string is kept until the text changes, not rebuilt every frame. + local shown + if self.text == "" and not self.focused then + shown = self.placeholderShown + if not shown then + shown = colorDim .. self.placeholder + self.placeholderShown = shown + end + else + if self.shownFor ~= self.text then + self.shownFor = self.text + self.shown = colorText .. self.text + end + shown = self.shown + end + + font:Begin() + font:Print(shown, tx, ty, self.fontSize, "ov") + font:End() + + if self.focused then + local cw = font:GetTextWidth(utf8.sub(self.text, 1, self.caret)) * self.fontSize + R(tx + cw, y1 + inset, tx + cw + math.max(1, floor(inset * 0.5)), y2 - inset, 0, 0, 0, 0, 0, caretFill) + end +end + +return Editbox diff --git a/luaui/Include/keybind_editor_view.lua b/luaui/Include/keybind_editor_view.lua new file mode 100644 index 00000000000..e95bd8f0149 --- /dev/null +++ b/luaui/Include/keybind_editor_view.lua @@ -0,0 +1,2903 @@ +-- Interactive view for the in-game keybind editor, hosted as the first tab of +-- the Keybind/Mouse Info panel. Immediate-mode in shape, but the panel body is baked +-- into a display list and replayed until something it was painted from changes. +-- +-- The picker lists the shipped profiles and the player's own. Edits are staged in the +-- working model and touch neither the engine nor disk until Save, which is also where +-- a shipped profile forks: saving over a read-only one creates a copy instead. Unsaved +-- work is marked with a "*" on the profile name and guarded on the way out. + +local keybindModel = VFS.Include("luaui/Include/keybind_model.lua") +local keybindConfig = VFS.Include("luaui/Include/keybind_config.lua") +local keyConfig = VFS.Include("luaui/configs/keyboard_layouts.lua") + +-- Shape and rules are documented in common/configs/keybinds.README.md; this is the +-- contract Chobby and the lobby read too, so it is data rather than Lua. +local catalog = keybindConfig.load("common/configs/keybind_catalog.json") or {} +local Editbox = VFS.Include("luaui/Include/keybind_editbox.lua") +local Dropdown = VFS.Include("luaui/Include/keybind_dropdown.lua") +local profiles = VFS.Include("luaui/Include/keybind_profiles.lua") + +local KEYSYMS = VFS.Include("luaui/Include/keybind_keysyms.lua") +local text = VFS.Include("luaui/Include/keybind_text.lua") + +local view = {} + +local floor = math.floor +local spGetMouseState = Spring.GetMouseState +local spGetModKeyState = Spring.GetModKeyState +local spGetTimer = Spring.GetTimer +local spDiffTimers = Spring.DiffTimers +local isInRect = math.isInRect +local spGetScanSymbol = Spring.GetScanSymbol +local glColor = gl.Color +local glTexture = gl.Texture +local glTexRect = gl.TexRect +local glBlending = gl.Blending + +-- The engine loads this one file; a profile is applied by writing it here. +local customKeysFile = profiles.activeFile + +local area = { x1 = 0, y1 = 0, x2 = 0, y2 = 0 } +local scale = 1 +local rowHeight = 22 +-- Sizes derived from the scale, in one table: this chunk is close to Lua's limit of +-- 200 locals, so they share a slot rather than each taking one. +local metrics = { + -- Category entries run taller than keybind rows and in their own, larger, font. + catRowHeight = 28, + rowFs = 12, + rowPad = 6, + sidePad = 12, + catInset = 4, + -- Chips sit inside their row by this much, top and bottom. + chipInset = 3, + -- Corner radii, taken from FlowUI's so the panel rounds like the rest of the UI. + csSmall = 2, + csButton = 3, + csPanel = 4, +} +-- Bumped by setArea. 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 listTop = 0 +local barX1 = 0 +local listX1 = 0 +local sidebarW = 0 +local categories = {} +-- Selection is held as the catalog's i18n key, never its translated title, so a language +-- change cannot strand it against titles that have all moved. +---@type string? +local selectedCategory +-- Stands in for the generated Other bucket when no catalog category is titled the same. +-- A table cannot collide with a catalog key, which is always a string. +local generatedOtherKey = {} +local otherCategoryKey = generatedOtherKey +-- Set while a category asks to be drawn as the grid menu instead of a row list. +---@type table? +local gridGroup +local listRight = 0 +local keyAreaX1 = 0 + +---@type table +local working +---@type table +local resolvedCatalog +-- Grouped chips per action. Derived purely from working.byAction, and every path that +-- changes that rebuilds the rows, so rebuildRows is where it gets dropped. +local chipGroups = {} +local catalogAny, catalogAnyPrefixes, catalogShiftPair = {}, {}, {} +local L = {} +local rows = {} +-- Bumped by rebuildRows, so the baked panel knows the list behind it changed. +local rowsGen = 0 +local scroll = 0 +local dragging = false +local dirty = false +---@type table? +local capturing + +---@type table +local font +---@type function +local RectRound +---@type table +local Scroller +---@type function +local UiElement +---@type function +local Highlight + +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 colorDanger = "\255\235\090\090" +-- SelectHighlight defaults to 0.35 and the rest of the UI stays near it. At 1 the +-- overlay is opaque and swallows the label under it. +local hoverOpacity = 0.25 +local buttonFill = { 0.18, 0.18, 0.18, 1 } +-- Matching stops because Draw.Button gradients color1 -> color2, and the white hover +-- overlay washes a tinted button out to grey, so these brighten instead. +local dangerFill = { 0.46, 0.10, 0.10, 1 } +local dangerFillHover = { 0.66, 0.14, 0.14, 1 } +local confirmFill = { 0.17, 0.38, 0.21, 1 } +local confirmFillHover = { 0.24, 0.52, 0.29, 1 } +local confirmFillMuted = { 0.11, 0.20, 0.13, 1 } +local pillFill = { 0.22, 0.22, 0.22, 1 } +local sheenTop = { 1, 1, 1, 0.05 } +local sheenNone = { 1, 1, 1, 0 } +-- Fills and captions the list is painted with, in one table for the same reason as +-- metrics above. +local look = { + chipFill = { 0, 0, 0, 0.35 }, + chipFillHover = { 0, 0, 0, 0.45 }, + addFill = { 0.2, 0.45, 0.25, 0.4 }, + addFillHover = { 0.2, 0.45, 0.25, 0.55 }, + selectedFill = { 1, 1, 1, 0.13 }, + -- 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 }, + white = { 1, 1, 1 }, + -- Rows, categories and grid cells hover with the same FlowUI highlight the settings + -- list uses, at the strength it gives a plain row. + rowHoverOpacity = 0.14, + removeHot = colorDanger .. "x", + removeCold = colorDim .. "x", + plusText = colorText .. "+", + arrow = colorKey .. string.char(226, 128, 186), +} + +---@type table +local searchBox +---@type table +local presetDropdown +---@type table +local nameBox +---@type function? +local menuToggle +local switchToPreset, scrollFromY + +---@type table? +local dialog + +local headerButtons = { + { id = "duplicate", icon = "LuaUI/Images/keybinds/duplicate.png", tooltipId = "keybind_duplicate" }, + { id = "edit", icon = "LuaUI/Images/keybinds/edit.png", tooltipId = "keybind_edit" }, +} + +local footerButtons = { + { id = "reset" }, + { id = "save", fill = confirmFill, fillHover = confirmFillHover, fillMuted = confirmFillMuted }, +} + +local buttonSets = { headerButtons, footerButtons } + +-- Header and footer bands, shared by the layout and by every geometry derived from it. +local headerH = 0 +local footerH = 0 +local layoutPending = false + +-- Tooltips are registered once per layout rather than per frame: registering with a +-- value throws the tooltip's cached text away each time, so per frame it never settled. +local tooltipsRegistered = false + +-- The panel below the header controls, baked once and replayed until something it was +-- painted from changes. +local panelList +local panelSig + +---------------------------------------------------------------- +-- Profiles and the picker +---------------------------------------------------------------- + +local presetOptions = {} + +-- Picker contents: the shipped profiles, the player's own, and any unsaved fork. +local function buildPresetOptions() + local active = profiles.activeName() + -- Editing a shipped profile does not change it: what is on screen is an unsaved new + -- profile, so the picker says that instead of marking the read-only one as modified. + local pending = dirty and profiles.isBuiltin(active) ~= nil + + presetOptions = {} + for _, b in ipairs(profiles.builtins) do + presetOptions[#presetOptions + 1] = { label = b.name, name = b.name, builtin = true } + end + for _, name in ipairs(profiles.list()) do + local marked = (dirty and name == active) and (name .. " *") or name + presetOptions[#presetOptions + 1] = { label = marked, name = name } + end + if pending then + presetOptions[#presetOptions + 1] = { label = L.newProfile .. " *", pending = true } + end + + return presetOptions +end + +-- Shipped profiles can be copied but not renamed or deleted. +local function activeIsOwn() + return profiles.get(profiles.activeName()) ~= nil +end + +-- Single source for whether a button is live, so it cannot draw enabled and do nothing. +local function buttonEnabled(id) + if id == "save" or id == "reset" then + return dirty + end + if id == "duplicate" then + return true + end + + return activeIsOwn() +end + +local function currentPresetIndex() + local last = presetOptions[#presetOptions] + if last and last.pending then + return #presetOptions + end + + local name = profiles.activeName() + for i = 1, #presetOptions do + if presetOptions[i].name == name then + return i + end + end + + return 1 +end + +---------------------------------------------------------------- +-- Scrolling +---------------------------------------------------------------- + +local function listBottom() + return area.y1 + footerH +end + +-- Whole rows the band can paint. +local function visibleRows() + return math.max(1, floor((listTop - listBottom()) / rowHeight)) +end + +-- Furthest offset that still fills the band. +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 + +---------------------------------------------------------------- +-- Catalog and the row list +---------------------------------------------------------------- + +-- Whether a label wants the prefix argument placed inside it. Probed rather than declared, +-- because a label shared with the command card names the thing rather than a numbered +-- variant of it, and then the argument goes on the end instead. +local labelPlacesArg = {} +local function prefixRowLabel(key, arg, row, col) + local places = labelPlacesArg[key] + if places == nil then + places = BAR.I18N(key, { n = "", row = "", col = "" }):find("", 1, true) ~= nil + labelPlacesArg[key] = places + end + + if places then + return BAR.I18N(key, { n = arg, row = row, col = col }) + end + + return BAR.I18N(key) .. " " .. arg +end + +-- Resolve i18n labels once (search rebuilds rows per keystroke); redone on refresh. +local function buildResolvedCatalog() + labelPlacesArg = {} + resolvedCatalog = {} + catalogAny, catalogAnyPrefixes, catalogShiftPair = {}, {}, {} + for _, group in ipairs(catalog) do + if group.hidden then + resolvedCatalog[#resolvedCatalog + 1] = { hidden = group.hidden, title = "", titleLower = "", items = {} } + else + local title = BAR.I18N(group.category) + local g = { + category = group.category, + layout = group.layout, + title = title, + titleLower = title:lower(), + items = {}, + } + for _, item in ipairs(group.items) do + if item.prefix then + if item.alwaysModifier == "any" then + catalogAnyPrefixes[#catalogAnyPrefixes + 1] = item.prefix + end + g.items[#g.items + 1] = + { prefix = item.prefix, label = item.label, unit = item.unit, members = item.members } + else + if item.action then + if item.alwaysModifier == "any" then + catalogAny[item.action] = true + elseif item.alwaysModifier == "shift" then + catalogShiftPair[item.action] = true + end + end + local label = BAR.I18N(item.label) + g.items[#g.items + 1] = { + action = item.action, + actionLower = item.action and item.action:lower(), + label = label, + labelLower = label:lower(), + } + end + end + if g.layout == "grid" then + -- Pulled off the same catalog entries the list would have used, so the grid and + -- the flat form name things identically. + g.categoryLabels = {} + for _, it in ipairs(group.items) do + local n = it.action and it.action:match("^gridmenu_category%s+(%d+)$") + if n then + g.categoryLabels[tonumber(n)] = BAR.I18N(it.label) + end + if it.prefix == "gridmenu_key" and it.label then + local key = it.label + g.cellLabel = function(row, col) + return BAR.I18N(key, { n = row .. " " .. col, row = row, col = col }) + end + end + end + g.cellLabel = g.cellLabel or function(row, col) + return row .. " " .. col + end + for _, it in ipairs(group.items) do + if it.action == "gridmenu_cycle_builder" and it.label then + g.cycleLabel = BAR.I18N(it.label) + end + end + g.cycleLabel = g.cycleLabel or "gridmenu_cycle_builder" + end + resolvedCatalog[#resolvedCatalog + 1] = g + end + end + + L.other = BAR.I18N("categories.other") + L.otherLower = L.other:lower() + L.title = BAR.I18N("ui.keybinds.title") + L.titleText = colorText .. L.title + L.allCategories = BAR.I18N("ui.keybinds.editor.allCategories") + L.gridNextPage = BAR.I18N("actions.gridMenu.nextPage") + -- gui_gridmenu hardcodes both the caption and the key on this button, so it is not + -- bindable and there is no i18n key to read. + -- Shared with gui_gridmenu, which draws the button this mirrors. + L.gridBack = BAR.I18N("ui.buildMenu.back") + + categories = { { label = L.allCategories } } + otherCategoryKey = generatedOtherKey + local seen = {} + for _, g in ipairs(resolvedCatalog) do + -- Keyed like the row filter below, not by title: two categories that translate to + -- the same words are still separate, and one has to not vanish from the column. + if not g.hidden and not seen[g.category] then + seen[g.category] = true + categories[#categories + 1] = { label = g.title, key = g.category } + -- A catalog category of the same name takes the leftovers, matching the row + -- order below, rather than a second column entry appearing beside it. + if g.title == L.other then + otherCategoryKey = g.category + end + end + end + if otherCategoryKey == generatedOtherKey then + categories[#categories + 1] = { label = L.other, key = otherCategoryKey } + end + L.pressKey = BAR.I18N("ui.keybinds.editor.pressKey") + L.newProfile = BAR.I18N("ui.keybinds.editor.newProfile") + L.duplicate = BAR.I18N("ui.keybinds.editor.duplicate") + L.edit = BAR.I18N("ui.keybinds.editor.edit") + L.editTitle = BAR.I18N("ui.keybinds.editor.editTitle") + L.delete = BAR.I18N("ui.keybinds.editor.delete") + L.duplicateTitle = BAR.I18N("ui.keybinds.editor.duplicateTitle") + L.save = BAR.I18N("ui.keybinds.editor.save") + L.reset = BAR.I18N("ui.keybinds.editor.reset") + L.resetConfirm = BAR.I18N("ui.keybinds.editor.resetConfirm") + L.saveTitle = BAR.I18N("ui.keybinds.editor.saveTitle") + L.discard = BAR.I18N("ui.keybinds.editor.discard") + L.unsavedTitle = BAR.I18N("ui.keybinds.editor.unsavedTitle") + L.unsavedMessage = BAR.I18N("ui.keybinds.editor.unsavedMessage") + L.applyFailedTitle = BAR.I18N("ui.keybinds.editor.applyFailedTitle") + L.accept = BAR.I18N("ui.keybinds.editor.accept") + L.cancel = BAR.I18N("ui.keybinds.editor.cancel") +end + +-- Rebuilds the display list from the catalog and the staged binds, honouring both the +-- search box and the category column. +local function rebuildRows() + chipGroups = {} + rowsGen = rowsGen + 1 + if not resolvedCatalog then + buildResolvedCatalog() + end + + rows = {} + + -- A grid category replaces the list outright: its keys only make sense laid out the + -- way the grid menu itself draws them, so there are no rows to build. + gridGroup = nil + if selectedCategory then + for _, group in ipairs(resolvedCatalog) do + if group.category == selectedCategory and group.layout == "grid" then + gridGroup = group + end + end + end + if gridGroup then + clampScroll() + + return + end + local query = searchBox and searchBox:getText():lower() or "" + local catalogActions = {} + local otherGroupEnd + + -- Claim hidden actions up front so they never surface, as a row or under Other. + -- Exact ids only (not prefixes), so a future action can't be hidden by coincidence. + for _, group in ipairs(resolvedCatalog) do + if group.hidden then + for _, h in ipairs(group.hidden) do + catalogActions[h] = true + end + end + end + + for _, group in ipairs(resolvedCatalog) do + -- Non-selected groups are still walked: they have to claim their actions or the + -- leftovers below would sweep them all into Other. + local inCategory = not selectedCategory or group.category == selectedCategory + local categoryMatch = query ~= "" and group.titleLower:find(query, 1, true) + local groupRows = {} + for _, item in ipairs(group.items) do + -- An empty prefix would claim every bound action, so treat it as no prefix. + if item.prefix and item.prefix ~= "" then + -- A declared member is a row whether or not it is bound, so unbinding the last + -- key of "group select 3" leaves the row there to bind again. Families the + -- catalog cannot enumerate (buildunit_ is per unit) list no members and are + -- still discovered from what is bound. + local matched = {} + for _, member in ipairs(item.members or {}) do + local action = item.prefix .. member + -- Skipped when an explicit entry already covers it, or a family whose + -- members are also listed individually renders each of them twice. + if not catalogActions[action] then + catalogActions[action] = true + matched[#matched + 1] = action + end + end + + local found = {} + for action in pairs(working.byAction) do + if not catalogActions[action] and action:sub(1, #item.prefix) == item.prefix then + catalogActions[action] = true + found[#found + 1] = action + end + end + table.sort(found) + for i = 1, #found do + matched[#matched + 1] = found[i] + end + for i = 1, #matched do + local action = matched[i] + local arg = action:sub(#item.prefix + 1) + if item.unit then + local def = UnitDefNames[arg] + if def then + arg = def.translatedHumanName or arg + else + -- Factions gated behind modoptions (Legion, scavengers) aren't in + -- UnitDefNames, but their names live in the units i18n regardless. + local key = "units.names." .. arg + local name = BAR.I18N(key) + if name ~= key then + arg = name + end + end + end + local row, col = arg:match("^%s*(%S+)%s+(%S+)") + local label = item.label and prefixRowLabel(item.label, arg, row, col) or action + if + query == "" + or categoryMatch + or action:lower():find(query, 1, true) + or label:lower():find(query, 1, true) + then + groupRows[#groupRows + 1] = { type = "editable", action = action, label = label } + end + end + -- Skip an action a hidden entry or an earlier prefix already claimed, so a + -- hide stays authoritative and entry order can't produce a duplicate row. + elseif not (item.action and catalogActions[item.action]) then + if item.action then + catalogActions[item.action] = true + end + if + query == "" + or categoryMatch + or item.labelLower:find(query, 1, true) + or (item.actionLower and item.actionLower:find(query, 1, true)) + then + groupRows[#groupRows + 1] = { type = "editable", action = item.action, label = item.label } + end + end + end + + if inCategory and #groupRows > 0 then + rows[#rows + 1] = { type = "header", text = group.title } + if group.layout == "grid" then + -- Its keys only read laid out, so the list points at that view rather than + -- repeating them flat. Still driven by the rows a search matched, so hunting + -- for one of them surfaces the way in. + rows[#rows + 1] = { type = "link", label = L.edit, category = group.category } + else + for i = 1, #groupRows do + rows[#rows + 1] = groupRows[i] + end + end + if group.title == L.other then + otherGroupEnd = #rows + end + end + end + + local otherMatch = query ~= "" and L.otherLower:find(query, 1, true) + local others = {} + for action in pairs(working.byAction) do + if not catalogActions[action] and (query == "" or otherMatch or action:lower():find(query, 1, true)) then + others[#others + 1] = action + end + end + + if #others > 0 and (not selectedCategory or selectedCategory == otherCategoryKey) then + table.sort(others) + + -- A catalog category can be titled the same as this generated one; when it is, + -- the leftovers join it after its own items instead of repeating the header. + local tail = {} + if otherGroupEnd then + for i = otherGroupEnd + 1, #rows do + tail[#tail + 1] = rows[i] + rows[i] = nil + end + else + rows[#rows + 1] = { type = "header", text = L.other } + end + + for _, action in ipairs(others) do + rows[#rows + 1] = { type = "editable", action = action, label = action } + end + for i = 1, #tail do + rows[#rows + 1] = tail[i] + end + end + + clampScroll() +end + +---------------------------------------------------------------- +-- Staging +---------------------------------------------------------------- + +-- Staged-edits flag, read by the picker marker and the footer buttons alike. +local function setDirty(value) + dirty = value +end + +-- Re-reading the engine replaces whatever was staged, so the flag describing those edits +-- goes with them. Without that a language or layout change leaves the flag armed over +-- bindings it no longer describes, and Save writes the engine's keymap back as an edit. +local function seedWorkingFromEngine() + local model = keybindModel.build() + working = { byAction = {}, layout = model.layout, binds = model.binds } + 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 + + setDirty(false) +end + +-- Detached copy of the staged binds, for handing to the store. +local function stagedBinds() + local out = {} + for i, b in ipairs(working.binds) do + out[i] = { keyset = b.keyset, action = b.action } + end + + return out +end + +-- Nothing in the editor rebinds the meta key, so a profile made from another one keeps +-- whatever that had; dropping it would silently change the new profile's modifiers. +local function activeFakeMeta() + local name = profiles.activeName() + local source = profiles.get(name) or profiles.isBuiltin(name) + + return source and source.fakeMeta or nil +end + +-- Which build menu a profile implies. Only the shipped ones imply anything: a profile of +-- the player's own leaves the menu alone, so the settings toggle stays theirs to set. +-- Read from the binds rather than the name, since the grid menu is inert without its +-- gridmenu_* keys and the build menu is what buildunit_* hotkeys drive. +local function wantsGridMenu(name) + local source = profiles.isBuiltin(name) + if not source then + return nil + end + + local buildunit = false + for _, b in ipairs(source.binds or {}) do + local action = b.action:lower() + if action:find("gridmenu_", 1, true) == 1 then + return true + elseif action:find("buildunit_", 1, true) == 1 then + buildunit = true + end + end + + if buildunit then + return false + end + + return nil +end + +-- Points the engine at a profile and brings the rest of the UI in line with it. +local function applyActiveProfile(name, fromName) + Spring.SetConfigString("KeybindingFile", customKeysFile) + if fromName and fromName ~= name then + Spring.Echo("Keybind profile: " .. fromName .. " -> " .. name) + end + if menuToggle then + menuToggle(wantsGridMenu(name)) + end + + if WG["bar_hotkeys"] and WG["bar_hotkeys"].reloadBindings then + WG["bar_hotkeys"].reloadBindings() + else + view.refresh() + end +end + +local function refreshPicker() + buildPresetOptions() + presetDropdown:setOptions(presetOptions) + presetDropdown:setSelected(currentPresetIndex()) +end + +-- Staging changes the picker too: the active profile picks up the unsaved marker. +local function markStaged() + setDirty(true) + refreshPicker() + rebuildRows() +end + +-- Staged edits live only in `working`, so throwing them away means re-reading the engine. +-- Clearing the flag on its own would leave the edits on screen and still saveable. +local function discardStaged() + seedWorkingFromEngine() + refreshPicker() + rebuildRows() +end + +---------------------------------------------------------------- +-- Dialogs +---------------------------------------------------------------- + +-- Raises a modal, taking focus off the search box. +local function openDialog(d) + -- Replacing a live modal outright would drop the rollback its cancel was holding, which + -- is how the picker ends up naming a profile that was never switched to. Run it first, + -- with the slot already empty so the cancel cannot clobber the incoming dialog. + local previous = dialog + dialog = nil + if previous and previous.cancel then + previous.cancel() + end + + -- A modal draws over the capture and takes its keys, so leaving one running behind would + -- drop the player back into it on cancel, seeded from bindings the modal may have changed. + capturing = nil + + dialog = d + searchBox:blur() + if not d.message then + nameBox:setText(d.initial or "") + nameBox:focus() + end +end + +-- Drops the modal, handing it back so the caller can act on it. +local function closeDialog() + local d = dialog + dialog = nil + nameBox:blur() + + return d +end + +local function cancelDialog() + local d = closeDialog() + if d and d.cancel then + d.cancel() + end +end + +-- The optional third button: "Discard" when leaving unsaved edits, "Delete" when +-- editing a profile. +local function middleDialog() + local d = closeDialog() + if d and d.middle then + d.middle.action() + end +end + +-- Confirmation path; only a dialog with a name field has text to read. +local function acceptDialog() + local name = dialog and not dialog.message and nameBox:getText():gsub("^%s+", ""):gsub("%s+$", "") or "" + local d = closeDialog() + if not d then + return + end + + -- An empty name would make the profile unselectable, so treat it as a cancel. + if not d.message and name == "" then + if d.cancel then + d.cancel() + end + return + end + + d.accept(name) +end + +---------------------------------------------------------------- +-- Profile commands +---------------------------------------------------------------- + +-- Makes a profile the live one, leaving its stored binds alone. Answers whether it took. +-- A keymap that never reached disk must not clear the staged flag: the reload below would +-- load whatever file is still there and the player would watch their edits revert. +local function selectProfile(name, fromName) + if not profiles.materialize(name) then + openDialog({ + title = L.applyFailedTitle, + message = BAR.I18N("ui.keybinds.editor.applyFailedMessage", { name = name }), + accept = function() end, + }) + + return false + end + + profiles.setActive(name) + setDirty(false) + refreshPicker() + applyActiveProfile(name, fromName) + + return true +end + +-- Commit point: the staged keymap reaches the engine and the store together. Answers +-- whether it landed, so a caller that closes the panel on the way out does not do so over +-- a save that failed. +local function applyStaged(name, fromName) + local profile = profiles.get(name) + if not profile then + return selectProfile(name, fromName) + end + + -- The store has to hold the new binds before materialize can write them out, so a failed + -- apply is undone rather than avoided. Left as-is, disk would keep edits the editor still + -- reports as unsaved, and Reset would appear to discard something already persisted. + local previous = profile.binds + profile.binds = stagedBinds() + profiles.save() + + if selectProfile(name, fromName) then + return true + end + + profile.binds = previous + profiles.save() + + return false +end + +-- Saving over a shipped profile is a fork: it asks for a name and writes a new one. +local function startSave(andThen, onCancel) + local name = profiles.activeName() + if profiles.get(name) then + if applyStaged(name) and andThen then + andThen() + end + + return + end + + openDialog({ + title = L.saveTitle, + initial = profiles.uniqueName(L.newProfile), + accept = function(newName) + local created = profiles.create(newName, stagedBinds(), activeFakeMeta()) + if applyStaged(created, name) and andThen then + andThen() + end + end, + cancel = onCancel, + }) +end + +-- Staged edits are not in the engine yet, so anything that would replace them asks +-- first. Returns whether it could go ahead immediately. +local function guardDirty(proceed, onCancel) + if not dirty then + proceed() + + return true + end + + openDialog({ + title = L.unsavedTitle, + message = L.unsavedMessage, + acceptLabel = L.save, + save = true, + accept = function() + startSave(proceed, onCancel) + end, + middle = { + label = L.discard, + action = function() + discardStaged() + proceed() + end, + }, + cancel = onCancel, + }) + + return false +end + +switchToPreset = function(opt) + -- The pending entry is already what is on screen; picking it is not a switch. + if opt.pending then + return + end + + guardDirty(function() + -- The picker committed the new name before the guard ran, so a switch that does not + -- happen has to put the selection back. + if not selectProfile(opt.name, profiles.activeName()) then + refreshPicker() + end + end, refreshPicker) +end + +-- Throws staged edits away, back to the active profile as last saved. +local function startReset() + openDialog({ + title = L.reset, + message = L.resetConfirm, + accept = function() + discardStaged() + end, + }) +end + +local function startDuplicate() + local from = profiles.activeName() + openDialog({ + title = L.duplicateTitle, + initial = profiles.uniqueName(from), + accept = function(name) + -- Copies what is on screen rather than what was last saved, so pending + -- edits come along instead of being silently dropped. + applyStaged(profiles.create(name, stagedBinds(), activeFakeMeta()), from) + end, + }) +end + +-- Renaming and deleting share one dialog: the name field commits a rename, the +-- middle button deletes. Deleting asks again, since it cannot be undone. +local function startEdit() + local name = profiles.activeName() + openDialog({ + title = L.editTitle, + initial = name, + accept = function(newName) + profiles.rename(name, newName) + refreshPicker() + end, + middle = { + label = L.delete, + danger = true, + action = function() + openDialog({ + title = L.delete, + message = BAR.I18N("ui.keybinds.editor.deleteConfirm", { name = name }), + acceptLabel = L.delete, + danger = true, + accept = function() + -- Re-seeded rather than just unflagged: clearing the flag alone would + -- leave the deleted profile's edits on screen with Save greyed out. + discardStaged() + profiles.delete(name) + + -- Whatever the store fell back to has to be made live; the deleted profile + -- is still what the engine has loaded. Selected rather than committed: the + -- staged keymap belongs to the profile just deleted. + selectProfile(profiles.activeName(), name) + end, + }) + end, + }, + }) +end + +---------------------------------------------------------------- +-- Layout and geometry +---------------------------------------------------------------- + +-- Builds the controls on first use, the font not existing at include time. +local function ensureControls() + if searchBox and presetDropdown then + return + end + + searchBox = Editbox.new({ placeholder = BAR.I18N("ui.keybinds.editor.search"), onChange = rebuildRows }) + presetDropdown = Dropdown.new({ options = presetOptions, onSelect = switchToPreset }) + nameBox = Editbox.new({ maxChars = 40 }) +end + +-- Buttons size to their own label so a longer translation is not clipped and a short +-- one is not padded out. Layout can run before view.init has a font, so that case +-- falls back to a width and asks draw to lay out again once the font is there. +local function labelWidth(label, size, pad) + if not font then + layoutPending = true + + return floor(110 * scale) + end + + return floor(font:GetTextWidth(label) * size) + pad * 2 +end + +-- Accept wording, needed by the geometry as well as the drawing. +local function acceptLabelFor(d) + return d.acceptLabel or (d.message and L.accept or L.save) +end + +-- Header and footer rects, placed right to left from the panel edge. +local function layoutHeader() + headerH = floor(34 * scale) + footerH = floor(34 * scale) + + if not (searchBox and presetDropdown) then + return + end + + layoutPending = false + + local gap = floor(8 * scale) + local rowTop = area.y2 - floor(4 * scale) + local rowBottom = area.y2 - headerH + floor(4 * scale) + local presetW = floor(240 * scale) + local btnFs = (rowTop - rowBottom) * 0.5 + + -- Right to left: the edit dialog opener, duplicate, then the picker they act on. + local iconW = rowTop - rowBottom + local editW, dupW = iconW, iconW + local editX1 = area.x2 - editW + local dupX1 = editX1 - gap - dupW + local pickerX1 = dupX1 - gap - presetW + + headerButtons[1].rect = { dupX1, rowBottom, dupX1 + dupW, rowTop } + headerButtons[2].rect = { editX1, rowBottom, area.x2, rowTop } + presetDropdown:setRect(pickerX1, rowBottom, pickerX1 + presetW, rowTop, btnFs) + searchBox:setRect(listX1, rowBottom, pickerX1 - gap, rowTop, btnFs) + + local fTop = area.y1 + footerH - floor(4 * scale) + local fBottom = area.y1 + floor(4 * scale) + local fFs = (fTop - fBottom) * 0.5 + local fPad = floor(14 * scale) + local bfs = floor(rowHeight * 0.55) + local x2 = area.x2 + for i = #footerButtons, 1, -1 do + local b = footerButtons[i] + local label = L[b.id] or b.id + local w = labelWidth(label, fFs, fPad) + b.rect = { x2 - w, fBottom, x2, fTop } + x2 = x2 - w - gap + -- Both states of the caption, fitted and coloured here so the draw only picks one. + if font then + local fitted = text.fit(font, label, w - metrics.rowPad * 2, bfs) + b.textOn = colorText .. fitted + b.textOff = colorDim .. fitted + end + end + + -- New rects, so the tooltip areas have to be handed over again. + tooltipsRegistered = false +end + +-- Profile-modal geometry, derived in one place so draw and mousePress agree. +local function dialogGeometry() + local w = floor(315 * scale) + local h = floor(150 * scale) + local messageLines, messageStep + if dialog and dialog.message and font then + messageStep = floor(rowHeight * 0.75) + messageLines = text.wrap(font, dialog.message, w - floor(32 * scale), floor(rowHeight * 0.5)) + h = h + math.max(0, #messageLines - 1) * messageStep + end + local cx = (area.x1 + area.x2) * 0.5 + local cy = (area.y1 + area.y2) * 0.5 + local bx1, bx2 = floor(cx - w * 0.5), floor(cx + w * 0.5) + local by1, by2 = floor(cy - h * 0.5), floor(cy + h * 0.5) + local bh = floor(28 * scale) + local pad = floor(16 * scale) + local btnY1 = by1 + pad + local bfs = bh * 0.5 + local bpad = floor(14 * scale) + + local cancelW = labelWidth(L.cancel, bfs, bpad) + local cancel = { bx1 + pad, btnY1, bx1 + pad + cancelW, btnY1 + bh } + + local okW = labelWidth(dialog and acceptLabelFor(dialog) or L.save, bfs, bpad) + local ok = { bx2 - pad - okW, btnY1, bx2 - pad, btnY1 + bh } + + local midW = labelWidth(dialog and dialog.middle and dialog.middle.label or L.discard, bfs, bpad) + local midX = (bx1 + bx2) * 0.5 + local discard = { floor(midX - midW * 0.5), btnY1, floor(midX + midW * 0.5), btnY1 + bh } + local fieldY1 = btnY1 + bh + floor(20 * scale) + local field = { bx1 + pad, fieldY1, bx2 - pad, fieldY1 + floor(26 * scale) } + + return bx1, by1, bx2, by2, ok, cancel, field, discard, messageLines, messageStep +end + +-- Capture-modal geometry, derived in one place so draw and mousePress agree. +local function captureGeometry() + local w = floor(420 * scale) + local h = floor(200 * scale) + local cx = (area.x1 + area.x2) * 0.5 + local cy = (area.y1 + area.y2) * 0.5 + local bx1, bx2 = floor(cx - w * 0.5), floor(cx + w * 0.5) + local by1, by2 = floor(cy - h * 0.5), floor(cy + h * 0.5) + local bw = floor(120 * scale) + local bh = floor(28 * scale) + local pad = floor(16 * scale) + local btnY1 = by1 + pad + local cancel = { bx1 + pad, btnY1, bx1 + pad + bw, btnY1 + bh } + local ok = { bx2 - pad - bw, btnY1, bx2 - pad, btnY1 + bh } + return bx1, by1, bx2, by2, ok, cancel +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 or the catalog is +-- rebuilt; a no-op until the font exists, and the sidebar asks again once it does. +local function fitCategories() + if not font then + return + end + + local labelW = sidebarW - metrics.sidePad * 2 + local fs = metrics.catRowHeight * 0.55 + for _, c in ipairs(categories) do + local fitted = text.fit(font, c.label, labelW, fs) + c.textSel = colorAction .. fitted + c.textDim = colorDim .. fitted + end +end + +---------------------------------------------------------------- +-- Panel lifecycle +---------------------------------------------------------------- + +-- Picks up the font and the FlowUI entry points, which do not exist at include time. +function view.init() + font = WG["fonts"].getFont() + RectRound = WG.FlowUI.Draw.RectRound + Scroller = WG.FlowUI.Draw.Scroller + UiElement = WG.FlowUI.Draw.Element + Highlight = WG.FlowUI.Draw.SelectHighlight + ensureControls() +end + +-- Re-reads the engine and rebuilds everything shown from it. +function view.refresh() + ensureControls() + seedWorkingFromEngine() + resolvedCatalog = nil + -- Ahead of the picker, which labels its "new profile" entry from L. + buildResolvedCatalog() + fitCategories() + refreshPicker() + layoutHeader() + rebuildRows() +end + +-- Takes the panel rect from the host; every band and column is derived from it. +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(24 * scale) + metrics.catRowHeight = floor(29 * scale) + metrics.rowFs = rowHeight * 0.55 + metrics.rowPad = floor(6 * scale) + metrics.sidePad = floor(12 * scale) + metrics.catInset = floor(4 * scale) + metrics.chipInset = floor(3 * scale) + + -- Rounded like the settings panel's inner elements, which take a share of this too. + local corner = WG.FlowUI.elementCorner + metrics.csPanel = corner + metrics.csButton = corner * 0.8 + metrics.csSmall = corner * 0.66 + + local pad = floor(6 * scale) + + sidebarW = floor(260 * scale) + listX1 = area.x1 + sidebarW + floor(12 * scale) + + layoutHeader() + + listTop = area.y2 - headerH - floor(4 * scale) + listRight = area.x2 - floor(12 * scale) - pad + barX1 = listRight + floor(4 * scale) + keyAreaX1 = listX1 + floor((listRight - listX1) * 0.45) + + -- Shortened here rather than in the draw loop: the column width and the font size are + -- both settled by now, and this runs on a resize where the loop runs every frame. + fitCategories() + + layoutGen = layoutGen + 1 + clampScroll() +end + +-- Panel closing: drop focus, tooltips and any open modal. +function view.blur() + if WG["tooltip"] then + for _, b in ipairs(headerButtons) do + WG["tooltip"].RemoveTooltip(b.tooltipId) + end + end + tooltipsRegistered = false + if panelList then + gl.DeleteList(panelList) + panelList = nil + panelSig = nil + end + if searchBox then + searchBox:blur() + end + if presetDropdown then + presetDropdown:close() + end + if nameBox then + nameBox:blur() + end + capturing = nil + + -- Through cancel rather than dropped: a live modal is holding a rollback, and the picker + -- names a profile that was never switched to until that runs. + cancelDialog() +end + +-- The host calls this before closing; false means a dialog is now asking what to do +-- with staged edits and the close should not happen yet. +function view.confirmClose(proceed) + return guardDirty(proceed) +end + +-- Host hook for swapping the build menu when a profile implies one. +function view.setMenuToggle(fn) + menuToggle = fn +end + +---------------------------------------------------------------- +-- Editing keysets +---------------------------------------------------------------- + +-- Whether the action already carries this binding. Compared canonically rather than by +-- the printed label: the label drops Any+ and resolves scancodes through the layout, so it +-- reports two bindings the engine resolves differently as the same one. exceptRaw skips +-- the keyset being rebound. +local function actionHasKeyset(action, newKeyset, exceptRaw) + local ks = working.byAction[action] + if not ks then + return false + end + local c = keybindModel.canonicalKeyset(newKeyset) + for _, k in ipairs(ks) do + if k.raw ~= exceptRaw and keybindModel.canonicalKeyset(k.raw) == c then + return true + end + end + return false +end + +-- The bind list is kept in engine order because two actions on one keyset are tried +-- in bind order; edits touch it in place rather than rebuilding it. +local function stageAdd(action, raw) + working.binds[#working.binds + 1] = { keyset = raw, action = action } + + local ks = working.byAction[action] + if not ks then + ks = {} + working.byAction[action] = ks + end + ks[#ks + 1] = { raw = raw, display = keybindModel.displayKeyset(raw, working.layout) } +end + +local function stageRemove(action, raw) + for i = #working.binds, 1, -1 do + local b = working.binds[i] + if b.action == action and b.keyset == raw then + table.remove(working.binds, i) + break + end + end + + local ks = working.byAction[action] or {} + for i = #ks, 1, -1 do + if ks[i].raw == raw then + table.remove(ks, i) + break + end + end + -- The entry stays when its last keyset goes. Rows the catalog does not name are derived + -- from this table, so dropping it takes the row with it and there is nothing left to + -- click to bind the action again. +end + +-- Rewrite a binding where it sits. Two actions on one keyset are tried in bind order, +-- so re-adding at the end would hand the other one priority. +local function stageReplace(action, oldRaw, newRaw) + local entry + for _, b in ipairs(working.binds) do + if b.action == action and b.keyset == oldRaw then + entry = b + break + end + end + + if not entry then + return false + end + + entry.keyset = newRaw + + for _, k in ipairs(working.byAction[action] or {}) do + if k.raw == oldRaw then + k.raw = newRaw + k.display = keybindModel.displayKeyset(newRaw, working.layout) + break + end + end + + return true +end + +-- The grid menu answers its category keys whether or not Shift is held, which the engine +-- can only express as two binds. Deriving both from one capture keeps a rebind from +-- leaving the halves on different keys. +-- Built from the captured elements rather than the joined keyset, so Shift lands after any +-- other modifiers the way the engine writes them: Ctrl+K pairs as Ctrl+K and Ctrl+Shift+K. +-- Shift qualifies the first tap only; later taps in a chain are the same in both halves. +local function shiftPairRaws(elems) + local bare, shifted = {}, {} + for i = 1, #elems do + local e = elems[i] + bare[i] = e.mods .. e.sym + shifted[i] = (i == 1) and (e.mods .. "Shift+" .. e.sym) or bare[i] + end + + return { table.concat(bare, ","), table.concat(shifted, ",") } +end + +-- Rewrite every keyset an action carries, reusing the slots it already holds so the +-- rewrite does not disturb bind order. Answers whether anything actually changed. +local function stageSetKeysets(action, raws) + local ks = working.byAction[action] or {} + local existing = {} + for i = 1, #ks do + existing[i] = ks[i].raw + end + + -- Compared as a set: these are all the same action, so which keyset sits in which slot + -- carries no meaning, and a positional check would call a reordered pair a change and + -- then rewrite the slots into the order they already had. + if #existing == #raws then + local wanted = {} + for i = 1, #raws do + wanted[raws[i]] = (wanted[raws[i]] or 0) + 1 + end + for i = 1, #existing do + wanted[existing[i]] = (wanted[existing[i]] or 0) - 1 + end + + local same = true + for _, count in pairs(wanted) do + if count ~= 0 then + same = false + break + end + end + + if same then + return false + end + end + + local shared = (#existing < #raws) and #existing or #raws + for i = 1, shared do + stageReplace(action, existing[i], raws[i]) + end + for i = shared + 1, #existing do + stageRemove(action, existing[i]) + end + for i = shared + 1, #raws do + stageAdd(action, raws[i]) + end + + return true +end + +-- Edit entry point: move a binding, and mark the profile staged. +local function rebindKeyset(action, oldRaw, newKeyset) + -- Accepting the capture unchanged is not an edit. Staging it would arm Save, grow a + -- pending entry in the picker, and raise the unsaved-changes guard over nothing. + if newKeyset == oldRaw then + return + end + + if actionHasKeyset(action, newKeyset, oldRaw) then + stageRemove(action, oldRaw) + elseif not stageReplace(action, oldRaw, newKeyset) then + stageRemove(action, oldRaw) + stageAdd(action, newKeyset) + end + + markStaged() +end + +-- Edit entry point: extra binding for an action, and mark the profile staged. +local function addKeyset(action, newKeyset) + if actionHasKeyset(action, newKeyset) then + return + end + + stageAdd(action, newKeyset) + markStaged() +end + +-- Edit entry point: drop a binding, and mark the profile staged. +local function removeKeyset(action, raw) + -- Exactly the keyset asked for. A chip hands over every raw it stands for, so a paired + -- action loses its pair and nothing else; clearing the action outright would take any + -- other key it happens to carry with it. + stageRemove(action, raw) + markStaged() +end + +-- One key can drive several actions (e.g. backspace = mutesound + edit_backspace), +-- so add the binding without disturbing others on the same keyset. +local function commitCapture(keyset) + ---@type table + local c = capturing + + -- Left open rather than closed on a key the action already carries. Closing with nothing + -- changed is indistinguishable from the editor having dropped the press. + if not c.oldRaw and not catalogShiftPair[c.action] and actionHasKeyset(c.action, keyset) then + return + end + + capturing = nil + + if catalogShiftPair[c.action] then + if stageSetKeysets(c.action, shiftPairRaws(c.elems)) then + markStaged() + end + elseif c.oldRaw then + rebindKeyset(c.action, c.oldRaw, keyset) + -- One chip stood for every binding that read as the same key, so they all move to + -- the new one. Collapsing them costs nothing: they were interchangeable already. + -- Skip the one the rebind already landed on, or this undoes it and leaves the + -- action bound to nothing. + if keyset ~= c.oldRaw then + for i = 2, #(c.oldRaws or {}) do + if c.oldRaws[i] ~= keyset then + removeKeyset(c.action, c.oldRaws[i]) + end + end + end + else + addKeyset(c.action, keyset) + end +end + +---------------------------------------------------------------- +-- Modifiers and key capture +---------------------------------------------------------------- + +local function rawHasAny(raw) + return raw ~= nil and raw:find("[Aa][Nn][Yy]%+") ~= nil +end + +-- Nothing lets a player choose this, so a rebind infers it: the catalog's alwaysModifier +-- flag first, then whatever the action is bound with today. The engine's own stateful +-- commands (CKeyBindings::statefulCommands - drawinmap, the move* family) carry the flag in +-- the catalog rather than a list here, so one place states it and every surface can read it. +local function actionUsesAny(action, oldRaw) + if catalogAny[action] then + return true + end + + for _, prefix in ipairs(catalogAnyPrefixes) do + if action:sub(1, #prefix) == prefix then + return true + end + end + + -- Rebinding one keyset keeps that keyset's own qualifier: another keyset of the same + -- action carrying Any+ says nothing about this one, and inheriting it would silently + -- drop the modifiers the player just pressed. + if oldRaw then + return rawHasAny(oldRaw) + end + + -- Actions the catalog does not list still reach the editor under Other, so fall back + -- to what they are bound with today. + for _, k in ipairs(working.byAction[action] or {}) do + if rawHasAny(k.raw) then + return true + end + end + + return false +end + +-- A press within the timeout extends the sequence; a slower one starts over. +local function appendChain(el) + local c = capturing + if not c then + return + end + + -- The first real press replaces what the modal opened showing. + if c.seeded then + c.seeded = false + c.elems = {} + end + + local now = spGetTimer() + if #c.elems == 0 then + c.elems[1] = el + elseif c.lastPress and spDiffTimers(now, c.lastPress) * 1000 <= c.timeout then + c.elems[#c.elems + 1] = el + else + c.elems = { el } + end + + c.lastPress = now +end + +-- Derived from the one canonical list so a modifier added there is understood here too. +-- modPrefix below emits in the same order, reading the state Spring returns positionally. +local modNames = {} +for i, name in ipairs(keyConfig.modifierOrder) do + modNames[i] = name .. "+" +end + +-- Strip modifiers by name so a "+"-key (e.g. numpad+) survives; the Any+ qualifier is +-- carried on the capture rather than in the element. +local function parseElem(raw) + raw = raw:gsub("[Aa][Nn][Yy]%+", "") + local mods = "" + local stripped = true + while stripped do + stripped = false + for _, m in ipairs(modNames) do + if raw:sub(1, #m):lower() == m:lower() then + mods = mods .. raw:sub(1, #m) + raw = raw:sub(#m + 1) + stripped = true + end + end + end + + return { sym = raw, mods = mods } +end + +local function startCapture(action, label, oldRaws) + -- The grid page rebinds one keyset by name; a row chip hands over every binding it + -- stands for, the first of which is the one being edited. + if type(oldRaws) == "string" then + oldRaws = { oldRaws } + end + local oldRaw = oldRaws and oldRaws[1] or nil + + -- Rebinding seeds the modal with the current binding; the first press clears it. + local pair = catalogShiftPair[action] + local elems = {} + if oldRaw then + for _, part in ipairs(keybindModel.splitChain(oldRaw)) do + local elem = parseElem(part) + if pair then + elem.mods = (elem.mods:gsub("[Ss][Hh][Ii][Ff][Tt]%+", "")) + end + elems[#elems + 1] = elem + end + end + + capturing = { + action = action, + label = label, + oldRaw = oldRaw, + oldRaws = oldRaws, + pair = pair, + elems = elems, + -- Showing the existing binding rather than anything the player has pressed. + seeded = #elems > 0, + pressed = {}, + lastPress = nil, + -- Matches the engine's KeyChainTimeout default; BAR ships a tighter 333ms. + timeout = 750, + any = actionUsesAny(action, oldRaw), + } +end + +local function modPrefix() + -- An action that ignores modifiers can only ever produce Any+, never a + -- contradictory Any+Shift+, so held modifiers are dropped outright. + if capturing and capturing.any then + return "" + end + + local alt, ctrl, meta, shift = spGetModKeyState() + local prefix = "" + if alt then + prefix = prefix .. "Alt+" + end + if ctrl then + prefix = prefix .. "Ctrl+" + end + if meta then + prefix = prefix .. "Meta+" + end + -- A paired action answers held or not held, so Shift is not a modifier the player picks + -- for it: holding it must read as the bare key and get its partner written behind. + if shift and not (capturing and capturing.pair) then + prefix = prefix .. "Shift+" + end + + return prefix +end + +-- Scancode to keyset symbol, refusing modifier keys so they cannot bind alone. +local function pressSym(scanCode) + local sym = scanCode and spGetScanSymbol(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 + + return sym +end + +-- Any+ replaces the held modifiers, so toggling the checkbox re-derives each element. +local function elemRaw(e) + return ((capturing and capturing.any) and "Any+" or e.mods) .. e.sym +end + +-- The captured sequence as one engine keyset string. +local function chainRaw() + local parts = {} + for i = 1, #capturing.elems do + parts[i] = elemRaw(capturing.elems[i]) + end + + return table.concat(parts, ",") +end + +---------------------------------------------------------------- +-- Chip layout and text batching +---------------------------------------------------------------- + +-- Fits a chip to its box, shrinking to a readable floor before it truncates. +local function chipMetrics(display, fs, pad, rightGap, chipArea) + local tw = font:GetTextWidth(display) * fs + if pad + tw + rightGap <= chipArea then + return display, fs, pad + tw + rightGap + end + + -- Keep inner positive, since a tiny share can drive it negative and the fit would then + -- return the string whole instead of truncating. + local inner = math.max(floor(fs), chipArea - pad - rightGap) + local chipFs = math.max(floor(fs * 0.75), floor(fs * inner / math.max(1, tw))) + local disp = text.fit(font, display, inner, chipFs) + + return disp, chipFs, pad + font:GetTextWidth(disp) * chipFs + rightGap +end + +-- Chain tokens over two lines at most; the second truncates rather than a third appearing. +local function wrapChainTwoLines(tokens, sep, maxW, fs) + local line1, i = "", 1 + while i <= #tokens do + local cand = (line1 == "") and tokens[i] or (line1 .. sep .. tokens[i]) + if line1 ~= "" and font:GetTextWidth(cand) * fs > maxW then + break + end + line1, i = cand, i + 1 + end + + if i > #tokens then + return { line1 } + end + + local rest = {} + for j = i, #tokens do + rest[#rest + 1] = tokens[j] + end + local line2 = table.concat(rest, sep) + if font:GetTextWidth(line2) * fs > maxW then + line2 = text.fit(font, line2, maxW, fs) + end + + return { line1 .. sep, line2 } +end + +-- One chip per distinct label rather than per binding. The Any+ qualifier is deliberately +-- never shown, so an action carrying both a plain and an Any+ binding of the same key +-- reads as that key twice; the chip stands for every binding behind it. +local function rowChipGroups(action) + local cached = chipGroups[action] + if cached then + return cached + end + + -- A paired action's two halves are one binding, so they read as one chip showing the bare + -- key. Grouping on the Shift-stripped form is what puts them together; the chip carries + -- both raws, so removing it takes the pair and rebinding moves the pair. + local pair = catalogShiftPair[action] + local groups, byDisplay = {}, {} + for _, k in ipairs(working.byAction[action] or {}) do + local shown = k.display + if pair then + local parts = keybindModel.splitChain(k.raw) + parts[1] = (parts[1]:gsub("[Ss][Hh][Ii][Ff][Tt]%+", "")) + shown = keybindModel.displayKeyset(table.concat(parts, ","), working.layout) + end + + local group = byDisplay[shown] + if not group then + group = { display = shown, raws = {} } + byDisplay[shown] = group + groups[#groups + 1] = group + end + group.raws[#group.raws + 1] = k.raw + end + + chipGroups[action] = groups + + return groups +end + +-- Shares a row's width across its chips so every one stays clickable when they overflow. +local function layoutRowChips(action, fs, pad, rightGap, chipArea, gap) + local groups = rowChipGroups(action) + local n = #groups + local mets = {} + if n == 0 then + return mets, keyAreaX1 + end + + local total = 0 + for i = 1, n do + local disp, cfs, w = chipMetrics(groups[i].display, fs, pad, rightGap, chipArea) + mets[i] = { group = groups[i], disp = disp, fs = cfs, w = w } + total = total + w + (i > 1 and gap or 0) + end + + if total > chipArea then + local share = floor((chipArea - (n - 1) * gap) / n) + for i = 1, n do + local disp, cfs, w = chipMetrics(groups[i].display, fs, pad, rightGap, share) + mets[i] = { group = groups[i], disp = disp, fs = cfs, w = w } + end + end + + local cx = keyAreaX1 + for i = 1, n do + mets[i].x = cx + mets[i].removeX1 = cx + mets[i].w - rightGap + cx = cx + mets[i].w + gap + end + + return mets, cx +end + +-- The chip band for a row: where each chip sits, where "+" starts after them, and the widths +-- both callers need. Drawing and hit testing take it from here rather than each deriving the +-- same eight constants, so the click zones cannot drift from what was painted. +local function rowChipBand(action, fs, pad) + local gap = floor(6 * scale) + local rightGap = pad + floor(fs * 0.9) + local addW = floor(fs + pad * 2) + -- Room reserved on the right so "+" always fits. + local chipArea = listRight - addW - floor(8 * scale) - keyAreaX1 + local mets, cx = layoutRowChips(action, fs, pad, rightGap, chipArea, gap) + + return mets, cx, addW, rightGap +end + +-- Everything drawRow needs that does not move with the mouse: the fitted label and the +-- chip band, each string already carrying its colour code. Built on first use and kept on +-- the row, which rebuildRows replaces outright; a geometry change bumps layoutGen so a +-- row laid out against the old widths is measured again. +local function rowLayout(row) + local lay = row.layout + if lay and lay.gen == layoutGen then + return lay + end + + lay = { gen = layoutGen } + if row.type == "header" then + lay.text = colorHeader .. row.text + elseif row.type == "link" then + lay.text = colorAction .. row.label + lay.arrow = look.arrow + lay.arrowX = listX1 + metrics.rowPad * 5 + floor(font:GetTextWidth(row.label) * metrics.rowFs) + metrics.rowPad * 2 + else + lay.text = colorAction .. text.fit(font, row.label, keyAreaX1 - (listX1 + metrics.rowPad) - metrics.rowPad, metrics.rowFs) + local mets, cx, addW, rightGap = rowChipBand(row.action, metrics.rowFs, metrics.rowPad) + for i = 1, #mets do + local m = mets[i] + m.textKey = colorKey .. m.disp + m.textHover = colorText .. m.disp + m.removeCx = m.removeX1 + rightGap * 0.5 + end + lay.mets = mets + lay.cx = cx + lay.addW = addW + -- A paired action holds one key expressed as two binds, so once it has one there is + -- no second to add: capturing again rewrites the pair, and "+" would read as "add + -- another" while silently replacing it. With nothing bound it is the only way in. + lay.showAdd = not (catalogShiftPair[row.action] and #mets > 0) + end + row.layout = lay + + return lay +end + +-- Which zone of an editable row sits under x: a chip body, its remove mark, or "+", with +-- the chip's index. y is checked against the chip band when given, so hover matches what +-- is painted; a click passes nil and takes the whole row height. +local function rowZone(lay, x, y, c1, c2) + if y and (y < c1 or y > c2) then + return nil + end + + local mets = lay.mets + for i = 1, #mets do + local m = mets[i] + if x >= m.x and x < m.removeX1 then + return "rebind", i + elseif x >= m.removeX1 and x <= m.x + m.w then + return "remove", i + end + end + + if lay.showAdd and x >= lay.cx and x <= lay.cx + lay.addW then + return "add" + end + + return nil +end + +-- 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; the modals draw every shape +-- before any text and so print directly. Held flat and refilled in place, since a table +-- per string per frame is hundreds of allocations a second. +local pendingText = {} +local pendingCount = 0 + +local function queueText(str, x, y, size, opts) + local at = pendingCount * 5 + pendingText[at + 1] = str + pendingText[at + 2] = x + pendingText[at + 3] = y + pendingText[at + 4] = size + pendingText[at + 5] = opts + pendingCount = pendingCount + 1 +end + +local function flushText() + if pendingCount == 0 then + return + end + + font:Begin() + for i = 0, pendingCount - 1 do + local at = i * 5 + font:Print( + pendingText[at + 1], + pendingText[at + 2], + pendingText[at + 3], + pendingText[at + 4], + pendingText[at + 5] + ) + end + font:End() + + pendingCount = 0 +end + +---------------------------------------------------------------- +-- Drawing +---------------------------------------------------------------- + +-- Rows are laid out from the top of the list band down, so the column lines up with the +-- keybind rows beside it. +local function categoryRect(i) + local top = listTop - (i - 1) * metrics.catRowHeight + + return area.x1, top - metrics.catRowHeight, area.x1 + 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) + if x < area.x1 or x > area.x1 + sidebarW or y > listTop or y <= listBottom() then + return nil + end + + local i = floor((listTop - 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 + +-- The grid menu is 3x4 with row 1 along the bottom, matching the keyboard rows it is +-- bound to (ZXCV under ASDF under QWER) and the order gui_gridmenu draws them in. +local gridRows, gridCols = 3, 4 + +-- The ids never change, so they are built once rather than concatenated per cell per frame. +local gridKeyActions, gridCategoryActions = {}, {} +for row = 1, gridRows do + gridKeyActions[row] = {} + for col = 1, gridCols do + gridKeyActions[row][col] = "gridmenu_key " .. row .. " " .. col + end +end +for c = 1, gridCols do + gridCategoryActions[c] = "gridmenu_category " .. c +end + +-- Two grids side by side: the build grid as it opens, and the same grid once a category +-- is picked, which is where Back and Next page live. Sized to roughly what the menu +-- occupies in game - 0.2125 of screen width over four columns - rather than stretched. +local function gridGeometry() + local headH = rowHeight + local top = listTop - headH - floor(8 * scale) + local bottom = listBottom() + floor(8 * scale) + local strip = floor(rowHeight * 1.2) + local gap = floor(4 * scale) + local blockGap = floor(28 * scale) + + local availH = (top - bottom) - (strip + gap) * 2 + local availW = (listRight - listX1) - blockGap + local cell = math.min(floor(availH / gridRows), floor(availW / (gridCols * 2)), floor(100 * scale)) + if cell < 1 then + cell = 1 + end + + local blockW = cell * gridCols + local blockH = cell * gridRows + (strip + gap) * 2 + -- Pinned to the top left of the list band, like the rows it replaces, rather than + -- floating in the middle of it. + local x1 = listX1 + local stripY = top - blockH + local gridBottom = stripY + strip + gap + + return x1, x1 + blockW + blockGap, gridBottom, cell, strip, gap, stripY, gridBottom + cell * gridRows + gap, headH +end + +-- Cell rect for a grid position. Row 1 is the bottom row, so it is laid out upward. +local function gridCellRect(row, col, x1, gridBottom, cell) + local cx = x1 + (col - 1) * cell + local cy = gridBottom + (row - 1) * cell + + return cx, cy, cx + cell, cy + cell +end + +local function drawButtonFace(r, base) + RectRound(r[1], r[2], r[3], r[4], metrics.csButton, 1, 1, 1, 1, base, base) + RectRound(r[1], r[2], r[3], (r[2] + r[4]) * 0.5, metrics.csButton, 0, 0, 1, 1, sheenTop, sheenNone) +end + +-- The category column: its own card under the title, then one entry per category, with +-- hoverIdx the entry under the cursor. +local function drawSidebar(hoverIdx) + RectRound(area.x1, area.y1, area.x1 + sidebarW, area.y2 - floor(33 * scale), metrics.csPanel, 1, 1, 1, 1, look.sidebarFill, look.sidebarFillTop) + queueText(L.titleText, area.x1 + metrics.sidePad, area.y2 - floor(17 * scale), floor(rowHeight * 0.85), "ov") + + -- Laid out before the font existed, so the labels are still waiting to be fitted. + if categories[1] and not categories[1].textDim then + fitCategories() + end + + local fs = metrics.catRowHeight * 0.55 + local lb = listBottom() + for i, c in ipairs(categories) do + local x1, y1, x2, y2 = categoryRect(i) + if y1 >= lb then + local selected = selectedCategory == c.key + if selected then + RectRound(x1 + metrics.catInset, y1, x2 - metrics.catInset, y2, metrics.csSmall, 1, 1, 1, 1, look.selectedFill) + elseif i == hoverIdx then + Highlight(x1 + metrics.catInset, y1, x2 - metrics.catInset, y2, metrics.csSmall, look.rowHoverOpacity, look.white) + end + queueText((selected and c.textSel or c.textDim) or c.label, x1 + metrics.sidePad, (y1 + y2) * 0.5, fs, "ov") + end + end +end + +-- Label left, key right, sized like a category button. Used for every pill in this view. +local function drawGridPill(x1, y1, x2, y2, label, key, fs, pad, hovered, dim) + RectRound(x1, y1, x2, y2, metrics.csSmall, 1, 1, 1, 1, pillFill, pillFill) + if hovered and not dim then + Highlight(x1, y1, x2, y2, metrics.csSmall, look.rowHoverOpacity, look.white) + end + -- Key is right aligned and gets only the width it needs, so the label keeps the rest + -- and stays readable. + local keyW = floor(font:GetTextWidth(key) * fs) + queueText( + (dim and colorDim or colorAction) .. text.fit(font, label, (x2 - x1) - keyW - pad * 5, fs), + x1 + pad * 2, + (y1 + y2) * 0.5, + fs, + "ov" + ) + queueText((dim and colorDim or colorKey) .. key, x2 - pad * 2, (y1 + y2) * 0.5, fs, "rov") +end + +-- The raw keyset a grid action carries, for seeding a rebind. nil when it has none. +local function gridKeyRaw(action) + local ks = working.byAction[action] + + return ks and ks[1] and ks[1].raw or nil +end + +-- The key a grid action currently carries, blank when it has none. +local function gridKeyText(action) + local ks = working.byAction[action] + + return ks and ks[1] and ks[1].display or "" +end + +-- Sized to its own label and key, so a longer translation widens it rather than being +-- clipped. +local function gridCycleRect(x1, bsize) + local fs = floor(bsize * 0.45) + local pad = floor(3 * scale) + -- Inset to match the cells below, which sit a pad in from the block edge. + local cx1 = x1 + pad + local need = floor( + (font:GetTextWidth(gridGroup.cycleLabel) + font:GetTextWidth(gridKeyText("gridmenu_cycle_builder"))) * fs + ) + pad * 10 + + return cx1, cx1 + math.max(need, floor(bsize * 2)) +end + +-- Which grid element sits under x,y: a build cell (row, col), a category pill (index), +-- Next page or the cycle-builder pill. Hover, the baked panel's signature and clicks all +-- read it, so the three cannot disagree. Back is left out on purpose: gui_gridmenu +-- hardcodes its key, so there is nothing to rebind and nothing to light up. +local function gridZone(x, y) + local x1, x2, gridBottom, cell, strip, _, stripY, builderY = gridGeometry() + local pad = floor(3 * scale) + + for row = 1, gridRows do + for col = 1, gridCols do + local cx1, cy1, cx2, cy2 = gridCellRect(row, col, x1, gridBottom, cell) + if isInRect(x, y, cx1 + pad, cy1 + pad, cx2 - pad, cy2 - pad) then + return "cell", row, col + end + end + end + + if y >= stripY and y <= stripY + strip then + for c = 1, gridCols do + local cx1 = x1 + (c - 1) * cell + if x >= cx1 + pad and x <= cx1 + cell - pad then + return "category", c + end + end + + local third = floor(cell * gridCols / 3) + if x >= x2 + gridCols * cell - third and x <= x2 + gridCols * cell - pad then + return "next" + end + end + + local ccx1, ccx2 = gridCycleRect(x1, strip) + if isInRect(x, y, ccx1, builderY, ccx2, builderY + strip) then + return "cycle" + end + + return nil +end + +-- Draws the grid menu as it sits on screen. Cells stay empty: what fills them in game +-- comes from the selected builder, which the editor has no notion of. zone (with its +-- two arguments) is what gridZone found under the cursor, and is what lights up. +local function drawGridMenu(zone, zoneA, zoneB) + local x1, x2, gridBottom, cell, strip, _, stripY, builderY, headH = gridGeometry() + local pad = floor(3 * scale) + local keyFs = floor(cell * 0.2) + local stripFs = floor(strip * 0.45) + + -- Same header the list puts above a category, so the two views read alike. + RectRound(listX1, listTop - headH, listRight, listTop, 0, 0, 0, 0, 0, sheenTop, sheenTop) + queueText(colorHeader .. gridGroup.title, listX1 + metrics.rowPad, listTop - headH * 0.5, metrics.rowFs * 0.95, "ov") + + for pass = 1, 2 do + local gx = (pass == 1) and x1 or x2 + for row = 1, gridRows do + for col = 1, gridCols do + local cx1, cy1, cx2, cy2 = gridCellRect(row, col, gx, gridBottom, cell) + -- Solid enough to read against the panel on its own, so the cells need no + -- container or outline behind them. + RectRound(cx1 + pad, cy1 + pad, cx2 - pad, cy2 - pad, metrics.csSmall, 1, 1, 1, 1, pillFill, pillFill) + -- Only the first grid carries the build keys; the second is the category view, + -- whose cells hold the same bindings and would just repeat them. + if pass == 1 then + if zone == "cell" and zoneA == row and zoneB == col then + Highlight(cx1 + pad, cy1 + pad, cx2 - pad, cy2 - pad, metrics.csSmall, look.rowHoverOpacity, look.white) + end + queueText( + colorKey .. gridKeyText(gridKeyActions[row][col]), + cx2 - pad * 3, + cy2 - pad * 2 - keyFs, + keyFs, + "ro" + ) + end + end + end + end + + for c = 1, gridCols do + local cx1 = x1 + (c - 1) * cell + drawGridPill( + cx1 + pad, + stripY, + cx1 + cell - pad, + stripY + strip, + gridGroup.categoryLabels[c] or "", + gridKeyText(gridCategoryActions[c]), + stripFs, + pad, + zone == "category" and zoneA == c + ) + end + + -- Second grid is the view after a category is picked: Back on the left, Next page on + -- the right, matching how gui_gridmenu splits that strip into thirds. + local third = floor(cell * gridCols / 3) + drawGridPill( + x2 + pad, + stripY, + x2 + third, + stripY + strip, + L.gridBack, + keybindModel.displayKeyset("shift", working.layout), + stripFs, + pad, + false, + true + ) + drawGridPill( + x2 + gridCols * cell - third, + stripY, + x2 + gridCols * cell - pad, + stripY + strip, + L.gridNextPage, + gridKeyText("gridmenu_next_page"), + stripFs, + pad, + zone == "next" + ) + + local ccx1, ccx2 = gridCycleRect(x1, strip) + drawGridPill( + ccx1, + builderY, + ccx2, + builderY + strip, + gridGroup.cycleLabel, + gridKeyText("gridmenu_cycle_builder"), + stripFs, + pad, + zone == "cycle" + ) +end + +-- Routes a click in the grid view to the action that cell or button binds. +local function gridPress(x, y) + local zone, a, b = gridZone(x, y) + + if zone == "cell" then + local action = gridKeyActions[a][b] + startCapture(action, gridGroup.cellLabel(a, b), gridKeyRaw(action)) + elseif zone == "category" then + local action = gridCategoryActions[a] + startCapture(action, gridGroup.categoryLabels[a], gridKeyRaw(action)) + elseif zone == "next" then + startCapture("gridmenu_next_page", L.gridNextPage, gridKeyRaw("gridmenu_next_page")) + elseif zone == "cycle" then + startCapture("gridmenu_cycle_builder", gridGroup.cycleLabel, gridKeyRaw("gridmenu_cycle_builder")) + end + + return true +end + +-- One list row. hovered says the cursor is on it; zone and zoneIdx are then which chip +-- or button of it, in rowZone's terms. +local function drawRow(row, top, bottom, hovered, zone, zoneIdx) + local cyc = (top + bottom) * 0.5 + local lay = rowLayout(row) + local fs = metrics.rowFs + + if row.type == "header" then + RectRound(listX1, bottom, listRight, top, 0, 0, 0, 0, 0, sheenTop, sheenTop) + queueText(lay.text, listX1 + metrics.rowPad, cyc, fs * 0.95, "ov") + return + end + + if hovered then + Highlight(listX1, bottom, listRight, top, metrics.csSmall, look.rowHoverOpacity, look.white) + end + + -- Indented and followed by an arrow, to read as a way through rather than a binding. + if row.type == "link" then + queueText(lay.text, listX1 + metrics.rowPad * 5, cyc, fs, "ov") + queueText(lay.arrow, lay.arrowX, cyc, fs, "ov") + return + end + + queueText(lay.text, listX1 + metrics.rowPad, cyc, fs, "ov") + + local c1, c2 = bottom + metrics.chipInset, top - metrics.chipInset + local mets = lay.mets + for i = 1, #mets do + local m = mets[i] + local overBody = zone == "rebind" and zoneIdx == i + local overRemove = zone == "remove" and zoneIdx == i + RectRound(m.x, c1, m.x + m.w, c2, metrics.csSmall, 1, 1, 1, 1, overBody and look.chipFillHover or look.chipFill) + queueText(overBody and m.textHover or m.textKey, m.x + metrics.rowPad, cyc, m.fs, "ov") + queueText(overRemove and look.removeHot or look.removeCold, m.removeCx, cyc, fs, "cov") + end + + if lay.showAdd then + local cx = lay.cx + RectRound(cx, c1, cx + lay.addW, c2, metrics.csSmall, 1, 1, 1, 1, zone == "add" and look.addFillHover or look.addFill) + queueText(look.plusText, (cx + cx + lay.addW) * 0.5, cyc, fs, "cov") + end +end + +-- Split out of view.draw: each modal is self-contained, and one function holding every +-- draw path ran past the 60-upvalue ceiling. +local function drawCaptureModal(mx, my) + local bx1, by1, bx2, by2, ok, cancel = captureGeometry() + local cs = metrics.csButton + local cx = (bx1 + bx2) * 0.5 + + RectRound(area.x1, area.y1, area.x2, area.y2, 0, 0, 0, 0, 0, { 0, 0, 0, 0.55 }) + UiElement(bx1, by1, bx2, by2, 1, 1, 1, 1, 1, 1, 1, 1, WG.FlowUI.clampedOpacity) + + drawButtonFace(cancel, buttonFill) + drawButtonFace(ok, buttonFill) + if isInRect(mx, my, cancel[1], cancel[2], cancel[3], cancel[4]) then + Highlight(cancel[1], cancel[2], cancel[3], cancel[4], cs, hoverOpacity, { 1, 1, 1 }) + end + if isInRect(mx, my, ok[1], ok[2], ok[3], ok[4]) then + Highlight(ok[1], ok[2], ok[3], ok[4], cs, hoverOpacity, { 1, 1, 1 }) + end + + local tfs = floor(rowHeight * 0.6) + local sfs = floor(rowHeight * 0.5) + local bigfs = floor(rowHeight * 0.95) + -- Preview held modifiers while forming the first element, through the same formatter a + -- finished keyset uses so the two do not render differently. A capture opened on an + -- existing binding still shows it, but a held modifier previews over the top - the player + -- is part-way through a replacement - and letting go puts the original back. + local heldRaw = modPrefix() + local held = heldRaw ~= "" and keybindModel.displayKeyset(heldRaw, working.layout) or "" + local hasChain = #capturing.elems > 0 and not (capturing.seeded and held ~= "") + local chainStr + if hasChain then + chainStr = keybindModel.displayKeyset(chainRaw(), working.layout) + elseif held ~= "" then + chainStr = held .. "_" + else + chainStr = L.pressKey + end + local hasContent = hasChain or held ~= "" + + -- Shrink toward a readable floor, then wrap to a second line, then ellipsize. + local chainMaxW = (bx2 - bx1) - floor(32 * scale) + local minFs = floor(rowHeight * 0.5) + local chainLines, chainFs = { chainStr }, bigfs + if hasChain then + local naturalW = font:GetTextWidth(chainStr) * bigfs + if naturalW <= chainMaxW then + chainLines, chainFs = { chainStr }, bigfs + elseif floor(bigfs * chainMaxW / naturalW) >= minFs then + chainLines, chainFs = { chainStr }, floor(bigfs * chainMaxW / naturalW) + else + local tokens = {} + for _, e in ipairs(capturing.elems) do + tokens[#tokens + 1] = keybindModel.displayKeyset(elemRaw(e), working.layout) + end + chainLines, chainFs = wrapChainTwoLines(tokens, keybindModel.chainSep, chainMaxW, minFs), minFs + end + else + local w = font:GetTextWidth(chainStr) * bigfs + if w > chainMaxW then + chainFs = math.max(minFs, floor(bigfs * chainMaxW / w)) + end + end + + -- Bar draining over the chain window: time left to extend before it resets. The + -- track is always drawn so the modal does not gain a row the moment a key lands. + local barW = floor((bx2 - bx1) * 0.5) + local barX = cx - barW * 0.5 + local barY = by1 + floor(88 * scale) + local barH = floor(4 * scale) + RectRound(barX, barY, barX + barW, barY + barH, floor(2 * scale), 1, 1, 1, 1, { 1, 1, 1, 0.1 }) + if hasChain and capturing.lastPress then + local frac = 1 - (spDiffTimers(spGetTimer(), capturing.lastPress) * 1000) / capturing.timeout + if frac < 0 then + frac = 0 + end + if frac > 0 then + RectRound( + barX, + barY, + barX + floor(barW * frac), + barY + barH, + floor(2 * scale), + 1, + 1, + 1, + 1, + { 0.9, 0.7, 0.2, 0.9 } + ) + end + end + + local chainCy = by1 + floor(122 * scale) + local lineStep = floor(chainFs * 1.15) + + font:Begin() + font:Print( + colorText .. text.fit(font, capturing.label or capturing.action, chainMaxW, tfs), + cx, + by2 - floor(26 * scale), + tfs, + "cov" + ) + for li = 1, #chainLines do + local ly = chainCy + (#chainLines - 1) * lineStep * 0.5 - (li - 1) * lineStep + font:Print((hasContent and colorKey or colorDim) .. chainLines[li], cx, ly, chainFs, "cov") + end + font:Print(colorText .. L.cancel, (cancel[1] + cancel[3]) * 0.5, (cancel[2] + cancel[4]) * 0.5, sfs, "cov") + font:Print( + (hasChain and colorText or colorDim) .. L.accept, + (ok[1] + ok[3]) * 0.5, + (ok[2] + ok[4]) * 0.5, + sfs, + "cov" + ) + font:End() +end + +local function drawProfileDialog(mx, my) + local bx1, by1, bx2, by2, ok, cancel, field, discard, messageLines, messageStep = dialogGeometry() + local cs = metrics.csButton + local cx = (bx1 + bx2) * 0.5 + local tfs = floor(rowHeight * 0.6) + local sfs = floor(rowHeight * 0.5) + + RectRound(area.x1, area.y1, area.x2, area.y2, 0, 0, 0, 0, 0, { 0, 0, 0, 0.55 }) + UiElement(bx1, by1, bx2, by2, 1, 1, 1, 1, 1, 1, 1, 1, WG.FlowUI.clampedOpacity) + + -- Anything whose accept saves is green, anything destructive is red, wherever it + -- appears; a tinted button brightens on hover instead of taking the white overlay. + local acceptSaves = dialog.save or (not dialog.message and not dialog.danger) + local buttons = { + { r = cancel }, + { r = ok, danger = dialog.danger, confirm = acceptSaves }, + } + if dialog.middle then + buttons[#buttons + 1] = { r = discard, danger = dialog.middle.danger } + end + for _, b in ipairs(buttons) do + local r = b.r + local hovered = isInRect(mx, my, r[1], r[2], r[3], r[4]) + local base = (b.danger and dangerFill) or (b.confirm and confirmFill) + local lift = (b.danger and dangerFillHover) or (b.confirm and confirmFillHover) + local fill = base and (hovered and lift or base) + drawButtonFace(r, fill or buttonFill) + if not fill and hovered then + Highlight(r[1], r[2], r[3], r[4], cs, hoverOpacity, { 1, 1, 1 }) + end + end + + font:Begin() + font:Print( + colorText .. text.fit(font, dialog.title, bx2 - bx1 - floor(32 * scale), tfs), + cx, + by2 - floor(26 * scale), + tfs, + "cov" + ) + if dialog.middle then + font:Print( + colorText .. dialog.middle.label, + (discard[1] + discard[3]) * 0.5, + (discard[2] + discard[4]) * 0.5, + sfs, + "cov" + ) + end + if messageLines then + local top = (field[2] + field[4]) * 0.5 + (#messageLines - 1) * messageStep * 0.5 + for i = 1, #messageLines do + font:Print( + colorDim .. text.fit(font, messageLines[i], bx2 - bx1 - floor(32 * scale), sfs), + cx, + top - (i - 1) * messageStep, + sfs, + "cov" + ) + end + end + font:Print(colorText .. L.cancel, (cancel[1] + cancel[3]) * 0.5, (cancel[2] + cancel[4]) * 0.5, sfs, "cov") + font:Print(colorText .. acceptLabelFor(dialog), (ok[1] + ok[3]) * 0.5, (ok[2] + ok[4]) * 0.5, sfs, "cov") + font:End() + + if not dialog.message then + nameBox:setRect(field[1], field[2], field[3], field[4], sfs) + nameBox:draw() + end +end + +-- Header and footer buttons, with hotId the one under the cursor. +local function drawButtons(hotId) + local bfs = floor(rowHeight * 0.55) + for _, set in ipairs(buttonSets) do + for _, b in ipairs(set) do + local r = b.rect + if r then + local enabled = buttonEnabled(b.id) + local hovered = enabled and hotId == b.id + -- A tinted button loses its colour under the usual white hover overlay, so it + -- brightens its own fill instead. + local fill = b.fill and ((not enabled and b.fillMuted) or (hovered and b.fillHover) or b.fill) + -- Drawn here rather than through Draw.Button: that caches each distinct button + -- into a display list compiled mid-frame on a budget, and the immediate and + -- replayed forms do not match, so a button sized to its own label visibly + -- alternates between them. + drawButtonFace(r, fill or buttonFill) + + if b.icon then + -- Square inset so the 64x64 art keeps its aspect inside a wider button. + -- Hover only lifts the tint, matching the search box and picker, which + -- carry no hover treatment of their own. + local inset = floor((r[4] - r[2]) * 0.22) + local side = (r[4] - r[2]) - inset * 2 + local ix = floor((r[1] + r[3] - side) * 0.5) + local iy = r[2] + inset + local shade = (not enabled and 0.4) or (hovered and 1 or 0.82) + -- Set explicitly: the icons are white-on-transparent, and whatever drew + -- before could leave a blend mode that renders them as solid squares. + glBlending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) + glColor(shade, shade, shade, 1) + glTexture(b.icon) + glTexRect(ix, iy, ix + side, iy + side) + glTexture(false) + glColor(1, 1, 1, 1) + else + if hovered and not fill then + Highlight(r[1], r[2], r[3], r[4], metrics.csButton, hoverOpacity, look.white) + end + queueText( + (enabled and b.textOn or b.textOff) or L[b.id], + (r[1] + r[3]) * 0.5, + (r[2] + r[4]) * 0.5, + bfs, + "cov" + ) + end + end + end + end +end + +-- What the cursor is over, in the terms the panel paints hover with. Refilled in place +-- each frame rather than allocated. +local hover = { sb = 0, row = 0, zone = "", idx = 0, gk = "", ga = 0, gb = 0, btn = "" } + +-- 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) + local h = hover + h.sb = sidebarIndexAt(mx, my) or 0 + h.row, h.zone, h.idx = 0, "", 0 + h.gk, h.ga, h.gb = "", 0, 0 + h.btn = "" + + if gridGroup then + if isInRect(mx, my, listX1, listBottom(), area.x2, listTop) then + local kind, a, b = gridZone(mx, my) + h.gk, h.ga, h.gb = kind or "", a or 0, b or 0 + end + elseif mx >= listX1 and mx <= listRight and my <= listTop and my > listBottom() then + -- Half-open on the shared edge: rows stack, so one row's top is the next one's + -- bottom and a closed test would put the cursor in both. + local r = floor((listTop - my) / rowHeight) + 1 + local row = r <= visibleRows() and rows[scroll + r] or nil + if row then + h.row = r + if row.type == "editable" then + local top = listTop - (r - 1) * rowHeight + local zone, idx = rowZone(rowLayout(row), mx, my, top - rowHeight + metrics.chipInset, top - metrics.chipInset) + h.zone, h.idx = zone or "", idx or 0 + end + end + end + + for _, set in ipairs(buttonSets) do + for _, b in ipairs(set) do + local r = b.rect + if r and isInRect(mx, my, r[1], r[2], r[3], r[4]) then + h.btn = b.id + end + end + end + + -- What the buttons read their enabled state from, alongside the hover and the list. + return h.sb + .. "|" + .. h.row + .. "|" + .. h.zone + .. "|" + .. h.idx + .. "|" + .. h.gk + .. "|" + .. h.ga + .. "|" + .. h.gb + .. "|" + .. h.btn + .. "|" + .. scroll + .. "|" + .. rowsGen + .. "|" + .. layoutGen + .. "|" + .. (dirty and 1 or 0) + .. "|" + .. (activeIsOwn() and 1 or 0) +end + +-- Everything under the header controls and above the modals: the sidebar, the list or +-- grid, the scroller and the buttons. Compiled into the panel display list, so an idle +-- frame replays it for one call instead of a few hundred draws. +local function drawPanel() + local h = hover + drawSidebar(h.sb) + + if gridGroup then + drawGridMenu(h.gk, h.ga, h.gb) + flushText() + else + local rowCount = visibleRows() + for r = 1, rowCount do + local row = rows[scroll + r] + if not row then + break + end + local top = listTop - (r - 1) * rowHeight + local hovered = h.row == r + drawRow(row, top, top - rowHeight, hovered, hovered and h.zone or "", h.idx) + end + flushText() + + Scroller(barX1, listBottom(), area.x2, listTop, #rows * rowHeight, scroll * rowHeight) + end + + drawButtons(h.btn) + flushText() +end + +-- The tooltip widget owns the hover delay and only draws once the cursor settles; it +-- keeps the area table, so this is redone whenever layoutHeader makes new rects. +local function registerTooltips() + for _, b in ipairs(headerButtons) do + if b.rect then + WG["tooltip"].AddTooltip(b.tooltipId, b.rect, L[b.id]) + end + end + tooltipsRegistered = true +end + +-- Paints the whole panel. The header controls and the modals draw live; the body is +-- replayed from its display list until panelSignature says something in it moved. +function view.draw() + if not font then + view.init() + end + if not working then + view.refresh() + end + if layoutPending then + layoutHeader() + end + + -- Pinned rather than assumed: widgets on lower layers draw first and leave blending, + -- colour and depth wherever they finished, which changes how everything below + -- composites from one frame to the next. + glTexture(false) + glColor(1, 1, 1, 1) + glBlending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) + gl.DepthTest(false) + + local rawMx, rawMy, lmb = spGetMouseState() + if dragging then + if lmb then + scrollFromY(rawMy) + else + dragging = false + end + end + + -- Prevent the hover over preset options and modals from also being detected by the + -- regular rows, sidebar and buttons sitting underneath them. + local mx, my = rawMx, rawMy + if dialog or capturing or presetDropdown:isOpen() then + mx, my = -1, -1 + end + + local sig = panelSignature(mx, my) + if sig ~= panelSig then + if panelList then + gl.DeleteList(panelList) + end + panelList = gl.CreateList(drawPanel) + panelSig = sig + end + gl.CallList(panelList) + + searchBox:draw() + + if not tooltipsRegistered and WG["tooltip"] then + registerTooltips() + end + + presetDropdown:draw() + + -- Real cursor: these are the overlay, so the hover is theirs to detect. + if capturing then + drawCaptureModal(rawMx, rawMy) + end + + if dialog then + drawProfileDialog(rawMx, rawMy) + 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 + +---------------------------------------------------------------- +-- Input +---------------------------------------------------------------- + +-- Scrolls the list; a modal swallows the wheel instead. +function view.mouseWheel(up, value) + if dialog or capturing or gridGroup then + return + end + + local _, my = spGetMouseState() + if my >= listBottom() and my <= listTop then + scroll = scroll + (up and -3 or 3) + clampScroll() + end +end + +-- Which zone of an editable row a click hit, through the same layout drawRow painted. +local function hitTestRow(row, x) + local lay = rowLayout(row) + local zone, i = rowZone(lay, x) + if i then + return zone, lay.mets[i].group.raws + end + + return zone +end + +-- Returns true when the click landed in the column, selected or not, so it never falls +-- through to the list behind it. +local function sidebarPress(x, y) + if x < area.x1 or x > area.x1 + sidebarW or y < listBottom() or y > listTop then + return false + end + + local i = sidebarIndexAt(x, y) + local c = i and categories[i] + if c and selectedCategory ~= c.key then + selectedCategory = c.key + scroll = 0 + rebuildRows() + end + + return true +end + +-- Routes a click on a keybind row to the edit it implies. +local function handleZone(kind, action, label, raws) + if kind == "remove" then + for _, raw in ipairs(raws) do + removeKeyset(action, raw) + end + elseif kind == "add" then + startCapture(action, label) + elseif kind == "rebind" then + startCapture(action, label, raws) + end +end + +-- Routes a click to whichever layer is on top: modal, dropdown, sidebar, then the list. +function view.mousePress(x, y, button) + if not isInRect(x, y, area.x1, area.y1, area.x2, area.y2) then + return false + end + + if dialog then + if button == 1 then + local bx1, by1, bx2, by2, ok, cancel, field, discard = dialogGeometry() + if isInRect(x, y, ok[1], ok[2], ok[3], ok[4]) then + acceptDialog() + elseif dialog.middle and isInRect(x, y, discard[1], discard[2], discard[3], discard[4]) then + middleDialog() + elseif + (isInRect(x, y, cancel[1], cancel[2], cancel[3], cancel[4])) + or x < bx1 + or x > bx2 + or y < by1 + or y > by2 + then + cancelDialog() + elseif not dialog.message then + nameBox:mousePress(x, y) + end + end + + return true + end + + -- In the modal, mouse1 drives its controls; only side buttons (mouse4+) bind. + if capturing then + local bx1, by1, bx2, by2, ok, cancel = captureGeometry() + if button == 1 then + if isInRect(x, y, ok[1], ok[2], ok[3], ok[4]) then + if #capturing.elems > 0 then + commitCapture(chainRaw()) + else + capturing = nil + end + elseif + (isInRect(x, y, cancel[1], cancel[2], cancel[3], cancel[4])) + or x < bx1 + or x > bx2 + or y < by1 + or y > by2 + then + capturing = nil + end + elseif button >= 4 then + appendChain({ sym = "mouse" .. button, mods = modPrefix() }) + end + return true + end + + if button ~= 1 then + return true + end + + -- The open list draws over the header and footer, so it gets the click before they do. + -- Closed, this only claims its own toggle and everything below still sees the press. + local ddWasOpen = presetDropdown:isOpen() + if presetDropdown:mousePress(x, y) then + searchBox:blur() + capturing = nil + + return true + end + if ddWasOpen then + return true + end + + for _, set in ipairs(buttonSets) do + for _, b in ipairs(set) do + local r = b.rect + if r and isInRect(x, y, r[1], r[2], r[3], r[4]) then + searchBox:blur() + presetDropdown:close() + if buttonEnabled(b.id) then + if b.id == "save" then + startSave() + elseif b.id == "reset" then + startReset() + elseif b.id == "duplicate" then + startDuplicate() + elseif b.id == "edit" then + startEdit() + end + end + + return true + end + end + end + + if searchBox:mousePress(x, y) then + capturing = nil + return true + end + searchBox:blur() + + if sidebarPress(x, y) then + return true + end + + if not gridGroup and isInRect(x, y, barX1, listBottom(), area.x2, listTop) then + dragging = true + scrollFromY(y) + return true + end + + if gridGroup and isInRect(x, y, listX1, listBottom(), area.x2, listTop) then + return gridPress(x, y) + end + + if isInRect(x, y, listX1, listBottom(), listRight, listTop) then + -- The band can end in a partial row that draw never paints, so clamp to the + -- painted count or a click in that strip would edit an unseen row. + local r = floor((listTop - y) / rowHeight) + 1 + local row = r <= visibleRows() and rows[scroll + r] or nil + if row and row.type == "editable" then + local kind, raw = hitTestRow(row, x) + if kind then + handleZone(kind, row.action, row.label, raw) + end + elseif row and row.type == "link" then + selectedCategory = row.category + scroll = 0 + rebuildRows() + end + return true + end + + return true +end + +function view.textInput(char) + if dialog then + return not dialog.message and nameBox:textInput(char) + end + if searchBox and searchBox:isFocused() then + return searchBox:textInput(char) + end + + return false +end + +-- Keys, offered to the modal, the capture, the dropdown and the search box in that order. +function view.keyPress(key, scanCode) + if dialog then + if key == KEYSYMS.ESCAPE then + cancelDialog() + elseif key == KEYSYMS.RETURN then + acceptDialog() + elseif not dialog.message then + -- Focus is dropped by the editbox on Escape/Return, which are handled above. + nameBox:keyPress(key) + end + + return true + end + + if capturing then + if key == 27 then + capturing = nil + else + -- Skip auto-repeat; only the initial press adds an element (release clears pressed). + local sym = pressSym(scanCode) + if sym and not capturing.pressed[scanCode] then + capturing.pressed[scanCode] = true + appendChain({ sym = sym, mods = modPrefix() }) + end + end + return true + end + + if presetDropdown and presetDropdown:isOpen() then + if key == 27 then + presetDropdown:close() + end + return true + end + + if searchBox and searchBox:isFocused() then + return searchBox:keyPress(key) + end + + return false +end + +-- Only a capture cares about releases, to know a held key has gone. +function view.keyRelease(key, scanCode) + if capturing then + capturing.pressed[scanCode] = nil + end +end + +return view diff --git a/luaui/Include/keybind_keysyms.lua b/luaui/Include/keybind_keysyms.lua new file mode 100644 index 00000000000..8c97a531484 --- /dev/null +++ b/luaui/Include/keybind_keysyms.lua @@ -0,0 +1,14 @@ +-- Serves the engine's key constants to the keybind editor's includes. +-- +-- barwidgets.lua loads KEYSYMS into the widget-handler environment, which an Include from +-- inside a widget does not inherit, so the global may or may not be visible here. + +local KEYSYMS = KEYSYMS + +if not KEYSYMS then + local env = {} + VFS.Include("luaui/Headers/keysym.h.lua", env) + KEYSYMS = env.KEYSYMS +end + +return KEYSYMS diff --git a/luaui/Include/keybind_model.lua b/luaui/Include/keybind_model.lua new file mode 100644 index 00000000000..1b4669632aa --- /dev/null +++ b/luaui/Include/keybind_model.lua @@ -0,0 +1,170 @@ +-- Read model for the in-game keybind editor. +-- Source of truth is Spring.GetKeyBindings(); we normalize each binding and group +-- by action. + +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" } + +-- Keychain separator (U+2192) shown between taps, since the engine's "," collides with a bound comma key. +local chainSep = " \226\134\146 " + +-- One engine keyset element as the player's keyboard layout would label it. +-- The Any+ qualifier is not shown: it is fixed per action rather than chosen, so there is +-- nothing on that key for a player to change. sanitizeKey is what drops it. +local function displayElement(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 + +-- Split a chain on separator commas. A comma is the bound key rather than a separator +-- when nothing has been read yet (","), straight after a modifier ("Alt+,") or after "sc_". +local function splitChain(raw) + local elems = {} + local cur = "" + for i = 1, #raw do + local c = raw:sub(i, i) + if c == "," and cur ~= "" and cur:sub(-1) ~= "+" and cur:sub(-3) ~= "sc_" then + if cur ~= "" then + elems[#elems + 1] = cur + end + cur = "" + else + cur = cur .. c + end + end + if cur ~= "" then + elems[#elems + 1] = cur + end + + return elems +end + +-- Whether two keysets are the same binding, which is a different question from whether +-- they print the same. Any+ and a bare modifier set resolve differently when the engine +-- picks an action, and scancodes and keycodes are separate maps that land on different +-- physical keys off qwerty, so both distinctions survive here. Only spellings of one key +-- fold. Display is no use for this: it drops exactly the qualifiers that decide priority. +local canonicalMods = { "any", "alt", "ctrl", "meta", "shift" } +local modToken = { + ["any+"] = "any", + ["*+"] = "any", + ["alt+"] = "alt", + ["ctrl+"] = "ctrl", + ["meta+"] = "meta", + ["shift+"] = "shift", +} + +local function canonicalElement(raw) + local rest, held = raw, {} + local stripped = true + while stripped do + stripped = false + for token, name in pairs(modToken) do + if rest:sub(1, #token):lower() == token then + held[name] = true + rest = rest:sub(#token + 1) + stripped = true + end + end + end + + local key = rest:lower() + local scan = key:sub(1, 3) == "sc_" + if scan then + key = key:sub(4) + end + key = keyNameAlias[key] or key + + local out = {} + for _, name in ipairs(canonicalMods) do + if held[name] then + out[#out + 1] = name + end + end + out[#out + 1] = (scan and "sc:" or "kc:") .. key + + return table.concat(out, "+") +end + +-- A whole keyset, joining a chain with the arrow separator. +local function displayKeyset(raw, layout) + if not raw:find(",", 1, true) then + return displayElement(raw, layout) + end + + local parts = splitChain(raw) + for i = 1, #parts do + parts[i] = displayElement(parts[i], layout) + end + + return table.concat(parts, chainSep) +end + +local function canonicalKeyset(raw) + local parts = splitChain(raw) + for i = 1, #parts do + parts[i] = canonicalElement(parts[i]) + end + + return table.concat(parts, ",") +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 + +-- Snapshot of every bound action, with both raw and display forms of its keysets. +local function build() + local layout = Spring.GetConfigString("KeyboardLayout", "qwerty") + local bindings = Spring.GetKeyBindings() or {} + + local byAction = {} + local order = {} + local binds = {} + + for _, b in ipairs(bindings) do + local id = actionId(b) + local raw = b.boundWith + binds[#binds + 1] = { keyset = raw, action = id } + + 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, binds = binds } +end + +return { + build = build, + displayKeyset = displayKeyset, + canonicalKeyset = canonicalKeyset, + splitChain = splitChain, + chainSep = chainSep, +} diff --git a/luaui/Include/keybind_profiles.lua b/luaui/Include/keybind_profiles.lua new file mode 100644 index 00000000000..8919a77bf10 --- /dev/null +++ b/luaui/Include/keybind_profiles.lua @@ -0,0 +1,670 @@ +-- Store for user keybind profiles, persisted to LuaUI/Config/keybind_profiles.json. +-- +-- Profiles are whole snapshots, never deltas: keyreload clears the keymap before it loads, +-- so a profile always defines every binding it wants. The emitter writes them in the one +-- shape the engine round-trips. +-- +-- Migration is the exception: a player's own file has to be read as written, so the reader +-- below understands the subset of the bind-file grammar that changes what ends up bound - +-- bind, the three unbinds, keyload, keysym and fakemeta. Everything after migration goes +-- through Spring.GetKeyBindings instead. + +local Json = Json or VFS.Include("common/luaUtilities/json.lua") +local keybindConfig = VFS.Include("luaui/Include/keybind_config.lua") + +local PROFILES_PATH = "LuaUI/Config/keybind_profiles.json" +local DEFAULTS_PATH = "common/configs/keybind_defaults.json" +local ACTIVE_FILE = "uikeys.txt" +local BACKUP_FILE = "uikeys.txt.bak" +local STORE_VERSION = 1 + +-- The shipped profiles a player can select but not edit; editing forks a copy. They +-- carry binds rather than a file path so every surface reads one shape, and applying +-- one takes the same path as applying a player's own profile. +local builtins = {} +local emitPriority = {} +do + local decoded = keybindConfig.load(DEFAULTS_PATH) + if decoded and type(decoded.profiles) == "table" then + builtins = decoded.profiles + if type(decoded.priority) == "table" then + emitPriority = decoded.priority + end + else + Spring.Echo("[keybind_profiles] Error: " .. DEFAULTS_PATH .. " has no profiles; none shipped") + end +end + +-- Only for upgrades: the preset a player was on is recorded as a bind-file path. Maps +-- each of those paths to the profile that now covers it. +local presetFiles = { + ["luaui/configs/hotkeys/grid_keys.txt"] = "Grid", + ["luaui/configs/hotkeys/grid_keys_60pct.txt"] = "Grid (60% Keyboard)", + ["luaui/configs/hotkeys/legacy_keys.txt"] = "Legacy", + ["luaui/configs/hotkeys/legacy_keys_60pct.txt"] = "Legacy (60% Keyboard)", +} + +---@type table +local store + +-- Shape a fresh store file takes. +local function emptyStore() + return { version = STORE_VERSION, active = nil, profiles = {} } +end + +-- Position of one of the player's own profiles, nil when the name is not theirs. +local function indexOf(name) + for i, p in ipairs(store.profiles) do + if p.name == name then + return i + end + end + + return nil +end + +local M = { builtins = builtins, activeFile = ACTIVE_FILE } + +-- The shipped profile of that name, nil when the player owns it instead. +function M.isBuiltin(name) + for _, b in ipairs(builtins) do + if b.name == name then + return b + end + end + + return nil +end + +-- Where an action sits in the shipped priority list, last for anything unlisted. +local function priorityRank(action) + for i = 1, #emitPriority do + local prefix = emitPriority[i] + if action:sub(1, #prefix) == prefix then + return i + end + end + + return #emitPriority + 1 +end + +-- Two actions on one key are tried in the order they were bound, so file order is what +-- settles which one wins. Sorting by declared priority keeps that decision with the +-- action instead of with whoever edited last. Equal ranks hold their existing order, so +-- only the listed actions move. +local function byPriority(binds) + local ordered = {} + for i = 1, #binds do + ordered[i] = { bind = binds[i], rank = priorityRank(binds[i].action), pos = i } + end + + table.sort(ordered, function(a, b) + if a.rank ~= b.rank then + return a.rank < b.rank + end + + return a.pos < b.pos + end) + + local out = {} + for i = 1, #ordered do + out[i] = ordered[i].bind + end + + return out +end + +-- Stamped into every file we write so migration can tell our own output from a file the +-- player wrote, and recover which profile was live when the store holding it is gone. +-- The engine drops everything from "//" to end of line, so it costs nothing on load. +local GENERATED_PREFIX = "// keybind editor profile: " +local GENERATED_PATTERN = "^" .. (GENERATED_PREFIX:gsub("(%W)", "%%%1")) .. "([^\r\n]*)" + +local function generatedName(text) + if not text then + return nil + end + + local name = text:match(GENERATED_PATTERN) + + return (name ~= nil and name ~= "") and name or nil +end + +-- uikeys.txt only needs the bind lines; keyreload clears and sets fakemeta itself. +local function toBindFile(profile) + local out = { GENERATED_PREFIX .. tostring(profile.name) } + -- One token only: anything longer emits a fakemeta directive the engine cannot parse. + if profile.fakeMeta and profile.fakeMeta ~= "" and not profile.fakeMeta:find("%s") then + out[#out + 1] = "fakemeta " .. profile.fakeMeta + end + -- The store is writable by the player and by other surfaces, so a malformed entry is + -- reachable here. Dropping one costs a keybind; letting it through takes the whole + -- hotkey loader down with it. + local binds, dropped = {}, 0 + for _, b in ipairs(profile.binds or {}) do + if + type(b) == "table" + and type(b.keyset) == "string" + and type(b.action) == "string" + and b.keyset ~= "" + and b.action ~= "" + then + binds[#binds + 1] = b + else + dropped = dropped + 1 + end + end + if dropped > 0 then + Spring.Echo( + "[keybind_profiles] skipped " .. dropped .. " malformed binding(s) in profile " .. tostring(profile.name) + ) + end + + for _, b in ipairs(byPriority(binds)) do + out[#out + 1] = "bind " .. b.keyset .. " " .. b.action + end + + return table.concat(out, "\n") .. "\n" +end + +-- The engine has no Lua getter for the fakemeta key, so migration is the only +-- chance to carry a non-default one over from the file the player already had. +-- Reads the bind lines back out of a keybind file. Needed for the player's own +-- uikeys.txt at migration time: the live keymap is whichever preset they had selected, +-- so it cannot stand in for what their own file holds. +local function readBindFile(text, depth) + if not text then + return nil + end + + depth = depth or 1 + local breaks = "[^" .. string.char(13, 10) .. "]+" + local binds = {} + + -- A file may name a key the engine has none for (capslock is commented out engine-side), + -- and every keyset after that point uses the name. Resolved here rather than carried, so + -- what we write out is only ever bind lines the engine already parses. The engine refuses + -- to redefine a name, so one definition per name is the whole of it. + local keySyms = {} + local function resolveKeySyms(keyset) + if not next(keySyms) then + return keyset + end + + local out = {} + for element in (keyset .. ","):gmatch("([^,]*),") do + local mods, key = element:match("^(.-)([^+]+)$") + local named = key and keySyms[key:lower()] + out[#out + 1] = named and (mods .. named) or element + end + + return table.concat(out, ",") + end + + -- Applied to what has been collected so far rather than issued as commands, so an unbind + -- means "drop what this file has bound up to here". Matched on the command word, never + -- its args: "unbindaction factory_preset" takes every "factory_preset load N" with it. + local function drop(match) + for i = #binds, 1, -1 do + if match(binds[i]) then + table.remove(binds, i) + end + end + end + + for line in text:gmatch(breaks) do + -- Everything from "//" is a comment to the engine, so it is gone before anything reads + -- the line as a directive. + line = line:gsub("//.*", ""):gsub("%s+$", "") + local keyset, action = line:match("^%s*bind%s+(%S+)%s+(.-)%s*$") + if keyset and action ~= "" then + binds[#binds + 1] = { keyset = resolveKeySyms(keyset), action = action } + elseif line:match("^%s*unbindall%s*$") then + binds = {} + elseif line:match("^%s*unbindaction%s+%S") then + local command = line:match("^%s*unbindaction%s+(%S+)") + drop(function(b) + return b.action:match("^%S+") == command + end) + elseif line:match("^%s*unbindkeyset%s+%S") then + local target = line:match("^%s*unbindkeyset%s+(%S+)"):lower() + drop(function(b) + return b.keyset:lower() == target + end) + elseif line:match("^%s*unbind%s+%S") then + local target, command = line:match("^%s*unbind%s+(%S+)%s+(%S+)") + if target then + target = target:lower() + drop(function(b) + return b.keyset:lower() == target and b.action:match("^%S+") == command + end) + end + elseif line:match("^%s*keysym%s+%S+%s+%S") then + local name, code = line:match("^%s*keysym%s+(%S+)%s+(%S+)") + if not keySyms[name:lower()] then + keySyms[name:lower()] = code + end + else + -- A player's file can pull in others the same way the shipped presets did, and + -- those bindings are just as much theirs. Depth-capped rather than cycle-tracked. + local included = line:match("^%s*keyload%s+(%S+)") + if included and depth < 8 then + local text = VFS.LoadFile(included) + local retired = presetFiles[included] and M.isBuiltin(presetFiles[included]) + if text then + for _, b in ipairs(readBindFile(text, depth + 1) or {}) do + binds[#binds + 1] = b + end + elseif retired then + -- The shipped presets stopped being files, so a keyload naming one has + -- nothing to read. Their bindings are the profile of that name now. + for _, b in ipairs(retired.binds or {}) do + binds[#binds + 1] = { keyset = b.keyset, action = b.action } + end + else + Spring.Echo( + "[keybind_profiles] Error: keyload could not read " + .. included + .. "; any bindings it held are missing from the migrated profile" + ) + end + end + end + end + + return binds +end + +local function readFakeMeta(text) + if not text then + return nil + end + -- Horizontal whitespace only: %s would match the line break and swallow the + -- next line as the value when fakemeta is present but unset. + -- Leading newline so the directive is still found on the first line, which is where + -- toBindFile puts it. + local value = ("\n" .. text):match("\n[ \t]*fakemeta[ \t]*([^\n]*)") + if not value then + return nil + end + + value = value:gsub("//.*", ""):gsub("%s+$", "") + + return value ~= "" and value or nil +end + +-- What a bind file binds, as one comparable string. Both sides of a comparison go through +-- the reader, so comments, line endings and any later change to how we emit cannot read as +-- an edit the player made. +local function keymapOf(text) + local binds = readBindFile(text) + if not binds then + return nil + end + + local parts = {} + for i = 1, #binds do + parts[i] = binds[i].keyset .. " " .. binds[i].action + end + + return table.concat(parts, "\n") .. "\nfakemeta " .. tostring(readFakeMeta(text)) +end + +-- Whether some profile already holds this keymap. The one migration just made of the +-- player's own file counts, which is what keeps the launch they arrive on from forking a +-- second copy of what it has only now imported. +local function matchesKnownProfile(text) + local theirs = keymapOf(text) + if not theirs then + return false + end + + -- Nearly always our own output for the profile it names, and this runs on every game + -- load, so try that one before reading out every profile there is. Keeps the usual path + -- off the full scan however many the player has accumulated. + local claimed = generatedName(text) + local i = claimed and indexOf(claimed) + local stamped = (i and store.profiles[i]) or (claimed and M.isBuiltin(claimed)) + if stamped and keymapOf(toBindFile(stamped)) == theirs then + return true + end + + for _, p in ipairs(store.profiles) do + if keymapOf(toBindFile(p)) == theirs then + return true + end + end + for _, b in ipairs(builtins) do + if keymapOf(toBindFile(b)) == theirs then + return true + end + end + + return false +end + +-- A name no existing profile holds, for copies. +function M.uniqueName(base) + M.load() + if not indexOf(base) and not M.isBuiltin(base) then + return base + end + + local n = 2 + while indexOf(base .. " " .. n) or M.isBuiltin(base .. " " .. n) do + n = n + 1 + end + + return base .. " " .. n +end + +-- The next free " (n)". A name already carrying one counts up from it, anything else +-- starts at 2. Kept distinct from uniqueName's suffix so a copy the player never asked for +-- reads as one rather than as another profile they made. +local function nextCopyName(name) + local stem, n = name:match("^(.-) %((%d+)%)$") + n = tonumber(n) or 1 + stem = stem or name + + repeat + n = n + 1 + until not indexOf(stem .. " (" .. n .. ")") and not M.isBuiltin(stem .. " (" .. n .. ")") + + return stem .. " (" .. n .. ")" +end + +-- Writes the store back to disk. +function M.save() + local file = io.open(PROFILES_PATH, "w") + if not file then + Spring.Echo("[keybind_profiles] could not open " .. PROFILES_PATH .. " for writing") + return false + end + + local encoded = Json.encode(store) + if not encoded then + file:close() + Spring.Echo("[keybind_profiles] could not encode " .. PROFILES_PATH) + return false + end + + file:write(encoded) + file:close() + + return true +end + +-- Players upgrading from the old preset picker keep what they had, so dropping the +-- preset list does not silently reset anyone. +-- The player's file as it was before any of this touched it. Written once and never again, +-- including on a later migration, so the copy is always the original rather than our own +-- output. Nothing reads it back: it exists for a human with a broken keymap. +local function backupActiveFile() + local existing = io.open(BACKUP_FILE, "r") + if existing then + existing:close() + + return + end + + local text = VFS.LoadFile(ACTIVE_FILE) + if not text then + return + end + + local file = io.open(BACKUP_FILE, "w") + if not file then + Spring.Echo( + "[keybind_profiles] Error: could not write " + .. BACKUP_FILE + .. "; continuing without a copy of the original keymap" + ) + + return + end + + file:write(text) + file:close() + Spring.Echo("[keybind_profiles] kept the original " .. ACTIVE_FILE .. " as " .. BACKUP_FILE) +end + +local function migrate() + backupActiveFile() + store = emptyStore() + + -- Every preset still ships, so a player on one only needs it selected; there is nothing + -- of theirs to carry across. + local configured = Spring.GetConfigString("KeybindingFile", "") + local preset = presetFiles[configured] + + -- Whichever file actually held their bindings: the one they pointed the engine at when + -- that is not a preset we still ship, otherwise the uikeys.txt a preset leaves unloaded. + -- The player's own file is a profile in its own right, whatever else they had going on. + local ownPath = (not preset and configured ~= "") and configured or ACTIVE_FILE + local ownText = VFS.LoadFile(ownPath) + local written = generatedName(ownText) + + if written and M.isBuiltin(written) then + -- Our own copy of a shipped profile. Select it rather than importing a duplicate. + store.active = written + else + local own = readBindFile(ownText) + if own and #own > 0 then + local name = written or "Custom" + store.profiles[1] = { name = name, binds = own, fakeMeta = readFakeMeta(ownText) } + store.active = preset or name + else + store.active = preset + end + end + + M.save() +end + +-- Reads the store once, migrating an older layout on the way in. +function M.load() + if store then + return store + end + + local content = VFS.LoadFile(PROFILES_PATH) + if not content then + migrate() + return store + end + + -- Json.decode raises on malformed input, so a corrupt file must not take LuaUI down. + local ok, decoded = pcall(Json.decode, content) + if not ok or type(decoded) ~= "table" or type(decoded.profiles) ~= "table" then + Spring.Echo("[keybind_profiles] could not decode " .. PROFILES_PATH .. "; starting empty") + store = emptyStore() + return store + end + + store = decoded + store.version = store.version or STORE_VERSION + -- A hand-edited file can repeat a name; keep the first so lookups stay unambiguous. + local seen, kept = {}, {} + for _, p in ipairs(store.profiles) do + if type(p) == "table" and type(p.name) == "string" and not seen[p.name] then + seen[p.name] = true + p.binds = type(p.binds) == "table" and p.binds or {} + if type(p.fakeMeta) ~= "string" or p.fakeMeta == "" or p.fakeMeta:find("%s") then + p.fakeMeta = nil + end + kept[#kept + 1] = p + end + end + store.profiles = kept + + return store +end + +-- Names of the player's own profiles, in store order. +function M.list() + M.load() + local names = {} + for _, p in ipairs(store.profiles) do + names[#names + 1] = p.name + end + + return names +end + +-- One of the player's own profiles by name. +function M.get(name) + M.load() + local i = indexOf(name) + + return i and store.profiles[i] or nil +end + +-- The selected profile, shipped or the player's own. +function M.getActive() + M.load() + + return store.active +end + +-- The selection, or the first shipped profile when it is missing or stale. +function M.activeName() + local active = M.getActive() + if active and (M.get(active) or M.isBuiltin(active)) then + return active + end + + return builtins[1] and builtins[1].name or nil +end + +-- Records the selection; the store owns this, not the engine config. +function M.setActive(name) + M.load() + store.active = name + + return M.save() +end + +-- A keymap the player edited themselves, kept as a profile instead of overwritten the next +-- time one is applied. Whichever file the engine is pointed at, since a hand-set +-- KeybindingFile is the same player doing the same thing somewhere else. +function M.adoptEditedKeymap() + M.load() + + local configured = Spring.GetConfigString("KeybindingFile", ACTIVE_FILE) + local text = VFS.LoadFile(configured ~= "" and configured or ACTIVE_FILE) + if not text then + return nil + end + + if matchesKnownProfile(text) then + return nil + end + + local binds = readBindFile(text) + if not binds or #binds == 0 then + return nil + end + + local previous = store.active + local name = nextCopyName(M.activeName() or "Custom") + store.profiles[#store.profiles + 1] = { name = name, binds = binds, fakeMeta = readFakeMeta(text) } + store.active = name + if not M.save() then + table.remove(store.profiles) + store.active = previous + Spring.Echo( + "[keybind_profiles] Error: could not write " + .. PROFILES_PATH + .. "; the edited " + .. ACTIVE_FILE + .. " was left alone rather than kept as a profile" + ) + + return nil + end + + return name +end + +-- Adds a profile of the player's own, without selecting it: whether it becomes the live one +-- depends on the keymap reaching disk, which only the caller finds out. Selecting it up front +-- would leave the picker naming a profile the engine never loaded when that write fails. +function M.create(name, binds, fakeMeta) + M.load() + name = M.uniqueName(name) + store.profiles[#store.profiles + 1] = { name = name, binds = binds, fakeMeta = fakeMeta } + if not M.save() then + Spring.Echo( + "[keybind_profiles] Error: could not write " + .. PROFILES_PATH + .. "; profile " + .. name + .. " will be gone next launch" + ) + end + + return name +end + +-- Renames one of the player's own, following the selection if it moves. +function M.rename(oldName, newName) + M.load() + local i = indexOf(oldName) + if not i or newName == oldName then + return oldName + end + + newName = M.uniqueName(newName) + store.profiles[i].name = newName + if store.active == oldName then + store.active = newName + end + if not M.save() then + Spring.Echo( + "[keybind_profiles] Error: could not write " + .. PROFILES_PATH + .. "; the rename to " + .. newName + .. " will be gone next launch" + ) + end + + return newName +end + +-- Removes one of the player's own. +function M.delete(name) + M.load() + local i = indexOf(name) + if not i then + return false + end + + table.remove(store.profiles, i) + if store.active == name then + store.active = store.profiles[1] and store.profiles[1].name or nil + end + + return M.save() +end + +-- Write a profile out where the engine can keyreload it, and return that path. +function M.materialize(name) + local profile = M.get(name) or M.isBuiltin(name) + if not profile then + return nil + end + + local file = io.open(ACTIVE_FILE, "w") + if not file then + Spring.Echo("[keybind_profiles] could not open " .. ACTIVE_FILE .. " for writing") + return nil + end + + file:write(toBindFile(profile)) + file:close() + + return ACTIVE_FILE +end + +return M diff --git a/luaui/Include/keybind_text.lua b/luaui/Include/keybind_text.lua new file mode 100644 index 00000000000..599f16c2573 --- /dev/null +++ b/luaui/Include/keybind_text.lua @@ -0,0 +1,65 @@ +-- 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. + +local utf8 = VFS.Include("common/luaUtilities/utf8.lua") + +local M = {} + +-- Shortens text until it draws inside maxWidth, marking the cut with "..". +function M.fit(font, text, maxWidth, size) + -- Callers derive the width by subtracting, so it can come through negative. Returning + -- the text whole there draws it straight out of whatever it was meant to fit inside. + if maxWidth <= 0 then + return "" + end + + local width = font:GetTextWidth(text) * size + if width <= maxWidth then + return text + end + + -- Trimmed by character, not byte: translated labels and the chain arrow are multi-byte. + -- Cut straight to the length the average glyph predicts and correct from there by one + -- character either way, rather than measuring every length down from the whole string. + local len = utf8.len(text) + local markW = font:GetTextWidth("..") * size + local keep = math.max(1, math.min(len - 1, math.floor(len * (maxWidth - markW) / width))) + local cut = utf8.sub(text, 1, keep) + while keep > 1 and font:GetTextWidth(cut .. "..") * size > maxWidth do + keep = keep - 1 + cut = utf8.sub(text, 1, keep) + end + while keep < len - 1 do + local longer = utf8.sub(text, 1, keep + 1) + if font:GetTextWidth(longer .. "..") * size > maxWidth then + break + end + keep, cut = keep + 1, longer + end + + return cut .. ".." +end + +-- Splits text into lines that each draw inside maxWidth. A word too long to fit is left +-- over-long for the caller to shorten, there being nowhere sensible to break it. +function M.wrap(font, text, maxWidth, size) + local lines = {} + local line + for word in text:gmatch("%S+") do + local candidate = line and (line .. " " .. word) or word + if line and font:GetTextWidth(candidate) * size > maxWidth then + lines[#lines + 1] = line + line = word + else + line = candidate + end + end + if line then + lines[#lines + 1] = line + end + + return lines +end + +return M diff --git a/luaui/Widgets/cmd_bar_hotkeys.lua b/luaui/Widgets/cmd_bar_hotkeys.lua index e34c6f89bb9..7f7b0c6e0ae 100644 --- a/luaui/Widgets/cmd_bar_hotkeys.lua +++ b/luaui/Widgets/cmd_bar_hotkeys.lua @@ -15,9 +15,7 @@ end -- Localized Spring API for performance local spEcho = Spring.Echo -local currentLayout -local currentKeybindingsFile -local keyConfig = VFS.Include("luaui/configs/keyboard_layouts.lua") +local profiles = VFS.Include("luaui/Include/keybind_profiles.lua") local function reloadWidgetsBindings() local reloadableWidgets = { "buildmenu", "ordermenu", "keybinds", "cmd_blueprint" } @@ -29,27 +27,31 @@ local function reloadWidgetsBindings() end end --- if keybinds are missing, load default hotkeys -local function fallbackToDefault(currentKeys) - local default = keyConfig.keybindingLayoutFiles[1] - spEcho("BAR Hotkeys: Did not find keybindings file " .. currentKeys .. ". Loading grid keys") - Spring.SendCommands("keyreload " .. default) - return default +-- Nothing to load, so write the active profile out and point the config at it. This +-- is also the upgrade path once the shipped preset files stop being installed. +local function fallbackToProfile(missing) + spEcho("BAR Hotkeys: Did not find keybindings file " .. missing .. ". Writing the active profile") + + local file = profiles.materialize(profiles.activeName()) + if file then + Spring.SetConfigString("KeybindingFile", file) + end + + return file end local function reloadBindings() - -- Second parameter here is just a fallback if this config is undefined - currentLayout = Spring.GetConfigString("KeyboardLayout", "qwerty") + -- Still read from config rather than the store: on the launch a player is + -- migrated this is what they were on, and the store snapshots the live keymap. + local file = Spring.GetConfigString("KeybindingFile", profiles.activeFile) - currentKeybindingsFile = Spring.GetConfigString("KeybindingFile", keyConfig.keybindingLayoutFiles[1]) - - if not VFS.FileExists(currentKeybindingsFile) then - currentKeybindingsFile = fallbackToDefault(currentKeybindingsFile) + if not VFS.FileExists(file) then + file = fallbackToProfile(file) end - if VFS.FileExists(currentKeybindingsFile) then - Spring.SendCommands("keyreload " .. currentKeybindingsFile) - spEcho("BAR Hotkeys: Loaded hotkeys from " .. currentKeybindingsFile) + if file then + Spring.SendCommands("keyreload " .. file) + spEcho("BAR Hotkeys: Loaded hotkeys from " .. file) else spEcho("BAR Hotkeys: No hotkey file found") end @@ -57,7 +59,25 @@ local function reloadBindings() reloadWidgetsBindings() end +-- Anyone who was editing uikeys.txt by hand keeps what they wrote: it becomes a profile of +-- theirs before the editor gets a chance to write over it. Materializing hands the file back +-- to us, so the next launch finds one that matches and leaves it alone. +local function adoptEditedKeymap() + local name = profiles.adoptEditedKeymap() + if not name then + return + end + + spEcho("BAR Hotkeys: " .. profiles.activeFile .. " was edited outside the keybind editor; kept as profile " .. name) + + local file = profiles.materialize(name) + if file then + Spring.SetConfigString("KeybindingFile", file) + end +end + function widget:Initialize() + adoptEditedKeymap() reloadBindings() WG.bar_hotkeys = {} diff --git a/luaui/Widgets/dbg_target_priority.lua b/luaui/Widgets/dbg_target_priority.lua new file mode 100644 index 00000000000..4a07f9fcccd --- /dev/null +++ b/luaui/Widgets/dbg_target_priority.lua @@ -0,0 +1,981 @@ +local widget = widget ---@type Widget + +function widget:GetInfo() + return { + name = "Target Priority Debug", + desc = "/debugtargetpriority shows a selected unit's autotarget priority for every enemy weapon it sweeps", + author = "efrec", + date = "September 2026", + license = "GNU GPL, v2 or later", + layer = 0, + enabled = false, + depends = { "gl4" }, + } +end + +-- /debugtargetpriority [on|off] [lines] [details] [weapon ] +-- +-- This reproduces CGameHelper::GenerateWeaponTargets as well as our own priority modifiers. +-- The engine checks for game modifiers in AllowWeaponTarget (in addition to allow/disallow). +-- Then, it elects a single target based on the lowest priority value that it received. +-- +-- There are some big approximations used: +-- - The angle term uses the weapon's wanted direction, not the direction of the weapon's aim piece +-- - Radar blips use the viewer's error position during live debugging. Be careful with /globallos. +-- - The avoidTarget and defend-firestate rules are not considered at all. + +local math_sqrt = math.sqrt +local math_max = math.max +local math_min = math.min +local math_floor = math.floor +local format = string.format +local char = string.char +local sort = table.sort + +local spGetSelectedUnits = Spring.GetSelectedUnits +local spGetSpectatingState = Spring.GetSpectatingState +local spGetMyAllyTeamID = Spring.GetMyAllyTeamID +local spGetGameFrame = Spring.GetGameFrame +local spGetGroundExtremes = Spring.GetGroundExtremes +local spGetViewGeometry = Spring.GetViewGeometry +local spWorldToScreenCoords = Spring.WorldToScreenCoords +local spIsGUIHidden = Spring.IsGUIHidden + +local spValidUnitID = Spring.ValidUnitID +local spGetUnitDefID = Spring.GetUnitDefID +local spGetUnitAllyTeam = Spring.GetUnitAllyTeam +local spGetUnitPosition = Spring.GetUnitPosition +local spGetUnitHealth = Spring.GetUnitHealth +local spGetUnitArmored = Spring.GetUnitArmored +local spGetUnitNeutral = Spring.GetUnitNeutral +local spGetUnitIsDead = Spring.GetUnitIsDead +local spGetUnitLosState = Spring.GetUnitLosState +local spGetUnitLastAttacker = Spring.GetUnitLastAttacker +local spGetUnitMoveTypeData = Spring.GetUnitMoveTypeData +local spGetUnitStates = Spring.GetUnitStates +local spGetUnitsInCylinder = Spring.GetUnitsInCylinder +local spGetUnitWeaponState = Spring.GetUnitWeaponState +local spGetUnitWeaponDamages = Spring.GetUnitWeaponDamages +local spGetUnitWeaponVectors = Spring.GetUnitWeaponVectors +local spGetUnitWeaponTarget = Spring.GetUnitWeaponTarget +local spGetUnitWeaponTryTarget = Spring.GetUnitWeaponTryTarget +local spGetUnitWeaponTestTarget = Spring.GetUnitWeaponTestTarget +local spGetUnitWeaponHaveFreeLineOfFire = Spring.GetUnitWeaponHaveFreeLineOfFire + +local FIRESTATE_FIREATWILL = CMD.FIRESTATE_FIREATWILL +local FIRESTATE_FIREATNEUTRAL = CMD.FIRESTATE_FIREATNEUTRAL + +local LOS_INLOS = 1 +local LOS_INRADAR = 2 +local LOS_PREVLOS = 4 + +local function hasBit(value, bit) + return value % (bit * 2) >= bit +end + +local paralyzeOnMaxHealth = Game.paralyzeOnMaxHealth +local fireAtCrashing = Game.fireAtCrashing ~= 0 +local armorTypeCount = #Game.armorTypes + 1 + +local colorAndOutlineCode = (Engine and Engine.textColorCodes and Engine.textColorCodes.ColorAndOutline) or "\254" + +-- Priority multipliers from CGameHelper::GenerateWeaponTargets: +local PRIORITY_RADAR_ONLY = 10 +local PRIORITY_BAD_CATEGORY = 100 +local PRIORITY_CRASHING = 1000 +local PRIORITY_LAST_ATTACKER = 0.5 +local PRIORITY_PARALYZED = 4 +local PRIORITY_BEYOND_RANGE = 100000 +local LAST_ATTACKER_FRAMES = 200 + +-- Priority multipliers from unit_aa_targeting_priority: +local PRIORITY_BOMBERS = 0.1 +local PRIORITY_VTOLS = 1 +local PRIORITY_FIGHTERS = 2 +local PRIORITY_SCOUTS = 100 + +---@diagnostic disable + +-------------------------------------------------------------------------------- +-- State ----------------------------------------------------------------------- + +local active = false +local showLines = false +local showDetails = false +local weaponNumber = nil ---@type number? explicit weapon number, else the first weapon that autotargets, sorry + +local vsx, vsy = spGetViewGeometry() + +local attackerID, attackerDefID, attackerWeapon + +local candidates = {} -- sorted array of target records +local candidateCount = 0 + +local currentTargetID, chaseTargetID +local selectionOrder = {} +local statusText = "" +local hintText = nil + +local lastAttackFrame = {} + +-------------------------------------------------------------------------------- +-- Unit and weapon def data ---------------------------------------------------- + +local airPriorityMultiplier = {} +local hasAntiAirPriority = {} + +do + local isAirCategory = { + vtol = true, + mobile = true, + nothover = true, + notship = true, + notsub = true, + } + + local nonAntiAirTypes = { + AircraftBomb = true, + Shield = true, + TorpedoLauncher = true, + } + + local function hasTargeting(unitDef, weapon) + return weapon.slavedTo == 0 and not (unitDef.canManualFire and WeaponDefs[weapon.weaponDef].manualFire) + end + + local function hasAntiAirTargeting(weapon) + return table.any(weapon.onlyTargets, function(v, k) + return isAirCategory[k] + end) and not table.any(weapon.badTargets, function(v, k) + return isAirCategory[k] + end) + end + + local function isBomberWeapon(weapon) + local weaponDef = WeaponDefs[weapon.weaponDef] ---@type table + return weaponDef.type == "AircraftBomb" + or weaponDef.type == "TorpedoLauncher" + or string.find(weaponDef.name, "arm_pidr", 1, true) + end + + local function isFighterWeapon(weapon) + return weapon.slavedTo == 0 and hasAntiAirTargeting(weapon) + end + + local function isNotFakeWeapon(weapon) + return not WeaponDefs[weapon.weaponDef].customParams.bogus + end + + local function hasAntiAirPriorityWeapon(unitDef, weapon) + if not hasTargeting(unitDef, weapon) or not hasAntiAirTargeting(weapon) then + return false + end + local weaponDef = WeaponDefs[weapon.weaponDef] ---@type table + if nonAntiAirTypes[weaponDef.type] or weaponDef.range < 100 then + return false + end + local damages = weaponDef.damages ---@type table + return damages[Game.armorTypes.vtol] > damages[Game.armorTypes.default] * 0.5 + end + + for unitDefID, unitDef in pairs(UnitDefs) do + local weapons = unitDef.weapons + if unitDef.isAirUnit then + airPriorityMultiplier[unitDefID] = (unitDef.isTransport or unitDef.isBuilder) and PRIORITY_VTOLS + or table.any(weapons, isBomberWeapon) and PRIORITY_BOMBERS + or table.any(weapons, isFighterWeapon) and PRIORITY_FIGHTERS + or table.any(weapons, isNotFakeWeapon) and PRIORITY_VTOLS + or PRIORITY_SCOUTS + end + for i = 1, #weapons do + if hasAntiAirPriorityWeapon(unitDef, weapons[i]) then + hasAntiAirPriority[weapons[i].weaponDef] = true + end + end + end +end + +-- weaponAimAdjustPriority is a unit weapon tag the engine does not expose to Lua, so read the unit file. +local aimAdjustPriority = {} -- unitDefID -> { [weaponNum] = value } + +local function getAimAdjustPriority(unitDefID, weaponNum) + local byWeapon = aimAdjustPriority[unitDefID] + if not byWeapon then + byWeapon = {} + aimAdjustPriority[unitDefID] = byWeapon + + local name = (UnitDefs[unitDefID] or {}).name:gsub("_scav$", "") + local files = VFS.DirList("units/", name .. ".lua", nil, true) + local ok, defs = false, nil + if files[1] then + ok, defs = pcall(VFS.Include, files[1]) + end + local unitDef = ok and type(defs) == "table" and defs[name] + if unitDef and type(unitDef.weapons) == "table" then + for index, weapon in pairs(unitDef.weapons) do + for key, value in pairs(weapon) do + if type(key) == "string" and key:lower() == "weaponaimadjustpriority" then + byWeapon[index] = tonumber(value) + end + end + end + end + end + return byWeapon[weaponNum] or 1 +end + +local function getSweepWeapon(unitDef) + local weapons = unitDef.weapons + if weaponNumber then + return weapons[weaponNumber] and weaponNumber or nil + end + for i = 1, #weapons do + local weapon = weapons[i] + local weaponDef = WeaponDefs[weapon.weaponDef] ---@type table + if + weapon.slavedTo == 0 + and not weaponDef.noAutoTarget + and weaponDef.interceptor == 0 + and not weaponDef.isShield + and not weaponDef.customParams.bogus + and not (unitDef.canManualFire and weaponDef.manualFire) + then + return i + end + end +end + +local function intersects(categorySet, unitCategories) + for category in pairs(categorySet) do + if unitCategories[category] then + return true + end + end + return false +end + +-------------------------------------------------------------------------------- +-- Autotargeting --------------------------------------------------------------- + +local function canReadAllyTeam(allyTeam) + local _, fullview = spGetSpectatingState() + return fullview or allyTeam == spGetMyAllyTeamID() +end + +local function isCrashing(unitID, fullview) + -- Not sure about this: + if not fullview then + return false + end + local moveData = spGetUnitMoveTypeData(unitID) ---@type table? + return moveData ~= nil and moveData.aircraftState == "crashing" +end + +local function resetSweep() + candidateCount = 0 + selectionOrder = {} + currentTargetID, chaseTargetID = nil, nil +end + +local function selectAttacker() + local selected = spGetSelectedUnits() + if #selected ~= 1 then + attackerID = nil + statusText = #selected == 0 and "Select a unit" or "Select a single unit" + return false + end + + local unitID = selected[1] + local unitDefID = spGetUnitDefID(unitID) + if not unitDefID then + attackerID = nil + statusText = "Unknown unit definition" + return false + end + + if not canReadAllyTeam(spGetUnitAllyTeam(unitID)) then + attackerID = nil + statusText = "Cannot read the targeting view of an enemy unit" + return false + end + + local weaponNum = getSweepWeapon(UnitDefs[unitDefID]) + if not weaponNum then + attackerID = nil + statusText = weaponNumber and ("No weapon " .. weaponNumber) or "No autotargeting weapon" + return false + end + + attackerID, attackerDefID, attackerWeapon = unitID, unitDefID, weaponNum + return true +end + +---Mirrors CWeapon::TestTarget for the checks Lua can see. Neutrals are checked in fire state. +local function passesTestTarget(unitID, targetDefID, onlyTargets, waterWeapon, aimY, fullview) + if targetDefID and not intersects(onlyTargets, UnitDefs[targetDefID].springCategories) then + return false + end + if spGetUnitIsDead(unitID) then + return false + end + if not fireAtCrashing and isCrashing(unitID, fullview) then + return false + end + if not waterWeapon and aimY < 0 then + return false + end + return true +end + +local function byPriority(a, b) + return a.priority < b.priority +end + +local function sweepTargets() + local frame = spGetGameFrame() + local unitID, weaponNum = attackerID, attackerWeapon + local unitDef = UnitDefs[attackerDefID] ---@type table + local weapon = unitDef.weapons[weaponNum] ---@type table + local weaponDef = WeaponDefs[weapon.weaponDef] + local allyTeam = spGetUnitAllyTeam(unitID) + local _, fullview = spGetSpectatingState() + + local ownerX, ownerY, ownerZ = spGetUnitPosition(unitID) + local aimX, aimY, aimZ, dirX, dirY, dirZ = spGetUnitWeaponVectors(unitID, weaponNum) + if not ownerX or not aimX then + resetSweep() + return + end + + local range = spGetUnitWeaponState(unitID, weaponNum, "range") + local rangeBoost = spGetUnitWeaponState(unitID, weaponNum, "autoTargetRangeBoost") + local salvoSize = spGetUnitWeaponState(unitID, weaponNum, "burst") + local reloadTime = math_max(spGetUnitWeaponState(unitID, weaponNum, "reloadTime"), 1 / 30) + local heightMod = weaponDef.heightMod + local proximityPriority = weaponDef.proximityPriority + local aimAdjust = getAimAdjustPriority(attackerDefID, weaponNum) + + local damages = {} + for armorType = 0, armorTypeCount - 1 do + damages[armorType] = spGetUnitWeaponDamages(unitID, weaponNum, armorType) or 0 + end + local secDamage = damages[0] * salvoSize / reloadTime + local paralyzer = (spGetUnitWeaponDamages(unitID, weaponNum, "paralyzeDamageTime") or 0) ~= 0 + + local _, _, minMapHeight = spGetGroundExtremes() + minMapHeight = math_max(0, minMapHeight) + local scanRadius = range + rangeBoost + (aimY - minMapHeight) * heightMod + + local states = spGetUnitStates(unitID) + local fireState = states.firestate + local lastAttacker = spGetUnitLastAttacker(unitID) + if (lastAttackFrame[unitID] or -LAST_ATTACKER_FRAMES) + LAST_ATTACKER_FRAMES <= frame then + lastAttacker = nil + end + + local badTargets = weapon.badTargets + local onlyTargets = weapon.onlyTargets + local antiAir = hasAntiAirPriority[weapon.weaponDef] + + candidateCount = 0 + local nearby = spGetUnitsInCylinder(ownerX, ownerZ, scanRadius) + for i = 1, #nearby do + local targetID = nearby[i] + if spGetUnitAllyTeam(targetID) ~= allyTeam then + local losState = spGetUnitLosState(targetID, allyTeam, true) + local inLos = hasBit(losState, LOS_INLOS) + local inRadar = hasBit(losState, LOS_INRADAR) + local prevLos = inLos or hasBit(losState, LOS_PREVLOS) + local targetDefID = spGetUnitDefID(targetID) + local targetDef = targetDefID and UnitDefs[targetDefID] + local posX, posY, posZ, _, _, _, targetX, targetY, targetZ = spGetUnitPosition(targetID, true, true) + + if + posX + and (inLos or inRadar) + and passesTestTarget(targetID, targetDefID, onlyTargets, weaponDef.waterWeapon, targetY, fullview) + and (fireState >= FIRESTATE_FIREATNEUTRAL or not spGetUnitNeutral(targetID)) + then + -- Radar blips are aimed at the error position, which GetUnitPosition already applies. + if not inLos then + targetX, targetY, targetZ = posX, posY, posZ + end + + local heightDiff = (targetY - aimY) * heightMod + local modRange = math_sqrt(math_max(0, (range + rangeBoost) ^ 2 - heightDiff ^ 2)) + local dx, dz = targetX - ownerX, targetZ - ownerZ + local dist2D = math_sqrt(dx * dx + dz * dz) + + if dist2D <= modRange then + local dy = targetY - ownerY + local length = math_sqrt(dx * dx + dy * dy + dz * dz) + local angleOffset = 1 + if length > 0 then + angleOffset = 1 - (dirX * dx + dirY * dy + dirZ * dz) / length + end + local angleMul = (angleOffset * aimAdjust + 1) ^ 2 + local rangeMul = dist2D * proximityPriority + modRange * 0.4 + 100 + + local health, maxHealth, paralyzeDamage = spGetUnitHealth(targetID) + local armored, armorMultiple = spGetUnitArmored(targetID) + local damageMul = 1 + if targetDef then + damageMul = math_max(0.0001, damages[targetDef.armorType] * (armored and armorMultiple or 1)) + end + + local priority = angleMul * rangeMul + local healthMul, valueDiv = 1, 1 + local badCategory, crashing, isLastAttacker, paralyzed, beyondRange = + false, false, false, false, false + + if not inLos then + priority = priority * PRIORITY_RADAR_ONLY + end + if dist2D > range then + beyondRange = true + priority = priority * PRIORITY_BEYOND_RANGE + end + + if inLos and health then + healthMul = secDamage + health + if paralyzer and paralyzeDamage > (paralyzeOnMaxHealth and maxHealth or health) then + paralyzed = true + healthMul = healthMul * PRIORITY_PARALYZED + end + else + healthMul = secDamage + 10000 + end + priority = priority * healthMul + + if prevLos and targetDef then + valueDiv = damageMul * targetDef.power + priority = priority / valueDiv + badCategory = intersects(badTargets, targetDef.springCategories) + crashing = isCrashing(targetID, fullview) + isLastAttacker = targetID == lastAttacker + if badCategory then + priority = priority * PRIORITY_BAD_CATEGORY + end + if crashing then + priority = priority * PRIORITY_CRASHING + end + if isLastAttacker then + priority = priority * PRIORITY_LAST_ATTACKER + end + end + + local airMul = antiAir and targetDefID and airPriorityMultiplier[targetDefID] or 1 + priority = priority * airMul + + candidateCount = candidateCount + 1 + local candidate = candidates[candidateCount] + if not candidate then + candidate = {} + candidates[candidateCount] = candidate + end + candidate.unitID = targetID + candidate.unitDefID = targetDefID + candidate.priority = priority + candidate.height = targetDef and targetDef.height or 30 + candidate.inLos = inLos + candidate.badCategory = badCategory + candidate.beyondRange = beyondRange + candidate.crashing = crashing + candidate.lastAttacker = isLastAttacker + candidate.paralyzed = paralyzed + candidate.angleMul = angleMul + candidate.rangeMul = rangeMul + candidate.healthMul = healthMul + candidate.valueDiv = valueDiv + candidate.airMul = airMul + candidate.dist2D = dist2D + -- Autotargets may sit beyond range, where the Lua TryTarget rejects them. + candidate.aimable = spGetUnitWeaponTryTarget(unitID, weaponNum, targetID) + or ( + beyondRange + and spGetUnitWeaponTestTarget(unitID, weaponNum, targetID) + and spGetUnitWeaponHaveFreeLineOfFire(unitID, weaponNum, targetID) + ) + end + end + end + end + + for i = candidateCount + 1, #candidates do + candidates[i] = nil + end + + sort(candidates, byPriority) + + -- CWeapon::AutoTarget takes the first good target, else falls back to the best bad one. + selectionOrder = {} + for i = 1, candidateCount do + local candidate = candidates[i] + candidate.rank = i + if candidate.aimable and not candidate.badCategory then + selectionOrder[#selectionOrder + 1] = i + end + end + for i = 1, candidateCount do + local candidate = candidates[i] + if candidate.aimable and candidate.badCategory then + selectionOrder[#selectionOrder + 1] = i + end + end + + local targetType, _, target = spGetUnitWeaponTarget(unitID, weaponNum) + currentTargetID = targetType == 1 and target or nil + + -- The command AI's own sweep chases the closest visible enemy any weapon can test as a target. + chaseTargetID = nil + if unitDef.canMove and not unitDef.isAirUnit and fireState >= FIRESTATE_FIREATWILL then + local moveState = states.movestate + local chaseRadius = unitDef.maxWeaponRange + 200 * moveState * moveState + local closest = math.huge + local noChase = unitDef.noChaseCategories + local chaseable = spGetUnitsInCylinder(ownerX, ownerZ, chaseRadius) + for i = 1, #chaseable do + local targetID = chaseable[i] + local targetDefID = spGetUnitDefID(targetID) + if + targetDefID + and spGetUnitAllyTeam(targetID) ~= allyTeam + and hasBit(spGetUnitLosState(targetID, allyTeam, true), LOS_INLOS) + and not spGetUnitNeutral(targetID) + and not intersects(noChase, UnitDefs[targetDefID].springCategories) + then + local tx, _, tz = spGetUnitPosition(targetID) + local distance = tx and (tx - ownerX) ^ 2 + (tz - ownerZ) ^ 2 or math.huge + if distance < closest then + for w = 1, #unitDef.weapons do + if spGetUnitWeaponTestTarget(unitID, w, targetID) then + closest = distance + chaseTargetID = targetID + break + end + end + end + end + end + end + + statusText = format( + "%s · weapon %d %s · %d candidates · %s%s", + unitDef.name, + weaponNum, + weaponDef.name, + candidateCount, + showLines and "lines on" or "lines off", + showDetails and " · details on" or "" + ) +end + +-------------------------------------------------------------------------------- +-- Rendering ------------------------------------------------------------------- + +local LuaShader = gl.LuaShader + +local lineShader, lineVBO, lineVAO +local lineCapacity = 1536 -- vertices +local lineData = {} +local lineVertexCount = 0 + +local FLOATS_PER_VERTEX = 7 + +local vsSrc = [[ +#version 420 +#extension GL_ARB_uniform_buffer_object : require +#extension GL_ARB_shading_language_420pack: require + +layout (location = 0) in vec3 posAcross; // screen xy, signed distance across the stroke in [-1, 1] +layout (location = 1) in vec4 color; + +//__ENGINEUNIFORMBUFFERDEFS__ + +out vec4 v_color; +out float v_across; + +void main() { + vec2 ndc = posAcross.xy / viewGeometry.xy * 2.0 - 1.0; + gl_Position = vec4(ndc, 0.0, 1.0); + v_color = color; + v_across = posAcross.z; +} +]] + +local fsSrc = [[ +#version 420 +#extension GL_ARB_uniform_buffer_object : require +#extension GL_ARB_shading_language_420pack: require + +//__ENGINEUNIFORMBUFFERDEFS__ +//__DEFINES__ + +in vec4 v_color; +in float v_across; + +out vec4 fragColor; + +void main() { + float edge = 1.0 - smoothstep(1.0 - FEATHER, 1.0, abs(v_across)); + fragColor = vec4(v_color.rgb, v_color.a * edge); +} +]] + +local STROKE_FEATHER_PX = 1.0 + +local function initLineBuffers(capacity) + if lineVBO then + lineVBO:Delete() + lineVAO:Delete() + end + lineCapacity = capacity + lineVBO = gl.GetVBO(GL.ARRAY_BUFFER, true) + lineVBO:Define(capacity, { + { id = 0, name = "posAcross", size = 3 }, + { id = 1, name = "color", size = 4 }, + }) + lineVAO = gl.GetVAO() + lineVAO:AttachVertexBuffer(lineVBO) +end + +local function initShader() + local engineUniformBufferDefs = LuaShader.GetEngineUniformBufferDefs() + lineShader = LuaShader({ + vertex = vsSrc:gsub("//__ENGINEUNIFORMBUFFERDEFS__", engineUniformBufferDefs), + fragment = fsSrc + :gsub("//__ENGINEUNIFORMBUFFERDEFS__", engineUniformBufferDefs) + :gsub("//__DEFINES__", "#define FEATHER 0.5"), + }, "TargetPriorityLines") + return lineShader:Initialize() +end + +local function pushVertex(x, y, across, r, g, b, a) + local base = lineVertexCount * FLOATS_PER_VERTEX + lineData[base + 1] = x + lineData[base + 2] = y + lineData[base + 3] = across + lineData[base + 4] = r + lineData[base + 5] = g + lineData[base + 6] = b + lineData[base + 7] = a + lineVertexCount = lineVertexCount + 1 +end + +---A stroke is two triangles with the feather baked into its half width. +local function pushStroke(x1, y1, x2, y2, width, c1, c2) + local dx, dy = x2 - x1, y2 - y1 + local length = math_sqrt(dx * dx + dy * dy) + if length < 0.5 then + return + end + local half = width * 0.5 + STROKE_FEATHER_PX + local nx, ny = -dy / length * half, dx / length * half + c2 = c2 or c1 + pushVertex(x1 + nx, y1 + ny, -1, c1[1], c1[2], c1[3], c1[4]) + pushVertex(x1 - nx, y1 - ny, 1, c1[1], c1[2], c1[3], c1[4]) + pushVertex(x2 + nx, y2 + ny, -1, c2[1], c2[2], c2[3], c2[4]) + pushVertex(x2 + nx, y2 + ny, -1, c2[1], c2[2], c2[3], c2[4]) + pushVertex(x1 - nx, y1 - ny, 1, c1[1], c1[2], c1[3], c1[4]) + pushVertex(x2 - nx, y2 - ny, 1, c2[1], c2[2], c2[3], c2[4]) +end + +local function drawStrokes() + if lineVertexCount == 0 then + return + end + if lineVertexCount > lineCapacity then + initLineBuffers(lineVertexCount * 2) + end + for i = lineVertexCount * FLOATS_PER_VERTEX + 1, #lineData do + lineData[i] = nil + end + lineVBO:Upload(lineData) + + gl.Blending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) + gl.DepthTest(false) + lineShader:Activate() + lineVAO:DrawArrays(GL.TRIANGLES, lineVertexCount) + lineShader:Deactivate() +end + +-- Viridis gradient from the most preferred target to the least. +local GRADIENT = { + { 0.992, 0.906, 0.145 }, + { 0.369, 0.788, 0.384 }, + { 0.129, 0.569, 0.549 }, + { 0.231, 0.322, 0.545 }, +} + +local COLOR_IGNORED = { 0.62, 0.62, 0.66, 1.0 } +local COLOR_OUTLINE = { 0.0, 0.0, 0.0, 0.85 } +local COLOR_ATTACKER = { 1.0, 1.0, 1.0, 0.9 } + +local function gradientColor(t) + local segments = #GRADIENT - 1 + local segment = math_min(segments, math_floor(t * segments) + 1) + local a, b = GRADIENT[segment], GRADIENT[segment + 1] + local s = t * segments - (segment - 1) + return { a[1] + (b[1] - a[1]) * s, a[2] + (b[2] - a[2]) * s, a[3] + (b[3] - a[3]) * s, 1.0 } +end + +local function colorByte(value) + return math_floor(math_max(0, math_min(1, value)) * 255 + 0.5) +end + +local function colorizeText(text, color, alpha) + return colorAndOutlineCode + .. char( + colorByte(color[1]), + colorByte(color[2]), + colorByte(color[3]), + colorByte(alpha), + colorByte(COLOR_OUTLINE[1]), + colorByte(COLOR_OUTLINE[2]), + colorByte(COLOR_OUTLINE[3]), + colorByte(COLOR_OUTLINE[4] * alpha) + ) + .. text +end + +local function formatPriority(value) + if value >= 100 then + return format("%.0f", value) + elseif value >= 10 then + return format("%.1f", value) + end + return format("%.2f", value) +end + +local function formatFactors(candidate) + local factors = format( + "angle %.2f range %.0f health %s value %s", + candidate.angleMul, + candidate.rangeMul, + formatPriority(candidate.healthMul), + formatPriority(candidate.valueDiv) + ) + if not candidate.inLos then + factors = factors .. " radar ×10" + end + if candidate.beyondRange then + factors = factors .. " beyond range ×1e5" + end + if candidate.badCategory then + factors = factors .. " bad category ×100" + end + if not candidate.aimable then + factors = factors .. " cannot aim" + end + if candidate.crashing then + factors = factors .. " crashing ×1000" + end + if candidate.paralyzed then + factors = factors .. " paralyzed ×4" + end + if candidate.lastAttacker then + factors = factors .. " last attacker ×0.5" + end + if candidate.airMul ~= 1 then + factors = factors .. format(" air ×%g", candidate.airMul) + end + return factors +end + +local font, fontSize +local labelScale = 0.6 + +local function refreshFont() + if WG.fonts then + font, fontSize = WG.fonts.getFont(2, labelScale, 0.22, 1.6) + else + fontSize = math_floor(17 * (vsy / 1080) + 0.5) + font = gl.LoadFont("fonts/" .. Spring.GetConfigString("bar_font2", "Exo2-SemiBold.otf"), fontSize, 4, 1.6) + end +end + +local screenX, screenY = {}, {} -- candidate index -> label anchor + +function widget:DrawScreenEffects() + if not active or spIsGUIHidden() then + return + end + + local labelSize = fontSize + local valueSize = fontSize * 0.8 + local factorSize = fontSize * 0.7 + + lineVertexCount = 0 + + local attackerX, attackerY + if attackerID and spValidUnitID(attackerID) then + local ux, uy, uz = spGetUnitPosition(attackerID) + if ux then + attackerX, attackerY = spWorldToScreenCoords(ux, uy, uz) + end + end + + local lastIndex = math_max(1, candidateCount - 1) + for i = 1, candidateCount do + local candidate = candidates[i] + local ux, uy, uz = spGetUnitPosition(candidate.unitID) + if ux then + local sx, sy = spWorldToScreenCoords(ux, uy + candidate.height + 12, uz) + screenX[i], screenY[i] = sx, sy + local ignored = candidate.badCategory or not candidate.aimable + local color = ignored and COLOR_IGNORED or gradientColor((i - 1) / lastIndex) + candidate.color = color + + if candidate.badCategory then + -- An X above the label marks a bad-category target. + local size = labelSize * 0.32 + local cx, cy = sx, sy + (showDetails and labelSize * 2.3 or labelSize * 1.2) + pushStroke(cx - size, cy - size, cx + size, cy + size, 2.5, color) + pushStroke(cx - size, cy + size, cx + size, cy - size, 2.5, color) + end + if candidate.unitID == currentTargetID then + -- An underline marks the weapon's current target. + local width = labelSize * 0.8 + pushStroke(sx - width, sy - 2, sx + width, sy - 2, 2.0, color) + end + else + screenX[i] = nil + end + end + + if showLines and attackerX then + local px, py, pc = attackerX, attackerY, COLOR_ATTACKER + for n = 1, #selectionOrder do + local i = selectionOrder[n] + if screenX[i] then + local candidate = candidates[i] + pushStroke(px, py, screenX[i], screenY[i] - 4, 2.0, pc, candidate.color) + px, py, pc = screenX[i], screenY[i] - 4, candidate.color + end + end + end + + drawStrokes() + + font:Begin() + font:Print(colorizeText(statusText, COLOR_ATTACKER, 1.0), vsx * 0.5, vsy * 0.86, valueSize, "co") + if hintText then + font:Print(colorizeText(hintText, COLOR_IGNORED, 1.0), vsx * 0.5, vsy * 0.86 - valueSize * 1.3, valueSize, "co") + end + + for i = 1, candidateCount do + local sx = screenX[i] + if sx then + local sy = screenY[i] + local candidate = candidates[i] + local alpha = candidate.aimable and 1.0 or 0.55 + local value = formatPriority(candidate.priority) + font:Print(colorizeText(value, candidate.color, alpha), sx, sy, valueSize, "co") + if showDetails then + local rank = candidate.aimable and tostring(i) or ("(" .. i .. ")") + if candidate.unitID == chaseTargetID then + rank = rank .. " chase" + end + font:Print(colorizeText(rank, candidate.color, alpha), sx, sy + valueSize * 1.1, labelSize, "co") + font:Print( + colorizeText(formatFactors(candidate), candidate.color, alpha), + sx, + sy - factorSize * 1.2, + factorSize, + "co" + ) + end + end + end + font:End() +end + +-------------------------------------------------------------------------------- +-- Engine callins -------------------------------------------------------------- + +function widget:GameFrame() + if not active then + return + end + if selectAttacker() then + sweepTargets() + else + resetSweep() + end +end + +function widget:UnitDamaged(unitID) + if unitID == attackerID then + lastAttackFrame[unitID] = spGetGameFrame() + end +end + +function widget:UnitDestroyed(unitID) + lastAttackFrame[unitID] = nil +end + +function widget:ViewResize() + vsx, vsy = spGetViewGeometry() + refreshFont() +end + +function widget:Initialize() + if not initShader() then + Spring.Log(widget:GetInfo().name, LOG.ERROR, "Shader compilation failed") + widgetHandler:RemoveWidget() + return + end + initLineBuffers(lineCapacity) + refreshFont() + + local function debugTargetPriorityCmd(_, _, words) + hintText = nil + if not words[1] then + active = not active + else + local show, lines, details, weapon = true, false, false, nil + local index = 1 + while words[index] do + local option = words[index]:lower() + if option == "on" then + show = true + elseif option == "off" then + show = false + elseif option == "lines" then + lines = true + elseif option == "details" then + details = true + elseif option == "weapon" then + index = index + 1 + weapon = tonumber(words[index]) + if not weapon then + hintText = "Options: on, off, lines, details, weapon " + end + else + hintText = "Options: on, off, lines, details, weapon " + end + index = index + 1 + end + showLines, showDetails, weaponNumber = lines, details, weapon + active = show + end + if active then + widget:GameFrame() -- run an immediate check for a selected unit in case we're paused + else + resetSweep() + end + return true + end + widgetHandler:AddAction("debugtargetpriority", debugTargetPriorityCmd, nil, "t") +end + +function widget:Shutdown() + widgetHandler:RemoveAction("debugtargetpriority", "t") + if lineShader then + lineShader:Finalize() + end + if lineVBO then + lineVBO:Delete() + lineVAO:Delete() + end +end diff --git a/luaui/Widgets/gui_gridmenu.lua b/luaui/Widgets/gui_gridmenu.lua index aabfc5c9327..ed8bd23f0f9 100644 --- a/luaui/Widgets/gui_gridmenu.lua +++ b/luaui/Widgets/gui_gridmenu.lua @@ -351,8 +351,8 @@ end local backgroundRect = Rect:new(0, 0, 0, 0) local backRect = Rect:new(0, 0, 0, 0, { - name = "Back", - keyText = "Shift", + name = BAR.I18N("ui.buildMenu.back"), + keyText = keyConfig.sanitizeKey("shift", currentLayout), }) local nextPageRect = Rect:new(0, 0, 0, 0) local categoriesRect = Rect:new(0, 0, 0, 0) @@ -1864,6 +1864,7 @@ end -- PERF: It seems we get i18n resources inside draw functions, we should do that in state instead function widget:LanguageChanged() + backRect.opts.name = BAR.I18N("ui.buildMenu.back") refreshUnitDefs() redraw = true end diff --git a/luaui/Widgets/gui_keybind_info.lua b/luaui/Widgets/gui_keybind_info.lua index aa9d12934e9..5e3cad12d1d 100644 --- a/luaui/Widgets/gui_keybind_info.lua +++ b/luaui/Widgets/gui_keybind_info.lua @@ -13,62 +13,19 @@ function widget:GetInfo() end -- Localized functions for performance -local mathCeil = math.ceil local mathFloor = math.floor -local mathMax = math.max -- Localized Spring API for performance local spGetViewGeometry = Spring.GetViewGeometry -local keyConfig = VFS.Include("luaui/configs/keyboard_layouts.lua") -local currentLayout - -local keybindsText -local lineType = { - title = 1, - blank = 2, - key = 3, -} - -local tabs = { - "Keybindings", - "Grid Keys", - "Grid CTRL Keys", - "Grid ALT Keys", - "Legacy Keys", - "Legacy CTRL Keys", - "Legacy ALT Keys", -} - -local keybindsimages = { - ["Grid Keys"] = "luaui/images/keybinds/grid_keys.png", - ["Grid CTRL Keys"] = "luaui/images/keybinds/grid_keys_CTRL.png", - ["Grid ALT Keys"] = "luaui/images/keybinds/grid_keys_ALT.png", - ["Legacy Keys"] = "luaui/images/keybinds/legacy_keys.png", - ["Legacy CTRL Keys"] = "luaui/images/keybinds/legacy_keys_CTRL.png", - ["Legacy ALT Keys"] = "luaui/images/keybinds/legacy_keys_ALT.png", -} - -local tabrects = {} -local lasttab = "Keybindings" +local keybindEditor = VFS.Include("luaui/Include/keybind_editor_view.lua") local doUpdate -local actionHotkeys - -local function getActionHotkey(action) - local key = actionHotkeys[action] - - if not key then - return "(none)" - end - - return keyConfig.sanitizeKey(key, currentLayout):gsub("%+", " + ") -end local vsx, vsy = spGetViewGeometry() -local screenHeightOrg = 550 -local screenWidthOrg = 1050 +local screenHeightOrg = 760 +local screenWidthOrg = 1180 local screenHeight = screenHeightOrg local screenWidth = screenWidthOrg @@ -76,14 +33,14 @@ local glCreateList = gl.CreateList local glCallList = gl.CallList local glDeleteList = gl.DeleteList -local RectRound, UiElement, elementCorner = WG.FlowUI.elementCorner +---@type function +local RectRound +---@type function +local UiElement +local elementCorner = WG.FlowUI.elementCorner local showOnceMore = false -local keybindColor = "\255\235\185\070" -local titleColor = "\255\254\254\254" -local descriptionColor = "\255\192\190\180" - local widgetScale = (vsy / 1080) local centerPosX = 0.5 local centerPosY = 0.5 @@ -91,57 +48,24 @@ local screenX = mathFloor((vsx * centerPosX) - (screenWidth / 2)) local screenY = mathFloor((vsy * centerPosY) + (screenHeight / 2)) local math_isInRect = math.isInRect -local font, font2, titleRect, keybinds, backgroundGuishader, show - -local function drawTextTable(lines, x, y) - local lineIndex = 0 - local height = 0 - local width = 0 - local fontSize = (screenHeight * 0.96) / mathCeil(#keybindsText / 3) - font:Begin() - for _, line in pairs(lines) do - if line.type == lineType.blank then - -- nothing here - elseif line.type == lineType.title then - -- title line - local title = line.text - local text = titleColor .. title - font:Print(text, x + 4, y - ((fontSize * 0.92) * lineIndex) + 5, fontSize) - screenWidth = mathMax(font:GetTextWidth(text) * 13, screenWidth) - elseif line.type == lineType.key then - -- keybind line - local bind = string.upper(line.key) - local description = line.text - local text = keybindColor .. bind .. " " .. descriptionColor .. description - font:Print(text, x + 14, y - (fontSize * 0.92) * lineIndex, fontSize * 0.8) - width = mathMax(font:GetTextWidth(text) * 11, width) - end - height = height + 13 - - lineIndex = lineIndex + 1 - -- dont let the first line of a column be blank - if lineIndex == 1 and line.blankLine then - lineIndex = lineIndex - 1 - end - end - font:End() - - return x, lineIndex -end - -local function drawWindow(activetab) - local activetab = activetab or lasttab - if activetab == nil then - activetab = "Keybindings" - end - - -- background +local keybinds, backgroundGuishader, show, wasShown +-- Input ownership is taken once on open and given back on close, rather than every +-- frame, so chat's handling is restored exactly as it was found. +local ownsInput = false +local panelHasInput = false +local textInputStarted = false +-- Keys already down when the panel opened. Their release has to be let through, or +-- whatever they started stays stuck on once the panel starts swallowing releases. +local heldAtOpen = {} + +-- Panel backdrop, baked into a display list rather than redrawn per frame. +local function drawWindow() UiElement( screenX, screenY - screenHeight, screenX + screenWidth, screenY, - 0, + 1, 1, 1, 1, @@ -151,426 +75,13 @@ local function drawWindow(activetab) 1, WG.FlowUI.clampedOpacity ) - - local titleFontSize = 18 * widgetScale - - local tabx = 0 - for i, tab in ipairs(tabs) do - local tabwidth = font2:GetTextWidth(tab) - tabrects[tab] = { - mathFloor(screenX + tabx), - screenY, - mathFloor(screenX + tabx + tabwidth * titleFontSize + (titleFontSize * 1.5)), - mathFloor(screenY + (titleFontSize * 1.7)), - tabx, - } - tabx = tabx + (tabwidth * titleFontSize + (titleFontSize * 1.5)) - gl.Color(0, 0, 0, WG.FlowUI.clampedOpacity) - RectRound(tabrects[tab][1], tabrects[tab][2], tabrects[tab][3], tabrects[tab][4], elementCorner, 1, 1, 0, 0) - end - - -- title - font2:Begin() - font2:SetTextColor(1, 1, 1, 1) - font2:SetOutlineColor(0, 0, 0, 0.4) - for i, tab in ipairs(tabs) do - local tabcolor = keybindColor - if tab ~= activetab then - tabcolor = titleColor - end - font2:Print( - tabcolor .. tab, - screenX + (titleFontSize * 0.75) + tabrects[tab][5], - screenY + (8 * widgetScale), - titleFontSize, - "on" - ) - end - font2:End() - - if activetab ~= "Keybindings" and 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 - local entriesPerColumn = mathCeil(#keybindsText / 3) - local entries1 = {} - local entries2 = {} - local entries3 = {} - for k, line in pairs(keybindsText) do - if k <= entriesPerColumn then - entries1[#entries1 + 1] = line - elseif k > entriesPerColumn and k <= entriesPerColumn * 2 then - entries2[#entries2 + 1] = line - else - entries3[#entries3 + 1] = line - end - end - local textPadding = 8 * widgetScale - local textTopPadding = 28 * widgetScale - local x = screenX + textPadding - drawTextTable(entries1, x, screenY - textTopPadding) - x = x + (350 * widgetScale) - drawTextTable(entries2, x, screenY - textTopPadding) - x = x + (350 * widgetScale) - drawTextTable(entries3, x, screenY - textTopPadding) - - gl.Color(1, 1, 1, 1) - font:Begin() - font:Print( - BAR.I18N("ui.keybinds.disclaimer"), - screenX + (12 * widgetScale), - screenY - screenHeight + (34 * widgetScale), - 12.5 * widgetScale - ) - font:Print( - BAR.I18N("ui.keybinds.howtochangekeybinds"), - screenX + (12 * widgetScale), - screenY - screenHeight + (20 * widgetScale), - 12.5 * widgetScale - ) - font:End() - end end local function refreshText() - actionHotkeys = VFS.Include("luaui/Include/action_hotkeys.lua") - currentLayout = Spring.GetConfigString("KeyboardLayout", "qwerty") - - keybindsText = { - { type = lineType.title, text = BAR.I18N("ui.keybinds.chat.title") }, - { type = lineType.key, key = getActionHotkey("chat"), text = BAR.I18N("ui.keybinds.chat.send") }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.chat.alliesKey"), - text = BAR.I18N("ui.keybinds.chat.allies"), - }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.chat.spectatorsKey"), - text = BAR.I18N("ui.keybinds.chat.spectators"), - }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.chat.ignoreKey"), - text = BAR.I18N("ui.keybinds.chat.ignore"), - }, - { type = lineType.blank }, - { type = lineType.title, text = BAR.I18N("ui.keybinds.menus.title") }, - { type = lineType.key, key = getActionHotkey("options"), text = BAR.I18N("ui.keybinds.menus.settings") }, - { type = lineType.key, key = getActionHotkey("sharedialog"), text = BAR.I18N("ui.keybinds.menus.share") }, - { type = lineType.blank }, - { type = lineType.title, text = BAR.I18N("ui.keybinds.camera.title") }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.camera.zoomKey"), - text = BAR.I18N("ui.keybinds.camera.zoom"), - }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.camera.panKey"), - text = BAR.I18N("ui.keybinds.camera.pan"), - }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.camera.tiltKey"), - text = BAR.I18N("ui.keybinds.camera.tilt"), - }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.camera.dragKey"), - text = BAR.I18N("ui.keybinds.camera.drag"), - }, - { type = lineType.key, key = getActionHotkey("cameraflip"), text = BAR.I18N("ui.keybinds.camera.flip") }, - { type = lineType.blank }, - { type = lineType.title, text = BAR.I18N("ui.keybinds.cameraModes.title") }, - { - type = lineType.key, - key = getActionHotkey("viewta") .. ", " .. getActionHotkey("viewspring"), - text = BAR.I18N("ui.keybinds.cameraModes.change"), - }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.cameraModes.fullscreenKey"), - text = BAR.I18N("ui.keybinds.cameraModes.fullscreen"), - }, - { - type = lineType.key, - key = getActionHotkey("toggleoverview"), - text = BAR.I18N("ui.keybinds.cameraModes.overview"), - }, - { - type = lineType.key, - key = getActionHotkey("togglelos"), - text = BAR.I18N("ui.keybinds.cameraModes.los"), - }, - { - type = lineType.key, - key = getActionHotkey("showelevation"), - text = BAR.I18N("ui.keybinds.cameraModes.heightmap"), - }, - { - type = lineType.key, - key = getActionHotkey("showpathtraversability"), - text = BAR.I18N("ui.keybinds.cameraModes.traversability"), - }, - { - type = lineType.key, - key = getActionHotkey("lastmsgpos"), - text = BAR.I18N("ui.keybinds.cameraModes.mapmarks"), - }, - { - type = lineType.key, - key = getActionHotkey("showmetalmap"), - text = BAR.I18N("ui.keybinds.cameraModes.resourceSpots"), - }, - { - type = lineType.key, - key = getActionHotkey("hideinterface"), - text = BAR.I18N("ui.keybinds.cameraModes.interface"), - }, - { type = lineType.blank }, - { type = lineType.title, text = BAR.I18N("ui.keybinds.sound.title") }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.sound.volumeKey"), - text = BAR.I18N("ui.keybinds.sound.volume"), - }, - { type = lineType.key, key = getActionHotkey("mutesound"), text = BAR.I18N("ui.keybinds.sound.mute") }, - { type = lineType.blank }, - { type = lineType.title, text = BAR.I18N("ui.keybinds.selection.title") }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.selection.unitsKey"), - text = BAR.I18N("ui.keybinds.selection.units"), - }, - { type = lineType.blank }, - { type = lineType.title, text = BAR.I18N("ui.keybinds.issueContextOrders.title") }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.issueContextOrders.orderKey"), - text = BAR.I18N("ui.keybinds.issueContextOrders.order"), - }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.issueContextOrders.formationOrderKey"), - text = BAR.I18N("ui.keybinds.issueContextOrders.formationOrder"), - }, - { type = lineType.blank }, - { type = lineType.title, text = BAR.I18N("ui.keybinds.orders.title") }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.orders.defaultKey"), - text = BAR.I18N("ui.keybinds.orders.default"), - }, - { type = lineType.key, key = getActionHotkey("move"), text = BAR.I18N("ui.keybinds.orders.move") }, - { type = lineType.key, key = getActionHotkey("attack"), text = BAR.I18N("ui.keybinds.orders.attack") }, - { - type = lineType.key, - key = getActionHotkey("settarget"), - text = BAR.I18N("ui.keybinds.orders.setTarget"), - }, - { type = lineType.key, key = getActionHotkey("repair"), text = BAR.I18N("ui.keybinds.orders.repair") }, - { type = lineType.key, key = getActionHotkey("reclaim"), text = BAR.I18N("ui.keybinds.orders.reclaim") }, - { - type = lineType.key, - key = getActionHotkey("resurrect"), - text = BAR.I18N("ui.keybinds.orders.resurrect"), - }, - { type = lineType.key, key = getActionHotkey("fight"), text = BAR.I18N("ui.keybinds.orders.fight") }, - { type = lineType.key, key = getActionHotkey("patrol"), text = BAR.I18N("ui.keybinds.orders.patrol") }, - { type = lineType.key, key = getActionHotkey("wantcloak"), text = BAR.I18N("ui.keybinds.orders.cloak") }, - { type = lineType.blank }, - { type = lineType.key, key = getActionHotkey("stop"), text = BAR.I18N("ui.keybinds.orders.stop") }, - { type = lineType.key, key = getActionHotkey("wait"), text = BAR.I18N("ui.keybinds.orders.wait") }, - { - type = lineType.key, - key = getActionHotkey("canceltarget"), - text = BAR.I18N("ui.keybinds.orders.cancelTarget"), - }, - { type = lineType.blank }, - { type = lineType.key, key = getActionHotkey("manualfire"), text = BAR.I18N("ui.keybinds.orders.dGun") }, - { type = lineType.key, key = getActionHotkey("selfd"), text = BAR.I18N("ui.keybinds.orders.selfDestruct") }, - { type = lineType.blank }, - { type = lineType.title, text = BAR.I18N("ui.keybinds.issueOrders.title") }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.issueOrders.orderKey"), - text = BAR.I18N("ui.keybinds.issueOrders.order"), - }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.issueOrders.revertKey"), - text = BAR.I18N("ui.keybinds.issueOrders.revert"), - }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.issueOrders.formationKey"), - text = BAR.I18N("ui.keybinds.issueOrders.formation"), - }, - { type = lineType.blank }, - { type = lineType.title, text = BAR.I18N("ui.keybinds.queues.title") }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.queues.appendKey"), - text = BAR.I18N("ui.keybinds.queues.append"), - }, - { - type = lineType.key, - key = getActionHotkey("commandinsert"), - text = BAR.I18N("ui.keybinds.queues.prepend"), - }, - { type = lineType.blank }, - { type = lineType.title, text = BAR.I18N("ui.keybinds.buildOrders.title") }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.buildOrders.selectTileKey"), - text = BAR.I18N("ui.keybinds.buildOrders.selectTile"), - }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.buildOrders.metalKey"), - text = BAR.I18N("ui.keybinds.buildOrders.metal"), - }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.buildOrders.energyKey"), - text = BAR.I18N("ui.keybinds.buildOrders.energy"), - }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.buildOrders.intelKey"), - text = BAR.I18N("ui.keybinds.buildOrders.intel"), - }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.buildOrders.factoriesKey"), - text = BAR.I18N("ui.keybinds.buildOrders.factories"), - }, - { - type = lineType.key, - key = getActionHotkey("buildfacing_inc") .. " / " .. getActionHotkey("buildfacing_dec"), - text = BAR.I18N("ui.keybinds.buildOrders.rotate"), - }, - { type = lineType.blank }, - { type = lineType.title, text = BAR.I18N("ui.keybinds.issueBuildOrders.title") }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.issueBuildOrders.orderKey"), - text = BAR.I18N("ui.keybinds.issueBuildOrders.order"), - }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.issueBuildOrders.deselect"), - text = BAR.I18N("ui.keybinds.issueBuildOrders.deselect"), - }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.issueBuildOrders.lineKey"), - text = BAR.I18N("ui.keybinds.issueBuildOrders.line"), - }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.issueBuildOrders.gridKey"), - text = BAR.I18N("ui.keybinds.issueBuildOrders.grid"), - }, - { - type = lineType.key, - key = getActionHotkey("buildspacing_inc"), - text = BAR.I18N("ui.keybinds.issueBuildOrders.spacingUp"), - }, - { - type = lineType.key, - key = getActionHotkey("buildspacing_dec"), - text = BAR.I18N("ui.keybinds.issueBuildOrders.spacingDown"), - }, - { type = lineType.blank }, - { type = lineType.title, text = BAR.I18N("ui.keybinds.massSelect.title") }, - { - type = lineType.key, - key = getActionHotkey("select_AllMap++_ClearSelection_SelectAll+"), - text = BAR.I18N("ui.keybinds.massSelect.all"), - }, - { - type = lineType.key, - key = getActionHotkey("select_AllMap+_Builder_Idle+_ClearSelection_SelectOne+"), - text = BAR.I18N("ui.keybinds.massSelect.builders"), - }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.massSelect.createGroupKey"), - text = BAR.I18N("ui.keybinds.massSelect.createGroup"), - }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.massSelect.createAutoGroupKey"), - text = BAR.I18N("ui.keybinds.massSelect.createAutoGroup"), - }, - { - type = lineType.key, - key = getActionHotkey("remove_from_autogroup"), - text = BAR.I18N("ui.keybinds.massSelect.removeAutoGroup"), - }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.massSelect.groupKey"), - text = BAR.I18N("ui.keybinds.massSelect.group"), - }, - { - type = lineType.key, - key = getActionHotkey("select_AllMap+_InPrevSel+_ClearSelection_SelectAll+"), - text = BAR.I18N("ui.keybinds.massSelect.sameType"), - }, - { - type = lineType.key, - key = getActionHotkey( - "select_PrevSelection+_Not_Building_Not_RelativeHealth_60+_ClearSelection_SelectAll+" - ), - text = BAR.I18N("ui.keybinds.massSelect.damaged"), - }, - { type = lineType.blank }, - { type = lineType.title, text = BAR.I18N("ui.keybinds.drawing.title") }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.drawing.mapmarkKey"), - text = BAR.I18N("ui.keybinds.drawing.mapmark"), - }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.drawing.drawKey"), - text = BAR.I18N("ui.keybinds.drawing.draw"), - }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.drawing.eraseKey"), - text = BAR.I18N("ui.keybinds.drawing.erase"), - }, - { type = lineType.blank }, - { type = lineType.title, text = BAR.I18N("ui.keybinds.console.title") }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.console.eraseKey"), - text = BAR.I18N("ui.keybinds.console.erase"), - }, - { - type = lineType.key, - key = BAR.I18N("ui.keybinds.console.pauseKey"), - text = BAR.I18N("ui.keybinds.console.pause"), - }, - } + keybindEditor.refresh() end +-- Rebuilds every rect and display list against the new screen size. function widget:ViewResize() vsx, vsy = spGetViewGeometry() widgetScale = (vsy / 1080) @@ -580,14 +91,21 @@ function widget:ViewResize() screenX = mathFloor((vsx * centerPosX) - (screenWidth / 2)) screenY = mathFloor((vsy * centerPosY) + (screenHeight / 2)) - font = WG.fonts.getFont() - font2 = WG.fonts.getFont(2) - elementCorner = WG.FlowUI.elementCorner RectRound = WG.FlowUI.Draw.RectRound UiElement = WG.FlowUI.Draw.Element + keybindEditor.init() + local pad = mathFloor(8 * widgetScale) + keybindEditor.setArea( + screenX + pad, + screenY - screenHeight + pad, + screenX + screenWidth - pad, + screenY - pad, + widgetScale + ) + if keybinds then gl.DeleteList(keybinds) end @@ -603,6 +121,7 @@ function widget:ViewResize() end end +-- Draws the panel and keeps the guishader rect in step with it. function widget:DrawScreen() -- draw the help if doUpdate then @@ -623,14 +142,10 @@ function widget:DrawScreen() if show or showOnceMore then gl.Texture(false) -- some other widget left it on glCallList(keybinds) + keybindEditor.draw() if WG.guishader and backgroundGuishader == nil then backgroundGuishader = glCreateList(function() - -- background RectRound(screenX, screenY - screenHeight, screenX + screenWidth, screenY, elementCorner, 0, 1, 1, 1) - -- title - for k, tabrect in pairs(tabrects) do - RectRound(tabrect[1], tabrect[2], tabrect[3], tabrect[4], elementCorner, 1, 1, 0, 0) - end end) WG.guishader.InsertDlist(backgroundGuishader, "keybindinfo") end @@ -652,45 +167,80 @@ function widget:DrawScreen() end end -function widget:KeyPress(key) - if key == 27 then - -- ESC +-- The editor may want to ask about unsaved keybind changes first, in which case it +-- closes the panel itself once the player answers. +local function closePanel() + keybindEditor.confirmClose(function() show = false + keybindEditor.blur() + end) +end + +-- An open editor takes every key, so it never fires the binds being edited. +function widget:KeyPress(key, mods, isRepeat, label, unicode, scanCode) + if not show then + return false + end + + -- The editor gets first refusal: a modal wants everything, and otherwise Escape may + -- close a dropdown or cancel a capture before it reaches the panel itself. + if not keybindEditor.keyPress(key, scanCode) and key == 27 then + closePanel() end + + -- Nothing escapes an open editor: it must never fire the keybinds it is editing. + return true +end + +-- The engine runs bound actions on release as well as on press, so releases have to be +-- swallowed too. A key already down when the panel opened is let through instead, or +-- whatever it started stays stuck on. +function widget:KeyRelease(key, mods, label, unicode, scanCode, actions) + if not show then + return false + end + + keybindEditor.keyRelease(key, scanCode) + + if heldAtOpen[key] then + heldAtOpen[key] = nil + + return false + end + + return true end +function widget:TextInput(utf8char) + if show then + return keybindEditor.textInput(utf8char) + end + + return false +end + +-- Clicks inside the panel go to the editor; a click outside closes it. local function mouseEvent(x, y, button, release) if Spring.IsGUIHidden() then return false end if show then - -- on window if math_isInRect(x, y, screenX, screenY - screenHeight, screenX + screenWidth, screenY) then - return true - else - for tab, tabrect in pairs(tabrects) do - if math_isInRect(x, y, tabrect[1], tabrect[2], tabrect[3], tabrect[4]) then - if keybinds then - gl.DeleteList(keybinds) - end - lasstab = tab - keybinds = gl.CreateList(drawWindow, tab) - if backgroundGuishader ~= nil then - if WG.guishader then - WG.guishader.DeleteDlist("keybindinfo") - else - glDeleteList(backgroundGuishader) - end - backgroundGuishader = nil - end - return true - end - end - 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 + if not release then + keybindEditor.mousePress(x, y, button) end + + return true + elseif not release then + -- Only a press outside closes. A release out here belongs to a drag that started + -- inside, which the handler routes to us wherever it ends up. + showOnceMore = show -- show once more because the guishader lags behind + closePanel() + + -- Consumed either way. With unsaved edits closePanel only raises the guard, so + -- letting the click through would order units under an open modal. + return true end end end @@ -703,15 +253,117 @@ function widget:MouseRelease(x, y, button) return mouseEvent(x, y, button, true) end +-- Swallowed across the whole panel, not just the list: a wheel that gets through zooms +-- the camera behind an open editor. +function widget:MouseWheel(up, value) + if not show then + return false + end + + local x, y = Spring.GetMouseState() + if not math_isInRect(x, y, screenX, screenY - screenHeight, screenX + screenWidth, screenY) then + return false + end + + keybindEditor.mouseWheel(up, value) + + return true +end + +-- Holds input ownership for as long as the panel is open. +function widget:Update() + -- Re-snapshot the live keymap each time the panel opens so bindings made since + -- (e.g. a runtime /bind) show without waiting for a preset switch or keyreload. + if show and not wasShown then + refreshText() + end + wasShown = show + + -- Text ownership is what puts this panel ahead of actionHandler, which otherwise runs + -- before every widget and would fire the keybinds being edited. It has to go through + -- OwnText: widgetHandler here is a per-widget proxy, so assigning textOwner on it just + -- writes a dead field. + if show then + if not panelHasInput then + panelHasInput = true + heldAtOpen = 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 panel is open would leave chat input dead next + -- launch. isInputActive is the accessor that means chat is holding input; + -- getHandleInput is a saved option that reads true whoever the owner is, so poking + -- on that cancels the settings search box or the widget selector's filter instead. + 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 close, is what kills that field. + if ownsInput and not textInputStarted then + textInputStarted = true + if Spring.SDLStartTextInput then + Spring.SDLStartTextInput() + end + end + elseif panelHasInput then + panelHasInput = false + if ownsInput then + ownsInput = false + widgetHandler:DisownText() + end + if textInputStarted then + textInputStarted = false + if Spring.SDLStopTextInput then + Spring.SDLStopTextInput() + end + end + end +end + +-- Registers the panel's action, its WG surface and the build-menu hook. function widget:Initialize() refreshText() + widgetHandler:AddAction("keybindeditor", function() + show = true + doUpdate = true + return true + end, nil, "t") + + -- Sent as commands because widgetHandler here is a per-widget proxy, which carries no + -- Enable/DisableWidget. + keybindEditor.setMenuToggle(function(useGrid) + if useGrid == nil then + return + end + + if useGrid then + Spring.SendCommands("luaui disablewidget Build menu") + Spring.SendCommands("luaui enablewidget Grid menu") + else + Spring.SendCommands("luaui disablewidget Grid menu") + Spring.SendCommands("luaui enablewidget Build menu") + end + end) + WG.keybinds = {} WG.keybinds.toggle = function(state) - if state ~= nil then - show = state + local wanted = state + if wanted == nil then + wanted = not show + end + + if wanted then + show = true else - show = not show + closePanel() end end WG.keybinds.isvisible = function() @@ -724,7 +376,16 @@ function widget:Initialize() widget:ViewResize() end +-- Hands back input ownership and frees the display lists. function widget:Shutdown() + keybindEditor.blur() + widgetHandler:DisownText() + if ownsInput then + ownsInput = false + if Spring.SDLStopTextInput then + Spring.SDLStopTextInput() + end + end if keybinds then glDeleteList(keybinds) keybinds = nil @@ -739,6 +400,7 @@ function widget:Shutdown() end end +-- Re-resolves every label against the new language. function widget:LanguageChanged() refreshText() doUpdate = true diff --git a/luaui/Widgets/gui_options.lua b/luaui/Widgets/gui_options.lua index be9537635f6..f4be39891c6 100644 --- a/luaui/Widgets/gui_options.lua +++ b/luaui/Widgets/gui_options.lua @@ -5159,70 +5159,6 @@ function init() end, }, - { - id = "keybindings", - group = "control", - category = types.basic, - name = BAR.I18N("ui.settings.option.keybindings"), - type = "select", - options = keyLayouts.keybindingLayouts, - value = 1, - description = BAR.I18N("ui.settings.option.keybindings_descr"), - onload = function() - local keyFile = Spring.GetConfigString("KeybindingFile") - local value = 1 - - if (not keyFile) or (keyFile == "") or (not VFS.FileExists(keyFile)) then - keyFile = keyLayouts.keybindingLayoutFiles[1] - end - - for i, v in ipairs(keyLayouts.keybindingLayoutFiles) do - if v == keyFile then - value = i - break - end - end - - options[getOptionByID("keybindings")].value = value - end, - onchange = function(_, value) - local keyFile = keyLayouts.keybindingLayoutFiles[value] - - if not keyFile or keyFile == "" then - return - end - - local isCustom = keyLayouts.keybindingPresets.Custom == keyFile - - if isCustom and not VFS.FileExists(keyFile) then - Spring.SendCommands("keysave " .. keyFile) - Spring.Echo("Preset Custom selected, file saved at: " .. keyFile) - end - - Spring.SetConfigString("KeybindingFile", keyFile) - if isCustom then - Spring.Echo("To test your custom bindings after changes type in chat: /keyreload") - end - -- enable grid menu for grid keybinds - local preset = options[getOptionByID("keybindings")].options[value] - Spring.Echo(preset) - if string.find(string.lower(preset), "grid", nil, true) then - widgetHandler:DisableWidget("Build menu") - widgetHandler:EnableWidget("Grid menu") - elseif preset == "Custom" then - -- do stuff with custom preset - else - widgetHandler:DisableWidget("Grid menu") - widgetHandler:EnableWidget("Build menu") - end - - if WG.bar_hotkeys and WG.bar_hotkeys.reloadBindings then - WG.bar_hotkeys.reloadBindings() - end - scheduleInit = true - end, - }, - { id = "gridmenu", group = "control", diff --git a/luaui/Widgets/gui_ordermenu.lua b/luaui/Widgets/gui_ordermenu.lua index e7f6be00bbf..c1d20ee3301 100644 --- a/luaui/Widgets/gui_ordermenu.lua +++ b/luaui/Widgets/gui_ordermenu.lua @@ -520,7 +520,7 @@ local function refreshCommands() or CustomFirestateDefs.stateLabel(cmd) if commandState then if not commandTextCache[commandState] then - commandTextCache[commandState] = getCachedTranslation("ui.orderMenu." .. commandState) + commandTextCache[commandState] = getCachedTranslation("commands." .. commandState) end cmd.cachedText = commandTextCache[commandState] else @@ -529,11 +529,11 @@ local function refreshCommands() else if cmd.action == "stockpile" then -- Stockpile command name gets mutated to reflect the current status, so can't cache persistently - cmd.cachedText = getCachedTranslation("ui.orderMenu." .. cmd.action, { stockpileStatus = cmd.name }) + cmd.cachedText = getCachedTranslation("commands." .. cmd.action, { stockpileStatus = cmd.name }) else local actionKey = cmd.action if not commandTextCache[actionKey] then - commandTextCache[actionKey] = getCachedTranslation("ui.orderMenu." .. actionKey) + commandTextCache[actionKey] = getCachedTranslation("commands." .. actionKey) end cmd.cachedText = commandTextCache[actionKey] end @@ -1429,7 +1429,7 @@ function widget:DrawScreen() local cmd = commands[cell] if WG.tooltip then local tooltipKey = cmd.action .. "_tooltip" - local tooltip = getCachedTranslation("ui.orderMenu." .. tooltipKey) + local tooltip = getCachedTranslation("commands." .. tooltipKey) if not hotkeyCache[cmd.action] then hotkeyCache[cmd.action] = @@ -1442,7 +1442,7 @@ function widget:DrawScreen() local commandState = OrderMenuFirestate.stateLabel(cmd) local modeDescrKey = commandState and OrderMenuFirestate.descrByState[commandState] if modeDescrKey then - local modeDescr = getCachedTranslation("ui.orderMenu." .. modeDescrKey) + local modeDescr = getCachedTranslation("commands." .. modeDescrKey) local generalDescr = tooltip if generalDescr ~= "" and hotkey ~= "" then generalDescr = getCachedTranslation("ui.orderMenu.hotkeyTooltip", { @@ -1478,10 +1478,10 @@ function widget:DrawScreen() and OrderMenuFirestate.stateLabel(cmd) or CustomFirestateDefs.stateLabel(cmd) if commandState then - title = getCachedTranslation("ui.orderMenu." .. commandState) + title = getCachedTranslation("commands." .. commandState) end else - title = getCachedTranslation("ui.orderMenu." .. cmd.action) + title = getCachedTranslation("commands." .. cmd.action) end WG.tooltip.ShowTooltip("ordermenu", tooltip, nil, nil, title) end diff --git a/luaui/Widgets/gui_savegame.lua b/luaui/Widgets/gui_savegame.lua index edccb41cb94..67a6abee03a 100644 --- a/luaui/Widgets/gui_savegame.lua +++ b/luaui/Widgets/gui_savegame.lua @@ -21,34 +21,8 @@ local spGetGameFrame = Spring.GetGameFrame local SAVE_DIR = "Saves" local SAVE_DIR_LENGTH = string.len(SAVE_DIR) + 2 -local LOAD_GAME_STRING = "loadFilename " local SAVE_TYPE = "save " -local saveFilenameEdit = nil -- never created; legacy UI stub the guards below check - -local function WriteDate(dateTable) - return string.format("%02d/%02d/%04d", dateTable.day, dateTable.month, dateTable.year) - .. " " - .. string.format("%02d:%02d:%02d", dateTable.hour, dateTable.min, dateTable.sec) -end - -local function SecondsToClock(seconds) - local seconds = tonumber(seconds) - - if seconds <= 0 then - return "00:00" - else - hours = string.format("%02d", mathFloor(seconds / 3600)) - mins = string.format("%02d", mathFloor(seconds / 60 - (hours * 60))) - secs = string.format("%02d", mathFloor(seconds - hours * 3600 - mins * 60)) - if seconds >= 3600 then - return hours .. ":" .. mins .. ":" .. secs - else - return mins .. ":" .. secs - end - end -end - local function trim(str) return str:match("^()%s*$") and "" or str:match("^%s*(.*%S)") end @@ -58,59 +32,6 @@ end -------------------------------------------------------------------------------- -- FIXME: currently unused as it doesn't seem to give the correct order -local function GetSaveExtension(path) - if VFS.FileExists(path .. ".ssf") then - return ".ssf" - end - return VFS.FileExists(path .. ".slsf") and ".slsf" -end - -local function GetSaveWithExtension(path) - local ext = GetSaveExtension(path) - return ext and path .. ext -end - --- Returns the data stored in a save file -local function GetSave(path) - local ret = nil - local success, err = pcall(function() - local saveData = VFS.Include(path) - saveData.filename = string.sub(path, SAVE_DIR_LENGTH, -5) -- pure filename without directory or extension - saveData.path = path - ret = saveData - end) - if not success then - Spring.Log(widget:GetInfo().name, LOG.ERROR, "Error getting save " .. path .. ": " .. err) - else - local engineSaveFilename = GetSaveWithExtension(string.sub(path, 1, -5)) - if not engineSaveFilename then - --Spring.Log(widget:GetInfo().name, LOG.ERROR, "Save " .. engineSaveFilename .. " does not exist") - return nil - else - return ret - end - end -end - -local function GetSaveDescText(saveFile) - if not saveFile then - return "" - end - return (saveFile.description or "no description") - .. "\n" - .. saveFile.gameName - .. " " - .. saveFile.gameVersion - .. "\n" - .. saveFile.map - .. "\n" - .. (WG.Translate("interface", "time_ingame") or "Ingame time") - .. ": " - .. SecondsToClock((saveFile.totalGameframe or saveFile.gameframe or 0) / 30) - .. "\n" - .. WriteDate(saveFile.date) -end - local function FindFirstEmptySaveSlot() -- Find the first unused save slot number (e.g., save001, save002, etc.) local saveFiles = VFS.DirList(SAVE_DIR, "*.lua") @@ -177,63 +98,6 @@ local function SaveGame(filename, description, requireOverwrite) end end -local function LoadGameByFilename(filename) - local saveData = GetSave(SAVE_DIR .. "/" .. filename .. ".lua") - if saveData then - if Spring.GetMenuName and Spring.SendLuaMenuMsg and Spring.GetMenuName() then - Spring.SendLuaMenuMsg(LOAD_GAME_STRING .. filename) - else - local ext = GetSaveExtension(SAVE_DIR .. "/" .. filename) - if not ext then - Spring.Log(widget:GetInfo().name, LOG.ERROR, "Error loading game: cannot find save file.") - return - end - local success, err = pcall(function() - -- This should perhaps be handled in chobby first? - --Spring.Log(widget:GetInfo().name, LOG.INFO, "Save file " .. path .. " loaded") - - local script = [[ - [GAME] - { - SaveFile=__FILE__; - IsHost=1; - OnlyLocal=1; - MyPlayerName=__PLAYERNAME__; - } - ]] - script = script:gsub("__FILE__", filename .. ext) - script = script:gsub("__PLAYERNAME__", saveData.playerName) - Spring.Reload(script) - end) - if not success then - Spring.Log(widget:GetInfo().name, LOG.ERROR, "Error loading game: " .. err) - end - end - else - Spring.Log(widget:GetInfo().name, LOG.ERROR, "Save game " .. filename .. " not found") - end - if saveFilenameEdit then - saveFilenameEdit:SetText(filename) - end -end - -local function DeleteSave(filename) - if not filename then - Spring.Log(widget:GetInfo().name, LOG.ERROR, "No filename specified for save deletion") - end - local success, err = pcall(function() - local pathNoExtension = SAVE_DIR .. "/" .. filename - os.remove(pathNoExtension .. ".lua") - local saveFilePath = GetSaveWithExtension(pathNoExtension) - if saveFilePath then - os.remove(saveFilePath) - end - end) - if not success then - Spring.Log(widget:GetInfo().name, LOG.ERROR, "Error deleting save " .. filename .. ": " .. err) - end -end - local function savegameCmd(_, _, params) Spring.Echo("Trying to save:", params[1]) local savefilename = params[1] diff --git a/luaui/configs/hotkeys/chat_and_ui_keys.txt b/luaui/configs/hotkeys/chat_and_ui_keys.txt deleted file mode 100644 index 2a0a3aeb876..00000000000 --- a/luaui/configs/hotkeys/chat_and_ui_keys.txt +++ /dev/null @@ -1,91 +0,0 @@ - -// common interface keys -bind esc select AllMap++_ClearSelection_SelectNum_0+ -bind esc quitmessage -bind Shift+esc quitmenu -bind Ctrl+Shift+esc quitforce -bind Alt+Shift+esc reloadforce -bind Any+escape edit_escape -bind Any+pause pause -bind esc teamstatus_close -bind esc customgameinfo_close -bind esc buildmenu_pregame_deselect -bind Alt+backspace fullscreen - -// common selectbox keys -bind Any+sc_z selectbox_same // select only units that share type with current selection modifier | Smart Select Widget -bind Any+space selectbox_idle // select only idle units modifier | Smart Select Widget -bind Any+shift selectbox_append // append to selection modifier | Smart Select Widget -bind Any+shift selectbox_any // select all units modifier | Smart Select Widget -bind Any+ctrl selectbox_deselect // remove units from current selection modifier | Smart Select Widget -bind Any+alt selectbox_mobile // select only mobile units modifier | Smart Select Widget - -bind Any+space selectloop // activate select shape | Loop Select Widget -bind Any+ctrl selectloop_invert // select units not present in current selection modifier | Loop Select Widget -bind Any+shift selectloop_add // add to selection modifier | Loop Select Widget - -bind Any+space buildsplit // activate build split mode (distribute orders between selected builders) | Build Split Widget - -// commandinsert keys -bind Any+space commandinsert prepend_between // prepend command into the queue between 2 commands close to cursor -// bind Any+space commandinsert prepend_queue // prepend command into a separate prepend queue until key released - -// attackrange keys -bind alt+sc_. attack_range_inc // cycle to next attack range display config for current unit type. -bind alt+sc_comma attack_range_dec // cycle to previous attack range display config for current unit type. -// bind alt+sc_/ cursor_range_toggle // toggles display of the attack range of the unit under the mouse cursor. - -// common chat keys -bind Any+enter chat -bind Alt+ctrl+sc_a chatswitchally -bind Alt+ctrl+sc_s chatswitchspec - -bind Any+tab edit_complete -bind Any+backspace edit_backspace -bind Any+delete edit_delete -bind Any+home edit_home -bind Alt+left edit_home -bind Any+end edit_end -bind Alt+right edit_end -bind Any+up edit_prev_line -bind Any+down edit_next_line -bind Any+left edit_prev_char -bind Any+right edit_next_char -bind Ctrl+left edit_prev_word -bind Ctrl+right edit_next_word -bind Any+enter edit_return - -bind Ctrl+v pastetext - -// common camera controls -bind Any+up moveforward -bind Any+down moveback -bind Any+right moveright -bind Any+left moveleft -bind Any+pageup moveup -bind Any+pagedown movedown - -bind Any+alt movereset // fast camera reset on mousewheel -bind Any+alt moverotate // rotate on x,y with mmb hold + move (Spring Camera) -bind Any+ctrl movetilt // rotate on x with mousewheel - -bind ctrl+sc_o fov_dec 5 // decrease field of view 5 degrees -bind ctrl+sc_p fov_inc 5 // increase field of view 5 degrees -bind sc_numpad1 fov_dec 5 // decrease field of view 5 degrees -bind sc_numpad7 fov_inc 5 // increase field of view 5 degrees -// bind fov 40 // set field of view to 40 degrees - -// picture in picture camera controls -// bind pip0_activity // Focus advanced minimap onto recently placed map markers -// bind pip0_trackplayer // Focus advanced minimap (while spectating) -// bind pip0_tv // Advanced minimap follows the current action -// bind pip0_view // Limit advanced minimap to team view (while spectating) -bind Meta+ctrl+tab pip1_copy // Copy the current camera location to pip1 -bind Meta+tab pip1_switch // Switch the current camera location with pip1 -bind Alt+sc_t pip1_track // track the selected units with pip1 -// bind pip2_copy -// bind pip2_switch -// bind pip2_track - -// toggles -bind Any+alt toggle_allied_upgrade diff --git a/luaui/configs/hotkeys/dev_keys.txt b/luaui/configs/hotkeys/dev_keys.txt deleted file mode 100644 index 8e566c71ede..00000000000 --- a/luaui/configs/hotkeys/dev_keys.txt +++ /dev/null @@ -1,3 +0,0 @@ -bind Alt+b debug -bind Alt+v debugcolvol -bind Alt+p debugpath diff --git a/luaui/configs/hotkeys/grid_keys.txt b/luaui/configs/hotkeys/grid_keys.txt deleted file mode 100644 index dc20e3dcb54..00000000000 --- a/luaui/configs/hotkeys/grid_keys.txt +++ /dev/null @@ -1,183 +0,0 @@ -// BAR hotkey config file: ergokeys for grid menu - - -// Load common keys -keyload luaui/configs/hotkeys/chat_and_ui_keys.txt -keyload luaui/configs/hotkeys/gridmenu_keys.txt -keyload luaui/configs/hotkeys/num_keys.txt - - -bind Alt+sc_= increasespeed -bind Alt+sc_- decreasespeed -bind Alt+numpad+ increasespeed -bind Alt+numpad- decreasespeed - -bind sc_[ buildfacing inc -bind Shift+sc_[ buildfacing inc -bind sc_] buildfacing dec -bind Shift+sc_] buildfacing dec -bind Alt+sc_z buildspacing inc -bind Shift+Alt+sc_z buildspacing inc -bind Alt+sc_x buildspacing dec -bind Shift+Alt+sc_x buildspacing dec - -bind sc_a attack -bind Shift+sc_a attack -bind Ctrl+sc_a areaattack -bind Ctrl+Shift+sc_a areaattack -bind Ctrl+sc_b selfd -bind Ctrl+Shift+sc_b selfd queued -bind sc_d manualfire -bind Shift+sc_d manualfire -bind sc_d manuallaunch -bind Shift+sc_d manuallaunch -bind sc_e reclaim -bind Shift+sc_e reclaim -bind sc_f fight -bind Shift+sc_f fight -bind Alt+sc_f chain force forcestart | say !cv forcestart -bind sc_g stopproduction -bind Shift+sc_g stopproduction -bind sc_g stop -bind Shift+sc_g stop -bind sc_h patrol -bind Shift+sc_h patrol -bind sc_i unit_stats -bind Ctrl+sc_i customgameinfo -bind sc_j loadunits -bind Shift+sc_j loadunits -bind sc_k cloak -bind Shift+sc_k cloak -bind sc_k wantcloak -bind Any+sc_k wantcloak -bind sc_m restore -bind Shift+sc_m restore -bind sc_n command_skip_current -bind Ctrl+sc_n command_cancel_last -bind sc_o guard -bind Shift+sc_o guard -bind sc_p gatherwait -bind Shift+sc_p gatherwait -bind sc_r repair -bind Shift+sc_r repair -bind sc_s settarget -bind Shift+sc_s settarget -bind Alt+sc_s settargetnoground -bind Shift+Alt+sc_s settargetnoground -bind Ctrl+sc_s canceltarget -bind sc_u unloadunits -bind Shift+sc_u unloadunits -bind sc_w resurrect -bind Shift+sc_w resurrect -bind sc_w capture -bind Shift+sc_w capture -bind sc_y wait -bind Shift+sc_y wait queued - -bind sc_b,sc_b onoff 0 -bind sc_b onoff 1 -bind Shift+sc_b,Shift+sc_b onoff 0 -bind Shift+sc_b onoff 1 - -bind sc_b,sc_b,sc_b trajectory_toggle 1 -bind sc_b,sc_b trajectory_toggle 0 -bind sc_b trajectory_toggle 2 -bind Shift+sc_b,Shift+sc_b,Shift+sc_b trajectory_toggle 1 -bind Shift+sc_b,Shift+sc_b trajectory_toggle 0 -bind Shift+sc_b trajectory_toggle 2 - -bind sc_l,sc_l,sc_l firestate 1 -bind sc_l,sc_l firestate 0 -bind sc_l firestate 2 -bind Shift+sc_l,Shift+sc_l,Shift+sc_l firestate 1 -bind Shift+sc_l,Shift+sc_l firestate 0 -bind Shift+sc_l firestate 2 - -bind sc_;,sc_;,sc_; movestate 1 -bind sc_;,sc_; movestate 0 -bind sc_; movestate 2 -bind Shift+sc_;,Shift+sc_;,Shift+sc_; movestate 1 -bind Shift+sc_;,Shift+sc_; movestate 0 -bind Shift+sc_; movestate 2 - -bind sc_t,sc_t repeat 0 -bind sc_t repeat 1 -bind Shift+sc_t,Shift+sc_t repeat 0 -bind Shift+sc_t repeat 1 - -bind Ctrl+sc_g,Ctrl+sc_g factoryguard 0 -bind Ctrl+sc_g factoryguard 1 - -bind Any+sc_' togglelos -bind Ctrl+sc_t toggleoverview -bind Alt+sc_o cameraflip - -bind Ctrl+f5 viewta -bind Ctrl+f6 viewspring -bind Ctrl+f7 HideInterface -bind Any+f5 LastMsgPos -bind Any+f6 ShowPathTraversability -bind Any+f7 ShowMetalMap -bind Any+f8 ShowElevation - -bind f10 options -bind f11 luaui selector -bind Any+f12 screenshot png - -bind Ctrl+sc_` group unset -bind Alt+sc_` remove_from_autogroup - -bind sc_`,sc_` drawlabel -bind sc_` drawinmap - -bind Ctrl+sc_e select AllMap++_ClearSelection_SelectAll+ -bind Ctrl+tab select AllMap+_Builder_Idle+_ClearSelection_SelectOne+ -bind Shift+tab selectcomm append -bind tab selectcomm focus -bind sc_q select Visible+_InPrevSel+_ClearSelection_SelectAll+ -bind Ctrl+sc_q select PrevSelection++_ClearSelection_SelectPart_50+ -bind Ctrl+sc_w select AllMap+_InPrevSel+_ClearSelection_SelectAll+ -bind Ctrl+sc_r select AllMap+_Transport_Idle+_ClearSelection_SelectAll+ -bind Ctrl+sc_y select Visible+_Waiting+_ClearSelection_SelectAll+ -bind Alt+sc_q select PrevSelection+_Not_Building_Not_RelativeHealth_60+_ClearSelection_SelectAll+ - -// cycle camera through selected units -bind sc_. cycleselected next -bind sc_comma cycleselected prev - - // numpad movement -bind numpad2 moveback -bind numpad6 moveright -bind numpad4 moveleft -bind numpad8 moveforward -bind numpad9 moveup -bind numpad3 movedown -bind numpad1 movefast - - // snd_volume_osd -bind backspace MuteSound -bind numpad+ snd_volume_increase -bind sc_= snd_volume_increase -bind sc_- snd_volume_decrease -bind numpad- snd_volume_decrease - -// camera anchors -bind Ctrl+F1 set_camera_anchor 1 -bind Ctrl+F2 set_camera_anchor 2 -bind Ctrl+F3 set_camera_anchor 3 -bind Ctrl+F4 set_camera_anchor 4 - -bind F1 focus_camera_anchor 1 -bind F2 focus_camera_anchor 2 -bind F3 focus_camera_anchor 3 -bind F4 focus_camera_anchor 4 - -// blueprint -bind Alt+sc_b blueprint_place -bind Alt+sc_t blueprint_create -bind Alt+sc_y blueprint_delete -bind Alt+sc_[ blueprint_prev -bind Alt+sc_] blueprint_next - -// quotas -bind Alt+sc_g factoryqueuemode diff --git a/luaui/configs/hotkeys/grid_keys_60pct.txt b/luaui/configs/hotkeys/grid_keys_60pct.txt deleted file mode 100644 index 80b554ecae2..00000000000 --- a/luaui/configs/hotkeys/grid_keys_60pct.txt +++ /dev/null @@ -1,184 +0,0 @@ -// BAR hotkey config file: grid optimized for 60% keyboards -// remap F-keys to use meta+ (spacebar) and ` to Q with modifiers - -// Load common keys -keyload luaui/configs/hotkeys/chat_and_ui_keys.txt -keyload luaui/configs/hotkeys/gridmenu_keys.txt -keyload luaui/configs/hotkeys/num_keys.txt - -unbindaction factory_preset -unbindaction factory_preset_show - -bind Alt+sc_= increasespeed -bind Alt+sc_- decreasespeed -bind Alt+numpad+ increasespeed -bind Alt+numpad- decreasespeed - -bind sc_[ buildfacing inc -bind Shift+sc_[ buildfacing inc -bind sc_] buildfacing dec -bind Shift+sc_] buildfacing dec -bind Alt+sc_z buildspacing inc -bind Shift+Alt+sc_z buildspacing inc -bind Alt+sc_x buildspacing dec -bind Shift+Alt+sc_x buildspacing dec - -bind sc_a attack -bind Shift+sc_a attack -bind Ctrl+sc_a areaattack -bind Ctrl+Shift+sc_a areaattack -bind Ctrl+sc_b selfd -bind Ctrl+Shift+sc_b selfd queued -bind sc_d manualfire -bind Shift+sc_d manualfire -bind sc_d manuallaunch -bind Shift+sc_d manuallaunch -bind sc_e reclaim -bind Shift+sc_e reclaim -bind sc_f fight -bind Shift+sc_f fight -bind Alt+sc_f chain force forcestart | say !cv forcestart -bind sc_g stopproduction -bind Shift+sc_g stopproduction -bind sc_g stop -bind Shift+sc_g stop -bind sc_h patrol -bind Shift+sc_h patrol -bind sc_i unit_stats -bind Ctrl+sc_i customgameinfo -bind sc_j loadunits -bind Shift+sc_j loadunits -bind sc_k cloak -bind Shift+sc_k cloak -bind sc_k wantcloak -bind Any+sc_k wantcloak -bind sc_m restore -bind Shift+sc_m restore -bind sc_n command_skip_current -bind Ctrl+sc_n command_cancel_last -bind sc_p gatherwait -bind Shift+sc_p gatherwait -bind sc_o guard -bind Shift+sc_o guard -bind sc_r repair -bind Shift+sc_r repair -bind sc_s settarget -bind Shift+sc_s settarget -bind Alt+sc_s settargetnoground -bind Shift+Alt+sc_s settargetnoground -bind Ctrl+sc_s canceltarget -bind sc_u unloadunits -bind Shift+sc_u unloadunits -bind sc_w resurrect -bind Shift+sc_w resurrect -bind sc_w capture -bind Shift+sc_w capture -bind sc_y wait -bind Shift+sc_y wait queued - -bind sc_b,sc_b onoff 0 -bind sc_b onoff 1 -bind Shift+sc_b,Shift+sc_b onoff 0 -bind Shift+sc_b onoff 1 - -bind sc_b,sc_b,sc_b trajectory_toggle 1 -bind sc_b,sc_b trajectory_toggle 0 -bind sc_b trajectory_toggle 2 -bind Shift+sc_b,Shift+sc_b,Shift+sc_b trajectory_toggle 1 -bind Shift+sc_b,Shift+sc_b trajectory_toggle 0 -bind Shift+sc_b trajectory_toggle 2 - -bind sc_l,sc_l,sc_l firestate 1 -bind sc_l,sc_l firestate 0 -bind sc_l firestate 2 -bind Shift+sc_l,Shift+sc_l,Shift+sc_l firestate 1 -bind Shift+sc_l,Shift+sc_l firestate 0 -bind Shift+sc_l firestate 2 - -bind sc_;,sc_;,sc_; movestate 1 -bind sc_;,sc_; movestate 0 -bind sc_; movestate 2 -bind Shift+sc_;,Shift+sc_;,Shift+sc_; movestate 1 -bind Shift+sc_;,Shift+sc_; movestate 0 -bind Shift+sc_; movestate 2 - -bind sc_t,sc_t repeat 0 -bind sc_t repeat 1 -bind Shift+sc_t,Shift+sc_t repeat 0 -bind Shift+sc_t repeat 1 - -bind Ctrl+sc_g,Ctrl+sc_g factoryguard 0 -bind Ctrl+sc_g factoryguard 1 - -bind Any+sc_' togglelos -bind Ctrl+sc_t toggleoverview -bind Alt+sc_o cameraflip - -bind Ctrl+meta+5 viewta -bind Ctrl+meta+6 viewspring -bind Ctrl+meta+7 HideInterface -bind meta+5 LastMsgPos -bind meta+6 ShowPathTraversability -bind meta+7 ShowMetalMap -bind meta+8 ShowElevation - -bind f10 options -bind f11 luaui selector -bind Any+f12 screenshot png - -bind Ctrl+meta+sc_q group unset -bind Alt+sc_q remove_from_autogroup - -bind meta+sc_q,meta+sc_q drawlabel -bind meta+sc_q drawinmap - -bind Ctrl+sc_e select AllMap++_ClearSelection_SelectAll+ -bind Ctrl+tab select AllMap+_Builder_Idle+_ClearSelection_SelectOne+ -bind Shift+tab selectcomm append -bind tab selectcomm focus -bind sc_q select Visible+_InPrevSel+_ClearSelection_SelectAll+ -bind Ctrl+sc_q select PrevSelection++_ClearSelection_SelectPart_50+ -bind Ctrl+sc_w select AllMap+_InPrevSel+_ClearSelection_SelectAll+ -bind Ctrl+sc_r select AllMap+_Transport_Idle+_ClearSelection_SelectAll+ -bind Ctrl+sc_y select Visible+_Waiting+_ClearSelection_SelectAll+ -bind Ctrl+Alt+sc_q select PrevSelection+_Not_Building_Not_RelativeHealth_60+_ClearSelection_SelectAll+ - -// cycle camera through selected units -bind sc_. cycleselected next -bind sc_comma cycleselected prev - -// numpad movement -bind numpad2 moveback -bind numpad6 moveright -bind numpad4 moveleft -bind numpad8 moveforward -bind numpad9 moveup -bind numpad3 movedown -bind numpad1 movefast - -// snd_volume_osd -bind backspace MuteSound -bind numpad+ snd_volume_increase -bind sc_= snd_volume_increase -bind sc_- snd_volume_decrease -bind numpad- snd_volume_decrease - - - -// camera anchors -bind Ctrl+meta+1 set_camera_anchor 1 -bind Ctrl+meta+2 set_camera_anchor 2 -bind Ctrl+meta+3 set_camera_anchor 3 -bind Ctrl+meta+4 set_camera_anchor 4 - -bind meta+1 focus_camera_anchor 1 -bind meta+2 focus_camera_anchor 2 -bind meta+3 focus_camera_anchor 3 -bind meta+4 focus_camera_anchor 4 - -// blueprint -bind Alt+sc_b blueprint_place -bind Alt+sc_t blueprint_create -bind Alt+sc_y blueprint_delete -bind Alt+sc_[ blueprint_prev -bind Alt+sc_] blueprint_next diff --git a/luaui/configs/hotkeys/gridmenu_keys.txt b/luaui/configs/hotkeys/gridmenu_keys.txt deleted file mode 100644 index 664d7c196d3..00000000000 --- a/luaui/configs/hotkeys/gridmenu_keys.txt +++ /dev/null @@ -1,22 +0,0 @@ -bind sc_z gridmenu_category 1 -bind sc_x gridmenu_category 2 -bind sc_c gridmenu_category 3 -bind sc_v gridmenu_category 4 -bind Shift+sc_z gridmenu_category 1 -bind Shift+sc_x gridmenu_category 2 -bind Shift+sc_c gridmenu_category 3 -bind Shift+sc_v gridmenu_category 4 -bind Any+sc_z gridmenu_key 1 1 -bind Any+sc_x gridmenu_key 1 2 -bind Any+sc_c gridmenu_key 1 3 -bind Any+sc_v gridmenu_key 1 4 -bind Any+sc_a gridmenu_key 2 1 -bind Any+sc_s gridmenu_key 2 2 -bind Any+sc_d gridmenu_key 2 3 -bind Any+sc_f gridmenu_key 2 4 -bind Any+sc_q gridmenu_key 3 1 -bind Any+sc_w gridmenu_key 3 2 -bind Any+sc_e gridmenu_key 3 3 -bind Any+sc_r gridmenu_key 3 4 -bind sc_b gridmenu_next_page -bind sc_. gridmenu_cycle_builder diff --git a/luaui/configs/hotkeys/legacy_keys.txt b/luaui/configs/hotkeys/legacy_keys.txt deleted file mode 100644 index 691fe7ce0d4..00000000000 --- a/luaui/configs/hotkeys/legacy_keys.txt +++ /dev/null @@ -1,321 +0,0 @@ -// Load common keys -keyload luaui/configs/hotkeys/chat_and_ui_keys.txt -keyload luaui/configs/hotkeys/num_keys.txt - -bind Any+sc_h sharedialog -bind sc_i customgameinfo - -bind Shift+backspace togglecammode -bind Ctrl+backspace togglecammode -bind Any+tab toggleoverview - -bind Any+home increaseViewRadius -bind Any+end decreaseViewRadius - -bind Alt+insert increasespeed -bind Alt+delete decreasespeed -bind Alt+sc_= increasespeed -bind Alt+sc_- decreasespeed -bind Alt+numpad+ increasespeed -bind Alt+numpad- decreasespeed - -bind sc_[ buildfacing inc -bind Shift+sc_[ buildfacing inc -bind sc_] buildfacing dec -bind Shift+sc_] buildfacing dec - -bind Alt+sc_z buildspacing inc -bind Shift+Alt+sc_z buildspacing inc -bind Alt+sc_x buildspacing dec -bind Shift+Alt+sc_x buildspacing dec - -bind sc_a attack -bind Shift+sc_a attack -bind Alt+sc_a areaattack -bind Alt+Shift+sc_a areaattack - -bind sc_d manualfire -bind Shift+sc_d manualfire -bind sc_d manuallaunch -bind Shift+sc_d manuallaunch -bind Ctrl+sc_d selfd -bind Ctrl+Shift+sc_d selfd queued -bind sc_e reclaim -bind Shift+sc_e reclaim -bind sc_f fight -bind Shift+sc_f fight -bind Alt+sc_f chain force forcestart | say !cv forcestart -bind sc_g guard -bind Shift+sc_g guard -bind sc_j canceltarget -bind sc_k cloak -bind Shift+sc_k cloak -bind sc_k wantcloak -bind Any+sc_k wantcloak -bind sc_l loadunits -bind Shift+sc_l loadunits -bind sc_m move -bind Shift+sc_m move -bind sc_n command_skip_current -bind Ctrl+sc_n command_cancel_last -bind sc_p patrol -bind Shift+sc_p patrol -bind sc_q,sc_q drawlabel // double hit Q for drawlabel, these are only present due to legacy. Should be deprecated at some point -bind sc_q drawinmap -bind sc_r repair -bind Shift+sc_r repair -bind Ctrl+sc_r resurrect -bind sc_s stop -bind Shift+sc_s stop -bind Ctrl+sc_s stopproduction -bind sc_u unloadunits -bind Shift+sc_u unloadunits -bind sc_w wait -bind Shift+sc_w wait queued -bind sc_x onoff -bind Shift+sc_x onoff - -bind Any+sc_l togglelos - -bind Ctrl+sc_t trackmode -bind Any+sc_t track - -bind Any+ctrl moveslow -bind Any+shift movefast - -bind Ctrl+f1 viewfps -bind Ctrl+f2 viewta -bind Ctrl+f3 viewspring -bind Ctrl+f4 viewrot -bind Ctrl+f5 viewfree - -bind Any+f1 ShowElevation -bind Any+f2 ShowPathTraversability -bind Any+f3 LastMsgPos -bind Any+f4 ShowMetalMap -bind Any+f5 HideInterface -bind Any+f6 MuteSound -bind Any+f7 DynamicSky -bind f10 options -bind f11 luaui selector -bind Any+f12 screenshot png - -bind Ctrl+Shift+f8 savegame -bind Alt+enter fullscreen - -bind sc_`,sc_` drawlabel // double hit ` for drawlabel -bind sc_` drawinmap - -bind Ctrl+sc_a select AllMap++_ClearSelection_SelectAll+ -bind Ctrl+sc_b select AllMap+_Builder_Idle+_ClearSelection_SelectOne+ -bind Ctrl+sc_c selectcomm focus -bind Ctrl+sc_v select AllMap+_Not_Builder_InPrevSel_Not_InHotkeyGroup+_SelectAll+ -bind Ctrl+sc_w select AllMap+_Not_Aircraft_Weapons+_ClearSelection_SelectAll+ -bind Ctrl+sc_x select AllMap+_InPrevSel_Not_InHotkeyGroup+_SelectAll+ -bind Ctrl+sc_z select AllMap+_InPrevSel+_ClearSelection_SelectAll+ - -// cycle camera through selected units -bind sc_. cycleselected next -bind sc_comma cycleselected prev - -// building hotkeys -bind sc_z buildunit_armmex -bind Shift+sc_z buildunit_armmex -bind sc_z buildunit_armamex -bind Shift+sc_z buildunit_armamex -bind sc_z buildunit_cormex -bind Shift+sc_z buildunit_cormex -bind sc_z buildunit_legmex -bind Shift+sc_z buildunit_legmex -bind sc_z buildunit_legmext15 -bind Shift+sc_z buildunit_legmext15 -bind sc_z buildunit_corexp -bind Shift+sc_z buildunit_corexp -bind sc_z buildunit_armmoho -bind Shift+sc_z buildunit_armmoho -bind sc_z buildunit_cormoho -bind Shift+sc_z buildunit_cormoho -bind sc_z buildunit_legmoho -bind Shift+sc_z buildunit_legmoho -bind sc_z buildunit_cormexp -bind Shift+sc_z buildunit_cormexp -bind sc_z buildunit_coruwmme -bind Shift+sc_z buildunit_coruwmme -bind sc_z buildunit_armuwmme -bind Shift+sc_z buildunit_armuwmme -bind sc_z areamex -bind Shift+sc_z areamex -bind Ctrl+Alt+sc_z areamex -bind sc_x buildunit_armsolar -bind Shift+sc_x buildunit_armsolar -bind sc_x buildunit_armwin -bind Shift+sc_x buildunit_armwin -bind sc_x buildunit_corsolar -bind Shift+sc_x buildunit_corsolar -bind sc_x buildunit_corwin -bind Shift+sc_x buildunit_corwin -bind sc_x buildunit_legsolar -bind Shift+sc_x buildunit_legsolar -bind sc_x buildunit_legwin -bind Shift+sc_x buildunit_legwin -bind sc_x buildunit_armadvsol -bind Shift+sc_x buildunit_armadvsol -bind sc_x buildunit_coradvsol -bind Shift+sc_x buildunit_coradvsol -bind sc_x buildunit_legadvsol -bind Shift+sc_x buildunit_legadvsol -bind sc_x buildunit_armfus -bind Shift+sc_x buildunit_armfus -bind sc_x buildunit_armmmkr -bind Shift+sc_x buildunit_armmmkr -bind sc_x buildunit_corfus -bind Shift+sc_x buildunit_corfus -bind sc_x buildunit_cormmkr -bind Shift+sc_x buildunit_cormmkr -bind sc_x buildunit_legfus -bind Shift+sc_x buildunit_legfus -bind sc_x buildunit_legadveconv -bind Shift+sc_x buildunit_legadveconv -bind sc_x buildunit_armtide -bind Shift+sc_x buildunit_armtide -bind sc_x buildunit_cortide -bind Shift+sc_x buildunit_cortide -bind sc_x buildunit_legtide -bind Shift+sc_x buildunit_legtide -bind sc_x buildunit_armuwfus -bind Shift+sc_x buildunit_armuwfus -bind sc_x buildunit_coruwfus -bind Shift+sc_x buildunit_coruwfus -bind sc_x buildunit_armuwmmm -bind Shift+sc_x buildunit_armuwmmm -bind sc_x buildunit_coruwmmm -bind Shift+sc_x buildunit_coruwmmm -bind sc_c buildunit_armllt -bind Shift+sc_c buildunit_armllt -bind sc_c buildunit_armrad -bind Shift+sc_c buildunit_armrad -bind sc_c buildunit_corllt -bind Shift+sc_c buildunit_corllt -bind sc_c buildunit_leglht -bind Shift+sc_c buildunit_leglht -bind sc_c buildunit_corrad -bind Shift+sc_c buildunit_corrad -bind sc_c buildunit_legrad -bind Shift+sc_c buildunit_legrad -bind sc_c buildunit_corrl -bind Shift+sc_c buildunit_corrl -bind sc_c buildunit_legrl -bind Shift+sc_c buildunit_legrl -bind sc_c buildunit_armrl -bind Shift+sc_c buildunit_armrl -bind sc_c buildunit_armpb -bind Shift+sc_c buildunit_armpb -bind sc_c buildunit_armflak -bind Shift+sc_c buildunit_armflak -bind sc_c buildunit_corvipe -bind Shift+sc_c buildunit_corvipe -bind sc_c buildunit_corflak -bind Shift+sc_c buildunit_corflak -bind sc_c buildunit_legapopupdef -bind Shift+sc_c buildunit_legapopupdef -bind sc_c buildunit_legflak -bind Shift+sc_c buildunit_legflak -bind sc_c buildunit_armgplat -bind Shift+sc_c buildunit_armgplat -bind sc_c buildunit_corgplat -bind Shift+sc_c buildunit_corgplat -bind sc_c buildunit_armtl -bind Shift+sc_c buildunit_armtl -bind sc_c buildunit_cortl -bind Shift+sc_c buildunit_cortl -bind sc_c buildunit_legtl -bind Shift+sc_c buildunit_legtl -bind sc_c buildunit_armsonar -bind Shift+sc_c buildunit_armsonar -bind sc_c buildunit_corsonar -bind Shift+sc_c buildunit_corsonar -bind sc_c buildunit_armfrad -bind Shift+sc_c buildunit_armfrad -bind sc_c buildunit_corfrad -bind Shift+sc_c buildunit_corfrad -bind sc_c buildunit_legfrad -bind Shift+sc_c buildunit_legfrad -bind sc_c buildunit_armfrt -bind Shift+sc_c buildunit_armfrt -bind sc_c buildunit_corfrt -bind Shift+sc_c buildunit_corfrt -bind sc_c buildunit_legfrl -bind Shift+sc_c buildunit_legfrl -bind sc_v buildunit_armnanotc -bind Shift+sc_v buildunit_armnanotc -bind sc_v buildunit_armnanotcplat -bind Shift+sc_v buildunit_armnanotcplat -bind sc_v buildunit_cornanotcplat -bind Shift+sc_v buildunit_cornanotcplat -bind sc_v buildunit_armlab -bind Shift+sc_v buildunit_armlab -bind sc_v buildunit_armvp -bind Shift+sc_v buildunit_armvp -bind sc_v buildunit_armap -bind Shift+sc_v buildunit_armap -bind sc_v buildunit_cornanotc -bind Shift+sc_v buildunit_cornanotc -bind sc_v buildunit_corlab -bind Shift+sc_v buildunit_corlab -bind sc_v buildunit_corvp -bind Shift+sc_v buildunit_corvp -bind sc_v buildunit_corap -bind Shift+sc_v buildunit_corap -bind sc_v buildunit_legnanotc -bind Shift+sc_v buildunit_legnanotc -bind sc_v buildunit_leglab -bind Shift+sc_v buildunit_leglab -bind sc_v buildunit_legvp -bind Shift+sc_v buildunit_legvp -bind sc_v buildunit_legap -bind Shift+sc_v buildunit_legap -bind sc_v buildunit_armsy -bind Shift+sc_v buildunit_armsy -bind sc_v buildunit_corsy -bind Shift+sc_v buildunit_corsy -bind sc_v buildunit_legsy -bind Shift+sc_v buildunit_legsy - -// numpad movement -bind numpad2 moveback -bind numpad6 moveright -bind numpad4 moveleft -bind numpad8 moveforward -bind numpad9 moveup -bind numpad3 movedown -bind numpad1 movefast - -// snd_volume_osd -bind numpad+ snd_volume_increase -bind sc_= snd_volume_increase -bind sc_- snd_volume_decrease -bind numpad- snd_volume_decrease - -bind Any+sc_; losradar // los_colors -bind Any+space unit_stats //unit_stats -bind Ctrl+Shift+sc_o cameraflip // CameraFlip - -//if not WG[ Set target default ] then -bind Alt+sc_y settarget -bind Shift+Alt+sc_y settarget -bind sc_y settargetnoground -bind Shift+sc_y settargetnoground - -bind Ctrl+sc_` group unset - -bind Alt+sc_` remove_from_autogroup // Auto Group - -// blueprint -bind Alt+sc_b blueprint_place -bind Alt+sc_c blueprint_create -bind Alt+sc_d blueprint_delete -bind Alt+sc_[ blueprint_prev -bind Alt+sc_] blueprint_next - -// quotas -bind Alt+sc_g factoryqueuemode diff --git a/luaui/configs/hotkeys/legacy_keys_60pct.txt b/luaui/configs/hotkeys/legacy_keys_60pct.txt deleted file mode 100644 index 8cb12c8a2df..00000000000 --- a/luaui/configs/hotkeys/legacy_keys_60pct.txt +++ /dev/null @@ -1,327 +0,0 @@ -// BAR hotkey config file: default for 60% keyboards -// remap F-keys to use meta+ (spacebar) and ` to Q with modifiers - -// Load common keys -keyload luaui/configs/hotkeys/chat_and_ui_keys.txt -keyload luaui/configs/hotkeys/num_keys.txt - -unbindaction factory_preset -unbindaction factory_preset_show - -bind Any+sc_h sharedialog -bind sc_i customgameinfo - -bind Shift+backspace togglecammode -bind Ctrl+backspace togglecammode -bind Any+tab toggleoverview - -bind Any+home increaseViewRadius -bind Any+end decreaseViewRadius - -bind Alt+insert increasespeed -bind Alt+delete decreasespeed -bind Alt+sc_= increasespeed -bind Alt+sc_- decreasespeed -bind Alt+numpad+ increasespeed -bind Alt+numpad- decreasespeed - -bind sc_[ buildfacing inc -bind Shift+sc_[ buildfacing inc -bind sc_] buildfacing dec -bind Shift+sc_] buildfacing dec - -bind Alt+sc_z buildspacing inc -bind Shift+Alt+sc_z buildspacing inc -bind Alt+sc_x buildspacing dec -bind Shift+Alt+sc_x buildspacing dec - -bind sc_a attack -bind Shift+sc_a attack -bind Alt+sc_a areaattack -bind Alt+Shift+sc_a areaattack -bind Alt+b debug -bind Alt+v debugcolvol -bind Alt+p debugpath -bind sc_d manualfire -bind Shift+sc_d manualfire -bind sc_d manuallaunch -bind Shift+sc_d manuallaunch -bind Ctrl+sc_d selfd -bind Ctrl+Shift+sc_d selfd queued -bind sc_e reclaim -bind Shift+sc_e reclaim -bind sc_f fight -bind Shift+sc_f fight -bind Alt+sc_f chain force forcestart | say !cv forcestart -bind sc_g guard -bind Shift+sc_g guard -bind sc_j canceltarget -bind sc_k cloak -bind Shift+sc_k cloak -bind sc_k wantcloak -bind Any+sc_k wantcloak -bind sc_l loadunits -bind Shift+sc_l loadunits -bind sc_m move -bind Shift+sc_m move -bind sc_n command_skip_current -bind Ctrl+sc_n command_cancel_last -bind sc_p patrol -bind Shift+sc_p patrol -bind sc_q,sc_q drawlabel // double hit Q for drawlabel -bind sc_q drawinmap -bind sc_r repair -bind Shift+sc_r repair -bind Ctrl+sc_r resurrect -bind sc_s stop -bind Shift+sc_s stop -bind Ctrl+sc_s stopproduction -bind sc_u unloadunits -bind Shift+sc_u unloadunits -bind sc_w wait -bind Shift+sc_w wait queued -bind sc_x onoff -bind Shift+sc_x onoff - -bind Any+sc_l togglelos - -bind Ctrl+sc_t trackmode -bind Any+sc_t track - -bind Any+ctrl moveslow -bind Any+shift movefast - -bind Ctrl+meta+1 viewfps -bind Ctrl+meta+2 viewta -bind Ctrl+meta+3 viewspring -bind Ctrl+meta+4 viewrot -bind Ctrl+meta+5 viewfree - -bind meta+1 ShowElevation -bind meta+2 ShowPathTraversability -bind meta+3 LastMsgPos -bind meta+4 ShowMetalMap -bind meta+5 HideInterface -bind meta+6 MuteSound -bind meta+7 DynamicSky -bind f11 luaui selector -bind meta+8 screenshot png - -bind Ctrl+Shift+f8 savegame -bind Alt+enter fullscreen - -bind Ctrl+sc_a select AllMap++_ClearSelection_SelectAll+ -bind Ctrl+sc_b select AllMap+_Builder_Idle+_ClearSelection_SelectOne+ -bind Ctrl+sc_c selectcomm focus -bind Ctrl+sc_v select AllMap+_Not_Builder_InPrevSel_Not_InHotkeyGroup+_SelectAll+ -bind Ctrl+sc_w select AllMap+_Not_Aircraft_Weapons+_ClearSelection_SelectAll+ -bind Ctrl+sc_x select AllMap+_InPrevSel_Not_InHotkeyGroup+_SelectAll+ -bind Ctrl+sc_z select AllMap+_InPrevSel+_ClearSelection_SelectAll+ - -// cycle camera through selected units -bind sc_. cycleselected next -bind sc_comma cycleselected prev - -// building hotkeys -bind sc_z buildunit_armmex -bind Shift+sc_z buildunit_armmex -bind sc_z buildunit_armamex -bind Shift+sc_z buildunit_armamex -bind sc_z buildunit_cormex -bind Shift+sc_z buildunit_cormex -bind sc_z buildunit_legmex -bind Shift+sc_z buildunit_legmex -bind sc_z buildunit_legmext15 -bind Shift+sc_z buildunit_legmext15 -bind sc_z buildunit_corexp -bind Shift+sc_z buildunit_corexp -bind sc_z buildunit_armmoho -bind Shift+sc_z buildunit_armmoho -bind sc_z buildunit_cormoho -bind Shift+sc_z buildunit_cormoho -bind sc_z buildunit_legmoho -bind Shift+sc_z buildunit_legmoho -bind sc_z buildunit_cormexp -bind Shift+sc_z buildunit_cormexp -bind sc_z buildunit_coruwmme -bind Shift+sc_z buildunit_coruwmme -bind sc_z buildunit_armuwmme -bind Shift+sc_z buildunit_armuwmme -bind sc_z areamex -bind Shift+sc_z areamex -bind Ctrl+Alt+sc_z areamex -bind sc_x buildunit_armsolar -bind Shift+sc_x buildunit_armsolar -bind sc_x buildunit_armwin -bind Shift+sc_x buildunit_armwin -bind sc_x buildunit_corsolar -bind Shift+sc_x buildunit_corsolar -bind sc_x buildunit_corwin -bind Shift+sc_x buildunit_corwin -bind sc_x buildunit_legsolar -bind Shift+sc_x buildunit_legsolar -bind sc_x buildunit_legwin -bind Shift+sc_x buildunit_legwin -bind sc_x buildunit_armadvsol -bind Shift+sc_x buildunit_armadvsol -bind sc_x buildunit_coradvsol -bind Shift+sc_x buildunit_coradvsol -bind sc_x buildunit_legadvsol -bind Shift+sc_x buildunit_legadvsol -bind sc_x buildunit_armfus -bind Shift+sc_x buildunit_armfus -bind sc_x buildunit_armmmkr -bind Shift+sc_x buildunit_armmmkr -bind sc_x buildunit_corfus -bind Shift+sc_x buildunit_corfus -bind sc_x buildunit_cormmkr -bind Shift+sc_x buildunit_cormmkr -bind sc_x buildunit_legfus -bind Shift+sc_x buildunit_legfus -bind sc_x buildunit_legadveconv -bind Shift+sc_x buildunit_legadveconv -bind sc_x buildunit_armtide -bind Shift+sc_x buildunit_armtide -bind sc_x buildunit_cortide -bind Shift+sc_x buildunit_cortide -bind sc_x buildunit_legtide -bind Shift+sc_x buildunit_legtide -bind sc_x buildunit_armuwfus -bind Shift+sc_x buildunit_armuwfus -bind sc_x buildunit_coruwfus -bind Shift+sc_x buildunit_coruwfus -bind sc_x buildunit_armuwmmm -bind Shift+sc_x buildunit_armuwmmm -bind sc_x buildunit_coruwmmm -bind Shift+sc_x buildunit_coruwmmm -bind sc_c buildunit_armllt -bind Shift+sc_c buildunit_armllt -bind sc_c buildunit_armrad -bind Shift+sc_c buildunit_armrad -bind sc_c buildunit_corllt -bind Shift+sc_c buildunit_corllt -bind sc_c buildunit_leglht -bind Shift+sc_c buildunit_leglht -bind sc_c buildunit_corrad -bind Shift+sc_c buildunit_corrad -bind sc_c buildunit_legrad -bind Shift+sc_c buildunit_legrad -bind sc_c buildunit_corrl -bind Shift+sc_c buildunit_corrl -bind sc_c buildunit_legrl -bind Shift+sc_c buildunit_legrl -bind sc_c buildunit_armrl -bind Shift+sc_c buildunit_armrl -bind sc_c buildunit_armpb -bind Shift+sc_c buildunit_armpb -bind sc_c buildunit_armflak -bind Shift+sc_c buildunit_armflak -bind sc_c buildunit_corvipe -bind Shift+sc_c buildunit_corvipe -bind sc_c buildunit_corflak -bind Shift+sc_c buildunit_corflak -bind sc_c buildunit_legapopupdef -bind Shift+sc_c buildunit_legapopupdef -bind sc_c buildunit_legflak -bind Shift+sc_c buildunit_legflak -bind sc_c buildunit_armgplat -bind Shift+sc_c buildunit_armgplat -bind sc_c buildunit_corgplat -bind Shift+sc_c buildunit_corgplat -bind sc_c buildunit_armtl -bind Shift+sc_c buildunit_armtl -bind sc_c buildunit_cortl -bind Shift+sc_c buildunit_cortl -bind sc_c buildunit_legtl -bind Shift+sc_c buildunit_legtl -bind sc_c buildunit_armsonar -bind Shift+sc_c buildunit_armsonar -bind sc_c buildunit_corsonar -bind Shift+sc_c buildunit_corsonar -bind sc_c buildunit_armfrad -bind Shift+sc_c buildunit_armfrad -bind sc_c buildunit_corfrad -bind Shift+sc_c buildunit_corfrad -bind sc_c buildunit_legfrad -bind Shift+sc_c buildunit_legfrad -bind sc_c buildunit_armfrt -bind Shift+sc_c buildunit_armfrt -bind sc_c buildunit_corfrt -bind Shift+sc_c buildunit_corfrt -bind sc_c buildunit_legfrl -bind Shift+sc_c buildunit_legfrl -bind sc_v buildunit_armnanotc -bind Shift+sc_v buildunit_armnanotc -bind sc_v buildunit_armnanotcplat -bind Shift+sc_v buildunit_armnanotcplat -bind sc_v buildunit_cornanotcplat -bind Shift+sc_v buildunit_cornanotcplat -bind sc_v buildunit_armlab -bind Shift+sc_v buildunit_armlab -bind sc_v buildunit_armvp -bind Shift+sc_v buildunit_armvp -bind sc_v buildunit_armap -bind Shift+sc_v buildunit_armap -bind sc_v buildunit_cornanotc -bind Shift+sc_v buildunit_cornanotc -bind sc_v buildunit_corlab -bind Shift+sc_v buildunit_corlab -bind sc_v buildunit_corvp -bind Shift+sc_v buildunit_corvp -bind sc_v buildunit_corap -bind Shift+sc_v buildunit_corap -bind sc_v buildunit_legnanotc -bind Shift+sc_v buildunit_legnanotc -bind sc_v buildunit_leglab -bind Shift+sc_v buildunit_leglab -bind sc_v buildunit_legvp -bind Shift+sc_v buildunit_legvp -bind sc_v buildunit_legap -bind Shift+sc_v buildunit_legap -bind sc_v buildunit_armsy -bind Shift+sc_v buildunit_armsy -bind sc_v buildunit_corsy -bind Shift+sc_v buildunit_corsy -bind sc_v buildunit_legsy -bind Shift+sc_v buildunit_legsy - -// numpad movement -bind numpad2 moveback -bind numpad6 moveright -bind numpad4 moveleft -bind numpad8 moveforward -bind numpad9 moveup -bind numpad3 movedown -bind numpad1 movefast - -// snd_volume_osd -bind numpad+ snd_volume_increase -bind sc_= snd_volume_increase -bind sc_- snd_volume_decrease -bind numpad- snd_volume_decrease - -// los_colors -bind Any+sc_; losradar - -//unit_stats -bind Any+space unit_stats - -// if WG[bind CameraFlip ] then -bind Ctrl+Shift+sc_o cameraflip - -//if not WG[bind Set target default ] then -bind Alt+sc_y settarget -bind Shift+Alt+sc_y settarget -bind sc_y settargetnoground -bind Shift+sc_y settargetnoground - -bind Ctrl+meta+sc_q group unset -// if WG[bind Auto Group ] then -bind Alt+sc_q remove_from_autogroup - -// blueprint -bind Alt+sc_b blueprint_place -bind Alt+sc_c blueprint_create -bind Alt+sc_d blueprint_delete -bind Alt+sc_[ blueprint_prev -bind Alt+sc_] blueprint_next diff --git a/luaui/configs/hotkeys/num_keys.txt b/luaui/configs/hotkeys/num_keys.txt deleted file mode 100644 index c64d674f4ad..00000000000 --- a/luaui/configs/hotkeys/num_keys.txt +++ /dev/null @@ -1,121 +0,0 @@ -bind 1 specteam 0 -bind 2 specteam 1 -bind 3 specteam 2 -bind 4 specteam 3 -bind 5 specteam 4 -bind 6 specteam 5 -bind 7 specteam 6 -bind 8 specteam 7 -bind 9 specteam 8 - -bind Alt+0 add_to_autogroup 0 -bind Alt+1 add_to_autogroup 1 -bind Alt+2 add_to_autogroup 2 -bind Alt+3 add_to_autogroup 3 -bind Alt+4 add_to_autogroup 4 -bind Alt+5 add_to_autogroup 5 -bind Alt+6 add_to_autogroup 6 -bind Alt+7 add_to_autogroup 7 -bind Alt+8 add_to_autogroup 8 -bind Alt+9 add_to_autogroup 9 - -bind Shift+Alt+0 load_autogroup_preset 0 -bind Shift+Alt+1 load_autogroup_preset 1 -bind Shift+Alt+2 load_autogroup_preset 2 -bind Shift+Alt+3 load_autogroup_preset 3 -bind Shift+Alt+4 load_autogroup_preset 4 -bind Shift+Alt+5 load_autogroup_preset 5 -bind Shift+Alt+6 load_autogroup_preset 6 -bind Shift+Alt+7 load_autogroup_preset 7 -bind Shift+Alt+8 load_autogroup_preset 8 -bind Shift+Alt+9 load_autogroup_preset 9 - -bind 0,0 group focus 0 -bind 1,1 group focus 1 -bind 2,2 group focus 2 -bind 3,3 group focus 3 -bind 4,4 group focus 4 -bind 5,5 group focus 5 -bind 6,6 group focus 6 -bind 7,7 group focus 7 -bind 8,8 group focus 8 -bind 9,9 group focus 9 - -bind 0 group select 0 -bind 1 group select 1 -bind 2 group select 2 -bind 3 group select 3 -bind 4 group select 4 -bind 5 group select 5 -bind 6 group select 6 -bind 7 group select 7 -bind 8 group select 8 -bind 9 group select 9 - -bind Ctrl+0 group set 0 -bind Ctrl+1 group set 1 -bind Ctrl+2 group set 2 -bind Ctrl+3 group set 3 -bind Ctrl+4 group set 4 -bind Ctrl+5 group set 5 -bind Ctrl+6 group set 6 -bind Ctrl+7 group set 7 -bind Ctrl+8 group set 8 -bind Ctrl+9 group set 9 - -bind Shift+0 group selectadd 0 -bind Shift+1 group selectadd 1 -bind Shift+2 group selectadd 2 -bind Shift+3 group selectadd 3 -bind Shift+4 group selectadd 4 -bind Shift+5 group selectadd 5 -bind Shift+6 group selectadd 6 -bind Shift+7 group selectadd 7 -bind Shift+8 group selectadd 8 -bind Shift+9 group selectadd 9 - -bind Ctrl+Shift+0 group add 0 -bind Ctrl+Shift+1 group add 1 -bind Ctrl+Shift+2 group add 2 -bind Ctrl+Shift+3 group add 3 -bind Ctrl+Shift+4 group add 4 -bind Ctrl+Shift+5 group add 5 -bind Ctrl+Shift+6 group add 6 -bind Ctrl+Shift+7 group add 7 -bind Ctrl+Shift+8 group add 8 -bind Ctrl+Shift+9 group add 9 - -bind Ctrl+Alt+0 group selecttoggle 0 -bind Ctrl+Alt+1 group selecttoggle 1 -bind Ctrl+Alt+2 group selecttoggle 2 -bind Ctrl+Alt+3 group selecttoggle 3 -bind Ctrl+Alt+4 group selecttoggle 4 -bind Ctrl+Alt+5 group selecttoggle 5 -bind Ctrl+Alt+6 group selecttoggle 6 -bind Ctrl+Alt+7 group selecttoggle 7 -bind Ctrl+Alt+8 group selecttoggle 8 -bind Ctrl+Alt+9 group selecttoggle 9 - -bind meta+alt+0 factory_preset save 0 -bind meta+alt+1 factory_preset save 1 -bind meta+alt+2 factory_preset save 2 -bind meta+alt+3 factory_preset save 3 -bind meta+alt+4 factory_preset save 4 -bind meta+alt+5 factory_preset save 5 -bind meta+alt+6 factory_preset save 6 -bind meta+alt+7 factory_preset save 7 -bind meta+alt+8 factory_preset save 8 -bind meta+alt+9 factory_preset save 9 - -bind meta+0 factory_preset load 0 -bind meta+1 factory_preset load 1 -bind meta+2 factory_preset load 2 -bind meta+3 factory_preset load 3 -bind meta+4 factory_preset load 4 -bind meta+5 factory_preset load 5 -bind meta+6 factory_preset load 6 -bind meta+7 factory_preset load 7 -bind meta+8 factory_preset load 8 -bind meta+9 factory_preset load 9 - -bind any+sc_space factory_preset_show diff --git a/luaui/configs/keyboard_layouts.lua b/luaui/configs/keyboard_layouts.lua index 21daab06b85..d80e71e1712 100644 --- a/luaui/configs/keyboard_layouts.lua +++ b/luaui/configs/keyboard_layouts.lua @@ -325,52 +325,85 @@ local layouts = { "workman", } +-- The order the engine writes modifiers in, and the one every surface should read and emit. +local modifierOrder = { "Alt", "Ctrl", "Meta", "Shift" } + +-- Single-letter abbreviations, as uikeys.txt may spell them (A/C/M/S; * for Any). Derived so +-- the vocabulary is stated once. +local modAbbrev = {} +for _, name in ipairs(modifierOrder) do + modAbbrev[name:sub(1, 1):upper()] = name:upper() .. "+" +end + +-- Printable keys the engine reports by name. The scancode names are positional, so +-- they map to the qwerty character and pick up the layout translation afterwards; +-- the keycode names are the character outright. +local scanKeyWords = { + minus = "-", + equals = "=", + comma = ",", + apostrophe = "'", + period = ".", + semicolon = ";", + leftbracket = "[", + rightbracket = "]", + slash = "/", + backquote = "`", + backslash = "\\", +} +local keyCodeWords = { + backquote = "`", + tilde = "`", + caret = "^", + backslash = "\\", +} + local function sanitizeKey(key, layout) - if not (type(key) == "string") then + if type(key) ~= "string" then return "" end layout = layout or Spring.GetConfigString("KeyboardLayout", "qwerty") - key = key:upper():gsub("ANY%+", "") - key = key:gsub("SC_(.)", function(c) - return scanToCode[layout][c] or c + -- The engine names the punctuation keys with words rather than the character they + -- produce ("sc_backquote", "backslash"). Fold those back first so they render as + -- the key itself, and so the scancode form still picks up the layout mapping below. + key = key:gsub("[Ss][Cc]_(%a%a+)", function(word) + return "sc_" .. (scanKeyWords[word:lower()] or word) + end) + -- Whole words wherever they sit, not just the last one: a chain's first tap is a key + -- name too. Anything not a key name falls through unchanged, modifiers included. + key = key:gsub("%f[%a](%a%a+)%f[%A]", function(word) + return keyCodeWords[word:lower()] or word end) - return key -end - -local keybindingLayouts = { - "Grid", -- the first element will be the default value if a fallback is ever needed - "Grid (60% Keyboard)", - "Legacy", - "Legacy (60% Keyboard)", - "Custom", -} + key = key:upper():gsub("ANY%+", ""):gsub("%*%+", "") -local keybindingPresets = { - [keybindingLayouts[1]] = "luaui/configs/hotkeys/grid_keys.txt", -- the first element will be the default value if a fallback is ever needed - [keybindingLayouts[2]] = "luaui/configs/hotkeys/grid_keys_60pct.txt", - [keybindingLayouts[3]] = "luaui/configs/hotkeys/legacy_keys.txt", - [keybindingLayouts[4]] = "luaui/configs/hotkeys/legacy_keys_60pct.txt", - [keybindingLayouts[5]] = "uikeys.txt", -} + -- Callers pass whole keysets, chains included, so a key name runs to the next comma + -- rather than to the end of the string. The leading "." takes one character before the + -- comma test so a bound comma key reads as itself instead of a separator. + -- Only a single character is positional: matching one character of a named key would + -- remap its first letter instead, which renders sc_space as "OPACE" on dvorak. + local positional = scanToCode[layout] or scanToCode.qwerty + key = key:gsub("SC_(.[^,]*)", function(token) + if #token == 1 then + return positional[token] or token + end -local keybindingLayoutFiles = {} -local presetKeybindings = {} + -- Named keys sit in the same place on every layout, so the name is the label. + return token + end) + -- Expand a single-letter modifier token (frontier so it doesn't eat the A in META+). + key = key:gsub("%f[%u]([ACMS])%+", function(m) + return modAbbrev[m] + end) -for i, v in ipairs(keybindingLayouts) do - local file = keybindingPresets[v] - keybindingLayoutFiles[i] = file - presetKeybindings[file] = v + return key end return { layouts = layouts, scanToCode = scanToCode, sanitizeKey = sanitizeKey, - keybindingLayouts = keybindingLayouts, - keybindingLayoutFiles = keybindingLayoutFiles, - keybindingPresets = keybindingPresets, - presetKeybindings = presetKeybindings, + modifierOrder = modifierOrder, } diff --git a/luaui/images/keybinds/duplicate.png b/luaui/images/keybinds/duplicate.png new file mode 100644 index 00000000000..144feba261f Binary files /dev/null and b/luaui/images/keybinds/duplicate.png differ diff --git a/luaui/images/keybinds/edit.png b/luaui/images/keybinds/edit.png new file mode 100644 index 00000000000..256b2820fda Binary files /dev/null and b/luaui/images/keybinds/edit.png differ diff --git a/luaui/images/keybinds/grid_keys.png b/luaui/images/keybinds/grid_keys.png deleted file mode 100644 index 9c0c8aef488..00000000000 Binary files a/luaui/images/keybinds/grid_keys.png and /dev/null differ diff --git a/luaui/images/keybinds/grid_keys_ALT.png b/luaui/images/keybinds/grid_keys_ALT.png deleted file mode 100644 index fb7a0fd6a2c..00000000000 Binary files a/luaui/images/keybinds/grid_keys_ALT.png and /dev/null differ diff --git a/luaui/images/keybinds/grid_keys_CTRL.png b/luaui/images/keybinds/grid_keys_CTRL.png deleted file mode 100644 index 00fbbecfc27..00000000000 Binary files a/luaui/images/keybinds/grid_keys_CTRL.png and /dev/null differ diff --git a/luaui/images/keybinds/legacy_keys.png b/luaui/images/keybinds/legacy_keys.png deleted file mode 100644 index f2dd95cd9d5..00000000000 Binary files a/luaui/images/keybinds/legacy_keys.png and /dev/null differ diff --git a/luaui/images/keybinds/legacy_keys_ALT.png b/luaui/images/keybinds/legacy_keys_ALT.png deleted file mode 100644 index 7eecc044c0b..00000000000 Binary files a/luaui/images/keybinds/legacy_keys_ALT.png and /dev/null differ diff --git a/luaui/images/keybinds/legacy_keys_CTRL.png b/luaui/images/keybinds/legacy_keys_CTRL.png deleted file mode 100644 index 8d5d7d10d61..00000000000 Binary files a/luaui/images/keybinds/legacy_keys_CTRL.png and /dev/null differ diff --git a/spec/common/json_schema.lua b/spec/common/json_schema.lua new file mode 100644 index 00000000000..a7be63b23bf --- /dev/null +++ b/spec/common/json_schema.lua @@ -0,0 +1,185 @@ +-- Minimal JSON Schema (draft-07) validator, covering only the keywords the shared +-- config schemas use. Unrecognised keywords are ignored rather than rejected. +-- +-- Exists so the schemas shipped for other surfaces are what CI enforces, rather than a +-- second set of rules hand-written in Lua that can drift from them. + +local M = {} + +-- Decoded JSON gives tables for both, so an array is a table keyed only by 1..n. An +-- empty table is indistinguishable and passes as either. +local function isArray(value) + if type(value) ~= "table" then + return false + end + + local count = 0 + for key in pairs(value) do + if type(key) ~= "number" then + return false + end + count = count + 1 + end + + return count == #value +end + +-- An empty Lua table is ambiguous: it reads as both an object and an array. +local function isEmptyTable(value) + return type(value) == "table" and next(value) == nil +end + +-- JSON type test over Lua values, where one table serves for object and array. Draft-07 +-- allows a list of acceptable types, which matches if any one of them does. +local function typeMatches(expected, value) + if type(expected) == "table" then + for _, one in ipairs(expected) do + if typeMatches(one, value) then + return true + end + end + + return false + end + + if expected == "object" then + return type(value) == "table" and (not isArray(value) or isEmptyTable(value)) + elseif expected == "array" then + return isArray(value) + elseif expected == "integer" then + return type(value) == "number" and value % 1 == 0 + elseif expected == "number" then + return type(value) == "number" + elseif expected == "string" then + return type(value) == "string" + elseif expected == "boolean" then + return type(value) == "boolean" + end + + return true +end + +-- Follows a local $ref into definitions. +local function resolve(schema, root) + local ref = schema["$ref"] + if not ref then + return schema + end + + local node = root + for part in ref:gmatch("[^/#]+") do + node = node and node[part] + end + assert(node, "unresolvable $ref: " .. ref) + + return node +end + +local validate + +-- oneOf holds only when exactly one branch matches. +local function validateOneOf(schema, value, root, path, errors) + local matched = 0 + for _, option in ipairs(schema.oneOf) do + local sub = {} + validate(option, value, root, path, sub) + if #sub == 0 then + matched = matched + 1 + end + end + + if matched ~= 1 then + errors[#errors + 1] = path + .. ": matches " + .. matched + .. " of " + .. #schema.oneOf + .. " allowed shapes, expected exactly 1" + end +end + +validate = function(schema, value, root, path, errors) + schema = resolve(schema, root) + + if schema.type and not typeMatches(schema.type, value) then + local expected = type(schema.type) == "table" and table.concat(schema.type, "/") or schema.type + errors[#errors + 1] = path .. ": expected " .. expected .. ", got " .. type(value) + + return errors + end + + if schema.oneOf then + validateOneOf(schema, value, root, path, errors) + end + + if schema.enum then + local allowed = false + for _, option in ipairs(schema.enum) do + if option == value then + allowed = true + break + end + end + + if not allowed then + errors[#errors + 1] = path .. ": " .. tostring(value) .. " is not one of the allowed values" + end + end + + if type(value) == "string" and schema.minLength and #value < schema.minLength then + errors[#errors + 1] = path .. ": shorter than minLength " .. schema.minLength + end + + if type(value) == "number" and schema.minimum and value < schema.minimum then + errors[#errors + 1] = path .. ": below minimum " .. schema.minimum + end + + if type(value) ~= "table" then + return errors + end + + for _, name in ipairs(schema.required or {}) do + if value[name] == nil then + errors[#errors + 1] = path .. ": missing required property '" .. name .. "'" + end + end + + if schema.properties then + for name, sub in pairs(schema.properties) do + if value[name] ~= nil then + validate(sub, value[name], root, path .. "." .. name, errors) + end + end + end + + -- Each of these stands on its own in the spec, so none of them hangs off another + -- keyword being present: additionalProperties is meaningful with no properties listed, + -- and minItems with no items schema. + if schema.additionalProperties == false then + local declared = schema.properties or {} + for name in pairs(value) do + if type(name) == "string" and declared[name] == nil then + errors[#errors + 1] = path .. ": unexpected property '" .. name .. "'" + end + end + end + + if schema.minItems and isArray(value) and #value < schema.minItems then + errors[#errors + 1] = path .. ": has " .. #value .. " items, needs at least " .. schema.minItems + end + + if schema.items and isArray(value) then + for i, entry in ipairs(value) do + validate(schema.items, entry, root, path .. "[" .. i .. "]", errors) + end + end + + return errors +end + +-- Entry point: validates a document against a schema. +function M.validate(schema, document) + return validate(schema, document, schema, "$", {}) +end + +return M diff --git a/spec/common/keybind_catalog_spec.lua b/spec/common/keybind_catalog_spec.lua new file mode 100644 index 00000000000..5ae2f4f5b60 --- /dev/null +++ b/spec/common/keybind_catalog_spec.lua @@ -0,0 +1,141 @@ +-- Guards the shared keybind data contract (common/configs/keybind_catalog.json and +-- keybind_defaults.json) against drift. Structure is checked against the shipped +-- schemas, so CI enforces the same rules other surfaces read; the rest is referential +-- integrity a schema cannot express - names unique across both profile lists, action +-- commands in the case the engine will match. +-- +-- Reads the JSON directly rather than through the Lua adapters, because the adapters +-- use VFS.LoadFile, which the test harness does not mock. + +local Json = VFS.Include("common/luaUtilities/json.lua") +local JsonSchema = VFS.Include("spec/common/json_schema.lua") + +local function loadJson(path) + local f = assert(io.open(path, "r"), "cannot open " .. path) + local content = f:read("*a") + f:close() + return Json.decode(content) +end + +local function conformsTo(schemaPath, documentPath) + local problems = JsonSchema.validate(loadJson(schemaPath), loadJson(documentPath)) + assert( + #problems == 0, + documentPath .. " does not match " .. schemaPath .. ":\n " .. table.concat(problems, "\n ") + ) +end + +describe("shipped keybind profiles", function() + local defaults = loadJson("common/configs/keybind_defaults.json") + + it("matches its schema", function() + conformsTo("common/configs/keybind_defaults.schema.json", "common/configs/keybind_defaults.json") + end) + + -- The picker lists these by name, and a duplicate would make one unreachable. + it("names every profile uniquely", function() + local seen = {} + for _, profile in ipairs(defaults.profiles) do + assert(not seen[profile.name], "duplicate profile name: " .. tostring(profile.name)) + seen[profile.name] = true + end + end) +end) + +describe("keybind catalog", function() + local catalog = loadJson("common/configs/keybind_catalog.json") + + it("matches its schema", function() + conformsTo("common/configs/keybind_catalog.schema.json", "common/configs/keybind_catalog.json") + end) + + -- Action::Action lowercases the command before Spring.GetKeyBindings ever sees it, so + -- a capitalised id here silently matches nothing and the row renders with no keys. + -- Only the command is lowered; its arguments keep their case. + + -- The editor cannot capture a bare modifier as a key, so an action bound only that way + -- is not editable at all and is hidden rather than listed: shown, its remove chip would + -- strip a binding the player could never put back. An action bound both ways stays + -- listed - hiding a real key to protect a modifier would be the worse trade. + it("hides every purely modifier-only action", function() + local defaults = loadJson("common/configs/keybind_defaults.json") + local modifiers = { alt = true, ctrl = true, shift = true, meta = true, any = true } + local function isModifierOnly(keyset) + local parts = 0 + for part in keyset:gmatch("[^+]+") do + if not modifiers[part:lower()] then + return false + end + parts = parts + 1 + end + return parts > 0 + end + + local kinds = {} + for _, profile in ipairs(defaults.profiles) do + for _, bind in ipairs(profile.binds) do + local seen = kinds[bind.action] or {} + seen[isModifierOnly(bind.keyset)] = true + kinds[bind.action] = seen + end + end + + local hidden = {} + for _, group in ipairs(catalog) do + for _, action in ipairs(group.hidden or {}) do + hidden[action] = true + end + end + + for action, seen in pairs(kinds) do + if seen[true] and not seen[false] then + assert( + hidden[action], + "action is bound only to modifiers but is not hidden, so " + .. "the editor would offer an edit it cannot undo: " + .. action + ) + end + end + + local listed = {} + for _, group in ipairs(catalog) do + for _, item in ipairs(group.items or {}) do + if item.action then + listed[item.action] = true + end + end + end + for action in pairs(hidden) do + assert(not listed[action], "action is both hidden and listed: " .. action) + end + end) + -- The engine lower-cases a command when it parses the bind line, so anything reading the + -- live keymap sees lower case and a capitalised id in either file matches nothing. Args + -- are left alone: the selection language is case-sensitive. + it("writes every action command in lower case", function() + local function check(id, where) + local command = id:match("^%S+") or id + assert(command == command:lower(), where .. " command is not lower case: " .. id) + end + + for _, group in ipairs(catalog) do + for _, item in ipairs(group.items or {}) do + local id = item.action or item.prefix + if id then + check(id, "catalog action") + end + end + for _, id in ipairs(group.hidden or {}) do + check(id, "hidden action") + end + end + + local defaults = loadJson("common/configs/keybind_defaults.json") + for _, profile in ipairs(defaults.profiles) do + for _, bind in ipairs(profile.binds) do + check(bind.action, "profile bind") + end + end + end) +end)