Skip to content

Commit 970ef25

Browse files
author
DavidQ
committed
Fix Input Mapping V2 gamepad detection and capture flow - PR_26140_089-fix-input-mapping-v2-gamepad-and-capture-flow
1 parent d5b8d87 commit 970ef25

7 files changed

Lines changed: 278 additions & 40 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# PR_26140_089 Input Mapping V2 Gamepad and Capture Flow Report
2+
3+
## Scope
4+
- Fixed Input Mapping V2 gamepad/joystick detection and capture through the existing engine input path (`InputService` + `GamepadInputAdapter`).
5+
- Kept the tool generic and did not add duplicate input models or hardcoded game-specific behavior.
6+
- Updated capture flow so Add Action creates an empty mapping tile immediately, independent of capture.
7+
- Added per-tile action reassignment while preserving captured input deletion.
8+
- Updated capture controls to stack vertically and fill the available container width.
9+
- Preserved PR_26140_088 fullscreen layout, 175px by 175px mapping tiles, and external JS/CSS only.
10+
11+
## Implementation Notes
12+
- `EngineInputSourceService` now refreshes gamepad state from the browser Game Controller API through the engine input service and records actionable WARN details when browser focus, permission, API availability, or timing prevents capture.
13+
- `ToolStarterApp` listens for `gamepadconnected` and `gamepaddisconnected`, refreshes engine input state, logs connection state, and refreshes the UI.
14+
- `InputMappingState` now supports empty visible mapping tiles and changing a tile's action after creation without requiring a captured input first.
15+
- `PreviewPanelControl` renders empty tiles, exposes a `Captured Mappings Action` dropdown per tile, and keeps token-click deletion behavior intact.
16+
- `inputMappingV2.css` makes the capture buttons a vertical full-width stack while preserving mapping tile dimensions.
17+
18+
## Playwright Impact
19+
Yes. This PR changes Input Mapping V2 UI controls, gamepad detection/capture behavior, and toolState-facing mapping interactions.
20+
21+
Playwright coverage was updated to validate:
22+
- connected gamepad detection state with a mocked browser Game Controller API gamepad,
23+
- actionable WARN logging when gamepad capture is unavailable,
24+
- stacked full-width capture buttons,
25+
- Add Action creating a tile without capture,
26+
- tile action reassignment after creation,
27+
- captured input token deletion,
28+
- preserved 175px by 175px mapping tiles and mapping list scroll behavior.
29+
30+
## Validation
31+
- PASS: targeted syntax/import validation for changed Input Mapping V2 and Playwright files.
32+
- PASS: external JS/CSS sanity check for Input Mapping V2 HTML.
33+
- PASS: no legacy `gamepad-button` / `gamepad-axis` source values remain in Input Mapping V2 coverage paths.
34+
- PASS: focused Playwright validation:
35+
`npx playwright test tests/playwright/tools/WorkspaceManagerV2.spec.mjs -g "launches Input Mapping V2 and captures keyboard mappings|uses header lifecycle controls and launches tools from fixed Workspace Manager V2 tiles"`
36+
- PASS: `npm run test:workspace-v2` (`60 passed`).
37+
- PASS: no sample JSON changes.
38+
- PASS: coverage changed JS guardrail reported no warnings.
39+
40+
Full samples smoke test was not run, per PR scope.
41+
42+
## Manual Validation
43+
1. Launch Workspace Manager V2 and open Input Mapping V2.
44+
2. Confirm Capture Keyboard, Capture Mouse, and Capture Gamepad are stacked vertically and fill the capture panel width.
45+
3. Select an action and click Add Action; confirm an empty 175px by 175px mapping tile appears immediately.
46+
4. Change the tile's Captured Mappings Action dropdown and confirm the tile/action selection updates.
47+
5. Capture Keyboard `KeyA`, confirm the token appears, then click the token and confirm it is deleted.
48+
6. Connect or enable a browser-visible gamepad, focus the page, and confirm Input Sources reports the connected controller; if unavailable, confirm the WARN explains browser focus/permission/API timing.
49+
50+
## Files Changed
51+
- `tools/input-mapping-v2/js/services/InputMappingState.js`
52+
- `tools/input-mapping-v2/js/controls/PreviewPanelControl.js`
53+
- `tools/input-mapping-v2/js/ToolStarterApp.js`
54+
- `tools/input-mapping-v2/js/services/EngineInputSourceService.js`
55+
- `tools/input-mapping-v2/styles/inputMappingV2.css`
56+
- `tests/playwright/tools/WorkspaceManagerV2.spec.mjs`

