mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
fix(controller): save remaps per profile, gate modals on enabled (#69)
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Controller config and debug shortcuts now stay closed while controller support is disabled and show a notice to enable `controller.enabled` manually.
|
||||||
|
- Controller binding rows now start learn mode from the edit pencil, so clicking edit and pressing a controller button saves the remap.
|
||||||
|
- Controller remaps are now saved per controller profile, binding badges also start learn mode, and row reset buttons restore individual bindings to their defaults.
|
||||||
@@ -138,7 +138,8 @@
|
|||||||
"axisIndex": 4, // Raw axis index captured for this analog controller action.
|
"axisIndex": 4, // Raw axis index captured for this analog controller action.
|
||||||
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
||||||
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
|
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||||
} // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
|
}, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
|
||||||
|
"profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.
|
||||||
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
|
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -608,8 +608,12 @@ Important behavior:
|
|||||||
- Controller input is only active while keyboard-only mode is enabled.
|
- Controller input is only active while keyboard-only mode is enabled.
|
||||||
- Keyboard-only mode continues to work normally without a controller.
|
- Keyboard-only mode continues to work normally without a controller.
|
||||||
- By default SubMiner uses the first connected controller.
|
- By default SubMiner uses the first connected controller.
|
||||||
|
- Fresh installs keep controller support disabled until you set `controller.enabled` to `true`.
|
||||||
- `Alt+C` opens the controller config modal by default, and you can remap that shortcut through `shortcuts.openControllerSelect`.
|
- `Alt+C` opens the controller config modal by default, and you can remap that shortcut through `shortcuts.openControllerSelect`.
|
||||||
- Click `Learn`, then press the next fresh button, trigger, or stick direction you want to bind for that overlay action.
|
- The `Alt+C` config modal and `Alt+Shift+C` debug modal stay closed while controller support is disabled.
|
||||||
|
- Click the binding badge, edit pencil, or `Learn`, then press the next fresh button, trigger, or stick direction you want to bind for that overlay action.
|
||||||
|
- Click the reset button beside the edit pencil to restore one binding to the built-in default.
|
||||||
|
- Learned bindings are saved under `controller.profiles` for the selected controller id. Global `controller.bindings` remains the fallback for controllers without a profile.
|
||||||
- `Alt+Shift+C` opens the debug modal by default, and you can remap that shortcut through `shortcuts.openControllerDebug`.
|
- `Alt+Shift+C` opens the debug modal by default, and you can remap that shortcut through `shortcuts.openControllerDebug`.
|
||||||
- The debug modal shows raw axes/button values plus a ready-to-copy `buttonIndices` config block.
|
- The debug modal shows raw axes/button values plus a ready-to-copy `buttonIndices` config block.
|
||||||
- `controller.buttonIndices` is a semantic reference/legacy mapping. Changing it does not rewrite the raw numeric descriptor values already stored under `controller.bindings`.
|
- `controller.buttonIndices` is a semantic reference/legacy mapping. Changing it does not rewrite the raw numeric descriptor values already stored under `controller.bindings`.
|
||||||
@@ -658,6 +662,15 @@ Important behavior:
|
|||||||
"rightStickHorizontal": { "kind": "axis", "axisIndex": 3, "dpadFallback": "none" },
|
"rightStickHorizontal": { "kind": "axis", "axisIndex": 3, "dpadFallback": "none" },
|
||||||
"rightStickVertical": { "kind": "axis", "axisIndex": 4, "dpadFallback": "none" },
|
"rightStickVertical": { "kind": "axis", "axisIndex": 4, "dpadFallback": "none" },
|
||||||
},
|
},
|
||||||
|
"profiles": {
|
||||||
|
"Xbox Wireless Controller": {
|
||||||
|
"label": "Xbox Wireless Controller",
|
||||||
|
"bindings": {
|
||||||
|
"toggleLookup": { "kind": "button", "buttonIndex": 0 },
|
||||||
|
"mineCard": { "kind": "button", "buttonIndex": 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -678,7 +691,7 @@ Default logical mapping:
|
|||||||
- `L3`: toggle mpv pause
|
- `L3`: toggle mpv pause
|
||||||
- `L2` / `R2`: unbound by default
|
- `L2` / `R2`: unbound by default
|
||||||
|
|
||||||
Discrete bindings may use raw button indices or raw axis directions, and analog bindings use raw axis indices with optional D-pad fallback. The `Alt+C` learn flow writes those descriptors for you, so manual edits are only needed when you want to script or copy exact mappings.
|
Discrete bindings may use raw button indices or raw axis directions, and analog bindings use raw axis indices with optional D-pad fallback. The `Alt+C` learn flow writes those descriptors under `controller.profiles["<controller id>"]` for the selected controller. Manual edits are only needed when you want to script or copy exact mappings.
|
||||||
|
|
||||||
If you bind a discrete action to an axis manually, include `direction`:
|
If you bind a discrete action to an axis manually, include `direction`:
|
||||||
|
|
||||||
@@ -692,15 +705,15 @@ If you bind a discrete action to an axis manually, include `direction`:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Treat `controller.buttonIndices` as reference-only unless you are still using legacy semantic bindings or copying values from the debug modal. Updating `controller.buttonIndices` alone does not rewrite the hardcoded raw numeric values already present in `controller.bindings`. If you need a real remap, prefer the `Alt+C` learn flow so both the source and the descriptor shape stay correct.
|
Treat `controller.buttonIndices` as reference-only unless you are still using legacy semantic bindings or copying values from the debug modal. Updating `controller.buttonIndices` alone does not rewrite the hardcoded raw numeric values already present in `controller.bindings` or `controller.profiles.*.bindings`. If you need a real remap, prefer the `Alt+C` learn flow so both the source and the descriptor shape stay correct.
|
||||||
|
|
||||||
If you choose to bind `L2` or `R2` manually, set `triggerInputMode` to `analog` and tune `triggerDeadzone` when your controller reports triggers as analog values instead of digital pressed/not-pressed buttons. `auto` accepts either style and remains the default.
|
If you choose to bind `L2` or `R2` manually, set `triggerInputMode` to `analog` and tune `triggerDeadzone` when your controller reports triggers as analog values instead of digital pressed/not-pressed buttons. `auto` accepts either style and remains the default.
|
||||||
|
|
||||||
If your controller reports non-standard raw button numbers, override `controller.buttonIndices` using values from the `Alt+Shift+C` debug modal.
|
If one controller reports non-standard raw button numbers, override `controller.profiles["<controller id>"].buttonIndices` using values from the `Alt+Shift+C` debug modal. Use global `controller.buttonIndices` only when the mapping should apply to every controller without a profile.
|
||||||
|
|
||||||
If you update this controller documentation or the generated controller examples, run `bun run docs:test` and `bun run docs:build` before merging.
|
If you update this controller documentation or the generated controller examples, run `bun run docs:test` and `bun run docs:build` before merging.
|
||||||
|
|
||||||
Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing, and `buttonIndices` to match your controller. See [config.example.jsonc](/config.example.jsonc) for the full generated comments for every controller field.
|
Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing, and profile `buttonIndices` to match your controller. See [config.example.jsonc](/config.example.jsonc) for the full generated comments for every controller field.
|
||||||
|
|
||||||
### Manual Card Update Shortcuts
|
### Manual Card Update Shortcuts
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ With a gamepad connected and keyboard-only mode enabled, the full mining loop wo
|
|||||||
6. **Close** — press `B` to dismiss the Yomitan popup and return to subtitle navigation.
|
6. **Close** — press `B` to dismiss the Yomitan popup and return to subtitle navigation.
|
||||||
7. **Pause/resume** — press `L3` (left stick click) to toggle mpv pause at any time.
|
7. **Pause/resume** — press `L3` (left stick click) to toggle mpv pause at any time.
|
||||||
|
|
||||||
The controller and keyboard can be used interchangeably — switching mid-session is seamless. Toggle keyboard-only mode on or off with `Y` on the controller.
|
After controller support is enabled, the controller and keyboard can be used interchangeably — switching mid-session is seamless. Toggle keyboard-only mode on or off with `Y` on the controller.
|
||||||
|
|
||||||
See [Usage — Controller Support](/usage#controller-support) for setup details and [Configuration — Controller Support](/configuration#controller-support) for the full mapping and tuning options.
|
See [Usage — Controller Support](/usage#controller-support) for setup details and [Configuration — Controller Support](/configuration#controller-support) for the full mapping and tuning options.
|
||||||
|
|
||||||
|
|||||||
@@ -138,7 +138,8 @@
|
|||||||
"axisIndex": 4, // Raw axis index captured for this analog controller action.
|
"axisIndex": 4, // Raw axis index captured for this analog controller action.
|
||||||
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
||||||
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
|
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||||
} // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
|
}, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
|
||||||
|
"profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.
|
||||||
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
|
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
+7
-6
@@ -283,13 +283,14 @@ SubMiner supports gamepad/controller input for couch-friendly usage via the Chro
|
|||||||
### Getting Started
|
### Getting Started
|
||||||
|
|
||||||
1. Connect a controller before or after launching SubMiner.
|
1. Connect a controller before or after launching SubMiner.
|
||||||
2. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding.
|
2. Set `controller.enabled` to `true` in your config.
|
||||||
3. Press `Alt+C` in the overlay by default to pick the controller you want to save and remap any action inline.
|
3. Press `Alt+C` in the overlay by default to pick the controller you want to save and remap any action inline.
|
||||||
4. Click `Learn` on the overlay action you want, then press the matching button, trigger, or stick direction on the controller.
|
4. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding.
|
||||||
5. Use the left stick to navigate subtitle tokens and scroll the popup; use the right stick vertically for popup page jumps.
|
5. Click the binding badge, edit pencil, or `Learn` on the overlay action you want, then press the matching button, trigger, or stick direction on the controller.
|
||||||
6. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup.
|
6. Use the left stick to navigate subtitle tokens and scroll the popup; use the right stick vertically for popup page jumps.
|
||||||
|
7. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup.
|
||||||
|
|
||||||
By default SubMiner uses the first connected controller. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline, and `Alt+Shift+C` opens the live debug modal with raw axes/button values for non-standard pads. Both shortcuts can be changed through `shortcuts.openControllerSelect` and `shortcuts.openControllerDebug`.
|
By default SubMiner uses the first connected controller after controller support is enabled. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline per controller. The reset button beside each edit pencil restores that binding to its built-in default for the selected controller. `Alt+Shift+C` opens the live debug modal with raw axes/button values for non-standard pads. Both modals stay closed while `controller.enabled` is false, and both shortcuts can be changed through `shortcuts.openControllerSelect` and `shortcuts.openControllerDebug`.
|
||||||
|
|
||||||
### Default Button Mapping
|
### Default Button Mapping
|
||||||
|
|
||||||
@@ -316,7 +317,7 @@ By default SubMiner uses the first connected controller. `Alt+C` opens the contr
|
|||||||
|
|
||||||
Learn mode ignores already-held inputs and waits for the next fresh button press or axis direction, which avoids accidental captures when you open the modal mid-input.
|
Learn mode ignores already-held inputs and waits for the next fresh button press or axis direction, which avoids accidental captures when you open the modal mid-input.
|
||||||
|
|
||||||
All button and axis mappings are configurable under the `controller` config block. See [Configuration — Controller Support](/configuration#controller-support) for the full options.
|
All button and axis mappings are configurable under the `controller` config block. Learned remaps are saved under `controller.profiles` for the selected controller id. See [Configuration — Controller Support](/configuration#controller-support) for the full options.
|
||||||
|
|
||||||
## Keybindings
|
## Keybindings
|
||||||
|
|
||||||
|
|||||||
@@ -1453,6 +1453,104 @@ test('parses descriptor-based controller bindings', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parses controller profiles as per-gamepad binding overrides', () => {
|
||||||
|
const dir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"controller": {
|
||||||
|
"buttonIndices": {
|
||||||
|
"buttonSouth": 0,
|
||||||
|
"leftTrigger": 6
|
||||||
|
},
|
||||||
|
"bindings": {
|
||||||
|
"toggleLookup": { "kind": "button", "buttonIndex": 0 },
|
||||||
|
"quitMpv": "leftTrigger"
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"8BitDo SN30": {
|
||||||
|
"label": "8BitDo SN30",
|
||||||
|
"bindings": {
|
||||||
|
"toggleLookup": { "kind": "button", "buttonIndex": 11 },
|
||||||
|
"leftStickVertical": { "kind": "axis", "axisIndex": 7, "dpadFallback": "none" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Xbox Wireless Controller": {
|
||||||
|
"buttonIndices": {
|
||||||
|
"leftTrigger": 8
|
||||||
|
},
|
||||||
|
"bindings": {
|
||||||
|
"quitMpv": "leftTrigger"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new ConfigService(dir);
|
||||||
|
const config = service.getConfig();
|
||||||
|
|
||||||
|
assert.deepEqual(config.controller.profiles['8BitDo SN30']?.bindings.toggleLookup, {
|
||||||
|
kind: 'button',
|
||||||
|
buttonIndex: 11,
|
||||||
|
});
|
||||||
|
assert.deepEqual(config.controller.profiles['8BitDo SN30']?.bindings.closeLookup, {
|
||||||
|
kind: 'button',
|
||||||
|
buttonIndex: 1,
|
||||||
|
});
|
||||||
|
assert.deepEqual(config.controller.profiles['8BitDo SN30']?.bindings.leftStickVertical, {
|
||||||
|
kind: 'axis',
|
||||||
|
axisIndex: 7,
|
||||||
|
dpadFallback: 'none',
|
||||||
|
});
|
||||||
|
assert.deepEqual(config.controller.profiles['Xbox Wireless Controller']?.bindings.quitMpv, {
|
||||||
|
kind: 'button',
|
||||||
|
buttonIndex: 8,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
config.controller.profiles['Xbox Wireless Controller']?.buttonIndices.leftTrigger,
|
||||||
|
8,
|
||||||
|
);
|
||||||
|
assert.deepEqual(config.controller.bindings.quitMpv, { kind: 'button', buttonIndex: 6 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects reserved controller profile ids from config', () => {
|
||||||
|
const dir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"controller": {
|
||||||
|
"profiles": {
|
||||||
|
"__proto__": { "label": "polluted" },
|
||||||
|
"constructor": { "label": "ctor" },
|
||||||
|
"prototype": { "label": "proto" },
|
||||||
|
"pad-1": { "label": "Pad 1" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new ConfigService(dir);
|
||||||
|
const config = service.getConfig();
|
||||||
|
const warnings = service.getWarnings();
|
||||||
|
|
||||||
|
assert.equal(Object.hasOwn(config.controller.profiles, '__proto__'), false);
|
||||||
|
assert.equal(Object.hasOwn(config.controller.profiles, 'constructor'), false);
|
||||||
|
assert.equal(Object.hasOwn(config.controller.profiles, 'prototype'), false);
|
||||||
|
assert.equal(config.controller.profiles['pad-1']?.label, 'Pad 1');
|
||||||
|
assert.equal(
|
||||||
|
warnings.some((warning) => warning.path === 'controller.profiles.constructor'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
warnings.some((warning) => warning.path === 'controller.profiles.prototype'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('controller descriptor config rejects malformed binding objects', () => {
|
test('controller descriptor config rejects malformed binding objects', () => {
|
||||||
const dir = makeTempDir();
|
const dir = makeTempDir();
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||||
},
|
},
|
||||||
|
profiles: {},
|
||||||
},
|
},
|
||||||
shortcuts: {
|
shortcuts: {
|
||||||
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
|
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
|
||||||
|
|||||||
@@ -239,6 +239,13 @@ export function buildCoreConfigOptionRegistry(
|
|||||||
description:
|
description:
|
||||||
'Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.',
|
'Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.profiles',
|
||||||
|
kind: 'object',
|
||||||
|
defaultValue: defaultConfig.controller.profiles,
|
||||||
|
description:
|
||||||
|
'Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.',
|
||||||
|
},
|
||||||
...discreteBindings.flatMap((binding) => [
|
...discreteBindings.flatMap((binding) => [
|
||||||
{
|
{
|
||||||
path: `controller.bindings.${binding.id}`,
|
path: `controller.bindings.${binding.id}`,
|
||||||
|
|||||||
@@ -0,0 +1,423 @@
|
|||||||
|
import type {
|
||||||
|
ControllerAxisBinding,
|
||||||
|
ControllerAxisDirection,
|
||||||
|
ControllerButtonBinding,
|
||||||
|
ControllerButtonIndicesConfig,
|
||||||
|
ControllerDpadFallback,
|
||||||
|
ResolvedControllerAxisBinding,
|
||||||
|
ResolvedControllerBindingsConfig,
|
||||||
|
ResolvedControllerDiscreteBinding,
|
||||||
|
} from '../../types/runtime';
|
||||||
|
import { ResolveContext } from './context';
|
||||||
|
import { asBoolean, asNumber, asString, isObject } from './shared';
|
||||||
|
|
||||||
|
const CONTROLLER_BUTTON_BINDINGS = [
|
||||||
|
'none',
|
||||||
|
'select',
|
||||||
|
'buttonSouth',
|
||||||
|
'buttonEast',
|
||||||
|
'buttonNorth',
|
||||||
|
'buttonWest',
|
||||||
|
'leftShoulder',
|
||||||
|
'rightShoulder',
|
||||||
|
'leftStickPress',
|
||||||
|
'rightStickPress',
|
||||||
|
'leftTrigger',
|
||||||
|
'rightTrigger',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const CONTROLLER_AXIS_BINDINGS = [
|
||||||
|
'leftStickX',
|
||||||
|
'leftStickY',
|
||||||
|
'rightStickX',
|
||||||
|
'rightStickY',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const CONTROLLER_AXIS_INDEX_BY_BINDING: Record<ControllerAxisBinding, number> = {
|
||||||
|
leftStickX: 0,
|
||||||
|
leftStickY: 1,
|
||||||
|
rightStickX: 3,
|
||||||
|
rightStickY: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING: Record<
|
||||||
|
Exclude<ControllerButtonBinding, 'none'>,
|
||||||
|
keyof Required<ControllerButtonIndicesConfig>
|
||||||
|
> = {
|
||||||
|
select: 'select',
|
||||||
|
buttonSouth: 'buttonSouth',
|
||||||
|
buttonEast: 'buttonEast',
|
||||||
|
buttonNorth: 'buttonNorth',
|
||||||
|
buttonWest: 'buttonWest',
|
||||||
|
leftShoulder: 'leftShoulder',
|
||||||
|
rightShoulder: 'rightShoulder',
|
||||||
|
leftStickPress: 'leftStickPress',
|
||||||
|
rightStickPress: 'rightStickPress',
|
||||||
|
leftTrigger: 'leftTrigger',
|
||||||
|
rightTrigger: 'rightTrigger',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONTROLLER_AXIS_FALLBACK_BY_SLOT = {
|
||||||
|
leftStickHorizontal: 'horizontal',
|
||||||
|
leftStickVertical: 'vertical',
|
||||||
|
rightStickHorizontal: 'none',
|
||||||
|
rightStickVertical: 'none',
|
||||||
|
} as const satisfies Record<string, ControllerDpadFallback>;
|
||||||
|
|
||||||
|
const CONTROLLER_BUTTON_INDEX_KEYS = [
|
||||||
|
'select',
|
||||||
|
'buttonSouth',
|
||||||
|
'buttonEast',
|
||||||
|
'buttonNorth',
|
||||||
|
'buttonWest',
|
||||||
|
'leftShoulder',
|
||||||
|
'rightShoulder',
|
||||||
|
'leftStickPress',
|
||||||
|
'rightStickPress',
|
||||||
|
'leftTrigger',
|
||||||
|
'rightTrigger',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const CONTROLLER_DISCRETE_BINDING_KEYS = [
|
||||||
|
'toggleLookup',
|
||||||
|
'closeLookup',
|
||||||
|
'toggleKeyboardOnlyMode',
|
||||||
|
'mineCard',
|
||||||
|
'quitMpv',
|
||||||
|
'previousAudio',
|
||||||
|
'nextAudio',
|
||||||
|
'playCurrentAudio',
|
||||||
|
'toggleMpvPause',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const CONTROLLER_AXIS_BINDING_KEYS = [
|
||||||
|
'leftStickHorizontal',
|
||||||
|
'leftStickVertical',
|
||||||
|
'rightStickHorizontal',
|
||||||
|
'rightStickVertical',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||||
|
|
||||||
|
type ControllerBindingsTarget = Required<ResolvedControllerBindingsConfig>;
|
||||||
|
type ControllerButtonIndicesTarget = Required<ControllerButtonIndicesConfig>;
|
||||||
|
|
||||||
|
function isControllerAxisDirection(value: unknown): value is ControllerAxisDirection {
|
||||||
|
return value === 'negative' || value === 'positive';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isControllerDpadFallback(value: unknown): value is ControllerDpadFallback {
|
||||||
|
return value === 'none' || value === 'horizontal' || value === 'vertical';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLegacyDiscreteBinding(
|
||||||
|
value: ControllerButtonBinding,
|
||||||
|
buttonIndices: Required<ControllerButtonIndicesConfig>,
|
||||||
|
): ResolvedControllerDiscreteBinding {
|
||||||
|
if (value === 'none') {
|
||||||
|
return { kind: 'none' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: 'button',
|
||||||
|
buttonIndex: buttonIndices[CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING[value]],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLegacyAxisBinding(
|
||||||
|
value: ControllerAxisBinding,
|
||||||
|
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
|
||||||
|
): ResolvedControllerAxisBinding {
|
||||||
|
return {
|
||||||
|
kind: 'axis',
|
||||||
|
axisIndex: CONTROLLER_AXIS_INDEX_BY_BINDING[value],
|
||||||
|
dpadFallback: CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDiscreteBindingObject(value: unknown): ResolvedControllerDiscreteBinding | null {
|
||||||
|
if (!isObject(value) || typeof value.kind !== 'string') return null;
|
||||||
|
if (value.kind === 'none') {
|
||||||
|
return { kind: 'none' };
|
||||||
|
}
|
||||||
|
if (value.kind === 'button') {
|
||||||
|
return typeof value.buttonIndex === 'number' &&
|
||||||
|
Number.isInteger(value.buttonIndex) &&
|
||||||
|
value.buttonIndex >= 0
|
||||||
|
? { kind: 'button', buttonIndex: value.buttonIndex }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (value.kind === 'axis') {
|
||||||
|
return typeof value.axisIndex === 'number' &&
|
||||||
|
Number.isInteger(value.axisIndex) &&
|
||||||
|
value.axisIndex >= 0 &&
|
||||||
|
isControllerAxisDirection(value.direction)
|
||||||
|
? { kind: 'axis', axisIndex: value.axisIndex, direction: value.direction }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAxisBindingObject(
|
||||||
|
value: unknown,
|
||||||
|
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
|
||||||
|
): ResolvedControllerAxisBinding | null {
|
||||||
|
if (isObject(value) && value.kind === 'none') {
|
||||||
|
return { kind: 'none' };
|
||||||
|
}
|
||||||
|
if (!isObject(value) || value.kind !== 'axis') return null;
|
||||||
|
if (
|
||||||
|
typeof value.axisIndex !== 'number' ||
|
||||||
|
!Number.isInteger(value.axisIndex) ||
|
||||||
|
value.axisIndex < 0
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value.dpadFallback !== undefined && !isControllerDpadFallback(value.dpadFallback)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: 'axis',
|
||||||
|
axisIndex: value.axisIndex,
|
||||||
|
dpadFallback: value.dpadFallback ?? CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyControllerButtonIndices(
|
||||||
|
source: unknown,
|
||||||
|
target: ControllerButtonIndicesTarget,
|
||||||
|
pathPrefix: string,
|
||||||
|
warn: ResolveContext['warn'],
|
||||||
|
): void {
|
||||||
|
if (!isObject(source)) return;
|
||||||
|
|
||||||
|
for (const key of CONTROLLER_BUTTON_INDEX_KEYS) {
|
||||||
|
const value = asNumber(source[key]);
|
||||||
|
if (value !== undefined && value >= 0 && Number.isInteger(value)) {
|
||||||
|
target[key] = value;
|
||||||
|
} else if (source[key] !== undefined) {
|
||||||
|
warn(`${pathPrefix}.${key}`, source[key], target[key], 'Expected non-negative integer.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyControllerBindings(
|
||||||
|
source: unknown,
|
||||||
|
target: ControllerBindingsTarget,
|
||||||
|
buttonIndices: ControllerButtonIndicesTarget,
|
||||||
|
pathPrefix: string,
|
||||||
|
warn: ResolveContext['warn'],
|
||||||
|
): void {
|
||||||
|
if (!isObject(source)) return;
|
||||||
|
|
||||||
|
for (const key of CONTROLLER_DISCRETE_BINDING_KEYS) {
|
||||||
|
const bindingValue = source[key];
|
||||||
|
const legacyValue = asString(bindingValue);
|
||||||
|
if (
|
||||||
|
legacyValue !== undefined &&
|
||||||
|
CONTROLLER_BUTTON_BINDINGS.includes(
|
||||||
|
legacyValue as (typeof CONTROLLER_BUTTON_BINDINGS)[number],
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
target[key] = resolveLegacyDiscreteBinding(
|
||||||
|
legacyValue as ControllerButtonBinding,
|
||||||
|
buttonIndices,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parsedObject = parseDiscreteBindingObject(bindingValue);
|
||||||
|
if (parsedObject) {
|
||||||
|
target[key] = parsedObject;
|
||||||
|
} else if (bindingValue !== undefined) {
|
||||||
|
warn(
|
||||||
|
`${pathPrefix}.${key}`,
|
||||||
|
bindingValue,
|
||||||
|
target[key],
|
||||||
|
"Expected legacy controller button name or binding object with kind 'none', 'button', or 'axis'.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of CONTROLLER_AXIS_BINDING_KEYS) {
|
||||||
|
const bindingValue = source[key];
|
||||||
|
const legacyValue = asString(bindingValue);
|
||||||
|
if (
|
||||||
|
legacyValue !== undefined &&
|
||||||
|
CONTROLLER_AXIS_BINDINGS.includes(legacyValue as (typeof CONTROLLER_AXIS_BINDINGS)[number])
|
||||||
|
) {
|
||||||
|
target[key] = resolveLegacyAxisBinding(legacyValue as ControllerAxisBinding, key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (legacyValue === 'none') {
|
||||||
|
target[key] = { kind: 'none' };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parsedObject = parseAxisBindingObject(bindingValue, key);
|
||||||
|
if (parsedObject) {
|
||||||
|
target[key] = parsedObject;
|
||||||
|
} else if (bindingValue !== undefined) {
|
||||||
|
warn(
|
||||||
|
`${pathPrefix}.${key}`,
|
||||||
|
bindingValue,
|
||||||
|
target[key],
|
||||||
|
"Expected legacy controller axis name ('none' allowed) or binding object with kind 'axis'.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyControllerConfig(context: ResolveContext): void {
|
||||||
|
const { src, resolved, warn } = context;
|
||||||
|
if (!isObject(src.controller)) return;
|
||||||
|
|
||||||
|
const enabled = asBoolean(src.controller.enabled);
|
||||||
|
if (enabled !== undefined) {
|
||||||
|
resolved.controller.enabled = enabled;
|
||||||
|
} else if (src.controller.enabled !== undefined) {
|
||||||
|
warn(
|
||||||
|
'controller.enabled',
|
||||||
|
src.controller.enabled,
|
||||||
|
resolved.controller.enabled,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferredGamepadId = asString(src.controller.preferredGamepadId);
|
||||||
|
if (preferredGamepadId !== undefined) {
|
||||||
|
resolved.controller.preferredGamepadId = preferredGamepadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferredGamepadLabel = asString(src.controller.preferredGamepadLabel);
|
||||||
|
if (preferredGamepadLabel !== undefined) {
|
||||||
|
resolved.controller.preferredGamepadLabel = preferredGamepadLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const smoothScroll = asBoolean(src.controller.smoothScroll);
|
||||||
|
if (smoothScroll !== undefined) {
|
||||||
|
resolved.controller.smoothScroll = smoothScroll;
|
||||||
|
} else if (src.controller.smoothScroll !== undefined) {
|
||||||
|
warn(
|
||||||
|
'controller.smoothScroll',
|
||||||
|
src.controller.smoothScroll,
|
||||||
|
resolved.controller.smoothScroll,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerInputMode = asString(src.controller.triggerInputMode);
|
||||||
|
if (
|
||||||
|
triggerInputMode === 'auto' ||
|
||||||
|
triggerInputMode === 'digital' ||
|
||||||
|
triggerInputMode === 'analog'
|
||||||
|
) {
|
||||||
|
resolved.controller.triggerInputMode = triggerInputMode;
|
||||||
|
} else if (src.controller.triggerInputMode !== undefined) {
|
||||||
|
warn(
|
||||||
|
'controller.triggerInputMode',
|
||||||
|
src.controller.triggerInputMode,
|
||||||
|
resolved.controller.triggerInputMode,
|
||||||
|
"Expected 'auto', 'digital', or 'analog'.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundedNumberKeys = [
|
||||||
|
'scrollPixelsPerSecond',
|
||||||
|
'horizontalJumpPixels',
|
||||||
|
'repeatDelayMs',
|
||||||
|
'repeatIntervalMs',
|
||||||
|
] as const;
|
||||||
|
for (const key of boundedNumberKeys) {
|
||||||
|
const value = asNumber(src.controller[key]);
|
||||||
|
if (value !== undefined && Math.floor(value) > 0) {
|
||||||
|
resolved.controller[key] = Math.floor(value) as (typeof resolved.controller)[typeof key];
|
||||||
|
} else if (src.controller[key] !== undefined) {
|
||||||
|
warn(
|
||||||
|
`controller.${key}`,
|
||||||
|
src.controller[key],
|
||||||
|
resolved.controller[key],
|
||||||
|
'Expected positive number.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadzoneKeys = ['stickDeadzone', 'triggerDeadzone'] as const;
|
||||||
|
for (const key of deadzoneKeys) {
|
||||||
|
const value = asNumber(src.controller[key]);
|
||||||
|
if (value !== undefined && value >= 0 && value <= 1) {
|
||||||
|
resolved.controller[key] = value as (typeof resolved.controller)[typeof key];
|
||||||
|
} else if (src.controller[key] !== undefined) {
|
||||||
|
warn(
|
||||||
|
`controller.${key}`,
|
||||||
|
src.controller[key],
|
||||||
|
resolved.controller[key],
|
||||||
|
'Expected number between 0 and 1.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyControllerButtonIndices(
|
||||||
|
src.controller.buttonIndices,
|
||||||
|
resolved.controller.buttonIndices,
|
||||||
|
'controller.buttonIndices',
|
||||||
|
warn,
|
||||||
|
);
|
||||||
|
applyControllerBindings(
|
||||||
|
src.controller.bindings,
|
||||||
|
resolved.controller.bindings,
|
||||||
|
resolved.controller.buttonIndices,
|
||||||
|
'controller.bindings',
|
||||||
|
warn,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isObject(src.controller.profiles)) {
|
||||||
|
for (const [profileId, rawProfile] of Object.entries(src.controller.profiles)) {
|
||||||
|
if (RESERVED_CONTROLLER_PROFILE_IDS.has(profileId)) {
|
||||||
|
warn(
|
||||||
|
`controller.profiles.${profileId}`,
|
||||||
|
rawProfile,
|
||||||
|
undefined,
|
||||||
|
'Reserved profile id is not allowed.',
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isObject(rawProfile)) {
|
||||||
|
warn(
|
||||||
|
`controller.profiles.${profileId}`,
|
||||||
|
rawProfile,
|
||||||
|
undefined,
|
||||||
|
'Expected controller profile object.',
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = asString(rawProfile.label);
|
||||||
|
if (rawProfile.label !== undefined && label === undefined) {
|
||||||
|
warn(
|
||||||
|
`controller.profiles.${profileId}.label`,
|
||||||
|
rawProfile.label,
|
||||||
|
profileId,
|
||||||
|
'Expected string.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = {
|
||||||
|
label: label ?? profileId,
|
||||||
|
buttonIndices: structuredClone(resolved.controller.buttonIndices),
|
||||||
|
bindings: structuredClone(resolved.controller.bindings),
|
||||||
|
};
|
||||||
|
applyControllerButtonIndices(
|
||||||
|
rawProfile.buttonIndices,
|
||||||
|
profile.buttonIndices,
|
||||||
|
`controller.profiles.${profileId}.buttonIndices`,
|
||||||
|
warn,
|
||||||
|
);
|
||||||
|
applyControllerBindings(
|
||||||
|
rawProfile.bindings,
|
||||||
|
profile.bindings,
|
||||||
|
profile.buttonIndices,
|
||||||
|
`controller.profiles.${profileId}.bindings`,
|
||||||
|
warn,
|
||||||
|
);
|
||||||
|
resolved.controller.profiles[profileId] = profile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,150 +1,7 @@
|
|||||||
import type {
|
|
||||||
ControllerAxisBinding,
|
|
||||||
ControllerAxisBindingConfig,
|
|
||||||
ControllerAxisDirection,
|
|
||||||
ControllerButtonBinding,
|
|
||||||
ControllerButtonIndicesConfig,
|
|
||||||
ControllerDpadFallback,
|
|
||||||
ControllerDiscreteBindingConfig,
|
|
||||||
ResolvedControllerAxisBinding,
|
|
||||||
ResolvedControllerDiscreteBinding,
|
|
||||||
} from '../../types/runtime';
|
|
||||||
import { ResolveContext } from './context';
|
import { ResolveContext } from './context';
|
||||||
|
import { applyControllerConfig } from './controller';
|
||||||
import { asBoolean, asNumber, asString, isObject } from './shared';
|
import { asBoolean, asNumber, asString, isObject } from './shared';
|
||||||
|
|
||||||
const CONTROLLER_BUTTON_BINDINGS = [
|
|
||||||
'none',
|
|
||||||
'select',
|
|
||||||
'buttonSouth',
|
|
||||||
'buttonEast',
|
|
||||||
'buttonNorth',
|
|
||||||
'buttonWest',
|
|
||||||
'leftShoulder',
|
|
||||||
'rightShoulder',
|
|
||||||
'leftStickPress',
|
|
||||||
'rightStickPress',
|
|
||||||
'leftTrigger',
|
|
||||||
'rightTrigger',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const CONTROLLER_AXIS_BINDINGS = [
|
|
||||||
'leftStickX',
|
|
||||||
'leftStickY',
|
|
||||||
'rightStickX',
|
|
||||||
'rightStickY',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const CONTROLLER_AXIS_INDEX_BY_BINDING: Record<ControllerAxisBinding, number> = {
|
|
||||||
leftStickX: 0,
|
|
||||||
leftStickY: 1,
|
|
||||||
rightStickX: 3,
|
|
||||||
rightStickY: 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
const CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING: Record<
|
|
||||||
Exclude<ControllerButtonBinding, 'none'>,
|
|
||||||
keyof Required<ControllerButtonIndicesConfig>
|
|
||||||
> = {
|
|
||||||
select: 'select',
|
|
||||||
buttonSouth: 'buttonSouth',
|
|
||||||
buttonEast: 'buttonEast',
|
|
||||||
buttonNorth: 'buttonNorth',
|
|
||||||
buttonWest: 'buttonWest',
|
|
||||||
leftShoulder: 'leftShoulder',
|
|
||||||
rightShoulder: 'rightShoulder',
|
|
||||||
leftStickPress: 'leftStickPress',
|
|
||||||
rightStickPress: 'rightStickPress',
|
|
||||||
leftTrigger: 'leftTrigger',
|
|
||||||
rightTrigger: 'rightTrigger',
|
|
||||||
};
|
|
||||||
|
|
||||||
const CONTROLLER_AXIS_FALLBACK_BY_SLOT = {
|
|
||||||
leftStickHorizontal: 'horizontal',
|
|
||||||
leftStickVertical: 'vertical',
|
|
||||||
rightStickHorizontal: 'none',
|
|
||||||
rightStickVertical: 'none',
|
|
||||||
} as const satisfies Record<string, ControllerDpadFallback>;
|
|
||||||
|
|
||||||
function isControllerAxisDirection(value: unknown): value is ControllerAxisDirection {
|
|
||||||
return value === 'negative' || value === 'positive';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isControllerDpadFallback(value: unknown): value is ControllerDpadFallback {
|
|
||||||
return value === 'none' || value === 'horizontal' || value === 'vertical';
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveLegacyDiscreteBinding(
|
|
||||||
value: ControllerButtonBinding,
|
|
||||||
buttonIndices: Required<ControllerButtonIndicesConfig>,
|
|
||||||
): ResolvedControllerDiscreteBinding {
|
|
||||||
if (value === 'none') {
|
|
||||||
return { kind: 'none' };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
kind: 'button',
|
|
||||||
buttonIndex: buttonIndices[CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING[value]],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveLegacyAxisBinding(
|
|
||||||
value: ControllerAxisBinding,
|
|
||||||
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
|
|
||||||
): ResolvedControllerAxisBinding {
|
|
||||||
return {
|
|
||||||
kind: 'axis',
|
|
||||||
axisIndex: CONTROLLER_AXIS_INDEX_BY_BINDING[value],
|
|
||||||
dpadFallback: CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDiscreteBindingObject(value: unknown): ResolvedControllerDiscreteBinding | null {
|
|
||||||
if (!isObject(value) || typeof value.kind !== 'string') return null;
|
|
||||||
if (value.kind === 'none') {
|
|
||||||
return { kind: 'none' };
|
|
||||||
}
|
|
||||||
if (value.kind === 'button') {
|
|
||||||
return typeof value.buttonIndex === 'number' &&
|
|
||||||
Number.isInteger(value.buttonIndex) &&
|
|
||||||
value.buttonIndex >= 0
|
|
||||||
? { kind: 'button', buttonIndex: value.buttonIndex }
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
if (value.kind === 'axis') {
|
|
||||||
return typeof value.axisIndex === 'number' &&
|
|
||||||
Number.isInteger(value.axisIndex) &&
|
|
||||||
value.axisIndex >= 0 &&
|
|
||||||
isControllerAxisDirection(value.direction)
|
|
||||||
? { kind: 'axis', axisIndex: value.axisIndex, direction: value.direction }
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAxisBindingObject(
|
|
||||||
value: unknown,
|
|
||||||
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
|
|
||||||
): ResolvedControllerAxisBinding | null {
|
|
||||||
if (isObject(value) && value.kind === 'none') {
|
|
||||||
return { kind: 'none' };
|
|
||||||
}
|
|
||||||
if (!isObject(value) || value.kind !== 'axis') return null;
|
|
||||||
if (
|
|
||||||
typeof value.axisIndex !== 'number' ||
|
|
||||||
!Number.isInteger(value.axisIndex) ||
|
|
||||||
value.axisIndex < 0
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (value.dpadFallback !== undefined && !isControllerDpadFallback(value.dpadFallback)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
kind: 'axis',
|
|
||||||
axisIndex: value.axisIndex,
|
|
||||||
dpadFallback: value.dpadFallback ?? CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyCoreDomainConfig(context: ResolveContext): void {
|
export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||||
const { src, resolved, warn } = context;
|
const { src, resolved, warn } = context;
|
||||||
|
|
||||||
@@ -245,203 +102,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isObject(src.controller)) {
|
applyControllerConfig(context);
|
||||||
const enabled = asBoolean(src.controller.enabled);
|
|
||||||
if (enabled !== undefined) {
|
|
||||||
resolved.controller.enabled = enabled;
|
|
||||||
} else if (src.controller.enabled !== undefined) {
|
|
||||||
warn(
|
|
||||||
'controller.enabled',
|
|
||||||
src.controller.enabled,
|
|
||||||
resolved.controller.enabled,
|
|
||||||
'Expected boolean.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const preferredGamepadId = asString(src.controller.preferredGamepadId);
|
|
||||||
if (preferredGamepadId !== undefined) {
|
|
||||||
resolved.controller.preferredGamepadId = preferredGamepadId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const preferredGamepadLabel = asString(src.controller.preferredGamepadLabel);
|
|
||||||
if (preferredGamepadLabel !== undefined) {
|
|
||||||
resolved.controller.preferredGamepadLabel = preferredGamepadLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
const smoothScroll = asBoolean(src.controller.smoothScroll);
|
|
||||||
if (smoothScroll !== undefined) {
|
|
||||||
resolved.controller.smoothScroll = smoothScroll;
|
|
||||||
} else if (src.controller.smoothScroll !== undefined) {
|
|
||||||
warn(
|
|
||||||
'controller.smoothScroll',
|
|
||||||
src.controller.smoothScroll,
|
|
||||||
resolved.controller.smoothScroll,
|
|
||||||
'Expected boolean.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const triggerInputMode = asString(src.controller.triggerInputMode);
|
|
||||||
if (
|
|
||||||
triggerInputMode === 'auto' ||
|
|
||||||
triggerInputMode === 'digital' ||
|
|
||||||
triggerInputMode === 'analog'
|
|
||||||
) {
|
|
||||||
resolved.controller.triggerInputMode = triggerInputMode;
|
|
||||||
} else if (src.controller.triggerInputMode !== undefined) {
|
|
||||||
warn(
|
|
||||||
'controller.triggerInputMode',
|
|
||||||
src.controller.triggerInputMode,
|
|
||||||
resolved.controller.triggerInputMode,
|
|
||||||
"Expected 'auto', 'digital', or 'analog'.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const boundedNumberKeys = [
|
|
||||||
'scrollPixelsPerSecond',
|
|
||||||
'horizontalJumpPixels',
|
|
||||||
'repeatDelayMs',
|
|
||||||
'repeatIntervalMs',
|
|
||||||
] as const;
|
|
||||||
for (const key of boundedNumberKeys) {
|
|
||||||
const value = asNumber(src.controller[key]);
|
|
||||||
if (value !== undefined && Math.floor(value) > 0) {
|
|
||||||
resolved.controller[key] = Math.floor(value) as (typeof resolved.controller)[typeof key];
|
|
||||||
} else if (src.controller[key] !== undefined) {
|
|
||||||
warn(
|
|
||||||
`controller.${key}`,
|
|
||||||
src.controller[key],
|
|
||||||
resolved.controller[key],
|
|
||||||
'Expected positive number.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const deadzoneKeys = ['stickDeadzone', 'triggerDeadzone'] as const;
|
|
||||||
for (const key of deadzoneKeys) {
|
|
||||||
const value = asNumber(src.controller[key]);
|
|
||||||
if (value !== undefined && value >= 0 && value <= 1) {
|
|
||||||
resolved.controller[key] = value as (typeof resolved.controller)[typeof key];
|
|
||||||
} else if (src.controller[key] !== undefined) {
|
|
||||||
warn(
|
|
||||||
`controller.${key}`,
|
|
||||||
src.controller[key],
|
|
||||||
resolved.controller[key],
|
|
||||||
'Expected number between 0 and 1.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isObject(src.controller.buttonIndices)) {
|
|
||||||
const buttonIndexKeys = [
|
|
||||||
'select',
|
|
||||||
'buttonSouth',
|
|
||||||
'buttonEast',
|
|
||||||
'buttonNorth',
|
|
||||||
'buttonWest',
|
|
||||||
'leftShoulder',
|
|
||||||
'rightShoulder',
|
|
||||||
'leftStickPress',
|
|
||||||
'rightStickPress',
|
|
||||||
'leftTrigger',
|
|
||||||
'rightTrigger',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
for (const key of buttonIndexKeys) {
|
|
||||||
const value = asNumber(src.controller.buttonIndices[key]);
|
|
||||||
if (value !== undefined && value >= 0 && Number.isInteger(value)) {
|
|
||||||
resolved.controller.buttonIndices[key] = value;
|
|
||||||
} else if (src.controller.buttonIndices[key] !== undefined) {
|
|
||||||
warn(
|
|
||||||
`controller.buttonIndices.${key}`,
|
|
||||||
src.controller.buttonIndices[key],
|
|
||||||
resolved.controller.buttonIndices[key],
|
|
||||||
'Expected non-negative integer.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isObject(src.controller.bindings)) {
|
|
||||||
const buttonBindingKeys = [
|
|
||||||
'toggleLookup',
|
|
||||||
'closeLookup',
|
|
||||||
'toggleKeyboardOnlyMode',
|
|
||||||
'mineCard',
|
|
||||||
'quitMpv',
|
|
||||||
'previousAudio',
|
|
||||||
'nextAudio',
|
|
||||||
'playCurrentAudio',
|
|
||||||
'toggleMpvPause',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
for (const key of buttonBindingKeys) {
|
|
||||||
const bindingValue = src.controller.bindings[key];
|
|
||||||
const legacyValue = asString(bindingValue);
|
|
||||||
if (
|
|
||||||
legacyValue !== undefined &&
|
|
||||||
CONTROLLER_BUTTON_BINDINGS.includes(
|
|
||||||
legacyValue as (typeof CONTROLLER_BUTTON_BINDINGS)[number],
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
resolved.controller.bindings[key] = resolveLegacyDiscreteBinding(
|
|
||||||
legacyValue as ControllerButtonBinding,
|
|
||||||
resolved.controller.buttonIndices,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const parsedObject = parseDiscreteBindingObject(bindingValue);
|
|
||||||
if (parsedObject) {
|
|
||||||
resolved.controller.bindings[key] = parsedObject;
|
|
||||||
} else if (bindingValue !== undefined) {
|
|
||||||
warn(
|
|
||||||
`controller.bindings.${key}`,
|
|
||||||
bindingValue,
|
|
||||||
resolved.controller.bindings[key],
|
|
||||||
"Expected legacy controller button name or binding object with kind 'none', 'button', or 'axis'.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const axisBindingKeys = [
|
|
||||||
'leftStickHorizontal',
|
|
||||||
'leftStickVertical',
|
|
||||||
'rightStickHorizontal',
|
|
||||||
'rightStickVertical',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
for (const key of axisBindingKeys) {
|
|
||||||
const bindingValue = src.controller.bindings[key];
|
|
||||||
const legacyValue = asString(bindingValue);
|
|
||||||
if (
|
|
||||||
legacyValue !== undefined &&
|
|
||||||
CONTROLLER_AXIS_BINDINGS.includes(
|
|
||||||
legacyValue as (typeof CONTROLLER_AXIS_BINDINGS)[number],
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
resolved.controller.bindings[key] = resolveLegacyAxisBinding(
|
|
||||||
legacyValue as ControllerAxisBinding,
|
|
||||||
key,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (legacyValue === 'none') {
|
|
||||||
resolved.controller.bindings[key] = { kind: 'none' };
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const parsedObject = parseAxisBindingObject(bindingValue, key);
|
|
||||||
if (parsedObject) {
|
|
||||||
resolved.controller.bindings[key] = parsedObject;
|
|
||||||
} else if (bindingValue !== undefined) {
|
|
||||||
warn(
|
|
||||||
`controller.bindings.${key}`,
|
|
||||||
bindingValue,
|
|
||||||
resolved.controller.bindings[key],
|
|
||||||
"Expected legacy controller axis name ('none' allowed) or binding object with kind 'axis'.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(src.keybindings)) {
|
if (Array.isArray(src.keybindings)) {
|
||||||
resolved.keybindings = src.keybindings.filter(
|
resolved.keybindings = src.keybindings.filter(
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ function createControllerConfigFixture() {
|
|||||||
rightStickHorizontal: { kind: 'axis' as const, axisIndex: 3, dpadFallback: 'none' as const },
|
rightStickHorizontal: { kind: 'axis' as const, axisIndex: 3, dpadFallback: 'none' as const },
|
||||||
rightStickVertical: { kind: 'axis' as const, axisIndex: 4, dpadFallback: 'none' as const },
|
rightStickVertical: { kind: 'axis' as const, axisIndex: 4, dpadFallback: 'none' as const },
|
||||||
},
|
},
|
||||||
|
profiles: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -975,6 +976,58 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('registerIpcHandlers accepts per-controller profile config updates', async () => {
|
||||||
|
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||||
|
const controllerSaves: unknown[] = [];
|
||||||
|
registerIpcHandlers(
|
||||||
|
createRegisterIpcDeps({
|
||||||
|
saveControllerConfig: async (update) => {
|
||||||
|
controllerSaves.push(update);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerConfig);
|
||||||
|
assert.ok(saveHandler);
|
||||||
|
|
||||||
|
const update = {
|
||||||
|
profiles: {
|
||||||
|
'pad-1': {
|
||||||
|
label: 'Pad One',
|
||||||
|
buttonIndices: {
|
||||||
|
buttonSouth: 11,
|
||||||
|
},
|
||||||
|
bindings: {
|
||||||
|
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
||||||
|
leftStickHorizontal: { kind: 'axis', axisIndex: 6, dpadFallback: 'horizontal' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await saveHandler({}, update);
|
||||||
|
assert.deepEqual(controllerSaves, [update]);
|
||||||
|
|
||||||
|
await assert.rejects(async () => {
|
||||||
|
await saveHandler(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
profiles: {
|
||||||
|
'pad-1': {
|
||||||
|
bindings: {
|
||||||
|
toggleLookup: { kind: 'axis', axisIndex: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, /Invalid controller config payload/);
|
||||||
|
|
||||||
|
await assert.rejects(async () => {
|
||||||
|
await saveHandler({}, JSON.parse('{"profiles":{"__proto__":{"label":"polluted"}}}'));
|
||||||
|
}, /Invalid controller config payload/);
|
||||||
|
});
|
||||||
|
|
||||||
test('registerIpcHandlers validates dispatchSessionAction payloads', async () => {
|
test('registerIpcHandlers validates dispatchSessionAction payloads', async () => {
|
||||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||||
const dispatched: SessionActionDispatchRequest[] = [];
|
const dispatched: SessionActionDispatchRequest[] = [];
|
||||||
|
|||||||
@@ -75,3 +75,67 @@ test('applyControllerConfigUpdate detaches updated binding values from the patch
|
|||||||
|
|
||||||
assert.deepEqual(next.bindings?.toggleLookup, { kind: 'button', buttonIndex: 7 });
|
assert.deepEqual(next.bindings?.toggleLookup, { kind: 'button', buttonIndex: 7 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('applyControllerConfigUpdate merges per-controller profile binding leaves', () => {
|
||||||
|
const next = applyControllerConfigUpdate(
|
||||||
|
{
|
||||||
|
profiles: {
|
||||||
|
'pad-1': {
|
||||||
|
label: 'Pad 1',
|
||||||
|
bindings: {
|
||||||
|
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||||
|
closeLookup: { kind: 'button', buttonIndex: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
profiles: {
|
||||||
|
'pad-1': {
|
||||||
|
bindings: {
|
||||||
|
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'pad-2': {
|
||||||
|
label: 'Pad 2',
|
||||||
|
bindings: {
|
||||||
|
mineCard: { kind: 'button', buttonIndex: 8 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(next.profiles?.['pad-1']?.bindings?.toggleLookup, {
|
||||||
|
kind: 'button',
|
||||||
|
buttonIndex: 11,
|
||||||
|
});
|
||||||
|
assert.deepEqual(next.profiles?.['pad-1']?.bindings?.closeLookup, {
|
||||||
|
kind: 'button',
|
||||||
|
buttonIndex: 1,
|
||||||
|
});
|
||||||
|
assert.deepEqual(next.profiles?.['pad-2']?.bindings?.mineCard, {
|
||||||
|
kind: 'button',
|
||||||
|
buttonIndex: 8,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyControllerConfigUpdate ignores reserved profile ids', () => {
|
||||||
|
const reservedProfiles = Object.create(null) as NonNullable<
|
||||||
|
Parameters<typeof applyControllerConfigUpdate>[1]['profiles']
|
||||||
|
>;
|
||||||
|
reservedProfiles.__proto__ = { label: 'polluted' };
|
||||||
|
reservedProfiles['constructor'] = { label: 'ctor' };
|
||||||
|
reservedProfiles['prototype'] = { label: 'proto' };
|
||||||
|
reservedProfiles['pad-1'] = { label: 'Pad 1' };
|
||||||
|
|
||||||
|
const next = applyControllerConfigUpdate(undefined, {
|
||||||
|
profiles: reservedProfiles,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(Object.getPrototypeOf(next.profiles), Object.prototype);
|
||||||
|
assert.equal(Object.hasOwn(next.profiles ?? {}, '__proto__'), false);
|
||||||
|
assert.equal(Object.hasOwn(next.profiles ?? {}, 'constructor'), false);
|
||||||
|
assert.equal(Object.hasOwn(next.profiles ?? {}, 'prototype'), false);
|
||||||
|
assert.equal(next.profiles?.['pad-1']?.label, 'Pad 1');
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,66 @@ import type { ControllerConfigUpdate, RawConfig } from '../types';
|
|||||||
|
|
||||||
type RawControllerConfig = NonNullable<RawConfig['controller']>;
|
type RawControllerConfig = NonNullable<RawConfig['controller']>;
|
||||||
type RawControllerBindings = NonNullable<RawControllerConfig['bindings']>;
|
type RawControllerBindings = NonNullable<RawControllerConfig['bindings']>;
|
||||||
|
type RawControllerButtonIndices = NonNullable<RawControllerConfig['buttonIndices']>;
|
||||||
|
type RawControllerProfiles = NonNullable<RawControllerConfig['profiles']>;
|
||||||
|
type RawControllerProfile = NonNullable<RawControllerProfiles[string]>;
|
||||||
|
|
||||||
|
const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||||
|
|
||||||
|
function mergeBindingPatch(
|
||||||
|
currentBindings: RawControllerBindings | undefined,
|
||||||
|
updateBindings: RawControllerBindings | undefined,
|
||||||
|
): RawControllerBindings | undefined {
|
||||||
|
if (!currentBindings && !updateBindings) return undefined;
|
||||||
|
const nextBindings: RawControllerBindings = {
|
||||||
|
...(currentBindings ?? {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(updateBindings ?? {}) as Array<
|
||||||
|
[keyof RawControllerBindings, RawControllerBindings[keyof RawControllerBindings] | undefined]
|
||||||
|
>) {
|
||||||
|
if (value === undefined) continue;
|
||||||
|
(nextBindings as Record<string, unknown>)[key] = structuredClone(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextBindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeButtonIndexPatch(
|
||||||
|
currentButtonIndices: RawControllerButtonIndices | undefined,
|
||||||
|
updateButtonIndices: RawControllerButtonIndices | undefined,
|
||||||
|
): RawControllerButtonIndices | undefined {
|
||||||
|
if (!currentButtonIndices && !updateButtonIndices) return undefined;
|
||||||
|
return {
|
||||||
|
...(currentButtonIndices ?? {}),
|
||||||
|
...(updateButtonIndices ?? {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeControllerProfile(
|
||||||
|
currentProfile: RawControllerProfile | undefined,
|
||||||
|
updateProfile: RawControllerProfile,
|
||||||
|
): RawControllerProfile {
|
||||||
|
const nextProfile: RawControllerProfile = {
|
||||||
|
...(currentProfile ?? {}),
|
||||||
|
...updateProfile,
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonIndices = mergeButtonIndexPatch(
|
||||||
|
currentProfile?.buttonIndices,
|
||||||
|
updateProfile.buttonIndices,
|
||||||
|
);
|
||||||
|
if (buttonIndices) {
|
||||||
|
nextProfile.buttonIndices = buttonIndices;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindings = mergeBindingPatch(currentProfile?.bindings, updateProfile.bindings);
|
||||||
|
if (bindings) {
|
||||||
|
nextProfile.bindings = bindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextProfile;
|
||||||
|
}
|
||||||
|
|
||||||
export function applyControllerConfigUpdate(
|
export function applyControllerConfigUpdate(
|
||||||
currentController: RawConfig['controller'] | undefined,
|
currentController: RawConfig['controller'] | undefined,
|
||||||
@@ -12,26 +72,38 @@ export function applyControllerConfigUpdate(
|
|||||||
...update,
|
...update,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (currentController?.buttonIndices || update.buttonIndices) {
|
const buttonIndices = mergeButtonIndexPatch(
|
||||||
nextController.buttonIndices = {
|
currentController?.buttonIndices,
|
||||||
...(currentController?.buttonIndices ?? {}),
|
update.buttonIndices,
|
||||||
...(update.buttonIndices ?? {}),
|
);
|
||||||
};
|
if (buttonIndices) {
|
||||||
|
nextController.buttonIndices = buttonIndices;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentController?.bindings || update.bindings) {
|
const bindings = mergeBindingPatch(currentController?.bindings, update.bindings);
|
||||||
const nextBindings: RawControllerBindings = {
|
if (bindings) {
|
||||||
...(currentController?.bindings ?? {}),
|
nextController.bindings = bindings;
|
||||||
};
|
}
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(update.bindings ?? {}) as Array<
|
if (currentController?.profiles || update.profiles) {
|
||||||
[keyof RawControllerBindings, RawControllerBindings[keyof RawControllerBindings] | undefined]
|
const nextProfiles: RawControllerProfiles = {};
|
||||||
|
for (const [profileId, profile] of Object.entries(currentController?.profiles ?? {}) as Array<
|
||||||
|
[string, RawControllerProfile]
|
||||||
>) {
|
>) {
|
||||||
if (value === undefined) continue;
|
if (RESERVED_CONTROLLER_PROFILE_IDS.has(profileId)) continue;
|
||||||
(nextBindings as Record<string, unknown>)[key] = structuredClone(value);
|
nextProfiles[profileId] = profile;
|
||||||
}
|
}
|
||||||
|
for (const [profileId, profileUpdate] of Object.entries(update.profiles ?? {}) as Array<
|
||||||
nextController.bindings = nextBindings;
|
[string, RawControllerProfile | undefined]
|
||||||
|
>) {
|
||||||
|
if (RESERVED_CONTROLLER_PROFILE_IDS.has(profileId)) continue;
|
||||||
|
if (profileUpdate === undefined) continue;
|
||||||
|
nextProfiles[profileId] = mergeControllerProfile(
|
||||||
|
currentController?.profiles?.[profileId],
|
||||||
|
profileUpdate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
nextController.profiles = nextProfiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
return nextController;
|
return nextController;
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { ResolvedControllerConfig, ResolvedControllerProfileConfig } from '../types';
|
||||||
|
|
||||||
|
export function getControllerProfile(
|
||||||
|
config: ResolvedControllerConfig | null,
|
||||||
|
gamepadId: string | null | undefined,
|
||||||
|
): ResolvedControllerProfileConfig | null {
|
||||||
|
if (!config || !gamepadId) return null;
|
||||||
|
return config.profiles[gamepadId] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveControllerConfigForGamepad(
|
||||||
|
config: ResolvedControllerConfig,
|
||||||
|
gamepadId: string | null | undefined,
|
||||||
|
): ResolvedControllerConfig {
|
||||||
|
const profile = getControllerProfile(config, gamepadId);
|
||||||
|
if (!profile) return config;
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
buttonIndices: profile.buttonIndices,
|
||||||
|
bindings: profile.bindings,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -67,5 +67,5 @@ export function createControllerStatusIndicator(
|
|||||||
previousConnectedIds = new Set(snapshot.connectedGamepads.map((device) => device.id));
|
previousConnectedIds = new Set(snapshot.connectedGamepads.map((device) => device.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { update };
|
return { show, update };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ function createControllerConfig(
|
|||||||
...(buttonIndexOverrides ?? {}),
|
...(buttonIndexOverrides ?? {}),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
profiles: {},
|
||||||
...restOverrides,
|
...restOverrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -449,6 +450,60 @@ test('gamepad controller maps left stick horizontal movement to token selection
|
|||||||
assert.deepEqual(calls, [1, 1, -1]);
|
assert.deepEqual(calls, [1, 1, -1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('gamepad controller uses active controller profile bindings before global bindings', () => {
|
||||||
|
let lookupToggles = 0;
|
||||||
|
const buttons = Array.from({ length: 12 }, () => ({
|
||||||
|
value: 0,
|
||||||
|
pressed: false,
|
||||||
|
touched: false,
|
||||||
|
}));
|
||||||
|
buttons[11] = { value: 1, pressed: true, touched: true };
|
||||||
|
|
||||||
|
const controller = createGamepadController({
|
||||||
|
getGamepads: () => [createGamepad('pad-profile', { buttons })],
|
||||||
|
getConfig: () =>
|
||||||
|
({
|
||||||
|
...createControllerConfig({
|
||||||
|
bindings: {
|
||||||
|
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
profiles: {
|
||||||
|
'pad-profile': {
|
||||||
|
label: 'Profile Pad',
|
||||||
|
buttonIndices: DEFAULT_BUTTON_INDICES,
|
||||||
|
bindings: {
|
||||||
|
...createControllerConfig().bindings,
|
||||||
|
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}) as ResolvedControllerConfig,
|
||||||
|
getKeyboardModeEnabled: () => true,
|
||||||
|
getLookupWindowOpen: () => false,
|
||||||
|
getInteractionBlocked: () => false,
|
||||||
|
toggleKeyboardMode: () => {},
|
||||||
|
toggleLookup: () => {
|
||||||
|
lookupToggles += 1;
|
||||||
|
},
|
||||||
|
closeLookup: () => {},
|
||||||
|
moveSelection: () => {},
|
||||||
|
mineCard: () => {},
|
||||||
|
quitMpv: () => {},
|
||||||
|
previousAudio: () => {},
|
||||||
|
nextAudio: () => {},
|
||||||
|
playCurrentAudio: () => {},
|
||||||
|
toggleMpvPause: () => {},
|
||||||
|
scrollPopup: () => {},
|
||||||
|
jumpPopup: () => {},
|
||||||
|
onState: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.poll(0);
|
||||||
|
|
||||||
|
assert.equal(lookupToggles, 1);
|
||||||
|
});
|
||||||
|
|
||||||
test('gamepad controller maps L1 play-current, R1 next-audio, and popup navigation', () => {
|
test('gamepad controller maps L1 play-current, R1 next-audio, and popup navigation', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const scrollCalls: number[] = [];
|
const scrollCalls: number[] = [];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
ResolvedControllerConfig,
|
ResolvedControllerConfig,
|
||||||
ResolvedControllerDiscreteBinding,
|
ResolvedControllerDiscreteBinding,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
|
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
|
||||||
|
|
||||||
type ControllerButtonState = {
|
type ControllerButtonState = {
|
||||||
value: number;
|
value: number;
|
||||||
@@ -410,87 +411,101 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
|||||||
resetHeldAction(jumpHold);
|
resetHeldAction(jumpHold);
|
||||||
}
|
}
|
||||||
|
|
||||||
let interactionAllowed =
|
const activeConfig = resolveControllerConfigForGamepad(config, activeGamepad.id);
|
||||||
config.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked();
|
|
||||||
if (config.enabled) {
|
if (activeConfig.enabled) {
|
||||||
handleActionEdge(
|
handleActionEdge(
|
||||||
'toggleKeyboardOnlyMode',
|
'toggleKeyboardOnlyMode',
|
||||||
config.bindings.toggleKeyboardOnlyMode,
|
activeConfig.bindings.toggleKeyboardOnlyMode,
|
||||||
activeGamepad,
|
activeGamepad,
|
||||||
config,
|
activeConfig,
|
||||||
options.toggleKeyboardMode,
|
options.toggleKeyboardMode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interactionAllowed =
|
const interactionAllowed =
|
||||||
config.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked();
|
activeConfig.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked();
|
||||||
|
|
||||||
if (!interactionAllowed) {
|
if (!interactionAllowed) {
|
||||||
syncBlockedInteractionState(activeGamepad, config, now);
|
syncBlockedInteractionState(activeGamepad, activeConfig, now);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleActionEdge(
|
handleActionEdge(
|
||||||
'toggleLookup',
|
'toggleLookup',
|
||||||
config.bindings.toggleLookup,
|
activeConfig.bindings.toggleLookup,
|
||||||
activeGamepad,
|
activeGamepad,
|
||||||
config,
|
activeConfig,
|
||||||
options.toggleLookup,
|
options.toggleLookup,
|
||||||
);
|
);
|
||||||
handleActionEdge(
|
handleActionEdge(
|
||||||
'closeLookup',
|
'closeLookup',
|
||||||
config.bindings.closeLookup,
|
activeConfig.bindings.closeLookup,
|
||||||
activeGamepad,
|
activeGamepad,
|
||||||
config,
|
activeConfig,
|
||||||
options.closeLookup,
|
options.closeLookup,
|
||||||
);
|
);
|
||||||
handleActionEdge('mineCard', config.bindings.mineCard, activeGamepad, config, options.mineCard);
|
handleActionEdge(
|
||||||
handleActionEdge('quitMpv', config.bindings.quitMpv, activeGamepad, config, options.quitMpv);
|
'mineCard',
|
||||||
|
activeConfig.bindings.mineCard,
|
||||||
|
activeGamepad,
|
||||||
|
activeConfig,
|
||||||
|
options.mineCard,
|
||||||
|
);
|
||||||
|
handleActionEdge(
|
||||||
|
'quitMpv',
|
||||||
|
activeConfig.bindings.quitMpv,
|
||||||
|
activeGamepad,
|
||||||
|
activeConfig,
|
||||||
|
options.quitMpv,
|
||||||
|
);
|
||||||
|
|
||||||
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
|
const activationThreshold = Math.max(activeConfig.stickDeadzone, 0.55);
|
||||||
|
|
||||||
if (options.getLookupWindowOpen()) {
|
if (options.getLookupWindowOpen()) {
|
||||||
handleActionEdge(
|
handleActionEdge(
|
||||||
'previousAudio',
|
'previousAudio',
|
||||||
config.bindings.previousAudio,
|
activeConfig.bindings.previousAudio,
|
||||||
activeGamepad,
|
activeGamepad,
|
||||||
config,
|
activeConfig,
|
||||||
options.previousAudio,
|
options.previousAudio,
|
||||||
);
|
);
|
||||||
handleActionEdge(
|
handleActionEdge(
|
||||||
'nextAudio',
|
'nextAudio',
|
||||||
config.bindings.nextAudio,
|
activeConfig.bindings.nextAudio,
|
||||||
activeGamepad,
|
activeGamepad,
|
||||||
config,
|
activeConfig,
|
||||||
options.nextAudio,
|
options.nextAudio,
|
||||||
);
|
);
|
||||||
handleActionEdge(
|
handleActionEdge(
|
||||||
'playCurrentAudio',
|
'playCurrentAudio',
|
||||||
config.bindings.playCurrentAudio,
|
activeConfig.bindings.playCurrentAudio,
|
||||||
activeGamepad,
|
activeGamepad,
|
||||||
config,
|
activeConfig,
|
||||||
options.playCurrentAudio,
|
options.playCurrentAudio,
|
||||||
);
|
);
|
||||||
|
|
||||||
const primaryScroll = resolveAxisBindingValue(
|
const primaryScroll = resolveAxisBindingValue(
|
||||||
activeGamepad,
|
activeGamepad,
|
||||||
config.bindings.leftStickVertical,
|
activeConfig.bindings.leftStickVertical,
|
||||||
config.triggerDeadzone,
|
activeConfig.triggerDeadzone,
|
||||||
config.stickDeadzone,
|
activeConfig.stickDeadzone,
|
||||||
);
|
);
|
||||||
if (elapsedMs > 0 && Math.abs(primaryScroll) >= config.stickDeadzone) {
|
if (elapsedMs > 0 && Math.abs(primaryScroll) >= activeConfig.stickDeadzone) {
|
||||||
options.scrollPopup((primaryScroll * config.scrollPixelsPerSecond * elapsedMs) / 1000);
|
options.scrollPopup(
|
||||||
|
(primaryScroll * activeConfig.scrollPixelsPerSecond * elapsedMs) / 1000,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleJumpAxis(
|
handleJumpAxis(
|
||||||
resolveAxisBindingValue(
|
resolveAxisBindingValue(
|
||||||
activeGamepad,
|
activeGamepad,
|
||||||
config.bindings.rightStickVertical,
|
activeConfig.bindings.rightStickVertical,
|
||||||
config.triggerDeadzone,
|
activeConfig.triggerDeadzone,
|
||||||
activationThreshold,
|
activationThreshold,
|
||||||
),
|
),
|
||||||
now,
|
now,
|
||||||
config,
|
activeConfig,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
resetHeldAction(jumpHold);
|
resetHeldAction(jumpHold);
|
||||||
@@ -498,21 +513,21 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
|||||||
|
|
||||||
handleActionEdge(
|
handleActionEdge(
|
||||||
'toggleMpvPause',
|
'toggleMpvPause',
|
||||||
config.bindings.toggleMpvPause,
|
activeConfig.bindings.toggleMpvPause,
|
||||||
activeGamepad,
|
activeGamepad,
|
||||||
config,
|
activeConfig,
|
||||||
options.toggleMpvPause,
|
options.toggleMpvPause,
|
||||||
);
|
);
|
||||||
|
|
||||||
handleSelectionAxis(
|
handleSelectionAxis(
|
||||||
resolveAxisBindingValue(
|
resolveAxisBindingValue(
|
||||||
activeGamepad,
|
activeGamepad,
|
||||||
config.bindings.leftStickHorizontal,
|
activeConfig.bindings.leftStickHorizontal,
|
||||||
config.triggerDeadzone,
|
activeConfig.triggerDeadzone,
|
||||||
activationThreshold,
|
activationThreshold,
|
||||||
),
|
),
|
||||||
now,
|
now,
|
||||||
config,
|
activeConfig,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -987,7 +987,7 @@ test('keyboard mode: configured controller select binding opens locally without
|
|||||||
|
|
||||||
assert.equal(openControllerSelectCount(), 1);
|
assert.equal(openControllerSelectCount(), 1);
|
||||||
assert.deepEqual(testGlobals.sessionActions, []);
|
assert.deepEqual(testGlobals.sessionActions, []);
|
||||||
assert.deepEqual(testGlobals.openedModalNotifications, ['controller-select']);
|
assert.deepEqual(testGlobals.openedModalNotifications, []);
|
||||||
} finally {
|
} finally {
|
||||||
testGlobals.restore();
|
testGlobals.restore();
|
||||||
}
|
}
|
||||||
@@ -1017,7 +1017,7 @@ test('keyboard mode: configured controller debug binding opens locally without d
|
|||||||
|
|
||||||
assert.equal(openControllerDebugCount(), 1);
|
assert.equal(openControllerDebugCount(), 1);
|
||||||
assert.deepEqual(testGlobals.sessionActions, []);
|
assert.deepEqual(testGlobals.sessionActions, []);
|
||||||
assert.deepEqual(testGlobals.openedModalNotifications, ['controller-debug']);
|
assert.deepEqual(testGlobals.openedModalNotifications, []);
|
||||||
} finally {
|
} finally {
|
||||||
testGlobals.restore();
|
testGlobals.restore();
|
||||||
}
|
}
|
||||||
@@ -1049,7 +1049,7 @@ test('keyboard mode: configured controller debug binding is not swallowed while
|
|||||||
|
|
||||||
assert.equal(openControllerDebugCount(), 1);
|
assert.equal(openControllerDebugCount(), 1);
|
||||||
assert.deepEqual(testGlobals.sessionActions, []);
|
assert.deepEqual(testGlobals.sessionActions, []);
|
||||||
assert.deepEqual(testGlobals.openedModalNotifications, ['controller-debug']);
|
assert.deepEqual(testGlobals.openedModalNotifications, []);
|
||||||
} finally {
|
} finally {
|
||||||
testGlobals.restore();
|
testGlobals.restore();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,13 +203,11 @@ export function createKeyboardHandlers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (binding.actionType === 'session-action' && binding.actionId === 'openControllerSelect') {
|
if (binding.actionType === 'session-action' && binding.actionId === 'openControllerSelect') {
|
||||||
window.electronAPI.notifyOverlayModalOpened('controller-select');
|
|
||||||
options.openControllerSelectModal?.();
|
options.openControllerSelectModal?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (binding.actionType === 'session-action' && binding.actionId === 'openControllerDebug') {
|
if (binding.actionType === 'session-action' && binding.actionId === 'openControllerDebug') {
|
||||||
window.electronAPI.notifyOverlayModalOpened('controller-debug');
|
|
||||||
options.openControllerDebugModal?.();
|
options.openControllerDebugModal?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,3 +144,69 @@ test('controller config form renders rows and dispatches learn clear reset callb
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('controller config form starts learn from badge or edit and resets from row button', () => {
|
||||||
|
const previousDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
createElement: () => createFakeElement(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const container = createFakeElement();
|
||||||
|
const form = createControllerConfigForm({
|
||||||
|
container: container as never,
|
||||||
|
getBindings: () =>
|
||||||
|
({
|
||||||
|
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||||
|
closeLookup: { kind: 'button', buttonIndex: 1 },
|
||||||
|
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
||||||
|
mineCard: { kind: 'button', buttonIndex: 2 },
|
||||||
|
quitMpv: { kind: 'button', buttonIndex: 6 },
|
||||||
|
previousAudio: { kind: 'none' },
|
||||||
|
nextAudio: { kind: 'button', buttonIndex: 5 },
|
||||||
|
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
||||||
|
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
||||||
|
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||||
|
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||||
|
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||||
|
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||||
|
}) as never,
|
||||||
|
getLearningActionId: () => null,
|
||||||
|
getDpadLearningActionId: () => null,
|
||||||
|
onLearn: (actionId, bindingType) => calls.push(`learn:${actionId}:${bindingType}`),
|
||||||
|
onClear: (actionId) => calls.push(`clear:${actionId}`),
|
||||||
|
onReset: (actionId) => calls.push(`reset:${actionId}`),
|
||||||
|
onDpadLearn: (actionId) => calls.push(`dpadLearn:${actionId}`),
|
||||||
|
onDpadClear: (actionId) => calls.push(`dpadClear:${actionId}`),
|
||||||
|
onDpadReset: (actionId) => calls.push(`dpadReset:${actionId}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
form.render();
|
||||||
|
|
||||||
|
const firstRow = container.children[1];
|
||||||
|
const right = firstRow.children[1];
|
||||||
|
const badge = right.children[0];
|
||||||
|
const resetButton = right.children[1];
|
||||||
|
const editButton = right.children[2];
|
||||||
|
|
||||||
|
badge.dispatch('click');
|
||||||
|
resetButton.dispatch('click');
|
||||||
|
editButton.dispatch('click');
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'learn:toggleLookup:discrete',
|
||||||
|
'reset:toggleLookup',
|
||||||
|
'learn:toggleLookup:discrete',
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (previousDocumentDescriptor) {
|
||||||
|
Object.defineProperty(globalThis, 'document', previousDocumentDescriptor);
|
||||||
|
} else {
|
||||||
|
Reflect.deleteProperty(globalThis, 'document');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -278,6 +278,17 @@ export function createControllerConfigForm(options: {
|
|||||||
formatFriendlyBindingLabel(binding),
|
formatFriendlyBindingLabel(binding),
|
||||||
binding.kind === 'none',
|
binding.kind === 'none',
|
||||||
isExpanded,
|
isExpanded,
|
||||||
|
`Learn ${definition.label}`,
|
||||||
|
(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
expandedRowKey = rowKey;
|
||||||
|
options.onLearn(definition.id, definition.bindingType);
|
||||||
|
},
|
||||||
|
`Reset ${definition.label}`,
|
||||||
|
(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
options.onReset(definition.id);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
row.addEventListener('click', () => {
|
row.addEventListener('click', () => {
|
||||||
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
||||||
@@ -321,6 +332,17 @@ export function createControllerConfigForm(options: {
|
|||||||
formatFriendlyStickLabel(binding),
|
formatFriendlyStickLabel(binding),
|
||||||
binding.kind === 'none',
|
binding.kind === 'none',
|
||||||
isExpanded,
|
isExpanded,
|
||||||
|
`Learn ${definition.label} stick`,
|
||||||
|
(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
expandedRowKey = rowKey;
|
||||||
|
options.onLearn(definition.id, 'axis');
|
||||||
|
},
|
||||||
|
`Reset ${definition.label} stick`,
|
||||||
|
(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
options.onReset(definition.id);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
row.addEventListener('click', () => {
|
row.addEventListener('click', () => {
|
||||||
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
||||||
@@ -366,6 +388,17 @@ export function createControllerConfigForm(options: {
|
|||||||
badgeText,
|
badgeText,
|
||||||
dpadFallback === 'none',
|
dpadFallback === 'none',
|
||||||
isExpanded,
|
isExpanded,
|
||||||
|
`Learn ${definition.label} D-pad`,
|
||||||
|
(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
expandedRowKey = rowKey;
|
||||||
|
options.onDpadLearn(definition.id);
|
||||||
|
},
|
||||||
|
`Reset ${definition.label} D-pad`,
|
||||||
|
(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
options.onDpadReset(definition.id);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
row.addEventListener('click', () => {
|
row.addEventListener('click', () => {
|
||||||
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
||||||
@@ -400,6 +433,10 @@ export function createControllerConfigForm(options: {
|
|||||||
badgeText: string,
|
badgeText: string,
|
||||||
isDisabled: boolean,
|
isDisabled: boolean,
|
||||||
isExpanded: boolean,
|
isExpanded: boolean,
|
||||||
|
editLabel: string,
|
||||||
|
onEdit: (e: Event) => void,
|
||||||
|
resetLabel: string,
|
||||||
|
onReset: (e: Event) => void,
|
||||||
): HTMLDivElement {
|
): HTMLDivElement {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'controller-config-row';
|
row.className = 'controller-config-row';
|
||||||
@@ -412,16 +449,33 @@ export function createControllerConfigForm(options: {
|
|||||||
const right = document.createElement('div');
|
const right = document.createElement('div');
|
||||||
right.className = 'controller-config-right';
|
right.className = 'controller-config-right';
|
||||||
|
|
||||||
const badge = document.createElement('span');
|
const badge = document.createElement('button');
|
||||||
|
badge.type = 'button';
|
||||||
badge.className = 'controller-config-badge';
|
badge.className = 'controller-config-badge';
|
||||||
if (isDisabled) badge.classList.add('disabled');
|
if (isDisabled) badge.classList.add('disabled');
|
||||||
|
badge.setAttribute('aria-label', editLabel);
|
||||||
|
badge.title = editLabel;
|
||||||
badge.textContent = badgeText;
|
badge.textContent = badgeText;
|
||||||
|
badge.addEventListener('click', onEdit);
|
||||||
|
|
||||||
const editIcon = document.createElement('span');
|
const resetIcon = document.createElement('button');
|
||||||
|
resetIcon.type = 'button';
|
||||||
|
resetIcon.className = 'controller-config-reset-icon';
|
||||||
|
resetIcon.setAttribute('aria-label', resetLabel);
|
||||||
|
resetIcon.title = resetLabel;
|
||||||
|
resetIcon.textContent = '\u21ba';
|
||||||
|
resetIcon.addEventListener('click', onReset);
|
||||||
|
|
||||||
|
const editIcon = document.createElement('button');
|
||||||
|
editIcon.type = 'button';
|
||||||
editIcon.className = 'controller-config-edit-icon';
|
editIcon.className = 'controller-config-edit-icon';
|
||||||
|
editIcon.setAttribute('aria-label', editLabel);
|
||||||
|
editIcon.title = editLabel;
|
||||||
editIcon.textContent = '\u270E';
|
editIcon.textContent = '\u270E';
|
||||||
|
editIcon.addEventListener('click', onEdit);
|
||||||
|
|
||||||
right.appendChild(badge);
|
right.appendChild(badge);
|
||||||
|
right.appendChild(resetIcon);
|
||||||
right.appendChild(editIcon);
|
right.appendChild(editIcon);
|
||||||
row.appendChild(label);
|
row.appendChild(label);
|
||||||
row.appendChild(right);
|
row.appendChild(right);
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ test('controller debug modal renders active controller axes, buttons, and config
|
|||||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||||
},
|
},
|
||||||
|
profiles: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
@@ -99,6 +100,7 @@ test('controller debug modal renders active controller axes, buttons, and config
|
|||||||
const modal = createControllerDebugModal(ctx as never, {
|
const modal = createControllerDebugModal(ctx as never, {
|
||||||
modalStateReader: { isAnyModalOpen: () => false },
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
syncSettingsModalSubtitleSuppression: () => {},
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
notifyControllerDisabled: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
modal.openControllerDebugModal();
|
modal.openControllerDebugModal();
|
||||||
@@ -189,6 +191,7 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
|
|||||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||||
},
|
},
|
||||||
|
profiles: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
@@ -217,6 +220,7 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
|
|||||||
const modal = createControllerDebugModal(ctx as never, {
|
const modal = createControllerDebugModal(ctx as never, {
|
||||||
modalStateReader: { isAnyModalOpen: () => false },
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
syncSettingsModalSubtitleSuppression: () => {},
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
notifyControllerDisabled: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
modal.wireDomEvents();
|
modal.wireDomEvents();
|
||||||
@@ -244,3 +248,97 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('controller debug modal stays closed and notifies when controller support is disabled', () => {
|
||||||
|
const globals = globalThis as typeof globalThis & { window?: unknown };
|
||||||
|
const previousWindow = globals.window;
|
||||||
|
let disabledNotices = 0;
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
electronAPI: {
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = createRendererState();
|
||||||
|
state.controllerConfig = {
|
||||||
|
enabled: false,
|
||||||
|
preferredGamepadId: '',
|
||||||
|
preferredGamepadLabel: '',
|
||||||
|
smoothScroll: true,
|
||||||
|
scrollPixelsPerSecond: 900,
|
||||||
|
horizontalJumpPixels: 160,
|
||||||
|
stickDeadzone: 0.2,
|
||||||
|
triggerInputMode: 'auto',
|
||||||
|
triggerDeadzone: 0.5,
|
||||||
|
repeatDelayMs: 320,
|
||||||
|
repeatIntervalMs: 120,
|
||||||
|
buttonIndices: {
|
||||||
|
select: 6,
|
||||||
|
buttonSouth: 0,
|
||||||
|
buttonEast: 1,
|
||||||
|
buttonWest: 2,
|
||||||
|
buttonNorth: 3,
|
||||||
|
leftShoulder: 4,
|
||||||
|
rightShoulder: 5,
|
||||||
|
leftStickPress: 9,
|
||||||
|
rightStickPress: 10,
|
||||||
|
leftTrigger: 6,
|
||||||
|
rightTrigger: 7,
|
||||||
|
},
|
||||||
|
bindings: {
|
||||||
|
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||||
|
closeLookup: { kind: 'button', buttonIndex: 1 },
|
||||||
|
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
||||||
|
mineCard: { kind: 'button', buttonIndex: 2 },
|
||||||
|
quitMpv: { kind: 'button', buttonIndex: 6 },
|
||||||
|
previousAudio: { kind: 'none' },
|
||||||
|
nextAudio: { kind: 'button', buttonIndex: 5 },
|
||||||
|
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
||||||
|
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
||||||
|
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||||
|
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||||
|
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||||
|
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||||
|
},
|
||||||
|
profiles: {},
|
||||||
|
};
|
||||||
|
const ctx = {
|
||||||
|
dom: {
|
||||||
|
overlay: { classList: createClassList() },
|
||||||
|
controllerDebugModal: {
|
||||||
|
classList: createClassList(['hidden']),
|
||||||
|
setAttribute: () => {},
|
||||||
|
},
|
||||||
|
controllerDebugClose: { addEventListener: () => {} },
|
||||||
|
controllerDebugCopy: { addEventListener: () => {} },
|
||||||
|
controllerDebugToast: { textContent: '', classList: createClassList(['hidden']) },
|
||||||
|
controllerDebugStatus: { textContent: '', classList: createClassList() },
|
||||||
|
controllerDebugSummary: { textContent: '' },
|
||||||
|
controllerDebugAxes: { textContent: '' },
|
||||||
|
controllerDebugButtons: { textContent: '' },
|
||||||
|
controllerDebugButtonIndices: { textContent: '' },
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
const modal = createControllerDebugModal(ctx as never, {
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
notifyControllerDisabled: () => {
|
||||||
|
disabledNotices += 1;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(modal.openControllerDebugModal(), false);
|
||||||
|
|
||||||
|
assert.equal(state.controllerDebugModalOpen, false);
|
||||||
|
assert.equal(ctx.dom.controllerDebugModal.classList.contains('hidden'), true);
|
||||||
|
assert.equal(disabledNotices, 1);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ModalStateReader, RendererContext } from '../context';
|
import type { ModalStateReader, RendererContext } from '../context';
|
||||||
|
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
|
||||||
|
|
||||||
function formatAxes(values: number[]): string {
|
function formatAxes(values: number[]): string {
|
||||||
if (values.length === 0) return 'No controller axes available.';
|
if (values.length === 0) return 'No controller axes available.';
|
||||||
@@ -50,6 +51,7 @@ export function createControllerDebugModal(
|
|||||||
options: {
|
options: {
|
||||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||||
syncSettingsModalSubtitleSuppression: () => void;
|
syncSettingsModalSubtitleSuppression: () => void;
|
||||||
|
notifyControllerDisabled: () => void;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -114,8 +116,11 @@ export function createControllerDebugModal(
|
|||||||
: 'Connect a controller and press any button to populate raw input values.';
|
: 'Connect a controller and press any button to populate raw input values.';
|
||||||
ctx.dom.controllerDebugAxes.textContent = formatAxes(ctx.state.controllerRawAxes);
|
ctx.dom.controllerDebugAxes.textContent = formatAxes(ctx.state.controllerRawAxes);
|
||||||
ctx.dom.controllerDebugButtons.textContent = formatButtons(ctx.state.controllerRawButtons);
|
ctx.dom.controllerDebugButtons.textContent = formatButtons(ctx.state.controllerRawButtons);
|
||||||
|
const activeConfig = ctx.state.controllerConfig
|
||||||
|
? resolveControllerConfigForGamepad(ctx.state.controllerConfig, ctx.state.activeGamepadId)
|
||||||
|
: null;
|
||||||
ctx.dom.controllerDebugButtonIndices.textContent = formatButtonIndices(
|
ctx.dom.controllerDebugButtonIndices.textContent = formatButtonIndices(
|
||||||
ctx.state.controllerConfig?.buttonIndices ?? null,
|
activeConfig?.buttonIndices ?? null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +141,11 @@ export function createControllerDebugModal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openControllerDebugModal(): void {
|
function openControllerDebugModal(): boolean {
|
||||||
|
if (ctx.state.controllerConfig?.enabled !== true) {
|
||||||
|
options.notifyControllerDisabled();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
ctx.state.controllerDebugModalOpen = true;
|
ctx.state.controllerDebugModalOpen = true;
|
||||||
options.syncSettingsModalSubtitleSuppression();
|
options.syncSettingsModalSubtitleSuppression();
|
||||||
ctx.dom.overlay.classList.add('interactive');
|
ctx.dom.overlay.classList.add('interactive');
|
||||||
@@ -144,6 +153,7 @@ export function createControllerDebugModal(
|
|||||||
ctx.dom.controllerDebugModal.setAttribute('aria-hidden', 'false');
|
ctx.dom.controllerDebugModal.setAttribute('aria-hidden', 'false');
|
||||||
hideToast();
|
hideToast();
|
||||||
render();
|
render();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeControllerDebugModal(): void {
|
function closeControllerDebugModal(): void {
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ function buildContext() {
|
|||||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||||
},
|
},
|
||||||
|
profiles: {},
|
||||||
};
|
};
|
||||||
state.connectedGamepads = [
|
state.connectedGamepads = [
|
||||||
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
|
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
|
||||||
@@ -201,6 +202,7 @@ test('controller select modal saves preferred controller from dropdown selection
|
|||||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||||
modalStateReader: { isAnyModalOpen: () => false },
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
syncSettingsModalSubtitleSuppression: () => {},
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
notifyControllerDisabled: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
modal.wireDomEvents();
|
modal.wireDomEvents();
|
||||||
@@ -246,6 +248,7 @@ test('controller select modal learn mode captures fresh button input and persist
|
|||||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||||
modalStateReader: { isAnyModalOpen: () => false },
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
syncSettingsModalSubtitleSuppression: () => {},
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
notifyControllerDisabled: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
modal.wireDomEvents();
|
modal.wireDomEvents();
|
||||||
@@ -276,6 +279,192 @@ test('controller select modal learn mode captures fresh button input and persist
|
|||||||
|
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
|
assert.deepEqual(saved.at(-1), {
|
||||||
|
profiles: {
|
||||||
|
'pad-1': {
|
||||||
|
bindings: {
|
||||||
|
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(state.controllerConfig?.profiles['pad-1']?.bindings.toggleLookup, {
|
||||||
|
kind: 'button',
|
||||||
|
buttonIndex: 11,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
domHandle.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('controller select modal reset control stores the default binding in the selected profile', async () => {
|
||||||
|
const domHandle = installFakeDom();
|
||||||
|
const saved: unknown[] = [];
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
focus: () => {},
|
||||||
|
electronAPI: {
|
||||||
|
saveControllerConfig: async (update: unknown) => {
|
||||||
|
saved.push(update);
|
||||||
|
},
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { state, dom } = buildContext();
|
||||||
|
if (state.controllerConfig) {
|
||||||
|
state.controllerConfig.profiles = {
|
||||||
|
'pad-1': {
|
||||||
|
label: 'pad-1',
|
||||||
|
buttonIndices: state.controllerConfig.buttonIndices,
|
||||||
|
bindings: {
|
||||||
|
...state.controllerConfig.bindings,
|
||||||
|
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
notifyControllerDisabled: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.wireDomEvents();
|
||||||
|
modal.openControllerSelectModal();
|
||||||
|
|
||||||
|
const firstRow = dom.controllerConfigList.children[1];
|
||||||
|
const right = firstRow.children[1];
|
||||||
|
const resetButton = right.children[1];
|
||||||
|
resetButton.dispatch('click');
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
assert.deepEqual(saved.at(-1), {
|
||||||
|
profiles: {
|
||||||
|
'pad-1': {
|
||||||
|
bindings: {
|
||||||
|
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(state.controllerConfig?.profiles['pad-1']?.bindings.toggleLookup, {
|
||||||
|
kind: 'button',
|
||||||
|
buttonIndex: 0,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
domHandle.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('controller select modal binding badge starts learn mode and persists binding', async () => {
|
||||||
|
const domHandle = installFakeDom();
|
||||||
|
const saved: unknown[] = [];
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
focus: () => {},
|
||||||
|
electronAPI: {
|
||||||
|
saveControllerConfig: async (update: unknown) => {
|
||||||
|
saved.push(update);
|
||||||
|
},
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { state, dom } = buildContext();
|
||||||
|
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
notifyControllerDisabled: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.wireDomEvents();
|
||||||
|
modal.openControllerSelectModal();
|
||||||
|
|
||||||
|
const firstRow = dom.controllerConfigList.children[1];
|
||||||
|
const right = firstRow.children[1];
|
||||||
|
const badge = right.children[0];
|
||||||
|
badge.dispatch('click');
|
||||||
|
|
||||||
|
state.controllerRawButtons = Array.from({ length: 12 }, () => ({
|
||||||
|
value: 0,
|
||||||
|
pressed: false,
|
||||||
|
touched: false,
|
||||||
|
}));
|
||||||
|
state.controllerRawButtons[11] = { value: 1, pressed: true, touched: true };
|
||||||
|
modal.updateDevices();
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
assert.deepEqual(saved.at(-1), {
|
||||||
|
profiles: {
|
||||||
|
'pad-1': {
|
||||||
|
bindings: {
|
||||||
|
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
domHandle.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('controller select modal learn mode falls back to global bindings without a controller', async () => {
|
||||||
|
const domHandle = installFakeDom();
|
||||||
|
const saved: unknown[] = [];
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
focus: () => {},
|
||||||
|
electronAPI: {
|
||||||
|
saveControllerConfig: async (update: unknown) => {
|
||||||
|
saved.push(update);
|
||||||
|
},
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { state, dom } = buildContext();
|
||||||
|
state.connectedGamepads = [];
|
||||||
|
state.activeGamepadId = null;
|
||||||
|
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
notifyControllerDisabled: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.wireDomEvents();
|
||||||
|
modal.openControllerSelectModal();
|
||||||
|
|
||||||
|
const firstRow = dom.controllerConfigList.children[1];
|
||||||
|
firstRow.dispatch('click');
|
||||||
|
const editPanel = dom.controllerConfigList.children[2];
|
||||||
|
const learnButton = editPanel.children[0].children[1].children[0];
|
||||||
|
learnButton.dispatch('click');
|
||||||
|
|
||||||
|
state.controllerRawButtons = Array.from({ length: 12 }, () => ({
|
||||||
|
value: 0,
|
||||||
|
pressed: false,
|
||||||
|
touched: false,
|
||||||
|
}));
|
||||||
|
state.controllerRawButtons[11] = { value: 1, pressed: true, touched: true };
|
||||||
|
modal.updateDevices();
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
assert.deepEqual(saved.at(-1), {
|
assert.deepEqual(saved.at(-1), {
|
||||||
bindings: {
|
bindings: {
|
||||||
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
||||||
@@ -290,6 +479,99 @@ test('controller select modal learn mode captures fresh button input and persist
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('controller select modal edit control starts learn mode and persists binding', async () => {
|
||||||
|
const domHandle = installFakeDom();
|
||||||
|
const saved: unknown[] = [];
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
focus: () => {},
|
||||||
|
electronAPI: {
|
||||||
|
saveControllerConfig: async (update: unknown) => {
|
||||||
|
saved.push(update);
|
||||||
|
},
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { state, dom } = buildContext();
|
||||||
|
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
notifyControllerDisabled: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.wireDomEvents();
|
||||||
|
modal.openControllerSelectModal();
|
||||||
|
|
||||||
|
const firstRow = dom.controllerConfigList.children[1];
|
||||||
|
const right = firstRow.children[1];
|
||||||
|
const editButton = right.children[2];
|
||||||
|
editButton.dispatch('click');
|
||||||
|
|
||||||
|
state.controllerRawButtons = Array.from({ length: 12 }, () => ({
|
||||||
|
value: 0,
|
||||||
|
pressed: false,
|
||||||
|
touched: false,
|
||||||
|
}));
|
||||||
|
state.controllerRawButtons[11] = { value: 1, pressed: true, touched: true };
|
||||||
|
modal.updateDevices();
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
assert.deepEqual(saved.at(-1), {
|
||||||
|
profiles: {
|
||||||
|
'pad-1': {
|
||||||
|
bindings: {
|
||||||
|
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
domHandle.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('controller select modal stays closed and notifies when controller support is disabled', async () => {
|
||||||
|
const domHandle = installFakeDom();
|
||||||
|
let disabledNotices = 0;
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
focus: () => {},
|
||||||
|
electronAPI: {
|
||||||
|
saveControllerConfig: async () => {},
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { state, dom } = buildContext();
|
||||||
|
if (state.controllerConfig) state.controllerConfig.enabled = false;
|
||||||
|
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
notifyControllerDisabled: () => {
|
||||||
|
disabledNotices += 1;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(modal.openControllerSelectModal(), false);
|
||||||
|
|
||||||
|
assert.equal(state.controllerSelectModalOpen, false);
|
||||||
|
assert.equal(dom.controllerSelectModal.classList.contains('hidden'), true);
|
||||||
|
assert.equal(disabledNotices, 1);
|
||||||
|
} finally {
|
||||||
|
domHandle.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('controller select modal uses unique picker values for duplicate controller ids', async () => {
|
test('controller select modal uses unique picker values for duplicate controller ids', async () => {
|
||||||
const domHandle = installFakeDom();
|
const domHandle = installFakeDom();
|
||||||
|
|
||||||
@@ -315,6 +597,7 @@ test('controller select modal uses unique picker values for duplicate controller
|
|||||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||||
modalStateReader: { isAnyModalOpen: () => false },
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
syncSettingsModalSubtitleSuppression: () => {},
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
notifyControllerDisabled: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
modal.wireDomEvents();
|
modal.wireDomEvents();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ModalStateReader, RendererContext } from '../context';
|
import type { ModalStateReader, RendererContext } from '../context';
|
||||||
|
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
|
||||||
import { createControllerBindingCapture } from '../handlers/controller-binding-capture.js';
|
import { createControllerBindingCapture } from '../handlers/controller-binding-capture.js';
|
||||||
import {
|
import {
|
||||||
createControllerConfigForm,
|
createControllerConfigForm,
|
||||||
@@ -24,6 +25,7 @@ export function createControllerSelectModal(
|
|||||||
options: {
|
options: {
|
||||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||||
syncSettingsModalSubtitleSuppression: () => void;
|
syncSettingsModalSubtitleSuppression: () => void;
|
||||||
|
notifyControllerDisabled: () => void;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
let selectedControllerKey: string | null = null;
|
let selectedControllerKey: string | null = null;
|
||||||
@@ -38,10 +40,24 @@ export function createControllerSelectModal(
|
|||||||
let dpadLearningActionId: ControllerBindingKey | null = null;
|
let dpadLearningActionId: ControllerBindingKey | null = null;
|
||||||
let bindingCapture: ReturnType<typeof createControllerBindingCapture> | null = null;
|
let bindingCapture: ReturnType<typeof createControllerBindingCapture> | null = null;
|
||||||
|
|
||||||
|
function getSelectedController() {
|
||||||
|
return ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedControllerId(): string | null {
|
||||||
|
return getSelectedController()?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedControllerConfig() {
|
||||||
|
const config = ctx.state.controllerConfig;
|
||||||
|
if (!config) return null;
|
||||||
|
return resolveControllerConfigForGamepad(config, getSelectedControllerId());
|
||||||
|
}
|
||||||
|
|
||||||
const controllerConfigForm = createControllerConfigForm({
|
const controllerConfigForm = createControllerConfigForm({
|
||||||
container: ctx.dom.controllerConfigList,
|
container: ctx.dom.controllerConfigList,
|
||||||
getBindings: () =>
|
getBindings: () =>
|
||||||
ctx.state.controllerConfig?.bindings ?? {
|
getSelectedControllerConfig()?.bindings ?? {
|
||||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
closeLookup: { kind: 'button', buttonIndex: 1 },
|
||||||
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
||||||
@@ -67,7 +83,7 @@ export function createControllerSelectModal(
|
|||||||
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
|
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
|
||||||
stickDeadzone: config?.stickDeadzone ?? 0.2,
|
stickDeadzone: config?.stickDeadzone ?? 0.2,
|
||||||
});
|
});
|
||||||
const currentBinding = config?.bindings[actionId];
|
const currentBinding = getSelectedControllerConfig()?.bindings[actionId];
|
||||||
const currentDpadFallback =
|
const currentDpadFallback =
|
||||||
currentBinding && currentBinding.kind === 'axis' && 'dpadFallback' in currentBinding
|
currentBinding && currentBinding.kind === 'axis' && 'dpadFallback' in currentBinding
|
||||||
? currentBinding.dpadFallback
|
? currentBinding.dpadFallback
|
||||||
@@ -216,6 +232,51 @@ export function createControllerSelectModal(
|
|||||||
...update.bindings,
|
...update.bindings,
|
||||||
} as typeof ctx.state.controllerConfig.bindings;
|
} as typeof ctx.state.controllerConfig.bindings;
|
||||||
}
|
}
|
||||||
|
if (update.profiles) {
|
||||||
|
ctx.state.controllerConfig.profiles = ctx.state.controllerConfig.profiles ?? {};
|
||||||
|
for (const [profileId, profileUpdate] of Object.entries(update.profiles)) {
|
||||||
|
const currentProfile = ctx.state.controllerConfig.profiles[profileId];
|
||||||
|
const baseProfile = currentProfile ?? {
|
||||||
|
label: profileUpdate.label ?? profileId,
|
||||||
|
buttonIndices: ctx.state.controllerConfig.buttonIndices,
|
||||||
|
bindings: ctx.state.controllerConfig.bindings,
|
||||||
|
};
|
||||||
|
ctx.state.controllerConfig.profiles[profileId] = {
|
||||||
|
label: profileUpdate.label ?? baseProfile.label,
|
||||||
|
buttonIndices: {
|
||||||
|
...baseProfile.buttonIndices,
|
||||||
|
...(profileUpdate.buttonIndices ?? {}),
|
||||||
|
},
|
||||||
|
bindings: {
|
||||||
|
...baseProfile.bindings,
|
||||||
|
...(profileUpdate.bindings ?? {}),
|
||||||
|
},
|
||||||
|
} as (typeof ctx.state.controllerConfig.profiles)[string];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBindingConfigUpdate(
|
||||||
|
actionId: ControllerBindingKey,
|
||||||
|
binding: ControllerBindingValue,
|
||||||
|
): Parameters<typeof window.electronAPI.saveControllerConfig>[0] {
|
||||||
|
const selected = getSelectedController();
|
||||||
|
if (!selected) {
|
||||||
|
return {
|
||||||
|
bindings: {
|
||||||
|
[actionId]: binding,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
profiles: {
|
||||||
|
[selected.id]: {
|
||||||
|
bindings: {
|
||||||
|
[actionId]: binding,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveBinding(
|
async function saveBinding(
|
||||||
@@ -224,11 +285,7 @@ export function createControllerSelectModal(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const definition = getControllerBindingDefinition(actionId);
|
const definition = getControllerBindingDefinition(actionId);
|
||||||
try {
|
try {
|
||||||
await saveControllerConfig({
|
await saveControllerConfig(buildBindingConfigUpdate(actionId, binding));
|
||||||
bindings: {
|
|
||||||
[actionId]: binding,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
learningActionId = null;
|
learningActionId = null;
|
||||||
dpadLearningActionId = null;
|
dpadLearningActionId = null;
|
||||||
bindingCapture = null;
|
bindingCapture = null;
|
||||||
@@ -245,11 +302,11 @@ export function createControllerSelectModal(
|
|||||||
dpadFallback: import('../../types').ControllerDpadFallback,
|
dpadFallback: import('../../types').ControllerDpadFallback,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const definition = getControllerBindingDefinition(actionId);
|
const definition = getControllerBindingDefinition(actionId);
|
||||||
const currentBinding = ctx.state.controllerConfig?.bindings[actionId];
|
const currentBinding = getSelectedControllerConfig()?.bindings[actionId];
|
||||||
if (!currentBinding || currentBinding.kind !== 'axis') return;
|
if (!currentBinding || currentBinding.kind !== 'axis') return;
|
||||||
const updated = { ...currentBinding, dpadFallback };
|
const updated = { ...currentBinding, dpadFallback };
|
||||||
try {
|
try {
|
||||||
await saveControllerConfig({ bindings: { [actionId]: updated } });
|
await saveControllerConfig(buildBindingConfigUpdate(actionId, updated));
|
||||||
dpadLearningActionId = null;
|
dpadLearningActionId = null;
|
||||||
bindingCapture = null;
|
bindingCapture = null;
|
||||||
controllerConfigForm.render();
|
controllerConfigForm.render();
|
||||||
@@ -330,7 +387,11 @@ export function createControllerSelectModal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openControllerSelectModal(): void {
|
function openControllerSelectModal(): boolean {
|
||||||
|
if (ctx.state.controllerConfig?.enabled !== true) {
|
||||||
|
options.notifyControllerDisabled();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
ctx.state.controllerSelectModalOpen = true;
|
ctx.state.controllerSelectModalOpen = true;
|
||||||
syncSelectedIndexToCurrentController();
|
syncSelectedIndexToCurrentController();
|
||||||
options.syncSettingsModalSubtitleSuppression();
|
options.syncSettingsModalSubtitleSuppression();
|
||||||
@@ -346,6 +407,7 @@ export function createControllerSelectModal(
|
|||||||
} else {
|
} else {
|
||||||
setStatus('Choose a controller or click Learn to remap an action.');
|
setStatus('Choose a controller or click Learn to remap an action.');
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeControllerSelectModal(): void {
|
function closeControllerSelectModal(): void {
|
||||||
@@ -387,6 +449,7 @@ export function createControllerSelectModal(
|
|||||||
);
|
);
|
||||||
syncSelectedControllerId();
|
syncSelectedControllerId();
|
||||||
renderPicker();
|
renderPicker();
|
||||||
|
controllerConfigForm.render();
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -400,6 +463,7 @@ export function createControllerSelectModal(
|
|||||||
);
|
);
|
||||||
syncSelectedControllerId();
|
syncSelectedControllerId();
|
||||||
renderPicker();
|
renderPicker();
|
||||||
|
controllerConfigForm.render();
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -429,6 +493,7 @@ export function createControllerSelectModal(
|
|||||||
ctx.state.controllerDeviceSelectedIndex = selectedIndex;
|
ctx.state.controllerDeviceSelectedIndex = selectedIndex;
|
||||||
syncSelectedControllerId();
|
syncSelectedControllerId();
|
||||||
renderPicker();
|
renderPicker();
|
||||||
|
controllerConfigForm.render();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,10 +128,12 @@ const subsyncModal = createSubsyncModal(ctx, {
|
|||||||
const controllerSelectModal = createControllerSelectModal(ctx, {
|
const controllerSelectModal = createControllerSelectModal(ctx, {
|
||||||
modalStateReader: { isAnyModalOpen },
|
modalStateReader: { isAnyModalOpen },
|
||||||
syncSettingsModalSubtitleSuppression,
|
syncSettingsModalSubtitleSuppression,
|
||||||
|
notifyControllerDisabled: showControllerDisabledNotice,
|
||||||
});
|
});
|
||||||
const controllerDebugModal = createControllerDebugModal(ctx, {
|
const controllerDebugModal = createControllerDebugModal(ctx, {
|
||||||
modalStateReader: { isAnyModalOpen },
|
modalStateReader: { isAnyModalOpen },
|
||||||
syncSettingsModalSubtitleSuppression,
|
syncSettingsModalSubtitleSuppression,
|
||||||
|
notifyControllerDisabled: showControllerDisabledNotice,
|
||||||
});
|
});
|
||||||
const controllerStatusIndicator = createControllerStatusIndicator(ctx.dom);
|
const controllerStatusIndicator = createControllerStatusIndicator(ctx.dom);
|
||||||
const sessionHelpModal = createSessionHelpModal(ctx, {
|
const sessionHelpModal = createSessionHelpModal(ctx, {
|
||||||
@@ -183,10 +185,14 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
|||||||
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
||||||
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
|
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
|
||||||
openControllerSelectModal: () => {
|
openControllerSelectModal: () => {
|
||||||
controllerSelectModal.openControllerSelectModal();
|
if (controllerSelectModal.openControllerSelectModal()) {
|
||||||
|
window.electronAPI.notifyOverlayModalOpened('controller-select');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
openControllerDebugModal: () => {
|
openControllerDebugModal: () => {
|
||||||
controllerDebugModal.openControllerDebugModal();
|
if (controllerDebugModal.openControllerDebugModal()) {
|
||||||
|
window.electronAPI.notifyOverlayModalOpened('controller-debug');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
appendClipboardVideoToQueue: () => {
|
appendClipboardVideoToQueue: () => {
|
||||||
void window.electronAPI.appendClipboardVideoToQueue();
|
void window.electronAPI.appendClipboardVideoToQueue();
|
||||||
@@ -291,6 +297,12 @@ function applyControllerSnapshot(snapshot: {
|
|||||||
controllerDebugModal.updateSnapshot();
|
controllerDebugModal.updateSnapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showControllerDisabledNotice(): void {
|
||||||
|
controllerStatusIndicator.show(
|
||||||
|
'Controller support disabled. Set controller.enabled to true in config to use controller tools.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function emitControllerPopupScroll(deltaPixels: number): void {
|
function emitControllerPopupScroll(deltaPixels: number): void {
|
||||||
if (deltaPixels === 0) return;
|
if (deltaPixels === 0) return;
|
||||||
keyboardHandlers.scrollPopupByController(0, deltaPixels);
|
keyboardHandlers.scrollPopupByController(0, deltaPixels);
|
||||||
@@ -311,7 +323,7 @@ function startControllerPolling(): void {
|
|||||||
getGamepads: () => Array.from(navigator.getGamepads?.() ?? []),
|
getGamepads: () => Array.from(navigator.getGamepads?.() ?? []),
|
||||||
getConfig: () =>
|
getConfig: () =>
|
||||||
ctx.state.controllerConfig ?? {
|
ctx.state.controllerConfig ?? {
|
||||||
enabled: true,
|
enabled: false,
|
||||||
preferredGamepadId: '',
|
preferredGamepadId: '',
|
||||||
preferredGamepadLabel: '',
|
preferredGamepadLabel: '',
|
||||||
smoothScroll: true,
|
smoothScroll: true,
|
||||||
@@ -350,6 +362,7 @@ function startControllerPolling(): void {
|
|||||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||||
},
|
},
|
||||||
|
profiles: {},
|
||||||
},
|
},
|
||||||
getKeyboardModeEnabled: () => ctx.state.keyboardDrivenModeEnabled,
|
getKeyboardModeEnabled: () => ctx.state.keyboardDrivenModeEnabled,
|
||||||
getLookupWindowOpen: () => ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document),
|
getLookupWindowOpen: () => ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document),
|
||||||
@@ -461,14 +474,16 @@ function registerModalOpenHandlers(): void {
|
|||||||
});
|
});
|
||||||
window.electronAPI.onOpenControllerSelect(() => {
|
window.electronAPI.onOpenControllerSelect(() => {
|
||||||
runGuarded('controller-select:open', () => {
|
runGuarded('controller-select:open', () => {
|
||||||
controllerSelectModal.openControllerSelectModal();
|
if (controllerSelectModal.openControllerSelectModal()) {
|
||||||
window.electronAPI.notifyOverlayModalOpened('controller-select');
|
window.electronAPI.notifyOverlayModalOpened('controller-select');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
window.electronAPI.onOpenControllerDebug(() => {
|
window.electronAPI.onOpenControllerDebug(() => {
|
||||||
runGuarded('controller-debug:open', () => {
|
runGuarded('controller-debug:open', () => {
|
||||||
controllerDebugModal.openControllerDebugModal();
|
if (controllerDebugModal.openControllerDebugModal()) {
|
||||||
window.electronAPI.notifyOverlayModalOpened('controller-debug');
|
window.electronAPI.notifyOverlayModalOpened('controller-debug');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
window.electronAPI.onOpenJimaku(() => {
|
window.electronAPI.onOpenJimaku(() => {
|
||||||
|
|||||||
+15
-1
@@ -1694,14 +1694,17 @@ iframe[id^='yomitan-popup'],
|
|||||||
}
|
}
|
||||||
|
|
||||||
.controller-config-badge {
|
.controller-config-badge {
|
||||||
display: inline-block;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
|
border: 0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
background: rgba(138, 173, 244, 0.12);
|
background: rgba(138, 173, 244, 0.12);
|
||||||
color: var(--ctp-blue);
|
color: var(--ctp-blue);
|
||||||
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1710,12 +1713,23 @@ iframe[id^='yomitan-popup'],
|
|||||||
color: var(--ctp-overlay0);
|
color: var(--ctp-overlay0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.controller-config-reset-icon,
|
||||||
.controller-config-edit-icon {
|
.controller-config-edit-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--ctp-overlay0);
|
color: var(--ctp-overlay0);
|
||||||
|
cursor: pointer;
|
||||||
transition: color 120ms ease;
|
transition: color 120ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.controller-config-row:hover .controller-config-reset-icon,
|
||||||
.controller-config-row:hover .controller-config-edit-icon {
|
.controller-config-row:hover .controller-config-edit-icon {
|
||||||
color: var(--ctp-overlay2);
|
color: var(--ctp-overlay2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import type { SessionActionId, SessionActionPayload } from '../../types/session-
|
|||||||
import type { SubtitlePosition } from '../../types/subtitle';
|
import type { SubtitlePosition } from '../../types/subtitle';
|
||||||
import { OVERLAY_HOSTED_MODALS, type OverlayHostedModal } from './contracts';
|
import { OVERLAY_HOSTED_MODALS, type OverlayHostedModal } from './contracts';
|
||||||
|
|
||||||
|
const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||||
|
|
||||||
const SESSION_ACTION_IDS: SessionActionId[] = [
|
const SESSION_ACTION_IDS: SessionActionId[] = [
|
||||||
'toggleStatsOverlay',
|
'toggleStatsOverlay',
|
||||||
'toggleVisibleOverlay',
|
'toggleVisibleOverlay',
|
||||||
@@ -166,6 +168,67 @@ function parseAxisBinding(value: unknown) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseControllerButtonIndices(
|
||||||
|
value: unknown,
|
||||||
|
): ControllerConfigUpdate['buttonIndices'] | null {
|
||||||
|
if (!isObject(value)) return null;
|
||||||
|
const buttonIndices: NonNullable<ControllerConfigUpdate['buttonIndices']> = {};
|
||||||
|
const keys = [
|
||||||
|
'select',
|
||||||
|
'buttonSouth',
|
||||||
|
'buttonEast',
|
||||||
|
'buttonNorth',
|
||||||
|
'buttonWest',
|
||||||
|
'leftShoulder',
|
||||||
|
'rightShoulder',
|
||||||
|
'leftStickPress',
|
||||||
|
'rightStickPress',
|
||||||
|
'leftTrigger',
|
||||||
|
'rightTrigger',
|
||||||
|
] as const;
|
||||||
|
for (const key of keys) {
|
||||||
|
if (value[key] === undefined) continue;
|
||||||
|
if (!isInteger(value[key]) || value[key] < 0) return null;
|
||||||
|
buttonIndices[key] = value[key];
|
||||||
|
}
|
||||||
|
return buttonIndices;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseControllerBindings(value: unknown): ControllerConfigUpdate['bindings'] | null {
|
||||||
|
if (!isObject(value)) return null;
|
||||||
|
const bindings: NonNullable<ControllerConfigUpdate['bindings']> = {};
|
||||||
|
const discreteKeys = [
|
||||||
|
'toggleLookup',
|
||||||
|
'closeLookup',
|
||||||
|
'toggleKeyboardOnlyMode',
|
||||||
|
'mineCard',
|
||||||
|
'quitMpv',
|
||||||
|
'previousAudio',
|
||||||
|
'nextAudio',
|
||||||
|
'playCurrentAudio',
|
||||||
|
'toggleMpvPause',
|
||||||
|
] as const;
|
||||||
|
for (const key of discreteKeys) {
|
||||||
|
if (value[key] === undefined) continue;
|
||||||
|
const parsed = parseDiscreteBinding(value[key]);
|
||||||
|
if (!parsed) return null;
|
||||||
|
bindings[key] = parsed as NonNullable<ControllerConfigUpdate['bindings']>[typeof key];
|
||||||
|
}
|
||||||
|
const axisKeys = [
|
||||||
|
'leftStickHorizontal',
|
||||||
|
'leftStickVertical',
|
||||||
|
'rightStickHorizontal',
|
||||||
|
'rightStickVertical',
|
||||||
|
] as const;
|
||||||
|
for (const key of axisKeys) {
|
||||||
|
if (value[key] === undefined) continue;
|
||||||
|
const parsed = parseAxisBinding(value[key]);
|
||||||
|
if (!parsed) return null;
|
||||||
|
bindings[key] = parsed as NonNullable<ControllerConfigUpdate['bindings']>[typeof key];
|
||||||
|
}
|
||||||
|
return bindings;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpdate | null {
|
export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpdate | null {
|
||||||
if (!isObject(value)) return null;
|
if (!isObject(value)) return null;
|
||||||
const update: ControllerConfigUpdate = {};
|
const update: ControllerConfigUpdate = {};
|
||||||
@@ -182,40 +245,42 @@ export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpd
|
|||||||
if (typeof value.preferredGamepadLabel !== 'string') return null;
|
if (typeof value.preferredGamepadLabel !== 'string') return null;
|
||||||
update.preferredGamepadLabel = value.preferredGamepadLabel;
|
update.preferredGamepadLabel = value.preferredGamepadLabel;
|
||||||
}
|
}
|
||||||
|
if (value.buttonIndices !== undefined) {
|
||||||
|
const parsed = parseControllerButtonIndices(value.buttonIndices);
|
||||||
|
if (!parsed) return null;
|
||||||
|
update.buttonIndices = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
if (value.bindings !== undefined) {
|
if (value.bindings !== undefined) {
|
||||||
if (!isObject(value.bindings)) return null;
|
const parsed = parseControllerBindings(value.bindings);
|
||||||
const bindings: NonNullable<ControllerConfigUpdate['bindings']> = {};
|
if (!parsed) return null;
|
||||||
const discreteKeys = [
|
update.bindings = parsed;
|
||||||
'toggleLookup',
|
}
|
||||||
'closeLookup',
|
|
||||||
'toggleKeyboardOnlyMode',
|
if (value.profiles !== undefined) {
|
||||||
'mineCard',
|
if (!isObject(value.profiles)) return null;
|
||||||
'quitMpv',
|
const profiles: NonNullable<ControllerConfigUpdate['profiles']> = Object.create(null);
|
||||||
'previousAudio',
|
for (const [profileId, rawProfile] of Object.entries(value.profiles)) {
|
||||||
'nextAudio',
|
if (RESERVED_CONTROLLER_PROFILE_IDS.has(profileId)) return null;
|
||||||
'playCurrentAudio',
|
if (!isObject(rawProfile)) return null;
|
||||||
'toggleMpvPause',
|
const profile: NonNullable<ControllerConfigUpdate['profiles']>[string] = {};
|
||||||
] as const;
|
if (rawProfile.label !== undefined) {
|
||||||
for (const key of discreteKeys) {
|
if (typeof rawProfile.label !== 'string') return null;
|
||||||
if (value.bindings[key] === undefined) continue;
|
profile.label = rawProfile.label;
|
||||||
const parsed = parseDiscreteBinding(value.bindings[key]);
|
}
|
||||||
if (!parsed) return null;
|
if (rawProfile.buttonIndices !== undefined) {
|
||||||
bindings[key] = parsed as NonNullable<ControllerConfigUpdate['bindings']>[typeof key];
|
const parsed = parseControllerButtonIndices(rawProfile.buttonIndices);
|
||||||
|
if (!parsed) return null;
|
||||||
|
profile.buttonIndices = parsed;
|
||||||
|
}
|
||||||
|
if (rawProfile.bindings !== undefined) {
|
||||||
|
const parsed = parseControllerBindings(rawProfile.bindings);
|
||||||
|
if (!parsed) return null;
|
||||||
|
profile.bindings = parsed;
|
||||||
|
}
|
||||||
|
profiles[profileId] = profile;
|
||||||
}
|
}
|
||||||
const axisKeys = [
|
update.profiles = profiles;
|
||||||
'leftStickHorizontal',
|
|
||||||
'leftStickVertical',
|
|
||||||
'rightStickHorizontal',
|
|
||||||
'rightStickVertical',
|
|
||||||
] as const;
|
|
||||||
for (const key of axisKeys) {
|
|
||||||
if (value.bindings[key] === undefined) continue;
|
|
||||||
const parsed = parseAxisBinding(value.bindings[key]);
|
|
||||||
if (!parsed) return null;
|
|
||||||
bindings[key] = parsed as NonNullable<ControllerConfigUpdate['bindings']>[typeof key];
|
|
||||||
}
|
|
||||||
update.bindings = bindings;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return update;
|
return update;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
ControllerButtonIndicesConfig,
|
ControllerButtonIndicesConfig,
|
||||||
ControllerConfig,
|
ControllerConfig,
|
||||||
|
ResolvedControllerProfileConfig,
|
||||||
ControllerTriggerInputMode,
|
ControllerTriggerInputMode,
|
||||||
Keybinding,
|
Keybinding,
|
||||||
ResolvedControllerBindingsConfig,
|
ResolvedControllerBindingsConfig,
|
||||||
@@ -164,6 +165,7 @@ export interface ResolvedConfig {
|
|||||||
repeatIntervalMs: number;
|
repeatIntervalMs: number;
|
||||||
buttonIndices: Required<ControllerButtonIndicesConfig>;
|
buttonIndices: Required<ControllerButtonIndicesConfig>;
|
||||||
bindings: Required<ResolvedControllerBindingsConfig>;
|
bindings: Required<ResolvedControllerBindingsConfig>;
|
||||||
|
profiles: Record<string, ResolvedControllerProfileConfig>;
|
||||||
};
|
};
|
||||||
ankiConnect: AnkiConnectConfig & {
|
ankiConnect: AnkiConnectConfig & {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|||||||
@@ -227,6 +227,18 @@ export interface ControllerButtonIndicesConfig {
|
|||||||
rightTrigger?: number;
|
rightTrigger?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ControllerProfileConfig {
|
||||||
|
label?: string;
|
||||||
|
buttonIndices?: ControllerButtonIndicesConfig;
|
||||||
|
bindings?: ControllerBindingsConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedControllerProfileConfig {
|
||||||
|
label: string;
|
||||||
|
buttonIndices: Required<ControllerButtonIndicesConfig>;
|
||||||
|
bindings: Required<ResolvedControllerBindingsConfig>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ControllerConfig {
|
export interface ControllerConfig {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
preferredGamepadId?: string;
|
preferredGamepadId?: string;
|
||||||
@@ -241,6 +253,7 @@ export interface ControllerConfig {
|
|||||||
repeatIntervalMs?: number;
|
repeatIntervalMs?: number;
|
||||||
buttonIndices?: ControllerButtonIndicesConfig;
|
buttonIndices?: ControllerButtonIndicesConfig;
|
||||||
bindings?: ControllerBindingsConfig;
|
bindings?: ControllerBindingsConfig;
|
||||||
|
profiles?: Record<string, ControllerProfileConfig>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ControllerPreferenceUpdate {
|
export interface ControllerPreferenceUpdate {
|
||||||
|
|||||||
Reference in New Issue
Block a user