From 79e5535823f936aaeec3176019a3c63e31215d46 Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 5 Nov 2026 00:54:25 -0800 Subject: [PATCH] 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 --- changes/controller-field-grouping.md | 2 +- changes/controller-media-timing-review.md | 2 +- docs-site/anki-integration.md | 4 +- .../controller-hint-visibility.test.ts | 80 +++++++++++++++++++ src/renderer/controller-hint-visibility.ts | 40 ++++++++++ src/renderer/renderer.ts | 13 ++- src/renderer/style.css | 2 +- 7 files changed, 135 insertions(+), 8 deletions(-) create mode 100644 src/renderer/controller-hint-visibility.test.ts create mode 100644 src/renderer/controller-hint-visibility.ts diff --git a/changes/controller-field-grouping.md b/changes/controller-field-grouping.md index f6fe4f2d..6a5bb407 100644 --- a/changes/controller-field-grouping.md +++ b/changes/controller-field-grouping.md @@ -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`. diff --git a/changes/controller-media-timing-review.md b/changes/controller-media-timing-review.md index b40278ef..e71b6f4e 100644 --- a/changes/controller-media-timing-review.md +++ b/changes/controller-media-timing-review.md @@ -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. diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index fffc45ef..0462358b 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -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 | | ------------------ | --------------------------------- | ----------------------- | diff --git a/src/renderer/controller-hint-visibility.test.ts b/src/renderer/controller-hint-visibility.test.ts new file mode 100644 index 00000000..5db50465 --- /dev/null +++ b/src/renderer/controller-hint-visibility.test.ts @@ -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) { + 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); +}); diff --git a/src/renderer/controller-hint-visibility.ts b/src/renderer/controller-hint-visibility.ts new file mode 100644 index 00000000..bef02349 --- /dev/null +++ b/src/renderer/controller-hint-visibility.ts @@ -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; +}) { + 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; + }, + }; +} diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 6bb052ee..b8208a47 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -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 | 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 ( diff --git a/src/renderer/style.css b/src/renderer/style.css index bad1856c..273c8099 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -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);