mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:30:45 +00:00
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:
@@ -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 site’s appearance between light and dark themes",
|
||||
"emojis_label": "Emojis",
|
||||
|
||||
+59
-19
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user