From 901d8a618203d7deed6e5ea1cb1a3882de89124a Mon Sep 17 00:00:00 2001 From: Mitvit <112669238+Mitvit@users.noreply.github.com> Date: Thu, 17 Sep 2026 19:42:09 +0300 Subject: [PATCH 1/6] Small balance patch: Aircons bp, Mobile jammers (#9218) Nerfed bp aircons, which were changed last month. Made mobile jammers stats more consistent, some were just obviously better than others for no reason. Now the logic is that vehicles are faster, bots are cheaper. Arm is little faster, but costs little more. Includes updated changelog for this + lasers + script improvements of the past weeks --- changelog.md | 8 ++++++++ gamedata/movedefs.lua | 2 +- units/ArmAircraft/armca.lua | 2 +- units/ArmBots/T2/armaser.lua | 2 +- units/ArmVehicles/T2/armjam.lua | 4 ++-- units/CorAircraft/corca.lua | 2 +- units/CorBots/T2/corspec.lua | 6 +++--- units/CorBots/T2/corvoyr.lua | 2 +- units/CorVehicles/T2/coreter.lua | 2 +- units/CorVehicles/T2/corvrad.lua | 4 ++-- units/Legion/Bots/T2 Bots/legajamk.lua | 6 +++--- units/Legion/Constructors/legca.lua | 4 ++-- 12 files changed, 26 insertions(+), 18 deletions(-) diff --git a/changelog.md b/changelog.md index fd57ccd00cb..1b81ae25c66 100644 --- a/changelog.md +++ b/changelog.md @@ -1,4 +1,12 @@ # September +- [Laser weapons] given a tiny AoE to be able to damage small units in shallow water. As a side-effect, they can deal AoE damage to stacked air units. +- [T1 Air Constructors] -10 buildpower +- [Grunt, Pawn, Goblin] Script improvements. No longer fire sideways when switching targets mid-shot or going in and out of range +- [Mobile Jammers] + - Smuggler 48.3 -> 37 speed + - Deceiver, Tiresias: 39 -> 35 speed, 1550 -> 1300 energycost + - Umbra 33 -> 43 speed, 1700 -> 2000 energycost +- [Heavy Ships] -15 -> -8 minimum water depth - Able to pass into same shallow waters as other ships. - [Legion changes] - Perdition doesn't gain extra range from elevation - Martyr damages are added to its team's damage dealt diff --git a/gamedata/movedefs.lua b/gamedata/movedefs.lua index 143b21cee64..8764f181df3 100644 --- a/gamedata/movedefs.lua +++ b/gamedata/movedefs.lua @@ -170,7 +170,7 @@ local moveDatas = { BOAT9 = { crushstrength = CRUSH.HEAVY + 2, footprint = 9, - minwaterdepth = DEPTH.SUBMERGED, + minwaterdepth = DEPTH.MIN_SHALLOW, }, --critter_goldfish coracsub armacsub armserp corrsub armsubk correcl corshark corsub diff --git a/units/ArmAircraft/armca.lua b/units/ArmAircraft/armca.lua index 21ead90dfe6..dc5f7a9bf75 100644 --- a/units/ArmAircraft/armca.lua +++ b/units/ArmAircraft/armca.lua @@ -31,7 +31,7 @@ return { terraformspeed = 225, turninplaceanglelimit = 360, turnrate = 240, - workertime = 60, + workertime = 50, buildoptions = { [1] = "armsolar", [2] = "armadvsol", diff --git a/units/ArmBots/T2/armaser.lua b/units/ArmBots/T2/armaser.lua index 2b9691220f3..e0d8e010e1b 100644 --- a/units/ArmBots/T2/armaser.lua +++ b/units/ArmBots/T2/armaser.lua @@ -30,7 +30,7 @@ return { seismicsignature = 0, selfdestructas = "smallExplosionGenericSelfd", sightdistance = 380, - speed = 48.3, + speed = 37, turninplace = true, turninplaceanglelimit = 90, turninplacespeedlimit = 1.0626, diff --git a/units/ArmVehicles/T2/armjam.lua b/units/ArmVehicles/T2/armjam.lua index 4cebeea2593..6f3f55e1aba 100644 --- a/units/ArmVehicles/T2/armjam.lua +++ b/units/ArmVehicles/T2/armjam.lua @@ -9,7 +9,7 @@ return { collisionvolumescales = "23 28 33", collisionvolumetype = "Box", corpse = "dead", - energycost = 1700, + energycost = 2000, energyupkeep = 80, explodeas = "smallexplosiongeneric", footprintx = 3, @@ -32,7 +32,7 @@ return { seismicsignature = 0, selfdestructas = "smallExplosionGenericSelfd", sightdistance = 330, - speed = 33, + speed = 43, trackoffset = 8, trackstrength = 10, tracktype = "StdTank", diff --git a/units/CorAircraft/corca.lua b/units/CorAircraft/corca.lua index 0b52e38fed0..a05edfa11b2 100644 --- a/units/CorAircraft/corca.lua +++ b/units/CorAircraft/corca.lua @@ -32,7 +32,7 @@ return { terraformspeed = 225, turninplaceanglelimit = 360, turnrate = 240, - workertime = 65, + workertime = 55, buildoptions = { [1] = "corsolar", [2] = "coradvsol", diff --git a/units/CorBots/T2/corspec.lua b/units/CorBots/T2/corspec.lua index 5e7e5ac02fd..e62016a7bef 100644 --- a/units/CorBots/T2/corspec.lua +++ b/units/CorBots/T2/corspec.lua @@ -9,12 +9,12 @@ return { collisionvolumescales = "29 45 29", collisionvolumetype = "Box", corpse = "dead", - energycost = 1550, + energycost = 1300, energyupkeep = 80, explodeas = "smallexplosiongeneric", footprintx = 2, footprintz = 2, - health = 345, + health = 350, maxacc = 0.115, maxdec = 0.414, maxslope = 32, @@ -31,7 +31,7 @@ return { seismicsignature = 0, selfdestructas = "smallExplosionGenericSelfd", sightdistance = 380, - speed = 39, + speed = 35, turninplace = true, turninplaceanglelimit = 90, turninplacespeedlimit = 0.858, diff --git a/units/CorBots/T2/corvoyr.lua b/units/CorBots/T2/corvoyr.lua index 3fe841620b0..93ee8236b90 100644 --- a/units/CorBots/T2/corvoyr.lua +++ b/units/CorBots/T2/corvoyr.lua @@ -28,7 +28,7 @@ return { seismicsignature = 0, selfdestructas = "smallExplosionGenericSelfd", sightdistance = 925, - speed = 45, + speed = 38, turninplace = true, turninplaceanglelimit = 90, turninplacespeedlimit = 0.99, diff --git a/units/CorVehicles/T2/coreter.lua b/units/CorVehicles/T2/coreter.lua index d94d2c93cb5..0252f47fe1e 100644 --- a/units/CorVehicles/T2/coreter.lua +++ b/units/CorVehicles/T2/coreter.lua @@ -30,7 +30,7 @@ return { seismicsignature = 0, selfdestructas = "smallExplosionGenericSelfd", sightdistance = 330, - speed = 41.4, + speed = 41, trackoffset = 3, trackstrength = 6, tracktype = "corwidetracks", diff --git a/units/CorVehicles/T2/corvrad.lua b/units/CorVehicles/T2/corvrad.lua index 36d9ac8b8d1..b487170aeab 100644 --- a/units/CorVehicles/T2/corvrad.lua +++ b/units/CorVehicles/T2/corvrad.lua @@ -19,7 +19,7 @@ return { maxdec = 0.1, maxslope = 16, maxwaterdepth = 0, - metalcost = 92, + metalcost = 100, movementclass = "TANK3", movestate = 0, objectname = "Units/CORVRAD.s3o", @@ -30,7 +30,7 @@ return { selfdestructas = "smallExplosionGenericSelfd", sightdistance = 900, sonardistance = 0, - speed = 48, + speed = 50, trackstrength = 10, tracktype = "corwidetracks", trackwidth = 23, diff --git a/units/Legion/Bots/T2 Bots/legajamk.lua b/units/Legion/Bots/T2 Bots/legajamk.lua index 9cadef180ba..229b291c59a 100644 --- a/units/Legion/Bots/T2 Bots/legajamk.lua +++ b/units/Legion/Bots/T2 Bots/legajamk.lua @@ -9,12 +9,12 @@ return { collisionvolumescales = "29 32 29", collisionvolumetype = "Box", corpse = "DEAD", - energycost = 1550, + energycost = 1300, energyupkeep = 80, explodeas = "smallexplosiongeneric", footprintx = 2, footprintz = 2, - health = 345, + health = 350, maxacc = 0.115, maxdec = 0.414, maxslope = 32, @@ -31,7 +31,7 @@ return { seismicsignature = 0, selfdestructas = "smallExplosionGenericSelfd", sightdistance = 380, - speed = 39, + speed = 35, turninplace = true, turninplaceanglelimit = 90, turninplacespeedlimit = 0.858, diff --git a/units/Legion/Constructors/legca.lua b/units/Legion/Constructors/legca.lua index 1e136154367..4ad0bc31ceb 100644 --- a/units/Legion/Constructors/legca.lua +++ b/units/Legion/Constructors/legca.lua @@ -4,7 +4,7 @@ return { blocking = false, maxdec = 0.4275, energycost = 2200, - metalcost = 105, + metalcost = 100, builddistance = 136, builder = true, buildpic = "LEGCA.DDS", @@ -32,7 +32,7 @@ return { terraformspeed = 225, turninplaceanglelimit = 360, turnrate = 240, - workertime = 55, + workertime = 45, buildoptions = { "legsolar", "legadvsol", From 1e97b9505a97736328102dd141a8d820e59aa533 Mon Sep 17 00:00:00 2001 From: Mitvit <112669238+Mitvit@users.noreply.github.com> Date: Thu, 17 Sep 2026 19:49:31 +0300 Subject: [PATCH 2/6] Fix lasers not hitting small units in water (#9141) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Small units getting submerged in shallow water, and therefore becoming invulnerable to non-AoE weapons (ie Lasers) is currently a problem. Normal units can go to -20 deep water, while a few of them (vehicle scouts, minelayers, light tanks, amphib tanks, t1 arty) have hitboxes less tall than that.   So a solution (not the only solution, but probably the simplest) to fix that is giving lasers a tiny AoE, and resizing some hitboxes by 1-2 elmos. The AoE added to Lasers doesn't matter much for ground combat - lasers typically hit the unit they're targeting right in the middle, and the AoE is smaller than the radius of any unit -> you won't ever hit multiple units with single attack except in fringe cases like shooting at radar dots, or a long beamtime weapon like Pulsar easier hitting multiple units. A notable change is that Lasers will then deal damage to stacked air units. But this should be fine too, as all other weapons already do so. --- gamedata/alldefs_post.lua | 6 ++++++ units/ArmBots/T2/armspid.lua | 2 +- units/ArmBots/T2/armsptk.lua | 2 +- units/ArmVehicles/T2/armgremlin.lua | 4 ++-- units/ArmVehicles/armfav.lua | 4 ++-- units/ArmVehicles/armflash.lua | 4 ++-- units/ArmVehicles/armmlv.lua | 2 +- units/ArmVehicles/armpincer.lua | 4 ++-- units/ArmVehicles/armstump.lua | 2 +- units/CorVehicles/T2/corseal.lua | 2 +- units/CorVehicles/T2/corvrad.lua | 2 +- units/CorVehicles/corfav.lua | 2 +- units/CorVehicles/corgarp.lua | 4 ++-- units/CorVehicles/corgator.lua | 2 +- units/CorVehicles/cormlv.lua | 2 +- units/CorVehicles/corraid.lua | 4 ++-- units/Legion/Vehicles/leghades.lua | 4 ++-- units/Legion/Vehicles/leghelios.lua | 4 ++-- units/Legion/Vehicles/legscout.lua | 2 +- 19 files changed, 32 insertions(+), 26 deletions(-) diff --git a/gamedata/alldefs_post.lua b/gamedata/alldefs_post.lua index b47ef0df204..8f567e56e26 100644 --- a/gamedata/alldefs_post.lua +++ b/gamedata/alldefs_post.lua @@ -1067,6 +1067,12 @@ local function weaponDef_Post(name, wDef) end end + if wDef.weapontype == "BeamLaser" and wDef.impactonly == 1 then + wDef.impactonly = nil + wDef.areaofeffect = 11 + wDef.edgeeffectiveness = 1 + end + -- Remove water splashes on lava maps if modOptions.map_waterislava and wDef.weapontype == "TorpedoLauncher" then wDef.explosiongenerator = "custom:blank" diff --git a/units/ArmBots/T2/armspid.lua b/units/ArmBots/T2/armspid.lua index 8507503fb0e..0d542c0c8be 100644 --- a/units/ArmBots/T2/armspid.lua +++ b/units/ArmBots/T2/armspid.lua @@ -9,7 +9,7 @@ return { canmove = true, canrepair = false, canrestore = false, - collisionvolumeoffsets = "0 0 0", + collisionvolumeoffsets = "0 4 0", collisionvolumescales = "28 15 28", collisionvolumetype = "box", corpse = "DEAD", diff --git a/units/ArmBots/T2/armsptk.lua b/units/ArmBots/T2/armsptk.lua index bc9c3d7e408..ba36aa5e25e 100644 --- a/units/ArmBots/T2/armsptk.lua +++ b/units/ArmBots/T2/armsptk.lua @@ -3,7 +3,7 @@ return { buildpic = "ARMSPTK.DDS", buildtime = 11500, canmove = true, - collisionvolumeoffsets = "0 -2 0", + collisionvolumeoffsets = "0 0 0", collisionvolumescales = "42 28 42", collisionvolumetype = "CylY", corpse = "DEAD", diff --git a/units/ArmVehicles/T2/armgremlin.lua b/units/ArmVehicles/T2/armgremlin.lua index 91073ade6fc..cb4799d9838 100644 --- a/units/ArmVehicles/T2/armgremlin.lua +++ b/units/ArmVehicles/T2/armgremlin.lua @@ -5,8 +5,8 @@ return { canmove = true, cloakcost = 5, cloakcostmoving = 20, - collisionvolumeoffsets = "0 1 0", - collisionvolumescales = "24 13 39", + collisionvolumeoffsets = "0 4 0", + collisionvolumescales = "24 15 39", collisionvolumetype = "Box", corpse = "DEAD", energycost = 3700, diff --git a/units/ArmVehicles/armfav.lua b/units/ArmVehicles/armfav.lua index 4005ce2b3e6..39465b285fb 100644 --- a/units/ArmVehicles/armfav.lua +++ b/units/ArmVehicles/armfav.lua @@ -4,8 +4,8 @@ return { buildtime = 1100, canmove = true, category = "GROUNDSCOUT", - collisionvolumeoffsets = "0 0 0", - collisionvolumescales = "21 15 26", + collisionvolumeoffsets = "0 1 0", + collisionvolumescales = "21 16 26", collisionvolumetype = "Box", corpse = "DEAD", energycost = 370, diff --git a/units/ArmVehicles/armflash.lua b/units/ArmVehicles/armflash.lua index f183a0948a6..adc66da22d1 100644 --- a/units/ArmVehicles/armflash.lua +++ b/units/ArmVehicles/armflash.lua @@ -3,8 +3,8 @@ return { buildpic = "ARMFLASH.DDS", buildtime = 2000, canmove = true, - collisionvolumeoffsets = "0 -3 0", - collisionvolumescales = "25 25 31", + collisionvolumeoffsets = "0 -1 0", + collisionvolumescales = "25 26 31", collisionvolumetype = "Ellipsoid", corpse = "DEAD", energycost = 900, diff --git a/units/ArmVehicles/armmlv.lua b/units/ArmVehicles/armmlv.lua index 7b461ae22cb..a39571a6e4c 100644 --- a/units/ArmVehicles/armmlv.lua +++ b/units/ArmVehicles/armmlv.lua @@ -9,7 +9,7 @@ return { canreclaim = false, canrepair = true, canrestore = false, - collisionvolumeoffsets = "0 -1 0", + collisionvolumeoffsets = "0 0 0", collisionvolumescales = "22 28 35", collisionvolumetype = "Box", corpse = "DEAD", diff --git a/units/ArmVehicles/armpincer.lua b/units/ArmVehicles/armpincer.lua index bd2e5e0c755..1e11a7265d7 100644 --- a/units/ArmVehicles/armpincer.lua +++ b/units/ArmVehicles/armpincer.lua @@ -4,8 +4,8 @@ return { buildpic = "ARMPINCER.DDS", buildtime = 2610, canmove = true, - collisionvolumeoffsets = "0 0 1", - collisionvolumescales = "31 13 31", + collisionvolumeoffsets = "0 3 1", + collisionvolumescales = "31 18 31", collisionvolumetype = "Box", corpse = "DEAD", energycost = 2000, diff --git a/units/ArmVehicles/armstump.lua b/units/ArmVehicles/armstump.lua index c32612224ba..3afd992e2d2 100644 --- a/units/ArmVehicles/armstump.lua +++ b/units/ArmVehicles/armstump.lua @@ -3,7 +3,7 @@ return { buildpic = "ARMSTUMP.DDS", buildtime = 3400, canmove = true, - collisionvolumeoffsets = "0 0 0", + collisionvolumeoffsets = "0 1 0", collisionvolumescales = "34 18 40", collisionvolumetype = "Box", corpse = "DEAD", diff --git a/units/CorVehicles/T2/corseal.lua b/units/CorVehicles/T2/corseal.lua index 306a2305d16..e0a0e02f5ea 100644 --- a/units/CorVehicles/T2/corseal.lua +++ b/units/CorVehicles/T2/corseal.lua @@ -4,7 +4,7 @@ return { buildpic = "CORSEAL.DDS", buildtime = 16000, canmove = true, - collisionvolumeoffsets = "0 -7 0", + collisionvolumeoffsets = "0 -4 0", collisionvolumescales = "31 31 31", collisionvolumetype = "BOX", corpse = "DEAD", diff --git a/units/CorVehicles/T2/corvrad.lua b/units/CorVehicles/T2/corvrad.lua index b487170aeab..cf0b16c4388 100644 --- a/units/CorVehicles/T2/corvrad.lua +++ b/units/CorVehicles/T2/corvrad.lua @@ -5,7 +5,7 @@ return { buildtime = 5000, canattack = false, canmove = true, - collisionvolumeoffsets = "0 0 0", + collisionvolumeoffsets = "0 1 0", collisionvolumescales = "24 18 29", collisionvolumetype = "box", corpse = "dead", diff --git a/units/CorVehicles/corfav.lua b/units/CorVehicles/corfav.lua index 41bef3afdcd..745489bdd1e 100644 --- a/units/CorVehicles/corfav.lua +++ b/units/CorVehicles/corfav.lua @@ -4,7 +4,7 @@ return { buildtime = 1150, canmove = true, category = "GROUNDSCOUT", - collisionvolumeoffsets = "0 0 0", + collisionvolumeoffsets = "0 1 0", collisionvolumescales = "16 16 26", collisionvolumetype = "Box", corpse = "DEAD", diff --git a/units/CorVehicles/corgarp.lua b/units/CorVehicles/corgarp.lua index 806712e6671..e7094c79bcb 100644 --- a/units/CorVehicles/corgarp.lua +++ b/units/CorVehicles/corgarp.lua @@ -4,8 +4,8 @@ return { buildpic = "CORGARP.DDS", buildtime = 3100, canmove = true, - collisionvolumeoffsets = "0 -3 0", - collisionvolumescales = "30 21 36", + collisionvolumeoffsets = "0 1 0", + collisionvolumescales = "30 23 36", collisionvolumetype = "Box", corpse = "DEAD", energycost = 2600, diff --git a/units/CorVehicles/corgator.lua b/units/CorVehicles/corgator.lua index d53fda185af..6f1497364e1 100644 --- a/units/CorVehicles/corgator.lua +++ b/units/CorVehicles/corgator.lua @@ -4,7 +4,7 @@ return { buildtime = 2300, canmove = true, collisionvolumeoffsets = "0 -1 1", - collisionvolumescales = "30 15 38", + collisionvolumescales = "23 24 33", collisionvolumetype = "Box", corpse = "DEAD", energycost = 1100, diff --git a/units/CorVehicles/cormlv.lua b/units/CorVehicles/cormlv.lua index 870e51f256c..8149bedc9c2 100644 --- a/units/CorVehicles/cormlv.lua +++ b/units/CorVehicles/cormlv.lua @@ -9,7 +9,7 @@ return { canreclaim = false, canrepair = true, canrestore = false, - collisionvolumeoffsets = "0 0 0", + collisionvolumeoffsets = "0 1 0", collisionvolumescales = "30 17 45", collisionvolumetype = "Box", corpse = "DEAD", diff --git a/units/CorVehicles/corraid.lua b/units/CorVehicles/corraid.lua index 4b40096bde1..561e6e13214 100644 --- a/units/CorVehicles/corraid.lua +++ b/units/CorVehicles/corraid.lua @@ -3,8 +3,8 @@ return { buildpic = "CORRAID.DDS", buildtime = 3900, canmove = true, - collisionvolumeoffsets = "0 -1 0", - collisionvolumescales = "32 16 32", + collisionvolumeoffsets = "0 6 0", + collisionvolumescales = "32 24 32", collisionvolumetype = "Box", corpse = "DEAD", energycost = 2400, diff --git a/units/Legion/Vehicles/leghades.lua b/units/Legion/Vehicles/leghades.lua index 8f50cf18039..060cdad2798 100644 --- a/units/Legion/Vehicles/leghades.lua +++ b/units/Legion/Vehicles/leghades.lua @@ -7,8 +7,8 @@ return { buildpic = "LEGHADES.DDS", buildtime = 1650, canmove = true, - collisionvolumeoffsets = "0 -1 0", - collisionvolumescales = "16 10 23", + collisionvolumeoffsets = "0 1 0", + collisionvolumescales = "16 15 23", collisionvolumetype = "Box", corpse = "DEAD", explodeas = "smallExplosionGeneric", diff --git a/units/Legion/Vehicles/leghelios.lua b/units/Legion/Vehicles/leghelios.lua index 805373f10c6..ff58782d6fc 100644 --- a/units/Legion/Vehicles/leghelios.lua +++ b/units/Legion/Vehicles/leghelios.lua @@ -7,8 +7,8 @@ return { buildpic = "LEGHELIOS.DDS", buildtime = 2600, canmove = true, - collisionvolumeoffsets = "0 -4 1", - collisionvolumescales = "30 12 28", + collisionvolumeoffsets = "0 -3 1", + collisionvolumescales = "30 16 28", collisionvolumetype = "Box", usepiececollisionvolumes = 1, corpse = "DEAD", diff --git a/units/Legion/Vehicles/legscout.lua b/units/Legion/Vehicles/legscout.lua index e2c07891317..e49fc2114c6 100644 --- a/units/Legion/Vehicles/legscout.lua +++ b/units/Legion/Vehicles/legscout.lua @@ -8,7 +8,7 @@ return { buildtime = 900, canmove = true, category = "GROUNDSCOUT", - collisionvolumeoffsets = "0 -4 0", + collisionvolumeoffsets = "0 -3 0", collisionvolumescales = "13 17 17", collisionvolumetype = "CylX", corpse = "DEAD", From 99707b228660a1ce017ee0845c336c6d79096489 Mon Sep 17 00:00:00 2001 From: Floris Date: Thu, 17 Sep 2026 20:09:51 +0200 Subject: [PATCH 3/6] teamstats: refinements (#9276) --- language/en/interface.json | 33 ++- luaui/Include/graph.lua | 203 +++++++++++----- luaui/Include/teamstats_graphs.lua | 365 +++++++++++++++++++++-------- luaui/Widgets/gui_teamstats.lua | 44 +++- 4 files changed, 483 insertions(+), 162 deletions(-) diff --git a/language/en/interface.json b/language/en/interface.json index 5d542394d25..f5bdc8af923 100644 --- a/language/en/interface.json +++ b/language/en/interface.json @@ -393,10 +393,33 @@ "composition": "Composition", "compositionDesc": "The team's unit value split by kind over the game, as shares of the whole.", "foldHint": "Click to fold this team's players away, and again to show them.", - "legendHint": "Click to highlight this team and show its milestones; right-click to hide it.", - "blockHint": "The players of this ally team; each square is one of them.", "all": "All", - "allHint": "Every team alike, with all their milestones on the chart.", + "allHint": "Clears the selection and shows hidden teams again, so every team is drawn alike.", + "allMilestones": "Every team's milestones go on the chart.", + "teams": "%{count} teams", + "players": "%{count} players", + "state": { + "hidden": "Hidden: left off the chart.", + "lit": "Selected: drawn in front at full strength, the others faded.", + "only": "Selected: on the chart.", + "counted": "Selected: counted in the composition.", + "some": "Some of its players are selected.", + "faded": "Not selected: faded behind the selected.", + "left": "Not selected: left off while others are selected.", + "notCounted": "Not selected: left out of the composition.", + "alike": "Nothing is selected: every team is drawn alike.", + "allCounted": "Nothing is selected: every team is counted in the composition.", + "milestones": "Its milestones are on the chart.", + "milestonesGroup": "Their milestones are on the chart." + }, + "control": { + "click": "Click: select or deselect", + "clickGroup": "Click: select or deselect all its players", + "ctrlClick": "Ctrl+click: select only this one", + "ctrlClickGroup": "Ctrl+click: select only its players", + "rightClick": "Right-click: hide or show", + "rightClickGroup": "Right-click: hide or show all its players" + }, "waiting": "The first samples arrive after 15 seconds.", "noData": "Nothing to plot for this stat yet." }, @@ -430,7 +453,9 @@ "bars": "Bars", "barsDesc": "A bar behind every number, scaled to the largest in its column.", "milestones": "Milestones", - "milestonesDesc": "The highlighted team's milestones as unit pictures on the chart, every team's when none is highlighted; hover one for what it was." + "milestonesDesc": "Milestones as unit pictures on the chart, framed in their player's colour: the selected teams', every team's when none is selected. Hover one for whose it was and what.", + "selectedOnly": "Selected only", + "selectedOnlyDesc": "Only the teams selected in the bar above the chart are drawn. Off, every team is drawn and the selected ones stand out in front." }, "damage": "Damage", "damageDealt": "Dealt", diff --git a/luaui/Include/graph.lua b/luaui/Include/graph.lua index d6ef0219666..480fd6ce868 100644 --- a/luaui/Include/graph.lua +++ b/luaui/Include/graph.lua @@ -27,7 +27,8 @@ -- -- Everything in the constructor can be changed later through chart:configure({ ... }), -- chart:setSeries(list), chart:setMarkers(list), chart:setBounds(x, y, w, h) and --- chart:setHighlight(seriesIndex); each marks the picture for a rebuild on the next draw. +-- chart:setHighlight(seriesIndex, or { [seriesIndex] = true, ... } for several); each +-- marks the picture for a rebuild on the next draw. -- chart:destroy() frees the list. Radar charts take `radar = { axes = { { key = "speed", -- label = "Speed", max = 100 }, ... }, rings = 4 }` and series with `values` keyed by axis -- (an array in axis order, or a table by axis key). A stacked chart turns every sample @@ -37,7 +38,8 @@ ---@field cfg table ---@field series table[] ---@field markers table[] ----@field highlight integer? +---@field highlight integer|table|nil +---@field highlightKey any ---@field hover table? ---@field list integer? ---@field dirty boolean @@ -72,7 +74,7 @@ local glBeginEnd = gl.BeginEnd local glVertex = gl.Vertex local glColor = gl.Color local glTexture = gl.Texture -local glTexRect = gl.TexRect +local glTexCoord = gl.TexCoord local glLineWidth = gl.LineWidth -- Anti-aliased lines; absent in an offline stub. ---@type function? @@ -139,9 +141,11 @@ local DEFAULTS = { legend = true, -- Markers: pictures with a hover text, at an x, on a series or in a lane above the plot. -- The picture is zoomed in by this share of its edges (nil: a subtle share that grows - -- as the picture shrinks), and framed this thick. + -- as the picture shrinks), its corners cut off by this much (nil: a small cut that + -- grows with the picture, like the unit pictures elsewhere), and framed this thick. markerSize = nil, markerZoom = nil, + markerCorner = nil, markerFrameWidth = 2, radar = { rings = 4, @@ -372,10 +376,28 @@ function Graph:setBounds(x, y, width, height) end end --- The series drawn in front, the others stepped back. nil for none. -function Graph:setHighlight(index) - if self.highlight ~= index then - self.highlight = index +-- A highlight as a value that compares equal for the same series, set or not. +local function highlightKey(highlight) + if type(highlight) ~= "table" then + return highlight + end + local keys = {} + for si, on in pairs(highlight) do + if on then + keys[#keys + 1] = si + end + end + table.sort(keys) + return "set:" .. table.concat(keys, ",") +end + +-- The series drawn in front, the others stepped back: one index, or a set of them +-- ({ [index] = true }). nil for none. +function Graph:setHighlight(highlight) + local key = highlightKey(highlight) + if self.highlightKey ~= key then + self.highlight = highlight + self.highlightKey = key self.dirty = true end end @@ -511,6 +533,7 @@ function Graph:prepareSamples() end end prepared[si] = { + index = si, source = s, name = s.name or ("Series " .. si), color = s.color or { 0.8, 0.8, 0.8 }, @@ -752,11 +775,42 @@ end -- Drawing a line or stacked chart into the list ---------------------------------------------------------------- +-- Whether a series is drawn at full strength: every one while nothing is highlighted. +local function isLit(self, si) + local highlight = self.highlight + if highlight == nil then + return true + elseif type(highlight) == "table" then + return highlight[si] == true + end + return highlight == si +end + +-- Whether a series is lifted above the rest: lit while something is highlighted. +local function isLifted(self, si) + return self.highlight ~= nil and isLit(self, si) +end + local function alphaOf(self, si, base) - if self.highlight and self.highlight ~= si then - return base * self.cfg.look.dimAlpha + if isLit(self, si) then + return base end - return base + return base * self.cfg.look.dimAlpha +end + +-- The series in drawing order: the stepped back ones first, so the highlighted ones lie +-- on top of them. Each one knows its own index (`index`, set when it was prepared), which +-- is what the alpha and the width are read off. +local function drawOrder(self) + local order, lit = {}, {} + for si, p in ipairs(self.prepared) do + local list = isLit(self, si) and lit or order + list[#list + 1] = p + end + for _, p in ipairs(lit) do + order[#order + 1] = p + end + return order end -- The points of a curve in pixels: the samples, or the smoothed run through them. A gap @@ -895,11 +949,12 @@ function Graph:drawLines() local sx, sy = self.sx, self.sy local smoothAll = cfg.smooth + local order = drawOrder(self) -- Fills first, so every line lies on top of every fill. if cfg.fill then - for si, p in ipairs(self.prepared) do + for _, p in ipairs(order) do local c = p.color - local a = alphaOf(self, si, look.fillAlpha) + local a = alphaOf(self, p.index, look.fillAlpha) glColor(c[1], c[2], c[3], a) local smooth = p.smooth if smooth == nil then @@ -920,11 +975,11 @@ function Graph:drawLines() if glSmoothing then glSmoothing(false, true, false) end - for si, p in ipairs(self.prepared) do + for _, p in ipairs(order) do local c = p.color - local a = alphaOf(self, si, 1) + local a = alphaOf(self, p.index, 1) local width = p.width - if self.highlight == si then + if isLifted(self, p.index) then width = width + 1 end glLineWidth(width) @@ -1080,62 +1135,100 @@ function Graph:drawStacked() end end +-- The eight corners of a rect with its corners cut off by `cut`, counter-clockwise from +-- the bottom edge, as x, y pairs. +local function chamfered(x1, y1, x2, y2, cut) + return { + x1 + cut, + y1, + x2 - cut, + y1, + x2, + y1 + cut, + x2, + y2 - cut, + x2 - cut, + y2, + x1 + cut, + y2, + x1, + y2 - cut, + x1, + y1 + cut, + } +end + +-- A band `w` wide along the inside of a cut-corner rect's edge. The diagonal sides stay +-- as thick as the straight ones: moved in by w along its normal, a diagonal edge cuts +-- each axis w * (2 - sqrt 2) less. +local function chamferRing(x1, y1, x2, y2, cut, w) + local inner = mathMax(0, cut - w * 0.5857864376) + local o = chamfered(x1, y1, x2, y2, cut) + local i = chamfered(x1 + w, y1 + w, x2 - w, y2 - w, inner) + glBeginEnd(GL_QUADS, function() + for k = 1, 8 do + local a = k * 2 - 1 + local b = (k % 8) * 2 + 1 + glVertex(o[a], o[a + 1]) + glVertex(o[b], o[b + 1]) + glVertex(i[b], i[b + 1]) + glVertex(i[a], i[a + 1]) + end + end) +end + +-- How much of each corner a marker picture this many pixels wide loses. +function Graph:markerCut(width) + local cut = self.cfg.markerCorner or mathMax(2, mathFloor(width * 0.08)) + return mathMin(cut, mathFloor(width * 0.5)) +end + function Graph:drawMarkers() local look = self.cfg.look for _, m in ipairs(self.placed) do local marker = m.marker + local cut = self:markerCut(m.x2 - m.x1) -- A tick from the picture down to the plot, or to the point it sits on. glColor(look.markerTick) glBeginEnd(GL_LINES, function() glVertex(m.cx + 0.5, m.y1) glVertex(m.px + 0.5, m.onSeries and m.py or self.area.bottom) end) + local corners = chamfered(m.x1, m.y1, m.x2, m.y2, cut) if marker.texture then -- Zoomed in a little, the more the smaller the picture: a unit picture has -- air around the unit. The engine's textures load flipped, so t runs from 1 - -- down to 0, as the plain call does on its own. + -- down to 0 going up. local z = marker.zoom or self.cfg.markerZoom or mathMin(0.06, 2.5 / mathMax(1, m.x2 - m.x1)) + local w, h = mathMax(1, m.x2 - m.x1), mathMax(1, m.y2 - m.y1) glColor(1, 1, 1, 1) glTexture(marker.texture) - glTexRect(m.x1, m.y1, m.x2, m.y2, z, 1 - z, 1 - z, z) + glBeginEnd(GL_TRIANGLE_FAN, function() + for k = 1, 16, 2 do + local x, y = corners[k], corners[k + 1] + glTexCoord(z + (x - m.x1) / w * (1 - 2 * z), 1 - z - (y - m.y1) / h * (1 - 2 * z)) + glVertex(x, y) + end + end) glTexture(false) else local c = marker.color or { 1, 1, 1 } glColor(c[1], c[2], c[3], 0.9) - glBeginEnd(GL_QUADS, function() - glVertex(m.x1, m.y1) - glVertex(m.x2, m.y1) - glVertex(m.x2, m.y2) - glVertex(m.x1, m.y2) + glBeginEnd(GL_TRIANGLE_FAN, function() + for k = 1, 16, 2 do + glVertex(corners[k], corners[k + 1]) + end end) end - -- The frame, in the marker's own colour when it has one (a team's), as four - -- whole-pixel bars so it is the same thickness all round. + -- The frame, in the marker's own colour when it has one (a team's), following the + -- cut corners. local frame = marker.frame - local w = self.cfg.markerFrameWidth if frame then glColor(frame[1], frame[2], frame[3], frame[4] or 1) else glColor(look.markerFrame) end - glBeginEnd(GL_QUADS, function() - glVertex(m.x1, m.y1) - glVertex(m.x2, m.y1) - glVertex(m.x2, m.y1 + w) - glVertex(m.x1, m.y1 + w) - glVertex(m.x1, m.y2 - w) - glVertex(m.x2, m.y2 - w) - glVertex(m.x2, m.y2) - glVertex(m.x1, m.y2) - glVertex(m.x1, m.y1 + w) - glVertex(m.x1 + w, m.y1 + w) - glVertex(m.x1 + w, m.y2 - w) - glVertex(m.x1, m.y2 - w) - glVertex(m.x2 - w, m.y1 + w) - glVertex(m.x2, m.y1 + w) - glVertex(m.x2, m.y2 - w) - glVertex(m.x2 - w, m.y2 - w) - end) + chamferRing(m.x1, m.y1, m.x2, m.y2, cut, self.cfg.markerFrameWidth) end end @@ -1172,7 +1265,7 @@ function Graph:drawLegend() glVertex(x + swatch, y + swatch) glVertex(x, y + swatch) end) - local color = self.highlight and self.highlight ~= si and "\255\130\130\130" or look.text + local color = isLit(self, si) and look.text or "\255\130\130\130" self:text(color .. p.name, mathFloor(x + swatch + fs * 0.4), mathFloor(y + swatch * 0.15), "o", fs) x = mathFloor(x + swatch + fs * 0.4 + w + fs * 1.2) end @@ -1264,14 +1357,14 @@ function Graph:drawRadar() end) -- Series: a filled polygon and its outline. - for si, p in ipairs(self.prepared) do + for _, p in ipairs(drawOrder(self)) do local c = p.color local shares = {} for ai = 1, n do shares[ai] = mathMin(1, mathMax(0, p.axisValues[ai] / self.axisMax(ai))) end if cfg.radar.fill then - glColor(c[1], c[2], c[3], alphaOf(self, si, look.radarFillAlpha)) + glColor(c[1], c[2], c[3], alphaOf(self, p.index, look.radarFillAlpha)) glBeginEnd(GL_TRIANGLE_FAN, function() glVertex(r.cx, r.cy) for ai = 1, n do @@ -1280,8 +1373,8 @@ function Graph:drawRadar() glVertex(axisPoint(1, shares[1])) end) end - glLineWidth(self.highlight == si and p.width + 1 or p.width) - glColor(c[1], c[2], c[3], alphaOf(self, si, 1)) + glLineWidth(isLifted(self, p.index) and p.width + 1 or p.width) + glColor(c[1], c[2], c[3], alphaOf(self, p.index, 1)) glBeginEnd(GL_LINE_LOOP, function() for ai = 1, n do glVertex(axisPoint(ai, shares[ai])) @@ -1418,15 +1511,11 @@ function Graph:drawOverlay() local look = self.cfg.look if hit.kind == "marker" then local m = hit.placed - glLineWidth(2) + -- Around the picture's own frame, its corners cut to match: a cut grown by w + -- keeps the diagonal w away. + local w = 2 glColor(look.markerHover) - glBeginEnd(GL_LINE_LOOP, function() - glVertex(m.x1 - 1, m.y1 - 1) - glVertex(m.x2 + 1, m.y1 - 1) - glVertex(m.x2 + 1, m.y2 + 1) - glVertex(m.x1 - 1, m.y2 + 1) - end) - glLineWidth(1) + chamferRing(m.x1 - w, m.y1 - w, m.x2 + w, m.y2 + w, self:markerCut(m.x2 - m.x1) + w * 0.5857864376, w) glColor(1, 1, 1, 1) return end diff --git a/luaui/Include/teamstats_graphs.lua b/luaui/Include/teamstats_graphs.lua index 9bcdf65a558..70af79f4687 100644 --- a/luaui/Include/teamstats_graphs.lua +++ b/luaui/Include/teamstats_graphs.lua @@ -1,6 +1,6 @@ -- The Graphs page of the team stats panel: one chart of the picked stat over the game, -- the teams as its series, with a stat list beside it and a legend bar above it that --- highlights or hides a team. The panel owns the frame, the sidebar and the switches +-- selects or hides teams. The panel owns the frame, the sidebar and the switches -- and hands this page the room between them; the page keeps the histories it plots (the -- engine's own team statistics and the team stats gadget's, through WG.teamStats) and -- rebuilds the chart when either grows or the pick changes. @@ -58,13 +58,19 @@ function M.new(ctx) local page = { open = false, stat = "damageDealt", + -- Units right-clicked off the chart, by key. ---@type table hidden = {}, - -- The highlighted unit's key; nil is every team alike. Starts on the viewer's own - -- team, when they have one. - ---@type string? - highlight = nil, - highlightSet = false, + -- The units picked in the legend bar, by key; none is every team alike. With the + -- Selected only switch they are the chart, without it they stand out on it. Starts + -- on the viewer's own team, when they have one. + ---@type table + selected = {}, + selectionSet = false, + -- The page's own grouping, kept apart from the table's: a chart of ally teams and + -- a table of ally teams are different questions, and picking a single player on the + -- chart should not flatten the table. + grouped = true, hover = { stat = 0, legend = 0, block = 0 }, -- Per team: the engine's history entries taken at the period, derived, and how -- many of the engine's list they are; the live newest entry is left out so the @@ -98,7 +104,7 @@ function M.new(ctx) barItems = {}, barLabels = true, -- Bumped whenever what the panel bakes for the page changes: the pick, the - -- highlight, the hidden set, the bar's layout. Part of the panel's bake signature. + -- selection, the hidden set, the bar's layout. Part of the panel's bake signature. gen = 0, ---@type table? rects = nil, @@ -119,7 +125,17 @@ function M.new(ctx) page.chart = chart local function grouped() - return ctx.filters.groupByTeam and not ctx.isFFA + return page.grouped and not ctx.isFFA + end + + -- The Group by team switch while the page is open, from the panel. + function page.setGrouped(state) + if page.grouped ~= state then + page.grouped = state + page.dirty = true + page.gen = page.gen + 1 + page.rebuildUnits() + end end ---------------------------------------------------------------- @@ -162,17 +178,19 @@ function M.new(ctx) local layoutBar -- The room the panel hands over, bottom-left to top-right, and the scale: the stat - -- list down the left, the legend bar along the top, the chart in the rest. - function page.setLayout(x1, y1, x2, y2, s) + -- list down the left, the legend bar along the top, the chart in the rest. The list's + -- card spans listY1..listY2 when given, so it lines up with the sidebar's beside it. + function page.setLayout(x1, y1, x2, y2, s, listY1, listY2) page.scale = s local listW = mathFloor(200 * s) local gap = mathFloor(12 * s) local barH = ctx.metrics.rowHeight + mathFloor(8 * s) page.rects = { - list = { x1, y1, x1 + listW, y2 }, + list = { x1, listY1 or y1, x1 + listW, listY2 or y2 }, bar = { x1 + listW + gap, y2 - barH, x2, y2 }, chart = { x1 + listW + gap, y1, x2, y2 - barH - mathFloor(4 * s) }, - rowH = ctx.metrics.rowHeight, + -- The sidebar's entry height, so the two lists run level. + rowH = ctx.metrics.catRowHeight, -- A legend square, the block plate's inset from the bar, and the frame around -- the picked block. square = ctx.metrics.rowHeight - mathFloor(8 * s), @@ -447,14 +465,33 @@ function M.new(ctx) end page.units = units page.unitByKey = byKey - -- The first highlight, and one that stopped standing for anything (the grouping - -- switch turned), is the viewer's own unit; a cleared one stays cleared. - if not page.highlightSet or (page.highlight and not byKey[page.highlight]) then - page.highlightSet = true - page.highlight = nil + + -- The selection and the hidden set carried over to these units: once the grouping + -- switch turned, an ally team stands for its players, and players stand for their + -- ally team - when any of them was selected, or all of them were hidden. + local function carried(set, whole) + local out = {} + for _, unit in ipairs(units) do + local count = 0 + for _, team in ipairs(unit.teams) do + if set[unit.key] or set["team" .. team.id] or set["ally" .. team.allyID] then + count = count + 1 + end + end + if count > 0 and (not whole or count == #unit.teams) then + out[unit.key] = true + end + end + return out + end + page.selected = carried(page.selected, false) + page.hidden = carried(page.hidden, true) + -- The first selection is the viewer's own unit; a cleared one stays cleared. + if not page.selectionSet then + page.selectionSet = true for _, unit in ipairs(units) do if unit.isLocal then - page.highlight = unit.key + page.selected[unit.key] = true end end end @@ -519,9 +556,6 @@ function M.new(ctx) local cy = mathFloor((r.bar[2] + r.bar[4]) * 0.5) local y1 = cy - mathFloor(square * 0.5) local y2 = y1 + square - for _, unit in ipairs(page.units) do - unit.barX1, unit.barX2 = nil, nil - end local half = mathFloor(pad * 0.5) for _, b in ipairs(blocks) do -- The plate runs from the caption to the last square, with half a pad of air. @@ -535,9 +569,8 @@ function M.new(ctx) x = x + b.labelW + pad end for _, m in ipairs(b.members) do - items[#items + 1] = { unit = m.unit, team = m.team, x1 = x, y1 = y1, x2 = x + square, y2 = y2 } - m.unit.barX1 = mathMin(m.unit.barX1 or x, x) - m.unit.barX2 = mathMax(m.unit.barX2 or x + square, x + square) + items[#items + 1] = + { unit = m.unit, team = m.team, block = b, x1 = x, y1 = y1, x2 = x + square, y2 = y2 } x = x + square end end @@ -562,8 +595,35 @@ function M.new(ctx) return list end - -- The milestones of these units as pictures on the chart, each framed in its unit's - -- colour, on the unit's series where `indexByKey` names one, else in the lane. + -- The selected units among the shown. + local function pickedUnits() + local list = {} + for _, u in ipairs(page.units) do + if page.selected[u.key] and not page.hidden[u.key] then + list[#list + 1] = u + end + end + return list + end + + -- Whether the Selected only switch applies: the composition is always the selection's. + function page.filterShown() + return page.stat ~= "composition" + end + + -- The units a chart is about, named for its title: every team, one by name, or how many. + local function namesOf(list) + if #list == #page.units then + return ctx.i18n("ui.teamStats.graph.all") + elseif #list == 1 then + return list[1].name + end + return ctx.i18n(grouped() and "ui.teamStats.graph.teams" or "ui.teamStats.graph.players", { count = #list }) + end + + -- The milestones of these units' players as pictures on the chart, each framed in its + -- player's colour, on the unit's series where `indexByKey` names one, else in the lane. + -- The hover text says whose it was, in their colour, then when and what. local function milestoneMarkers(list, indexByKey) local markers = {} local live = ctx.live() @@ -571,21 +631,23 @@ function M.new(ctx) return markers end for _, unit in ipairs(list) do - for _, teamID in ipairs(unit.members) do - local team = live[teamID] - for _, m in ipairs(team and team.milestones or {}) do + for _, team in ipairs(unit.teams) do + local teamLive = live[team.id] + for _, m in ipairs(teamLive and teamLive.milestones or {}) do local ud = m.unitDefID and UnitDefs[m.unitDefID] or nil ---@cast ud table? local label = ctx.L.milestone[m.key] or m.key if ud then label = label .. " (" .. (ud.translatedHumanName or ud.name) .. ")" end + local whose = (team.nameColor or "") .. team.name + local what = ctx.colors.title .. Graph.frameLabel(m.frame) .. " " .. label markers[#markers + 1] = { x = m.frame, texture = ud and ("#" .. m.unitDefID) or nil, - text = Graph.frameLabel(m.frame) .. " " .. label, + text = whose .. "\n" .. what, series = indexByKey and indexByKey[unit.key] or nil, - frame = unit.color, + frame = { team.accent[1], team.accent[2], team.accent[3] }, } end end @@ -593,32 +655,40 @@ function M.new(ctx) return markers end - -- The chart's series from the pick and the switches: the teams' runs of the stat as - -- lines, the highlighted one lifted and its milestones on it (every team's when none - -- is highlighted); or a composition as bands, the highlighted team's or everyone's - -- together; or, with the share switch on, the highlighted ally team's members as - -- shares of it, or every team as a share of the whole when none is highlighted. + -- The chart's series from the pick and the switches. The same rules for every kind: + -- hidden units are left out; with Selected only on and a selection, only the selected + -- are plotted, otherwise every shown unit is, the selected ones lit and on top and the + -- rest faded; the milestones are the selection's, every plotted unit's when nothing is + -- selected. A composition is one whole, so it is the selection's summed (every shown + -- team's without one). Share of team plots the players of the plotted ally teams as + -- shares of their total. function page.build() rebuildSamples() page.rebuildUnits() local column = ctx.COLUMNS[page.stat] local isGrouped = grouped() local perMinute = ctx.filters.perMinute and column and column.rate or false - local focus = page.highlight and page.unitByKey[page.highlight] or nil local shown = shownUnits() + local picked = pickedUnits() + local anyPicked = #picked > 0 + local plotted = (anyPicked and ctx.filters.selectedOnly) and picked or shown + -- Something stands out only while the selection is not everything plotted. + local lifts = anyPicked and #picked < #plotted + local marked = anyPicked and picked or plotted local series, markers = {}, {} local kind = "line" local title local yFormat = nil - local highlightIndex = nil + ---@type table + local lifted = {} local ownLegend = false - local all = ctx.i18n("ui.teamStats.graph.all") if page.stat == "composition" then kind = "stacked" ownLegend = true + local of = anyPicked and picked or shown local members = {} - for _, u in ipairs(focus and { focus } or shown) do + for _, u in ipairs(of) do for _, teamID in ipairs(u.members) do members[#members + 1] = teamID end @@ -637,36 +707,30 @@ function M.new(ctx) } end end - title = ctx.i18n("ui.teamStats.graph.composition") .. " \194\183 " .. (focus and focus.name or all) - markers = milestoneMarkers(focus and { focus } or shown, nil) + lifts = false + title = ctx.i18n("ui.teamStats.graph.composition") .. " \194\183 " .. namesOf(of) + markers = milestoneMarkers(of, nil) elseif column and ctx.filters.shareOfTeam and isGrouped and column.fmt == "si" then kind = "stacked" - if focus then - for _, team in ipairs(focus.teams) do + for _, u in ipairs(plotted) do + for _, team in ipairs(u.teams) do series[#series + 1] = { name = team.name, color = { team.accent[1], team.accent[2], team.accent[3] }, points = pointsOf({ team.id }, column.key, perMinute), } - end - else - for _, u in ipairs(shown) do - series[#series + 1] = { - name = u.name, - color = u.color, - points = pointsOf(u.members, column.key, perMinute), - } + lifted[#series] = page.selected[u.key] end end title = ctx.columnTitle(column) .. " \194\183 " .. ctx.L.switch.shareOfTeam .. " \194\183 " - .. (focus and focus.name or all) - markers = milestoneMarkers(focus and { focus } or shown, nil) + .. namesOf(plotted) + markers = milestoneMarkers(marked, nil) elseif column then local indexByKey = {} - for _, u in ipairs(shown) do + for _, u in ipairs(plotted) do series[#series + 1] = { name = u.name, color = u.color, @@ -674,9 +738,7 @@ function M.new(ctx) width = 2, } indexByKey[u.key] = #series - if u.key == page.highlight then - highlightIndex = #series - end + lifted[#series] = page.selected[u.key] end title = ctx.columnTitle(column) if perMinute then @@ -685,7 +747,7 @@ function M.new(ctx) if column.fmt == "percent" then yFormat = percentFormat end - markers = milestoneMarkers(focus and { focus } or shown, indexByKey) + markers = milestoneMarkers(marked, indexByKey) end page.empty = true @@ -701,7 +763,7 @@ function M.new(ctx) chart:configure({ kind = kind, title = title, legend = ownLegend, bandLabels = ownLegend }) chart:setSeries(series) chart:setMarkers(markers) - chart:setHighlight(kind == "line" and highlightIndex or nil) + chart:setHighlight(lifts and lifted or nil) page.dirty = false end @@ -719,13 +781,21 @@ function M.new(ctx) Rect(x2 - w, y1, x2, y2) end - -- Whether a block of the bar is the picked one: All with no highlight, an ally - -- team's when the grouping switch makes the block the unit. + -- Whether a block of the bar is lit: All while nothing is selected; an ally team's + -- while it is selected, or every one of its players is. local function blockLit(b) if b.all then - return page.highlight == nil + return #pickedUnits() == 0 end - return grouped() and b.unit ~= nil and b.unit.key == page.highlight + if #b.members == 0 then + return false + end + for _, m in ipairs(b.members) do + if not page.selected[m.unit.key] or page.hidden[m.unit.key] then + return false + end + end + return true end -- What is baked into the panel's list: the stat list on a card like the sidebar's, @@ -771,20 +841,26 @@ function M.new(ctx) ) end - -- The legend bar: a plate per block like a button, lit when it is the picked one - -- or hovered, then its caption and squares; the picked one framed warm. + -- The legend bar: a plate per block like a button, lit and framed warm when it is + -- selected as a whole, then its caption and squares. A selected square stands at + -- full strength inside a warm frame (neighbours share one), the others fade while + -- anything is selected, and a hidden one is only an outline. local isGrouped = grouped() + local anyPicked = #pickedUnits() > 0 local cy = mathFloor((r.bar[2] + r.bar[4]) * 0.5) local py1, py2 = r.bar[2] + r.inset, r.bar[4] - r.inset local fw = r.frame for i, b in ipairs(page.barBlocks) do local lit = blockLit(b) + b.lit = lit RectRound(b.x1, py1, b.x2, py2, cs, 1, 1, 1, 1, lit and look.selectedFill or PLATE) if lit then -- A frame that follows the plate's corners. ctx.draw.RectRoundOutline(b.x1, py1, b.x2, py2, cs, fw, 1, 1, 1, 1, PICKED_FRAME, PICKED_FRAME) end - if i == page.hover.block and not lit then + -- Without the grouping a square is its player, so the plate lights only for its + -- caption, which stands for the whole ally team. + if i == page.hover.block and not lit and (isGrouped or b.all or page.hover.legend == 0) then Highlight(b.x1, py1, b.x2, py2, cs, look.rowHoverOpacity, look.white) end if b.labelX then @@ -793,17 +869,40 @@ function M.new(ctx) end for i, item in ipairs(page.barItems) do local c = item.team.accent - local hidden = page.hidden[item.unit.key] - local alpha = hidden and 0.22 or (i == page.hover.legend and 1 or 0.9) - Color(c[1], c[2], c[3], alpha) - Rect(item.x1, item.y1, item.x2, item.y2) + local key = item.unit.key + local hovered = i == page.hover.legend or (isGrouped and item.block == page.barBlocks[page.hover.block]) + if page.hidden[key] then + Color(c[1], c[2], c[3], hovered and 0.22 or 0.12) + Rect(item.x1, item.y1, item.x2, item.y2) + frame(item.x1, item.y1, item.x2, item.y2, 1, { c[1], c[2], c[3], 0.55 }) + else + local alpha = (anyPicked and not page.selected[key]) and 0.4 or 0.9 + Color(c[1], c[2], c[3], hovered and alpha + 0.1 or alpha) + Rect(item.x1, item.y1, item.x2, item.y2) + end end if not isGrouped then - for _, unit in ipairs(page.units) do - if unit.key == page.highlight and unit.barX1 then - frame(unit.barX1 - fw, page.barY1 - fw, unit.barX2 + fw, page.barY2 + fw, fw, PICKED_FRAME) + ---@type table?, table? + local first, last = nil, nil + local function frameRun() + if first and last then + frame(first.x1 - fw, page.barY1 - fw, last.x2 + fw, page.barY2 + fw, fw, PICKED_FRAME) + end + first, last = nil, nil + end + for _, item in ipairs(page.barItems) do + local key = item.unit.key + local on = page.selected[key] and not page.hidden[key] and not item.block.lit + if on and first and first.block == item.block then + last = item + else + frameRun() + if on then + first, last = item, item + end end end + frameRun() end Color(1, 1, 1, 1) @@ -869,18 +968,26 @@ function M.new(ctx) return page.hover.stat .. "|" .. page.hover.legend .. "|" .. page.hover.block .. "|" .. page.gen end - -- The unit under the cursor in the bar: a square's, or the block's ally team when - -- the grouping switch makes the block the unit. - local function unitUnderCursor() + -- The units a press in the bar acts on: a square's; with the grouping switch on the + -- block's ally team, off it the caption's players, all of them. + local function unitsUnderCursor() if page.hover.legend > 0 then local item = page.barItems[page.hover.legend] ---@cast item -? - return item.unit + return { item.unit } end if page.hover.block > 0 then local b = page.barBlocks[page.hover.block] ---@cast b -? - return b.unit + if b.unit then + return { b.unit } + elseif not b.all then + local list = {} + for _, m in ipairs(b.members) do + list[#list + 1] = m.unit + end + return list + end end return nil end @@ -891,8 +998,10 @@ function M.new(ctx) ctx.playSound() end - -- A press: picks a stat; on the bar, highlights the unit under the cursor, or hides - -- it with the right button; All clears the highlight. + -- A press: picks a stat. On the bar a click adds the units under the cursor to the + -- selection, or takes them out when they all were in it; Ctrl+click makes them the + -- whole selection; right-click hides them, or shows them again when they all were + -- hidden. All clears the selection and shows every team again. function page.mousePress(x, y, button) page.hoverAt(x, y) if page.hover.stat > 0 then @@ -906,20 +1015,38 @@ function M.new(ctx) end local block = page.hover.block > 0 and page.barBlocks[page.hover.block] or nil if block and block.all then - if button ~= 3 and page.highlight ~= nil then - page.highlight = nil + if button ~= 3 and (next(page.selected) or next(page.hidden)) then + page.selected, page.hidden = {}, {} changed() end return true end - local unit = unitUnderCursor() - if unit then + local targets = unitsUnderCursor() + if targets then + local allHidden, allSelected = true, true + for _, u in ipairs(targets) do + allHidden = allHidden and page.hidden[u.key] == true + allSelected = allSelected and page.selected[u.key] == true and not page.hidden[u.key] + end + local _, ctrl = Spring.GetModKeyState() if button == 3 then - page.hidden[unit.key] = not page.hidden[unit.key] or nil - elseif page.highlight == unit.key then - page.highlight = nil + for _, u in ipairs(targets) do + page.hidden[u.key] = not allHidden or nil + if not allHidden then + page.selected[u.key] = nil + end + end else - page.highlight = unit.key + if ctrl then + page.selected = {} + end + for _, u in ipairs(targets) do + local on = ctrl or not allSelected + page.selected[u.key] = on or nil + if on then + page.hidden[u.key] = nil + end + end end changed() return true @@ -927,8 +1054,48 @@ function M.new(ctx) return block ~= nil end + -- What the units under the cursor are to the chart right now, and what the mouse does + -- to them: a line on their state (selected, not, hidden, and what that means with + -- the switches as they are), one on their milestones when those are on the chart, + -- then the controls. + local function barHint(targets) + local L = "ui.teamStats.graph." + local allHidden, allSelected, someSelected = true, true, false + for _, u in ipairs(targets) do + local on = page.selected[u.key] == true and not page.hidden[u.key] + allHidden = allHidden and page.hidden[u.key] == true + allSelected = allSelected and on + someSelected = someSelected or on + end + local composition = page.stat == "composition" + local anyPicked = #pickedUnits() > 0 + local state + if allHidden then + state = "hidden" + elseif allSelected then + state = composition and "counted" or (ctx.filters.selectedOnly and "only" or "lit") + elseif someSelected then + state = "some" + elseif anyPicked then + state = composition and "notCounted" or (ctx.filters.selectedOnly and "left" or "faded") + else + state = composition and "allCounted" or "alike" + end + local group = #targets > 1 + local lines = { ctx.colors.title .. ctx.i18n(L .. "state." .. state) } + if ctx.filters.milestones and not allHidden and (allSelected or not anyPicked) then + local key = group and "milestonesGroup" or "milestones" + lines[#lines + 1] = ctx.colors.title .. ctx.i18n(L .. "state." .. key) + end + local suffix = group and "Group" or "" + for _, control in ipairs({ "click", "ctrlClick", "rightClick" }) do + lines[#lines + 1] = ctx.colors.dim .. ctx.i18n(L .. "control." .. control .. suffix) + end + return table.concat(lines, "\n") + end + -- The tooltip for the cursor: the chart's description, a stat's explanation, or the - -- team under the cursor in the bar and how the bar works. + -- units under the cursor in the bar and how the bar works. function page.tooltip() if page.chartHit then return chart.cfg.title, chart:describe(page.chartHit) @@ -948,16 +1115,21 @@ function M.new(ctx) if item.unit.name ~= item.team.name then title = item.team.name .. " \194\183 " .. item.unit.name end - return title, ctx.i18n("ui.teamStats.graph.legendHint") + return title, barHint({ item.unit }) end if page.hover.block > 0 then local b = page.barBlocks[page.hover.block] ---@cast b -? if b.all then - return b.label, ctx.i18n("ui.teamStats.graph.allHint") + local tip = ctx.i18n("ui.teamStats.graph.allHint") + if ctx.filters.milestones then + tip = tip .. "\n" .. ctx.i18n("ui.teamStats.graph.allMilestones") + end + return b.label, tip end - return b.label, - b.unit and ctx.i18n("ui.teamStats.graph.legendHint") or ctx.i18n("ui.teamStats.graph.blockHint") + local targets = unitsUnderCursor() + ---@cast targets -? + return b.label, barHint(targets) end return nil end @@ -967,13 +1139,16 @@ function M.new(ctx) ---------------------------------------------------------------- function page.getConfig() - return { graphStat = page.stat } + return { graphStat = page.stat, graphGroupByTeam = page.grouped } end function page.setConfig(data) if type(data.graphStat) == "string" then page.stat = data.graphStat end + if data.graphGroupByTeam ~= nil then + page.grouped = data.graphGroupByTeam == true + end end -- The switches and the sidebar change what the chart is made of. diff --git a/luaui/Widgets/gui_teamstats.lua b/luaui/Widgets/gui_teamstats.lua index ef577075e88..fe731098b3a 100644 --- a/luaui/Widgets/gui_teamstats.lua +++ b/luaui/Widgets/gui_teamstats.lua @@ -466,9 +466,10 @@ end local switches = { -- The Graphs page: a mode, so it leads the row; its state is the page's, not a filter. { key = "graphs", mode = true }, - -- The Graphs page's alone, beside its switch: the highlighted team's milestones on - -- the chart. + -- The Graphs page's alone, beside its switch: the selected teams' milestones on the + -- chart, and whether the selection is all the chart shows or only stands out on it. { key = "milestones", page = true }, + { key = "selectedOnly", page = true }, { key = "groupByTeam" }, -- Only offered while there are bands to take a share of. { key = "shareOfTeam" }, @@ -488,6 +489,7 @@ local filters = { perMinute = false, bars = false, milestones = true, + selectedOnly = false, } -- Excess is waste once it passes a share of what was produced; these say what to compare @@ -568,6 +570,8 @@ local metrics = { -- How far the column starts below the table beside it, to leave the title room. sidebarDrop = 8, sidebarW = 190, + -- Between the column and the Graphs page's stat list, two cards side by side. + pageGap = 8, barW = 14, -- Rows the wheel moves per notch. wheelRows = 3, @@ -1557,6 +1561,7 @@ local function setLayout() metrics.titleFs = mathFloor(metrics.rowHeight * 0.85) metrics.sidebarDrop = mathFloor(8 * s) metrics.sidebarW = mathFloor(190 * s) + metrics.pageGap = mathFloor(8 * s) metrics.barW = mathFloor(14 * s) metrics.nameMinW = mathFloor(150 * s) -- Narrower than the cell padding it sits in. @@ -1581,10 +1586,19 @@ local function setLayout() -- it, so the bar sits in a channel rather than hugging them. barX1 = area.x2 - metrics.edgeInset - metrics.barW listRight = barX1 - metrics.listGap - -- The Graphs page takes the table's room, header rows and scrollbar included. + -- The Graphs page takes the table's room, header rows and scrollbar included. Its + -- stat list is a card like the column's, so it sits closer and spans the same height. if graphs then graphs.setFont(font, metrics.rowFs) - graphs.setLayout(listX1, listBottom, area.x2 - metrics.edgeInset, metrics.bandTop, s) + graphs.setLayout( + area.x1 + metrics.sidebarW + metrics.pageGap, + listBottom, + area.x2 - metrics.edgeInset, + metrics.bandTop, + s, + area.y1, + sidebarTop() + metrics.cardLip + ) end -- The header band: the switches, right to left from the panel's edge. A switch this @@ -1604,11 +1618,15 @@ local function setLayout() for i = #switches, 1, -1 do local sw = switches[i] local onGraphs = graphs and graphs.open + -- A share of a team's total needs teams to group by: the page groups its own way + -- while it is open, the table's grouping stands otherwise. + local anyGrouping = onGraphs and (graphs.grouped and not isFFA) or (not onGraphs and grouped()) if (sw.mode and not (showPlannedPages and handover.on)) or (sw.key == "perMinute" and not rateShown()) + or (sw.key == "selectedOnly" and not (graphs and graphs.filterShown())) or (sw.key == "groupByTeam" and isFFA) - or (sw.key == "shareOfTeam" and not grouped()) + or (sw.key == "shareOfTeam" and not anyGrouping) or (sw.table and onGraphs) or (sw.page and not onGraphs) then @@ -2104,7 +2122,14 @@ local function drawHeader() if hovered then Highlight(sw.hit[1], sw.hit[2], sw.hit[3], sw.hit[4], metrics.csSmall, look.rowHoverOpacity, look.white) end - local on = sw.mode and graphs.open or filters[sw.key] + -- The Graphs switch shows the page's state, and the grouping switch the + -- grouping of whichever of the two is open. + local on = filters[sw.key] + if sw.mode then + on = graphs.open + elseif sw.key == "groupByTeam" and graphs.open then + on = graphs.grouped + end UiToggle(sw.draw[1], sw.draw[2], sw.draw[3], sw.draw[4], on, hovered) queueText( (on and colorSelected or colorDim) .. sw.label, @@ -2666,6 +2691,11 @@ local function toggleSwitch(i) graphs.refresh() graphs.invalidate() end + elseif key == "groupByTeam" and graphs.open then + -- The page groups its own way: the table keeps the grouping it was left with. + graphs.setGrouped(not graphs.grouped) + -- Grouping decides whether the share switch is offered here too. + setLayout() else filters[key] = not filters[key] -- Grouping decides whether the share switch is offered, so the header is laid @@ -2921,7 +2951,9 @@ function widget:GetConfigData() perMinute = filters.perMinute, bars = filters.bars, milestones = filters.milestones, + selectedOnly = filters.selectedOnly, graphStat = graphs.stat, + graphGroupByTeam = graphs.grouped, } end From fee5722b07537ac17a8854d30efd3644090e2e64 Mon Sep 17 00:00:00 2001 From: Robert Burnham Date: Thu, 17 Sep 2026 13:10:17 -0500 Subject: [PATCH 4/6] Add keybinds for switching the active keybind profile (#9275) Adds a bindable action per keybind profile, so a key can make a specific profile active. The binding names its profile, so the name is an id in the keymaps and not only in the store. That is why a rename rewrites uikeys.txt as well as the profiles: leave it stale and the next launch finds a keymap matching no profile and keeps it as a new one. AI disclosure: written with assistance from Claude Code. --- common/configs/keybind_catalog.json | 6 + common/configs/keybind_catalog.schema.json | 2 + common/configs/keybinds.README.md | 15 ++- language/en/commands.json | 4 + language/en/interface.json | 2 +- luaui/Include/keybind_editor_view.lua | 111 +++++++++++++++++-- luaui/Include/keybind_profiles.lua | 59 +++++++++- luaui/Widgets/cmd_bar_hotkeys.lua | 14 ++- luaui/Widgets/gui_keybind_info.lua | 8 ++ spec/luaui/Include/keybind_profiles_spec.lua | 70 ++++++++++++ 10 files changed, 278 insertions(+), 13 deletions(-) diff --git a/common/configs/keybind_catalog.json b/common/configs/keybind_catalog.json index 967ee9e2a59..e9595465cb0 100644 --- a/common/configs/keybind_catalog.json +++ b/common/configs/keybind_catalog.json @@ -866,6 +866,12 @@ "7", "8" ] + }, + { + "prefix": "keybindprofile ", + "label": "actions.keybindProfiles.activate", + "description": "actions.keybindProfiles.activateDescription", + "membersFrom": "profiles" } ] }, diff --git a/common/configs/keybind_catalog.schema.json b/common/configs/keybind_catalog.schema.json index c175132d94f..e6205df90bc 100644 --- a/common/configs/keybind_catalog.schema.json +++ b/common/configs/keybind_catalog.schema.json @@ -61,9 +61,11 @@ "additionalProperties": false, "properties": { "prefix": { "type": "string", "description": "Action id prefix." }, + "description": { "type": "string", "description": "i18n key for a sentence saying what the actions in this family do, for a tooltip. Shared by every row the prefix matches, so it says what the family does rather than any one member. Optional, and falls back the same way an action entry's does." }, "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." }, + "membersFrom": { "type": "string", "enum": ["profiles"], "description": "Members this file cannot list because they are per player, named by where a surface reads them from instead. \"profiles\" is the name of every selectable keybind profile, shipped and the player's own, other than the active one, which a key could only switch to from itself. Use in place of members, not alongside it." }, "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 7353a4c097c..20ba377930d 100644 --- a/common/configs/keybinds.README.md +++ b/common/configs/keybinds.README.md @@ -66,6 +66,16 @@ form each action. Listing them makes those rows exist whether or not anything is unbinding one leaves it there to bind again. Families that cannot be enumerated - `buildunit_` is per unit - list none and are discovered from what is bound instead. +Where the members exist but are the player's rather than the game's, the entry names where +to read them with `"membersFrom"` instead of listing them. The one source is `"profiles"`, +which is what `keybindprofile ` covers: one bindable action per profile, named after it, so +a key means the same profile whatever is active. + +That source is every selectable profile, shipped and the player's own, except the active +one - switching to the profile already loaded can only do nothing. A key that already names +the active profile is still read from the keymap and listed, so there is somewhere to remove +it from. + An entry may carry `"alwaysModifier"`, naming a modifier the action always tolerates so no surface shows it or lets the player pick it: @@ -153,7 +163,10 @@ the clipboard and what Import reads back, and the same text a player would put i so the first edit made while one is selected forks it into a copy and edits that. - **Create / rename / delete.** Names are the identity, so they must stay unique across both files; disambiguate rather than overwrite. Deleting the active profile means - falling back and applying whatever is left. + falling back and applying whatever is left. A name is also an id inside the keymaps, + because `keybindprofile ` is what a key switching to that profile is bound to, so + a rename has to rewrite those binds everywhere they appear and a delete has to drop + them - in every profile, not just the one being changed. ### Same rules, different plumbing diff --git a/language/en/commands.json b/language/en/commands.json index 0babc4b85ed..96109ea68c0 100644 --- a/language/en/commands.json +++ b/language/en/commands.json @@ -267,6 +267,10 @@ }, "spectating": { "spectate": "Spectate team %{n}" + }, + "keybindProfiles": { + "activate": "Switch to %{n}", + "activateDescription": "Loads this keybind profile, replacing every key you have bound." } }, "categories": { diff --git a/language/en/interface.json b/language/en/interface.json index f5bdc8af923..51617357cc6 100644 --- a/language/en/interface.json +++ b/language/en/interface.json @@ -286,7 +286,7 @@ "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.", + "keyboardTooltip": "Show this preset's keybinds laid out on a keyboard.\nClick Shift, Ctrl, Alt or Meta on it to see what the keys do with that held.\nClick 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", diff --git a/luaui/Include/keybind_editor_view.lua b/luaui/Include/keybind_editor_view.lua index 7e21ef868d1..333e87a40df 100644 --- a/luaui/Include/keybind_editor_view.lua +++ b/luaui/Include/keybind_editor_view.lua @@ -737,6 +737,7 @@ local function buildResolvedCatalog() label = item.label, unit = item.unit, members = item.members, + membersFrom = item.membersFrom, description = describe(item), icon = (item.icon and VFS.FileExists(item.icon) and item.icon) or nil, } @@ -1139,12 +1140,34 @@ local function rebuildRows() for _, item in ipairs(group.items) do -- An empty prefix would claim every bound action, so treat it as no prefix. if item.prefix and item.prefix ~= "" then + -- A family whose members are the player's is named a source rather than listed, + -- and read here rather than with the rest of the catalog, so a profile made a + -- moment ago gets its row without waiting for a refresh. + local members = item.members + if item.membersFrom == "profiles" then + -- Switching to the one already on is a key that can only do nothing, so it is + -- not offered. One already bound is still found from the keymap below, which + -- is what leaves a stale self-binding somewhere to remove it. + local active = profiles.activeName() + members = {} + for _, builtin in ipairs(profiles.builtins) do + if builtin.name ~= active then + members[#members + 1] = builtin.name + end + end + for _, own in ipairs(profiles.list()) do + if own ~= active then + members[#members + 1] = own + end + end + end + -- A declared member is a row whether or not it is bound, so unbinding the last -- key of "group select 3" leaves the row there to bind again. Families the -- catalog cannot enumerate (buildunit_ is per unit) list no members and are -- still discovered from what is bound. local matched = {} - for _, member in ipairs(item.members or {}) do + for _, member in ipairs(members or {}) do local action = item.prefix .. member -- Skipped when an explicit entry already covers it, or a family whose -- members are also listed individually renders each of them twice. @@ -1843,13 +1866,20 @@ end -- Makes a profile the live one, leaving its stored binds alone. Answers whether it took. -- A keymap that never reached disk must not clear the staged flag: the reload below would -- load whatever file is still there and the player would watch their edits revert. -local function selectProfile(name, fromName) +-- offPanel marks a caller the player is not looking at, a bound key with the editor +-- closed, where a modal would sit unseen and then surface attributed to whatever they did +-- next; that failure goes to the console instead. +local function selectProfile(name, fromName, offPanel) if not profiles.materialize(name) then - openDialog({ - title = L.applyFailedTitle, - message = BAR.I18N("ui.keybinds.editor.applyFailedMessage", { name = name }), - accept = function() end, - }) + if offPanel then + Spring.Echo("Keybind profile: could not apply " .. name) + else + openDialog({ + title = L.applyFailedTitle, + message = BAR.I18N("ui.keybinds.editor.applyFailedMessage", { name = name }), + accept = function() end, + }) + end return false end @@ -2055,13 +2085,51 @@ end -- middle button deletes. Deleting asks again, since it cannot be undone. local function startEdit() local name = profiles.activeName() + + -- One keymap's binds and its index by action, with the switch to one profile pointed at + -- another. The staged keymap and everything undo can put back move with the rename too, + -- or a save writes back a key that switches to a profile no longer there. + local function retargetSwitch(set, from, to) + local keysets = set and set.byAction[from] + if not keysets then + return + end + + for _, bind in ipairs(set.binds) do + if bind.action == from then + bind.action = to + end + end + set.byAction[from] = nil + set.byAction[to] = keysets + end + openDialog({ title = L.editTitle, initial = name, allow = name, accept = function(newName) - profiles.rename(name, newName) - refreshPicker() + -- The store renumbers a name already taken, so follow what it settled on. + local from = profiles.switchAction(name) + local settled = profiles.rename(name, newName) + local to = profiles.switchAction(settled) + retargetSwitch(working, from, to) + retargetSwitch(state.snapshot, from, to) + for _, snap in ipairs(state.undo) do + retargetSwitch(snap, from, to) + end + + -- The rename moved the store's copy of the binding, so the file has to be written + -- again or the next launch finds a keymap matching no profile and adopts it as a + -- separate one. Reloading it is the part staged edits cannot take, so with edits + -- pending only the file is written and the engine catches up on Save. + if dirty then + profiles.materialize(settled) + refreshPicker() + rebuildRows() + else + selectProfile(settled, nil) + end end, middle = { label = L.delete, @@ -2475,6 +2543,31 @@ end function view.setOwner(w) shade.owner = w end +-- Host hook for the bindable per-profile actions. Answers whether the profile was there to +-- switch to, so a key naming one the player has since deleted falls through to whatever else +-- is on it rather than being swallowed. +function view.applyProfile(name) + if not name or not (profiles.get(name) or profiles.isBuiltin(name)) then + return false + end + + local from = profiles.activeName() + if name == from then + return true + end + + -- Switching drops whatever is staged. Every other way of doing that asks first, and this + -- one can arrive from the console or another widget while the panel is open, so it + -- declines rather than discarding edits the player never answered for. + if dirty then + Spring.Echo("Keybind profile: save or discard your keybind changes before switching to " .. name) + + return false + end + + return selectProfile(name, from, true) +end + -- Host hook for swapping the build menu when a profile implies one. function view.setMenuToggle(fn) menuToggle = fn diff --git a/luaui/Include/keybind_profiles.lua b/luaui/Include/keybind_profiles.lua index 4bb9471fc68..24b3693f9ae 100644 --- a/luaui/Include/keybind_profiles.lua +++ b/luaui/Include/keybind_profiles.lua @@ -18,6 +18,10 @@ local RETIRED_INCLUDES_PATH = "common/configs/keybind_retired_includes.json" local ACTIVE_FILE = "uikeys.txt" local BACKUP_FILE = "uikeys.txt.bak" local STORE_VERSION = 2 +-- Bindable action that makes a profile active, one per profile, named after it. That puts +-- the name in the keymap as well as in the store, so renaming or deleting one has to follow +-- it into every profile's binds. +local SWITCH_COMMAND = "keybindprofile" -- 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 @@ -51,6 +55,8 @@ 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 +-- Set when another surface may have written the store since this one read it. +local stale = false -- Shape a fresh store file takes. local function emptyStore() @@ -70,6 +76,40 @@ end local M = { builtins = builtins, activeFile = ACTIVE_FILE } +-- The action a key is bound to in order to switch to this profile. +function M.switchAction(name) + return SWITCH_COMMAND .. " " .. name +end + +-- Points the binds that switch to oldName at newName instead, or drops them when newName is +-- nil. Hands back the list to use and whether anything moved, so a caller can leave a +-- profile it did not touch alone. +function M.retargetSwitchBinds(binds, oldName, newName) + local from = M.switchAction(oldName) + local out, moved = {}, false + for _, bind in ipairs(binds or {}) do + if bind.action ~= from then + out[#out + 1] = bind + else + moved = true + if newName then + out[#out + 1] = { keyset = bind.keyset, action = M.switchAction(newName) } + end + end + end + + return out, moved +end + +local function retargetStore(oldName, newName) + for _, p in ipairs(store.profiles) do + local binds, moved = M.retargetSwitchBinds(p.binds, oldName, newName) + if moved then + p.binds = binds + end + end +end + -- The shipped profile of that name, nil when the player owns it instead. function M.isBuiltin(name) for _, b in ipairs(builtins) do @@ -540,14 +580,29 @@ local function migrate() end end +-- Marks the cached store for re-reading rather than dropping it. Each VFS.Include of this +-- module runs it again and gets a store of its own, so a surface that did not make a change +-- has no way of knowing another one did. +function M.invalidate() + stale = true +end + -- Reads the store once, migrating an older layout on the way in. function M.load() - if store then + if store and not stale then return store end + stale = false local content = VFS.LoadFile(PROFILES_PATH) if not content then + -- Migration is for a player who has never had a store, not for one whose file went + -- missing mid-session: re-running it would snapshot the live keymap as a new profile + -- every time anything reloaded. What was already read stands until a read succeeds. + if store then + return store + end + migrate() return store end @@ -859,6 +914,7 @@ function M.rename(oldName, newName) p.basedOn = newName end end + retargetStore(oldName, newName) if not M.save() then Spring.Echo( "[keybind_profiles] Error: could not write " @@ -891,6 +947,7 @@ function M.delete(name) p.basedOn = M.inferBase(p) end end + retargetStore(name, nil) return M.save() end diff --git a/luaui/Widgets/cmd_bar_hotkeys.lua b/luaui/Widgets/cmd_bar_hotkeys.lua index 7f7b0c6e0ae..a2c17b99803 100644 --- a/luaui/Widgets/cmd_bar_hotkeys.lua +++ b/luaui/Widgets/cmd_bar_hotkeys.lua @@ -41,6 +41,10 @@ local function fallbackToProfile(missing) end local function reloadBindings() + -- The editor holds a store of its own, so the selection this one last read may be two + -- switches old by now. Whatever moved the keymap has already written it out. + profiles.invalidate() + -- Still read from config rather than the store: on the launch a player is -- migrated this is what they were on, and the store snapshots the live keymap. local file = Spring.GetConfigString("KeybindingFile", profiles.activeFile) @@ -51,7 +55,15 @@ local function reloadBindings() if file then Spring.SendCommands("keyreload " .. file) - spEcho("BAR Hotkeys: Loaded hotkeys from " .. file) + -- Only the file the profiles write is one of theirs, so a KeybindingFile the player + -- pointed somewhere else is named on its own rather than credited to whatever the + -- store happens to have selected. + local name = file == profiles.activeFile and profiles.activeName() + if name then + spEcho("BAR Hotkeys: Loaded profile '" .. name .. "' from " .. file) + else + spEcho("BAR Hotkeys: Loaded hotkeys from " .. file) + end else spEcho("BAR Hotkeys: No hotkey file found") end diff --git a/luaui/Widgets/gui_keybind_info.lua b/luaui/Widgets/gui_keybind_info.lua index 027841eb3f5..7b5870d4c08 100644 --- a/luaui/Widgets/gui_keybind_info.lua +++ b/luaui/Widgets/gui_keybind_info.lua @@ -384,6 +384,14 @@ function widget:Initialize() return true end, nil, "t") + -- "keybindprofile " makes that profile live. One action per profile rather than a + -- cycle, so a key means the same profile whatever is active. The whole argument line is + -- the name: profile names hold spaces, and the engine hands back the args it was bound + -- with, so this matches the action ids the editor lists. + widgetHandler:AddAction("keybindprofile", function(_, line) + return keybindEditor.applyProfile(line) + end, nil, "tp") + -- Sent as commands because widgetHandler here is a per-widget proxy, which carries no -- Enable/DisableWidget. keybindEditor.setMenuToggle(function(useGrid) diff --git a/spec/luaui/Include/keybind_profiles_spec.lua b/spec/luaui/Include/keybind_profiles_spec.lua index 5ce5f6ae4d6..b5f0bf90420 100644 --- a/spec/luaui/Include/keybind_profiles_spec.lua +++ b/spec/luaui/Include/keybind_profiles_spec.lua @@ -3,6 +3,10 @@ -- bound has to come from common/configs; a keyload that resolves to nothing costs the player -- every binding it held, silently. -- +-- Also guards the binds that switch between profiles, which name their profile and so have +-- to be moved when one is renamed and dropped when one is deleted. Every surface owes that, +-- so it is the module's rule rather than the editor's. +-- -- Nothing here reaches disk: the store and the keymap the migration writes are both discarded. local Json = VFS.Include("common/luaUtilities/json.lua") @@ -130,3 +134,69 @@ describe("migrating a keyload of a bind file the game no longer ships", function assert.are.equal(1, #profile.binds) end) end) + +-- The module with its shipped profiles read in. Included rather than required so each call +-- gets a store of its own, and only the reader is stubbed: nothing here has a store on disk. +local function includeProfiles() + local realLoadFile, realGetKeyCode = VFS.LoadFile, Spring.GetKeyCode + VFS.LoadFile = function(path) + local file = io.open(path, "rb") + if not file then + return nil + end + + local contents = file:read("*a") + file:close() + + return contents + end + Spring.GetKeyCode = function() + return 1 + end + + local ok, result = pcall(VFS.Include, "luaui/Include/keybind_profiles.lua") + VFS.LoadFile, Spring.GetKeyCode = realLoadFile, realGetKeyCode + assert(ok, tostring(result)) + + return result +end + +describe("the binds that switch between profiles", function() + it("names the profile the key switches to", function() + assert.are.equal("keybindprofile Grid", includeProfiles().switchAction("Grid")) + end) + + it("follows a rename", function() + local profiles = includeProfiles() + local out, moved = profiles.retargetSwitchBinds({ + { keyset = "Ctrl+1", action = profiles.switchAction("Mine") }, + { keyset = "Ctrl+2", action = "screenshot" }, + }, "Mine", "Yours") + + assert.is_true(moved) + assert.are.same({ + { keyset = "Ctrl+1", action = "keybindprofile Yours" }, + { keyset = "Ctrl+2", action = "screenshot" }, + }, out) + end) + + it("goes with a delete", function() + local profiles = includeProfiles() + local out, moved = profiles.retargetSwitchBinds({ + { keyset = "Ctrl+1", action = profiles.switchAction("Mine") }, + { keyset = "Ctrl+2", action = "screenshot" }, + }, "Mine", nil) + + assert.is_true(moved) + assert.are.same({ { keyset = "Ctrl+2", action = "screenshot" } }, out) + end) + + it("leaves a keymap that switches to nothing of that name alone", function() + local profiles = includeProfiles() + local binds = { { keyset = "Ctrl+2", action = "screenshot" } } + local out, moved = profiles.retargetSwitchBinds(binds, "Mine", "Yours") + + assert.is_false(moved) + assert.are.same(binds, out) + end) +end) From e8efb1f664987ecb7076c656613a154e5970fc66 Mon Sep 17 00:00:00 2001 From: Robert Burnham Date: Thu, 17 Sep 2026 16:58:50 -0500 Subject: [PATCH 5/6] Apply keybind profile changes instead of forking a copy (#9277) Carries a changed keybind profile onto the player keymap instead of forking their old one into a copy. A shipped profile moved by a game update, or a store edited by an external tool between sessions, previously reached nobody who had already launched once. Existing stores hold no record of what was last written, so the launch that upgrades them still falls back to the old whole-keymap match, which cannot tell a profile that changed from a file the player edited. That means this wants to land before any release that changes a shipped profile, or those players fork once more on the way through. AI disclosure: written with assistance from Claude Code. --- common/configs/keybinds.README.md | 21 ++- luaui/Include/keybind_profiles.lua | 53 +++++- .../Include/keybind_shipped_update_spec.lua | 159 ++++++++++++++++++ 3 files changed, 224 insertions(+), 9 deletions(-) create mode 100644 spec/luaui/Include/keybind_shipped_update_spec.lua diff --git a/common/configs/keybinds.README.md b/common/configs/keybinds.README.md index 20ba377930d..265f0d6b3d2 100644 --- a/common/configs/keybinds.README.md +++ b/common/configs/keybinds.README.md @@ -131,7 +131,11 @@ Structure lives in the schemas; these are the operations, which a schema can't e Every surface answers the same questions from the same facts. The player's own profiles live in `LuaUI/Config/keybind_profiles.json`, in the same -shape as the shipped ones plus an `active` field naming the selected profile. That file +shape as the shipped ones plus an `active` field naming the selected profile and a +`written` field recording the keymap last emitted - `{ "name": , "stamp": }`. +The stamp stands for the bindings the file holds rather than its bytes, so changing how the +file is emitted does not make every player's keymap read as edited; it is taken over the same +normalised ` ` lines a comparison uses, plus the meta key. That file is per-install rather than shared, but its format is the contract - a surface that can read one can read the other. @@ -158,7 +162,20 @@ the clipboard and what Import reads back, and the same text a player would put i 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. + `space`; `fakemeta none` asks for no Meta modifier at all. Record what was written in the + store as `written`, above - a surface that skips this makes the next launch read its own + output as a keymap the player hand-wrote. +- **Reconcile on load.** Either side can have moved since the keymap was written: a game + update changes a shipped profile, or a tool changes the store between sessions. Compare the + keymap on disk against `written.stamp`. Equal means nobody has touched the file, so the + store is the authority and the selected profile is written out again, carrying whichever + change it was. Unequal means the player edited the file themselves, and that is kept as a + profile of theirs rather than overwritten. Where there is no stamp to compare - a store from + before this was recorded, or a player who points the engine at a keymap file of their own - + fall back to matching the whole keymap against every profile first, which still says nobody + edited it and stops a file being copied afresh on every launch. That older test cannot tell a + profile that changed from a file that did, so do not change a shipped profile in the same + release that starts recording stamps. - **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 24b3693f9ae..64ab15fbd66 100644 --- a/luaui/Include/keybind_profiles.lua +++ b/luaui/Include/keybind_profiles.lua @@ -409,6 +409,25 @@ local function keymapOf(text) return table.concat(parts, "\n"), fakeMetaOf(text) end +-- A short stand-in for a keymap, recorded when we write one so the file can later be told +-- apart from one somebody edited. Taken over the bindings rather than the bytes holding them, +-- so changing how we emit does not make every player's file read as edited the day we do. +-- djb2 with the length alongside it, which is plenty for telling an edit from our own output. +local function stampOf(text) + local binds, meta = keymapOf(text) + if not binds then + return nil + end + + local subject = binds .. "\n" .. tostring(meta) + local h = 5381 + for i = 1, #subject do + h = (h * 33 + subject:byte(i)) % 4294967296 + end + + return #subject .. ":" .. string.format("%08x", h) +end + -- 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. @@ -712,14 +731,27 @@ function M.adoptEditedKeymap() return nil end + -- Ours, and untouched since we wrote it. The store is then the authority on what should be + -- loaded, whichever side moved: a shipped profile changed by a game update, one of the + -- player's own changed by a tool between sessions, or a selection changed the same way. + -- Writing the selected profile back out is what carries any of those onto the keymap. + if store.written and store.written.stamp == stampOf(text) then + local name = M.activeName() + if name then + M.materialize(name) + end + + return nil + end + + -- Not what we last wrote, which covers a store from before any of this was recorded and a + -- player who points KeybindingFile at a file of their own, since what gets stamped is the + -- one we emit. Matching the whole keymap is the older, weaker test - it cannot tell a + -- profile that changed from a file that did - but it still says this is nobody's edit, and + -- writing out what it found records the stamp the test above wants. 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 + M.materialize(matched) return nil end @@ -1030,9 +1062,16 @@ function M.materialize(name) return nil end - file:write(toBindFile(profile)) + local text = toBindFile(profile) + file:write(text) file:close() + -- What the keymap held the last time it was ours. A file still holding this has not been + -- edited since, so the profile behind it can be rewritten over the top; one that does not + -- is the player's own work and is kept. + store.written = { name = name, stamp = stampOf(text) } + M.save() + return ACTIVE_FILE end diff --git a/spec/luaui/Include/keybind_shipped_update_spec.lua b/spec/luaui/Include/keybind_shipped_update_spec.lua new file mode 100644 index 00000000000..bef1b8a2140 --- /dev/null +++ b/spec/luaui/Include/keybind_shipped_update_spec.lua @@ -0,0 +1,159 @@ +-- Reconciling the keymap on disk with the profiles behind it. Either side can move between +-- sessions: a game update changes a shipped profile, or a tool changes the player's own store. +-- Neither is an edit the player made to uikeys.txt, and only that last one may fork. +-- +-- Nothing here reaches disk. Every write the module makes is captured instead. + +local Json = VFS.Include("common/luaUtilities/json.lua") + +local STORE = "LuaUI/Config/keybind_profiles.json" +local KEYMAP = "uikeys.txt" + +-- Runs `body` with the module included against `files`, the engine stubbed, and every write +-- collected into `writes` by path. The stubs stay up for the whole body: emitting a profile +-- asks the engine to resolve its meta key, and adopting one reads the keymap back. +local function run(files, writes, body) + local realOpen, realLoadFile = io.open, VFS.LoadFile + local realGetConfig, realSetConfig = Spring.GetConfigString, Spring.SetConfigString + local realGetKeyCode = Spring.GetKeyCode + + VFS.LoadFile = function(path) + if files[path] ~= nil then + return files[path] + end + + local file = realOpen(path, "rb") + if not file then + return nil + end + + local contents = file:read("*a") + file:close() + + return contents + end + Spring.GetConfigString = function(_, default) + return default + end + Spring.SetConfigString = function() end + Spring.GetKeyCode = function() + return 1 + end + io.open = function(path, mode) + if mode == "w" then + writes[path] = "" + + return { + write = function(_, text) + writes[path] = writes[path] .. text + end, + close = function() end, + } + end + + return realOpen(path, mode) + end + + -- The body hands back one table: pcall keeps only the first result, and a nil answer among + -- several would not survive being packed either. + local ok, result = pcall(function() + return body(VFS.Include("luaui/Include/keybind_profiles.lua")) + end) + + io.open, VFS.LoadFile = realOpen, realLoadFile + Spring.GetConfigString, Spring.SetConfigString = realGetConfig, realSetConfig + Spring.GetKeyCode = realGetKeyCode + + assert(ok, tostring(result)) + + return result +end + +-- A player who has run the game once on the profile named: the store records what was written +-- and uikeys.txt holds it. Hands back both, as the next launch would find them. +local function afterFirstRun(name) + local writes = {} + run( + { [STORE] = '{"version":2,"active":"' .. name .. '","profiles":[]}', [KEYMAP] = false }, + writes, + function(profiles) + return { path = profiles.materialize(name) } + end + ) + + return { store = assert(writes[STORE], "no store written"), keymap = assert(writes[KEYMAP], "no keymap written") } +end + +-- What the next launch does with that pair, with `change` free to move either side first. +local function nextRun(disk, change) + local writes = {} + local result = run({ [STORE] = disk.store, [KEYMAP] = disk.keymap }, writes, function(profiles) + if change then + change(profiles) + end + + return { adopted = profiles.adoptEditedKeymap(), active = profiles.activeName() } + end) + result.keymap = writes[KEYMAP] + result.store = writes[STORE] or disk.store + + return result +end + +describe("reconciling the keymap with the profiles behind it", function() + it("carries a changed shipped profile onto a player sitting on it", function() + local result = nextRun(afterFirstRun("Grid"), function(profiles) + -- The shipped side gains an action, the way a game update adding one would. + local grid = assert(profiles.isBuiltin("Grid")) + grid.binds[#grid.binds + 1] = { keyset = "Ctrl+Alt+Shift+k", action = "somethingnew" } + end) + + assert.is_nil(result.adopted) + assert.are.equal("Grid", result.active) + assert.is_truthy(result.keymap and result.keymap:find("somethingnew", 1, true)) + end) + + it("carries a store changed outside the game onto an untouched keymap", function() + local disk = afterFirstRun("Grid") + local store = Json.decode(disk.store) + store.profiles[#store.profiles + 1] = { + name = "Mine", + binds = { { keyset = "Ctrl+Alt+Shift+m", action = "theirtoolwrotethis" } }, + } + store.active = "Mine" + disk.store = Json.encode(store) + + local result = nextRun(disk) + + assert.is_nil(result.adopted) + assert.are.equal("Mine", result.active) + assert.is_truthy(result.keymap and result.keymap:find("theirtoolwrotethis", 1, true)) + end) + + -- The stamp is of the file we emit. A player who points KeybindingFile somewhere else is + -- read from that file instead, so the stamp never matches and the whole-keymap test is all + -- that stands between them and a fresh copy of their keymap every single launch. + it("forks a keymap it does not recognise only once", function() + local disk = afterFirstRun("Grid") + disk.keymap = disk.keymap .. "\nbind Ctrl+Alt+Shift+j theirownbinding" + + local first = nextRun(disk) + assert.is_not_nil(first.adopted) + + -- Their file is untouched by that, and the fork now holds what it says. + local second = nextRun({ store = first.store, keymap = disk.keymap }) + + assert.is_nil(second.adopted) + assert.are.equal(first.adopted, second.active) + end) + + it("keeps a keymap the player edited themselves", function() + local disk = afterFirstRun("Grid") + disk.keymap = disk.keymap .. "\nbind Ctrl+Alt+Shift+j theirownbinding" + + local result = nextRun(disk) + + assert.is_not_nil(result.adopted) + assert.are.equal(result.adopted, result.active) + end) +end) From 781dc12fef18681af1d9f7ca92b4c936e818eb8c Mon Sep 17 00:00:00 2001 From: Robert Burnham Date: Thu, 17 Sep 2026 17:01:46 -0500 Subject: [PATCH 6/6] Keybind editor: display, ordering and navigation fixes (#9278) A pass over the keybind editor, cleaning up what testing and review turned up. - placeholder text showing as an action description - revert hint on the whole row rather than the default chip - a key's actions listed out of the order the engine tries them - keychains listed among what a single press fires (these still show, but aren't in the middle of the list, on the keyboard view) - clicking a key on the keyboard left you in a hidden filter (UX on this was preserved by using functionality that Floris had already built into the regular "search" box) - Any+ bindings missing from a search naming modifiers - the list not scrolling back to the top when narrowed - a search outliving the panel it was typed in - the selected profile styling breaking up the dropdown AI disclosure: written with assistance from Claude Code. --- language/en/interface.json | 4 +- luaui/Include/keybind_dropdown.lua | 24 ++- luaui/Include/keybind_editor_view.lua | 254 +++++++++++--------------- luaui/Include/keybind_keyboard.lua | 104 ++++++----- 4 files changed, 184 insertions(+), 202 deletions(-) diff --git a/language/en/interface.json b/language/en/interface.json index 51617357cc6..efe9fdada9e 100644 --- a/language/en/interface.json +++ b/language/en/interface.json @@ -242,7 +242,7 @@ "allCategories": "All", "search": "Search actions or keys...", "boundTo": "Bound to %{keys}", - "boundToAny": "Also fires on this key with any modifier held", + "boundToAny": "Also fires on these keys with any modifier held", "preset": "Preset", "defaultTag": "Default", "pressKey": "Press a key or mouse button...", @@ -274,7 +274,7 @@ "conflictCapture": "Also bound to: %{actions}", "defaultIn": "Default in %{name}: %{keys}", "defaultNone": "Not bound in %{name}", - "revertHint": "Click to put the default keybind back.", + "revertTooltip": "Reset keybind back to %{keys}", "revertNone": "none", "revertChip": "default: %{keys}", "presetDefault": "A default preset. It cannot be changed; editing it saves a new preset of your own.", diff --git a/luaui/Include/keybind_dropdown.lua b/luaui/Include/keybind_dropdown.lua index c9dcb65e7c0..67098c453b7 100644 --- a/luaui/Include/keybind_dropdown.lua +++ b/luaui/Include/keybind_dropdown.lua @@ -24,11 +24,13 @@ local hoverOpacity = 0.25 -- is on it, the other says this is the option a click would take. local controlHoverOpacity = 0.14 local white = { 1, 1, 1 } +-- The two ends of the wash SelectHighlight paints, taken from the colour it would be handed so +-- a row of the list and the control above it cannot drift apart. Held rather than built per +-- row per frame. +local washLow = { white[1] * 0.5, white[2] * 0.5, white[3] * 0.5, hoverOpacity } +local washHigh = { white[1], white[2], white[3], hoverOpacity } local listFill = { 0.09, 0.09, 0.09, 0.96 } local tagFill = { 1, 1, 1, 0.08 } --- Under the option the list was opened on. Fainter than the hover, so the two stay apart --- when the cursor is on another row. -local selectedFill = { 1, 1, 1, 0.07 } local ruleColor = { 1, 1, 1, 0.14 } -- Font is fetched per draw; it does not exist when this file is included. @@ -277,11 +279,17 @@ function Dropdown:draw() for i, opt in ipairs(self.options) do ---@type table local r = self.optRects[i] - if self.markSelected and i == self.selected then - R(r.x1, r.y1, r.x2, r.y2, cs, 1, 1, 1, 1, selectedFill) - end - if mx >= r.x1 and mx <= r.x2 and my >= r.y1 and my <= r.y2 then - Highlight(r.x1, r.y1, r.x2, r.y2, cs, hoverOpacity, white) + -- The option the list was opened on is shaded exactly as a hovered one: one treatment + -- for the list rather than two washes of different weights sitting next to each other. + -- Drawn once when the cursor is on that row, so it does not double up. + local hovered = mx >= r.x1 and mx <= r.x2 and my >= r.y1 and my <= r.y2 + if hovered or (self.markSelected and i == self.selected) then + -- The wash SelectHighlight draws, with the corners ours to set: it rounds all four, + -- and a row has corners only where the list itself has them. Square against the row + -- above or below, which has none to meet. + local first = (i == 1) and 1 or 0 + local last = (i == #self.options) and 1 or 0 + R(r.x1, r.y1, r.x2, r.y2, cs, first, first, last, last, washLow, washHigh) end local optTag = optionTag(opt) diff --git a/luaui/Include/keybind_editor_view.lua b/luaui/Include/keybind_editor_view.lua index 333e87a40df..7c9129287ee 100644 --- a/luaui/Include/keybind_editor_view.lua +++ b/luaui/Include/keybind_editor_view.lua @@ -704,7 +704,11 @@ local function buildResolvedCatalog() end for _, key in ipairs(keys) do local found = BAR.I18N(key, { default = "" }) - if type(found) == "string" and found ~= "" and found ~= key then + -- The engine command descriptions were filled in in bulk, and the few commands that + -- had none got a stand-in reading "" rather than + -- being left out. Shown, that is what a row says it does. + local placeholder = type(found) == "string" and found:match("^%b<>$") ~= nil + if type(found) == "string" and found ~= "" and found ~= key and not placeholder then return found end end @@ -858,7 +862,6 @@ local function buildResolvedCatalog() 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") L.revertNone = BAR.I18N("ui.keybinds.editor.revertNone") L.presetDefault = BAR.I18N("ui.keybinds.editor.presetDefault") L.presetOwn = BAR.I18N("ui.keybinds.editor.presetOwn") @@ -890,6 +893,12 @@ local function buildResolvedCatalog() L.cancel = BAR.I18N("ui.keybinds.editor.cancel") end +-- The modifier names a search can name, off the same list the chips are printed from. +local modifierKey = {} +for _, name in ipairs(keyConfig.modifierOrder) do + modifierKey[name:lower()] = true +end + -- A keyset's canonical form, kept on the keyset record against the raw it came from: the -- change and conflict checks below run for every row on every rebuild. local function canonOf(k) @@ -1033,22 +1042,31 @@ 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 -- "q ctrl" all find what Ctrl+Q does. Whole keys only, as the chips print them: "f1" does not -- find F11, and a paired action's hidden Shift half does not answer to "shift". local wantKeys = {} + -- The same keys with the modifiers dropped. An Any+ binding fires whatever is held, so it + -- answers a query naming modifiers even though its chip prints the bare key and holds none + -- of them; without this it is missing from the one search that should find it. + local wantPlain, namedMods = {}, false for key in query.text:gmatch("[^%s%+]+") do wantKeys[#wantKeys + 1] = key + if modifierKey[key] then + namedMods = true + else + wantPlain[#wantPlain + 1] = key + end end + -- How the action answers the query: "exact" when a chip holds every key named, "any" when it + -- only holds the keys and carries Any+ for the modifiers, false when neither. local function boundToQuery(action) if not (wantKeys[1] and action) then return false end + local any = false local pair = catalogShiftPair[action] for _, k in ipairs(working.byAction[action] or {}) do -- The chip's text, which for a paired action is not the keyset's own. Kept on the keyset @@ -1061,55 +1079,21 @@ local function rebuildRows() shown = k.unshifted end if keybindModel.holdsKeys(shown, wantKeys) then - return true + return "exact" end - end - - 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 + if namedMods and wantPlain[1] then + local mods = keybindModel.splitElement(canonOf(k)) + if mods.any and keybindModel.holdsKeys(shown, wantPlain) 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 - + -- Whether an action is listed by key: by the keys the search text names, within the + -- category shown. + local function keyHit(action, _, inCategory) return inCategory and boundToQuery(action) end -- Rows found by key are listed ahead of everything found by name, under a heading of their @@ -1131,7 +1115,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 = filter ~= nil or not selectedCategory or changedOnly or group.category == selectedCategory + local inCategory = 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) @@ -1217,14 +1201,9 @@ local function rebuildRows() (change or not changedOnly) and ( byKey - or ( - not filter - and ( - categoryMatch - or Search.matches(query, action:lower()) - or Search.matches(query, label:lower()) - ) - ) + or categoryMatch + or Search.matches(query, action:lower()) + or Search.matches(query, label:lower()) ) then local entry = { @@ -1233,7 +1212,7 @@ local function rebuildRows() label = label, description = item.description, change = change, - filterAny = byKey == "any", + queryAny = byKey == "any", } if not byKey then groupRows[#groupRows + 1] = entry @@ -1259,14 +1238,9 @@ local function rebuildRows() (change or not changedOnly) and ( byKey - or ( - not filter - and ( - categoryMatch - or Search.matches(query, item.labelLower) - or Search.matches(query, item.actionLower) - ) - ) + or categoryMatch + or Search.matches(query, item.labelLower) + or Search.matches(query, item.actionLower) ) then local entry = { @@ -1277,7 +1251,7 @@ local function rebuildRows() cursorColumn = group.hasCursors, description = item.description, change = change, - filterAny = byKey == "any", + queryAny = byKey == "any", } if not byKey then groupRows[#groupRows + 1] = entry @@ -1326,7 +1300,7 @@ local function rebuildRows() -- Not what the column entry asked for. elseif keyHit(action, action, inOther) then otherKeyed[#otherKeyed + 1] = action - elseif not filter and (otherMatch or Search.matches(query, action:lower())) then + elseif otherMatch or Search.matches(query, action:lower()) then others[#others + 1] = action end end @@ -1339,7 +1313,7 @@ local function rebuildRows() action = action, label = action, change = rowChange(action), - filterAny = filter ~= nil and boundToFilter(action) == "any", + queryAny = boundToQuery(action) == "any", } end @@ -1368,8 +1342,7 @@ 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. - -- Under a key filter the heading is always there, since it is where the filter is cleared. - if #keyRows > 0 or filter then + if #keyRows > 0 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 @@ -1385,18 +1358,20 @@ 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 = named }), clear = filter ~= nil }, + { + type = "header", + text = BAR.I18N("ui.keybinds.editor.boundTo", { keys = table.concat(keys, " + ") }), + }, } - -- 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. + -- What answers only through Any+ is set apart: it does fire on the keys searched for, 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 - if keyRows[i].filterAny and filter and next(filter.mods) then + if keyRows[i].queryAny then anyRows[#anyRows + 1] = keyRows[i] else ordered[#ordered + 1] = keyRows[i] @@ -1427,7 +1402,7 @@ local function rebuildRows() -- 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 changedOnly and #rows == 0 then if not state.base then rows[1] = { type = "note", text = L.compareNoneHint } else @@ -1525,15 +1500,6 @@ function state.pickBase(option) 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 ---------------------------------------------------------------- @@ -2518,10 +2484,12 @@ function view.blur() 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) + -- The search is a view of the moment; the panel opens on the whole list next time. Clicking + -- a key on the keyboard page narrows through the same box, so this is what keeps that from + -- outliving the visit that asked for it. + if searchBox and searchBox:getText() ~= "" then + searchBox:setText("") + scroll = 0 end -- Or the blur outlives the panel: guishader keeps drawing a rect nobody owns any more. @@ -3209,6 +3177,8 @@ local function rowLayout(row) end end local keys = #shown > 0 and table.concat(shown, ", ") or L.revertNone + -- Kept whole for the chip's own tooltip, which has room the chip itself does not. + lay.ghostKeysFull = keys lay.ghostFs = floor(metrics.rowFs * 0.9) keys = text.fit(font, keys, floor((listRight - metrics.keyAreaX1) * 0.3), lay.ghostFs) lay.ghostKeys = keys @@ -3778,11 +3748,7 @@ 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 @@ -4338,8 +4304,6 @@ 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 @@ -4631,35 +4595,46 @@ function state.showTooltips(mx, my) key = "row|" .. row.action .. "|" .. hover.zone .. "|" .. hover.idx .. "|" .. rowsGen .. "|" .. layoutGen title = row.label if key ~= state.tipKey then - lines = {} - if row.description then - lines[#lines + 1] = colorText .. row.description - end local lay = rowLayout(row) - local m = hover.idx > 0 and lay.mets[hover.idx] - if m and m.others then - local names = {} - for i, o in ipairs(m.others) do - local name = state.labels[o.action] or o.action - names[i] = o.before and BAR.I18N("ui.keybinds.editor.conflictFirst", { action = name }) or name + lines = {} + -- The chip is a control of its own, so it says what clicking it does and nothing + -- else. Leading with the row, which describes something the click does not do, + -- buries the one line that belongs to what is under the cursor. + if hover.zone == "revert" and row.change and state.base then + title = nil + lines[1] = colorText .. BAR.I18N("ui.keybinds.editor.revertTooltip", { keys = lay.ghostKeysFull }) + else + if row.description then + lines[#lines + 1] = colorText .. row.description end - -- A warning when the sharing is the player's; a note when the game ships it so. - lines[#lines + 1] = (m.clash and colorDanger or colorDim) - .. BAR.I18N( - "ui.keybinds.editor.conflict", - { keys = m.group.display, actions = table.concat(names, ", ") } - ) - lines[#lines + 1] = colorDim .. (m.clash and L.conflictOrder or L.conflictShipped) - end - if row.change and state.base then - if #row.change > 0 then - lines[#lines + 1] = colorHeader - .. BAR.I18N("ui.keybinds.editor.defaultIn", { name = state.base.name, keys = lay.ghostKeys }) - else - lines[#lines + 1] = colorHeader - .. BAR.I18N("ui.keybinds.editor.defaultNone", { name = state.base.name }) + local m = hover.idx > 0 and lay.mets[hover.idx] + if m and m.others then + local names = {} + for i, o in ipairs(m.others) do + local name = state.labels[o.action] or o.action + names[i] = o.before and BAR.I18N("ui.keybinds.editor.conflictFirst", { action = name }) + or name + end + -- A warning when the sharing is the player's; a note when the game ships it so. + lines[#lines + 1] = (m.clash and colorDanger or colorDim) + .. BAR.I18N( + "ui.keybinds.editor.conflict", + { keys = m.group.display, actions = table.concat(names, ", ") } + ) + lines[#lines + 1] = colorDim .. (m.clash and L.conflictOrder or L.conflictShipped) + end + if row.change and state.base then + if #row.change > 0 then + lines[#lines + 1] = colorHeader + .. BAR.I18N( + "ui.keybinds.editor.defaultIn", + { name = state.base.name, keys = lay.ghostKeys } + ) + else + lines[#lines + 1] = colorHeader + .. BAR.I18N("ui.keybinds.editor.defaultNone", { name = state.base.name }) + end end - lines[#lines + 1] = colorDim .. L.revertHint end end end @@ -5095,36 +5070,25 @@ function view.mousePress(x, y, button) -- 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. + -- it with its bindings to hand. 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 + -- Handed to the search box rather than filtered behind the scenes. It finds what a + -- key holds already, and it does it somewhere the player can see what is narrowing + -- the list and clear it. The keyset is spelled the way they would have typed it. state.setPage("list") selectedCategory = nil - state.setKeyFilter({ - id = key.id, - layer = layer, - tokens = tokens, - mods = mods, - display = state.keyboard:keysetName(key, layer), - }) + scroll = 0 + if searchBox then + searchBox:setText(state.keyboard:keysetName(key, layer)) + end 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 @@ -5162,8 +5126,6 @@ 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 @@ -5254,12 +5216,6 @@ 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 index 5cbb4c0a217..7641c3231b2 100644 --- a/luaui/Include/keybind_keyboard.lua +++ b/luaui/Include/keybind_keyboard.lua @@ -13,11 +13,13 @@ -- 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. +-- doing: the best by catalog order, not the first 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 +-- picks out the general action, which is what the key is for. The tooltip lists them all: what +-- one press can fire first, in bind order, since that is the order they are actually tried in, +-- and the chains this key only begins after, since a press alone never reaches those. local keybindModel = VFS.Include("luaui/Include/keybind_model.lua") local keyConfig = VFS.Include("luaui/configs/keyboard_layouts.lua") @@ -44,8 +46,6 @@ local glBlending = gl.Blending ---@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 @@ -294,7 +294,7 @@ 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. + -- whose layer is showing, and a key the search found. bound = { 0.22, 0.22, 0.22, 1 }, unbound = { 0.16, 0.16, 0.16, 1 }, modifier = { 0.28, 0.28, 0.28, 1 }, @@ -391,8 +391,6 @@ function M.new() 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 @@ -660,34 +658,32 @@ function M:infoOf(entry) 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. +-- What a key holds on a layer. What one press of it can fire comes first, in the order the +-- engine tries them - the bindings naming exactly those modifiers, then the Any+ ones, each +-- block as it was bound - and the chains this key only begins come after, since a press alone +-- never reaches them. Which of them the face wears is a different question, and faceEntry +-- answers it. 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 presses, chains = {}, {} 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 + for _, e in ipairs(list or {}) do + local into = e.chain and chains or presses + into[#into + 1] = e end end - take(key.layers[layer] or {}) + take(key.layers[layer]) take(key.any) + local entries = presses + for _, e in ipairs(chains) do + entries[#entries + 1] = e + end + -- 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 @@ -755,13 +751,6 @@ function M:setQuery(str) 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 @@ -812,7 +801,6 @@ function M:signature(hoverIdx) .. "|" .. self.view .. "|" - .. self.filterGen end -- A click: the toggle swaps the view; a modifier toggles its layer; a bound key is handed @@ -881,8 +869,8 @@ function M:tooltip(idx) 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. +-- The tooltip's lines: every action on the key for this layer, what one press fires first and +-- in the order the engine tries them, each with what it does; then what a click here does. function M:tooltipLines(idx) local L = self.L if idx == -1 then @@ -945,14 +933,46 @@ function M:nameSize(key, room) return size end +-- The one action a key wears. Not the first the engine would try: the presets lean on bind +-- order to put a special case ahead of the general one - "stopproduction" before "stop" on G +-- - and the face is for what the key is for. Lowest catalog rank takes it, ties by action so +-- the pick does not move between frames. +function M:faceEntry(key, layer) + local best, bestRank, bestChain + for _, e in ipairs(self:entries(key, layer)) do + local rank = self:infoOf(e).rank or math.huge + local chain = e.chain and true or false + local better + if not best then + better = true + elseif chain ~= bestChain then + -- A press wins over a chain whatever the catalog says: the cap answers for what + -- pressing the key does, not for what it begins. + better = not chain + elseif rank ~= bestRank then + better = rank < bestRank + else + better = e.action < best.action + end + if better then + best, bestRank, bestChain = e, rank, chain + end + end + + return best +end + -- 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) +function M:faceLines(key, layer, faceW, maxLines) + -- Asked for before the cache is looked at: a placement this answer is stale for rebuilds the + -- show table, and reading it first would leave this writing its lines into the one that was + -- thrown away, so the cache would never hit again. + local first = self:faceEntry(key, layer) 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) @@ -1016,7 +1036,6 @@ function M:draw(hoverIdx) local unit, pad, cs = self.unit, self.pad, self.cs local padY = self.padY local searching = not self.query.empty - local filter = self.filter local nameLineH = floor(self.nameFs * 1.12) local lineH = floor(self.labelFs * 1.1) local prints = {} @@ -1030,8 +1049,7 @@ function M:draw(hoverIdx) 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 hit = searching and self:matches(key, entries) local fill = (active and look.modifierActive) or (hit and look.hit) or (key.mod and look.modifier) @@ -1070,7 +1088,7 @@ function M:draw(hoverIdx) local bandTop = nameTop - nameLineH local bandBottom = fy1 + floor(padY * 0.5) local maxLines = min(3, max(1, floor((bandTop - bandBottom) / lineH))) - local lines, first = self:faceLines(key, layer, entries, faceW, maxLines) + local lines, first = self:faceLines(key, layer, 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