mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-27 00:55:16 -07:00
fix(controller): save remaps per profile, gate modals on enabled (#69)
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
return { update };
|
||||
return { show, update };
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ function createControllerConfig(
|
||||
...(buttonIndexOverrides ?? {}),
|
||||
}),
|
||||
},
|
||||
profiles: {},
|
||||
...restOverrides,
|
||||
};
|
||||
}
|
||||
@@ -449,6 +450,60 @@ test('gamepad controller maps left stick horizontal movement to token selection
|
||||
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', () => {
|
||||
const calls: string[] = [];
|
||||
const scrollCalls: number[] = [];
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
ResolvedControllerConfig,
|
||||
ResolvedControllerDiscreteBinding,
|
||||
} from '../../types';
|
||||
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
|
||||
|
||||
type ControllerButtonState = {
|
||||
value: number;
|
||||
@@ -410,87 +411,101 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
resetHeldAction(jumpHold);
|
||||
}
|
||||
|
||||
let interactionAllowed =
|
||||
config.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked();
|
||||
if (config.enabled) {
|
||||
const activeConfig = resolveControllerConfigForGamepad(config, activeGamepad.id);
|
||||
|
||||
if (activeConfig.enabled) {
|
||||
handleActionEdge(
|
||||
'toggleKeyboardOnlyMode',
|
||||
config.bindings.toggleKeyboardOnlyMode,
|
||||
activeConfig.bindings.toggleKeyboardOnlyMode,
|
||||
activeGamepad,
|
||||
config,
|
||||
activeConfig,
|
||||
options.toggleKeyboardMode,
|
||||
);
|
||||
}
|
||||
|
||||
interactionAllowed =
|
||||
config.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked();
|
||||
const interactionAllowed =
|
||||
activeConfig.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked();
|
||||
|
||||
if (!interactionAllowed) {
|
||||
syncBlockedInteractionState(activeGamepad, config, now);
|
||||
syncBlockedInteractionState(activeGamepad, activeConfig, now);
|
||||
return;
|
||||
}
|
||||
|
||||
handleActionEdge(
|
||||
'toggleLookup',
|
||||
config.bindings.toggleLookup,
|
||||
activeConfig.bindings.toggleLookup,
|
||||
activeGamepad,
|
||||
config,
|
||||
activeConfig,
|
||||
options.toggleLookup,
|
||||
);
|
||||
handleActionEdge(
|
||||
'closeLookup',
|
||||
config.bindings.closeLookup,
|
||||
activeConfig.bindings.closeLookup,
|
||||
activeGamepad,
|
||||
config,
|
||||
activeConfig,
|
||||
options.closeLookup,
|
||||
);
|
||||
handleActionEdge('mineCard', config.bindings.mineCard, activeGamepad, config, options.mineCard);
|
||||
handleActionEdge('quitMpv', config.bindings.quitMpv, activeGamepad, config, options.quitMpv);
|
||||
handleActionEdge(
|
||||
'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()) {
|
||||
handleActionEdge(
|
||||
'previousAudio',
|
||||
config.bindings.previousAudio,
|
||||
activeConfig.bindings.previousAudio,
|
||||
activeGamepad,
|
||||
config,
|
||||
activeConfig,
|
||||
options.previousAudio,
|
||||
);
|
||||
handleActionEdge(
|
||||
'nextAudio',
|
||||
config.bindings.nextAudio,
|
||||
activeConfig.bindings.nextAudio,
|
||||
activeGamepad,
|
||||
config,
|
||||
activeConfig,
|
||||
options.nextAudio,
|
||||
);
|
||||
handleActionEdge(
|
||||
'playCurrentAudio',
|
||||
config.bindings.playCurrentAudio,
|
||||
activeConfig.bindings.playCurrentAudio,
|
||||
activeGamepad,
|
||||
config,
|
||||
activeConfig,
|
||||
options.playCurrentAudio,
|
||||
);
|
||||
|
||||
const primaryScroll = resolveAxisBindingValue(
|
||||
activeGamepad,
|
||||
config.bindings.leftStickVertical,
|
||||
config.triggerDeadzone,
|
||||
config.stickDeadzone,
|
||||
activeConfig.bindings.leftStickVertical,
|
||||
activeConfig.triggerDeadzone,
|
||||
activeConfig.stickDeadzone,
|
||||
);
|
||||
if (elapsedMs > 0 && Math.abs(primaryScroll) >= config.stickDeadzone) {
|
||||
options.scrollPopup((primaryScroll * config.scrollPixelsPerSecond * elapsedMs) / 1000);
|
||||
if (elapsedMs > 0 && Math.abs(primaryScroll) >= activeConfig.stickDeadzone) {
|
||||
options.scrollPopup(
|
||||
(primaryScroll * activeConfig.scrollPixelsPerSecond * elapsedMs) / 1000,
|
||||
);
|
||||
}
|
||||
|
||||
handleJumpAxis(
|
||||
resolveAxisBindingValue(
|
||||
activeGamepad,
|
||||
config.bindings.rightStickVertical,
|
||||
config.triggerDeadzone,
|
||||
activeConfig.bindings.rightStickVertical,
|
||||
activeConfig.triggerDeadzone,
|
||||
activationThreshold,
|
||||
),
|
||||
now,
|
||||
config,
|
||||
activeConfig,
|
||||
);
|
||||
} else {
|
||||
resetHeldAction(jumpHold);
|
||||
@@ -498,21 +513,21 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
|
||||
handleActionEdge(
|
||||
'toggleMpvPause',
|
||||
config.bindings.toggleMpvPause,
|
||||
activeConfig.bindings.toggleMpvPause,
|
||||
activeGamepad,
|
||||
config,
|
||||
activeConfig,
|
||||
options.toggleMpvPause,
|
||||
);
|
||||
|
||||
handleSelectionAxis(
|
||||
resolveAxisBindingValue(
|
||||
activeGamepad,
|
||||
config.bindings.leftStickHorizontal,
|
||||
config.triggerDeadzone,
|
||||
activeConfig.bindings.leftStickHorizontal,
|
||||
activeConfig.triggerDeadzone,
|
||||
activationThreshold,
|
||||
),
|
||||
now,
|
||||
config,
|
||||
activeConfig,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -987,7 +987,7 @@ test('keyboard mode: configured controller select binding opens locally without
|
||||
|
||||
assert.equal(openControllerSelectCount(), 1);
|
||||
assert.deepEqual(testGlobals.sessionActions, []);
|
||||
assert.deepEqual(testGlobals.openedModalNotifications, ['controller-select']);
|
||||
assert.deepEqual(testGlobals.openedModalNotifications, []);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
@@ -1017,7 +1017,7 @@ test('keyboard mode: configured controller debug binding opens locally without d
|
||||
|
||||
assert.equal(openControllerDebugCount(), 1);
|
||||
assert.deepEqual(testGlobals.sessionActions, []);
|
||||
assert.deepEqual(testGlobals.openedModalNotifications, ['controller-debug']);
|
||||
assert.deepEqual(testGlobals.openedModalNotifications, []);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
@@ -1049,7 +1049,7 @@ test('keyboard mode: configured controller debug binding is not swallowed while
|
||||
|
||||
assert.equal(openControllerDebugCount(), 1);
|
||||
assert.deepEqual(testGlobals.sessionActions, []);
|
||||
assert.deepEqual(testGlobals.openedModalNotifications, ['controller-debug']);
|
||||
assert.deepEqual(testGlobals.openedModalNotifications, []);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
|
||||
@@ -203,13 +203,11 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
|
||||
if (binding.actionType === 'session-action' && binding.actionId === 'openControllerSelect') {
|
||||
window.electronAPI.notifyOverlayModalOpened('controller-select');
|
||||
options.openControllerSelectModal?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (binding.actionType === 'session-action' && binding.actionId === 'openControllerDebug') {
|
||||
window.electronAPI.notifyOverlayModalOpened('controller-debug');
|
||||
options.openControllerDebugModal?.();
|
||||
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),
|
||||
binding.kind === 'none',
|
||||
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', () => {
|
||||
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
||||
@@ -321,6 +332,17 @@ export function createControllerConfigForm(options: {
|
||||
formatFriendlyStickLabel(binding),
|
||||
binding.kind === 'none',
|
||||
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', () => {
|
||||
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
||||
@@ -366,6 +388,17 @@ export function createControllerConfigForm(options: {
|
||||
badgeText,
|
||||
dpadFallback === 'none',
|
||||
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', () => {
|
||||
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
||||
@@ -400,6 +433,10 @@ export function createControllerConfigForm(options: {
|
||||
badgeText: string,
|
||||
isDisabled: boolean,
|
||||
isExpanded: boolean,
|
||||
editLabel: string,
|
||||
onEdit: (e: Event) => void,
|
||||
resetLabel: string,
|
||||
onReset: (e: Event) => void,
|
||||
): HTMLDivElement {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'controller-config-row';
|
||||
@@ -412,16 +449,33 @@ export function createControllerConfigForm(options: {
|
||||
const right = document.createElement('div');
|
||||
right.className = 'controller-config-right';
|
||||
|
||||
const badge = document.createElement('span');
|
||||
const badge = document.createElement('button');
|
||||
badge.type = 'button';
|
||||
badge.className = 'controller-config-badge';
|
||||
if (isDisabled) badge.classList.add('disabled');
|
||||
badge.setAttribute('aria-label', editLabel);
|
||||
badge.title = editLabel;
|
||||
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.setAttribute('aria-label', editLabel);
|
||||
editIcon.title = editLabel;
|
||||
editIcon.textContent = '\u270E';
|
||||
editIcon.addEventListener('click', onEdit);
|
||||
|
||||
right.appendChild(badge);
|
||||
right.appendChild(resetIcon);
|
||||
right.appendChild(editIcon);
|
||||
row.appendChild(label);
|
||||
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' },
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
},
|
||||
profiles: {},
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
@@ -99,6 +100,7 @@ test('controller debug modal renders active controller axes, buttons, and config
|
||||
const modal = createControllerDebugModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
notifyControllerDisabled: () => {},
|
||||
});
|
||||
|
||||
modal.openControllerDebugModal();
|
||||
@@ -189,6 +191,7 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
},
|
||||
profiles: {},
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
@@ -217,6 +220,7 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
|
||||
const modal = createControllerDebugModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
notifyControllerDisabled: () => {},
|
||||
});
|
||||
|
||||
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 { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
|
||||
|
||||
function formatAxes(values: number[]): string {
|
||||
if (values.length === 0) return 'No controller axes available.';
|
||||
@@ -50,6 +51,7 @@ export function createControllerDebugModal(
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
notifyControllerDisabled: () => void;
|
||||
},
|
||||
) {
|
||||
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.';
|
||||
ctx.dom.controllerDebugAxes.textContent = formatAxes(ctx.state.controllerRawAxes);
|
||||
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.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;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
@@ -144,6 +153,7 @@ export function createControllerDebugModal(
|
||||
ctx.dom.controllerDebugModal.setAttribute('aria-hidden', 'false');
|
||||
hideToast();
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
function closeControllerDebugModal(): void {
|
||||
|
||||
@@ -158,6 +158,7 @@ function buildContext() {
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
},
|
||||
profiles: {},
|
||||
};
|
||||
state.connectedGamepads = [
|
||||
{ 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, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
notifyControllerDisabled: () => {},
|
||||
});
|
||||
|
||||
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, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
notifyControllerDisabled: () => {},
|
||||
});
|
||||
|
||||
modal.wireDomEvents();
|
||||
@@ -276,6 +279,192 @@ test('controller select modal learn mode captures fresh button input and persist
|
||||
|
||||
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), {
|
||||
bindings: {
|
||||
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 () => {
|
||||
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, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
notifyControllerDisabled: () => {},
|
||||
});
|
||||
|
||||
modal.wireDomEvents();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
|
||||
import { createControllerBindingCapture } from '../handlers/controller-binding-capture.js';
|
||||
import {
|
||||
createControllerConfigForm,
|
||||
@@ -24,6 +25,7 @@ export function createControllerSelectModal(
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
notifyControllerDisabled: () => void;
|
||||
},
|
||||
) {
|
||||
let selectedControllerKey: string | null = null;
|
||||
@@ -38,10 +40,24 @@ export function createControllerSelectModal(
|
||||
let dpadLearningActionId: ControllerBindingKey | 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({
|
||||
container: ctx.dom.controllerConfigList,
|
||||
getBindings: () =>
|
||||
ctx.state.controllerConfig?.bindings ?? {
|
||||
getSelectedControllerConfig()?.bindings ?? {
|
||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
||||
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
||||
@@ -67,7 +83,7 @@ export function createControllerSelectModal(
|
||||
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
|
||||
stickDeadzone: config?.stickDeadzone ?? 0.2,
|
||||
});
|
||||
const currentBinding = config?.bindings[actionId];
|
||||
const currentBinding = getSelectedControllerConfig()?.bindings[actionId];
|
||||
const currentDpadFallback =
|
||||
currentBinding && currentBinding.kind === 'axis' && 'dpadFallback' in currentBinding
|
||||
? currentBinding.dpadFallback
|
||||
@@ -216,6 +232,51 @@ export function createControllerSelectModal(
|
||||
...update.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(
|
||||
@@ -224,11 +285,7 @@ export function createControllerSelectModal(
|
||||
): Promise<void> {
|
||||
const definition = getControllerBindingDefinition(actionId);
|
||||
try {
|
||||
await saveControllerConfig({
|
||||
bindings: {
|
||||
[actionId]: binding,
|
||||
},
|
||||
});
|
||||
await saveControllerConfig(buildBindingConfigUpdate(actionId, binding));
|
||||
learningActionId = null;
|
||||
dpadLearningActionId = null;
|
||||
bindingCapture = null;
|
||||
@@ -245,11 +302,11 @@ export function createControllerSelectModal(
|
||||
dpadFallback: import('../../types').ControllerDpadFallback,
|
||||
): Promise<void> {
|
||||
const definition = getControllerBindingDefinition(actionId);
|
||||
const currentBinding = ctx.state.controllerConfig?.bindings[actionId];
|
||||
const currentBinding = getSelectedControllerConfig()?.bindings[actionId];
|
||||
if (!currentBinding || currentBinding.kind !== 'axis') return;
|
||||
const updated = { ...currentBinding, dpadFallback };
|
||||
try {
|
||||
await saveControllerConfig({ bindings: { [actionId]: updated } });
|
||||
await saveControllerConfig(buildBindingConfigUpdate(actionId, updated));
|
||||
dpadLearningActionId = null;
|
||||
bindingCapture = null;
|
||||
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;
|
||||
syncSelectedIndexToCurrentController();
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
@@ -346,6 +407,7 @@ export function createControllerSelectModal(
|
||||
} else {
|
||||
setStatus('Choose a controller or click Learn to remap an action.');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function closeControllerSelectModal(): void {
|
||||
@@ -387,6 +449,7 @@ export function createControllerSelectModal(
|
||||
);
|
||||
syncSelectedControllerId();
|
||||
renderPicker();
|
||||
controllerConfigForm.render();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -400,6 +463,7 @@ export function createControllerSelectModal(
|
||||
);
|
||||
syncSelectedControllerId();
|
||||
renderPicker();
|
||||
controllerConfigForm.render();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -429,6 +493,7 @@ export function createControllerSelectModal(
|
||||
ctx.state.controllerDeviceSelectedIndex = selectedIndex;
|
||||
syncSelectedControllerId();
|
||||
renderPicker();
|
||||
controllerConfigForm.render();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -128,10 +128,12 @@ const subsyncModal = createSubsyncModal(ctx, {
|
||||
const controllerSelectModal = createControllerSelectModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
notifyControllerDisabled: showControllerDisabledNotice,
|
||||
});
|
||||
const controllerDebugModal = createControllerDebugModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
notifyControllerDisabled: showControllerDisabledNotice,
|
||||
});
|
||||
const controllerStatusIndicator = createControllerStatusIndicator(ctx.dom);
|
||||
const sessionHelpModal = createSessionHelpModal(ctx, {
|
||||
@@ -183,10 +185,14 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
||||
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
|
||||
openControllerSelectModal: () => {
|
||||
controllerSelectModal.openControllerSelectModal();
|
||||
if (controllerSelectModal.openControllerSelectModal()) {
|
||||
window.electronAPI.notifyOverlayModalOpened('controller-select');
|
||||
}
|
||||
},
|
||||
openControllerDebugModal: () => {
|
||||
controllerDebugModal.openControllerDebugModal();
|
||||
if (controllerDebugModal.openControllerDebugModal()) {
|
||||
window.electronAPI.notifyOverlayModalOpened('controller-debug');
|
||||
}
|
||||
},
|
||||
appendClipboardVideoToQueue: () => {
|
||||
void window.electronAPI.appendClipboardVideoToQueue();
|
||||
@@ -291,6 +297,12 @@ function applyControllerSnapshot(snapshot: {
|
||||
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 {
|
||||
if (deltaPixels === 0) return;
|
||||
keyboardHandlers.scrollPopupByController(0, deltaPixels);
|
||||
@@ -311,7 +323,7 @@ function startControllerPolling(): void {
|
||||
getGamepads: () => Array.from(navigator.getGamepads?.() ?? []),
|
||||
getConfig: () =>
|
||||
ctx.state.controllerConfig ?? {
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
preferredGamepadId: '',
|
||||
preferredGamepadLabel: '',
|
||||
smoothScroll: true,
|
||||
@@ -350,6 +362,7 @@ function startControllerPolling(): void {
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
},
|
||||
profiles: {},
|
||||
},
|
||||
getKeyboardModeEnabled: () => ctx.state.keyboardDrivenModeEnabled,
|
||||
getLookupWindowOpen: () => ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document),
|
||||
@@ -461,14 +474,16 @@ function registerModalOpenHandlers(): void {
|
||||
});
|
||||
window.electronAPI.onOpenControllerSelect(() => {
|
||||
runGuarded('controller-select:open', () => {
|
||||
controllerSelectModal.openControllerSelectModal();
|
||||
window.electronAPI.notifyOverlayModalOpened('controller-select');
|
||||
if (controllerSelectModal.openControllerSelectModal()) {
|
||||
window.electronAPI.notifyOverlayModalOpened('controller-select');
|
||||
}
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenControllerDebug(() => {
|
||||
runGuarded('controller-debug:open', () => {
|
||||
controllerDebugModal.openControllerDebugModal();
|
||||
window.electronAPI.notifyOverlayModalOpened('controller-debug');
|
||||
if (controllerDebugModal.openControllerDebugModal()) {
|
||||
window.electronAPI.notifyOverlayModalOpened('controller-debug');
|
||||
}
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenJimaku(() => {
|
||||
|
||||
+15
-1
@@ -1694,14 +1694,17 @@ iframe[id^='yomitan-popup'],
|
||||
}
|
||||
|
||||
.controller-config-badge {
|
||||
display: inline-block;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
background: rgba(138, 173, 244, 0.12);
|
||||
color: var(--ctp-blue);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1710,12 +1713,23 @@ iframe[id^='yomitan-popup'],
|
||||
color: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.controller-config-reset-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;
|
||||
color: var(--ctp-overlay0);
|
||||
cursor: pointer;
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
|
||||
.controller-config-row:hover .controller-config-reset-icon,
|
||||
.controller-config-row:hover .controller-config-edit-icon {
|
||||
color: var(--ctp-overlay2);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user