Skip to content

Commit e5e1211

Browse files
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
1 parent d0a9146 commit e5e1211

File tree

6 files changed

+236
-21
lines changed

6 files changed

+236
-21
lines changed

resources/lang/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,7 @@
545545
"title": "Settings",
546546
"tab_basic": "Basic Settings",
547547
"tab_keybinds": "Keybinds",
548+
"keybinds_hint": "Click a key to rebind it. You can assign a single key or Shift + key combination.",
548549
"dark_mode_label": "Dark Mode",
549550
"dark_mode_desc": "Toggle the site’s appearance between light and dark themes",
550551
"emojis_label": "Emojis",

src/client/InputHandler.ts

Lines changed: 59 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -281,15 +281,18 @@ export class InputHandler {
281281
return;
282282
}
283283

284-
if (e.code === this.keybinds.toggleView) {
284+
if (this.keybindMatchesEvent(e, this.keybinds.toggleView)) {
285285
e.preventDefault();
286286
if (!this.alternateView) {
287287
this.alternateView = true;
288288
this.eventBus.emit(new AlternateViewEvent(true));
289289
}
290290
}
291291

292-
if (e.code === this.keybinds.coordinateGrid && !e.repeat) {
292+
if (
293+
this.keybindMatchesEvent(e, this.keybinds.coordinateGrid) &&
294+
!e.repeat
295+
) {
293296
e.preventDefault();
294297
this.coordinateGridEnabled = !this.coordinateGridEnabled;
295298
this.eventBus.emit(
@@ -375,7 +378,7 @@ export class InputHandler {
375378
this.activeKeys.delete(this.keybinds.zoomOut);
376379
}
377380

378-
if (e.code === this.keybinds.toggleView) {
381+
if (this.keybindMatchesEvent(e, this.keybinds.toggleView)) {
379382
e.preventDefault();
380383
this.alternateView = false;
381384
this.eventBus.emit(new AlternateViewEvent(false));
@@ -387,55 +390,58 @@ export class InputHandler {
387390
this.eventBus.emit(new RefreshGraphicsEvent());
388391
}
389392

390-
if (e.code === this.keybinds.boatAttack) {
393+
if (this.keybindMatchesEvent(e, this.keybinds.boatAttack)) {
391394
e.preventDefault();
392395
this.eventBus.emit(new DoBoatAttackEvent());
393396
}
394397

395-
if (e.code === this.keybinds.groundAttack) {
398+
if (this.keybindMatchesEvent(e, this.keybinds.groundAttack)) {
396399
e.preventDefault();
397400
this.eventBus.emit(new DoGroundAttackEvent());
398401
}
399402

400-
if (e.code === this.keybinds.attackRatioDown) {
403+
if (this.keybindMatchesEvent(e, this.keybinds.attackRatioDown)) {
401404
e.preventDefault();
402405
const increment = this.userSettings.attackRatioIncrement();
403406
this.eventBus.emit(new AttackRatioEvent(-increment));
404407
}
405408

406-
if (e.code === this.keybinds.attackRatioUp) {
409+
if (this.keybindMatchesEvent(e, this.keybinds.attackRatioUp)) {
407410
e.preventDefault();
408411
const increment = this.userSettings.attackRatioIncrement();
409412
this.eventBus.emit(new AttackRatioEvent(increment));
410413
}
411414

412-
if (e.code === this.keybinds.centerCamera) {
415+
if (this.keybindMatchesEvent(e, this.keybinds.centerCamera)) {
413416
e.preventDefault();
414417
this.eventBus.emit(new CenterCameraEvent());
415418
}
416419

417420
// Two-phase build keybind matching: exact code match first, then digit/Numpad alias.
418-
const matchedBuild = this.resolveBuildKeybind(e.code);
421+
const matchedBuild = this.resolveBuildKeybind(e.code, e.shiftKey);
419422
if (matchedBuild !== null) {
420423
e.preventDefault();
421424
this.setGhostStructure(matchedBuild);
422425
}
423426

424-
if (e.code === this.keybinds.swapDirection) {
427+
if (this.keybindMatchesEvent(e, this.keybinds.swapDirection)) {
425428
e.preventDefault();
426429
const nextDirection = !this.uiState.rocketDirectionUp;
427430
this.eventBus.emit(new SwapRocketDirectionEvent(nextDirection));
428431
}
429432

430-
if (!e.repeat && e.code === this.keybinds.pauseGame) {
433+
if (!e.repeat && this.keybindMatchesEvent(e, this.keybinds.pauseGame)) {
431434
e.preventDefault();
432435
this.eventBus.emit(new TogglePauseIntentEvent());
433436
}
434-
if (!e.repeat && e.code === this.keybinds.gameSpeedUp) {
437+
if (!e.repeat && this.keybindMatchesEvent(e, this.keybinds.gameSpeedUp)) {
435438
e.preventDefault();
436439
this.eventBus.emit(new GameSpeedUpIntentEvent());
437440
}
438-
if (!e.repeat && e.code === this.keybinds.gameSpeedDown) {
441+
if (
442+
!e.repeat &&
443+
this.keybindMatchesEvent(e, this.keybinds.gameSpeedDown)
444+
) {
439445
e.preventDefault();
440446
this.eventBus.emit(new GameSpeedDownIntentEvent());
441447
}
@@ -615,6 +621,27 @@ export class InputHandler {
615621
this.eventBus.emit(new GhostStructureChangedEvent(ghostStructure));
616622
}
617623

624+
/**
625+
* Parses a keybind value that may include a "Shift+" prefix.
626+
* e.g. "Shift+KeyB" → { shift: true, code: "KeyB" }
627+
* "KeyB" → { shift: false, code: "KeyB" }
628+
*/
629+
private parseKeybind(value: string): { shift: boolean; code: string } {
630+
if (value?.startsWith("Shift+")) {
631+
return { shift: true, code: value.slice(6) };
632+
}
633+
return { shift: false, code: value };
634+
}
635+
636+
/**
637+
* Returns true if the keyboard event matches the given keybind value,
638+
* including optional Shift+ prefix support.
639+
*/
640+
private keybindMatchesEvent(e: KeyboardEvent, keybindValue: string): boolean {
641+
const parsed = this.parseKeybind(keybindValue);
642+
return e.code === parsed.code && e.shiftKey === parsed.shift;
643+
}
644+
618645
/**
619646
* Extracts the digit character from KeyboardEvent.code.
620647
* Codes look like "Digit0".."Digit9" (6 chars, digit at index 5) and
@@ -637,25 +664,36 @@ export class InputHandler {
637664
}
638665

639666
/** Strict equality only: used for first-pass exact KeyboardEvent.code match. */
640-
private buildKeybindMatches(code: string, keybindValue: string): boolean {
641-
return code === keybindValue;
667+
private buildKeybindMatches(
668+
code: string,
669+
shiftKey: boolean,
670+
keybindValue: string,
671+
): boolean {
672+
const parsed = this.parseKeybind(keybindValue);
673+
return code === parsed.code && shiftKey === parsed.shift;
642674
}
643675

644676
/** Digit/Numpad alias match: used only when no exact match was found. */
645677
private buildKeybindMatchesDigit(
646678
code: string,
679+
shiftKey: boolean,
647680
keybindValue: string,
648681
): boolean {
682+
const parsed = this.parseKeybind(keybindValue);
683+
if (shiftKey !== parsed.shift) return false;
649684
const digit = this.digitFromKeyCode(code);
650-
const bindDigit = this.digitFromKeyCode(keybindValue);
685+
const bindDigit = this.digitFromKeyCode(parsed.code);
651686
return digit !== null && bindDigit !== null && digit === bindDigit;
652687
}
653688

654689
/**
655690
* Resolves a keyup code to a build action: exact code match first, then digit/Numpad alias.
656691
* Returns the UnitType to set as ghost, or null if no build keybind matched.
657692
*/
658-
private resolveBuildKeybind(code: string): PlayerBuildableUnitType | null {
693+
private resolveBuildKeybind(
694+
code: string,
695+
shiftKey: boolean,
696+
): PlayerBuildableUnitType | null {
659697
const buildKeybinds: ReadonlyArray<{
660698
key: string;
661699
type: PlayerBuildableUnitType;
@@ -672,10 +710,12 @@ export class InputHandler {
672710
{ key: "buildMIRV", type: UnitType.MIRV },
673711
];
674712
for (const { key, type } of buildKeybinds) {
675-
if (this.buildKeybindMatches(code, this.keybinds[key])) return type;
713+
if (this.buildKeybindMatches(code, shiftKey, this.keybinds[key]))
714+
return type;
676715
}
677716
for (const { key, type } of buildKeybinds) {
678-
if (this.buildKeybindMatchesDigit(code, this.keybinds[key])) return type;
717+
if (this.buildKeybindMatchesDigit(code, shiftKey, this.keybinds[key]))
718+
return type;
679719
}
680720
return null;
681721
}

src/client/UserSettingModal.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,26 @@ export class UserSettingModal extends BaseModal {
383383

384384
private renderKeybindSettings() {
385385
return html`
386+
<div
387+
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"
388+
>
389+
<svg
390+
xmlns="http://www.w3.org/2000/svg"
391+
class="h-3.5 w-3.5 shrink-0 opacity-70"
392+
fill="none"
393+
viewBox="0 0 24 24"
394+
stroke="currentColor"
395+
>
396+
<path
397+
stroke-linecap="round"
398+
stroke-linejoin="round"
399+
stroke-width="2"
400+
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
401+
/>
402+
</svg>
403+
${translateText("user_setting.keybinds_hint")}
404+
</div>
405+
386406
<h2
387407
class="text-blue-200 text-xl font-bold mt-4 mb-3 border-b border-white/10 pb-2"
388408
>

src/client/Utils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,11 @@ export function formatKeyForDisplay(value: string): string {
314314
// Handle empty string
315315
if (!value) return "";
316316

317+
// Handle Shift+ prefix: format as "Shift+X"
318+
if (value.startsWith("Shift+")) {
319+
return "Shift+" + formatKeyForDisplay(value.slice(6));
320+
}
321+
317322
// Handle space character or "Space" key
318323
if (value === " " || value === "Space") return "Space";
319324

src/client/components/baseComponents/setting/SettingKeybind.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,17 +100,32 @@ export class SettingKeybind extends LitElement {
100100
return;
101101
}
102102

103+
// Don't capture lone modifier keys — wait for the actual key
104+
if (
105+
e.code === "ShiftLeft" ||
106+
e.code === "ShiftRight" ||
107+
e.code === "ControlLeft" ||
108+
e.code === "ControlRight" ||
109+
e.code === "AltLeft" ||
110+
e.code === "AltRight" ||
111+
e.code === "MetaLeft" ||
112+
e.code === "MetaRight"
113+
) {
114+
return;
115+
}
116+
103117
// Prevent default only for keys we're actually capturing
104118
e.preventDefault();
105119

106-
const code = e.code;
120+
const code = e.shiftKey ? `Shift+${e.code}` : e.code;
121+
const displayKey = e.shiftKey ? `Shift+${e.key.toUpperCase()}` : e.key;
107122
const prevValue = this.value;
108123

109124
// Temporarily set the value to the new code for validation in parent
110125
this.value = code;
111126

112127
const event = new CustomEvent("change", {
113-
detail: { action: this.action, value: code, key: e.key, prevValue },
128+
detail: { action: this.action, value: code, key: displayKey, prevValue },
114129
bubbles: true,
115130
composed: true,
116131
});

0 commit comments

Comments
 (0)