From d1fed8ffdad9c181729d121957f3d89b4014b1a7 Mon Sep 17 00:00:00 2001 From: Devyn Lynch Date: Thu, 10 Sep 2026 20:47:59 -0600 Subject: [PATCH 1/3] fix(client): stop Mac Ctrl+click from also attacking (#4918) Ctrl+left is a secondary-click on macOS, so skip MouseUpEvent after the build/emoji menu modifiers. Win/Linux Ctrl+left still opens the build menu. Co-authored-by: Cursor --- src/client/InputHandler.ts | 11 ++++ tests/InputHandler.test.ts | 115 +++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 312891bc98..cb195f6020 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -843,6 +843,9 @@ export class InputHandler { this.eventBus.emit(new WarshipSelectionBoxCancelEvent()); } } + + // Modifier menus first: on Win/Linux Ctrl is the default build-menu + // key, so a ctrl+left must still reach ShowBuildMenuEvent. if (this.activeKeys.has(this.keybinds.buildMenuModifier)) { this.suppressNextTap = false; this.eventBus.emit(new ShowBuildMenuEvent(event.clientX, event.clientY)); @@ -854,6 +857,14 @@ export class InputHandler { return; } + // macOS treats Ctrl+Left as secondary-click (context menu). Skip the + // primary-click path so we don't also fire an attack (#4918). The + // contextmenu listener opens the radial. After the modifier checks this + // is safe on Win/Linux too (those already returned with the build menu). + if (event.ctrlKey) { + return; + } + const dist = Math.abs(event.x - this.lastPointerDownX) + Math.abs(event.y - this.lastPointerDownY); diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts index 5d7c86aee3..b7369fbb63 100644 --- a/tests/InputHandler.test.ts +++ b/tests/InputHandler.test.ts @@ -23,6 +23,8 @@ class MockPointerEvent { pointerId: number; type: string; pointerType: string; + ctrlKey: boolean; + shiftKey: boolean; preventDefault: () => void; constructor(type: string, init: any) { @@ -34,6 +36,8 @@ class MockPointerEvent { this.y = init.y ?? init.clientY; this.pointerId = init.pointerId; this.pointerType = init.pointerType ?? "mouse"; + this.ctrlKey = init.ctrlKey ?? false; + this.shiftKey = init.shiftKey ?? false; this.preventDefault = vi.fn(); } } @@ -290,6 +294,117 @@ describe("InputHandler AutoUpgrade", () => { }); }); + describe("Ctrl+left click (#4918)", () => { + test("should not emit MouseUpEvent on ctrl+left release (Mac secondary-click)", () => { + const mockEmit = vi.spyOn(eventBus, "emit"); + + inputHandler["userSettings"].leftClickOpensMenu = () => false; + + const pointerEvent = new PointerEvent("pointerup", { + button: 0, + clientX: 150, + clientY: 250, + ctrlKey: true, + }); + inputHandler["lastPointerDownX"] = 149; + inputHandler["lastPointerDownY"] = 249; + + inputHandler["onPointerUp"](pointerEvent); + + const emittedTypes = mockEmit.mock.calls.map( + (call) => call[0].constructor.name, + ); + expect(emittedTypes).not.toContain("MouseUpEvent"); + expect(emittedTypes).not.toContain("ContextMenuEvent"); + expect(emittedTypes).not.toContain("ShowBuildMenuEvent"); + }); + + test("should still emit MouseUpEvent on plain left release", () => { + const mockEmit = vi.spyOn(eventBus, "emit"); + + inputHandler["userSettings"].leftClickOpensMenu = () => false; + + const pointerEvent = new PointerEvent("pointerup", { + button: 0, + clientX: 150, + clientY: 250, + ctrlKey: false, + }); + inputHandler["lastPointerDownX"] = 149; + inputHandler["lastPointerDownY"] = 249; + + inputHandler["onPointerUp"](pointerEvent); + + const emittedTypes = mockEmit.mock.calls.map( + (call) => call[0].constructor.name, + ); + expect(emittedTypes).toContain("MouseUpEvent"); + }); + + test("Win/Linux: ctrl+left still opens the build menu when Control is held", () => { + inputHandler["keybinds"].buildMenuModifier = "ControlLeft"; + inputHandler["activeKeys"].add("ControlLeft"); + + const mockEmit = vi.spyOn(eventBus, "emit"); + + const pointerEvent = new PointerEvent("pointerup", { + button: 0, + clientX: 150, + clientY: 250, + ctrlKey: true, + }); + inputHandler["lastPointerDownX"] = 149; + inputHandler["lastPointerDownY"] = 249; + + inputHandler["onPointerUp"](pointerEvent); + + const emittedTypes = mockEmit.mock.calls.map( + (call) => call[0].constructor.name, + ); + expect(emittedTypes).toContain("ShowBuildMenuEvent"); + expect(emittedTypes).not.toContain("MouseUpEvent"); + }); + + test("Mac: cmd+left still opens the build menu", () => { + inputHandler["keybinds"].buildMenuModifier = "MetaLeft"; + inputHandler["activeKeys"].add("MetaLeft"); + + const mockEmit = vi.spyOn(eventBus, "emit"); + + const pointerEvent = new PointerEvent("pointerup", { + button: 0, + clientX: 150, + clientY: 250, + ctrlKey: false, + }); + inputHandler["lastPointerDownX"] = 149; + inputHandler["lastPointerDownY"] = 249; + + inputHandler["onPointerUp"](pointerEvent); + + const emittedTypes = mockEmit.mock.calls.map( + (call) => call[0].constructor.name, + ); + expect(emittedTypes).toContain("ShowBuildMenuEvent"); + expect(emittedTypes).not.toContain("MouseUpEvent"); + }); + + test("onContextMenu still opens the radial after ctrl+left", () => { + const mockEmit = vi.spyOn(eventBus, "emit"); + + const mouseEvent = new MouseEvent("contextmenu", { + clientX: 150, + clientY: 250, + }); + inputHandler["onContextMenu"](mouseEvent); + + const emittedTypes = mockEmit.mock.calls.map( + (call) => call[0].constructor.name, + ); + expect(emittedTypes).toContain("ContextMenuEvent"); + }); + }); + describe("Left-click menu with ghost structure (#4789)", () => { test("should emit MouseUpEvent and not ContextMenuEvent when placing a ghost structure with left-click menu enabled", () => { const mockEmit = vi.spyOn(eventBus, "emit"); From ef125e996a47cc9c111fc042e13ff445c4d2c469 Mon Sep 17 00:00:00 2001 From: Devyn Lynch Date: Thu, 10 Sep 2026 21:11:13 -0600 Subject: [PATCH 2/3] fix(client): scope the Ctrl+click guard to macOS after spawn The #4918 skip is Mac-only so Win/Linux Right Ctrl still attacks. Spawn-phase Ctrl+click still emits MouseUpEvent, and suppressNextTap is cleared on that path. Co-authored-by: Cursor --- src/client/InputHandler.ts | 10 ++-- tests/InputHandler.test.ts | 100 ++++++++++++++++++++++--------------- 2 files changed, 67 insertions(+), 43 deletions(-) diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index cb195f6020..a6d87f588f 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -858,10 +858,12 @@ export class InputHandler { } // macOS treats Ctrl+Left as secondary-click (context menu). Skip the - // primary-click path so we don't also fire an attack (#4918). The - // contextmenu listener opens the radial. After the modifier checks this - // is safe on Win/Linux too (those already returned with the build menu). - if (event.ctrlKey) { + // primary-click path so we don't also fire an attack (#4918). Mac-only: + // on Win/Linux event.ctrlKey is also true for Right Ctrl, which is not + // the default build-menu bind and must still attack. Spawn-phase + // Ctrl+click still needs MouseUpEvent — contextmenu is ignored then. + if (Platform.isMac && event.ctrlKey && !this.gameView.inSpawnPhase()) { + this.suppressNextTap = false; return; } diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts index b7369fbb63..0bf640d687 100644 --- a/tests/InputHandler.test.ts +++ b/tests/InputHandler.test.ts @@ -8,6 +8,7 @@ import { WarshipSelectionBoxCompleteEvent, WarshipSelectionBoxUpdateEvent, } from "../src/client/InputHandler"; +import { Platform } from "../src/client/Platform"; import { UIState } from "../src/client/UIState"; import { GameView, PlayerView, UnitView } from "../src/client/view"; import { EventBus } from "../src/core/EventBus"; @@ -295,21 +296,43 @@ describe("InputHandler AutoUpgrade", () => { }); describe("Ctrl+left click (#4918)", () => { - test("should not emit MouseUpEvent on ctrl+left release (Mac secondary-click)", () => { - const mockEmit = vi.spyOn(eventBus, "emit"); + let isMacDescriptor: PropertyDescriptor | undefined; - inputHandler["userSettings"].leftClickOpensMenu = () => false; + function setIsMac(value: boolean) { + Object.defineProperty(Platform, "isMac", { + configurable: true, + value, + }); + } + function fireLeftPointerUp(ctrlKey: boolean) { const pointerEvent = new PointerEvent("pointerup", { button: 0, clientX: 150, clientY: 250, - ctrlKey: true, + ctrlKey, }); inputHandler["lastPointerDownX"] = 149; inputHandler["lastPointerDownY"] = 249; - inputHandler["onPointerUp"](pointerEvent); + } + + beforeEach(() => { + isMacDescriptor = Object.getOwnPropertyDescriptor(Platform, "isMac"); + inputHandler["userSettings"].leftClickOpensMenu = () => false; + }); + + afterEach(() => { + if (isMacDescriptor) { + Object.defineProperty(Platform, "isMac", isMacDescriptor); + } + }); + + test("on Mac, should not emit MouseUpEvent on ctrl+left release (secondary-click)", () => { + setIsMac(true); + const mockEmit = vi.spyOn(eventBus, "emit"); + + fireLeftPointerUp(true); const emittedTypes = mockEmit.mock.calls.map( (call) => call[0].constructor.name, @@ -322,18 +345,7 @@ describe("InputHandler AutoUpgrade", () => { test("should still emit MouseUpEvent on plain left release", () => { const mockEmit = vi.spyOn(eventBus, "emit"); - inputHandler["userSettings"].leftClickOpensMenu = () => false; - - const pointerEvent = new PointerEvent("pointerup", { - button: 0, - clientX: 150, - clientY: 250, - ctrlKey: false, - }); - inputHandler["lastPointerDownX"] = 149; - inputHandler["lastPointerDownY"] = 249; - - inputHandler["onPointerUp"](pointerEvent); + fireLeftPointerUp(false); const emittedTypes = mockEmit.mock.calls.map( (call) => call[0].constructor.name, @@ -342,21 +354,12 @@ describe("InputHandler AutoUpgrade", () => { }); test("Win/Linux: ctrl+left still opens the build menu when Control is held", () => { + setIsMac(false); inputHandler["keybinds"].buildMenuModifier = "ControlLeft"; inputHandler["activeKeys"].add("ControlLeft"); const mockEmit = vi.spyOn(eventBus, "emit"); - - const pointerEvent = new PointerEvent("pointerup", { - button: 0, - clientX: 150, - clientY: 250, - ctrlKey: true, - }); - inputHandler["lastPointerDownX"] = 149; - inputHandler["lastPointerDownY"] = 249; - - inputHandler["onPointerUp"](pointerEvent); + fireLeftPointerUp(true); const emittedTypes = mockEmit.mock.calls.map( (call) => call[0].constructor.name, @@ -365,22 +368,28 @@ describe("InputHandler AutoUpgrade", () => { expect(emittedTypes).not.toContain("MouseUpEvent"); }); + test("Win/Linux: Right Ctrl+left still attacks (not a dead click)", () => { + setIsMac(false); + inputHandler["keybinds"].buildMenuModifier = "ControlLeft"; + inputHandler["activeKeys"].add("ControlRight"); + + const mockEmit = vi.spyOn(eventBus, "emit"); + fireLeftPointerUp(true); + + const emittedTypes = mockEmit.mock.calls.map( + (call) => call[0].constructor.name, + ); + expect(emittedTypes).toContain("MouseUpEvent"); + expect(emittedTypes).not.toContain("ShowBuildMenuEvent"); + }); + test("Mac: cmd+left still opens the build menu", () => { + setIsMac(true); inputHandler["keybinds"].buildMenuModifier = "MetaLeft"; inputHandler["activeKeys"].add("MetaLeft"); const mockEmit = vi.spyOn(eventBus, "emit"); - - const pointerEvent = new PointerEvent("pointerup", { - button: 0, - clientX: 150, - clientY: 250, - ctrlKey: false, - }); - inputHandler["lastPointerDownX"] = 149; - inputHandler["lastPointerDownY"] = 249; - - inputHandler["onPointerUp"](pointerEvent); + fireLeftPointerUp(false); const emittedTypes = mockEmit.mock.calls.map( (call) => call[0].constructor.name, @@ -389,6 +398,19 @@ describe("InputHandler AutoUpgrade", () => { expect(emittedTypes).not.toContain("MouseUpEvent"); }); + test("Mac: ctrl+left during spawn still emits MouseUpEvent", () => { + setIsMac(true); + mockGameView.inSpawnPhase = () => true; + const mockEmit = vi.spyOn(eventBus, "emit"); + + fireLeftPointerUp(true); + + const emittedTypes = mockEmit.mock.calls.map( + (call) => call[0].constructor.name, + ); + expect(emittedTypes).toContain("MouseUpEvent"); + }); + test("onContextMenu still opens the radial after ctrl+left", () => { const mockEmit = vi.spyOn(eventBus, "emit"); From 0ec8780e50f7c032380bd89ed40070e7bae2621e Mon Sep 17 00:00:00 2001 From: Devyn Lynch Date: Fri, 11 Sep 2026 11:49:02 -0600 Subject: [PATCH 3/3] fix(client): suppress Mac Ctrl+click before modifier menus If buildMenuModifier is rebound to ControlLeft on macOS, handling the build menu first could emit ShowBuildMenuEvent while contextmenu still opens the radial. Skip the primary path first on Mac Ctrl+left, and add a regression test. --- src/client/InputHandler.ts | 26 ++++++++++++++------------ tests/InputHandler.test.ts | 15 +++++++++++++++ 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 371065853c..dfb39c1c91 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -870,8 +870,20 @@ export class InputHandler { } } - // Modifier menus first: on Win/Linux Ctrl is the default build-menu - // key, so a ctrl+left must still reach ShowBuildMenuEvent. + // macOS treats Ctrl+Left as secondary-click (context menu). Skip the + // primary-click path so we don't also fire an attack (#4918), and do it + // before modifier menus: if buildMenuModifier is rebound to ControlLeft, + // we'd otherwise emit ShowBuildMenuEvent and still get ContextMenuEvent. + // Mac-only: on Win/Linux event.ctrlKey is also true for Right Ctrl, which + // is not the default build-menu bind and must still attack. Spawn-phase + // Ctrl+click still needs MouseUpEvent — contextmenu is ignored then. + if (Platform.isMac && event.ctrlKey && !this.gameView.inSpawnPhase()) { + this.suppressNextTap = false; + return; + } + + // Modifier menus: on Win/Linux Ctrl is the default build-menu key, so a + // ctrl+left must still reach ShowBuildMenuEvent (Mac already returned). if (this.activeKeys.has(this.keybinds.buildMenuModifier)) { this.suppressNextTap = false; this.eventBus.emit(new ShowBuildMenuEvent(event.clientX, event.clientY)); @@ -883,16 +895,6 @@ export class InputHandler { return; } - // macOS treats Ctrl+Left as secondary-click (context menu). Skip the - // primary-click path so we don't also fire an attack (#4918). Mac-only: - // on Win/Linux event.ctrlKey is also true for Right Ctrl, which is not - // the default build-menu bind and must still attack. Spawn-phase - // Ctrl+click still needs MouseUpEvent — contextmenu is ignored then. - if (Platform.isMac && event.ctrlKey && !this.gameView.inSpawnPhase()) { - this.suppressNextTap = false; - return; - } - const dist = Math.abs(event.x - this.lastPointerDownX) + Math.abs(event.y - this.lastPointerDownY); diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts index 0bf640d687..dec246c51b 100644 --- a/tests/InputHandler.test.ts +++ b/tests/InputHandler.test.ts @@ -383,6 +383,21 @@ describe("InputHandler AutoUpgrade", () => { expect(emittedTypes).not.toContain("ShowBuildMenuEvent"); }); + test("Mac: ctrl+left does not open build menu even if rebound to ControlLeft", () => { + setIsMac(true); + inputHandler["keybinds"].buildMenuModifier = "ControlLeft"; + inputHandler["activeKeys"].add("ControlLeft"); + + const mockEmit = vi.spyOn(eventBus, "emit"); + fireLeftPointerUp(true); + + const emittedTypes = mockEmit.mock.calls.map( + (call) => call[0].constructor.name, + ); + expect(emittedTypes).not.toContain("ShowBuildMenuEvent"); + expect(emittedTypes).not.toContain("MouseUpEvent"); + }); + test("Mac: cmd+left still opens the build menu", () => { setIsMac(true); inputHandler["keybinds"].buildMenuModifier = "MetaLeft";