Skip to content

Commit d096bb5

Browse files
committed
Polish Input Mapping V2 actions combos and export controls - PR_26140_096-polish-input-mapping-v2-actions-combos-export
1 parent 714ab41 commit d096bb5

11 files changed

Lines changed: 342 additions & 31 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Input Mapping V2 Actions Combos Export Report
2+
3+
## Scope
4+
- PR: `PR_26140_096-polish-input-mapping-v2-actions-combos-export`
5+
- Source of truth: user PR request. `docs/pr/BUILD_PR.md` was checked and is for a different overlay runtime hardening task, so it was not used for implementation scope.
6+
- Required setup: read `docs/dev/PROJECT_INSTRUCTIONS.md` first.
7+
8+
## Implementation
9+
- Updated Input Mapping V2 diagnostics cards to render as one full-width card per row inside the diagnostics accordion scrollport.
10+
- Reordered Actions controls exactly as `Add Action`, `Clear Actions`, `Delete Action`.
11+
- Added `Capture Combo` for explicit two-input combo capture. `Ctrl + R` displays as `Combo Ctrl + R` and stores a schema-compatible binding such as `Combo:ControlLeft+KeyR`.
12+
- Preserved existing multi-input behavior by continuing to allow additional captured inputs on the selected action tile.
13+
- Added a gamepad rumble/haptic feedback checkbox that uses browser `GamepadHapticActuator` or `vibrationActuator` when available.
14+
- Added actionable WARN status logging when rumble is requested but unsupported, unavailable, or rejected by the browser.
15+
- Kept rumble UI-local because the current Input Mapping V2 `toolState` contract contains only `toolId`, `version`, and `payload`, with no mapping/action options field.
16+
- Removed the duplicate `Export toolState` control, renamed the visible tool action to `Export`, and wired it to show the toolState export preview.
17+
- Kept `Copy JSON` wired to copy the payload JSON.
18+
19+
## Files Changed
20+
- `tools/input-mapping-v2/index.html`
21+
- `tools/input-mapping-v2/how_to_use.html`
22+
- `tools/input-mapping-v2/js/ToolStarterApp.js`
23+
- `tools/input-mapping-v2/js/bootstrap.js`
24+
- `tools/input-mapping-v2/js/controls/ActionNavControl.js`
25+
- `tools/input-mapping-v2/js/controls/ActionSelectionControl.js`
26+
- `tools/input-mapping-v2/js/controls/CaptureControl.js`
27+
- `tools/input-mapping-v2/js/services/EngineInputSourceService.js`
28+
- `tools/input-mapping-v2/styles/inputMappingV2.css`
29+
- `tests/playwright/tools/WorkspaceManagerV2.spec.mjs`
30+
31+
## Validation
32+
- Playwright impacted: Yes.
33+
- Targeted JS syntax validation: PASS.
34+
- Targeted browser import/page-load validation for Input Mapping V2: PASS.
35+
- Focused Playwright coverage for the updated Input Mapping V2 flow: PASS.
36+
- `npm run test:workspace-v2`: PASS, 61 tests.
37+
- `git diff --check`: PASS, with repository line-ending warnings only.
38+
- Full samples smoke test: not run, per request.
39+
40+
## Coverage Added
41+
- Diagnostics cards fill the available accordion scrollport width.
42+
- Actions buttons appear in exact order.
43+
- `Ctrl + R` combo can be captured and displayed.
44+
- Rumble checkbox appears.
45+
- Unsupported rumble logs an actionable WARN in the main status log.
46+
- Export label is correct and the duplicate Export control is absent.
47+
- Export writes the toolState preview.
48+
- Copy JSON still writes payload JSON to the clipboard.
49+
50+
## Schema And Samples
51+
- No schema changes.
52+
- No sample JSON changes.
53+
54+
## Manual Validation
55+
1. Open `tools/input-mapping-v2/index.html`.
56+
2. Confirm the Actions buttons read `Add Action`, `Clear Actions`, `Delete Action` in that order.
57+
3. Select an action, click `Add Action`, click `Capture Combo`, then press `Ctrl` and `R`.
58+
4. Expected: the selected mapping tile shows `Combo Ctrl + R`, remains selectable, and Export shows a toolState preview containing the combo binding.
59+
5. Check `Gamepad rumble/haptic feedback` without a haptics-capable controller.
60+
6. Expected: the main Status log shows an actionable WARN and no fake rumble is attempted.
61+
7. Click `Copy JSON`.
62+
8. Expected: payload JSON is copied and the main Status log reports success.
63+
9. Out of scope: full samples smoke test and sample JSON launch validation.
64+
65+
## Packaging
66+
- Delta ZIP: `tmp/PR_26140_096-polish-input-mapping-v2-actions-combos-export_delta.zip`
67+
- ZIP verification: PASS, 17 files, nonzero size.

