From 483fc9e8634359a7c5f6e846394037a46763f84f Mon Sep 17 00:00:00 2001
From: VariableVince <24507472+VariableVince@users.noreply.github.com>
Date: Fri, 9 Oct 2026 00:44:42 +0200
Subject: [PATCH 1/4] Fix arrow keys being hardcoded to camera movement, while
they could also be assigned to other actions. Leading to two actions
happening for one key. Add them to the default keybinds they already belonged
to being hardcoded keys. Now they can be (un)assigned by the user and the
settings modal will make sure they aren't assigned to an action twice.
Do the same for the hardcoded Minus/NumpadSubtract/Equal/NumpadAdd keys (they do zoom actions).
---
src/client/InputHandler.ts | 32 +++++++-------
src/client/UserSettingModal.ts | 80 ++++++++++++++++++++++++++++++++++
src/client/UserSettings.ts | 8 ++++
tests/InputHandler.test.ts | 15 +++++++
tests/UserSettings.test.ts | 30 +++++++++++++
5 files changed, 149 insertions(+), 16 deletions(-)
diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts
index 86d25abb59..5fd202c31c 100644
--- a/src/client/InputHandler.ts
+++ b/src/client/InputHandler.ts
@@ -690,22 +690,22 @@ export class InputHandler {
if (
this.activeKeys.has(this.keybinds.moveUp) ||
- this.activeKeys.has("ArrowUp")
+ this.activeKeys.has(this.keybinds.moveUpArrow)
)
deltaY += this.PAN_SPEED;
if (
this.activeKeys.has(this.keybinds.moveDown) ||
- this.activeKeys.has("ArrowDown")
+ this.activeKeys.has(this.keybinds.moveDownArrow)
)
deltaY -= this.PAN_SPEED;
if (
this.activeKeys.has(this.keybinds.moveLeft) ||
- this.activeKeys.has("ArrowLeft")
+ this.activeKeys.has(this.keybinds.moveLeftArrow)
)
deltaX += this.PAN_SPEED;
if (
this.activeKeys.has(this.keybinds.moveRight) ||
- this.activeKeys.has("ArrowRight")
+ this.activeKeys.has(this.keybinds.moveRightArrow)
)
deltaX -= this.PAN_SPEED;
@@ -718,15 +718,15 @@ export class InputHandler {
if (
this.activeKeys.has(this.keybinds.zoomOut) ||
- this.activeKeys.has("Minus") ||
- this.activeKeys.has("NumpadSubtract")
+ this.activeKeys.has(this.keybinds.zoomOutMinus) ||
+ this.activeKeys.has(this.keybinds.zoomOutNumpad)
) {
this.eventBus.emit(new ZoomEvent(cx, cy, this.ZOOM_SPEED));
}
if (
this.activeKeys.has(this.keybinds.zoomIn) ||
- this.activeKeys.has("Equal") ||
- this.activeKeys.has("NumpadAdd")
+ this.activeKeys.has(this.keybinds.zoomInEqual) ||
+ this.activeKeys.has(this.keybinds.zoomInNumpad)
) {
this.eventBus.emit(new ZoomEvent(cx, cy, -this.ZOOM_SPEED));
}
@@ -842,16 +842,16 @@ export class InputHandler {
this.keybinds.moveDown,
this.keybinds.moveLeft,
this.keybinds.moveRight,
+ this.keybinds.moveUpArrow,
+ this.keybinds.moveDownArrow,
+ this.keybinds.moveLeftArrow,
+ this.keybinds.moveRightArrow,
this.keybinds.zoomOut,
this.keybinds.zoomIn,
- "ArrowUp",
- "ArrowLeft",
- "ArrowDown",
- "ArrowRight",
- "Minus",
- "Equal",
- "NumpadAdd",
- "NumpadSubtract",
+ this.keybinds.zoomOutMinus,
+ this.keybinds.zoomOutNumpad,
+ this.keybinds.zoomInEqual,
+ this.keybinds.zoomInNumpad,
this.keybinds.attackRatioDown,
this.keybinds.attackRatioUp,
this.keybinds.centerCamera,
diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts
index a315a6ca89..08c2373ab6 100644
--- a/src/client/UserSettingModal.ts
+++ b/src/client/UserSettingModal.ts
@@ -1589,6 +1589,26 @@ export class UserSettingModal extends BaseModal {
@change=${this.handleKeybindChange}
>
+
+
+
+
+
+
+
+
@@ -1625,6 +1665,16 @@ export class UserSettingModal extends BaseModal {
@change=${this.handleKeybindChange}
>
+
+
+
+
+
+
+
+
`;
}
diff --git a/src/client/UserSettings.ts b/src/client/UserSettings.ts
index 3f064d4f7d..57d088f54c 100644
--- a/src/client/UserSettings.ts
+++ b/src/client/UserSettings.ts
@@ -44,11 +44,19 @@ export function getDefaultKeybinds(isMac: boolean): Record {
swapDirection: "KeyU",
zoomOut: "KeyQ",
zoomIn: "KeyE",
+ zoomOutMinus: "Minus",
+ zoomOutNumpad: "NumpadSubtract",
+ zoomInEqual: "Equal",
+ zoomInNumpad: "NumpadAdd",
centerCamera: "KeyC",
moveUp: "KeyW",
moveLeft: "KeyA",
moveDown: "KeyS",
moveRight: "KeyD",
+ moveUpArrow: "ArrowUp",
+ moveLeftArrow: "ArrowLeft",
+ moveDownArrow: "ArrowDown",
+ moveRightArrow: "ArrowRight",
buildMenuModifier: isMac ? "MetaLeft" : "ControlLeft",
emojiMenuModifier: "AltLeft",
boxSelectWarships: "ShiftLeft",
diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts
index 90c5a7f534..b2e484e7d6 100644
--- a/tests/InputHandler.test.ts
+++ b/tests/InputHandler.test.ts
@@ -922,6 +922,21 @@ describe("InputHandler AutoUpgrade", () => {
});
describe("Keybinds JSON parsing", () => {
+ test("loads arrow movement and zoom fallback keys as configurable defaults", () => {
+ inputHandler.initialize();
+
+ expect((inputHandler as any).keybinds.moveUpArrow).toBe("ArrowUp");
+ expect((inputHandler as any).keybinds.moveLeftArrow).toBe("ArrowLeft");
+ expect((inputHandler as any).keybinds.moveDownArrow).toBe("ArrowDown");
+ expect((inputHandler as any).keybinds.moveRightArrow).toBe("ArrowRight");
+ expect((inputHandler as any).keybinds.zoomOutMinus).toBe("Minus");
+ expect((inputHandler as any).keybinds.zoomOutNumpad).toBe(
+ "NumpadSubtract",
+ );
+ expect((inputHandler as any).keybinds.zoomInEqual).toBe("Equal");
+ expect((inputHandler as any).keybinds.zoomInNumpad).toBe("NumpadAdd");
+ });
+
test("parses nested object values and flattens them to strings", () => {
const nested = {
moveUp: { key: "moveUp", value: "KeyZ" },
diff --git a/tests/UserSettings.test.ts b/tests/UserSettings.test.ts
index 4c538f914e..4a5875ce9a 100644
--- a/tests/UserSettings.test.ts
+++ b/tests/UserSettings.test.ts
@@ -598,6 +598,14 @@ describe("getDefaultKeybinds", () => {
expect(keybinds.quickChat).toBe("KeyR");
expect(keybinds.targetPlayer).toBe("KeyN");
expect(keybinds.buildMenuModifier).toBe("ControlLeft");
+ expect(keybinds.moveUpArrow).toBe("ArrowUp");
+ expect(keybinds.moveLeftArrow).toBe("ArrowLeft");
+ expect(keybinds.moveDownArrow).toBe("ArrowDown");
+ expect(keybinds.moveRightArrow).toBe("ArrowRight");
+ expect(keybinds.zoomOutMinus).toBe("Minus");
+ expect(keybinds.zoomOutNumpad).toBe("NumpadSubtract");
+ expect(keybinds.zoomInEqual).toBe("Equal");
+ expect(keybinds.zoomInNumpad).toBe("NumpadAdd");
});
it("never gives two actions the same letter key, except shared pairs", () => {
@@ -707,6 +715,28 @@ describe("UserSettings keybinds with new defaults", () => {
).toBeUndefined();
});
+ it("unbinds the new arrow and zoom defaults when saved bindings already use their keys", () => {
+ store({
+ boatAttack: { value: "ArrowUp", key: "ArrowUp" },
+ attackRatioUp: { value: "Equal", key: "=" },
+ });
+ const settings = new UserSettings();
+ const keybinds = settings.keybinds(false);
+
+ expect(keybinds.boatAttack).toBe("ArrowUp");
+ expect(keybinds.moveUpArrow).toBeUndefined();
+ expect(keybinds.attackRatioUp).toBe("Equal");
+ expect(keybinds.zoomInEqual).toBeUndefined();
+ expect(settings.parsedUserKeybinds().moveUpArrow).toEqual({
+ value: "Null",
+ key: "",
+ });
+ expect(settings.parsedUserKeybinds().zoomInEqual).toEqual({
+ value: "Null",
+ key: "",
+ });
+ });
+
it("keeps quick chat on R when the player saved reset graphics there", () => {
store({ resetGfx: { value: "KeyR", key: "r" } });
const keybinds = new UserSettings().keybinds(false);
From 2f7b3c776ad2ac9e0df03d2a9185bd8cadb9bf18 Mon Sep 17 00:00:00 2001
From: LoSt543215543
Date: Fri, 9 Oct 2026 10:46:25 -0700
Subject: [PATCH 2/4] fix(client): support Shift-modified hold-to-pan and
hold-to-zoom keybinds (#5866)
- Support Shift-modified continuous movement and zoom keybinds on top of configurable arrow and zoom defaults (#5864).
- Update active key tracking and interval evaluation to be modifier-aware via shiftHeld.
- Ensure unmodified controls do not trigger when Shift is held.
- Dynamically stop/start actions when Shift is released/pressed while holding physical keys.
- Prevent phantom ShiftLeft in activeKeys when ShiftRight is pressed.
- Add comprehensive vitest suite for Shift-modified continuous controls.
---
src/client/InputHandler.ts | 153 +++++++----
tests/InputHandler.test.ts | 516 +++++++++++++++++++++++++++++++++++++
2 files changed, 624 insertions(+), 45 deletions(-)
diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts
index 5fd202c31c..c4174a7228 100644
--- a/src/client/InputHandler.ts
+++ b/src/client/InputHandler.ts
@@ -313,6 +313,7 @@ export class InputHandler {
* `initializePointerAndKeyboardEvents()`. */
private listenerAbort: AbortController | null = null;
private activeKeys = new Set();
+ private shiftHeld = false;
private keybinds: Record = {};
private keybindAndEvent: Array<[string, KeybindEntry]> = [];
private coordinateGridEnabled = false;
@@ -663,6 +664,7 @@ export class InputHandler {
"blur",
() => {
this.activeKeys.clear();
+ this.shiftHeld = false;
if (this.alternateView) {
this.alternateView = false;
this.eventBus.emit(new AlternateViewEvent(false));
@@ -683,29 +685,34 @@ export class InputHandler {
let deltaX = 0;
let deltaY = 0;
- // Skip if select warship modifier is held down
- if (this.activeKeys.has(this.keybinds.boxSelectWarships)) {
+ // Skip movement if selection box drag is active or a non-Shift warship selector is held
+ if (
+ this.selectionBoxActive ||
+ (this.keybinds.boxSelectWarships !== "ShiftLeft" &&
+ this.keybinds.boxSelectWarships !== "ShiftRight" &&
+ this.activeKeys.has(this.keybinds.boxSelectWarships))
+ ) {
return;
}
if (
- this.activeKeys.has(this.keybinds.moveUp) ||
- this.activeKeys.has(this.keybinds.moveUpArrow)
+ this.isContinuousActionActive(this.keybinds.moveUp) ||
+ this.isContinuousActionActive(this.keybinds.moveUpArrow)
)
deltaY += this.PAN_SPEED;
if (
- this.activeKeys.has(this.keybinds.moveDown) ||
- this.activeKeys.has(this.keybinds.moveDownArrow)
+ this.isContinuousActionActive(this.keybinds.moveDown) ||
+ this.isContinuousActionActive(this.keybinds.moveDownArrow)
)
deltaY -= this.PAN_SPEED;
if (
- this.activeKeys.has(this.keybinds.moveLeft) ||
- this.activeKeys.has(this.keybinds.moveLeftArrow)
+ this.isContinuousActionActive(this.keybinds.moveLeft) ||
+ this.isContinuousActionActive(this.keybinds.moveLeftArrow)
)
deltaX += this.PAN_SPEED;
if (
- this.activeKeys.has(this.keybinds.moveRight) ||
- this.activeKeys.has(this.keybinds.moveRightArrow)
+ this.isContinuousActionActive(this.keybinds.moveRight) ||
+ this.isContinuousActionActive(this.keybinds.moveRightArrow)
)
deltaX -= this.PAN_SPEED;
@@ -717,16 +724,16 @@ export class InputHandler {
const cy = window.innerHeight / 2;
if (
- this.activeKeys.has(this.keybinds.zoomOut) ||
- this.activeKeys.has(this.keybinds.zoomOutMinus) ||
- this.activeKeys.has(this.keybinds.zoomOutNumpad)
+ this.isContinuousActionActive(this.keybinds.zoomOut) ||
+ this.isContinuousActionActive(this.keybinds.zoomOutMinus) ||
+ this.isContinuousActionActive(this.keybinds.zoomOutNumpad)
) {
this.eventBus.emit(new ZoomEvent(cx, cy, this.ZOOM_SPEED));
}
if (
- this.activeKeys.has(this.keybinds.zoomIn) ||
- this.activeKeys.has(this.keybinds.zoomInEqual) ||
- this.activeKeys.has(this.keybinds.zoomInNumpad)
+ this.isContinuousActionActive(this.keybinds.zoomIn) ||
+ this.isContinuousActionActive(this.keybinds.zoomInEqual) ||
+ this.isContinuousActionActive(this.keybinds.zoomInNumpad)
) {
this.eventBus.emit(new ZoomEvent(cx, cy, -this.ZOOM_SPEED));
}
@@ -827,41 +834,42 @@ export class InputHandler {
e.code === "NumpadAdd" ||
e.code === "NumpadSubtract");
+ const continuousBindings = this.getContinuousBindings();
+
const isConfiguredKeybind =
- Object.values(this.keybinds).includes(e.code) ||
- this.keybindAndEvent.some(([k]) => this.keybindMatchesEvent(e, k));
+ Object.values(this.keybinds).some((k) =>
+ this.keybindMatchesEvent(e, k),
+ ) ||
+ this.keybindAndEvent.some(([k]) => this.keybindMatchesEvent(e, k)) ||
+ continuousBindings.some((k) => this.keybindMatchesEvent(e, k));
if (isConfiguredKeybind && !isBrowserZoomCombo) {
e.preventDefault();
}
+ this.shiftHeld = e.shiftKey;
+
+ const continuousPhysicalCodes = continuousBindings.map(
+ (k) => this.parseKeybind(k).code,
+ );
+
if (
!isBrowserZoomCombo &&
- [
- this.keybinds.moveUp,
- this.keybinds.moveDown,
- this.keybinds.moveLeft,
- this.keybinds.moveRight,
- this.keybinds.moveUpArrow,
- this.keybinds.moveDownArrow,
- this.keybinds.moveLeftArrow,
- this.keybinds.moveRightArrow,
- this.keybinds.zoomOut,
- this.keybinds.zoomIn,
- this.keybinds.zoomOutMinus,
- this.keybinds.zoomOutNumpad,
- this.keybinds.zoomInEqual,
- this.keybinds.zoomInNumpad,
- this.keybinds.attackRatioDown,
- this.keybinds.attackRatioUp,
- this.keybinds.centerCamera,
- "ControlLeft",
- "ControlRight",
- this.keybinds.boxSelectWarships,
- this.keybinds.emojiMenuModifier,
- this.keybinds.buildMenuModifier,
- this.keybinds.altKey,
- ].includes(e.code)
+ (continuousPhysicalCodes.includes(e.code) ||
+ [
+ "ShiftLeft",
+ "ShiftRight",
+ "ControlLeft",
+ "ControlRight",
+ "AltLeft",
+ "AltRight",
+ "MetaLeft",
+ "MetaRight",
+ this.keybinds.boxSelectWarships,
+ this.keybinds.emojiMenuModifier,
+ this.keybinds.buildMenuModifier,
+ this.keybinds.altKey,
+ ].includes(e.code))
) {
this.activeKeys.add(e.code);
}
@@ -885,6 +893,8 @@ export class InputHandler {
return;
}
+ this.shiftHeld = e.shiftKey;
+
// When the meta (cmd) or ctrl key is released, any keys that were held
// simultaneously will have had their keyup swallowed by the browser
// (e.g. cmd+Plus for browser zoom). Clear zoom-related keys to
@@ -899,8 +909,6 @@ export class InputHandler {
this.activeKeys.delete("Equal");
this.activeKeys.delete("NumpadAdd");
this.activeKeys.delete("NumpadSubtract");
- this.activeKeys.delete(this.keybinds.zoomIn);
- this.activeKeys.delete(this.keybinds.zoomOut);
}
if (this.emojiTableOpen) {
@@ -1377,6 +1385,60 @@ export class InputHandler {
return e.code === parsed.code && e.shiftKey === parsed.shift;
}
+ private isShiftHeld(): boolean {
+ return this.shiftHeld;
+ }
+
+ /**
+ * Evaluates whether a continuous movement or zoom action is currently active.
+ *
+ * A binding is active when its physical key is held in `activeKeys` and its
+ * Shift-modifier condition is satisfied:
+ * - Shift-modified bindings (e.g. "Shift+ArrowUp") require Shift to be held.
+ * Releasing Shift while the physical key remains held stops the action.
+ * Pressing Shift while the physical key is already held activates the action.
+ * - Unmodified bindings (e.g. "ArrowUp") require Shift NOT to be held.
+ * This ensures unmodified controls do not activate when Shift is held for
+ * other actions (such as warship box selection).
+ * - Unbound ("Null" or undefined) bindings never activate.
+ */
+ private isContinuousActionActive(keybindValue: string | undefined): boolean {
+ if (!keybindValue || keybindValue === "Null") {
+ return false;
+ }
+ const { shift, code } = this.parseKeybind(keybindValue);
+ if (!this.activeKeys.has(code)) {
+ return false;
+ }
+ const shiftHeld = this.isShiftHeld();
+ return shift ? shiftHeld : !shiftHeld;
+ }
+
+ private getContinuousBindings(): string[] {
+ const bindings = [
+ this.keybinds.moveUp,
+ this.keybinds.moveUpArrow,
+ this.keybinds.moveDown,
+ this.keybinds.moveDownArrow,
+ this.keybinds.moveLeft,
+ this.keybinds.moveLeftArrow,
+ this.keybinds.moveRight,
+ this.keybinds.moveRightArrow,
+ this.keybinds.zoomOut,
+ this.keybinds.zoomOutMinus,
+ this.keybinds.zoomOutNumpad,
+ this.keybinds.zoomIn,
+ this.keybinds.zoomInEqual,
+ this.keybinds.zoomInNumpad,
+ this.keybinds.attackRatioDown,
+ this.keybinds.attackRatioUp,
+ this.keybinds.centerCamera,
+ ];
+ return bindings.filter(
+ (k): k is string => typeof k === "string" && k !== "" && k !== "Null",
+ );
+ }
+
/**
* Extracts the digit character from KeyboardEvent.code.
* Codes look like "Digit0".."Digit9" (6 chars, digit at index 5) and
@@ -1557,6 +1619,7 @@ export class InputHandler {
// renderer has already removed.
this.resetPointerState();
this.activeKeys.clear();
+ this.shiftHeld = false;
this.emojiTableOpen = false;
this.emojiKeysDown.clear();
this.keybindAndEvent = [];
diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts
index b2e484e7d6..3bf7961e4e 100644
--- a/tests/InputHandler.test.ts
+++ b/tests/InputHandler.test.ts
@@ -23,6 +23,7 @@ import {
WarshipSelectionBoxCancelEvent,
WarshipSelectionBoxCompleteEvent,
WarshipSelectionBoxUpdateEvent,
+ ZoomEvent,
} from "../src/client/InputHandler";
import { Platform } from "../src/client/Platform";
import { UIState } from "../src/client/UIState";
@@ -2215,4 +2216,519 @@ describe("InputHandler teardown (OPE-411)", () => {
vi.useRealTimers();
}
});
+
+ describe("Shift-modified hold-to-pan and hold-to-zoom keybinds", () => {
+ let handler: InputHandler;
+ let settings: UserSettings;
+ let bus: EventBus;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ settings = new UserSettings();
+ settings.removeCached(KEYBINDS_KEY, false);
+ bus = new EventBus();
+ handler = new InputHandler(
+ {
+ inSpawnPhase: () => false,
+ myPlayer: () => ({ isAlive: () => true }),
+ } as any,
+ {
+ attackRatio: 20,
+ ghostStructure: null,
+ rocketDirectionUp: true,
+ upgradeMultiplier: 1,
+ } as any,
+ document.createElement("canvas"),
+ bus,
+ );
+ });
+
+ afterEach(() => {
+ handler.destroy();
+ vi.useRealTimers();
+ });
+
+ test("pans up when moveUp is bound to Shift+KeyW", () => {
+ settings.setKeybinds({ moveUp: "Shift+KeyW" });
+ handler.initialize();
+
+ const emitSpy = vi.spyOn(bus, "emit");
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { code: "ShiftLeft", shiftKey: true }),
+ );
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { code: "KeyW", shiftKey: true }),
+ );
+
+ vi.advanceTimersByTime(10);
+ const dragCalls = emitSpy.mock.calls.filter(
+ (c: unknown[]) => c[0] instanceof DragEvent,
+ );
+ expect(dragCalls.length).toBeGreaterThan(0);
+ expect((dragCalls[0][0] as DragEvent).deltaY).toBe(handler["PAN_SPEED"]);
+
+ // Releasing KeyW stops panning
+ window.dispatchEvent(
+ new KeyboardEvent("keyup", { code: "KeyW", shiftKey: true }),
+ );
+ emitSpy.mockClear();
+ vi.advanceTimersByTime(10);
+ expect(
+ emitSpy.mock.calls.some((c: unknown[]) => c[0] instanceof DragEvent),
+ ).toBe(false);
+ });
+
+ test("pans in all 4 directions with Shift-modified bindings", () => {
+ settings.setKeybinds({
+ moveUp: "Shift+KeyW",
+ moveDown: "Shift+KeyS",
+ moveLeft: "Shift+KeyA",
+ moveRight: "Shift+KeyD",
+ });
+ handler.initialize();
+
+ const emitSpy = vi.spyOn(bus, "emit");
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { code: "ShiftLeft", shiftKey: true }),
+ );
+
+ // Down
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { code: "KeyS", shiftKey: true }),
+ );
+ vi.advanceTimersByTime(5);
+ expect(
+ emitSpy.mock.calls.some(
+ (c: unknown[]) =>
+ c[0] instanceof DragEvent &&
+ (c[0] as DragEvent).deltaY === -handler["PAN_SPEED"],
+ ),
+ ).toBe(true);
+ window.dispatchEvent(
+ new KeyboardEvent("keyup", { code: "KeyS", shiftKey: true }),
+ );
+ emitSpy.mockClear();
+
+ // Left
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { code: "KeyA", shiftKey: true }),
+ );
+ vi.advanceTimersByTime(5);
+ expect(
+ emitSpy.mock.calls.some(
+ (c: unknown[]) =>
+ c[0] instanceof DragEvent &&
+ (c[0] as DragEvent).deltaX === handler["PAN_SPEED"],
+ ),
+ ).toBe(true);
+ window.dispatchEvent(
+ new KeyboardEvent("keyup", { code: "KeyA", shiftKey: true }),
+ );
+ emitSpy.mockClear();
+
+ // Right
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { code: "KeyD", shiftKey: true }),
+ );
+ vi.advanceTimersByTime(5);
+ expect(
+ emitSpy.mock.calls.some(
+ (c: unknown[]) =>
+ c[0] instanceof DragEvent &&
+ (c[0] as DragEvent).deltaX === -handler["PAN_SPEED"],
+ ),
+ ).toBe(true);
+ window.dispatchEvent(
+ new KeyboardEvent("keyup", { code: "KeyD", shiftKey: true }),
+ );
+ });
+
+ test("pans with Shift-modified arrow bindings (moveUpArrow etc.)", () => {
+ settings.setKeybinds({
+ moveUpArrow: "Shift+ArrowUp",
+ moveDownArrow: "Shift+ArrowDown",
+ moveLeftArrow: "Shift+ArrowLeft",
+ moveRightArrow: "Shift+ArrowRight",
+ });
+ handler.initialize();
+
+ const emitSpy = vi.spyOn(bus, "emit");
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { code: "ShiftLeft", shiftKey: true }),
+ );
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { code: "ArrowUp", shiftKey: true }),
+ );
+
+ vi.advanceTimersByTime(5);
+ expect(
+ emitSpy.mock.calls.some(
+ (c: unknown[]) =>
+ c[0] instanceof DragEvent &&
+ (c[0] as DragEvent).deltaY === handler["PAN_SPEED"],
+ ),
+ ).toBe(true);
+
+ window.dispatchEvent(
+ new KeyboardEvent("keyup", { code: "ArrowUp", shiftKey: true }),
+ );
+ });
+
+ test("an unmodified press does NOT activate a Shift-only binding", () => {
+ settings.setKeybinds({ moveUp: "Shift+KeyW" });
+ handler.initialize();
+
+ const emitSpy = vi.spyOn(bus, "emit");
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { code: "KeyW", shiftKey: false }),
+ );
+
+ vi.advanceTimersByTime(20);
+ expect(
+ emitSpy.mock.calls.some((c: unknown[]) => c[0] instanceof DragEvent),
+ ).toBe(false);
+ });
+
+ test("releasing Shift while the physical key remains held stops the action", () => {
+ settings.setKeybinds({ moveUp: "Shift+KeyW" });
+ handler.initialize();
+
+ const emitSpy = vi.spyOn(bus, "emit");
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { code: "ShiftLeft", shiftKey: true }),
+ );
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { code: "KeyW", shiftKey: true }),
+ );
+
+ vi.advanceTimersByTime(5);
+ expect(
+ emitSpy.mock.calls.some((c: unknown[]) => c[0] instanceof DragEvent),
+ ).toBe(true);
+
+ // Release Shift while KeyW is still held
+ window.dispatchEvent(
+ new KeyboardEvent("keyup", { code: "ShiftLeft", shiftKey: false }),
+ );
+ emitSpy.mockClear();
+
+ vi.advanceTimersByTime(10);
+ expect(
+ emitSpy.mock.calls.some((c: unknown[]) => c[0] instanceof DragEvent),
+ ).toBe(false);
+ });
+
+ test("pressing Shift while the physical key is already held activates the binding", () => {
+ settings.setKeybinds({ moveUp: "Shift+KeyW" });
+ handler.initialize();
+
+ const emitSpy = vi.spyOn(bus, "emit");
+ // Press KeyW first without Shift
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { code: "KeyW", shiftKey: false }),
+ );
+
+ vi.advanceTimersByTime(10);
+ expect(
+ emitSpy.mock.calls.some((c: unknown[]) => c[0] instanceof DragEvent),
+ ).toBe(false);
+
+ // Now press Shift while KeyW is still held
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { code: "ShiftLeft", shiftKey: true }),
+ );
+
+ vi.advanceTimersByTime(10);
+ expect(
+ emitSpy.mock.calls.some(
+ (c: unknown[]) =>
+ c[0] instanceof DragEvent &&
+ (c[0] as DragEvent).deltaY === handler["PAN_SPEED"],
+ ),
+ ).toBe(true);
+ });
+
+ test("zooms with Shift-modified zoom bindings (zoomIn, zoomOut)", () => {
+ settings.setKeybinds({
+ zoomOut: "Shift+KeyQ",
+ zoomIn: "Shift+KeyE",
+ });
+ handler.initialize();
+
+ const emitSpy = vi.spyOn(bus, "emit");
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { code: "ShiftLeft", shiftKey: true }),
+ );
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { code: "KeyQ", shiftKey: true }),
+ );
+
+ vi.advanceTimersByTime(5);
+ const zoomOutCalls = emitSpy.mock.calls.filter(
+ (c: unknown[]) =>
+ c[0] instanceof ZoomEvent &&
+ (c[0] as ZoomEvent).delta === handler["ZOOM_SPEED"],
+ );
+ expect(zoomOutCalls.length).toBeGreaterThan(0);
+
+ window.dispatchEvent(
+ new KeyboardEvent("keyup", { code: "KeyQ", shiftKey: true }),
+ );
+ emitSpy.mockClear();
+
+ // Zoom in
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { code: "KeyE", shiftKey: true }),
+ );
+ vi.advanceTimersByTime(5);
+ const zoomInCalls = emitSpy.mock.calls.filter(
+ (c: unknown[]) =>
+ c[0] instanceof ZoomEvent &&
+ (c[0] as ZoomEvent).delta === -handler["ZOOM_SPEED"],
+ );
+ expect(zoomInCalls.length).toBeGreaterThan(0);
+ });
+
+ test("zooms with Shift-modified alternate zoom bindings (Equal, Minus, Numpad)", () => {
+ settings.setKeybinds({
+ zoomOutMinus: "Shift+Minus",
+ zoomInEqual: "Shift+Equal",
+ });
+ handler.initialize();
+
+ const emitSpy = vi.spyOn(bus, "emit");
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { code: "ShiftLeft", shiftKey: true }),
+ );
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { code: "Minus", shiftKey: true }),
+ );
+
+ vi.advanceTimersByTime(5);
+ expect(
+ emitSpy.mock.calls.some(
+ (c: unknown[]) =>
+ c[0] instanceof ZoomEvent &&
+ (c[0] as ZoomEvent).delta === handler["ZOOM_SPEED"],
+ ),
+ ).toBe(true);
+
+ window.dispatchEvent(
+ new KeyboardEvent("keyup", { code: "Minus", shiftKey: true }),
+ );
+ emitSpy.mockClear();
+
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { code: "Equal", shiftKey: true }),
+ );
+ vi.advanceTimersByTime(5);
+ expect(
+ emitSpy.mock.calls.some(
+ (c: unknown[]) =>
+ c[0] instanceof ZoomEvent &&
+ (c[0] as ZoomEvent).delta === -handler["ZOOM_SPEED"],
+ ),
+ ).toBe(true);
+ });
+
+ test("unmodified bindings work normally and do not trigger when Shift is held", () => {
+ handler.initialize();
+
+ const emitSpy = vi.spyOn(bus, "emit");
+ // Plain KeyW pans
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { code: "KeyW", shiftKey: false }),
+ );
+ vi.advanceTimersByTime(5);
+ expect(
+ emitSpy.mock.calls.some((c: unknown[]) => c[0] instanceof DragEvent),
+ ).toBe(true);
+
+ // Now press Shift (as in warship box selection mode) -> panning stops
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { code: "ShiftLeft", shiftKey: true }),
+ );
+ emitSpy.mockClear();
+ vi.advanceTimersByTime(10);
+ expect(
+ emitSpy.mock.calls.some((c: unknown[]) => c[0] instanceof DragEvent),
+ ).toBe(false);
+
+ // Release Shift -> panning resumes
+ window.dispatchEvent(
+ new KeyboardEvent("keyup", { code: "ShiftLeft", shiftKey: false }),
+ );
+ emitSpy.mockClear();
+ vi.advanceTimersByTime(5);
+ expect(
+ emitSpy.mock.calls.some((c: unknown[]) => c[0] instanceof DragEvent),
+ ).toBe(true);
+
+ window.dispatchEvent(
+ new KeyboardEvent("keyup", { code: "KeyW", shiftKey: false }),
+ );
+ });
+
+ test("browser zoom shortcuts (Ctrl/Cmd + Minus/Equal) do not trigger game zoom or preventDefault", () => {
+ handler.initialize();
+
+ const emitSpy = vi.spyOn(bus, "emit");
+ const event = new KeyboardEvent("keydown", {
+ code: "Minus",
+ ctrlKey: true,
+ });
+ const preventSpy = vi.spyOn(event, "preventDefault");
+ window.dispatchEvent(event);
+
+ vi.advanceTimersByTime(10);
+ expect(preventSpy).not.toHaveBeenCalled();
+ expect(
+ emitSpy.mock.calls.some((c: unknown[]) => c[0] instanceof ZoomEvent),
+ ).toBe(false);
+ expect(handler["activeKeys"].has("Minus")).toBe(false);
+ });
+
+ test("window blur clears held action state", () => {
+ settings.setKeybinds({ moveUp: "Shift+KeyW" });
+ handler.initialize();
+
+ const emitSpy = vi.spyOn(bus, "emit");
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { code: "ShiftLeft", shiftKey: true }),
+ );
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { code: "KeyW", shiftKey: true }),
+ );
+
+ vi.advanceTimersByTime(5);
+ expect(
+ emitSpy.mock.calls.some((c: unknown[]) => c[0] instanceof DragEvent),
+ ).toBe(true);
+
+ // Blur window
+ window.dispatchEvent(new Event("blur"));
+ emitSpy.mockClear();
+
+ vi.advanceTimersByTime(10);
+ expect(
+ emitSpy.mock.calls.some((c: unknown[]) => c[0] instanceof DragEvent),
+ ).toBe(false);
+ expect(handler["activeKeys"].size).toBe(0);
+ });
+
+ test("unbound ('Null') bindings do not trigger continuous action", () => {
+ settings.setKeybinds({ moveUp: "Null" });
+ handler.initialize();
+
+ const emitSpy = vi.spyOn(bus, "emit");
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { code: "KeyW", shiftKey: false }),
+ );
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { code: "KeyW", shiftKey: true }),
+ );
+
+ vi.advanceTimersByTime(10);
+ expect(
+ emitSpy.mock.calls.some((c: unknown[]) => c[0] instanceof DragEvent),
+ ).toBe(false);
+ });
+
+ test("a held KeyE continues to emit zoom after Control or Meta is released", () => {
+ handler.initialize();
+
+ const emitSpy = vi.spyOn(bus, "emit");
+ // Hold KeyE to zoom in
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { code: "KeyE", shiftKey: false }),
+ );
+ vi.advanceTimersByTime(5);
+ expect(
+ emitSpy.mock.calls.some(
+ (c: unknown[]) =>
+ c[0] instanceof ZoomEvent &&
+ (c[0] as ZoomEvent).delta === -handler["ZOOM_SPEED"],
+ ),
+ ).toBe(true);
+
+ // Press and release Control (e.g. modifier tapped or shortcuts used)
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { code: "ControlLeft", ctrlKey: true }),
+ );
+ window.dispatchEvent(
+ new KeyboardEvent("keyup", { code: "ControlLeft", ctrlKey: false }),
+ );
+ emitSpy.mockClear();
+
+ // KeyE is still held down, zoom should continue emitting
+ vi.advanceTimersByTime(5);
+ expect(
+ emitSpy.mock.calls.some(
+ (c: unknown[]) =>
+ c[0] instanceof ZoomEvent &&
+ (c[0] as ZoomEvent).delta === -handler["ZOOM_SPEED"],
+ ),
+ ).toBe(true);
+ expect(handler["activeKeys"].has("KeyE")).toBe(true);
+
+ // Release KeyE
+ window.dispatchEvent(
+ new KeyboardEvent("keyup", { code: "KeyE", shiftKey: false }),
+ );
+ emitSpy.mockClear();
+ vi.advanceTimersByTime(5);
+ expect(
+ emitSpy.mock.calls.some((c: unknown[]) => c[0] instanceof ZoomEvent),
+ ).toBe(false);
+ });
+
+ test("holding ShiftRight and pressing another key does NOT add ShiftLeft to activeKeys", () => {
+ settings.setKeybinds({ moveUp: "Shift+KeyW" });
+ handler.initialize();
+
+ const emitSpy = vi.spyOn(bus, "emit");
+ // Press physical Right Shift
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { code: "ShiftRight", shiftKey: true }),
+ );
+ // Press KeyW with shiftKey: true
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { code: "KeyW", shiftKey: true }),
+ );
+
+ // activeKeys must not contain ShiftLeft (which would trigger boxSelectWarships)
+ expect(handler["activeKeys"].has("ShiftLeft")).toBe(false);
+ expect(handler["activeKeys"].has("ShiftRight")).toBe(true);
+ expect(handler["activeKeys"].has("KeyW")).toBe(true);
+
+ vi.advanceTimersByTime(5);
+ expect(
+ emitSpy.mock.calls.some(
+ (c: unknown[]) =>
+ c[0] instanceof DragEvent &&
+ (c[0] as DragEvent).deltaY === handler["PAN_SPEED"],
+ ),
+ ).toBe(true);
+
+ // Release ShiftRight while KeyW is held
+ window.dispatchEvent(
+ new KeyboardEvent("keyup", { code: "ShiftRight", shiftKey: false }),
+ );
+ expect(handler["activeKeys"].has("ShiftRight")).toBe(false);
+ emitSpy.mockClear();
+ vi.advanceTimersByTime(10);
+ // Shift action should stop
+ expect(
+ emitSpy.mock.calls.some((c: unknown[]) => c[0] instanceof DragEvent),
+ ).toBe(false);
+ });
+
+ test("rebuilds continuous action bindings when keybind settings change", () => {
+ handler.initialize();
+ expect(handler["keybinds"].moveUpArrow).toBe("ArrowUp");
+
+ settings.setKeybinds({ moveUpArrow: "Shift+ArrowUp" });
+ expect(handler["keybinds"].moveUpArrow).toBe("Shift+ArrowUp");
+ });
+ });
});
From 87e406803fcac63f8af3515e046d193fffcaa1f3 Mon Sep 17 00:00:00 2001
From: LoSt543215543
Date: Fri, 9 Oct 2026 10:54:14 -0700
Subject: [PATCH 3/4] fix(client): use consistent defaultKey attribute binding
for arrow keybinds
---
src/client/UserSettingModal.ts | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts
index 08c2373ab6..a91f76aed3 100644
--- a/src/client/UserSettingModal.ts
+++ b/src/client/UserSettingModal.ts
@@ -1669,7 +1669,7 @@ export class UserSettingModal extends BaseModal {
action="moveUpArrow"
label=${translateText("user_setting.move_up")}
description=${translateText("user_setting.move_up_desc")}
- .defaultKey=${this.defaultKeybinds.moveUpArrow}
+ defaultKey=${this.defaultKeybinds.moveUpArrow}
.value=${this.getKeyValue("moveUpArrow")}
.display=${this.getKeyChar("moveUpArrow")}
@change=${this.handleKeybindChange}
@@ -1689,7 +1689,7 @@ export class UserSettingModal extends BaseModal {
action="moveLeftArrow"
label=${translateText("user_setting.move_left")}
description=${translateText("user_setting.move_left_desc")}
- .defaultKey=${this.defaultKeybinds.moveLeftArrow}
+ defaultKey=${this.defaultKeybinds.moveLeftArrow}
.value=${this.getKeyValue("moveLeftArrow")}
.display=${this.getKeyChar("moveLeftArrow")}
@change=${this.handleKeybindChange}
@@ -1709,7 +1709,7 @@ export class UserSettingModal extends BaseModal {
action="moveDownArrow"
label=${translateText("user_setting.move_down")}
description=${translateText("user_setting.move_down_desc")}
- .defaultKey=${this.defaultKeybinds.moveDownArrow}
+ defaultKey=${this.defaultKeybinds.moveDownArrow}
.value=${this.getKeyValue("moveDownArrow")}
.display=${this.getKeyChar("moveDownArrow")}
@change=${this.handleKeybindChange}
@@ -1729,7 +1729,7 @@ export class UserSettingModal extends BaseModal {
action="moveRightArrow"
label=${translateText("user_setting.move_right")}
description=${translateText("user_setting.move_right_desc")}
- .defaultKey=${this.defaultKeybinds.moveRightArrow}
+ defaultKey=${this.defaultKeybinds.moveRightArrow}
.value=${this.getKeyValue("moveRightArrow")}
.display=${this.getKeyChar("moveRightArrow")}
@change=${this.handleKeybindChange}
From 6525cf065cb51b15b090848e80414ecf4798ef70 Mon Sep 17 00:00:00 2001
From: LoSt543215543
Date: Fri, 9 Oct 2026 11:16:22 -0700
Subject: [PATCH 4/4] test(client): cover Shift-modified zoomOutNumpad and
zoomInNumpad in InputHandler tests
---
tests/InputHandler.test.ts | 44 +++++++++++++++++++++++++++++++++++++-
1 file changed, 43 insertions(+), 1 deletion(-)
diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts
index 3bf7961e4e..a3754b9097 100644
--- a/tests/InputHandler.test.ts
+++ b/tests/InputHandler.test.ts
@@ -2452,6 +2452,8 @@ describe("InputHandler teardown (OPE-411)", () => {
settings.setKeybinds({
zoomOut: "Shift+KeyQ",
zoomIn: "Shift+KeyE",
+ zoomOutNumpad: "Shift+NumpadSubtract",
+ zoomInNumpad: "Shift+NumpadAdd",
});
handler.initialize();
@@ -2476,6 +2478,29 @@ describe("InputHandler teardown (OPE-411)", () => {
);
emitSpy.mockClear();
+ // zoomOutNumpad
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ code: "NumpadSubtract",
+ shiftKey: true,
+ }),
+ );
+ vi.advanceTimersByTime(5);
+ const zoomOutNumpadCalls = emitSpy.mock.calls.filter(
+ (c: unknown[]) =>
+ c[0] instanceof ZoomEvent &&
+ (c[0] as ZoomEvent).delta === handler["ZOOM_SPEED"],
+ );
+ expect(zoomOutNumpadCalls.length).toBeGreaterThan(0);
+
+ window.dispatchEvent(
+ new KeyboardEvent("keyup", {
+ code: "NumpadSubtract",
+ shiftKey: true,
+ }),
+ );
+ emitSpy.mockClear();
+
// Zoom in
window.dispatchEvent(
new KeyboardEvent("keydown", { code: "KeyE", shiftKey: true }),
@@ -2487,9 +2512,26 @@ describe("InputHandler teardown (OPE-411)", () => {
(c[0] as ZoomEvent).delta === -handler["ZOOM_SPEED"],
);
expect(zoomInCalls.length).toBeGreaterThan(0);
+
+ window.dispatchEvent(
+ new KeyboardEvent("keyup", { code: "KeyE", shiftKey: true }),
+ );
+ emitSpy.mockClear();
+
+ // zoomInNumpad
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { code: "NumpadAdd", shiftKey: true }),
+ );
+ vi.advanceTimersByTime(5);
+ const zoomInNumpadCalls = emitSpy.mock.calls.filter(
+ (c: unknown[]) =>
+ c[0] instanceof ZoomEvent &&
+ (c[0] as ZoomEvent).delta === -handler["ZOOM_SPEED"],
+ );
+ expect(zoomInNumpadCalls.length).toBeGreaterThan(0);
});
- test("zooms with Shift-modified alternate zoom bindings (Equal, Minus, Numpad)", () => {
+ test("zooms with Shift-modified alternate zoom bindings (Equal, Minus)", () => {
settings.setKeybinds({
zoomOutMinus: "Shift+Minus",
zoomInEqual: "Shift+Equal",