Compare commits

...
3 Commits
Author SHA1 Message Date
sudacode 79e5535823 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
2026-11-05 00:54:25 -08:00
sudacode 5140b5e9be test(overlay): pin an X11 session for Linux assembly tests
Fullscreen recreation only runs outside Hyprland and Sway, and the tests
inherited the running session's compositor variables, so they failed when run
from a Hyprland desktop.
2026-11-04 23:31:11 -08:00
sudacode c2dac537da fix(overlay): keep popup pause working with Hachidori 0.4.0
Hachidori 0.4.0 closed its popup shadow root, so isHachidoriPopupOpen always
returned false. The popup never took over the subtitle hover pause, and moving
off the subtitle resumed playback with the popup open. Popup key and controller
routing read the same check. The sidebar kept working because its hold follows
the attention event instead of the pane.

The fork now publishes the pane state on the host element, and the renderer
reads that attribute instead of upstream's shadow root and CSS class. The unit
stubs modelled an open root and hid this, so test:dictionary:electron now drives
the real reader and asserts the attribute follows the pane.
2026-11-04 22:46:25 -08:00
13 changed files with 246 additions and 38 deletions

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 |
| ------------------ | --------------------------------- | ----------------------- |
+56
View File
@@ -8,6 +8,9 @@ const http = require('node:http');
const root = path.resolve(__dirname, '..');
const parser = require(path.join(root, 'dist/core/services/tokenizer/yomitan-parser-runtime.js'));
const { writeStoredZip } = require(path.join(root, 'dist/shared/stored-zip.js'));
const { HACHIDORI_POPUP_OPEN_SELECTOR } = require(
path.join(root, 'dist/renderer/yomitan-popup.js'),
);
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-hachidori-parser-'));
protocol.registerSchemesAsPrivileged([
@@ -43,6 +46,58 @@ function fixture(name, title, bankName, entries, frequencyMode = 'rank-based') {
return zipPath;
}
// The renderer's popup auto-pause and key routing read this marker because the
// reader's shadow root is closed. Drives the real reader with real input, since
// stubbed DOM tests cannot notice an upstream merge that changes what it exposes.
async function assertPopupOpenMarkerFollowsPane(targetSession) {
const server = http.createServer((_request, response) => {
response.setHeader('Content-Type', 'text/html; charset=utf-8');
response.end(
'<!doctype html><p id="word" style="font-size:48px;margin:300px 0 0 100px">食べた</p>',
);
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const page = new BrowserWindow({
width: 900,
height: 600,
show: true,
webPreferences: { session: targetSession, contextIsolation: true, nodeIntegration: false },
});
try {
await page.loadURL(`http://127.0.0.1:${server.address().port}/`);
const { webContents } = page;
const word = await webContents.executeJavaScript(
`(() => { const { x, y, height } = document.getElementById('word').getBoundingClientRect(); return { x, y, height }; })()`,
);
const repeatUntilMarker = async (expected, act) => {
const query = `document.querySelector(${JSON.stringify(HACHIDORI_POPUP_OPEN_SELECTOR)}) !== null`;
for (let attempt = 0; attempt < 100; attempt += 1) {
// Content scripts load asynchronously, so keep acting until the reader reacts.
act(attempt);
await new Promise((resolve) => setTimeout(resolve, 100));
if ((await webContents.executeJavaScript(query)) === expected) return;
}
assert.fail(`Hachidori popup-open marker never became ${expected}`);
};
page.focus();
webContents.focus();
await repeatUntilMarker(true, (attempt) =>
webContents.sendInputEvent({
type: 'mouseMove',
x: Math.round(word.x + 24) + (attempt % 2),
y: Math.round(word.y + word.height / 2),
}),
);
await repeatUntilMarker(false, () => {
webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' });
});
} finally {
page.destroy();
await new Promise((resolve) => server.close(resolve));
}
}
app
.whenReady()
.then(async () => {
@@ -211,6 +266,7 @@ app
assert.deepEqual(unmatched, []);
assert.equal(await parser.getYomitanCurrentAnkiDeckName(deps, logger), 'Test Mining');
assert.equal((await targetSession.extensions.getAllExtensions()).length, 1);
await assertPopupOpenMarkerFollowsPane(targetSession);
assert.equal(
await parser.syncYomitanDefaultAnkiServer('http://127.0.0.1:8765', deps, logger),
true,
+16
View File
@@ -157,6 +157,22 @@ function setup(t: TestContext, options: { platform?: NodeJS.Platform; visible?:
Object.defineProperty(process, 'platform', { value: options.platform, configurable: true });
t.after(() => Object.defineProperty(process, 'platform', { value: originalPlatform }));
}
if (options.platform === 'linux') {
// Fullscreen recreation only runs outside Hyprland and Sway, so pin an X11
// session rather than inheriting the compositor the tests happen to run under.
for (const key of [
'HYPRLAND_INSTANCE_SIGNATURE',
'SWAYSOCK',
'XDG_CURRENT_DESKTOP',
'XDG_SESSION_DESKTOP',
]) {
const original = process.env[key];
delete process.env[key];
t.after(() => {
if (original !== undefined) process.env[key] = original;
});
}
}
const recorder = createRecorder();
const state: FixtureState = {
visible: options.visible ?? false,
@@ -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;
},
};
}
+3 -6
View File
@@ -3,7 +3,7 @@ import test from 'node:test';
import { createKeyboardHandlers } from './keyboard.js';
import { createRendererState } from '../state.js';
import { HACHIDORI_HOST_SELECTOR } from '../yomitan-popup.js';
import { HACHIDORI_HOST_SELECTOR, HACHIDORI_POPUP_OPEN_SELECTOR } from '../yomitan-popup.js';
import type { CompiledSessionBinding } from '../../types';
import type { MpvInputBindingsSnapshot } from '../../types/session-bindings';
import { DEFAULT_KEYBINDINGS, SPECIAL_COMMANDS } from '../../config/definitions';
@@ -156,9 +156,6 @@ function installKeyboardTestGlobals() {
let popupVisible = false;
let hachidoriPaneOpen = false;
const hachidoriHost = {
shadowRoot: { querySelectorAll: () => [{ hidden: false }] },
};
const popupIframe = {
tagName: 'IFRAME',
@@ -293,8 +290,8 @@ function installKeyboardTestGlobals() {
documentListeners.set(type, listeners);
},
querySelectorAll: (selector: string) => {
if (selector === HACHIDORI_HOST_SELECTOR) {
return hachidoriPaneOpen ? [hachidoriHost] : [];
if (selector === HACHIDORI_POPUP_OPEN_SELECTOR) {
return hachidoriPaneOpen ? [{}] : [];
}
if (popupVisible) {
return [popupIframe];
+30 -15
View File
@@ -5,9 +5,8 @@ import { SUBTITLE_DEFAULT_CONFIG } from '../../config/definitions/defaults-subti
import { createRendererState } from '../state';
import { createMouseHandlers } from './mouse.js';
import {
HACHIDORI_HOST_SELECTOR,
HACHIDORI_POPUP_HIDDEN_EVENT,
HACHIDORI_POPUP_SELECTOR,
HACHIDORI_POPUP_OPEN_SELECTOR,
HACHIDORI_POPUP_SHOWN_EVENT,
YOMITAN_LOOKUP_EVENT,
YOMITAN_POPUP_HIDDEN_EVENT,
@@ -110,23 +109,11 @@ function createMouseHarness(
};
const ignoreCalls: IgnoreCall[] = [];
const mpvCommands: Array<(string | number)[]> = [];
const popupPane = {
get hidden() {
return !harness.popupPaneOpen;
},
};
const popupHost = {
tagName: options.hachidori ? 'HACHIDORI-HOST' : 'DIV',
getAttribute: (name: string) =>
name === 'data-subminer-yomitan-popup-visible' ? String(harness.popupHostVisible) : null,
shadowRoot: {
querySelectorAll: (selector: string) =>
selector === HACHIDORI_POPUP_SELECTOR ? [popupPane] : [],
},
};
const hostSelectors = options.hachidori
? [HACHIDORI_HOST_SELECTOR, YOMITAN_POPUP_HOST_SELECTOR]
: [YOMITAN_POPUP_HOST_SELECTOR];
const harness = {
ctx,
@@ -183,7 +170,10 @@ function createMouseHarness(
if (selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR) {
return harness.popupHostVisible ? [popupHost] : [];
}
return hostSelectors.includes(selector) &&
if (selector === HACHIDORI_POPUP_OPEN_SELECTOR) {
return harness.popupPaneOpen ? [popupHost] : [];
}
return selector === YOMITAN_POPUP_HOST_SELECTOR &&
(harness.popupHostVisible || harness.popupHostAttached)
? [popupHost]
: [];
@@ -529,6 +519,31 @@ test('Hachidori hover popup pauses when shown', async (t) => {
assert.deepEqual(h.mpvCommands, [PAUSE]);
});
test('leaving the subtitle keeps playback paused while a Hachidori popup is open', async (t) => {
let paused = false;
const h = createMouseHarness(t, {
hoverPause: true,
popupPause: true,
hachidori: true,
getPlaybackPaused: async () => paused,
});
h.handlers.setupYomitanObserver();
await h.handlers.handleMouseEnter();
paused = true;
h.popupPaneOpen = true;
h.popupHostVisible = true;
h.fire('window', HACHIDORI_POPUP_SHOWN_EVENT);
await waitForNextTick();
await h.handlers.handleMouseLeave();
assert.deepEqual(h.mpvCommands, [PAUSE]);
h.popupPaneOpen = false;
h.popupHostVisible = false;
h.fire('window', HACHIDORI_POPUP_HIDDEN_EVENT);
assert.deepEqual(h.mpvCommands, [PAUSE, RESUME]);
});
test('popup shown reclaims overlay focus on macOS and captures click-away', (t) => {
const h = createMouseHarness(t, { platform: 'macos' });
h.handlers.setupYomitanObserver();
+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);
+5 -8
View File
@@ -12,11 +12,13 @@ export const YOMITAN_LOOKUP_EVENT = 'subminer-yomitan-lookup';
export const PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS = 'primary-sub-visible-on-yomitan-popup';
// Hachidori's shown/hidden pair means "the reader needs mouse events", which
// also covers a left press on subtitle text that may start a selection. Its
// popup panes live in the host's open shadow root.
// popup panes live in a closed shadow root, so the fork's subminer-host.js
// publishes whether one is on screen as an attribute on the host.
export const HACHIDORI_POPUP_SHOWN_EVENT = 'hachidori-popup-shown';
export const HACHIDORI_POPUP_HIDDEN_EVENT = 'hachidori-popup-hidden';
export const HACHIDORI_HOST_SELECTOR = 'hachidori-host';
export const HACHIDORI_POPUP_SELECTOR = '.gsm-hoshidicts-popup';
export const HACHIDORI_POPUP_OPEN_SELECTOR =
'hachidori-host[data-subminer-hachidori-popup-open="true"]';
export type DictionaryReader = 'yomitan' | 'hachidori';
@@ -114,12 +116,7 @@ function queryPopupElements<T extends Element>(
* attaches its host on the first lookup, so no host means no popup.
*/
export function isHachidoriPopupOpen(root: ParentNode | null | undefined = document): boolean {
const hosts = queryPopupElements<HTMLElement>(root, HACHIDORI_HOST_SELECTOR);
return hosts.some((host) =>
queryPopupElements<HTMLElement>(host.shadowRoot, HACHIDORI_POPUP_SELECTOR).some(
(pane) => !pane.hidden,
),
);
return queryPopupElements(root, HACHIDORI_POPUP_OPEN_SELECTOR).length > 0;
}
/**