tests/playwright/tools/WorkspaceManagerV2.spec.mjs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1473,6 +1473,8 @@ test.describe("Workspace Manager V2 bootstrap", () => {
14731473
await expect(page.locator("[data-tool-starter-summary]")).toBeVisible();
14741474
await expect(page.locator(".tools-platform-frame__title[data-tool-id='input-mapping-v2']")).toHaveText("Input Mapping V2");
14751475
await expect(page.locator(".tool-starter__tool__menu")).toBeVisible();
1476+
await expect(page.locator('[data-launch-mode-nav="tool"] button')).toHaveText(["Export", "Copy JSON"]);
1477+
await expect(page.locator("#toolExportToolStateButton")).toHaveCount(0);
14761478
await expect(page.locator(".tool-starter__workspace__menu")).toBeHidden();
14771479
const fullscreenLayout = await page.locator(".input-mapping-v2.tool-starter.app-shell").evaluate((shell) => {
14781480
const left = shell.querySelector(".tool-starter__panel--left").getBoundingClientRect();
@@ -1513,6 +1515,10 @@ test.describe("Workspace Manager V2 bootstrap", () => {
15131515
expect(actionOptions).toEqual(expect.arrayContaining(["Move Left", "Confirm", "Cancel", "Fire", "Thrust", "Rotate Left", "Rotate Right", "Pause", "Select", "Start"]));
15141516
await expect(page.locator("#inputMappingV2ResetActionsButton")).toHaveCount(0);
15151517
await expect(page.locator("#inputMappingV2ClearActionButton")).toHaveText("Clear Actions");
1518+
await expect(page.locator("#actionSetupContent .input-mapping-v2__button-row button")).toHaveText(["Add Action", "Clear Actions", "Delete Action"]);
1519+
await expect(page.locator("#inputMappingV2RumbleFeedbackCheckbox")).toBeVisible();
1520+
await page.locator("#inputMappingV2RumbleFeedbackCheckbox").check();
1521+
await expect(page.locator("#statusLog")).toHaveValue(/WARN Gamepad rumble unavailable:/);
15161522
await expect(page.locator("#previewOutput")).toContainText("No inputs captured yet.");
15171523
await expect(page.locator(".input-mapping-v2__mapping-card")).toHaveCount(0);
15181524
expect(await page.locator("#previewOutput").evaluate((node) => getComputedStyle(node).overflowY)).toBe("auto");
@@ -1557,6 +1563,15 @@ test.describe("Workspace Manager V2 bootstrap", () => {
15571563
await expect(page.locator("#inputMappingV2GamepadDiagnostics")).toContainText("navigator.getGamepads() count");
15581564
await expect(page.locator("#inputMappingV2GamepadDiagnostics")).toContainText("Last poll timestamp");
15591565
await expect(page.locator("#inputMappingV2GamepadDiagnostics")).toContainText(/Window focus(yes|no)/);
1566+
const diagnosticsCardLayout = await page.locator("#inputMappingV2GamepadDiagnostics").evaluate((container) => {
1567+
const containerWidth = container.clientWidth;
1568+
return Array.from(container.querySelectorAll(".input-mapping-v2__diagnostics-card")).map((card) => ({
1569+
cardWidth: Math.round(card.getBoundingClientRect().width),
1570+
containerWidth
1571+
}));
1572+
});
1573+
expect(diagnosticsCardLayout.length).toBeGreaterThan(0);
1574+
expect(diagnosticsCardLayout.every((entry) => Math.abs(entry.cardWidth - entry.containerWidth) <= 2)).toBe(true);
15601575
await expect(page.locator(".input-mapping-v2__gamepad-capture-button")).toHaveCount(0);
15611576
await page.locator("#inputMappingV2ActionSelect").selectOption("moveLeft");
15621577
await page.locator("#inputMappingV2AddActionButton").click();
@@ -1573,6 +1588,27 @@ test.describe("Workspace Manager V2 bootstrap", () => {
15731588
const emptyMappingTileBox = await page.locator(".input-mapping-v2__mapping-card").first().boundingBox();
15741589
expect(Math.round(emptyMappingTileBox.width)).toBe(275);
15751590
expect(Math.round(emptyMappingTileBox.height)).toBe(225);
1591+
await page.locator("#inputMappingV2CaptureComboButton").click();
1592+
await expect(page.locator("#inputMappingV2CaptureComboButton")).toHaveClass(/is-capturing/);
1593+
await page.evaluate(() => {
1594+
window.dispatchEvent(new KeyboardEvent("keydown", {
1595+
bubbles: true,
1596+
cancelable: true,
1597+
code: "ControlLeft",
1598+
ctrlKey: true,
1599+
key: "Control"
1600+
}));
1601+
window.dispatchEvent(new KeyboardEvent("keydown", {
1602+
bubbles: true,
1603+
cancelable: true,
1604+
code: "KeyR",
1605+
ctrlKey: true,
1606+
key: "r"
1607+
}));
1608+
});
1609+
await expect(page.locator("#inputMappingV2CaptureComboButton")).not.toHaveClass(/is-capturing/);
1610+
await expect(page.locator("#previewOutput")).toContainText("Combo Ctrl + R");
1611+
await expect(page.locator("#inspectorOutput")).toContainText('"binding": "Combo:ControlLeft+KeyR"');
15761612
await page.locator("#inputMappingV2CaptureKeyboardButton").click();
15771613
await expect(page.locator("#inputMappingV2CaptureKeyboardButton")).toHaveClass(/is-capturing/);
15781614
await expect(page.locator("#inputMappingV2CaptureMessage")).toContainText("Press a keyboard key");
@@ -1762,6 +1798,9 @@ test.describe("Workspace Manager V2 bootstrap", () => {
17621798
version: 1
17631799
});
17641800
expect(asteroidsManifestInputMapping.actions.map((action) => action.label)).toEqual(actionOptions);
1801+
await page.locator("#toolExportButton").click();
1802+
await expect(page.locator("#inspectorOutput")).toContainText('"payload"');
1803+
await expect(page.locator("#statusLog")).toHaveValue(/OK Input Mapping V2 export preview written\./);
17651804
await page.evaluate(() => {
17661805
Object.defineProperty(navigator, "clipboard", {
17671806
configurable: true,

tools/input-mapping-v2/how_to_use.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@
1212
<main class="tool-starter app-shell" data-tool-id="input-mapping-v2">
1313
<section class="tool-starter__panel" aria-label="Input Mapping V2 workflow">
1414
<h1>Input Mapping V2</h1>
15-
<p>Select an action, capture a keyboard key, mouse button, or gamepad input, then review the generated mapping JSON.</p>
15+
<p>Select an action, capture a keyboard key, combo, mouse button, pointer drag, or gamepad input, then review the generated mapping JSON.</p>
1616
<ol>
1717
<li>Open Input Mapping V2.</li>
1818
<li>Select an action from the Actions panel.</li>
19-
<li>Use Capture Keyboard, Capture Mouse, or Capture Gamepad.</li>
19+
<li>Use Capture Keyboard, Capture Combo, Capture Mouse, pointer drag, or Capture Gamepad.</li>
2020
<li>Click a captured input token to delete that binding.</li>
21-
<li>Use Copy JSON or Export toolState when the mapping is ready.</li>
21+
<li>Use Copy JSON or Export when the mapping is ready.</li>
2222
</ol>
2323
<nav class="tool-starter__menu" aria-label="Input Mapping V2 links">
2424
<a href="./index.html">Open Tool</a>

tools/input-mapping-v2/index.html

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ <h2 class="tools-platform-frame__eyebrow">Input Sources and Action Mapping</h2>
4040
<nav class="tool-starter__menu tool-starter__tool__menu" aria-label="Tool actions" data-launch-mode-nav="tool">
4141
<button id="toolExportButton" type="button">Export</button>
4242
<button id="toolCopyJsonButton" type="button">Copy JSON</button>
43-
<button id="toolExportToolStateButton" type="button">Export toolState</button>
4443
</nav>
4544

4645
<nav class="tool-starter__menu tool-starter__workspace__menu" aria-label="Workspace actions" data-launch-mode-nav="workspace" hidden>
@@ -66,10 +65,14 @@ <h2 class="tools-platform-frame__eyebrow">Input Sources and Action Mapping</h2>
6665
<span>Custom action</span>
6766
<input id="inputMappingV2CustomActionInput" type="text" autocomplete="off" placeholder="dash, interact, inspect">
6867
</label>
68+
<label class="input-mapping-v2__checkbox-field" for="inputMappingV2RumbleFeedbackCheckbox">
69+
<input id="inputMappingV2RumbleFeedbackCheckbox" type="checkbox">
70+
<span>Gamepad rumble/haptic feedback</span>
71+
</label>
6972
<div class="input-mapping-v2__button-row">
7073
<button id="inputMappingV2AddActionButton" type="button">Add Action</button>
71-
<button id="inputMappingV2DeleteActionButton" type="button">Delete Action</button>
7274
<button id="inputMappingV2ClearActionButton" type="button">Clear Actions</button>
75+
<button id="inputMappingV2DeleteActionButton" type="button">Delete Action</button>
7376
</div>
7477
<p id="inputMappingV2ActionHint" class="tool-starter__hint">Default actions are engine-style action names sorted alphabetically.</p>
7578
</div>
@@ -85,6 +88,7 @@ <h2 class="tools-platform-frame__eyebrow">Input Sources and Action Mapping</h2>
8588
<div class="input-mapping-v2__button-row">
8689
<button id="inputMappingV2CaptureKeyboardButton" class="input-mapping-v2__capture-button" type="button">Capture Keyboard</button>
8790
<button id="inputMappingV2CaptureMouseButton" class="input-mapping-v2__capture-button" type="button">Capture Mouse</button>
91+
<button id="inputMappingV2CaptureComboButton" class="input-mapping-v2__capture-button" type="button">Capture Combo</button>
8892
</div>
8993
<div id="inputMappingV2PointerDragCaptureButtons" class="input-mapping-v2__pointer-drag-buttons" aria-label="Pointer drag capture buttons"></div>
9094
<div id="inputMappingV2GamepadCaptureButtons" class="input-mapping-v2__gamepad-buttons" aria-label="Detected gamepad capture buttons"></div>

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

Lines changed: 73 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export class ToolStarterApp {
2929
this.window = windowRef;
3030
this.captureMode = "";
3131
this.activeGamepadIndex = null;
32+
this.comboCaptureInputs = [];
33+
this.rumbleFeedbackEnabled = false;
3234
this.captureTimeoutMs = 8000;
3335
this.captureTimeoutTimer = null;
3436
this.gamepadPollIntervalMs = 750;
@@ -47,8 +49,7 @@ export class ToolStarterApp {
4749
onToolCopyJson: () => {
4850
void this.copyJson();
4951
},
50-
onToolExport: () => this.exportMappings(),
51-
onToolExportToolState: () => this.exportToolState(),
52+
onToolExport: () => this.exportToolState(),
5253
onWorkspaceCopyManifest: () => this.statusLog.ok("Input Mapping V2 workspace copy action is ready."),
5354
onWorkspaceExportManifest: () => this.statusLog.ok("Input Mapping V2 workspace export action is ready."),
5455
onWorkspaceImportManifest: () => this.statusLog.ok("Input Mapping V2 workspace import action is ready.")
@@ -57,9 +58,13 @@ export class ToolStarterApp {
5758
onActionChanged: (actionId) => this.selectAction(actionId),
5859
onAddAction: (label) => this.addAction(label),
5960
onClearAction: () => this.clearSelectedAction(),
60-
onDeleteAction: () => this.deleteSelectedAction()
61+
onDeleteAction: () => this.deleteSelectedAction(),
62+
onRumbleFeedbackChanged: (isEnabled) => {
63+
void this.setRumbleFeedback(isEnabled);
64+
}
6165
});
6266
this.capture.mount({
67+
onCaptureCombo: () => this.startComboCapture(),
6368
onCaptureGamepad: (gamepadIndex) => this.startGamepadCapture(gamepadIndex),
6469
onCaptureKeyboard: () => this.startKeyboardCapture(),
6570
onCaptureMouse: () => this.startMouseCapture(),
@@ -127,6 +132,17 @@ export class ToolStarterApp {
127132
this.statusLog.ok(`Mouse capture armed for ${this.state.selectedActionLabel()}.`);
128133
}
129134

135+
startComboCapture() {
136+
if (this.isCaptureActive("combo")) {
137+
this.cancelCapture("Combo");
138+
return;
139+
}
140+
this.comboCaptureInputs = [];
141+
this.beginCapture("combo");
142+
this.capture.showMessage(`Press two keys or mouse buttons to bind a combo to ${this.state.selectedActionLabel()}.`);
143+
this.statusLog.ok(`Combo capture armed for ${this.state.selectedActionLabel()}.`);
144+
}
145+
130146
startGamepadCapture(gamepadIndex) {
131147
const selectedIndex = Number(gamepadIndex);
132148
if (!Number.isInteger(selectedIndex)) {
@@ -154,6 +170,12 @@ export class ToolStarterApp {
154170
}
155171

156172
handleKeyDown(event) {
173+
if (this.captureMode === "combo") {
174+
event.preventDefault();
175+
event.stopPropagation();
176+
this.recordComboInput(this.engineInputSources.captureKeyboard(event));
177+
return;
178+
}
157179
if (this.captureMode !== "keyboard") {
158180
return;
159181
}
@@ -163,6 +185,12 @@ export class ToolStarterApp {
163185
}
164186

165187
handleMouseDown(event) {
188+
if (this.captureMode === "combo") {
189+
event.preventDefault();
190+
event.stopPropagation();
191+
this.recordComboInput(this.engineInputSources.captureMouse(event));
192+
return;
193+
}
166194
if (this.captureMode !== "mouse") {
167195
return;
168196
}
@@ -190,6 +218,29 @@ export class ToolStarterApp {
190218
return true;
191219
}
192220

221+
recordComboInput(input) {
222+
if (this.comboCaptureInputs.some((candidate) => candidate.binding === input.binding)) {
223+
this.capture.showMessage("Combo capture needs two different inputs.");
224+
this.statusLog.warn("Combo capture needs two different inputs.");
225+
return;
226+
}
227+
228+
this.comboCaptureInputs.push(input);
229+
if (this.comboCaptureInputs.length < 2) {
230+
this.capture.showMessage(`Combo capture recorded ${input.label}. Press one more key or mouse button.`);
231+
return;
232+
}
233+
234+
const result = this.engineInputSources.captureCombo(this.comboCaptureInputs);
235+
if (!result.ok) {
236+
this.statusLog.warn(result.message);
237+
this.clearCapture();
238+
this.refreshActions();
239+
return;
240+
}
241+
this.addCapturedInput(result.input);
242+
}
243+
193244
handleGamepadConnectionChange() {
194245
this.refreshGamepads();
195246
}
@@ -234,6 +285,20 @@ export class ToolStarterApp {
234285
this.statusLog.ok(status.message);
235286
}
236287

288+
async setRumbleFeedback(isEnabled) {
289+
this.rumbleFeedbackEnabled = isEnabled;
290+
if (!isEnabled) {
291+
this.statusLog.ok("Gamepad rumble/haptic feedback disabled for this tool session.");
292+
return;
293+
}
294+
295+
const result = await this.engineInputSources.requestGamepadRumblePreview();
296+
this.statusLog[result.ok ? "ok" : "warn"](result.message);
297+
if (result.ok) {
298+
this.statusLog.ok("Gamepad rumble/haptic feedback is UI-local because Input Mapping V2 toolState has no options field.");
299+
}
300+
}
301+
237302
addCapturedInput(input) {
238303
const result = this.state.addBindingToSelectedAction(input);
239304
this.clearCapture();
@@ -267,6 +332,7 @@ export class ToolStarterApp {
267332
this.captureTimeoutTimer = null;
268333
this.captureMode = "";
269334
this.activeGamepadIndex = null;
335+
this.comboCaptureInputs = [];
270336
this.capture.clearActiveCapture();
271337
}
272338

@@ -298,6 +364,9 @@ export class ToolStarterApp {
298364
if (this.captureMode === "mouse") {
299365
return "Mouse";
300366
}
367+
if (this.captureMode === "combo") {
368+
return "Combo";
369+
}
301370
if (this.captureMode === "gamepad") {
302371
return "Gamepad";
303372
}
@@ -310,14 +379,9 @@ export class ToolStarterApp {
310379
this.refreshActions();
311380
}
312381

313-
exportMappings() {
314-
this.inspector.showObject(this.state.payload());
315-
this.statusLog.ok("Mapping JSON preview written.");
316-
}
317-
318382
exportToolState() {
319383
this.inspector.showObject(this.state.toolState());
320-
this.statusLog.ok("Input Mapping V2 toolState preview written.");
384+
this.statusLog.ok("Input Mapping V2 export preview written.");
321385
}
322386

323387
async copyJson() {

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ window.addEventListener("DOMContentLoaded", () => {
3232
returnToWorkspaceButton: requireElement("#returnToWorkspaceButton"),
3333
toolCopyJsonButton: requireElement("#toolCopyJsonButton"),
3434
toolExportButton: requireElement("#toolExportButton"),
35-
toolExportToolStateButton: requireElement("#toolExportToolStateButton"),
3635
toolNav: requireElement(".tool-starter__tool__menu"),
3736
workspaceCopyManifestButton: requireElement("#workspaceCopyManifestButton"),
3837
workspaceExportManifestButton: requireElement("#workspaceExportManifestButton"),
@@ -45,9 +44,11 @@ window.addEventListener("DOMContentLoaded", () => {
4544
addActionButton: requireElement("#inputMappingV2AddActionButton"),
4645
clearActionButton: requireElement("#inputMappingV2ClearActionButton"),
4746
customActionInput: requireElement("#inputMappingV2CustomActionInput"),
48-
deleteActionButton: requireElement("#inputMappingV2DeleteActionButton")
47+
deleteActionButton: requireElement("#inputMappingV2DeleteActionButton"),
48+
rumbleFeedbackCheckbox: requireElement("#inputMappingV2RumbleFeedbackCheckbox")
4949
}),
5050
capture: new CaptureControl({
51+
captureComboButton: requireElement("#inputMappingV2CaptureComboButton"),
5152
captureGamepadButtons: requireElement("#inputMappingV2GamepadCaptureButtons"),
5253
captureKeyboardButton: requireElement("#inputMappingV2CaptureKeyboardButton"),
5354
captureMessage: requireElement("#inputMappingV2CaptureMessage"),

0 commit comments

Comments
 (0)