tests/playwright/tools/WorkspaceManagerV2.spec.mjs

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1435,27 +1435,52 @@ test.describe("Workspace Manager V2 bootstrap", () => {
14351435
await expect(page.locator("#previewOutput")).toContainText("No inputs captured yet.");
14361436
await expect(page.locator(".input-mapping-v2__mapping-card")).toHaveCount(0);
14371437
expect(await page.locator("#previewOutput").evaluate((node) => getComputedStyle(node).overflowY)).toBe("auto");
1438-
expect(Math.round((await page.locator("#inputMappingV2CaptureKeyboardButton").boundingBox()).width)).toBe(150);
1438+
const captureButtonLayout = await page.locator("#captureInputContent").evaluate((content) => {
1439+
const buttons = [
1440+
content.querySelector("#inputMappingV2CaptureKeyboardButton"),
1441+
content.querySelector("#inputMappingV2CaptureMouseButton"),
1442+
content.querySelector("#inputMappingV2CaptureGamepadButton")
1443+
];
1444+
return buttons.map((button) => {
1445+
const box = button.getBoundingClientRect();
1446+
return {
1447+
left: Math.round(box.left),
1448+
top: Math.round(box.top),
1449+
width: Math.round(box.width)
1450+
};
1451+
});
1452+
});
1453+
expect(captureButtonLayout.every((entry) => entry.width > 250)).toBe(true);
1454+
expect(new Set(captureButtonLayout.map((entry) => entry.left)).size).toBe(1);
1455+
expect(captureButtonLayout[1].top).toBeGreaterThan(captureButtonLayout[0].top);
1456+
expect(captureButtonLayout[2].top).toBeGreaterThan(captureButtonLayout[1].top);
14391457
await page.locator("#inputMappingV2ActionSelect").selectOption("moveLeft");
1458+
await page.locator("#inputMappingV2AddActionButton").click();
1459+
await expect(page.locator(".input-mapping-v2__mapping-card")).toHaveCount(1);
1460+
await expect(page.locator(".input-mapping-v2__empty-token", { hasText: "No inputs captured." })).toHaveCount(1);
1461+
const emptyMappingTileBox = await page.locator(".input-mapping-v2__mapping-card").first().boundingBox();
1462+
expect(Math.round(emptyMappingTileBox.width)).toBe(175);
1463+
expect(Math.round(emptyMappingTileBox.height)).toBe(175);
1464+
await page.locator(".input-mapping-v2__tile-action-select").first().selectOption("fire");
1465+
await expect(page.locator(".input-mapping-v2__tile-action-select").first()).toHaveValue("fire");
1466+
await expect(page.locator("#inputMappingV2ActionSelect")).toHaveValue("fire");
14401467
await page.locator("#inputMappingV2CaptureKeyboardButton").click();
14411468
await expect(page.locator("#inputMappingV2CaptureMessage")).toContainText("Press a keyboard key");
14421469
await page.keyboard.press("KeyA");
14431470
await expect(page.locator(".input-mapping-v2__mapping-card")).toHaveCount(1);
14441471
const mappingTileBox = await page.locator(".input-mapping-v2__mapping-card").first().boundingBox();
14451472
expect(Math.round(mappingTileBox.width)).toBe(175);
14461473
expect(Math.round(mappingTileBox.height)).toBe(175);
1447-
await expect(page.locator("#previewOutput")).toContainText("Move Left");
14481474
await expect(page.locator("#previewOutput")).toContainText("Keyboard KeyA");
1449-
await expect(page.locator("#inspectorOutput")).toContainText('"action": "moveLeft"');
1475+
await expect(page.locator("#inspectorOutput")).toContainText('"action": "fire"');
14501476
await expect(page.locator("#inspectorOutput")).toContainText('"binding": "KeyA"');
14511477
await page.locator(".input-mapping-v2__input-token", { hasText: "Keyboard KeyA" }).click();
14521478
await expect(page.locator(".input-mapping-v2__input-token", { hasText: "Keyboard KeyA" })).toHaveCount(0);
1453-
await expect(page.locator("#previewOutput")).toContainText("No inputs captured yet.");
1479+
await expect(page.locator(".input-mapping-v2__empty-token", { hasText: "No inputs captured." })).toHaveCount(1);
14541480
const actionValues = await page.locator("#inputMappingV2ActionSelect option").evaluateAll((options) => options.map((option) => option.value));
14551481
for (const actionValue of actionValues.slice(0, 16)) {
14561482
await page.locator("#inputMappingV2ActionSelect").selectOption(actionValue);
1457-
await page.locator("#inputMappingV2CaptureKeyboardButton").click();
1458-
await page.keyboard.press("KeyA");
1483+
await page.locator("#inputMappingV2AddActionButton").click();
14591484
}
14601485
const mappingListScroll = await page.locator("#previewOutput").evaluate((node) => ({
14611486
clientHeight: node.clientHeight,
@@ -1466,6 +1491,28 @@ test.describe("Workspace Manager V2 bootstrap", () => {
14661491
expect(mappingListScroll.scrollHeight).toBeGreaterThan(mappingListScroll.clientHeight);
14671492
await page.locator("#inputMappingV2CaptureGamepadButton").click();
14681493
await expect(page.locator("#statusLog")).toHaveValue(/WARN Gamepad capture unavailable:/);
1494+
await expect(page.locator("#statusLog")).toHaveValue(/Click inside this page|browser focus|permission|Gamepad API timing|Hold a button/);
1495+
await page.evaluate(() => {
1496+
Object.defineProperty(navigator, "getGamepads", {
1497+
configurable: true,
1498+
value: () => [{
1499+
axes: [0, 0, 0, 0],
1500+
buttons: [{ pressed: true }, { pressed: false }],
1501+
connected: true,
1502+
id: "Mock Flight Stick",
1503+
index: 0,
1504+
mapping: "standard",
1505+
timestamp: 42
1506+
}]
1507+
});
1508+
window.dispatchEvent(new Event("gamepadconnected"));
1509+
});
1510+
await expect(page.locator("#inputMappingV2SourceList")).toContainText("1 connected gamepad");
1511+
await expect(page.locator("#inputMappingV2SourceList")).toContainText("Mock Flight Stick");
1512+
await page.locator("#inputMappingV2ActionSelect").selectOption("start");
1513+
await page.locator("#inputMappingV2CaptureGamepadButton").click();
1514+
await expect(page.locator("#previewOutput")).toContainText("Gamepad 0 Button 0");
1515+
await expect(page.locator("#inspectorOutput")).toContainText('"source": "gamepad"');
14691516
const asteroidsManifestInputMapping = await page.evaluate(async () => {
14701517
const response = await fetch("/games/Asteroids/game.manifest.json");
14711518
const manifest = await response.json();

tools/input-mapping-v2/js/ToolStarterApp.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export class ToolStarterApp {
2626
this.statusLog = statusLog;
2727
this.window = windowRef;
2828
this.captureMode = "";
29+
this.handleGamepadConnectionChange = this.handleGamepadConnectionChange.bind(this);
2930
this.handleKeyDown = this.handleKeyDown.bind(this);
3031
this.handleMouseDown = this.handleMouseDown.bind(this);
3132
}
@@ -55,10 +56,13 @@ export class ToolStarterApp {
5556
onCaptureMouse: () => this.startMouseCapture()
5657
});
5758
this.engineInputSources.attach();
59+
this.window.addEventListener("gamepadconnected", this.handleGamepadConnectionChange);
60+
this.window.addEventListener("gamepaddisconnected", this.handleGamepadConnectionChange);
5861
this.window.addEventListener("keydown", this.handleKeyDown, true);
5962
this.window.addEventListener("mousedown", this.handleMouseDown, true);
6063
this.statusLog.mount();
6164
this.preview.mount({
65+
onChangeTileAction: ({ actionId, nextActionId }) => this.changeTileAction(actionId, nextActionId),
6266
onDeleteBinding: ({ actionId, binding }) => this.deleteBinding(actionId, binding)
6367
});
6468
this.refreshActions();
@@ -77,6 +81,12 @@ export class ToolStarterApp {
7781
this.refreshActions();
7882
}
7983

84+
changeTileAction(actionId, nextActionId) {
85+
const result = this.state.changeTileAction(actionId, nextActionId);
86+
this.statusLog[result.ok ? "ok" : "warn"](result.message);
87+
this.refreshActions();
88+
}
89+
8090
clearSelectedAction() {
8191
const result = this.state.clearSelectedAction();
8292
this.statusLog[result.ok ? "ok" : "warn"](result.message);
@@ -133,6 +143,16 @@ export class ToolStarterApp {
133143
this.addCapturedInput(result.input);
134144
}
135145

146+
handleGamepadConnectionChange() {
147+
const status = this.engineInputSources.refreshGamepadState();
148+
if (status.warning) {
149+
this.statusLog.warn(status.warning);
150+
} else {
151+
this.statusLog.ok(status.message);
152+
}
153+
this.refreshActions();
154+
}
155+
136156
addCapturedInput(input) {
137157
const result = this.state.addBindingToSelectedAction(input);
138158
this.capture.showMessage(result.message);

tools/input-mapping-v2/js/controls/PreviewPanelControl.js

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,62 @@
11
export class PreviewPanelControl {
22
constructor(output) {
33
this.output = output;
4+
this.onChangeTileAction = () => {};
45
this.onDeleteBinding = () => {};
56
}
67

7-
mount({ onDeleteBinding }) {
8+
mount({ onChangeTileAction, onDeleteBinding }) {
9+
this.onChangeTileAction = onChangeTileAction;
810
this.onDeleteBinding = onDeleteBinding;
911
}
1012

1113
render(actions) {
12-
const mappedActions = actions.filter((action) => action.inputs.length > 0);
13-
if (!mappedActions.length) {
14+
const visibleActions = actions.filter((action) => action.tileVisible || action.inputs.length > 0);
15+
if (!visibleActions.length) {
1416
this.output.replaceChildren(this.createEmptyState());
1517
return;
1618
}
17-
this.output.replaceChildren(...mappedActions.map((action) => this.createActionCard(action)));
19+
this.output.replaceChildren(...visibleActions.map((action) => this.createActionCard(action, actions)));
1820
}
1921

20-
createActionCard(action) {
22+
createActionCard(action, actions) {
2123
const card = document.createElement("article");
2224
card.className = "input-mapping-v2__mapping-card";
2325

24-
const title = document.createElement("h2");
25-
title.textContent = action.label;
26+
const actionField = document.createElement("label");
27+
actionField.className = "input-mapping-v2__tile-action-field";
28+
29+
const actionLabel = document.createElement("span");
30+
actionLabel.textContent = "Captured Mappings Action";
31+
32+
const actionSelect = document.createElement("select");
33+
actionSelect.className = "input-mapping-v2__tile-action-select";
34+
actionSelect.dataset.inputMappingTileActionId = action.id;
35+
actionSelect.append(...actions.map((entry) => {
36+
const option = document.createElement("option");
37+
option.value = entry.id;
38+
option.textContent = entry.label;
39+
option.selected = entry.id === action.id;
40+
return option;
41+
}));
42+
actionSelect.addEventListener("change", () => {
43+
this.onChangeTileAction({ actionId: action.id, nextActionId: actionSelect.value });
44+
});
45+
46+
actionField.append(actionLabel, actionSelect);
2647

2748
const tokens = document.createElement("div");
2849
tokens.className = "input-mapping-v2__token-list";
29-
tokens.append(...action.inputs.map((input) => this.createInputToken(action.id, input)));
50+
if (!action.inputs.length) {
51+
const empty = document.createElement("span");
52+
empty.className = "input-mapping-v2__empty-token";
53+
empty.textContent = "No inputs captured.";
54+
tokens.append(empty);
55+
} else {
56+
tokens.append(...action.inputs.map((input) => this.createInputToken(action.id, input)));
57+
}
3058

31-
card.append(title, tokens);
59+
card.append(actionField, tokens);
3260
return card;
3361
}
3462

tools/input-mapping-v2/js/services/EngineInputSourceService.js

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,16 @@ export class EngineInputSourceService {
1111
getGamepads: () => this.readNavigatorGamepads()
1212
});
1313
this.gamepadAdapter = new GamepadInputAdapter({ input: this.inputService });
14+
this.lastGamepadReadError = "";
1415
}
1516

1617
attach() {
1718
this.inputService.attach();
1819
}
1920

2021
sources() {
21-
const gamepads = this.inputService.getGamepads();
22-
const gamepadCount = gamepads.length;
22+
const status = this.refreshGamepadState();
23+
const gamepadCount = status.connectedCount;
2324
return [
2425
{
2526
name: "Keyboard",
@@ -33,12 +34,12 @@ export class EngineInputSourceService {
3334
},
3435
{
3536
name: "Gamepad Buttons",
36-
detail: `${gamepadCount} connected gamepad${gamepadCount === 1 ? "" : "s"} detected for button capture.`,
37+
detail: status.warning || `${gamepadCount} connected gamepad${gamepadCount === 1 ? "" : "s"} detected for button capture${status.connectedLabels ? `: ${status.connectedLabels}` : "."}`,
3738
engine: "InputService + GamepadState + GamepadInputAdapter"
3839
},
3940
{
4041
name: "Gamepad Axes",
41-
detail: "Analog axes are reported when a connected gamepad axis crosses the capture threshold.",
42+
detail: status.warning || "Analog axes are reported when a connected gamepad axis crosses the capture threshold.",
4243
engine: "GamepadInputAdapter"
4344
}
4445
];
@@ -67,15 +68,21 @@ export class EngineInputSourceService {
6768
if (typeof this.window.navigator?.getGamepads !== "function") {
6869
return {
6970
ok: false,
70-
message: "Gamepad capture unavailable: browser Gamepad API is not available in this context."
71+
message: "Gamepad capture unavailable: browser Gamepad API is not available in this context. Use a focused browser tab that supports navigator.getGamepads."
72+
};
73+
}
74+
const status = this.refreshGamepadState();
75+
if (status.warning) {
76+
return {
77+
ok: false,
78+
message: status.warning
7179
};
7280
}
73-
this.inputService.update();
7481
const connectedIndices = this.gamepadAdapter.listConnectedIndices();
7582
if (!connectedIndices.length) {
7683
return {
7784
ok: false,
78-
message: "Gamepad capture unavailable: connect a gamepad, press a button or move an axis, then capture again."
85+
message: "Gamepad capture unavailable: no connected gamepad is visible. Click inside this page, press any gamepad button to wake or authorize the controller, check browser focus or permission prompts, then try again."
7986
};
8087
}
8188
for (const padIndex of connectedIndices) {
@@ -85,7 +92,7 @@ export class EngineInputSourceService {
8592
return {
8693
ok: true,
8794
input: {
88-
source: "gamepad-button",
95+
source: "gamepad",
8996
binding: `Pad${padIndex}:Button${buttonIndex}`,
9097
label: `Gamepad ${padIndex} Button ${buttonIndex}`,
9198
engine: "GamepadInputAdapter"
@@ -98,7 +105,7 @@ export class EngineInputSourceService {
98105
return {
99106
ok: true,
100107
input: {
101-
source: "gamepad-axis",
108+
source: "gamepad",
102109
binding: `Pad${padIndex}:Axis${axisIndex}${direction}`,
103110
label: `Gamepad ${padIndex} Axis ${axisIndex} ${direction}`,
104111
engine: "GamepadInputAdapter"
@@ -108,14 +115,46 @@ export class EngineInputSourceService {
108115
}
109116
return {
110117
ok: false,
111-
message: "Gamepad capture unavailable: no pressed button or moved axis was visible from the Gamepad API."
118+
message: "Gamepad capture unavailable: a connected gamepad was detected, but no live button or axis value was active. Hold a button or move a stick while pressing Capture Gamepad."
119+
};
120+
}
121+
122+
refreshGamepadState() {
123+
this.inputService.update();
124+
return this.gamepadStatus();
125+
}
126+
127+
gamepadStatus() {
128+
const gamepads = this.inputService.getGamepads();
129+
const connectedLabels = gamepads
130+
.map((gamepad) => `Gamepad ${gamepad.index}${gamepad.id ? ` ${gamepad.id}` : ""}`.trim())
131+
.join(", ");
132+
if (this.lastGamepadReadError) {
133+
return {
134+
connectedCount: 0,
135+
connectedLabels: "",
136+
message: "Gamepad API access blocked.",
137+
warning: `Gamepad capture unavailable: browser focus, permission, or Gamepad API timing blocked live gamepad access (${this.lastGamepadReadError}). Click inside this page, press a gamepad button, allow browser permission if prompted, then try again.`
138+
};
139+
}
140+
return {
141+
connectedCount: gamepads.length,
142+
connectedLabels,
143+
message: `${gamepads.length} connected gamepad${gamepads.length === 1 ? "" : "s"} detected${connectedLabels ? `: ${connectedLabels}` : "."}`,
144+
warning: ""
112145
};
113146
}
114147

115148
readNavigatorGamepads() {
149+
this.lastGamepadReadError = "";
116150
if (typeof this.window.navigator?.getGamepads !== "function") {
117151
return [];
118152
}
119-
return Array.from(this.window.navigator.getGamepads() || []);
153+
try {
154+
return Array.from(this.window.navigator.getGamepads() || []);
155+
} catch (error) {
156+
this.lastGamepadReadError = error?.message || "unknown Gamepad API error";
157+
return [];
158+
}
120159
}
121160
}

0 commit comments

Comments
 (0)