From 5e3f3f663bbc01d272692b8d656e8284339087e2 Mon Sep 17 00:00:00 2001 From: Robert Burnham Date: Mon, 14 Sep 2026 11:41:21 -0500 Subject: [PATCH 1/5] Restore the space meta key on the shipped keymaps (#9244) Puts the space meta key back on the shipped keymaps, which is what the factory queue presets on meta+0-9 have been missing since profiles replaced the preset files. Follows up on #8153. The old preset files never mentioned fakemeta, so the tempting fix is to go back to emitting nothing and let the engine's default stand. That doesn't hold up any more: loading a keymap leaves the meta key alone, so saying nothing now means inheriting whatever the last profile set rather than getting the engine's back, and there's no Lua getter to read it with. Naming it in the profile is what keeps a keymap a whole snapshot. Existing players need a hand, though: every uikeys.txt written so far says "fakemeta none", so without the store version bump they would all quietly fork a "Grid (2)" holding the broken value and never see the fix. The rewrite runs after the usual hand-edit check, so anything someone actually changed is still kept. Not touching the editor's refusal to capture a bare modifier, so space still can't be bound on its own; same gap as ctrl and alt, and it wants fixing all together. AI disclosure: written with assistance from Claude Code. --- common/configs/keybind_defaults.json | 4 + common/configs/keybind_defaults.schema.json | 2 +- common/configs/keybinds.README.md | 10 +- luaui/Include/keybind_profiles.lua | 130 +++++++++++++++----- 4 files changed, 109 insertions(+), 37 deletions(-) diff --git a/common/configs/keybind_defaults.json b/common/configs/keybind_defaults.json index ed9da7b5792..cccfb377946 100644 --- a/common/configs/keybind_defaults.json +++ b/common/configs/keybind_defaults.json @@ -5,6 +5,7 @@ { "name": "Grid", "description": "ui.keybinds.presets.grid", + "fakeMeta": "space", "binds": [ { "keyset": "esc", @@ -1339,6 +1340,7 @@ { "name": "Grid (60% Keyboard)", "description": "ui.keybinds.presets.grid60", + "fakeMeta": "space", "binds": [ { "keyset": "esc", @@ -2585,6 +2587,7 @@ { "name": "Legacy", "description": "ui.keybinds.presets.legacy", + "fakeMeta": "space", "binds": [ { "keyset": "esc", @@ -4371,6 +4374,7 @@ { "name": "Legacy (60% Keyboard)", "description": "ui.keybinds.presets.legacy60", + "fakeMeta": "space", "binds": [ { "keyset": "esc", diff --git a/common/configs/keybind_defaults.schema.json b/common/configs/keybind_defaults.schema.json index d35707ee9d5..03ec57840c5 100644 --- a/common/configs/keybind_defaults.schema.json +++ b/common/configs/keybind_defaults.schema.json @@ -24,7 +24,7 @@ }, "fakeMeta": { "type": "string", - "description": "Key to treat as the Meta modifier, if the profile wants one." + "description": "Key to treat as the Meta modifier. A keycode, never a scancode, which the engine refuses here. Leave it out to get the engine's own, which is space; set \"none\" for a profile that wants no Meta modifier at all." }, "binds": { "type": "array", diff --git a/common/configs/keybinds.README.md b/common/configs/keybinds.README.md index 243777b17a2..52999a34f93 100644 --- a/common/configs/keybinds.README.md +++ b/common/configs/keybinds.README.md @@ -127,10 +127,12 @@ the clipboard and what Import reads back, and the same text a player would put i - **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. +- **Apply a profile.** Write its binds out as `bind ` lines with a leading + `fakemeta `, 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. It does not clear the meta key, so always write that line: leave it out + and whatever the last profile set stays. A profile naming no key wants the engine's own, + `space`; `fakemeta none` asks for no Meta modifier at all. - **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 diff --git a/luaui/Include/keybind_profiles.lua b/luaui/Include/keybind_profiles.lua index 6784df02302..d0dbba33434 100644 --- a/luaui/Include/keybind_profiles.lua +++ b/luaui/Include/keybind_profiles.lua @@ -16,7 +16,7 @@ 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 +local STORE_VERSION = 2 -- 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 @@ -47,6 +47,10 @@ local presetFiles = { ---@type table local store +-- Set while reading a store written before profiles named a meta key, so the launch that +-- upgrades one can still recognise the files that version wrote. +local storePredatesMeta = false + -- Shape a fresh store file takes. local function emptyStore() return { version = STORE_VERSION, active = nil, profiles = {} } @@ -130,15 +134,42 @@ local function generatedName(text) return (name ~= nil and name ~= "") and name or nil end +-- Loading a keymap leaves the meta key alone, so a bind file naming none runs under whatever +-- the engine set at startup. Every shipped keymap relied on that before profiles carried one. +local ENGINE_FAKE_META = "space" + +-- A meta key the engine will actually take, nil for anything else. It keeps the key it already +-- had when it cannot parse one, so emitting a name it does not know leaves the live keymap +-- disagreeing with the profile that named it. "none", which clears the key, is the one non-key +-- it accepts, and it takes that ahead of any parsing. Scancodes it refuses outright. +local function validFakeMeta(value) + if type(value) ~= "string" or value == "" or value:find("%s") then + return nil + end + + if value == "none" or (Spring.GetKeyCode(value) or 0) > 0 then + return value + end + + return nil +end + +-- What a profile's meta key comes to. Naming nothing asks for the engine's, the same as a bind +-- file that names none does; "none" is how a profile asks for no meta key at all. +local function resolveFakeMeta(value) + return validFakeMeta(value) or ENGINE_FAKE_META +end + +-- Shipped profiles never go through the store, so this is the only place their meta key is +-- checked before the editor reads it back and hands it to a fork. +for _, b in ipairs(builtins) do + b.fakeMeta = resolveFakeMeta(b.fakeMeta) +end + -- A whole keymap: keyreload clears the bindings before it loads, but not the meta key. local function toBindFile(profile) local out = { GENERATED_PREFIX .. tostring(profile.name) } - -- One token only: anything longer emits a directive the engine cannot parse; "none" clears. - local fakeMeta = profile.fakeMeta - if not fakeMeta or fakeMeta == "" or fakeMeta:find("%s") then - fakeMeta = "none" - end - out[#out + 1] = "fakemeta " .. fakeMeta + out[#out + 1] = "fakemeta " .. resolveFakeMeta(profile.fakeMeta) -- 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. @@ -295,9 +326,13 @@ local function readFakeMeta(text) 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 fakeMetaOf(text) + return resolveFakeMeta(readFakeMeta(text)) +end + +-- What a bind file binds, as one comparable string, and the meta key it leaves set. 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 @@ -309,16 +344,28 @@ local function keymapOf(text) parts[i] = binds[i].keyset .. " " .. binds[i].action end - return table.concat(parts, "\n") .. "\nfakemeta " .. tostring(readFakeMeta(text)) + return table.concat(parts, "\n"), fakeMetaOf(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 +-- The profile already holding this keymap, nil when none does. 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 + local theirBinds, theirMeta = keymapOf(text) + if not theirBinds then + return nil + end + + -- Before profiles named a meta key every file we wrote said "fakemeta none", so on the + -- launch that upgrades a store one differing only there is still ours rather than an edit. + -- A player who named some other key still forks. + local function holds(profile) + local ourBinds, ourMeta = keymapOf(toBindFile(profile)) + if ourBinds ~= theirBinds then + return false + end + + return ourMeta == theirMeta or (storePredatesMeta and theirMeta == "none") end -- Nearly always our own output for the profile it names, and this runs on every game @@ -327,22 +374,22 @@ local function matchesKnownProfile(text) 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 + if stamped and holds(stamped) then + return stamped.name end for _, p in ipairs(store.profiles) do - if keymapOf(toBindFile(p)) == theirs then - return true + if holds(p) then + return p.name end end for _, b in ipairs(builtins) do - if keymapOf(toBindFile(b)) == theirs then - return true + if holds(b) then + return b.name end end - return false + return nil end -- A name no existing profile holds, for copies. @@ -453,7 +500,7 @@ local function migrate() 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.profiles[1] = { name = name, binds = own, fakeMeta = fakeMetaOf(ownText) } store.active = preset or name else store.active = preset @@ -492,16 +539,27 @@ function M.load() end store = decoded - store.version = store.version or STORE_VERSION + storePredatesMeta = (tonumber(store.version) or 1) < 2 + store.version = STORE_VERSION -- A hand-edited file can repeat a name; keep the first so lookups stay unambiguous. local seen, kept, inferred = {}, {}, false 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 + -- Said here rather than on the way out, where the emitter runs once per profile per + -- comparison and would repeat it all session. + if p.fakeMeta and not validFakeMeta(p.fakeMeta) then + Spring.Echo( + "[keybind_profiles] profile " + .. p.name + .. " names meta key " + .. tostring(p.fakeMeta) + .. ", which the engine has none of; falling back to " + .. ENGINE_FAKE_META + ) end + p.fakeMeta = resolveFakeMeta(p.fakeMeta) -- Which shipped profile it was forked from. Only a name that still ships means -- anything: a retired one would have the editor comparing against nothing, so a -- profile without a usable one is given the closest shipped profile instead, and @@ -514,7 +572,7 @@ function M.load() end end store.profiles = kept - if inferred then + if inferred or storePredatesMeta then M.save() end @@ -577,7 +635,15 @@ function M.adoptEditedKeymap() return nil end - if matchesKnownProfile(text) then + local matched = matchesKnownProfile(text) + if matched then + -- A keymap still matching its profile is never rewritten, so the "fakemeta none" the + -- previous version wrote into every file would outlive the upgrade that gave the + -- profiles a meta key. Left until here so a file the player did edit is adopted first. + if storePredatesMeta then + M.materialize(matched) + end + return nil end @@ -588,7 +654,7 @@ function M.adoptEditedKeymap() local previous = store.active local name = nextCopyName(M.activeName() or "Custom") - store.profiles[#store.profiles + 1] = { name = name, binds = binds, fakeMeta = readFakeMeta(text) } + store.profiles[#store.profiles + 1] = { name = name, binds = binds, fakeMeta = fakeMetaOf(text) } store.active = name if not M.save() then table.remove(store.profiles) @@ -695,7 +761,7 @@ end function M.create(name, binds, fakeMeta, basedOn) M.load() name = M.uniqueName(name) - local profile = { name = name, binds = binds, fakeMeta = fakeMeta } + local profile = { name = name, binds = binds, fakeMeta = resolveFakeMeta(fakeMeta) } profile.basedOn = (basedOn and M.isBuiltin(basedOn)) and basedOn or M.inferBase(profile) store.profiles[#store.profiles + 1] = profile if not M.save() then @@ -815,7 +881,7 @@ function M.parseBindFile(text) return nil end - return binds, readFakeMeta(text), generatedName(text) + return binds, fakeMetaOf(text), generatedName(text) end -- Write a profile out where the engine can keyreload it, and return that path. From d514c9d611414145f265c57eaa23a9eae9f9fd21 Mon Sep 17 00:00:00 2001 From: Robert Burnham Date: Mon, 14 Sep 2026 11:50:34 -0500 Subject: [PATCH 2/5] Remove yardmap from mobile unit defs (#9243) Removes the yardmap key from the 16 mobile defs that carry one. --- luaui/Tests/unitdefs/test_mobile_yardmap.lua | 15 +++++++++++++++ units/other/ceg_test_projectile.lua | 1 - units/other/chip.lua | 1 - units/other/dice.lua | 1 - units/other/volcano_projectile_unit.lua | 1 - units/other/xmasball1_1.lua | 1 - units/other/xmasball1_2.lua | 1 - units/other/xmasball1_3.lua | 1 - units/other/xmasball1_4.lua | 1 - units/other/xmasball1_5.lua | 1 - units/other/xmasball1_6.lua | 1 - units/other/xmasball2_1.lua | 1 - units/other/xmasball2_2.lua | 1 - units/other/xmasball2_3.lua | 1 - units/other/xmasball2_4.lua | 1 - units/other/xmasball2_5.lua | 1 - units/other/xmasball2_6.lua | 1 - 17 files changed, 15 insertions(+), 16 deletions(-) create mode 100644 luaui/Tests/unitdefs/test_mobile_yardmap.lua diff --git a/luaui/Tests/unitdefs/test_mobile_yardmap.lua b/luaui/Tests/unitdefs/test_mobile_yardmap.lua new file mode 100644 index 00000000000..5815f4a5900 --- /dev/null +++ b/luaui/Tests/unitdefs/test_mobile_yardmap.lua @@ -0,0 +1,15 @@ +-- UnitDefs has no yardmap field; unitdefs_post exports it as customparams.buildsquare_yardmap. +-- The engine only honours a yardmap on buildings and is moving to reject one on anything mobile (beyond-all-reason/RecoilEngine#1597). +local function test() + local offenders = {} + for _, unitDef in pairs(UnitDefs) do + if not unitDef.isImmobile and unitDef.customParams.buildsquare_yardmap then + offenders[#offenders + 1] = unitDef.name + end + end + table.sort(offenders) + + assertEqual(#offenders, 0, "mobile unit defs with a yardmap: " .. table.concat(offenders, ", ")) +end + +return { test = test } diff --git a/units/other/ceg_test_projectile.lua b/units/other/ceg_test_projectile.lua index fdbcb281f7f..19e6012b1d4 100644 --- a/units/other/ceg_test_projectile.lua +++ b/units/other/ceg_test_projectile.lua @@ -59,7 +59,6 @@ return { drawtype = 0, selectable = false, blocking = false, - yardmap = "o", canstop = false, canpatrol = false, diff --git a/units/other/chip.lua b/units/other/chip.lua index 4e6e1e19684..5d205478d53 100644 --- a/units/other/chip.lua +++ b/units/other/chip.lua @@ -35,7 +35,6 @@ return { sonarstealth = true, stealth = true, usebuildinggrounddecal = false, - yardmap = "f", customparams = { unitgroup = "util", model_author = "Floris", diff --git a/units/other/dice.lua b/units/other/dice.lua index a533ede1622..3c8bdfd7094 100644 --- a/units/other/dice.lua +++ b/units/other/dice.lua @@ -35,7 +35,6 @@ return { sonarstealth = true, stealth = true, usebuildinggrounddecal = false, - yardmap = "f", customparams = { nohealthbars = true, subfolder = "other", diff --git a/units/other/volcano_projectile_unit.lua b/units/other/volcano_projectile_unit.lua index 0041a050ad0..0fd7f6426be 100644 --- a/units/other/volcano_projectile_unit.lua +++ b/units/other/volcano_projectile_unit.lua @@ -48,7 +48,6 @@ return { drawtype = 0, selectable = false, blocking = false, - yardmap = "o", canstop = false, canpatrol = false, diff --git a/units/other/xmasball1_1.lua b/units/other/xmasball1_1.lua index 3e0b9c406cc..cd25ddf9138 100644 --- a/units/other/xmasball1_1.lua +++ b/units/other/xmasball1_1.lua @@ -35,7 +35,6 @@ return { sonarstealth = true, stealth = true, usebuildinggrounddecal = false, - yardmap = "f", customparams = { model_author = "Floris", nohealthbars = true, diff --git a/units/other/xmasball1_2.lua b/units/other/xmasball1_2.lua index 5f3ce79c37b..a0407254f7d 100644 --- a/units/other/xmasball1_2.lua +++ b/units/other/xmasball1_2.lua @@ -35,7 +35,6 @@ return { sonarstealth = true, stealth = true, usebuildinggrounddecal = false, - yardmap = "f", customparams = { model_author = "Floris", nohealthbars = true, diff --git a/units/other/xmasball1_3.lua b/units/other/xmasball1_3.lua index 393a0ab5ca7..333bb3d4349 100644 --- a/units/other/xmasball1_3.lua +++ b/units/other/xmasball1_3.lua @@ -35,7 +35,6 @@ return { sonarstealth = true, stealth = true, usebuildinggrounddecal = false, - yardmap = "f", customparams = { model_author = "Floris", nohealthbars = true, diff --git a/units/other/xmasball1_4.lua b/units/other/xmasball1_4.lua index 05c83a11fd0..a3fb6f7ed92 100644 --- a/units/other/xmasball1_4.lua +++ b/units/other/xmasball1_4.lua @@ -35,7 +35,6 @@ return { sonarstealth = true, stealth = true, usebuildinggrounddecal = false, - yardmap = "f", customparams = { model_author = "Floris", nohealthbars = true, diff --git a/units/other/xmasball1_5.lua b/units/other/xmasball1_5.lua index ea6f360323d..89c10becc72 100644 --- a/units/other/xmasball1_5.lua +++ b/units/other/xmasball1_5.lua @@ -35,7 +35,6 @@ return { sonarstealth = true, stealth = true, usebuildinggrounddecal = false, - yardmap = "f", customparams = { model_author = "Floris", nohealthbars = true, diff --git a/units/other/xmasball1_6.lua b/units/other/xmasball1_6.lua index 652290deae5..16e6dc6590a 100644 --- a/units/other/xmasball1_6.lua +++ b/units/other/xmasball1_6.lua @@ -35,7 +35,6 @@ return { sonarstealth = true, stealth = true, usebuildinggrounddecal = false, - yardmap = "f", customparams = { model_author = "Floris", nohealthbars = true, diff --git a/units/other/xmasball2_1.lua b/units/other/xmasball2_1.lua index 898cb7fed2b..2ba0a7f1f7e 100644 --- a/units/other/xmasball2_1.lua +++ b/units/other/xmasball2_1.lua @@ -35,7 +35,6 @@ return { sonarstealth = true, stealth = true, usebuildinggrounddecal = false, - yardmap = "f", customparams = { model_author = "Floris", nohealthbars = true, diff --git a/units/other/xmasball2_2.lua b/units/other/xmasball2_2.lua index 2324c807fba..40ddce93e13 100644 --- a/units/other/xmasball2_2.lua +++ b/units/other/xmasball2_2.lua @@ -35,7 +35,6 @@ return { sonarstealth = true, stealth = true, usebuildinggrounddecal = false, - yardmap = "f", customparams = { model_author = "Floris", nohealthbars = true, diff --git a/units/other/xmasball2_3.lua b/units/other/xmasball2_3.lua index 9e8deace050..39cda10ac0b 100644 --- a/units/other/xmasball2_3.lua +++ b/units/other/xmasball2_3.lua @@ -35,7 +35,6 @@ return { sonarstealth = true, stealth = true, usebuildinggrounddecal = false, - yardmap = "f", customparams = { model_author = "Floris", nohealthbars = true, diff --git a/units/other/xmasball2_4.lua b/units/other/xmasball2_4.lua index 27037e246b4..93ee2d4280a 100644 --- a/units/other/xmasball2_4.lua +++ b/units/other/xmasball2_4.lua @@ -35,7 +35,6 @@ return { sonarstealth = true, stealth = true, usebuildinggrounddecal = false, - yardmap = "f", customparams = { model_author = "Floris", nohealthbars = true, diff --git a/units/other/xmasball2_5.lua b/units/other/xmasball2_5.lua index f0940c00acc..b5b16d45209 100644 --- a/units/other/xmasball2_5.lua +++ b/units/other/xmasball2_5.lua @@ -35,7 +35,6 @@ return { sonarstealth = true, stealth = true, usebuildinggrounddecal = false, - yardmap = "f", customparams = { model_author = "Floris", nohealthbars = true, diff --git a/units/other/xmasball2_6.lua b/units/other/xmasball2_6.lua index ebb17ed044f..de13599fcd4 100644 --- a/units/other/xmasball2_6.lua +++ b/units/other/xmasball2_6.lua @@ -35,7 +35,6 @@ return { sonarstealth = true, stealth = true, usebuildinggrounddecal = false, - yardmap = "f", customparams = { model_author = "Floris", nohealthbars = true, From 87b325d6e4a7cd38a27b587939b5d8cb0b500930 Mon Sep 17 00:00:00 2001 From: Floris Date: Mon, 14 Sep 2026 20:53:11 +0200 Subject: [PATCH 3/5] keybind editor: added keyboard layout button (#9248) --- common/configs/keybind_catalog.schema.json | 2 + common/configs/keybinds.README.md | 14 +- language/en/interface.json | 23 + luaui/Include/keybind_editor_view.lua | 650 ++++++++++- luaui/Include/keybind_keyboard.lua | 1136 ++++++++++++++++++++ luaui/Include/keybind_model.lua | 22 + luaui/Include/keybind_profiles.lua | 72 +- luaui/Widgets/gui_flowui.lua | 142 +++ luaui/Widgets/gui_keybind_info.lua | 51 +- 9 files changed, 2050 insertions(+), 62 deletions(-) create mode 100644 luaui/Include/keybind_keyboard.lua diff --git a/common/configs/keybind_catalog.schema.json b/common/configs/keybind_catalog.schema.json index 05c2bc56fc6..c175132d94f 100644 --- a/common/configs/keybind_catalog.schema.json +++ b/common/configs/keybind_catalog.schema.json @@ -50,6 +50,7 @@ "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." }, "description": { "type": "string", "description": "i18n key for a sentence saying what the action does, for a tooltip. Optional: without one a surface may fall back to the command card's tooltip (commands._tooltip) or the engine's command description (cmd.)." }, + "icon": { "type": "string", "description": "VFS path of a picture for the action, drawn on its key in the keyboard overview and wherever else a surface has room for one. Optional: without one an order shows the cursor it is already known by, and anything else shows no picture." }, "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." } } }, @@ -63,6 +64,7 @@ "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." }, + "icon": { "type": "string", "description": "VFS path of a picture shared by every action in the family, drawn on its key in the keyboard overview. Optional." }, "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/keybinds.README.md b/common/configs/keybinds.README.md index 52999a34f93..6ef9ec393ae 100644 --- a/common/configs/keybinds.README.md +++ b/common/configs/keybinds.README.md @@ -66,6 +66,12 @@ surface shows it or lets the player pick it: - `"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. +An entry, action or prefix, may carry `"icon"`: the VFS path of a picture for the action, +drawn on its key in the editor's keyboard overview (and wherever else a surface has room for +one). Without one, an order shows the cursor it is already known by in game, and anything +else shows no picture; the field exists so actions can be given pictures as art for them is +made, without any surface changing. + 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. @@ -111,11 +117,13 @@ shape as the shipped ones plus an `active` field naming the selected profile. Th is per-install rather than shared, but its format is the contract - a surface that can read one can read the other. -A player's profile carries `basedOn`, the name of the shipped profile it descends from: +A player's profile carries `basedOn`, the name of the profile it is compared with: recorded when it was forked or duplicated, and otherwise (imported, or made before the -field existed, or naming a profile that no longer ships) inferred on load as the shipped +field existed, or naming a profile that no longer exists) inferred on load as the shipped profile it differs from on the fewest actions, and written back. That is what lets a -surface say which keys the player changed and what the default was. +surface say which keys the player changed and what the default was. The player can point +it at any other profile, shipped or their own, or at `"none"`, which means no comparison +and is the one value loading leaves alone rather than replacing with a guess. A shipped profile may carry `description`, an i18n key for a sentence saying what the profile is for, shown wherever a surface lets the player pick one. diff --git a/language/en/interface.json b/language/en/interface.json index 4c59c22bc0e..3d413c0183b 100644 --- a/language/en/interface.json +++ b/language/en/interface.json @@ -242,6 +242,7 @@ "allCategories": "All", "search": "Search actions or keys...", "boundTo": "Bound to %{keys}", + "boundToAny": "Also fires on this key with any modifier held", "preset": "Preset", "defaultTag": "Default", "pressKey": "Press a key or mouse button...", @@ -259,6 +260,12 @@ "noticeUnsaved": "Unsaved changes (Ctrl+Z undoes the last edit).", "changed": "Changed", "changedCount": "Changed (%{n})", + "changedUnknown": "Changed (?)", + "changedNoneTooltip": "No preset is being compared with. Pick one in this section to list the actions whose keys differ from it.", + "compareWith": "Compare with", + "compareNone": "None", + "compareNoneHint": "Pick a preset to compare with. The actions whose keys differ from it are listed here, each with the key it has there.", + "changedNothing": "No keys differ from %{name}.", "changedTooltip": "Every action whose keys differ from %{name}. Restore default keybinds by clicking the button containing the defualt keybind on the right.", "conflict": "%{keys} is also bound to: %{actions}", "conflictFirst": "%{action} (tried first)", @@ -278,6 +285,8 @@ "exportDone": "\"%{name}\" is on the clipboard. Paste it anywhere to share or keep it; Import reads it back in.", "import": "Import", "importTooltip": "Read a preset from text on the clipboard and add it as a new preset.", + "keyboard": "Keyboard", + "keyboardTooltip": "Show this preset's keybinds laid out on a keyboard. Click Shift, Ctrl, Alt or Meta on it to see what the keys do with that held; click again for the list.", "importTitle": "Import preset", "importEmpty": "The clipboard is empty. Copy a preset's text first, then try again.", "importSummary": "%{n} keybinds found", @@ -298,6 +307,20 @@ "accept": "Accept", "cancel": "Cancel" }, + "keyboard": { + "layerBase": "No modifier held", + "layer": "%{mods} held", + "hint": "Click Shift, Ctrl, Alt or Meta to see what the keys do with it held, or hold it. Click a key to list its actions.", + "notShown": "%{n} keybinds sit on keys neither keyboard draws.", + "numpad": "Numpad", + "numpadTooltip": "Show the arrow keys, the navigation keys and the number pad. Click again for the main keys.", + "unbound": "Nothing bound", + "anyModifier": "with any modifier", + "paired": "with or without Shift", + "clickKey": "Click to list everything on this key.", + "clickModifier": "Click to show what the keys do with %{mod} held.", + "clickModifierOff": "Click to stop showing the keys with %{mod} held." + }, "presets": { "grid": "Builds through the grid menu: QWER, ASDF and ZXCV pick a slot in the build grid, so the same key builds the same slot for every builder. Orders sit on the keys around them.", "grid60": "The Grid preset for keyboards without a function row: map views, camera anchors and the other F-key actions move to Meta + number, and what sat on ` moves to Meta + Q.", diff --git a/luaui/Include/keybind_editor_view.lua b/luaui/Include/keybind_editor_view.lua index 9ed88f6e59f..7e21ef868d1 100644 --- a/luaui/Include/keybind_editor_view.lua +++ b/luaui/Include/keybind_editor_view.lua @@ -134,8 +134,22 @@ local scroll = 0 -- to its top edge, so the thumb follows the cursor instead of jumping its middle to the -- press. It rides here rather than in a local of its own: this chunk is at Lua's ceiling of -- 200 locals, which is why the sizes above share `metrics` too. -local hover = - { sb = 0, row = 0, zone = "", idx = 0, gk = "", ga = 0, gb = 0, btn = "", bar = 0, grab = 0, cat = 0, drag = false } +-- `kb` is the key under the cursor on the keyboard page. +local hover = { + sb = 0, + row = 0, + zone = "", + idx = 0, + gk = "", + ga = 0, + gb = 0, + btn = "", + bar = 0, + grab = 0, + cat = 0, + drag = false, + kb = 0, +} local dirty = false -- Blur behind whatever floats over the panel, and the floating content drawn back on top @@ -285,6 +299,16 @@ local look = { -- of taking the white overlay, which would wash the green out to grey. addFillHover = { 0.32, 0.74, 0.4, 0.6 }, selectedFill = { 1, 1, 1, 0.13 }, + -- The outline every string the panel prints is drawn with, set on every batch rather than + -- once: the font is shared with every other widget, some of which set an outline of their + -- own and leave it set, and text baked into a display list keeps whatever outline was set + -- last. The settings panel's value, which this panel is styled after. + outline = { 0, 0, 0, 0.4 }, + -- The keyboard page's toggle in the header, pressed while that page is showing: lifted + -- above the resting button grey, and further under the cursor, since a tinted face takes + -- no hover overlay. + toggleFill = { 0.33, 0.33, 0.33, 1 }, + toggleFillHover = { 0.4, 0.4, 0.4, 1 }, -- 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 }, @@ -385,7 +409,8 @@ local dialog -- active preset is a default. That is when Edit is greyed out, and its tooltip is then the -- one place saying why. -- The two with icons act on the active preset; the two with captions carry presets in and out --- through the clipboard. +-- through the clipboard. The last is the page toggle, set apart by a wider gap: it swaps the +-- list for the keyboard overview and back, and sits pressed while the keyboard is showing. local headerButtons = { { id = "duplicate", @@ -402,6 +427,7 @@ local headerButtons = { }, { id = "export", tooltipId = "keybind_export", tip = "exportTooltip" }, { id = "import", tooltipId = "keybind_import", tip = "importTooltip" }, + { id = "keyboard", tooltipId = "keybind_keyboard", tip = "keyboardTooltip", toggle = true, gap = 2 }, } -- Discarding is destructive and saving is not, so the two footer buttons are coloured for @@ -436,6 +462,10 @@ local buttonSets = { headerButtons, footerButtons } -- files one snapshot for the lot. -- tipKey/tipTitle/tipText: the tooltip last built, kept until the cursor is on something -- else, since building one wraps text. +-- page: "list" or "keyboard", the page the body shows. keyboard is the keyboard page +-- itself, and keyboardGen the rowsGen its bindings were placed from, so it is placed +-- again only once the staged keymap has changed. keyInfo: each action's card for it, +-- built from the catalog on first use and dropped with the catalog. local state = { headerH = 0, footerH = 0, @@ -449,6 +479,10 @@ local state = { undo = {}, batching = false, batchEdited = false, + ---@type string + page = "list", + keyboard = VFS.Include("luaui/Include/keybind_keyboard.lua").new(), + keyboardGen = -1, } -- A copy of the staged keymap, for putting back. Binds and keysets are copied rather than @@ -704,6 +738,7 @@ local function buildResolvedCatalog() unit = item.unit, members = item.members, description = describe(item), + icon = (item.icon and VFS.FileExists(item.icon) and item.icon) or nil, } else if item.action then @@ -722,6 +757,9 @@ local function buildResolvedCatalog() label = label, labelLower = label:lower(), cursor = cursor, + -- The picture a key shows for the action: the catalog's own where it names + -- one, else the cursor an order is already known by. + icon = (item.icon and VFS.FileExists(item.icon) and item.icon) or cursor, description = describe(item), } if item.action then @@ -811,6 +849,12 @@ local function buildResolvedCatalog() L.noticeDefaultUnsaved = BAR.I18N("ui.keybinds.editor.noticeDefaultUnsaved") L.noticeUnsaved = BAR.I18N("ui.keybinds.editor.noticeUnsaved") L.changed = BAR.I18N("ui.keybinds.editor.changed") + L.boundToAny = BAR.I18N("ui.keybinds.editor.boundToAny") + L.changedUnknown = BAR.I18N("ui.keybinds.editor.changedUnknown") + L.changedNoneTooltip = BAR.I18N("ui.keybinds.editor.changedNoneTooltip") + L.compareWith = BAR.I18N("ui.keybinds.editor.compareWith") + L.compareNone = BAR.I18N("ui.keybinds.editor.compareNone") + L.compareNoneHint = BAR.I18N("ui.keybinds.editor.compareNoneHint") L.conflictOrder = BAR.I18N("ui.keybinds.editor.conflictOrder") L.conflictShipped = BAR.I18N("ui.keybinds.editor.conflictShipped") L.revertHint = BAR.I18N("ui.keybinds.editor.revertHint") @@ -821,6 +865,11 @@ local function buildResolvedCatalog() L.exportTooltip = BAR.I18N("ui.keybinds.editor.exportTooltip") L.import = BAR.I18N("ui.keybinds.editor.import") L.importTooltip = BAR.I18N("ui.keybinds.editor.importTooltip") + L.keyboard = BAR.I18N("ui.keybinds.editor.keyboard") + L.keyboardTooltip = BAR.I18N("ui.keybinds.editor.keyboardTooltip") + -- The keyboard page's cards are built from this catalog, so they go with it. + state.keyInfo = nil + state.keyboard:refreshStrings() L.importTitle = BAR.I18N("ui.keybinds.editor.importTitle") L.importEmpty = BAR.I18N("ui.keybinds.editor.importEmpty") L.importNone = BAR.I18N("ui.keybinds.editor.importNone") @@ -930,6 +979,11 @@ local function rebuildRows() if not resolvedCatalog then buildResolvedCatalog() end + -- The keyboard page searches by the same text, lighting the keys it finds. + state.keyboard:setQuery(searchBox and searchBox:getText()) + -- The Changed section lowers the rows for its picker; any other section has them back. + state.applyListTop() + state.layoutCompare() rows = {} @@ -978,6 +1032,9 @@ local function rebuildRows() -- there are is counted whatever is shown, since its label says so. local changedOnly = selectedCategory == state.changedKey local changedCount = 0 + -- A key clicked on the keyboard page: the list shows what is bound to it and nothing else, + -- whatever the category, the search text narrowing that by name. + local filter = state.keyFilter -- A query can name keys as well as words. An action matches by key when one of its chips holds -- every key the query names, modifiers included and in any order, so "ctrl+q", "ctrl q" and @@ -1009,6 +1066,51 @@ local function rebuildRows() return false end + -- How one of the action's keysets fires from the filtered key on its layer: "exact" when + -- its first tap lands on the key (any of the engine's spellings of it) and names exactly + -- the layer's modifiers, "any" when it carries Any+ instead, which fires on every layer; + -- false when neither. Precise where the typed key search is loose: "1" here is the 1 key + -- with nothing held, not every chip holding a 1. + local function boundToFilter(action) + local any = false + for _, k in ipairs(working.byAction[action] or look.noRaws) do + local mods, keyToken = keybindModel.splitElement(canonOf(k)) + if keyToken and filter.tokens[keyToken] then + if mods.any then + any = true + else + local same = true + for name in pairs(mods) do + if not filter.mods[name] then + same = false + end + end + for name in pairs(filter.mods) do + if not mods[name] then + same = false + end + end + if same then + return "exact" + end + end + end + end + + return any and "any" or false + end + -- Whether an action is listed by key: under a filter, by the filtered key and then the + -- search text, answering how it is bound there; otherwise by the keys the search text + -- names, within the category shown. + local function keyHit(action, label, inCategory) + if filter then + local how = boundToFilter(action) + + return how and (Search.matches(query, label:lower()) or Search.matches(query, action:lower())) and how + end + + return inCategory and boundToQuery(action) + end -- Rows found by key are listed ahead of everything found by name, under a heading of their -- own, and only there. Gathered as they are met, so they keep the catalog's order. local keyRows = {} @@ -1028,7 +1130,7 @@ local function rebuildRows() 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 changedOnly or group.category == selectedCategory + local inCategory = filter ~= nil or not selectedCategory or changedOnly or group.category == selectedCategory -- A group whose own title matches keeps every row under it, so searching for a -- category's name shows the category rather than emptying it. local categoryMatch = Search.claims(query, group.titleLower) @@ -1087,14 +1189,19 @@ local function rebuildRows() if change then changedCount = changedCount + 1 end - local byKey = inCategory and boundToQuery(action) + local byKey = keyHit(action, label, inCategory) if (change or not changedOnly) and ( byKey - or categoryMatch - or Search.matches(query, action:lower()) - or Search.matches(query, label:lower()) + or ( + not filter + and ( + categoryMatch + or Search.matches(query, action:lower()) + or Search.matches(query, label:lower()) + ) + ) ) then local entry = { @@ -1103,6 +1210,7 @@ local function rebuildRows() label = label, description = item.description, change = change, + filterAny = byKey == "any", } if not byKey then groupRows[#groupRows + 1] = entry @@ -1123,14 +1231,19 @@ local function rebuildRows() if change then changedCount = changedCount + 1 end - local byKey = inCategory and boundToQuery(item.action) + local byKey = keyHit(item.action, item.label, inCategory) if (change or not changedOnly) and ( byKey - or categoryMatch - or Search.matches(query, item.labelLower) - or Search.matches(query, item.actionLower) + or ( + not filter + and ( + categoryMatch + or Search.matches(query, item.labelLower) + or Search.matches(query, item.actionLower) + ) + ) ) then local entry = { @@ -1141,6 +1254,7 @@ local function rebuildRows() cursorColumn = group.hasCursors, description = item.description, change = change, + filterAny = byKey == "any", } if not byKey then groupRows[#groupRows + 1] = entry @@ -1187,9 +1301,9 @@ local function rebuildRows() end if changedOnly and not change then -- Not what the column entry asked for. - elseif inOther and boundToQuery(action) then + elseif keyHit(action, action, inOther) then otherKeyed[#otherKeyed + 1] = action - elseif otherMatch or Search.matches(query, action:lower()) then + elseif not filter and (otherMatch or Search.matches(query, action:lower())) then others[#others + 1] = action end end @@ -1197,7 +1311,13 @@ local function rebuildRows() -- Leftovers found by key join the other key rows, in a steady order. table.sort(otherKeyed) for _, action in ipairs(otherKeyed) do - keyRows[#keyRows + 1] = { type = "editable", action = action, label = action, change = rowChange(action) } + keyRows[#keyRows + 1] = { + type = "editable", + action = action, + label = action, + change = rowChange(action), + filterAny = filter ~= nil and boundToFilter(action) == "any", + } end if #others > 0 and inOther then @@ -1225,7 +1345,8 @@ local function rebuildRows() -- The key rows go on top, under a heading that names the keys the way a chip would. One -- cursor among them gives them all the column, as it does within a category. - if #keyRows > 0 then + -- Under a key filter the heading is always there, since it is where the filter is cleared. + if #keyRows > 0 or filter then -- Modifiers ahead of the key, as a chip prints them, whatever order they were typed in. local modifierAt = { ctrl = 1, alt = 2, meta = 3, shift = 4 } local keys, column = {}, false @@ -1241,13 +1362,28 @@ local function rebuildRows() for i = 1, #keyRows do column = column or keyRows[i].cursor ~= nil end + local named = filter and filter.display or table.concat(keys, " + ") local ordered = { - { type = "header", text = BAR.I18N("ui.keybinds.editor.boundTo", { keys = table.concat(keys, " + ") }) }, + { type = "header", text = BAR.I18N("ui.keybinds.editor.boundTo", { keys = named }), clear = filter ~= nil }, } + -- Under a filter on a layer with modifiers, what fires through Any+ is set apart under a + -- heading of its own: it does fire on that layer, but its chip reads as the bare key, + -- and side by side with the exact bindings that reads as a mistake. + local anyRows = {} for i = 1, #keyRows do keyRows[i].cursorColumn = column keyRows[i].hitKeys = wantKeys - ordered[#ordered + 1] = keyRows[i] + if keyRows[i].filterAny and filter and next(filter.mods) then + anyRows[#anyRows + 1] = keyRows[i] + else + ordered[#ordered + 1] = keyRows[i] + end + end + if #anyRows > 0 then + ordered[#ordered + 1] = { type = "header", text = L.boundToAny } + for i = 1, #anyRows do + ordered[#ordered + 1] = anyRows[i] + end end for i = 1, #rows do ordered[#ordered + 1] = rows[i] @@ -1260,15 +1396,121 @@ local function rebuildRows() -- list from the one just built: built again, once, with the selection gone. if state.changedCount ~= changedCount then state.changedCount = changedCount - state.syncChangedEntry(state.base and changedCount or 0) + state.syncChangedEntry() if changedOnly and selectedCategory ~= state.changedKey then return rebuildRows() end end + -- The Changed section with nothing to list says why: no preset is being compared with, + -- or nothing differs from the one that is. + if changedOnly and not filter and #rows == 0 then + if not state.base then + rows[1] = { type = "note", text = L.compareNoneHint } + else + rows[1] = { + type = "note", + text = BAR.I18N("ui.keybinds.editor.changedNothing", { name = state.base.name }), + } + end + end + clampScroll() end +-- Where the rows start: the band's top, or a picker's band lower when the Changed section +-- is showing. Everything that draws, scrolls or hit-tests rows reads listTop, so the whole +-- band moves as one. +function state.applyListTop() + if not metrics.listTopBase then + return + end + listTop = metrics.listTopBase - (state.compareBand() and metrics.compareBandH or 0) +end + +-- Whether the comparison picker's band is up: the Changed section, on the list page. +function state.compareBand() + return state.page == "list" and selectedCategory == state.changedKey and not gridGroup +end + +-- Places the comparison strip and the picker at its right end, once the band is placed. The +-- strip's rect and the caption's place are kept in metrics for the draw. +function state.layoutCompare() + local dd = state.compareDropdown + if not (dd and metrics.listTopBase and metrics.compareStripH) then + return + end + local y2 = metrics.listTopBase - floor(2 * scale) + local y1 = y2 - metrics.compareStripH + metrics.compareY1, metrics.compareY2 = y1, y2 + local inset = floor(4 * scale) + local w = floor(280 * scale) + local x2 = listRight - metrics.rowPad + dd:setRect(x2 - w, y1 + inset, x2, y2 - inset, floor((y2 - y1 - inset * 2) * 0.5)) + -- The caption sits right against the picker, as the header's "Preset" does. + metrics.compareCaptionX = x2 - w - floor(8 * scale) + metrics.compareFs = floor((y2 - y1 - inset * 2) * 0.5) +end + +-- The picker's options: none, then every other preset, the shipped ones tagged as defaults +-- and ruled off from the player's own. Selected: whatever the active preset is compared +-- with now. +function state.compareOptions() + local active = profiles.activeName() + local options = { { label = L.compareNone or "None", none = true } } + for _, b in ipairs(profiles.builtins) do + if b.name ~= active then + options[#options + 1] = { label = b.name, name = b.name, tag = L.defaultTag, group = "default" } + end + end + -- The store lists its profiles by name. + for _, name in ipairs(profiles.list()) do + if name ~= active then + options[#options + 1] = { label = name, name = name, group = "own" } + end + end + local selected = 1 + local base = state.base and state.base.name + for i, o in ipairs(options) do + if o.name and o.name == base then + selected = i + end + end + + return options, selected +end + +function state.refreshCompare() + local dd = state.compareDropdown + if not dd then + return + end + local options, selected = state.compareOptions() + dd:setOptions(options) + dd:setSelected(selected) + state.layoutCompare() +end + +-- The player picked what to compare the active preset with. Recorded on the preset, so it +-- holds across sessions; "none" is a choice too, and stays one. +function state.pickBase(option) + if not activeIsOwn() then + return + end + profiles.setBase(profiles.activeName(), option and option.name or nil) + state.refreshBase() + rebuildRows() +end + +-- Filters the list to one key of the keyboard page, or clears the filter. The keyboard +-- lights the key while the filter stands. +function state.setKeyFilter(filter) + state.keyFilter = filter + state.keyboard:setFilter(filter and { id = filter.id, layer = filter.layer } or nil) + scroll = 0 + rebuildRows() +end + ---------------------------------------------------------------- -- Staging ---------------------------------------------------------------- @@ -1400,12 +1642,12 @@ function state.refreshBase() state.shippedPairs = shipped end - local builtin = profiles.baseOf(profiles.activeName()) - local wanted = builtin and builtin.name or nil + local base = profiles.baseOf(profiles.activeName()) + local wanted = base and base.name or nil if (state.base and state.base.name) ~= wanted then - if builtin then + if base then local byAction = {} - for _, b in ipairs(builtin.binds or {}) do + for _, b in ipairs(base.binds or {}) do local entry = byAction[b.action] if not entry then entry = { set = {}, n = 0, raws = {} } @@ -1424,18 +1666,22 @@ function state.refreshBase() end state.changedCount = -1 end - if not state.base then - state.syncChangedEntry(0) - end + state.syncChangedEntry() + state.refreshCompare() end --- The column's Changed entry, there only while there is something for it to list, with the --- count in its label. With the entry gone from under the selection, the column falls back --- to everything. -function state.syncChangedEntry(count) +-- The column's Changed entry: there for every preset of the player's own, with the count of +-- rows differing from the compared preset in its label, or a question mark while nothing is +-- being compared with. A shipped preset is measured against itself, so it only has the entry +-- while staged edits differ from it: a "Changed (0)" on a default is noise. With the entry +-- gone from under the selection, the column falls back to everything. +function state.syncChangedEntry() local listed = categories[2] ~= nil and categories[2].key == state.changedKey - if count > 0 then - local label = BAR.I18N("ui.keybinds.editor.changedCount", { n = count }) + if activeIsOwn() or (state.base and state.changedCount > 0) then + local label = L.changedUnknown or "?" + if state.base then + label = BAR.I18N("ui.keybinds.editor.changedCount", { n = math.max(0, state.changedCount) }) + end if not listed then table.insert(categories, 2, { label = label, key = state.changedKey }) state.refit = true @@ -1853,13 +2099,28 @@ local function ensureControls() return end + -- Each control prints live, every frame, on the shared font, so each pins the panel's + -- outline for itself. searchBox = Editbox.new({ placeholder = BAR.I18N("ui.keybinds.editor.search"), clearable = true, onChange = rebuildRows, + outline = look.outline, + }) + presetDropdown = Dropdown.new({ + options = presetOptions, + onSelect = switchToPreset, + markSelected = true, + outline = look.outline, + }) + nameBox = Editbox.new({ maxChars = 40, outline = look.outline }) + -- The Changed section's picker of what to compare the active preset with. + state.compareDropdown = Dropdown.new({ + options = {}, + onSelect = state.pickBase, + markSelected = true, + outline = look.outline, }) - presetDropdown = Dropdown.new({ options = presetOptions, onSelect = switchToPreset, markSelected = true }) - nameBox = Editbox.new({ maxChars = 40 }) end -- Buttons size to their own label so a longer translation is not clipped and a short @@ -1915,7 +2176,7 @@ local function layoutHeader() end end b.rect = { bx2 - w, rowBottom, bx2, rowTop } - bx2 = bx2 - w - gap + bx2 = floor(bx2 - w - gap * (b.gap or 1)) end local pickerX1 = bx2 - presetW metrics.presetLabelX = pickerX1 - gap - labelWidth(L.preset or "", btnFs, 0) @@ -2063,6 +2324,7 @@ function view.init() Highlight = WG.FlowUI.Draw.SelectHighlight UiButton = WG.FlowUI.Draw.Button UiUnitFrame = WG.FlowUI.Draw.UnitFrame + state.keyboard:init(font) ensureControls() end @@ -2126,6 +2388,11 @@ function view.setArea(x1, y1, x2, y2, s, wx1, wy1, wx2, wy2) layoutHeader() listTop = area.y2 - state.headerH - floor(4 * scale) + metrics.listTopBase = listTop + -- The strip the Changed section puts its comparison picker in, above its rows, and the + -- room it takes from them: the strip plus a gap, so it stands apart from the first heading. + metrics.compareStripH = floor(rowHeight * 1.45) + metrics.compareBandH = metrics.compareStripH + floor(rowHeight * 0.5) -- The scrollbar owns a column of its own: its right edge lines up with the buttons -- above it, and the list stops a clear gap short of it rather than running up against -- it. That gap matches the one the bar keeps from the panel edge on its other side, so @@ -2135,6 +2402,19 @@ function view.setArea(x1, y1, x2, y2, s, wx1, wy1, wx2, wy2) listRight = barX1 - metrics.listGap metrics.keyAreaX1 = listX1 + floor((listRight - listX1) * 0.45) + -- The keyboard page takes the whole band the column and the list share, inset from the + -- panel's sides like the column's own text. + state.keyboard:setArea( + area.x1 + metrics.sidePad, + listBottom(), + area.x2 - metrics.sidePad, + metrics.listTopBase, + scale, + metrics.titleFs + ) + state.applyListTop() + state.layoutCompare() + -- 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() @@ -2163,10 +2443,18 @@ function view.blur() if presetDropdown then presetDropdown:close() end + if state.compareDropdown then + state.compareDropdown:close() + end if nameBox then nameBox:blur() end capturing = nil + -- A key filter is a view of the moment; the panel opens on the whole list next time. + if state.keyFilter then + state.keyFilter = nil + state.keyboard:setFilter(nil) + end -- Or the blur outlives the panel: guishader keeps drawing a rect nobody owns any more. shade.clear() @@ -2192,6 +2480,17 @@ function view.setMenuToggle(fn) menuToggle = fn end +-- Which page the body shows: "keyboard" for the overview, anything else for the list. The +-- host's action takes it as a word, so a key can open the panel straight onto the keyboard. +function view.setPage(page) + state.setPage(page == "keyboard" and "keyboard" or "list") +end + +-- Host hook, called with the page whenever it changes, so the host can size the panel to it. +function view.setPageHook(fn) + state.pageHook = fn +end + ---------------------------------------------------------------- -- Editing keysets ---------------------------------------------------------------- @@ -2784,6 +3083,8 @@ local function rowLayout(row) lay = { gen = layoutGen } if row.type == "header" then lay.text = colorHeader .. row.text + elseif row.type == "note" then + lay.text = colorDim .. text.fit(font, row.text, listRight - listX1 - metrics.rowPad * 4, metrics.rowFs) elseif row.type == "link" then lay.text = colorAction .. row.label lay.arrow = look.arrow @@ -2915,6 +3216,7 @@ local function flushText() end font:Begin() + font:SetOutlineColor(look.outline) for i = 0, pendingCount - 1 do local at = i * 5 font:Print( @@ -2939,7 +3241,9 @@ end -- The category column starts below where the keybind rows do, so the title above it is not -- crowded by the first entry. Everything in the column measures from here. local function sidebarTop() - return listTop - metrics.sidebarDrop + -- Off the band's fixed top, not the rows' own: the Changed section lowers the rows for its + -- comparison picker, and the column beside them must not move with it. + return (metrics.listTopBase or listTop) - metrics.sidebarDrop end -- `i` is the entry's place in `categories`, not its place on screen: the two differ by @@ -3381,6 +3685,17 @@ local function drawRow(row, top, bottom, hovered, zone, zoneIdx) if row.type == "header" then drawHeaderBand(top, bottom, lay.text) + -- A heading that stands for a key filter carries the mark that clears it. + if row.clear then + local mark = zone == "clear" and look.removeHot or look.removeCold + queueText(mark, listRight - metrics.rowPad * 2, cyc, fs, "cov") + end + return + end + + -- A note explains an empty section; it is neither lit nor clicked. + if row.type == "note" then + queueText(lay.text, listX1 + metrics.rowPad * 2, cyc, fs, "ov") return end @@ -3571,6 +3886,7 @@ local function drawCaptureModal(mx, my) end font:Begin() + font:SetOutlineColor(look.outline) if clash then font:Print(clash, cx, by1 + floor(64 * scale), sfs, "cov") end @@ -3694,6 +4010,7 @@ function state.drawPreview(pv, x1, y1, x2, y2, mx, my) end mono:Begin() + mono:SetOutlineColor(look.outline) for i = pv.scroll + 1, last do local line = pv.lines[i] local cy = floor(y2 - pad - (i - pv.scroll - 0.5) * lineH) @@ -3757,6 +4074,7 @@ local function drawProfileDialog(mx, my) end font:Begin() + font:SetOutlineColor(look.outline) font:Print( colorText .. text.fit(font, dialog.title, bx2 - bx1 - floor(32 * scale), tfs), cx, @@ -3833,6 +4151,10 @@ local function drawButtons(hotId) -- 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) + -- The page toggle sits pressed while its page is showing. + if b.toggle and state.page == "keyboard" then + fill = hovered and look.toggleFillHover or look.toggleFill + end drawButtonFace(r, fill or buttonFill) -- The face lights under the cursor the way a row or the search field does. A @@ -3890,22 +4212,26 @@ end -- 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 + local keyboardPage = state.page == "keyboard" + h.sb = (not keyboardPage and sidebarIndexAt(mx, my)) or 0 h.row, h.zone, h.idx = 0, "", 0 h.gk, h.ga, h.gb = "", 0, 0 h.btn = "" h.bar = 0 + h.kb = 0 -- Over the thumb itself, which lights it. The track either side is not part of this: -- only the thumb is something to take hold of. - if mx >= barX1 and mx <= area.x2 - metrics.edgeInset then + if not keyboardPage and mx >= barX1 and mx <= area.x2 - metrics.edgeInset then local top, height = scrollerThumb() if top and my <= top and my >= top - height then h.bar = 1 end end - if gridGroup then + if keyboardPage then + h.kb = state.keyboard:hitTest(mx, my) or 0 + elseif 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 @@ -3919,6 +4245,8 @@ local function panelSignature(mx, my) local c1, c2 = bottom + metrics.chipInset, top - metrics.chipInset local zone, idx = rowZone(rowLayout(row), mx, my, c1, c2) h.zone, h.idx = zone or "", idx or 0 + elseif row.type == "header" and row.clear and mx >= listRight - metrics.rowPad * 4 then + h.zone = "clear" end end end @@ -3964,6 +4292,99 @@ local function panelSignature(mx, my) .. h.cat .. "|" .. (hover.drag and 1 or 0) + .. "|" + .. state.page + .. "|" + .. (keyboardPage and state.keyboard:signature(h.kb) or "") +end + +-- The card the keyboard page shows for an action: its label, what it does, its picture, its +-- category and where the catalog ranks it. Built from the resolved catalog on first use and +-- dropped with it; an action under a prefix family takes the family's card with the label the +-- list gave its row, and one the catalog never lists is its own id under Other. +function state.keyInfoFor(action) + local info = state.keyInfo + if not info then + info = { byAction = {}, prefixes = {} } + state.keyInfo = info + for gi, g in ipairs(resolvedCatalog or {}) do + if not g.hidden then + for ii, item in ipairs(g.items) do + local rank = gi * 1000 + ii + if item.prefix then + info.prefixes[#info.prefixes + 1] = { + prefix = item.prefix, + description = item.description, + icon = item.icon, + category = g.category, + rank = rank, + } + elseif item.action then + info.byAction[item.action] = { + label = item.label, + description = item.description, + icon = item.icon, + category = g.category, + rank = rank, + } + end + end + end + end + end + + local card = info.byAction[action] + if card then + return card + end + local best + for _, p in ipairs(info.prefixes) do + if p.prefix ~= "" and action:sub(1, #p.prefix) == p.prefix and (not best or #p.prefix > #best.prefix) then + best = p + end + end + card = { + label = state.labels[action] or action, + description = best and best.description, + icon = best and best.icon, + category = (best and best.category) or "categories.other", + rank = (best and best.rank) or math.huge, + } + info.byAction[action] = card + + return card +end + +-- Places the staged keymap on the keyboard, once per change to it. +function state.ensureKeyboard() + if state.keyboardGen ~= rowsGen then + state.keyboardGen = rowsGen + state.keyboard:place(working.binds, state.hidden, working.layout, catalogShiftPair, state.keyInfoFor) + end +end + +-- Shows the list or the keyboard. The tooltip is forgotten with the page: the same cursor +-- position means something else on the other one. +function state.setPage(page) + if state.page == page then + return + end + state.page = page + state.tipKey = nil + hover.kb = 0 + -- The rows' band depends on the page: the comparison band only shows on the list. + state.applyListTop() + -- The host sizes the panel to the page. + if state.pageHook then + state.pageHook(page) + end +end + +-- The keyboard page's body: the panel title where the list page puts it, then the keyboard. +function state.drawKeyboardPage(hoverIdx) + state.ensureKeyboard() + queueText(L.titleText, area.x1 + metrics.sidePad, area.y2 - metrics.titleY, metrics.titleFs, "ov") + state.keyboard:draw(hoverIdx) end -- Everything under the header controls and above the modals: the sidebar, the list or @@ -3971,12 +4392,34 @@ end -- frame replays it for one call instead of a few hundred draws. local function drawPanel() local h = hover - drawSidebar(h.sb) + if state.page == "keyboard" then + state.drawKeyboardPage(h.kb) + else + drawSidebar(h.sb) + end - if gridGroup then + if state.page == "keyboard" then + flushText() + elseif gridGroup then drawGridMenu(h.gk, h.ga, h.gb) flushText() else + -- The Changed section's comparison strip, above its rows: a dark strip rather than a + -- heading, so it reads as a control and not as a second title over the first heading, + -- with a dim caption against the picker, which draws live over the strip's right end + -- since its list can open. + if state.compareBand() and metrics.compareY1 then + local y1, y2 = metrics.compareY1, metrics.compareY2 + RectRound(listX1, y1, listRight, y2, metrics.csSmall, 1, 1, 1, 1, look.previewFill) + local inset = floor(4 * scale) + queueText( + colorDim .. (L.compareWith or ""), + metrics.compareCaptionX, + text.baseline(font, y1 + inset, y2 - inset, metrics.compareFs), + metrics.compareFs, + "ro" + ) + end -- Whole rows only: the band can end mid-row, and a row painted across the footer -- would be clipped by nothing. local base = scrollOffset() @@ -4039,9 +4482,19 @@ function state.showTooltips(mx, my) end local key, title, lines + -- The preset picker's options, and the comparison picker's while its band is up: both + -- name presets, so both get the preset's description. local pick = presetDropdown:optionAt(mx, my) + local pickOptions, pickSelected = presetOptions, presetDropdown.selected + if not pick and state.compareBand() then + pick = state.compareDropdown:optionAt(mx, my) + pickOptions, pickSelected = state.compareDropdown.options, state.compareDropdown.selected + end if pick then - local opt = pick > 0 and presetOptions[pick] or presetOptions[presetDropdown.selected] + local opt = pick > 0 and pickOptions[pick] or pickOptions[pickSelected] + if opt and not opt.name then + opt = nil + end if opt then key = "preset|" .. opt.name title = opt.name @@ -4060,11 +4513,24 @@ function state.showTooltips(mx, my) end end end + elseif state.page ~= "list" then + -- The keyboard page: the key under the cursor, on the layer showing, or the view toggle. + if hover.kb ~= 0 then + state.ensureKeyboard() + key, title = state.keyboard:tooltip(hover.kb) + if key and key ~= state.tipKey then + lines = state.keyboard:tooltipLines(hover.kb) + end + end elseif hover.sb > 0 and categories[hover.sb] and categories[hover.sb].key == state.changedKey then - key = "changed" + key = "changed|" .. tostring(state.base and state.base.name) title = categories[hover.sb].label - if key ~= state.tipKey and state.base then - lines = { colorText .. BAR.I18N("ui.keybinds.editor.changedTooltip", { name = state.base.name }) } + if key ~= state.tipKey then + if state.base then + lines = { colorText .. BAR.I18N("ui.keybinds.editor.changedTooltip", { name = state.base.name }) } + else + lines = { colorDim .. L.changedNoneTooltip } + end end elseif hover.row > 0 then local row = rows[scroll + hover.row] @@ -4154,6 +4620,14 @@ function shade.update() else shade.rect("picker") end + + local compare = state.compareDropdown + local copts = compare and state.compareBand() and compare:isOpen() and compare.optRects + if copts and copts[1] then + shade.rect("compare", copts[1].x1, copts[#copts].y1, copts[1].x2, copts[1].y2) + else + shade.rect("compare") + end end function view.draw() if not font then @@ -4186,10 +4660,17 @@ function view.draw() -- 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 + if dialog or capturing or presetDropdown:isOpen() or state.compareDropdown:isOpen() then mx, my = -1, -1 end + -- The keyboard page shows the layer of whatever is held on the real keyboard, for as long + -- as it is held; the signature below carries the layer, so the picture follows. + if state.page == "keyboard" then + local alt, ctrl, meta, shift = Spring.GetModKeyState() + state.keyboard:setHeld(alt, ctrl, meta, shift) + end + local sig = panelSignature(mx, my) if sig ~= state.panelSig then if state.panelList then @@ -4202,6 +4683,20 @@ function view.draw() searchBox:draw() + -- The comparison picker, live like the preset picker: its list opens over the rows. + if state.compareBand() then + if state.compareDropdown:isOpen() then + shade.float("compare", function() + state.compareDropdown:draw() + end) + else + shade.drop("compare") + state.compareDropdown:draw() + end + else + shade.drop("compare") + end + if not state.tooltipsRegistered and WG["tooltip"] then registerTooltips() end @@ -4281,7 +4776,7 @@ function view.mouseWheel(up, value) return end - if capturing or gridGroup then + if capturing or gridGroup or state.page == "keyboard" then return end @@ -4457,6 +4952,19 @@ function view.mousePress(x, y, button) return true end + -- The comparison picker likewise, while its band is up. + if state.compareBand() then + local wasOpen = state.compareDropdown:isOpen() + if state.compareDropdown:mousePress(x, y) then + searchBox:blur() + + return true + end + if wasOpen then + return true + end + end + for _, set in ipairs(buttonSets) do for _, b in ipairs(set) do local r = b.rect @@ -4476,6 +4984,8 @@ function view.mousePress(x, y, button) startClipboard(true) elseif b.id == "import" then startClipboard(false) + elseif b.id == "keyboard" then + state.setPage(state.page == "keyboard" and "list" or "keyboard") end end @@ -4490,6 +5000,38 @@ function view.mousePress(x, y, button) end searchBox:blur() + -- The keyboard page: a modifier toggles its layer, the toggle swaps the view, and a bound + -- key goes to the list page filtered to that key on that layer, which lists everything on + -- it with its bindings to hand. The search text is left alone: the filter is its own thing. + if state.page == "keyboard" then + state.ensureKeyboard() + local kind, key, layer = state.keyboard:mousePress(x, y, button) + if kind == "key" then + local tokens, mods = {}, {} + for _, token in ipairs(key.tokens or {}) do + tokens[token] = true + end + for name in layer:gmatch("[^+]+") do + mods[name] = true + end + state.setPage("list") + selectedCategory = nil + state.setKeyFilter({ + id = key.id, + layer = layer, + tokens = tokens, + mods = mods, + display = state.keyboard:keysetName(key, layer), + }) + end + + return true + end + + -- Picking a category is asking for the whole of it, so a key filter goes first. + if state.keyFilter and x >= area.x1 and x <= area.x1 + sidebarW and y > listBottom() and y <= sidebarTop() then + state.setKeyFilter(nil) + end if sidebarPress(x, y) then return true end @@ -4527,6 +5069,8 @@ function view.mousePress(x, y, button) if kind then handleZone(kind, row.action, row.label, raw) end + elseif row and row.type == "header" and row.clear and x >= listRight - metrics.rowPad * 4 then + state.setKeyFilter(nil) elseif row and row.type == "link" then selectedCategory = row.category scroll = 0 @@ -4584,6 +5128,12 @@ function view.keyPress(key, scanCode) end return true end + if state.compareDropdown and state.compareDropdown:isOpen() then + if key == 27 then + state.compareDropdown:close() + end + return true + end -- A grid category replaces the list outright, and picking another category in the -- column is otherwise the only way back out of it. Escape is the other way, and it @@ -4611,6 +5161,12 @@ function view.keyPress(key, scanCode) -- the search made, and the first Escape is asking for that back. With nothing left to -- clear it goes unclaimed, and the widget above closes the panel on it. if key == KEYSYMS.ESCAPE then + -- A key filter goes before the search text: it is the narrower of the two. + if state.keyFilter then + state.setKeyFilter(nil) + + return true + end if searchBox and searchBox:getText() ~= "" then -- Focus stays, so the next thing typed starts a new search. searchBox:setText("") diff --git a/luaui/Include/keybind_keyboard.lua b/luaui/Include/keybind_keyboard.lua new file mode 100644 index 00000000000..854c53df9d2 --- /dev/null +++ b/luaui/Include/keybind_keyboard.lua @@ -0,0 +1,1136 @@ +-- The keyboard page of the keybind editor: a full-size keyboard drawn key by key, each cap +-- carrying the action it fires on the layer shown. A layer is a set of modifiers. Clicking +-- Shift, Ctrl, Alt or Meta on the drawn keyboard toggles that modifier into the layer, and +-- holding the real one shows it for as long as it is held, so the page reads like the +-- keyboard it stands for. Two views share the page: the main block, and the arrows, +-- navigation keys and number pad, with the modifiers beside them so the layers still work. +-- It is an overview rather than an editor: a click on a bound key hands that key to the +-- list page, which is where bindings are changed. +-- +-- Bindings come in as the editor's working keymap, so staged edits show here before they +-- are saved. Each is placed on the key that starts it (a chain lands on its first tap) under +-- the modifiers it names. Any+ bindings fire whatever is held, so they sit on every layer, +-- after the bindings that name that layer exactly - the order the engine tries them in. +-- +-- The face of a key shows one action, and it is the one a player thinks of the key as +-- doing: the first by catalog order, not by bind order. The engine walks a key's actions +-- in bind order until one takes it, and the presets lean on that to put a special case +-- ahead of the general one - the Grid preset binds "stopproduction" ahead of "stop" on G, +-- and the spectator's "specteam" ahead of "group select" on the digits. Catalog order puts +-- the general action first, which is what the key is for. The tooltip lists them all. + +local keybindModel = VFS.Include("luaui/Include/keybind_model.lua") +local keyConfig = VFS.Include("luaui/configs/keyboard_layouts.lua") +local Search = VFS.Include("luaui/Include/search.lua") +local text = VFS.Include("luaui/Include/keybind_text.lua") + +local floor = math.floor +local max = math.max +local min = math.min +local isInRect = math.isInRect +local glColor = gl.Color +local glTexture = gl.Texture +local glTexRect = gl.TexRect +local glBlending = gl.Blending + +---@class KeybindKeyboard +---@field keys table[] Every drawn key, in definition order; `id` is its index +---@field shown integer[] The keys placed in the current view +---@field view string "main" or "numpad" +---@field toggled table Modifiers toggled on the drawn keyboard +---@field held table Modifiers held on the real one +---@field layoutName string? The keyboard layout the names were resolved for +---@field tokenIndex table? Canonical key token -> key index +---@field query table The search, from Search.query +---@field queryTokens string[] +---@field queryGen integer +---@field filter table? The key the list is filtered to: `id` and `layer` +---@field filterGen integer +---@field gen integer Bumped by every placement +---@field layoutGen integer Bumped by every resize +---@field unplaced integer Bindings on keys neither view draws +---@field L table The page's strings +---@field area table +---@field scale number +---@field font table? +---@field UiKey function? +---@field UiButton function? +---@field Highlight function? +---@field infoFor function? +---@field shiftPair table +---@field hintLines string[]? +---@field hintWidth number? +---@field unit number? +---@field frames table Per view: the origin the keys are placed from +---@field button table? The view toggle's rect +---@field cs number +---@field pad number +---@field nameFs number +---@field labelFs number +---@field moreFs number +---@field iconSize number +---@field hintFs number +---@field titleFs number +---@field buttonFs number +local M = {} +M.__index = M + +---------------------------------------------------------------- +-- The keyboard +---------------------------------------------------------------- + +-- Every key the page can draw, once, with where it sits in each view that shows it: `main` +-- and `numpad` give `x` from the view's left edge and `y` from the top in key units, `w` +-- and `h` in units when not one. A view is as many units wide as `viewCols` says, and the +-- page is `ROWS` tall: the caption row on top, then the keys spaced the way they are on the +-- keyboard itself - the function row under the caption, the main block half a unit lower. +-- +-- Named keys carry the engine's names for them: `scan` for the scancode names (sc_) +-- and `code` for the keycode ones, every spelling the engine accepts. A character key +-- carries the qwerty character of its position instead. Its scancode name is that character +-- (or the engine's word for a punctuation key, `word`), and the player's keyboard layout +-- decides both the character printed on it and the keycode that lands there. `shifted` is +-- the symbol the US layout prints above a punctuation key, shown when the layout leaves +-- that key as it is. `mod` marks a modifier key, which toggles its layer when clicked; the +-- modifiers sit in both views, so a layer can be toggled from either. +local ROWS = 7.5 +local viewCols = { main = 15, numpad = 10 } +local viewOrder = { "main", "numpad" } + +local up, down, left, right = "\226\134\145", "\226\134\147", "\226\134\144", "\226\134\146" + +local keyDefs = { + { main = { x = 14, y = 0 }, numpad = { x = 4.5, y = 1 }, name = "Pause", scan = { "pause" }, code = { "pause" } }, + + { main = { x = 0, y = 1 }, name = "Esc", scan = { "esc", "escape" }, code = { "esc", "escape" } }, + { main = { x = 2, y = 1 }, name = "F1", scan = { "f1" }, code = { "f1" } }, + { main = { x = 3, y = 1 }, name = "F2", scan = { "f2" }, code = { "f2" } }, + { main = { x = 4, y = 1 }, name = "F3", scan = { "f3" }, code = { "f3" } }, + { main = { x = 5, y = 1 }, name = "F4", scan = { "f4" }, code = { "f4" } }, + { main = { x = 6.5, y = 1 }, name = "F5", scan = { "f5" }, code = { "f5" } }, + { main = { x = 7.5, y = 1 }, name = "F6", scan = { "f6" }, code = { "f6" } }, + { main = { x = 8.5, y = 1 }, name = "F7", scan = { "f7" }, code = { "f7" } }, + { main = { x = 9.5, y = 1 }, name = "F8", scan = { "f8" }, code = { "f8" } }, + { main = { x = 11, y = 1 }, name = "F9", scan = { "f9" }, code = { "f9" } }, + { main = { x = 12, y = 1 }, name = "F10", scan = { "f10" }, code = { "f10" } }, + { main = { x = 13, y = 1 }, name = "F11", scan = { "f11" }, code = { "f11" } }, + { main = { x = 14, y = 1 }, name = "F12", scan = { "f12" }, code = { "f12" } }, + + { + main = { x = 0, y = 2.5 }, + char = "`", + word = "backquote", + shifted = "~", + scan = { "`" }, + code = { "~", "tilde", "backquote" }, + }, + { main = { x = 1, y = 2.5 }, char = "1" }, + { main = { x = 2, y = 2.5 }, char = "2" }, + { main = { x = 3, y = 2.5 }, char = "3" }, + { main = { x = 4, y = 2.5 }, char = "4" }, + { main = { x = 5, y = 2.5 }, char = "5" }, + { main = { x = 6, y = 2.5 }, char = "6" }, + { main = { x = 7, y = 2.5 }, char = "7" }, + { main = { x = 8, y = 2.5 }, char = "8" }, + { main = { x = 9, y = 2.5 }, char = "9" }, + { main = { x = 10, y = 2.5 }, char = "0" }, + { main = { x = 11, y = 2.5 }, char = "-", word = "minus", shifted = "_", scan = { "-" } }, + { main = { x = 12, y = 2.5 }, char = "=", word = "equals", shifted = "+", scan = { "=" } }, + { main = { x = 13, y = 2.5, w = 2 }, name = "Backspace", scan = { "backspace" }, code = { "backspace" } }, + + { main = { x = 0, y = 3.5, w = 1.5 }, name = "Tab", scan = { "tab" }, code = { "tab" } }, + { main = { x = 1.5, y = 3.5 }, char = "Q" }, + { main = { x = 2.5, y = 3.5 }, char = "W" }, + { main = { x = 3.5, y = 3.5 }, char = "E" }, + { main = { x = 4.5, y = 3.5 }, char = "R" }, + { main = { x = 5.5, y = 3.5 }, char = "T" }, + { main = { x = 6.5, y = 3.5 }, char = "Y" }, + { main = { x = 7.5, y = 3.5 }, char = "U" }, + { main = { x = 8.5, y = 3.5 }, char = "I" }, + { main = { x = 9.5, y = 3.5 }, char = "O" }, + { main = { x = 10.5, y = 3.5 }, char = "P" }, + { main = { x = 11.5, y = 3.5 }, char = "[", word = "leftbracket", shifted = "{", scan = { "[" } }, + { main = { x = 12.5, y = 3.5 }, char = "]", word = "rightbracket", shifted = "}", scan = { "]" } }, + { + main = { x = 13.5, y = 3.5, w = 1.5 }, + char = "\\", + word = "backslash", + shifted = "|", + scan = { "\\" }, + code = { "backslash" }, + }, + + { main = { x = 0, y = 4.5, w = 1.75 }, name = "Caps Lock", code = { "capslock" } }, + { main = { x = 1.75, y = 4.5 }, char = "A" }, + { main = { x = 2.75, y = 4.5 }, char = "S" }, + { main = { x = 3.75, y = 4.5 }, char = "D" }, + { main = { x = 4.75, y = 4.5 }, char = "F" }, + { main = { x = 5.75, y = 4.5 }, char = "G" }, + { main = { x = 6.75, y = 4.5 }, char = "H" }, + { main = { x = 7.75, y = 4.5 }, char = "J" }, + { main = { x = 8.75, y = 4.5 }, char = "K" }, + { main = { x = 9.75, y = 4.5 }, char = "L" }, + { main = { x = 10.75, y = 4.5 }, char = ";", word = "semicolon", shifted = ":", scan = { ";" } }, + { main = { x = 11.75, y = 4.5 }, char = "'", word = "apostrophe", shifted = '"', scan = { "'" } }, + { main = { x = 12.75, y = 4.5, w = 2.25 }, name = "Enter", scan = { "return" }, code = { "return", "enter" } }, + + { + main = { x = 0, y = 5.5, w = 2.25 }, + numpad = { x = 0, y = 2.5, w = 2 }, + name = "Shift", + mod = "shift", + scan = { "shift" }, + code = { "shift" }, + }, + { main = { x = 2.25, y = 5.5 }, char = "Z" }, + { main = { x = 3.25, y = 5.5 }, char = "X" }, + { main = { x = 4.25, y = 5.5 }, char = "C" }, + { main = { x = 5.25, y = 5.5 }, char = "V" }, + { main = { x = 6.25, y = 5.5 }, char = "B" }, + { main = { x = 7.25, y = 5.5 }, char = "N" }, + { main = { x = 8.25, y = 5.5 }, char = "M" }, + { main = { x = 9.25, y = 5.5 }, char = ",", word = "comma", shifted = "<", scan = {} }, + { main = { x = 10.25, y = 5.5 }, char = ".", word = "period", shifted = ">", scan = { "." } }, + { main = { x = 11.25, y = 5.5 }, char = "/", word = "slash", shifted = "?", scan = { "/" } }, + { main = { x = 12.25, y = 5.5, w = 2.75 }, name = "Shift", mod = "shift", code = { "rshift" } }, + + { + main = { x = 0, y = 6.5, w = 1.25 }, + numpad = { x = 0, y = 3.5, w = 2 }, + name = "Ctrl", + mod = "ctrl", + scan = { "ctrl" }, + code = { "ctrl" }, + }, + { + main = { x = 1.25, y = 6.5, w = 1.25 }, + numpad = { x = 0, y = 5.5, w = 2 }, + name = "Meta", + mod = "meta", + scan = { "meta" }, + code = { "meta" }, + }, + { + main = { x = 2.5, y = 6.5, w = 1.25 }, + numpad = { x = 0, y = 4.5, w = 2 }, + name = "Alt", + mod = "alt", + scan = { "alt" }, + code = { "alt" }, + }, + { main = { x = 3.75, y = 6.5, w = 6.25 }, name = "Space", scan = { "space" }, code = { "space" } }, + { main = { x = 10, y = 6.5, w = 1.25 }, name = "Alt", mod = "alt", code = { "ralt" } }, + { main = { x = 13.75, y = 6.5, w = 1.25 }, name = "Ctrl", mod = "ctrl", code = { "rctrl" } }, + + -- The navigation keys, the arrows and the number pad, laid out as they sit to the right of + -- the main block, with the modifiers in a column to their left. + { + numpad = { x = 2.5, y = 1 }, + name = "Print", + scan = { "printscreen", "print" }, + code = { "printscreen", "print" }, + }, + { numpad = { x = 3.5, y = 1 }, name = "Scroll Lock", code = { "scrollock" } }, + { numpad = { x = 2.5, y = 2.5 }, name = "Insert", scan = { "insert" }, code = { "insert" } }, + { numpad = { x = 3.5, y = 2.5 }, name = "Home", scan = { "home" }, code = { "home" } }, + { numpad = { x = 4.5, y = 2.5 }, name = "Page Up", scan = { "pageup" }, code = { "pageup" } }, + { numpad = { x = 2.5, y = 3.5 }, name = "Delete", scan = { "delete" }, code = { "delete" } }, + { numpad = { x = 3.5, y = 3.5 }, name = "End", scan = { "end" }, code = { "end" } }, + { numpad = { x = 4.5, y = 3.5 }, name = "Page Down", scan = { "pagedown" }, code = { "pagedown" } }, + { numpad = { x = 3.5, y = 5.5 }, name = up, scan = { "up" }, code = { "up" } }, + { numpad = { x = 2.5, y = 6.5 }, name = left, scan = { "left" }, code = { "left" } }, + { numpad = { x = 3.5, y = 6.5 }, name = down, scan = { "down" }, code = { "down" } }, + { numpad = { x = 4.5, y = 6.5 }, name = right, scan = { "right" }, code = { "right" } }, + + { numpad = { x = 6, y = 2.5 }, name = "Num Lock", code = { "numlock" } }, + { numpad = { x = 7, y = 2.5 }, name = "/", scan = { "numpad/" }, code = { "numpad/" } }, + { numpad = { x = 8, y = 2.5 }, name = "*", scan = { "numpad*" }, code = { "numpad*" } }, + { numpad = { x = 9, y = 2.5 }, name = "-", scan = { "numpad-" }, code = { "numpad-" } }, + { numpad = { x = 6, y = 3.5 }, name = "7", scan = { "numpad7" }, code = { "numpad7" } }, + { numpad = { x = 7, y = 3.5 }, name = "8", scan = { "numpad8" }, code = { "numpad8" } }, + { numpad = { x = 8, y = 3.5 }, name = "9", scan = { "numpad9" }, code = { "numpad9" } }, + { numpad = { x = 9, y = 3.5, h = 2 }, name = "+", scan = { "numpad+" }, code = { "numpad+" } }, + { numpad = { x = 6, y = 4.5 }, name = "4", scan = { "numpad4" }, code = { "numpad4" } }, + { numpad = { x = 7, y = 4.5 }, name = "5", scan = { "numpad5" }, code = { "numpad5" } }, + { numpad = { x = 8, y = 4.5 }, name = "6", scan = { "numpad6" }, code = { "numpad6" } }, + { numpad = { x = 6, y = 5.5 }, name = "1", scan = { "numpad1" }, code = { "numpad1" } }, + { numpad = { x = 7, y = 5.5 }, name = "2", scan = { "numpad2" }, code = { "numpad2" } }, + { numpad = { x = 8, y = 5.5 }, name = "3", scan = { "numpad3" }, code = { "numpad3" } }, + { numpad = { x = 9, y = 5.5, h = 2 }, name = "Enter", scan = { "numpad_enter" }, code = { "numpad_enter" } }, + { numpad = { x = 6, y = 6.5, w = 2 }, name = "0", scan = { "numpad0" }, code = { "numpad0" } }, + { numpad = { x = 8, y = 6.5 }, name = ".", scan = { "numpad." }, code = { "numpad." } }, +} + +-- Modifier names in the order the engine writes them, which is the order a layer's caption +-- and its key read them in. +local modifierNames = {} +for i, name in ipairs(keyConfig.modifierOrder) do + modifierNames[i] = name:lower() +end + +-- A layer's key: the modifiers it holds, in that order, joined with "+". No modifiers is "". +local function layerKeyOf(mods) + local parts = {} + for _, name in ipairs(modifierNames) do + if mods[name] then + parts[#parts + 1] = name + end + end + + return table.concat(parts, "+") +end + +---------------------------------------------------------------- +-- Colours and sizes +---------------------------------------------------------------- + +local colorText = "\255\235\235\235" +local colorDim = "\255\160\160\160" +local colorKey = "\255\235\185\070" + +local look = { + -- Caps: a bound key, one with nothing on this layer, a modifier at rest, a modifier + -- whose layer is showing, and a key the search found or the list is filtered to. + bound = { 0.22, 0.22, 0.22, 1 }, + unbound = { 0.16, 0.16, 0.16, 1 }, + modifier = { 0.28, 0.28, 0.28, 1 }, + modifierActive = { 0.8, 0.8, 0.78, 1 }, + hit = { 0.5, 0.4, 0.16, 1 }, + -- A key the search did not find sinks into the panel so the found ones stand out. + missOpacity = 0.4, + -- Text on a dark cap, and on the light cap of an active modifier. + name = "\255\200\200\200", + nameOnLight = "\255\40\40\40", + shifted = "\255\125\125\125", + shiftedOnLight = "\255\110\110\110", + labelOnLight = "\255\30\30\30", + -- The Shift half of a paired order, which does what the key does without Shift. + paired = "\255\150\150\150", + more = colorKey, + iconAlpha = 0.85, + pairedIconAlpha = 0.45, + caption = colorText, + captionMods = colorKey, + hint = colorDim, + -- The view toggle: a button like the header's, pressed while the number pad is showing. + buttonFill = { 0.18, 0.18, 0.18, 1 }, + buttonFillActive = { 0.33, 0.33, 0.33, 1 }, + buttonFillHover = { 0.4, 0.4, 0.4, 1 }, + buttonText = colorText, + buttonHoverOpacity = 0.25, + white = { 1, 1, 1 }, + -- The outline every string here is drawn with, set on every batch: the font is shared with + -- every other widget, some of which set an outline of their own and leave it, and text + -- baked into a display list keeps whatever outline was set last. The editor's value. + outline = { 0, 0, 0, 0.4 }, +} + +-- FlowUI's Button gradients from a bottom stop to a top one; each fill becomes a darker +-- bottom and itself on top, the shape the editor's other buttons take. Derived once per fill. +look.gradients = setmetatable({}, { + __index = function(self, fill) + local pair = { + { fill[1] * 0.55, fill[2] * 0.55, fill[3] * 0.55, fill[4] or 1 }, + { fill[1], fill[2], fill[3], fill[4] or 1 }, + } + self[fill] = pair + + return pair + end, +}) + +-- Label colours by catalog category, so a key's action reads as the kind of thing it is at a +-- glance: groups in blue, camera in gold, build in yellow. Anything unlisted prints plain. +local categoryColors = { + ["categories.selection"] = "\255\150\205\255", + ["categories.orders"] = colorText, + ["categories.queues"] = "\255\255\190\120", + ["categories.unitStates"] = "\255\170\230\150", + ["categories.controlGroups"] = "\255\120\190\255", + ["categories.buildHotkeys"] = "\255\255\225\120", + ["categories.gridMenu"] = "\255\255\225\120", + ["categories.blueprints"] = "\255\200\170\255", + ["categories.camera"] = "\255\255\215\130", + ["categories.mapViews"] = "\255\150\230\220", + ["categories.interfaceDisplay"] = "\255\220\220\220", + ["categories.drawing"] = "\255\255\170\200", + ["categories.sound"] = "\255\190\200\230", + ["categories.gameControl"] = "\255\255\150\150", +} + +---------------------------------------------------------------- +-- Construction +---------------------------------------------------------------- + +function M.new() + local self = setmetatable({}, M) ---@type KeybindKeyboard + self.keys = {} + for i, def in ipairs(keyDefs) do + ---@type table + local key = {} + for k, v in pairs(def) do + key[k] = v + end + key.id = i + key.rects = {} + key.layers = {} + key.any = {} + key.show = {} + self.keys[i] = key + end + self.shown = {} + self.view = "main" + -- Modifiers toggled on the drawn keyboard, and those held on the real one. + self.toggled = {} + self.held = {} + self.layoutName = nil + self.query = Search.query(nil) + self.queryTokens = {} + self.queryGen = 0 + self.filter = nil + self.filterGen = 0 + self.gen = 0 + self.layoutGen = 0 + self.unplaced = 0 + self.L = {} + self.area = { x1 = 0, y1 = 0, x2 = 0, y2 = 0 } + self.frames = {} + self.scale = 1 + + return self +end + +-- Picks up the font and the FlowUI entry points, which do not exist at include time. Called +-- again on a resize: the font handler hands out new objects then. +function M:init(font) + self.font = font + self.UiKey = WG.FlowUI.Draw.Key + self.UiButton = WG.FlowUI.Draw.Button + self.Highlight = WG.FlowUI.Draw.SelectHighlight + self.layoutGen = self.layoutGen + 1 +end + +-- Re-reads the page's own strings. Modifier and key names are read from the layout on the +-- next placement, since they change with the keyboard layout rather than the language. +function M:refreshStrings() + local L = self.L + L.layerBase = BAR.I18N("ui.keybinds.keyboard.layerBase") + L.layer = BAR.I18N("ui.keybinds.keyboard.layer") + L.hint = BAR.I18N("ui.keybinds.keyboard.hint") + L.notShown = BAR.I18N("ui.keybinds.keyboard.notShown") + L.unbound = BAR.I18N("ui.keybinds.keyboard.unbound") + L.anyModifier = BAR.I18N("ui.keybinds.keyboard.anyModifier") + L.paired = BAR.I18N("ui.keybinds.keyboard.paired") + L.clickKey = BAR.I18N("ui.keybinds.keyboard.clickKey") + L.clickModifier = BAR.I18N("ui.keybinds.keyboard.clickModifier") + L.clickModifierOff = BAR.I18N("ui.keybinds.keyboard.clickModifierOff") + L.numpad = BAR.I18N("ui.keybinds.keyboard.numpad") + L.numpadTooltip = BAR.I18N("ui.keybinds.keyboard.numpadTooltip") + L.numpadText = look.buttonText .. L.numpad + self.hintLines = nil +end + +---------------------------------------------------------------- +-- Geometry +---------------------------------------------------------------- + +-- Lays both views out inside the rect: as large as the main view's fifteen units fit across +-- and seven and a half units fit down, each view centred in whatever is left over. The +-- caption row and the view toggle keep to the main view's frame, so they stay put when the +-- view changes. Every edge and size is a whole pixel. +function M:setArea(x1, y1, x2, y2, scale, titleFs) + local a = self.area + a.x1, a.y1, a.x2, a.y2 = x1, y1, x2, y2 + self.scale = scale or 1 + + local unit = floor(min((x2 - x1) / viewCols.main, (y2 - y1) / ROWS)) + self.unit = unit + self.titleFs = max(titleFs or 0, floor(unit * 0.22)) + -- Half the gap between two keys goes on each side of every key, so a wide key and two + -- narrow ones fill the same span. + local half = max(1, floor(unit * 0.045)) + local oy = floor(y2 - (y2 - y1 - unit * ROWS) * 0.5) + for _, view in ipairs(viewOrder) do + self.frames[view] = { ox = floor(x1 + (x2 - x1 - unit * viewCols[view]) * 0.5), oy = oy } + end + self.cs = max(2, floor(unit * 0.09)) + self.pad = max(2, floor(unit * 0.07)) + -- The key's name reads first, its action smaller under it: three short lines of it fit + -- a plain key, which is what most of the catalog's labels need. + self.nameFs = max(8, floor(unit * 0.14)) + self.labelFs = max(7, floor(unit * 0.125)) + self.moreFs = max(7, floor(unit * 0.11)) + self.iconSize = floor(unit * 0.24) + -- The hint is a sentence read at a glance, so it prints larger than a key's label; two or + -- three lines of it fit the caption row. + self.hintFs = max(9, floor(unit * 0.17)) + self.buttonFs = max(8, floor(unit * 0.15)) + + for _, key in ipairs(self.keys) do + for _, view in ipairs(viewOrder) do + local at = key[view] + if at then + local ox = self.frames[view].ox + key.rects[view] = { + ox + floor(at.x * unit) + half, + oy - floor((at.y + (at.h or 1)) * unit) + half, + ox + floor((at.x + (at.w or 1)) * unit) - half, + oy - floor(at.y * unit) - half, + } + end + end + end + + -- The toggle, at the right of the caption row, clear of the Pause key at the row's end. + local main = self.frames.main + local bw, bh = floor(unit * 2.2), floor(unit * 0.5) + local bx2 = main.ox + floor(unit * 13.75) + local by1 = floor(oy - unit * 0.5 - bh * 0.5) + self.button = { bx2 - bw, by1, bx2, by1 + bh } + + self:applyView() + self.layoutGen = self.layoutGen + 1 + self.hintLines = nil +end + +-- Which keys the current view draws, and where. A key not in the view has no rect, so the +-- hit test and the drawing skip it. +function M:applyView() + self.shown = {} + for i, key in ipairs(self.keys) do + local r = key.rects[self.view] + if r then + key.x1, key.y1, key.x2, key.y2 = r[1], r[2], r[3], r[4] + self.shown[#self.shown + 1] = i + else + key.x1, key.y1, key.x2, key.y2 = nil, nil, nil, nil + end + end +end + +function M:setView(view) + if not viewCols[view] or view == self.view then + return + end + self.view = view + self:applyView() + self.layoutGen = self.layoutGen + 1 +end + +---------------------------------------------------------------- +-- Names and placement +---------------------------------------------------------------- + +-- What the cap prints and the engine names the key by, for the player's keyboard layout. +-- Each key gets the canonical tokens (as keybind_model spells them: "sc:q", "kc:a") that +-- land on it, and the index from token to key that placement looks bindings up in. +---@return table index +function M:applyLayout(layoutName) + self.layoutName = layoutName + local positional = keyConfig.scanToCode[layoutName] or keyConfig.scanToCode.qwerty + local index = {} + self.tokenIndex = index + + local function claim(token, i) + -- First come first served: a layout that puts one character on two keys is broken, + -- and the drawn keyboard can only show it once. + if index[token] == nil then + index[token] = i + end + end + + for i, key in ipairs(self.keys) do + local tokens = {} + if key.char then + local upper = key.char:upper() + local produced = positional[upper] or upper + -- The engine's scancode name for the position, and the keycode of whatever the + -- layout puts there. Punctuation carries the engine's word for it as well. + tokens[#tokens + 1] = "sc:" .. key.char:lower() + if key.word then + tokens[#tokens + 1] = "sc:" .. key.word + end + tokens[#tokens + 1] = "kc:" .. produced:lower() + -- The engine's other spellings of a keycode only hold while the key still makes + -- the character they spell. + if key.code and produced == upper then + for _, name in ipairs(key.code) do + tokens[#tokens + 1] = "kc:" .. name + end + end + key.label = keyConfig.sanitizeKey("sc_" .. (key.word or key.char), layoutName) + key.shiftedLabel = (produced == upper) and key.shifted or nil + -- What the list's chips print for the key. + key.searchName = key.label + else + for _, name in ipairs(key.scan or {}) do + tokens[#tokens + 1] = "sc:" .. name + end + for _, name in ipairs(key.code or {}) do + tokens[#tokens + 1] = "kc:" .. name + end + key.label = key.name + key.shiftedLabel = nil + local spelled = (key.scan and key.scan[1] and ("sc_" .. key.scan[1])) or (key.code and key.code[1]) or "" + key.searchName = keyConfig.sanitizeKey(spelled, layoutName) + end + key.tokens = tokens + for _, token in ipairs(tokens) do + claim(token, i) + end + key.lower = key.label:lower() + end + + return index +end + +-- Places every binding on its key. `infoFor(action)` answers with the action's label, +-- description, icon, category and catalog rank; `hidden` names the actions the catalog +-- keeps off every surface; `shiftPair` the actions bound twice, bare and with Shift. +function M:place(binds, hidden, layoutName, shiftPair, infoFor) + if not self.tokenIndex or layoutName ~= self.layoutName then + self:applyLayout(layoutName) + end + local index = self.tokenIndex or {} + self.infoFor = infoFor + self.shiftPair = shiftPair or {} + self.gen = self.gen + 1 + self.unplaced = 0 + self.hintLines = nil + + for _, key in ipairs(self.keys) do + key.layers = {} + key.any = {} + key.show = {} + end + + local seen = {} + for _, b in ipairs(binds or {}) do + if not (hidden and hidden[b.action]) then + local elems = keybindModel.splitChain(b.keyset) + local mods, keyToken = keybindModel.splitElement(keybindModel.canonicalKeyset(elems[1] or b.keyset)) + -- Nil for a key the keyboard does not draw: keys[0] is nothing. + local idx = (keyToken and index[keyToken]) or 0 + local key = self.keys[idx] --[[@as table?]] + if key then + local list, layer + if mods.any then + list = key.any + layer = "any" + else + layer = layerKeyOf(mods) + list = key.layers[layer] + if not list then + list = {} + key.layers[layer] = list + end + end + -- The same action on the same key and layer twice is one entry: a keymap can + -- say it twice, and the face has one slot. + local dup = idx .. "|" .. layer .. "|" .. b.action + if not seen[dup] then + seen[dup] = true + list[#list + 1] = { + action = b.action, + raw = b.keyset, + chain = #elems > 1, + any = mods.any or false, + } + end + else + self.unplaced = self.unplaced + 1 + end + end + end +end + +-- The action's card, asked of the host and kept on the entry. +function M:infoOf(entry) + if not entry.info then + entry.info = (self.infoFor and self.infoFor(entry.action)) or { label = entry.action, rank = math.huge } + end + + return entry.info +end + +-- What a key shows on a layer: the bindings naming exactly those modifiers, then the Any+ +-- ones, each block in catalog order. Kept per layer until the bindings change. +function M:entries(key, layer) + local show = key.show[layer] + if show and show.gen == self.gen then + return show.entries + end + + local entries = {} + local function take(list) + local sorted = {} + for i, e in ipairs(list) do + sorted[i] = e + end + table.sort(sorted, function(a, b) + local ra, rb = self:infoOf(a).rank or math.huge, self:infoOf(b).rank or math.huge + if ra ~= rb then + return ra < rb + end + return a.action < b.action + end) + for _, e in ipairs(sorted) do + entries[#entries + 1] = e + end + end + take(key.layers[layer] or {}) + take(key.any) + + -- A paired order's Shift half does what the bare key does; on a layer holding Shift it is + -- marked, so the layer reads as what Shift adds rather than everything Shift keeps. + local bare + if layer:find("shift", 1, true) then + local without = layer:gsub("%+?shift", "") + bare = key.layers[without] or {} + end + for _, e in ipairs(entries) do + e.paired = false + if bare and self.shiftPair[e.action] then + for _, b in ipairs(bare) do + if b.action == e.action then + e.paired = true + break + end + end + end + end + + key.show[layer] = { gen = self.gen, entries = entries } + + return entries +end + +---------------------------------------------------------------- +-- Layers, search, filter and hit testing +---------------------------------------------------------------- + +-- The modifiers in effect: toggled on the drawn keyboard or held on the real one. +function M:activeMods() + local mods = {} + for _, name in ipairs(modifierNames) do + mods[name] = self.toggled[name] or self.held[name] or false + end + + return mods +end + +function M:layer() + return layerKeyOf(self:activeMods()) +end + +function M:toggle(mod) + self.toggled[mod] = not self.toggled[mod] or nil +end + +function M:setHeld(alt, ctrl, meta, shift) + local h = self.held + h.alt, h.ctrl, h.meta, h.shift = alt or nil, ctrl or nil, meta or nil, shift or nil +end + +-- The search box's text. A key is found by its own name, or by an action it shows on the +-- layer; the rest sink. Key names are whole words, as the list's key search takes them, so +-- "f1" does not light F11. +function M:setQuery(str) + local query = Search.query(str) + if query.text == self.query.text then + return + end + self.query = query + self.queryTokens = {} + for token in query.text:gmatch("[^%s%+]+") do + self.queryTokens[#self.queryTokens + 1] = token + end + self.queryGen = self.queryGen + 1 +end + +-- The key the list is filtered to, lit here and nowhere else: `id` names the key and `layer` +-- the modifiers it was clicked under. Nil clears it. +function M:setFilter(filter) + self.filter = filter + self.filterGen = self.filterGen + 1 +end + +function M:matches(key, entries) + local query = self.query + if query.empty then + return nil + end + for _, token in ipairs(self.queryTokens) do + if token == key.lower or (key.mod and token == key.mod) then + return true + end + end + for _, e in ipairs(entries) do + local info = self:infoOf(e) + if Search.matches(query, (info.label or ""):lower()) or Search.matches(query, e.action:lower()) then + return true + end + end + + return false +end + +-- The key under the point, by index; -1 for the view toggle; nil for neither. +function M:hitTest(x, y) + local b = self.button + if b and isInRect(x, y, b[1], b[2], b[3], b[4]) then + return -1 + end + for _, i in ipairs(self.shown) do + local key = self.keys[i] --[[@as table]] + if isInRect(x, y, key.x1, key.y1, key.x2, key.y2) then + return i + end + end + + return nil +end + +-- Everything the baked picture is painted from, beyond what the host already tracks. +function M:signature(hoverIdx) + return (hoverIdx or 0) + .. "|" + .. self:layer() + .. "|" + .. self.queryGen + .. "|" + .. self.gen + .. "|" + .. self.layoutGen + .. "|" + .. self.view + .. "|" + .. self.filterGen +end + +-- A click: the toggle swaps the view; a modifier toggles its layer; a bound key is handed +-- back with the layer it was clicked under, for the list to filter to. Nothing else answers. +function M:mousePress(x, y, button) + if button ~= 1 then + return nil + end + local idx = self:hitTest(x, y) + if idx == -1 then + self:setView(self.view == "main" and "numpad" or "main") + + return "view", self.view + end + local key = idx and self.keys[idx] or nil + if not key then + return nil + end + if key.mod then + self:toggle(key.mod) + + return "modifier", key.mod + end + local layer = self:layer() + if #self:entries(key, layer) == 0 then + return nil + end + + return "key", key, layer +end + +-- The key with the layer's modifiers in front, the way a chip prints it. +function M:keysetName(key, layer) + local parts = {} + local mods = layer and {} or self:activeMods() + if layer then + for name in layer:gmatch("[^+]+") do + mods[name] = true + end + end + for _, name in ipairs(modifierNames) do + if mods[name] then + parts[#parts + 1] = name:sub(1, 1):upper() .. name:sub(2) + end + end + parts[#parts + 1] = key.searchName or key.label + + return table.concat(parts, " + ") +end + +---------------------------------------------------------------- +-- Tooltips +---------------------------------------------------------------- + +-- What the tooltip is about, so the host rebuilds its text only when that changes. +function M:tooltip(idx) + if idx == -1 then + return "kb|button|" .. self.view, self.L.numpad + end + local key = self.keys[idx] + if not key then + return nil + end + local layer = self:layer() + + return "kb|" .. idx .. "|" .. layer .. "|" .. self.gen, self:keysetName(key) +end + +-- The tooltip's lines: every action on the key for this layer, in the order the face ranks +-- them, each with what it does; then what a click here does. +function M:tooltipLines(idx) + local L = self.L + if idx == -1 then + return { colorDim .. L.numpadTooltip } + end + local key = self.keys[idx] + if not key then + return {} + end + local lines = {} + if key.mod then + local tipKey = self.toggled[key.mod] and "ui.keybinds.keyboard.clickModifierOff" + or "ui.keybinds.keyboard.clickModifier" + lines[#lines + 1] = colorDim .. BAR.I18N(tipKey, { mod = key.label }) + end + local entries = self:entries(key, self:layer()) + for _, e in ipairs(entries) do + local info = self:infoOf(e) + local line = (e.paired and look.paired or colorText) .. (info.label or e.action) + if e.chain then + line = line .. " " .. colorKey .. keybindModel.displayKeyset(e.raw, self.layoutName) + end + if e.any then + line = line .. " " .. colorDim .. L.anyModifier + elseif e.paired then + line = line .. " " .. colorDim .. L.paired + end + lines[#lines + 1] = line + if info.description then + lines[#lines + 1] = colorDim .. info.description + end + end + if #entries == 0 and not key.mod then + lines[#lines + 1] = colorDim .. L.unbound + end + if #entries > 0 and not key.mod then + lines[#lines + 1] = colorDim .. L.clickKey + end + + return lines +end + +---------------------------------------------------------------- +-- Drawing +---------------------------------------------------------------- + +-- The label a key wears on a layer, wrapped and fitted to its face, kept until the bindings +-- or the geometry change. +function M:faceLines(key, layer, entries, faceW, maxLines) + local show = key.show[layer] + if show.lines and show.linesGen == self.layoutGen and show.linesMax == maxLines then + return show.lines, show.first + end + local first = entries[1] + local lines = {} + if first then + local info = self:infoOf(first) + local label = info.label or first.action + local fs = self.labelFs + local wrapped = text.wrap(self.font, label, faceW, fs) + if #wrapped > maxLines then + -- The last line that fits takes the rest of the label, shortened to the face. + local rest = {} + for i = maxLines, #wrapped do + rest[#rest + 1] = wrapped[i] + end + wrapped[maxLines] = table.concat(rest, " ") + for i = #wrapped, maxLines + 1, -1 do + wrapped[i] = nil + end + end + for i, line in ipairs(wrapped) do + lines[i] = text.fit(self.font, line, faceW, fs) + end + end + show.lines, show.first, show.linesGen, show.linesMax = lines, first, self.layoutGen, maxLines + + return lines, first +end + +-- The caption's hint, wrapped to the room left of the caption once, per size and language. +function M:hintFor(width) + if self.hintLines and self.hintWidth == width then + return self.hintLines + end + local lines = text.wrap(self.font, self.L.hint or "", width, self.hintFs) + if self.unplaced > 0 then + local note = BAR.I18N("ui.keybinds.keyboard.notShown", { n = self.unplaced }) + for _, line in ipairs(text.wrap(self.font, note, width, self.hintFs)) do + lines[#lines + 1] = line + end + end + for i = 4, #lines do + lines[i] = nil + end + for i, line in ipairs(lines) do + lines[i] = text.fit(self.font, line, width, self.hintFs) + end + self.hintLines, self.hintWidth = lines, width + + return lines +end + +-- Paints the page: the caption row with the view toggle, then every key of the view with +-- its picture and words. Called inside the host's display list, so all of it bakes and +-- replays until the signature moves. +function M:draw(hoverIdx) + local font = self.font + local UiKey = self.UiKey + if not font or not UiKey or not self.unit then + return + end + local mods = self:activeMods() + local layer = layerKeyOf(mods) + local unit, pad, cs = self.unit, self.pad, self.cs + local searching = not self.query.empty + local filter = self.filter + local nameLineH = floor(self.nameFs * 1.2) + local lineH = floor(self.labelFs * 1.1) + local prints = {} + local function print(str, x, y, size, opts) + prints[#prints + 1] = { str, x, y, size, opts } + end + + -- Caps and pictures first, words after, in one font batch. + glBlending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) + for _, i in ipairs(self.shown) do + local key = self.keys[i] --[[@as table]] + local entries = self:entries(key, layer) + local active = key.mod and mods[key.mod] + local filtered = filter and filter.id == key.id and filter.layer == layer + local hit = (searching and self:matches(key, entries)) or filtered + local fill = (active and look.modifierActive) + or (hit and look.hit) + or (key.mod and look.modifier) + or (entries[1] and look.bound) + or look.unbound + local opacity = (searching and not hit and not active) and look.missOpacity or 1 + local fx1, fy1, fx2, fy2 = UiKey(key.x1, key.y1, key.x2, key.y2, cs, fill, active, hoverIdx == i, opacity) + local light = active + local faceW = fx2 - fx1 - pad * 2 + -- Text on a dark cap carries the panel's dark outline; dark text on a light cap does + -- not, an outline there being a dark ring round dark letters. + local oLeft, oCentre, oRight = "o", "co", "ro" + if light then + oLeft, oCentre, oRight = "", "c", "r" + end + + -- The key's own name, top left; the symbol Shift makes of it beside, dimmer. + local nameTop = fy2 - pad + local nameY = text.baseline(font, nameTop - nameLineH, nameTop, self.nameFs) + print((light and look.nameOnLight or look.name) .. key.label, fx1 + pad, nameY, self.nameFs, oLeft) + if key.shiftedLabel then + local nameW = floor(font:GetTextWidth(key.label) * self.nameFs) + print( + (light and look.shiftedOnLight or look.shifted) .. key.shiftedLabel, + fx1 + pad + nameW + floor(pad * 0.8), + nameY, + self.nameFs, + oLeft + ) + end + + -- The room under the name: the first action's words, as many lines as fit, centred. + local bandTop = nameTop - nameLineH - floor(pad * 0.4) + local bandBottom = fy1 + pad + local maxLines = min(3, max(1, floor((bandTop - bandBottom) / lineH))) + local lines, first = self:faceLines(key, layer, entries, faceW, maxLines) + -- The top right corner: the action's picture, and how many more actions the tooltip + -- lists, which sits left of the picture when there is one. + local cornerX = fx2 - pad + if first then + local info = self:infoOf(first) + local color = light and look.labelOnLight + or (first.paired and look.paired) + or categoryColors[info.category or ""] + or colorText + local n = #lines + local blockTop = floor((bandTop + bandBottom + n * lineH) * 0.5) + local cx = floor((fx1 + fx2) * 0.5) + for li = 1, n do + local top = blockTop - (li - 1) * lineH + print(color .. lines[li], cx, text.baseline(font, top - lineH, top, self.labelFs), self.labelFs, oCentre) + end + + if info.icon and self.iconSize > 0 then + local s = self.iconSize + local iy2 = fy2 - pad + glColor(1, 1, 1, (first.paired and look.pairedIconAlpha or look.iconAlpha) * opacity) + glTexture(info.icon) + glTexRect(cornerX - s, iy2 - s, cornerX, iy2) + glTexture(false) + glColor(1, 1, 1, 1) + cornerX = cornerX - s - floor(pad * 0.6) + end + end + if #entries > 1 then + print((light and look.nameOnLight or look.more) .. "+" .. (#entries - 1), cornerX, nameY, self.moreFs, oRight) + end + end + + -- The caption row: which layer this is, centred over the keyboard; how to work the page, + -- in the room to the left of it; and the view toggle at its right. + local frame = self.frames.main + local rowTop, rowBottom = frame.oy, frame.oy - unit + local caption + local held = {} + for _, name in ipairs(modifierNames) do + if mods[name] then + held[#held + 1] = name:sub(1, 1):upper() .. name:sub(2) + end + end + if #held > 0 then + caption = look.captionMods .. BAR.I18N("ui.keybinds.keyboard.layer", { mods = table.concat(held, " + ") }) + else + caption = look.caption .. (self.L.layerBase or "") + end + local captionX = frame.ox + floor(unit * 7.5) + print(caption, captionX, text.baseline(font, rowBottom, rowTop, self.titleFs), self.titleFs, "co") + + local hintW = floor(unit * 5.5) - pad + local hintLines = self:hintFor(hintW) + local hintLineH = floor(self.hintFs * 1.25) + local blockTop = floor((rowTop + rowBottom + #hintLines * hintLineH) * 0.5) + for li, line in ipairs(hintLines) do + local top = blockTop - (li - 1) * hintLineH + local y = text.baseline(font, top - hintLineH, top, self.hintFs) + print(look.hint .. line, frame.ox + pad, y, self.hintFs, "o") + end + + local b = self.button + if b and self.UiButton then + local overButton = hoverIdx == -1 + local fill = (self.view == "numpad" and (overButton and look.buttonFillHover or look.buttonFillActive)) + or look.buttonFill + local pair = look.gradients[fill] + self.UiButton(b[1], b[2], b[3], b[4], 1, 1, 1, 1, 1, 1, 1, 1, nil, pair[1], pair[2]) + if overButton and self.view ~= "numpad" and self.Highlight then + self.Highlight(b[1], b[2], b[3], b[4], floor(cs * 0.5), look.buttonHoverOpacity, look.white) + end + print( + self.L.numpadText or "", + floor((b[1] + b[3]) * 0.5), + text.baseline(font, b[2], b[4], self.buttonFs), + self.buttonFs, + "co" + ) + end + + font:Begin() + font:SetOutlineColor(look.outline) + for _, p in ipairs(prints) do + font:Print(p[1], p[2], p[3], p[4], p[5]) + end + font:End() +end + +return M diff --git a/luaui/Include/keybind_model.lua b/luaui/Include/keybind_model.lua index ef7db9890ae..854ee1dc753 100644 --- a/luaui/Include/keybind_model.lua +++ b/luaui/Include/keybind_model.lua @@ -151,6 +151,27 @@ local function canonicalKeyset(raw) return table.concat(parts, ",") end +-- The first tap of a canonical keyset, taken apart: the modifiers it names as a set ("any" +-- among them when the engine's qualifier is on) and the key token ("sc:q", "kc:a"). What the +-- keyboard page places a binding by, and what a filter on one key matches chips against. +local function splitElement(canon) + local first = canon:match("^[^,]+") or canon + local mods, key = {}, nil + -- The key runs from its "sc:"/"kc:" tag to the end: a key can be named "+" itself + -- ("kc:numpad+"), so the tag decides where the modifiers stop, not the separator. + local at = first:find("[sk]c:") + local modPart = first + if at then + key = first:sub(at) + modPart = first:sub(1, at - 1) + end + for token in modPart:gmatch("[^+]+") do + mods[token] = true + end + + return mods, key +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 @@ -202,6 +223,7 @@ return { displayWithoutShift = displayWithoutShift, holdsKeys = holdsKeys, canonicalKeyset = canonicalKeyset, + splitElement = splitElement, splitChain = splitChain, chainSep = chainSep, } diff --git a/luaui/Include/keybind_profiles.lua b/luaui/Include/keybind_profiles.lua index d0dbba33434..b6df6830f22 100644 --- a/luaui/Include/keybind_profiles.lua +++ b/luaui/Include/keybind_profiles.lua @@ -564,7 +564,7 @@ function M.load() -- anything: a retired one would have the editor comparing against nothing, so a -- profile without a usable one is given the closest shipped profile instead, and -- that is written back so every surface reads the same origin from then on. - if type(p.basedOn) ~= "string" or not M.isBuiltin(p.basedOn) then + if not M.baseIsUsable(p.basedOn, store.profiles) then p.basedOn = M.inferBase(p) inferred = inferred or p.basedOn ~= nil end @@ -738,10 +738,32 @@ function M.inferBase(profile) return best and best.name or nil end --- The shipped profile a profile descends from: itself for a shipped one, the recorded fork --- for the player's own. What an editor compares against to say which keys the player --- changed. Every profile of the player's carries one: recorded when it was forked or --- duplicated, inferred as the closest shipped profile otherwise. +-- The one value of `basedOn` that is not a profile's name: the player chose to compare the +-- profile with nothing, which loading must not turn back into a guess. +local NO_BASE = "none" + +-- Whether a profile's `basedOn` still says something: no comparison, a shipped profile, or +-- one of the player's own in the list given (the store's, so a later entry counts too). +function M.baseIsUsable(basedOn, profiles) + if type(basedOn) ~= "string" then + return false + end + if basedOn == NO_BASE or M.isBuiltin(basedOn) then + return true + end + for _, p in ipairs(profiles or {}) do + if type(p) == "table" and p.name == basedOn then + return true + end + end + + return false +end + +-- The profile a profile is compared with: itself for a shipped one, the recorded fork or +-- the player's later choice for their own - a shipped profile or another of theirs. What an +-- editor compares against to say which keys the player changed. Nil when the player chose +-- none, or the profile it named is gone. function M.baseOf(name) local builtin = M.isBuiltin(name) if builtin then @@ -749,20 +771,39 @@ function M.baseOf(name) end local own = M.get(name) + if not own or type(own.basedOn) ~= "string" or own.basedOn == NO_BASE or own.basedOn == name then + return nil + end + + return M.isBuiltin(own.basedOn) or M.get(own.basedOn) or nil +end + +-- Records what one of the player's profiles is compared with: a shipped profile, another of +-- their own, or nothing at all (nil). False when either name is unknown. +function M.setBase(name, baseName) + M.load() + local i = indexOf(name) + if not i then + return false + end + if baseName ~= nil and (baseName == name or not (M.isBuiltin(baseName) or indexOf(baseName))) then + return false + end + store.profiles[i].basedOn = baseName or NO_BASE - return own and own.basedOn and M.isBuiltin(own.basedOn) or nil + return M.save() 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. --- `basedOn` names the shipped profile it was forked from; without one, the closest shipped --- profile stands in. +-- `basedOn` names the profile it was forked from; without one, the closest shipped profile +-- stands in. function M.create(name, binds, fakeMeta, basedOn) M.load() name = M.uniqueName(name) local profile = { name = name, binds = binds, fakeMeta = resolveFakeMeta(fakeMeta) } - profile.basedOn = (basedOn and M.isBuiltin(basedOn)) and basedOn or M.inferBase(profile) + profile.basedOn = (basedOn and (M.isBuiltin(basedOn) or indexOf(basedOn))) and basedOn or M.inferBase(profile) store.profiles[#store.profiles + 1] = profile if not M.save() then Spring.Echo( @@ -790,6 +831,12 @@ function M.rename(oldName, newName) if store.active == oldName then store.active = newName end + -- Whatever was compared with it follows the name. + for _, p in ipairs(store.profiles) do + if p.basedOn == oldName then + p.basedOn = newName + end + end if not M.save() then Spring.Echo( "[keybind_profiles] Error: could not write " @@ -815,6 +862,13 @@ function M.delete(name) if store.active == name then store.active = store.profiles[1] and store.profiles[1].name or nil end + -- A profile compared with the one gone falls back to the closest shipped one, as a + -- profile with no recorded origin does. + for _, p in ipairs(store.profiles) do + if p.basedOn == name then + p.basedOn = M.inferBase(p) + end + end return M.save() end diff --git a/luaui/Widgets/gui_flowui.lua b/luaui/Widgets/gui_flowui.lua index 80c2741fd83..d7d603dfe77 100644 --- a/luaui/Widgets/gui_flowui.lua +++ b/luaui/Widgets/gui_flowui.lua @@ -3309,6 +3309,148 @@ WG.FlowUI.Draw.Selector = function(px, py, sx, sy) --WG.FlowUI.Draw.Button(sx-(sy-py), py, sx, sy, 1, 1, 1, 1, 1,1,1,1, nil, { 1, 1, 1, 0.1 }, nil, cs) end +local keyCapColor = { 0.22, 0.22, 0.22, 1 } +local mathCos = math.cos +local mathSin = math.sin + +-- A rectangle with round corners, as a fan of triangles about its centre. Round rather than +-- chamfered, since a keycap is; each corner is an arc of `segments` steps. The colour runs +-- from `c1` to `c2` up the rectangle, or from its bottom-right to its top-left corner when +-- `diagonal` is set - a keycap's face is lit from one corner. Per-vertex colours interpolate +-- exactly for a gradient that is linear over the plane, which both are. +local function DrawKeyRoundRect(x1, y1, x2, y2, radius, segments, c1, c2, diagonal) + local w, h = x2 - x1, y2 - y1 + local cx, cy = (x1 + x2) * 0.5, (y1 + y2) * 0.5 + local dr, dg, db, da = c2[1] - c1[1], c2[2] - c1[2], c2[3] - c1[3], (c2[4] or 1) - (c1[4] or 1) + local flat = dr == 0 and dg == 0 and db == 0 and da == 0 + + local function colorAt(x, y) + if flat then + return + end + local t + if diagonal then + t = ((y - y1) / h) * 0.5 + ((x2 - x) / w) * 0.5 + else + t = (y - y1) / h + end + gl.Color(c1[1] + dr * t, c1[2] + dg * t, c1[3] + db * t, (c1[4] or 1) + da * t) + end + + gl.Color(c1[1], c1[2], c1[3], c1[4] or 1) + colorAt(cx, cy) + gl.Vertex(cx, cy, 0) + + -- Corner centres and the angle each arc starts at, going round anticlockwise from the + -- bottom left. + local step = (mathPi * 0.5) / segments + for corner = 0, 3 do + local ccx, ccy, a0 + if corner == 0 then + ccx, ccy, a0 = x1 + radius, y1 + radius, mathPi + elseif corner == 1 then + ccx, ccy, a0 = x2 - radius, y1 + radius, mathPi * 1.5 + elseif corner == 2 then + ccx, ccy, a0 = x2 - radius, y2 - radius, 0 + else + ccx, ccy, a0 = x1 + radius, y2 - radius, mathPi * 0.5 + end + for i = 0, segments do + local angle = a0 + i * step + local vx, vy = ccx + radius * mathCos(angle), ccy + radius * mathSin(angle) + colorAt(vx, vy) + gl.Vertex(vx, vy, 0) + end + end + -- Closed on the first rim vertex. + colorAt(x1, y1 + radius) + gl.Vertex(x1, y1 + radius, 0) +end + +local function KeyRoundRect(x1, y1, x2, y2, radius, c1, c2, diagonal) + if x2 <= x1 or y2 <= y1 then + return + end + radius = mathMax(0, mathMin(radius, (x2 - x1) * 0.5, (y2 - y1) * 0.5)) + local segments = mathMax(3, mathMin(12, mathFloor(radius * 0.6))) + gl.BeginEnd(GL.TRIANGLE_FAN, DrawKeyRoundRect, x1, y1, x2, y2, radius, segments, c1, c2 or c1, diagonal) +end + +---Draws a keyboard key, the way a keycap looks from above: a flat dark body with round +---corners, a lighter face set into it with a thin rim catching the light, and the body's +---bottom edge lit where the cap curves away. Every edge is a hard one: no shadow, feather or +---gloss, so the shape stays crisp at any size. Pressed, the whole key sinks an eighth of its +---height into a socket that shows above it; hovered, it lights. The face is returned so a +---caller can put its caption on the cap rather than the footprint - text and pictures are +---the caller's to draw. Immediate rather than cached: a keyboard of these is baked into one +---display list by whoever draws it. +---@param px number Left +---@param py number Bottom +---@param sx number Right +---@param sy number Top +---@param cs number? Corner radius of the body. Defaults to 9% of the shorter side +---@param color rgb|rgba? The face's colour, which the body, rim and lip are shades of. +---Defaults to keycap grey; a light colour gets a dark rim +---@param pressed boolean? Sunk, the way a toggled modifier or a held key sits +---@param hovered boolean? Lit under the cursor +---@param opacity number? Defaults to `1`. Multiplies every alpha +---@return number left, number bottom, number right, number top The face of the cap +WG.FlowUI.Draw.Key = function(px, py, sx, sy, cs, color, pressed, hovered, opacity) + local width = sx - px + local height = sy - py + if width <= 0 or height <= 0 or px ~= px or py ~= py or sx ~= sx or sy ~= sy then + return px, py, sx, sy + end + local short = mathMin(width, height) + local radius = cs or mathMax(2, mathFloor(short * 0.09)) + color = color or keyCapColor + local r, g, b = color[1], color[2], color[3] + local a = (color[4] or 1) * (opacity or 1) + -- A light cap reads the other way round: its rim and lip darker than its face. + local light = (r * 0.3 + g * 0.59 + b * 0.11) > 0.5 + + local function shade(k) + return { mathMin(1, r * k), mathMin(1, g * k), mathMin(1, b * k), a } + end + + -- Pressed, the whole key sinks: its top comes down by an eighth of the key into a socket, + -- which shows above it darker than anything on the key. The footprint given stays the + -- key's place - the socket fills it - so a row of keys keeps its line. + local drop = pressed and mathMax(1, mathFloor(short * 0.12)) or 0 + local top = sy - drop + if pressed then + KeyRoundRect(px, py, sx, sy, radius, shade(0.45)) + end + + -- The rim's width, the body's lit bottom edge, and how far the face sits in from the body. + -- A pressed key shows less of the body below its face, having gone down into it. + local edge = mathMax(1, mathFloor(short * 0.014)) + local insetX = mathMax(edge + 1, mathFloor(short * 0.07)) + local insetTop = mathMax(edge + 1, mathFloor(short * 0.08)) + local insetBottom = mathMax(edge + 1, mathFloor(short * (pressed and 0.11 or 0.14))) + local fx1, fy1, fx2, fy2 = px + insetX, py + insetBottom, sx - insetX, top - insetTop + local faceRadius = mathMax(1, mathFloor(radius * 0.7)) + + -- The body: its lit bottom edge first, then the body itself a step higher, so the edge + -- shows along the bottom and round the two lower corners. + KeyRoundRect(px, py, sx, top, radius, shade(light and 0.5 or 1.5)) + KeyRoundRect(px, py + edge, sx, top, radius, shade(light and 0.72 or 0.75)) + + -- The rim, and the face inside it, lit from the top left; a pressed face lies in shadow. + KeyRoundRect(fx1 - edge, fy1 - edge, fx2 + edge, fy2 + edge, faceRadius + edge, shade(light and 0.6 or 1.85)) + local lo, hi = 0.86, 1.14 + if pressed then + lo, hi = 0.84, 1.0 + end + KeyRoundRect(fx1, fy1, fx2, fy2, faceRadius, shade(lo), shade(hi), true) + + if hovered then + KeyRoundRect(fx1, fy1, fx2, fy2, faceRadius, { 1, 1, 1, 0.07 * a }) + end + + return fx1, fy1, fx2, fy2 +end + ---Draws a highlighted area inside a selector. Also usable to highlight any other ---generic area. ---@param px number Left diff --git a/luaui/Widgets/gui_keybind_info.lua b/luaui/Widgets/gui_keybind_info.lua index 997d6367ae2..027841eb3f5 100644 --- a/luaui/Widgets/gui_keybind_info.lua +++ b/luaui/Widgets/gui_keybind_info.lua @@ -24,8 +24,14 @@ local doUpdate local vsx, vsy = spGetViewGeometry() -local screenHeightOrg = 610 -local screenWidthOrg = 1100 +-- The panel's size at 1080p: the list fits this panel, and the keyboard overview asks for +-- whatever gets its keys read, worked out per screen below. +local listWidthOrg = 1100 +local listHeightOrg = 610 +-- The page the panel is showing, which decides its size. +local currentPage = "list" +local screenHeightOrg = listHeightOrg +local screenWidthOrg = listWidthOrg local screenHeight = screenHeightOrg local screenWidth = screenWidthOrg @@ -81,10 +87,32 @@ local function refreshText() keybindEditor.refresh() end +-- The panel's size at 1080p for the page showing. The keyboard page keeps the list's panel +-- wherever that already gives it keys of ninety pixels or more on screen, which a 1440p +-- screen does, so the panel does not change between the pages there. Where it does not - a +-- 1080p screen gives 68 - the page grows to keys of about a hundred pixels: fifteen of them +-- plus the panel's margins across, and the keyboard's proportions with the bands around it +-- down. The 96 is those bands at 1080p: the panel's padding, the header and the footer. +local function pageSize(page) + if page ~= "keyboard" then + return listWidthOrg, listHeightOrg + end + local listKeys = (listHeightOrg - 96) / 7.5 * widgetScale + if listKeys >= 90 then + return listWidthOrg, listHeightOrg + end + local keysOrg = 100 * 15 / widgetScale + local width = mathFloor(math.max(listWidthOrg, math.min(1560, keysOrg + 40))) + local height = mathFloor(math.max(listHeightOrg, (width - 40) * 0.5 + 96)) + + return width, height +end + -- Rebuilds every rect and display list against the new screen size. function widget:ViewResize() vsx, vsy = spGetViewGeometry() widgetScale = (vsy / 1080) + screenWidthOrg, screenHeightOrg = pageSize(currentPage) screenHeight = mathFloor(screenHeightOrg * widgetScale) screenWidth = mathFloor(screenWidthOrg * widgetScale) @@ -345,7 +373,12 @@ end function widget:Initialize() refreshText() - widgetHandler:AddAction("keybindeditor", function() + -- "keybindeditor keyboard" opens straight onto the keyboard overview, "keybindeditor list" + -- onto the list; bare, it leaves the page as it was last left. + widgetHandler:AddAction("keybindeditor", function(_, _, words) + if words and words[1] then + keybindEditor.setPage(words[1]) + end show = true doUpdate = true return true @@ -367,6 +400,18 @@ function widget:Initialize() end end) + -- The panel takes the size its page wants; a page switch lays everything out again. + keybindEditor.setPageHook(function(page) + currentPage = page + local width, height = pageSize(page) + if width ~= screenWidthOrg or height ~= screenHeightOrg then + local resize = widget.ViewResize + if resize then + resize(widget, vsx, vsy) + end + end + end) + -- lets the handler hide the rest of the interface while the panel is open widgetHandler:RegisterModalWindow(function() return show == true From e33bdced386009f47cb12784e9297ad25983af49 Mon Sep 17 00:00:00 2001 From: Beherith Date: Mon, 14 Sep 2026 22:42:35 +0200 Subject: [PATCH 4/5] Airjets extension (#9207) Texture mode has changed: - Atlas is supported - Up to 8 different effects on the atlas - Red channel control opacity (as before) - Green channel controls strength of perlin distortion effect Three new parameters have been added to airjet effects - jetType [integer 0-7], choose which jet from the atlas to use - xzVelSizeMult, default 0, controls how much XZ velocity effects jet length (can only increase it) - yVelSizeMult: default 1.0, controls how much Y velocity effect jet length (can reduce it) AI used: Qwen3.8-27b for concepting --- bitmaps/gpl/jet2.bmp | Bin 6200 -> 0 bytes luaui/Widgets/gfx_airjets_gl4.lua | 58 ++++++++++++++++++++++++++---- luaui/configs/airjet_effects.lua | 40 ++++++++++++++++++--- luaui/images/jet_atlas.tga | Bin 0 -> 262162 bytes 4 files changed, 87 insertions(+), 11 deletions(-) delete mode 100644 bitmaps/gpl/jet2.bmp create mode 100644 luaui/images/jet_atlas.tga diff --git a/bitmaps/gpl/jet2.bmp b/bitmaps/gpl/jet2.bmp deleted file mode 100644 index ca44bd6aca1db8eeb73fb7393e603c42d65d65a2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6200 zcmeI#^|O}M76)+YR=T7^xIMDj2Qt!4uhdUPUXs#Kl|*nS+izs+_-VyzI{SC zapJ_;vu7dPx^;`IOkl%&_wHTb_C4jv!Gj04Z{I$D{`~&^`vb|EHEZ(Z$^U|X`9EdK zl(}=~u358Y-@bjPPoKVg`SJ}LHf-IxbP@3}3i#fx}=~ zk#TuPn*AL+b}U)4M97@_{PWLy_UuVQM4l~Mwg^9Q;=~_*_@TVxAA1s1p+bfF_3KL( z$8d{e6kWV{Q52BP%$+%N#{RZ#+kX1#r?0;HigF+$$BXcD=FAB;_^%v%EMC005Q-Kp znm2D=1lFomOD^EkDtVxS2?^!4+1k#bLx*Sp^CL!#Xwjkt#~>T{p_T}rR#h%oc}EoW z=F69_UcGw6FmK*G>D#+^FINFdD7Vels$1$s_v4R0!k0T4%#$Y%oc#IobC}za|1b@8 zM^_4&|IL~;lVbSm*RS8TYZoXc5FQ5b!-o&65z*mi)TmLuU$}7LOqnvtEGLbq!~{a0 zknI?>BtTi#DCC#0k*pAyKnPgF!bjrSv}x0#MT^Fc9owc&8w`6FPO4O?=ri)4KADhi z3;0Y|tXQ#Iw{G8j^9}!3u3QO(PM{%*&opml(WWwlKYsl9E?v54FPw(}IYo*T5&u|% z9NJN66jiHMrL=Fq{Z>gTfpzQF;ek+h?%athh791yU%Phg!i5XzQ)A&3et-u*BYenV zFr;tf$dRLZ_3FKP_2U2c-+#|N?Nk;-0gzzOWmt3GBm66_-o1O%PoNz>D}Re0`QM{Q z5A`^6=1lSsf+7_KKyqTpu$CH+_z%BaxpM8>x7Xh$PMjEewGaSe1G3p#Tc9pawOQx2{)5#BUkalB7V#hO6*48a zZQE9V7&mU56mSv`09m0Hv$X?4XnH;$M2gRkKmItv51KcJ5t+b0e2q>UA2@Iz32_g@ zvH*}3oS3Z{wJ$-+M03{9b=}yu@E7*W`$;t5t3mxiv(?X~m^Lts2Y?7M*s```pYz(t zph1K52dswr3dksm<2);pqKD`t`XR|V?}R|!E)@U~0@-YB$G#fW26>`Ci<)md4Bi3{ zCL-qpe!6t&iWDj0ELH*>69NW;32fuU08U!6qei4jGilPKN$778en@dFc)-`C-I40l zsY9Y169V~RNg}{Xx7pebW^{hXoaEN6TUS?)ZdRaz|8ek%DovU+&X;P{sS7rh}L1@sRpja5re~2*L5Sp04OF90-5J% zr}=QE`;KHezN2eZ9L(5{ENXFNfJ_vr0)>wOP)s0%6*F+WIiLmTJ8lJ1Mi>$Kv6ykz zB!ot1=qI0i^696a;uZs-M1gIkWMtwe($J0>$GTgS zYd`XxiLuFz0~`A>P((#qmO@ZFj(b;km<-{Bba{>$Pqj_T3@7jFf{BHV*21JTDhii6 z@&HAIF!^lOoy6rn=^Cajxc0a(#E1M~LYNL>&Cqp@u+R#FPABf;05MF@+!tpin{QVOZM{nx5uD z=*q{Rc#(UfYWd`I}ij)jtV;B?@FvGAG6ua)6l&Pis zBXS~&WX;tY2q-3ChG8A<;mZ6C$d4#eGG15vPBe*@%`mJx@jD3ll>cVtogaMpI$`M`s+d^Nd@53*j#oZI zHndg|y*)Xx6v?tf4YGlG5rv-;vBAFCItl^dkXf-1(VLak-}(@;jBv;X=QaGdR8Fj+ z5CX!P0Iu)7D{>+$LV(ExW;p*2$q6Px2u#pCS-z$0Be$ake1vlwxP_0=qj$9kCV; jet length multiplier (0 = off, keeps old look) +local defaultYVelSizeMult = 1.0 -- Y velocity -> jet length multiplier (1 = current behaviour) local effectDefs = VFS.Include("luaui/configs/airjet_effects.lua") @@ -123,6 +128,15 @@ for name, effects in pairs(effectDefs) do if not effectDefs[name][fx].emitVector then effectDefs[name][fx].emitVector = { 0, 0, -1 } end + if not effectDefs[name][fx].jetType then + effectDefs[name][fx].jetType = defaultJetType + end + if not effectDefs[name][fx].xzVelSizeMult then + effectDefs[name][fx].xzVelSizeMult = defaultXZVelSizeMult + end + if not effectDefs[name][fx].yVelSizeMult then + effectDefs[name][fx].yVelSizeMult = defaultYVelSizeMult + end if effectDefs[name][fx].xzVelocity then xzVelocityUnits[UnitDefNames[name].id] = effectDefs[name][fx].xzVelocity end @@ -196,6 +210,7 @@ layout (location = 2) in vec3 emitdir; layout (location = 3) in vec3 color; layout (location = 4) in uint pieceIndex; layout (location = 5) in uvec4 instData; // unitID, teamID, ?? +layout (location = 6) in vec3 jetParams; // x: xzVelSizeMult, y: yVelSizeMult, z: jetType (atlas column) //__DEFINES__ //__ENGINEUNIFORMBUFFERDEFS__ @@ -203,6 +218,7 @@ layout (location = 5) in uvec4 instData; // unitID, teamID, ?? out DataVS { vec4 texCoords; vec4 jetcolor; + float jetAtlas; #if (DEBUG == 1) vec4 debug0; @@ -292,7 +308,8 @@ void main() vec4 speedvector = uni[instData.y].speed; vec2 modulatedsize = widthlengthtime.xy * 1.5; - modulatedsize.y *= clamp(speedvector.y * 0.5 + 1.0 , 0.66, 2.0); // make the jet shorter/longer based on Y velocity + modulatedsize.y *= clamp(speedvector.y * 0.5 * jetParams.y + 1.0 , 0.33, 4.0); // Y velocity -> length + modulatedsize.y *= clamp(length(speedvector.xz) * 0.5 * jetParams.x + 1.0, 0.33, 4.0); // XZ velocity -> length // modulatedsize += rndVec3.xy * modulatedsize * 0.25; // not very pretty vec4 vertexPos = vec4(position_xy_uv.x * modulatedsize.x * 2.0, 0, position_xy_uv.y*modulatedsize.y * 0.66 ,1.0); @@ -325,6 +342,7 @@ void main() texCoords.st = position_xy_uv.zw; texCoords.pq = position_xy_uv.zw; texCoords.q += (timeInfo.x + timeInfo.w) * 0.1; + jetAtlas = jetParams.z; jetcolor.rgb = color; jetcolor.a = clamp((timeInfo.x + timeInfo.w - widthlengthtime.z)*0.053, 0.0, 1.0); @@ -362,9 +380,11 @@ uniform sampler2D mask; uniform int reflectionPass = 0; #define DISTORTION 0.01 +#define JET_ATLAS_COLS 8.0 // 256px / 32px per cell in DataVS { vec4 texCoords; vec4 jetcolor; + float jetAtlas; #if DEBUG == 1 vec4 debug0; vec4 debug1; @@ -376,10 +396,17 @@ out vec4 fragColor; void main(void) { vec2 displacement = texCoords.pq; - vec2 txCoord = texCoords.st; - txCoord.s += (texture(noiseMap, displacement * DISTORTION * 20.0).y - 0.5) * 40.0 * DISTORTION; - txCoord.t += texture(noiseMap, displacement).x * (1.0-texCoords.t) * 15.0 * DISTORTION; - float opac = texture(mask,txCoord.st).r; + vec2 cellUV = texCoords.st; + + // per-cell perlin displacement strength from the GREEN channel (g=1.0 => baseline DISTORTION) + float distortion = texture(mask, vec2((jetAtlas + cellUV.s) / JET_ATLAS_COLS, cellUV.t)).g * DISTORTION; + + vec2 txCoord = cellUV; + txCoord.s += (texture(noiseMap, displacement * DISTORTION * 20.0).y - 0.5) * 40.0 * distortion; + txCoord.t += texture(noiseMap, displacement).x * (1.0-cellUV.t) * 15.0 * distortion; + + vec2 atlasUV = vec2((jetAtlas + clamp(txCoord.s, 0.0, 1.0)) / JET_ATLAS_COLS, txCoord.t); + float opac = texture(mask, atlasUV).r; fragColor.rgb = opac * jetcolor.rgb; //color fragColor.rgb += pow(opac, 5.0 ); //white flame @@ -433,6 +460,7 @@ local function initGL4() { id = 3, name = "color", size = 3 }, --- color { id = 4, name = "pieceIndex", type = GL.UNSIGNED_INT, size = 1 }, { id = 5, name = "instData", type = GL.UNSIGNED_INT, size = 4 }, + { id = 6, name = "jetParams", size = 3 }, -- x: xzVelSizeMult, y: yVelSizeMult, z: jetType } jetInstanceVBO = gl.InstanceVBOTable.makeInstanceVBOTable(jetInstanceVBOLayout, 256, "jetInstanceVBO", 5) jetInstanceVBO.numVertices = numVertices @@ -588,6 +616,9 @@ local function Activate(unitID, unitDefID, who, when) 0, 0, 0, -- this is needed to keep the lua copy of the vbo the correct size + effectDef.xzVelSizeMult, + effectDef.yVelSizeMult, + effectDef.jetType, } pushElementInstance( jetInstanceVBO, @@ -829,7 +860,17 @@ function widget:Initialize() WG.airjets = {} - WG.airjets.addAirJet = function(unitID, piecenum, width, length, color3, emitVector) -- for WG external calls + WG.airjets.addAirJet = function( + unitID, + piecenum, + width, + length, + color3, + emitVector, + xzVelSizeMult, + yVelSizeMult, + jetType + ) -- for WG external calls local airjetkey = tostring(unitID) .. "_" .. tostring(piecenum) if emitVector == nil then emitVector = { 0, 0, -1 } @@ -851,6 +892,9 @@ function widget:Initialize() 0, 0, 0, -- this is needed to keep the lua copy of the vbo the correct size + xzVelSizeMult or defaultXZVelSizeMult, + yVelSizeMult or defaultYVelSizeMult, + jetType or defaultJetType, }, airjetkey, true, -- update existing diff --git a/luaui/configs/airjet_effects.lua b/luaui/configs/airjet_effects.lua index 9bb04365a74..b23b48ef9ed 100644 --- a/luaui/configs/airjet_effects.lua +++ b/luaui/configs/airjet_effects.lua @@ -118,10 +118,42 @@ return { { color = { 0.7, 0.4, 0.1 }, width = 6, length = 17, piece = "thrust4", emitVector = { 0, 1, 0 }, light = 1 }, }, corhvytrans = { - { color = { 0.7, 0.4, 0.1 }, width = 6, length = 17, piece = "thrustfl", emitVector = { 0, 1, 0 }, light = 1 }, - { color = { 0.7, 0.4, 0.1 }, width = 6, length = 17, piece = "thrustfr", emitVector = { 0, 1, 0 }, light = 1 }, - { color = { 0.7, 0.4, 0.1 }, width = 6, length = 17, piece = "thrustbl", emitVector = { 0, 1, 0 }, light = 1 }, - { color = { 0.7, 0.4, 0.1 }, width = 6, length = 17, piece = "thrustbr", emitVector = { 0, 1, 0 }, light = 1 }, + { + color = { 0.7, 0.4, 0.1 }, + width = 6, + length = 17, + piece = "thrustfl", + emitVector = { 0, 1, 0 }, + light = 1, + xzVelSizeMult = 1.0, + }, + { + color = { 0.7, 0.4, 0.1 }, + width = 6, + length = 17, + piece = "thrustfr", + emitVector = { 0, 1, 0 }, + light = 1, + xzVelSizeMult = 1.0, + }, + { + color = { 0.7, 0.4, 0.1 }, + width = 6, + length = 17, + piece = "thrustbl", + emitVector = { 0, 1, 0 }, + light = 1, + xzVelSizeMult = 1.0, + }, + { + color = { 0.7, 0.4, 0.1 }, + width = 6, + length = 17, + piece = "thrustbr", + emitVector = { 0, 1, 0 }, + light = 1, + xzVelSizeMult = 1.0, + }, }, armdfly = { { color = { 0.1, 0.4, 0.6 }, width = 3.5, length = 35, piece = "thrusta", xzVelocity = 1.5, light = 1 }, diff --git a/luaui/images/jet_atlas.tga b/luaui/images/jet_atlas.tga new file mode 100644 index 0000000000000000000000000000000000000000..b8505237c6732a1ca9100598b0bd2c062f999a26 GIT binary patch literal 262162 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 D)k*}c literal 0 HcmV?d00001 From bf7395d526ebcb604208575afa0dd8dce8e1ac8d Mon Sep 17 00:00:00 2001 From: Beherith Date: Mon, 14 Sep 2026 22:52:25 +0200 Subject: [PATCH 5/5] Add printf support to CUS_GL4 (#9061) Note the configs in ```lua local printfPass = "forward" -- Chose which pass to print debug information for. Can be any of "forward", "shadow", "deferred", "reflection" local printfMaterial = "unit" ``` --- luarules/gadgets/cus_gl4.lua | 59 ++++++++++++++----- .../templates/cus_gl4.frag.glsl | 2 + .../templates/cus_gl4.vert.glsl | 2 + modules/graphics/LuaShader.lua | 9 +++ 4 files changed, 58 insertions(+), 14 deletions(-) diff --git a/luarules/gadgets/cus_gl4.lua b/luarules/gadgets/cus_gl4.lua index a9f3683a909..192a373f544 100644 --- a/luarules/gadgets/cus_gl4.lua +++ b/luarules/gadgets/cus_gl4.lua @@ -226,6 +226,8 @@ local autoReload = { enabled = false, vssrc = "", fssrc = "", lastUpdate = Sprin -- Indicates whether the first round of getting units should grab all instead of delta local manualReload = autoReload.enabled or false +local printfPass = "forward" -- Chose which pass to print debug information for. Can be any of "forward", "shadow", "deferred", "reflection" +local printfMaterial = "unit" local debugmode = false local perfdebug = false @@ -681,8 +683,6 @@ end local LuaShader = gl.LuaShader -local engineUniformBufferDefs = LuaShader.GetEngineUniformBufferDefs() - local QUATERNIONDEFS = "" if Engine.FeatureSupport.transformsInGL4 then QUATERNIONDEFS = LuaShader.GetQuaternionDefs() @@ -838,7 +838,7 @@ local function dumpShaderCodeToInfolog(defs, src, filename) -- no IO in unsynced Spring.Echo(src) end -local function CompileLuaShader(shader, definitions, plugIns, addName, recompilation) +local function CompileLuaShader(shader, definitions, plugIns, addName, recompilation, stripPrintf) --Spring.Echo(" CompileLuaShader",shader, definitions, plugIns, addName) if definitions == nil or definitions == {} then Spring.Echo(addName, "nul definitions", definitions) @@ -857,9 +857,6 @@ local function CompileLuaShader(shader, definitions, plugIns, addName, recompila -- First the default default defs shader.definitions = table.concat(definitions, "\n") .. "\n" - -- Then the engineUniformBufferDefs (see LuaShader.lua) - shader.definitions = shader.definitions .. engineUniformBufferDefs - --// insert small pieces of code named `plugins` --// this way we can use a basic shader and add some simple vertex animations etc. do @@ -884,9 +881,22 @@ local function CompileLuaShader(shader, definitions, plugIns, addName, recompila end end - local luaShader = LuaShader(shader, "CUS_" .. addName) - local compilationResult = luaShader:Initialize() - if compilationResult ~= true then + local function CompleteSource(source) + return source and (shader.definitions .. source) + end + + local luaShader = LuaShader.CheckShaderUpdates({ + vsSrc = CompleteSource(shader.vertex), + fsSrc = CompleteSource(shader.fragment), + gsSrc = CompleteSource(shader.geometry), + shaderConfig = { stripPrintf = stripPrintf }, + shaderName = "CUS_" .. addName, + uniformInt = shader.uniformInt, + uniformFloat = shader.uniformFloat, + forceupdate = true, + silent = true, + }, 0) + if not luaShader then Spring.Echo("Custom Unit Shaders. " .. addName .. " shader compilation failed") --dumpShaderCodeToInfolog(shader.definitions, shader.vertex, "vs" .. addName) --dumpShaderCodeToInfolog(shader.definitions, shader.fragment, "fs" .. addName) @@ -896,7 +906,8 @@ local function CompileLuaShader(shader, definitions, plugIns, addName, recompila return nil end - return (compilationResult and luaShader) or nil + luaShader.ignoreUnkUniform = false + return luaShader end -- {shaderName : {textureUnit : true}}: the texture units the shadow pass has to bind for a @@ -912,28 +923,32 @@ local function compileMaterialShader(template, name, recompilation) template.shaderDefinitions, template.shaderPlugins, name .. "_forward", - recompilation + recompilation, + printfPass ~= "forward" or printfMaterial ~= name ) local shadowShader = CompileLuaShader( template.shadow, template.shadowDefinitions, template.shaderPlugins, name .. "_shadow", - recompilation + recompilation, + printfPass ~= "shadow" or printfMaterial ~= name ) local deferredShader = CompileLuaShader( template.deferred, template.deferredDefinitions, template.shaderPlugins, name .. "_deferred", - recompilation + recompilation, + printfPass ~= "deferred" or printfMaterial ~= name ) local reflectionShader = CompileLuaShader( template.reflection, template.reflectionDefinitions, template.shaderPlugins, name .. "_reflection", - recompilation + recompilation, + printfPass ~= "reflection" or printfMaterial ~= name ) if recompilation then if (not forwardShader) or not shadowShader or not deferredShader or not reflectionShader then @@ -3204,3 +3219,19 @@ function gadget:DrawShadowUnitsLua() local batches, units = ExecuteDrawPass(16) tracy.ZoneEnd() end + +if autoReload.enabled then + function gadget:DrawScreen() + --Spring.Echo("DrawScreen Called") + local yoffset = 0 + for drawflag, drawpass in pairs(shaders) do + for binname, shader in pairs(drawpass) do + --Spring.Echo("DrawScreen:", drawflag, binname, "has drawprintf", shader.DrawPrintf ~= nil) + if shader.DrawPrintf then + shader.DrawPrintf(0, yoffset) + yoffset = yoffset + 24 + end + end + end + end +end \ No newline at end of file diff --git a/modelmaterials_gl4/templates/cus_gl4.frag.glsl b/modelmaterials_gl4/templates/cus_gl4.frag.glsl index 9bf05ddb1dd..188a0a7f276 100644 --- a/modelmaterials_gl4/templates/cus_gl4.frag.glsl +++ b/modelmaterials_gl4/templates/cus_gl4.frag.glsl @@ -1,6 +1,8 @@ // This shader is Copyright (c) 2025 Beherith (mysterme@gmail.com) and licensed under the MIT License //shader version is added via gadget +//__ENGINEUNIFORMBUFFERDEFS__ + #if (RENDERING_MODE == 2) //shadows pass. AMD requests that extensions are declared right on top of the shader #if (SUPPORT_DEPTH_LAYOUT == 1) //#extension GL_ARB_conservative_depth : enable // this is commented out because AMD wants me to add it at start of shader, hope this works... diff --git a/modelmaterials_gl4/templates/cus_gl4.vert.glsl b/modelmaterials_gl4/templates/cus_gl4.vert.glsl index d3872e5efaf..0bc8265276a 100644 --- a/modelmaterials_gl4/templates/cus_gl4.vert.glsl +++ b/modelmaterials_gl4/templates/cus_gl4.vert.glsl @@ -1,6 +1,8 @@ // This shader is Copyright (c) 2025 Beherith (mysterme@gmail.com) and licensed under the MIT License //shader version is added via widget +//__ENGINEUNIFORMBUFFERDEFS__ + layout (location = 0) in vec3 pos; layout (location = 1) in vec3 normal; layout (location = 2) in vec3 T; diff --git a/modules/graphics/LuaShader.lua b/modules/graphics/LuaShader.lua index 4fe28455342..4a561998ed5 100644 --- a/modules/graphics/LuaShader.lua +++ b/modules/graphics/LuaShader.lua @@ -603,6 +603,11 @@ local function CheckShaderUpdates(shadersourcecache, delaytime) --Spring.Echo(i,line) local glslvariable = line:match(printfpattern) if glslvariable then + -- shaderconfig.stripPrintf is convenience for reused megashaders (e.g. CUS_GL4) to only printf from one draw pass or bin + if shadersourcecache.shaderConfig.stripPrintf then + Spring.Echo("Stripping printf from fragment shader line", i) + fsSrcNewLines[i] = "" + else --Spring.Echo("printf in fragment shader",i, glslvariable, line) -- init our printf table @@ -640,6 +645,7 @@ local function CheckShaderUpdates(shadersourcecache, delaytime) ) Spring.Echo(string.format("Replacing f:%d %s", i, line)) fsSrcNewLines[i] = replacementstring + end end end @@ -699,6 +705,9 @@ local function CheckShaderUpdates(shadersourcecache, delaytime) fsSrcNew = table.concat(fsSrcNewLines, "\n") --Spring.Echo(fsSrcNew) end + + if shadersourcecache.shaderConfig.stripPrintf then fsSrcNew = table.concat(fsSrcNewLines, "\n") end + if vsSrcNew then vsSrcNew = vsSrcNew:gsub("//__ENGINEUNIFORMBUFFERDEFS__", engineUniformBufferDefs) vsSrcNew = vsSrcNew:gsub("//__DEFINES__", shaderDefines)