fix(controller): show dialog hints only while a controller is connected

- Add createControllerHintVisibility: use the dialog window's pad, else ask main which controller the overlay is reading, at most once per second while a dialog is open
- Hide shortcut tags when controller support is on but no controller is present
- Update the Anki integration docs and changelog fragments to match
This commit is contained in:
sudacode committed 2026-11-05 00:54:25 -08:00
1 parent 5140b5e9be
commit 79e5535823
7 files changed
+135 -8

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
type: added
area: overlay
- The duplicate card (field grouping) dialog has a controller layout: the D-pad picks the original or new card, `A` continues and confirms the merge, `B` cancels or goes back from the preview, `X` turns "Delete duplicate" on or off, `Y` switches Compact / Full, and the D-pad scrolls the preview. Each control shows its shortcut, and the layout can be changed per controller in the new **Duplicate card (field grouping)** group of `Alt+C`.
- The duplicate card (field grouping) dialog has a controller layout: the D-pad picks the original or new card, `A` continues and confirms the merge, `B` cancels or goes back from the preview, `X` turns "Delete duplicate" on or off, `Y` switches Compact / Full, and the D-pad scrolls the preview. Each control shows its shortcut while a controller is connected, and the layout can be changed per controller in the new **Duplicate card (field grouping)** group of `Alt+C`.
+1 -1
View File
@@ -1,5 +1,5 @@
type: added
area: overlay
- Added a controller layout for the media timing review dialog covering every button, with L1 / R1 + D-pad combos for the clip start and end, and D-pad actions that repeat while held. Each button shows its controller shortcut while controller support is on, and the first press in a newly opened dialog already counts. The controller's usual actions pause while the dialog is open, including in the hidden subtitle overlay behind it.
- Added a controller layout for the media timing review dialog covering every button, with L1 / R1 + D-pad combos for the clip start and end, and D-pad actions that repeat while held. Each button shows its controller shortcut while a controller is connected, and the first press in a newly opened dialog already counts. The controller's usual actions pause while the dialog is open, including in the hidden subtitle overlay behind it.
- Added `controller.faceButtonLabels` (`xbox` or `nintendo`) so fixed controller layouts and the `Alt+C` button names follow the letters printed on the pad. Use `nintendo` for controllers with A on the right and B at the bottom.
+2 -2
View File
@@ -162,7 +162,7 @@ When you cancel, you can go back to editing, keep the original timing, create th
#### With a controller
With [controller support](/usage#controller-support) on, the dialog has its own controller layout, and each button shows its controller shortcut. It works with or without keyboard-only mode, and the controller's usual actions pause until the dialog closes. Buttons that were already held when the dialog opened, such as the mine button, wait for a fresh press.
With [controller support](/usage#controller-support) on, the dialog has its own controller layout, and each button shows its controller shortcut once SubMiner has seen a controller, which takes one button press after connecting it. It works with or without keyboard-only mode, and the controller's usual actions pause until the dialog closes. Buttons that were already held when the dialog opened, such as the mine button, wait for a fresh press.
`A`, `B`, `X` and `Y` mean the letters printed on your controller. Pick your controller's **Button Style** in `Alt+C`: choose Nintendo if A is on the right and B at the bottom (Switch-style and many 8BitDo pads). On PlayStation, `A` is ╳, `B` ○, `X` □ and `Y` △, and the shortcuts on screen show those symbols.
@@ -267,7 +267,7 @@ The manual dialog cancels itself after 90 seconds. Identical entries are not ded
### With a controller {#field-grouping-with-a-controller}
With [controller support](/usage#controller-support) on, the dialog has its own controller layout, and each control shows its controller shortcut. Like the [media timing review](#with-a-controller), it works with or without keyboard-only mode, `A`/`B`/`X`/`Y` are the printed letters (set by **Button Style** in `Alt+C`), and a button already held when the dialog opens waits for a fresh press.
With [controller support](/usage#controller-support) on, the dialog has its own controller layout, and each control shows its controller shortcut once SubMiner has seen a controller. Like the [media timing review](#with-a-controller), it works with or without keyboard-only mode, `A`/`B`/`X`/`Y` are the printed letters (set by **Button Style** in `Alt+C`), and a button already held when the dialog opens waits for a fresh press.
| Controller | Choosing the card | Merge preview |
| ------------------ | --------------------------------- | ----------------------- |
@@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createControllerHintVisibility } from './controller-hint-visibility.js';
function createHarness(answers: Array<string | null | Error>) {
const pending = [...answers];
let requests = 0;
const visibility = createControllerHintVisibility({
getOverlayControllerId: async () => {
requests += 1;
const answer = pending.shift() ?? null;
if (answer instanceof Error) throw answer;
return answer;
},
});
const frame = async (now: number, activeGamepadId: string | null = null) => {
const visible = visibility.update({ enabled: true, dialogOpen: true, activeGamepadId, now });
await new Promise((resolve) => setImmediate(resolve));
return visible;
};
return { visibility, frame, getRequests: () => requests };
}
test('hints stay hidden while no window can see a controller', async () => {
const { frame } = createHarness([null, null]);
assert.equal(await frame(0), false);
assert.equal(await frame(16), false);
assert.equal(await frame(2_000), false);
});
test('a controller the dialog window can see shows hints without asking the overlay', async () => {
const { frame, getRequests } = createHarness([]);
assert.equal(await frame(0, 'pad-1'), true);
assert.equal(getRequests(), 0);
});
test('a fresh dialog window shows hints for the controller the overlay reports', async () => {
const { frame } = createHarness(['pad-1']);
assert.equal(await frame(0), false);
assert.equal(await frame(16), true);
});
test('hints hide on the next refresh after the overlay loses the controller', async () => {
const { frame, getRequests } = createHarness(['pad-1', null]);
await frame(0);
assert.equal(await frame(500), true);
assert.equal(getRequests(), 1);
await frame(1_000);
assert.equal(getRequests(), 2);
assert.equal(await frame(1_016), false);
});
test('a failed request keeps the last answer and retries on the next refresh', async () => {
const { frame, getRequests } = createHarness(['pad-1', new Error('ipc closed'), null]);
await frame(0);
await frame(1_000);
assert.equal(await frame(1_016), true);
await frame(2_000);
assert.equal(getRequests(), 3);
assert.equal(await frame(2_016), false);
});
test('nothing is requested while controller support is off or no dialog is open', () => {
const { visibility, getRequests } = createHarness([]);
assert.equal(
visibility.update({ enabled: false, dialogOpen: true, activeGamepadId: 'pad-1', now: 0 }),
false,
);
visibility.update({ enabled: true, dialogOpen: false, activeGamepadId: null, now: 0 });
assert.equal(getRequests(), 0);
});
@@ -0,0 +1,40 @@
const OVERLAY_CONTROLLER_REFRESH_MS = 1_000;
/**
* Decides whether dialogs show their controller shortcut tags. A fresh dialog window cannot
* see a pad until a button is pressed in it, so an open dialog also asks main which controller
* the overlay is reading. Called every frame; requests are limited to one per refresh interval.
*/
export function createControllerHintVisibility(options: {
getOverlayControllerId: () => Promise<string | null>;
}) {
let overlayHasController = false;
let nextRefreshAt = Number.NEGATIVE_INFINITY;
function refreshOverlayController(now: number): void {
if (now < nextRefreshAt) return;
nextRefreshAt = now + OVERLAY_CONTROLLER_REFRESH_MS;
options.getOverlayControllerId().then(
(gamepadId) => {
overlayHasController = gamepadId !== null;
},
() => {
// Keep the last answer; the next refresh tries again.
},
);
}
return {
update(state: {
enabled: boolean;
dialogOpen: boolean;
activeGamepadId: string | null;
now: number;
}): boolean {
if (!state.enabled) return false;
if (state.activeGamepadId !== null) return true;
if (state.dialogOpen) refreshOverlayController(state.now);
return overlayHasController;
},
};
}
+10 -3
View File
@@ -29,6 +29,7 @@ import type {
import type { OverlayHostedModal } from '../shared/ipc/contracts';
import { createKeyboardHandlers } from './handlers/keyboard.js';
import { createGamepadController } from './handlers/gamepad-controller.js';
import { createControllerHintVisibility } from './controller-hint-visibility.js';
import { createControllerPressReplay } from './handlers/controller-press-replay.js';
import { createMouseHandlers } from './handlers/mouse.js';
import { createControllerStatusIndicator } from './controller-status-indicator.js';
@@ -398,6 +399,9 @@ let lastSecondarySubtitlePreview = '';
let overlayErrorToastTimeout: ReturnType<typeof setTimeout> | null = null;
let controllerAnimationFrameId: number | null = null;
const controllerPressReplay = createControllerPressReplay();
const controllerHintVisibility = createControllerHintVisibility({
getOverlayControllerId: () => window.electronAPI.getActiveControllerId(),
});
function truncateForErrorLog(text: string): string {
const normalized = text.replace(/\s+/g, ' ').trim();
@@ -453,10 +457,13 @@ function applyControllerSnapshot(snapshot: {
ctx.state.controllerRawButtons = snapshot.rawButtons;
controllerSelectModal.updateDevices();
controllerDebugModal.updateSnapshot();
// Shown whenever controller support is on: a fresh dialog window cannot see the pad
// until its first press, so "connected" would hide the hints exactly when needed.
const controllerConfig = ctx.state.controllerConfig;
const hintsVisible = controllerConfig?.enabled === true;
const hintsVisible = controllerHintVisibility.update({
enabled: controllerConfig?.enabled === true,
dialogOpen: ctx.state.mediaTimingReviewModalOpen || ctx.state.kikuModalOpen,
activeGamepadId: snapshot.activeGamepadId,
now: performance.now(),
});
mediaTimingReviewModal.setControllerHintsVisible(hintsVisible);
kikuModal.setControllerHintsVisible(hintsVisible);
if (
+1 -1
View File
@@ -2045,7 +2045,7 @@ body:focus-visible,
text-align: center;
}
/* Controller hints: shown beside each control while controller support is on. */
/* Controller hints: shown beside each control while a controller is connected. */
.media-timing-review-modal.controller-hints-visible button[data-controller-hint]::after,
#kikuFieldGroupingModal.controller-hints-visible [data-controller-hint]::after {
content: attr(data-controller-hint);