mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-09 16:19:25 -07:00
Add controller support and harden overlay input handling
- Ship v0.6.0 with keyboard-overlay gamepad support, controller modals, and detection indicator - Tighten controller config/IPC validation (integer button indices, reject malformed preference payloads) - Fix blocked-input edge handling and lookup/modal error paths to avoid stale UI state
This commit is contained in:
11
CHANGELOG.md
11
CHANGELOG.md
@@ -1,5 +1,16 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.6.0 (2026-03-12)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Overlay: Added Chrome Gamepad API controller support for keyboard-only overlay mode, including configurable logical bindings for lookup, mining, popup navigation, Yomitan audio, mpv pause, d-pad fallback navigation, and slower smooth popup scrolling.
|
||||||
|
- Overlay: Added `Alt+C` controller selection and `Alt+Shift+C` controller debug modals, with preferred controller persistence and live raw input inspection.
|
||||||
|
- Overlay: Added a transient in-overlay controller-detected indicator when a controller is first found.
|
||||||
|
- Overlay: Fixed stale keyboard-only token highlight cleanup when keyboard-only mode turns off or the Yomitan popup closes.
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
- Config: add an enforced `verify:config-example` gate so checked-in example config artifacts cannot drift silently
|
||||||
|
|
||||||
## v0.5.6 (2026-03-10)
|
## v0.5.6 (2026-03-10)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
type: internal
|
|
||||||
area: config
|
|
||||||
|
|
||||||
- add an enforced `verify:config-example` gate so checked-in example config artifacts cannot drift silently
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
type: added
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Added Chrome Gamepad API controller support for keyboard-only overlay mode, including configurable logical bindings for lookup, mining, popup navigation, Yomitan audio, mpv pause, d-pad fallback navigation, and slower smooth popup scrolling.
|
|
||||||
- Added `Alt+C` controller selection and `Alt+Shift+C` controller debug modals, with preferred controller persistence and live raw input inspection.
|
|
||||||
- Added a transient in-overlay controller-detected indicator when a controller is first found.
|
|
||||||
- Fixed stale keyboard-only token highlight cleanup when keyboard-only mode turns off or the Yomitan popup closes.
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"version": "0.5.6",
|
"version": "0.6.0",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
|
|||||||
@@ -1204,6 +1204,37 @@ test('controller positive-number tuning rejects sub-unit values that floor to ze
|
|||||||
assert.equal(warnings.some((warning) => warning.path === 'controller.repeatIntervalMs'), true);
|
assert.equal(warnings.some((warning) => warning.path === 'controller.repeatIntervalMs'), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('controller button index config rejects fractional values', () => {
|
||||||
|
const dir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"controller": {
|
||||||
|
"buttonIndices": {
|
||||||
|
"select": 6.5,
|
||||||
|
"leftStickPress": 9.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new ConfigService(dir);
|
||||||
|
const config = service.getConfig();
|
||||||
|
const warnings = service.getWarnings();
|
||||||
|
|
||||||
|
assert.equal(config.controller.buttonIndices.select, DEFAULT_CONFIG.controller.buttonIndices.select);
|
||||||
|
assert.equal(
|
||||||
|
config.controller.buttonIndices.leftStickPress,
|
||||||
|
DEFAULT_CONFIG.controller.buttonIndices.leftStickPress,
|
||||||
|
);
|
||||||
|
assert.equal(warnings.some((warning) => warning.path === 'controller.buttonIndices.select'), true);
|
||||||
|
assert.equal(
|
||||||
|
warnings.some((warning) => warning.path === 'controller.buttonIndices.leftStickPress'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('runtime options registry is centralized', () => {
|
test('runtime options registry is centralized', () => {
|
||||||
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
|
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
|
||||||
assert.deepEqual(ids, [
|
assert.deepEqual(ids, [
|
||||||
|
|||||||
@@ -209,8 +209,8 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
|||||||
|
|
||||||
for (const key of buttonIndexKeys) {
|
for (const key of buttonIndexKeys) {
|
||||||
const value = asNumber(src.controller.buttonIndices[key]);
|
const value = asNumber(src.controller.buttonIndices[key]);
|
||||||
if (value !== undefined && value >= 0) {
|
if (value !== undefined && value >= 0 && Number.isInteger(value)) {
|
||||||
resolved.controller.buttonIndices[key] = Math.floor(value);
|
resolved.controller.buttonIndices[key] = value;
|
||||||
} else if (src.controller.buttonIndices[key] !== undefined) {
|
} else if (src.controller.buttonIndices[key] !== undefined) {
|
||||||
warn(
|
warn(
|
||||||
`controller.buttonIndices.${key}`,
|
`controller.buttonIndices.${key}`,
|
||||||
|
|||||||
@@ -467,7 +467,12 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
|
|||||||
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerPreference);
|
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerPreference);
|
||||||
assert.ok(saveHandler);
|
assert.ok(saveHandler);
|
||||||
|
|
||||||
await saveHandler!({}, { preferredGamepadId: 12 });
|
await assert.rejects(
|
||||||
|
async () => {
|
||||||
|
await saveHandler!({}, { preferredGamepadId: 12 });
|
||||||
|
},
|
||||||
|
/Invalid controller preference payload/,
|
||||||
|
);
|
||||||
await saveHandler!({}, {
|
await saveHandler!({}, {
|
||||||
preferredGamepadId: 'pad-1',
|
preferredGamepadId: 'pad-1',
|
||||||
preferredGamepadLabel: 'Pad 1',
|
preferredGamepadLabel: 'Pad 1',
|
||||||
@@ -480,3 +485,95 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('registerIpcHandlers rejects malformed controller preference payloads', async () => {
|
||||||
|
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||||
|
registerIpcHandlers(
|
||||||
|
{
|
||||||
|
onOverlayModalClosed: () => {},
|
||||||
|
openYomitanSettings: () => {},
|
||||||
|
quitApp: () => {},
|
||||||
|
toggleDevTools: () => {},
|
||||||
|
getVisibleOverlayVisibility: () => false,
|
||||||
|
toggleVisibleOverlay: () => {},
|
||||||
|
tokenizeCurrentSubtitle: async () => null,
|
||||||
|
getCurrentSubtitleRaw: () => '',
|
||||||
|
getCurrentSubtitleAss: () => '',
|
||||||
|
getPlaybackPaused: () => false,
|
||||||
|
getSubtitlePosition: () => null,
|
||||||
|
getSubtitleStyle: () => null,
|
||||||
|
saveSubtitlePosition: () => {},
|
||||||
|
getMecabStatus: () => ({ available: false, enabled: false, path: null }),
|
||||||
|
setMecabEnabled: () => {},
|
||||||
|
handleMpvCommand: () => {},
|
||||||
|
getKeybindings: () => [],
|
||||||
|
getConfiguredShortcuts: () => ({}),
|
||||||
|
getControllerConfig: () => ({
|
||||||
|
enabled: true,
|
||||||
|
preferredGamepadId: '',
|
||||||
|
preferredGamepadLabel: '',
|
||||||
|
smoothScroll: true,
|
||||||
|
scrollPixelsPerSecond: 960,
|
||||||
|
horizontalJumpPixels: 160,
|
||||||
|
stickDeadzone: 0.2,
|
||||||
|
triggerInputMode: 'auto',
|
||||||
|
triggerDeadzone: 0.5,
|
||||||
|
repeatDelayMs: 220,
|
||||||
|
repeatIntervalMs: 80,
|
||||||
|
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: 'buttonSouth',
|
||||||
|
closeLookup: 'buttonEast',
|
||||||
|
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||||
|
mineCard: 'buttonWest',
|
||||||
|
quitMpv: 'select',
|
||||||
|
previousAudio: 'leftShoulder',
|
||||||
|
nextAudio: 'rightShoulder',
|
||||||
|
playCurrentAudio: 'rightTrigger',
|
||||||
|
toggleMpvPause: 'leftTrigger',
|
||||||
|
leftStickHorizontal: 'leftStickX',
|
||||||
|
leftStickVertical: 'leftStickY',
|
||||||
|
rightStickHorizontal: 'rightStickX',
|
||||||
|
rightStickVertical: 'rightStickY',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
saveControllerPreference: async () => {},
|
||||||
|
getSecondarySubMode: () => 'hover',
|
||||||
|
getCurrentSecondarySub: () => '',
|
||||||
|
focusMainWindow: () => {},
|
||||||
|
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
|
||||||
|
getAnkiConnectStatus: () => false,
|
||||||
|
getRuntimeOptions: () => [],
|
||||||
|
setRuntimeOption: () => ({ ok: true }),
|
||||||
|
cycleRuntimeOption: () => ({ ok: true }),
|
||||||
|
reportOverlayContentBounds: () => {},
|
||||||
|
getAnilistStatus: () => ({}),
|
||||||
|
clearAnilistToken: () => {},
|
||||||
|
openAnilistSetup: () => {},
|
||||||
|
getAnilistQueueStatus: () => ({}),
|
||||||
|
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||||
|
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
||||||
|
},
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerPreference);
|
||||||
|
await assert.rejects(
|
||||||
|
async () => {
|
||||||
|
await saveHandler!({}, { preferredGamepadId: 12 });
|
||||||
|
},
|
||||||
|
/Invalid controller preference payload/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -267,7 +267,9 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
|
|
||||||
ipc.handle(IPC_CHANNELS.command.saveControllerPreference, async (_event: unknown, update: unknown) => {
|
ipc.handle(IPC_CHANNELS.command.saveControllerPreference, async (_event: unknown, update: unknown) => {
|
||||||
const parsedUpdate = parseControllerPreferenceUpdate(update);
|
const parsedUpdate = parseControllerPreferenceUpdate(update);
|
||||||
if (!parsedUpdate) return;
|
if (!parsedUpdate) {
|
||||||
|
throw new Error('Invalid controller preference payload');
|
||||||
|
}
|
||||||
await deps.saveControllerPreference(parsedUpdate);
|
await deps.saveControllerPreference(parsedUpdate);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -208,6 +208,55 @@ test('gamepad controller does not toggle keyboard mode when controller support i
|
|||||||
assert.deepEqual(calls, []);
|
assert.deepEqual(calls, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('gamepad controller does not treat blocked held inputs as fresh edges when interaction resumes', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const selectionCalls: number[] = [];
|
||||||
|
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||||
|
buttons[0] = { value: 1, pressed: true, touched: true };
|
||||||
|
let axes = [0.9, 0, 0, 0];
|
||||||
|
let keyboardModeEnabled = true;
|
||||||
|
let interactionBlocked = true;
|
||||||
|
|
||||||
|
const controller = createGamepadController({
|
||||||
|
getGamepads: () => [createGamepad('pad-1', { buttons, axes })],
|
||||||
|
getConfig: () => createControllerConfig(),
|
||||||
|
getKeyboardModeEnabled: () => keyboardModeEnabled,
|
||||||
|
getLookupWindowOpen: () => false,
|
||||||
|
getInteractionBlocked: () => interactionBlocked,
|
||||||
|
toggleKeyboardMode: () => {},
|
||||||
|
toggleLookup: () => calls.push('toggle-lookup'),
|
||||||
|
closeLookup: () => {},
|
||||||
|
moveSelection: (delta) => selectionCalls.push(delta),
|
||||||
|
mineCard: () => {},
|
||||||
|
quitMpv: () => {},
|
||||||
|
previousAudio: () => {},
|
||||||
|
nextAudio: () => {},
|
||||||
|
playCurrentAudio: () => {},
|
||||||
|
toggleMpvPause: () => {},
|
||||||
|
scrollPopup: () => {},
|
||||||
|
jumpPopup: () => {},
|
||||||
|
onState: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.poll(0);
|
||||||
|
interactionBlocked = false;
|
||||||
|
controller.poll(100);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
assert.deepEqual(selectionCalls, []);
|
||||||
|
|
||||||
|
buttons[0] = { value: 0, pressed: false, touched: false };
|
||||||
|
axes = [0, 0, 0, 0];
|
||||||
|
controller.poll(200);
|
||||||
|
|
||||||
|
buttons[0] = { value: 1, pressed: true, touched: true };
|
||||||
|
axes = [0.9, 0, 0, 0];
|
||||||
|
controller.poll(300);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['toggle-lookup']);
|
||||||
|
assert.deepEqual(selectionCalls, [1]);
|
||||||
|
});
|
||||||
|
|
||||||
test('gamepad controller maps left stick horizontal movement to token selection repeats', () => {
|
test('gamepad controller maps left stick horizontal movement to token selection repeats', () => {
|
||||||
const calls: number[] = [];
|
const calls: number[] = [];
|
||||||
let axes = [0.9, 0, 0, 0];
|
let axes = [0.9, 0, 0, 0];
|
||||||
|
|||||||
@@ -226,6 +226,24 @@ function resetHeldAction(state: HoldState): void {
|
|||||||
state.initialFired = false;
|
state.initialFired = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncHeldActionBlocked(
|
||||||
|
state: HoldState,
|
||||||
|
value: number,
|
||||||
|
now: number,
|
||||||
|
activationThreshold: number,
|
||||||
|
): void {
|
||||||
|
if (Math.abs(value) < activationThreshold) {
|
||||||
|
resetHeldAction(state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const direction = value > 0 ? 1 : -1;
|
||||||
|
state.repeatStarted = false;
|
||||||
|
state.direction = direction;
|
||||||
|
state.lastFireAt = now;
|
||||||
|
state.initialFired = true;
|
||||||
|
}
|
||||||
|
|
||||||
export function createGamepadController(options: GamepadControllerOptions) {
|
export function createGamepadController(options: GamepadControllerOptions) {
|
||||||
let previousButtons = new Map<ControllerButtonBinding, boolean>();
|
let previousButtons = new Map<ControllerButtonBinding, boolean>();
|
||||||
let selectionHold = createHoldState();
|
let selectionHold = createHoldState();
|
||||||
@@ -331,6 +349,58 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncBlockedInteractionState(
|
||||||
|
activeGamepad: GamepadLike,
|
||||||
|
config: ResolvedControllerConfig,
|
||||||
|
now: number,
|
||||||
|
): void {
|
||||||
|
const buttonBindings = new Set<ControllerButtonBinding>([
|
||||||
|
config.bindings.toggleKeyboardOnlyMode,
|
||||||
|
config.bindings.toggleLookup,
|
||||||
|
config.bindings.closeLookup,
|
||||||
|
config.bindings.mineCard,
|
||||||
|
config.bindings.quitMpv,
|
||||||
|
config.bindings.previousAudio,
|
||||||
|
config.bindings.nextAudio,
|
||||||
|
config.bindings.playCurrentAudio,
|
||||||
|
config.bindings.toggleMpvPause,
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const binding of buttonBindings) {
|
||||||
|
if (binding === 'none') continue;
|
||||||
|
previousButtons.set(
|
||||||
|
binding,
|
||||||
|
normalizeButtonState(
|
||||||
|
activeGamepad,
|
||||||
|
config,
|
||||||
|
binding,
|
||||||
|
config.triggerInputMode,
|
||||||
|
config.triggerDeadzone,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectionValue = (() => {
|
||||||
|
const axisValue = resolveAxisValue(activeGamepad, config.bindings.leftStickHorizontal);
|
||||||
|
if (Math.abs(axisValue) >= Math.max(config.stickDeadzone, 0.55)) {
|
||||||
|
return axisValue;
|
||||||
|
}
|
||||||
|
return resolveDpadHorizontalValue(activeGamepad, config.triggerDeadzone);
|
||||||
|
})();
|
||||||
|
syncHeldActionBlocked(selectionHold, selectionValue, now, Math.max(config.stickDeadzone, 0.55));
|
||||||
|
|
||||||
|
if (options.getLookupWindowOpen()) {
|
||||||
|
syncHeldActionBlocked(
|
||||||
|
jumpHold,
|
||||||
|
resolveAxisValue(activeGamepad, config.bindings.rightStickVertical),
|
||||||
|
now,
|
||||||
|
Math.max(config.stickDeadzone, 0.55),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
resetHeldAction(jumpHold);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function poll(now: number): void {
|
function poll(now: number): void {
|
||||||
const elapsedMs = lastPollAt === null ? 0 : Math.max(now - lastPollAt, 0);
|
const elapsedMs = lastPollAt === null ? 0 : Math.max(now - lastPollAt, 0);
|
||||||
lastPollAt = now;
|
lastPollAt = now;
|
||||||
@@ -365,6 +435,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!interactionAllowed) {
|
if (!interactionAllowed) {
|
||||||
|
syncBlockedInteractionState(activeGamepad, config, now);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -744,6 +744,34 @@ test('keyboard mode: closing lookup clears yomitan active text source so same to
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: lookup toggle closes popup when DOM visibility is the source of truth', async () => {
|
||||||
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
|
||||||
|
ctx.state.keyboardSelectedWordIndex = 1;
|
||||||
|
handlers.syncKeyboardTokenSelection();
|
||||||
|
ctx.state.yomitanPopupVisible = false;
|
||||||
|
testGlobals.setPopupVisible(true);
|
||||||
|
|
||||||
|
handlers.handleLookupWindowToggleRequested();
|
||||||
|
await wait(0);
|
||||||
|
|
||||||
|
const closeCommands = testGlobals.commandEvents.filter(
|
||||||
|
(event) => event.type === 'setVisible' || event.type === 'clearActiveTextSource',
|
||||||
|
);
|
||||||
|
assert.deepEqual(closeCommands.slice(-2), [
|
||||||
|
{ type: 'setVisible', visible: false },
|
||||||
|
{ type: 'clearActiveTextSource' },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('keyboard mode: moving right beyond end jumps next subtitle and resets selector to start', async () => {
|
test('keyboard mode: moving right beyond end jumps next subtitle and resets selector to start', async () => {
|
||||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
|||||||
@@ -435,15 +435,8 @@ export function createKeyboardHandlers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleLookupWindowToggleRequested(): void {
|
function handleLookupWindowToggleRequested(): void {
|
||||||
if (ctx.state.yomitanPopupVisible) {
|
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
||||||
dispatchYomitanPopupVisibility(false);
|
closeLookupWindow();
|
||||||
dispatchYomitanFrontendClearActiveTextSource();
|
|
||||||
clearNativeSubtitleSelection();
|
|
||||||
if (ctx.state.keyboardDrivenModeEnabled) {
|
|
||||||
queueMicrotask(() => {
|
|
||||||
restoreOverlayKeyboardFocus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
triggerLookupForSelectedWord();
|
triggerLookupForSelectedWord();
|
||||||
|
|||||||
@@ -496,6 +496,120 @@ test('controller select modal preserves saved status across polling updates', as
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('controller select modal surfaces save errors without mutating saved preference', async () => {
|
||||||
|
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||||
|
const previousWindow = globals.window;
|
||||||
|
const previousDocument = globals.document;
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
focus: () => {},
|
||||||
|
electronAPI: {
|
||||||
|
saveControllerPreference: async () => {
|
||||||
|
throw new Error('disk write failed');
|
||||||
|
},
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
createElement: () => ({
|
||||||
|
className: '',
|
||||||
|
textContent: '',
|
||||||
|
classList: createClassList(),
|
||||||
|
appendChild: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = createRendererState();
|
||||||
|
state.controllerConfig = {
|
||||||
|
enabled: true,
|
||||||
|
preferredGamepadId: 'pad-1',
|
||||||
|
preferredGamepadLabel: 'pad-1',
|
||||||
|
smoothScroll: true,
|
||||||
|
scrollPixelsPerSecond: 960,
|
||||||
|
horizontalJumpPixels: 160,
|
||||||
|
stickDeadzone: 0.2,
|
||||||
|
triggerInputMode: 'auto',
|
||||||
|
triggerDeadzone: 0.5,
|
||||||
|
repeatDelayMs: 220,
|
||||||
|
repeatIntervalMs: 80,
|
||||||
|
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: 'buttonSouth',
|
||||||
|
closeLookup: 'buttonEast',
|
||||||
|
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||||
|
mineCard: 'buttonWest',
|
||||||
|
quitMpv: 'select',
|
||||||
|
previousAudio: 'none',
|
||||||
|
nextAudio: 'rightShoulder',
|
||||||
|
playCurrentAudio: 'leftShoulder',
|
||||||
|
toggleMpvPause: 'leftStickPress',
|
||||||
|
leftStickHorizontal: 'leftStickX',
|
||||||
|
leftStickVertical: 'leftStickY',
|
||||||
|
rightStickHorizontal: 'rightStickX',
|
||||||
|
rightStickVertical: 'rightStickY',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
state.connectedGamepads = [{ id: 'pad-2', index: 1, mapping: 'standard', connected: true }];
|
||||||
|
state.activeGamepadId = 'pad-2';
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
dom: {
|
||||||
|
overlay: { classList: createClassList(), focus: () => {} },
|
||||||
|
controllerSelectModal: {
|
||||||
|
classList: createClassList(['hidden']),
|
||||||
|
setAttribute: () => {},
|
||||||
|
},
|
||||||
|
controllerSelectClose: { addEventListener: () => {} },
|
||||||
|
controllerSelectHint: { textContent: '' },
|
||||||
|
controllerSelectStatus: { textContent: '', classList: createClassList() },
|
||||||
|
controllerSelectList: {
|
||||||
|
innerHTML: '',
|
||||||
|
appendChild: () => {},
|
||||||
|
},
|
||||||
|
controllerSelectSave: { addEventListener: () => {} },
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
|
||||||
|
const modal = createControllerSelectModal(ctx as never, {
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.openControllerSelectModal();
|
||||||
|
await modal.handleControllerSelectKeydown({
|
||||||
|
key: 'Enter',
|
||||||
|
preventDefault: () => {},
|
||||||
|
} as KeyboardEvent);
|
||||||
|
|
||||||
|
assert.match(ctx.dom.controllerSelectStatus.textContent, /Failed to save preferred controller/);
|
||||||
|
assert.equal(state.controllerConfig.preferredGamepadId, 'pad-1');
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('controller select modal does not rerender unchanged device snapshots every poll', () => {
|
test('controller select modal does not rerender unchanged device snapshots every poll', () => {
|
||||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||||
const previousWindow = globals.window;
|
const previousWindow = globals.window;
|
||||||
|
|||||||
@@ -154,10 +154,16 @@ export function createControllerSelectModal(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await window.electronAPI.saveControllerPreference({
|
try {
|
||||||
preferredGamepadId: selected.id,
|
await window.electronAPI.saveControllerPreference({
|
||||||
preferredGamepadLabel: selected.id,
|
preferredGamepadId: selected.id,
|
||||||
});
|
preferredGamepadLabel: selected.id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
setStatus(`Failed to save preferred controller: ${message}`, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (ctx.state.controllerConfig) {
|
if (ctx.state.controllerConfig) {
|
||||||
ctx.state.controllerConfig.preferredGamepadId = selected.id;
|
ctx.state.controllerConfig.preferredGamepadId = selected.id;
|
||||||
|
|||||||
2
vendor/subminer-yomitan
vendored
2
vendor/subminer-yomitan
vendored
Submodule vendor/subminer-yomitan updated: da2ba5ceb1...994ab578ac
Reference in New Issue
Block a user