mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-03 21:40:42 +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:
@@ -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