feat: add Shift+ modifier support for keybinds (#3679)

## Description:

This PR adds support for `Shift+<key>` keybind combinations across the
entire keybind system.

Previously, keybinds only supported a single key (e.g. `KeyB` for boat
attack). Now any keybind can be configured as `Shift+KeyB`, which will
only trigger when Shift is held down simultaneously.

Enables to use Shift + A for "select all" feature from #3677 

**Changes:**
- `InputHandler.ts`: Added `parseKeybind()` helper that parses
`"Shift+KeyB"` → `{ shift: true, code: "KeyB" }`. Added
`keybindMatchesEvent()` for consistent matching across all keyup/keydown
handlers. Updated `resolveBuildKeybind()` and all keybind comparisons to
respect the shift modifier.
- `SettingKeybind.ts`: When recording a keybind, lone modifier keys
(Shift, Ctrl, etc.) are skipped — the component waits for the actual
key. If Shift is held when the key is pressed, the value is stored as
`"Shift+<code>"`.
- `Utils.ts`: `formatKeyForDisplay()` now handles the `Shift+` prefix,
displaying e.g. `"Shift+B"`.
- `tests/InputHandler.test.ts`: Added 6 tests covering Shift+ keybind
matching, negative cases (plain key not triggering Shift-bound action),
coexistence of `Digit1` and `Shift+Digit1` on different actions, and
Numpad alias support with Shift.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## UI changes:

<img width="2255" height="2070" alt="CleanShot 2026-04-15 at 20 23
25@2x"
src="https://github.com/user-attachments/assets/96c19fc3-6294-40b7-82eb-3fde52b71618"
/>


## Please put your Discord username so you can be contacted if a bug or
regression is found:

fghjk_60845
This commit is contained in:
Ivan Batsulin
2026-04-17 05:46:01 +03:00
committed by GitHub
parent d0a9146843
commit e5e1211480
6 changed files with 236 additions and 21 deletions
+1
View File
@@ -545,6 +545,7 @@
"title": "Settings",
"tab_basic": "Basic Settings",
"tab_keybinds": "Keybinds",
"keybinds_hint": "Click a key to rebind it. You can assign a single key or Shift + key combination.",
"dark_mode_label": "Dark Mode",
"dark_mode_desc": "Toggle the sites appearance between light and dark themes",
"emojis_label": "Emojis",
+59 -19
View File
@@ -281,7 +281,7 @@ export class InputHandler {
return;
}
if (e.code === this.keybinds.toggleView) {
if (this.keybindMatchesEvent(e, this.keybinds.toggleView)) {
e.preventDefault();
if (!this.alternateView) {
this.alternateView = true;
@@ -289,7 +289,10 @@ export class InputHandler {
}
}
if (e.code === this.keybinds.coordinateGrid && !e.repeat) {
if (
this.keybindMatchesEvent(e, this.keybinds.coordinateGrid) &&
!e.repeat
) {
e.preventDefault();
this.coordinateGridEnabled = !this.coordinateGridEnabled;
this.eventBus.emit(
@@ -375,7 +378,7 @@ export class InputHandler {
this.activeKeys.delete(this.keybinds.zoomOut);
}
if (e.code === this.keybinds.toggleView) {
if (this.keybindMatchesEvent(e, this.keybinds.toggleView)) {
e.preventDefault();
this.alternateView = false;
this.eventBus.emit(new AlternateViewEvent(false));
@@ -387,55 +390,58 @@ export class InputHandler {
this.eventBus.emit(new RefreshGraphicsEvent());
}
if (e.code === this.keybinds.boatAttack) {
if (this.keybindMatchesEvent(e, this.keybinds.boatAttack)) {
e.preventDefault();
this.eventBus.emit(new DoBoatAttackEvent());
}
if (e.code === this.keybinds.groundAttack) {
if (this.keybindMatchesEvent(e, this.keybinds.groundAttack)) {
e.preventDefault();
this.eventBus.emit(new DoGroundAttackEvent());
}
if (e.code === this.keybinds.attackRatioDown) {
if (this.keybindMatchesEvent(e, this.keybinds.attackRatioDown)) {
e.preventDefault();
const increment = this.userSettings.attackRatioIncrement();
this.eventBus.emit(new AttackRatioEvent(-increment));
}
if (e.code === this.keybinds.attackRatioUp) {
if (this.keybindMatchesEvent(e, this.keybinds.attackRatioUp)) {
e.preventDefault();
const increment = this.userSettings.attackRatioIncrement();
this.eventBus.emit(new AttackRatioEvent(increment));
}
if (e.code === this.keybinds.centerCamera) {
if (this.keybindMatchesEvent(e, this.keybinds.centerCamera)) {
e.preventDefault();
this.eventBus.emit(new CenterCameraEvent());
}
// Two-phase build keybind matching: exact code match first, then digit/Numpad alias.
const matchedBuild = this.resolveBuildKeybind(e.code);
const matchedBuild = this.resolveBuildKeybind(e.code, e.shiftKey);
if (matchedBuild !== null) {
e.preventDefault();
this.setGhostStructure(matchedBuild);
}
if (e.code === this.keybinds.swapDirection) {
if (this.keybindMatchesEvent(e, this.keybinds.swapDirection)) {
e.preventDefault();
const nextDirection = !this.uiState.rocketDirectionUp;
this.eventBus.emit(new SwapRocketDirectionEvent(nextDirection));
}
if (!e.repeat && e.code === this.keybinds.pauseGame) {
if (!e.repeat && this.keybindMatchesEvent(e, this.keybinds.pauseGame)) {
e.preventDefault();
this.eventBus.emit(new TogglePauseIntentEvent());
}
if (!e.repeat && e.code === this.keybinds.gameSpeedUp) {
if (!e.repeat && this.keybindMatchesEvent(e, this.keybinds.gameSpeedUp)) {
e.preventDefault();
this.eventBus.emit(new GameSpeedUpIntentEvent());
}
if (!e.repeat && e.code === this.keybinds.gameSpeedDown) {
if (
!e.repeat &&
this.keybindMatchesEvent(e, this.keybinds.gameSpeedDown)
) {
e.preventDefault();
this.eventBus.emit(new GameSpeedDownIntentEvent());
}
@@ -615,6 +621,27 @@ export class InputHandler {
this.eventBus.emit(new GhostStructureChangedEvent(ghostStructure));
}
/**
* Parses a keybind value that may include a "Shift+" prefix.
* e.g. "Shift+KeyB" → { shift: true, code: "KeyB" }
* "KeyB" → { shift: false, code: "KeyB" }
*/
private parseKeybind(value: string): { shift: boolean; code: string } {
if (value?.startsWith("Shift+")) {
return { shift: true, code: value.slice(6) };
}
return { shift: false, code: value };
}
/**
* Returns true if the keyboard event matches the given keybind value,
* including optional Shift+ prefix support.
*/
private keybindMatchesEvent(e: KeyboardEvent, keybindValue: string): boolean {
const parsed = this.parseKeybind(keybindValue);
return e.code === parsed.code && e.shiftKey === parsed.shift;
}
/**
* Extracts the digit character from KeyboardEvent.code.
* Codes look like "Digit0".."Digit9" (6 chars, digit at index 5) and
@@ -637,17 +664,25 @@ export class InputHandler {
}
/** Strict equality only: used for first-pass exact KeyboardEvent.code match. */
private buildKeybindMatches(code: string, keybindValue: string): boolean {
return code === keybindValue;
private buildKeybindMatches(
code: string,
shiftKey: boolean,
keybindValue: string,
): boolean {
const parsed = this.parseKeybind(keybindValue);
return code === parsed.code && shiftKey === parsed.shift;
}
/** Digit/Numpad alias match: used only when no exact match was found. */
private buildKeybindMatchesDigit(
code: string,
shiftKey: boolean,
keybindValue: string,
): boolean {
const parsed = this.parseKeybind(keybindValue);
if (shiftKey !== parsed.shift) return false;
const digit = this.digitFromKeyCode(code);
const bindDigit = this.digitFromKeyCode(keybindValue);
const bindDigit = this.digitFromKeyCode(parsed.code);
return digit !== null && bindDigit !== null && digit === bindDigit;
}
@@ -655,7 +690,10 @@ export class InputHandler {
* Resolves a keyup code to a build action: exact code match first, then digit/Numpad alias.
* Returns the UnitType to set as ghost, or null if no build keybind matched.
*/
private resolveBuildKeybind(code: string): PlayerBuildableUnitType | null {
private resolveBuildKeybind(
code: string,
shiftKey: boolean,
): PlayerBuildableUnitType | null {
const buildKeybinds: ReadonlyArray<{
key: string;
type: PlayerBuildableUnitType;
@@ -672,10 +710,12 @@ export class InputHandler {
{ key: "buildMIRV", type: UnitType.MIRV },
];
for (const { key, type } of buildKeybinds) {
if (this.buildKeybindMatches(code, this.keybinds[key])) return type;
if (this.buildKeybindMatches(code, shiftKey, this.keybinds[key]))
return type;
}
for (const { key, type } of buildKeybinds) {
if (this.buildKeybindMatchesDigit(code, this.keybinds[key])) return type;
if (this.buildKeybindMatchesDigit(code, shiftKey, this.keybinds[key]))
return type;
}
return null;
}
+20
View File
@@ -383,6 +383,26 @@ export class UserSettingModal extends BaseModal {
private renderKeybindSettings() {
return html`
<div
class="flex items-center gap-2 px-3 py-2 mb-3 rounded-lg bg-blue-500/10 border border-blue-500/20 text-blue-300/70 text-xs"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3.5 w-3.5 shrink-0 opacity-70"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
${translateText("user_setting.keybinds_hint")}
</div>
<h2
class="text-blue-200 text-xl font-bold mt-4 mb-3 border-b border-white/10 pb-2"
>
+5
View File
@@ -314,6 +314,11 @@ export function formatKeyForDisplay(value: string): string {
// Handle empty string
if (!value) return "";
// Handle Shift+ prefix: format as "Shift+X"
if (value.startsWith("Shift+")) {
return "Shift+" + formatKeyForDisplay(value.slice(6));
}
// Handle space character or "Space" key
if (value === " " || value === "Space") return "Space";
@@ -100,17 +100,32 @@ export class SettingKeybind extends LitElement {
return;
}
// Don't capture lone modifier keys — wait for the actual key
if (
e.code === "ShiftLeft" ||
e.code === "ShiftRight" ||
e.code === "ControlLeft" ||
e.code === "ControlRight" ||
e.code === "AltLeft" ||
e.code === "AltRight" ||
e.code === "MetaLeft" ||
e.code === "MetaRight"
) {
return;
}
// Prevent default only for keys we're actually capturing
e.preventDefault();
const code = e.code;
const code = e.shiftKey ? `Shift+${e.code}` : e.code;
const displayKey = e.shiftKey ? `Shift+${e.key.toUpperCase()}` : e.key;
const prevValue = this.value;
// Temporarily set the value to the new code for validation in parent
this.value = code;
const event = new CustomEvent("change", {
detail: { action: this.action, value: code, key: e.key, prevValue },
detail: { action: this.action, value: code, key: displayKey, prevValue },
bubbles: true,
composed: true,
});
+134
View File
@@ -725,4 +725,138 @@ describe("InputHandler AutoUpgrade", () => {
expect(inputHandler["uiState"].ghostStructure).toBe(UnitType.City);
});
});
describe("Shift+ keybind support", () => {
let uiState: UIState;
beforeEach(() => {
inputHandler.destroy();
uiState = {
attackRatio: 20,
ghostStructure: null,
rocketDirectionUp: true,
overlappingRailroads: [],
ghostRailPaths: [],
} as UIState;
});
test("Shift+Digit1 sets City when buildCity is bound to Shift+Digit1", () => {
testSettings.setKeybinds({ buildCity: "Shift+Digit1" });
inputHandler = new InputHandler(
mockGameView,
uiState,
mockCanvas,
eventBus,
);
inputHandler.initialize();
window.dispatchEvent(
new KeyboardEvent("keyup", { code: "Digit1", shiftKey: true }),
);
expect(uiState.ghostStructure).toBe(UnitType.City);
});
test("plain Digit1 does NOT trigger buildCity when bound to Shift+Digit1", () => {
testSettings.setKeybinds({ buildCity: "Shift+Digit1" });
inputHandler = new InputHandler(
mockGameView,
uiState,
mockCanvas,
eventBus,
);
inputHandler.initialize();
window.dispatchEvent(
new KeyboardEvent("keyup", { code: "Digit1", shiftKey: false }),
);
expect(uiState.ghostStructure).toBeNull();
});
test("Shift+KeyB triggers boatAttack when bound to Shift+KeyB", () => {
testSettings.setKeybinds({ boatAttack: "Shift+KeyB" });
inputHandler = new InputHandler(
mockGameView,
uiState,
mockCanvas,
eventBus,
);
inputHandler.initialize();
const mockEmit = vi.spyOn(eventBus, "emit");
window.dispatchEvent(
new KeyboardEvent("keyup", { code: "KeyB", shiftKey: true }),
);
const emittedTypes = mockEmit.mock.calls.map(
(call) => call[0].constructor.name,
);
expect(emittedTypes).toContain("DoBoatAttackEvent");
});
test("plain KeyB does NOT trigger boatAttack when bound to Shift+KeyB", () => {
testSettings.setKeybinds({ boatAttack: "Shift+KeyB" });
inputHandler = new InputHandler(
mockGameView,
uiState,
mockCanvas,
eventBus,
);
inputHandler.initialize();
const mockEmit = vi.spyOn(eventBus, "emit");
window.dispatchEvent(
new KeyboardEvent("keyup", { code: "KeyB", shiftKey: false }),
);
const emittedTypes = mockEmit.mock.calls.map(
(call) => call[0].constructor.name,
);
expect(emittedTypes).not.toContain("DoBoatAttackEvent");
});
test("Shift+Digit1 and Digit1 can be bound to different actions without conflict", () => {
testSettings.setKeybinds({
buildCity: "Digit1",
buildFactory: "Shift+Digit1",
});
inputHandler = new InputHandler(
mockGameView,
uiState,
mockCanvas,
eventBus,
);
inputHandler.initialize();
window.dispatchEvent(
new KeyboardEvent("keyup", { code: "Digit1", shiftKey: false }),
);
expect(uiState.ghostStructure).toBe(UnitType.City);
uiState.ghostStructure = null;
window.dispatchEvent(
new KeyboardEvent("keyup", { code: "Digit1", shiftKey: true }),
);
expect(uiState.ghostStructure).toBe(UnitType.Factory);
});
test("Numpad alias works with Shift+Digit keybind", () => {
testSettings.setKeybinds({ buildCity: "Shift+Digit1" });
inputHandler = new InputHandler(
mockGameView,
uiState,
mockCanvas,
eventBus,
);
inputHandler.initialize();
window.dispatchEvent(
new KeyboardEvent("keyup", { code: "Numpad1", shiftKey: true }),
);
expect(uiState.ghostStructure).toBe(UnitType.City);
});
});
});