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:
2026-03-11 19:11:18 -07:00
parent 90b312ef69
commit f23e2f019e
15 changed files with 421 additions and 30 deletions

View File

@@ -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

View File

@@ -1,4 +0,0 @@
type: internal
area: config
- add an enforced `verify:config-example` gate so checked-in example config artifacts cannot drift silently

View File

@@ -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.

View File

@@ -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",

View File

@@ -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, [

View File

@@ -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}`,

View File

@@ -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/,
);
});

View File

@@ -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);
}); });

View File

@@ -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];

View File

@@ -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;
} }

View File

@@ -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();

View File

@@ -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();

View File

@@ -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;

View File

@@ -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;