refactor: remove invisible subtitle overlay code

This commit is contained in:
2026-02-26 16:40:46 -08:00
parent 3c2c8453be
commit a33a87bf8f
119 changed files with 691 additions and 2946 deletions

View File

@@ -4,14 +4,11 @@ export interface CliArgs {
stop: boolean;
toggle: boolean;
toggleVisibleOverlay: boolean;
toggleInvisibleOverlay: boolean;
settings: boolean;
show: boolean;
hide: boolean;
showVisibleOverlay: boolean;
hideVisibleOverlay: boolean;
showInvisibleOverlay: boolean;
hideInvisibleOverlay: boolean;
copySubtitle: boolean;
copySubtitleMultiple: boolean;
mineSentence: boolean;
@@ -67,14 +64,11 @@ export function parseArgs(argv: string[]): CliArgs {
stop: false,
toggle: false,
toggleVisibleOverlay: false,
toggleInvisibleOverlay: false,
settings: false,
show: false,
hide: false,
showVisibleOverlay: false,
hideVisibleOverlay: false,
showInvisibleOverlay: false,
hideInvisibleOverlay: false,
copySubtitle: false,
copySubtitleMultiple: false,
mineSentence: false,
@@ -122,14 +116,11 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--stop') args.stop = true;
else if (arg === '--toggle') args.toggle = true;
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
else if (arg === '--toggle-invisible-overlay') args.toggleInvisibleOverlay = true;
else if (arg === '--settings' || arg === '--yomitan') args.settings = true;
else if (arg === '--show') args.show = true;
else if (arg === '--hide') args.hide = true;
else if (arg === '--show-visible-overlay') args.showVisibleOverlay = true;
else if (arg === '--hide-visible-overlay') args.hideVisibleOverlay = true;
else if (arg === '--show-invisible-overlay') args.showInvisibleOverlay = true;
else if (arg === '--hide-invisible-overlay') args.hideInvisibleOverlay = true;
else if (arg === '--copy-subtitle') args.copySubtitle = true;
else if (arg === '--copy-subtitle-multiple') args.copySubtitleMultiple = true;
else if (arg === '--mine-sentence') args.mineSentence = true;
@@ -263,14 +254,11 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.stop ||
args.toggle ||
args.toggleVisibleOverlay ||
args.toggleInvisibleOverlay ||
args.settings ||
args.show ||
args.hide ||
args.showVisibleOverlay ||
args.hideVisibleOverlay ||
args.showInvisibleOverlay ||
args.hideInvisibleOverlay ||
args.copySubtitle ||
args.copySubtitleMultiple ||
args.mineSentence ||
@@ -307,7 +295,6 @@ export function shouldStartApp(args: CliArgs): boolean {
args.start ||
args.toggle ||
args.toggleVisibleOverlay ||
args.toggleInvisibleOverlay ||
args.copySubtitle ||
args.copySubtitleMultiple ||
args.mineSentence ||
@@ -331,13 +318,10 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
return (
args.toggle ||
args.toggleVisibleOverlay ||
args.toggleInvisibleOverlay ||
args.show ||
args.hide ||
args.showVisibleOverlay ||
args.hideVisibleOverlay ||
args.showInvisibleOverlay ||
args.hideInvisibleOverlay ||
args.copySubtitle ||
args.copySubtitleMultiple ||
args.mineSentence ||

View File

@@ -17,11 +17,8 @@ ${B}Session${R}
${B}Overlay${R}
--toggle-visible-overlay Toggle subtitle overlay
--toggle-invisible-overlay Toggle interactive overlay ${D}(Yomitan lookup)${R}
--show-visible-overlay Show subtitle overlay
--hide-visible-overlay Hide subtitle overlay
--show-invisible-overlay Show interactive overlay
--hide-invisible-overlay Hide interactive overlay
--settings Open Yomitan settings window
--auto-start-overlay Auto-hide mpv subs, show overlay on connect

View File

@@ -27,7 +27,8 @@ test('loads defaults when config is missing', () => {
assert.equal(config.discordPresence.updateIntervalMs, 3_000);
assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)');
assert.equal(config.subtitleStyle.preserveLineBreaks, false);
assert.equal(config.subtitleStyle.hoverTokenColor, '#c6a0f6');
assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, '#363a4fd6');
assert.equal(config.immersionTracking.enabled, true);
assert.equal(config.immersionTracking.dbPath, '');
assert.equal(config.immersionTracking.batchSize, 25);
@@ -136,6 +137,44 @@ test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => {
);
});
test('parses subtitleStyle.hoverTokenBackgroundColor and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{
"subtitleStyle": {
"hoverTokenBackgroundColor": "#363a4fd6"
}
}`,
'utf-8',
);
const validService = new ConfigService(validDir);
assert.equal(validService.getConfig().subtitleStyle.hoverTokenBackgroundColor, '#363a4fd6');
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{
"subtitleStyle": {
"hoverTokenBackgroundColor": true
}
}`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
assert.equal(
invalidService.getConfig().subtitleStyle.hoverTokenBackgroundColor,
DEFAULT_CONFIG.subtitleStyle.hoverTokenBackgroundColor,
);
assert.ok(
invalidService
.getWarnings()
.some((warning) => warning.path === 'subtitleStyle.hoverTokenBackgroundColor'),
);
});
test('parses anilist.enabled and warns for invalid value', () => {
const dir = makeTempDir();
fs.writeFileSync(
@@ -597,19 +636,15 @@ test('warns and ignores unknown top-level config keys', () => {
assert.ok(warnings.some((warning) => warning.path === 'unknownFeatureFlag'));
});
test('parses invisible overlay config and new global shortcuts', () => {
test('parses global shortcuts and startup visibility flags', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"shortcuts": {
"toggleVisibleOverlayGlobal": "Alt+Shift+U",
"toggleInvisibleOverlayGlobal": "Alt+Shift+I",
"openJimaku": "Ctrl+Alt+J"
},
"invisibleOverlay": {
"startupVisibility": "hidden"
},
"bind_visible_overlay_to_mpv_sub_visibility": false,
"youtubeSubgen": {
"primarySubLanguages": ["ja", "jpn", "jp"]
@@ -621,9 +656,7 @@ test('parses invisible overlay config and new global shortcuts', () => {
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.shortcuts.toggleVisibleOverlayGlobal, 'Alt+Shift+U');
assert.equal(config.shortcuts.toggleInvisibleOverlayGlobal, 'Alt+Shift+I');
assert.equal(config.shortcuts.openJimaku, 'Ctrl+Alt+J');
assert.equal(config.invisibleOverlay.startupVisibility, 'hidden');
assert.equal(config.bind_visible_overlay_to_mpv_sub_visibility, false);
assert.deepEqual(config.youtubeSubgen.primarySubLanguages, ['ja', 'jpn', 'jp']);
});

View File

@@ -29,7 +29,6 @@ const {
subsync,
auto_start_overlay,
bind_visible_overlay_to_mpv_sub_visibility,
invisibleOverlay,
} = CORE_DEFAULT_CONFIG;
const { ankiConnect, jimaku, anilist, jellyfin, discordPresence, youtubeSubgen } =
INTEGRATIONS_DEFAULT_CONFIG;
@@ -54,7 +53,6 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
jellyfin,
discordPresence,
youtubeSubgen,
invisibleOverlay,
immersionTracking,
};

View File

@@ -12,7 +12,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
| 'subsync'
| 'auto_start_overlay'
| 'bind_visible_overlay_to_mpv_sub_visibility'
| 'invisibleOverlay'
> = {
subtitlePosition: { yPercent: 10 },
keybindings: [],
@@ -28,7 +27,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
},
shortcuts: {
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
toggleInvisibleOverlayGlobal: 'Alt+Shift+I',
copySubtitle: 'CommandOrControl+C',
copySubtitleMultiple: 'CommandOrControl+Shift+C',
updateLastCardFromClipboard: 'CommandOrControl+V',
@@ -55,7 +53,4 @@ export const CORE_DEFAULT_CONFIG: Pick<
},
auto_start_overlay: false,
bind_visible_overlay_to_mpv_sub_visibility: true,
invisibleOverlay: {
startupVisibility: 'platform-default',
},
};

View File

@@ -4,7 +4,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
subtitleStyle: {
enableJlpt: false,
preserveLineBreaks: false,
hoverTokenColor: '#c6a0f6',
hoverTokenColor: '#f4dbd6',
hoverTokenBackgroundColor: '#363a4fd6',
fontFamily:
'M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif',
fontSize: 35,

View File

@@ -27,6 +27,12 @@ export function buildSubtitleConfigOptionRegistry(
defaultValue: defaultConfig.subtitleStyle.hoverTokenColor,
description: 'Hex color used for hovered subtitle token highlight in mpv.',
},
{
path: 'subtitleStyle.hoverTokenBackgroundColor',
kind: 'string',
defaultValue: defaultConfig.subtitleStyle.hoverTokenBackgroundColor,
description: 'CSS color used for hovered subtitle token background highlight in mpv.',
},
{
path: 'subtitleStyle.frequencyDictionary.enabled',
kind: 'boolean',

View File

@@ -40,15 +40,6 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
notes: ['Hot-reload: shortcut changes apply live and update the session help modal on reopen.'],
key: 'shortcuts',
},
{
title: 'Invisible Overlay',
description: ['Startup behavior for the invisible interactive subtitle mining layer.'],
notes: [
'Invisible subtitle position edit mode: Ctrl/Cmd+Shift+P to toggle, arrow keys to move, Enter or Ctrl/Cmd+S to save, Esc to cancel.',
'This edit-mode shortcut is fixed and is not currently configurable.',
],
key: 'invisibleOverlay',
},
{
title: 'Keybindings (MPV Commands)',
description: [

View File

@@ -100,6 +100,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt;
const fallbackSubtitleStylePreserveLineBreaks = resolved.subtitleStyle.preserveLineBreaks;
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
const fallbackSubtitleStyleHoverTokenBackgroundColor =
resolved.subtitleStyle.hoverTokenBackgroundColor;
resolved.subtitleStyle = {
...resolved.subtitleStyle,
...(src.subtitleStyle as ResolvedConfig['subtitleStyle']),
@@ -154,6 +156,24 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
);
}
const hoverTokenBackgroundColor = asString(
(src.subtitleStyle as { hoverTokenBackgroundColor?: unknown }).hoverTokenBackgroundColor,
);
if (hoverTokenBackgroundColor !== undefined) {
resolved.subtitleStyle.hoverTokenBackgroundColor = hoverTokenBackgroundColor;
} else if (
(src.subtitleStyle as { hoverTokenBackgroundColor?: unknown }).hoverTokenBackgroundColor !==
undefined
) {
resolved.subtitleStyle.hoverTokenBackgroundColor = fallbackSubtitleStyleHoverTokenBackgroundColor;
warn(
'subtitleStyle.hoverTokenBackgroundColor',
(src.subtitleStyle as { hoverTokenBackgroundColor?: unknown }).hoverTokenBackgroundColor,
resolved.subtitleStyle.hoverTokenBackgroundColor,
'Expected string.',
);
}
const frequencyDictionary = isObject(
(src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary,
)

View File

@@ -10,14 +10,11 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
stop: false,
toggle: false,
toggleVisibleOverlay: false,
toggleInvisibleOverlay: false,
settings: false,
show: false,
hide: false,
showVisibleOverlay: false,
hideVisibleOverlay: false,
showInvisibleOverlay: false,
hideInvisibleOverlay: false,
copySubtitle: false,
copySubtitleMultiple: false,
mineSentence: false,
@@ -94,18 +91,12 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
toggleVisibleOverlay: () => {
calls.push('toggleVisibleOverlay');
},
toggleInvisibleOverlay: () => {
calls.push('toggleInvisibleOverlay');
},
openYomitanSettingsDelayed: (delayMs) => {
calls.push(`openYomitanSettingsDelayed:${delayMs}`);
},
setVisibleOverlayVisible: (visible) => {
calls.push(`setVisibleOverlayVisible:${visible}`);
},
setInvisibleOverlayVisible: (visible) => {
calls.push(`setInvisibleOverlayVisible:${visible}`);
},
copyCurrentSubtitle: () => {
calls.push('copyCurrentSubtitle');
},
@@ -339,10 +330,6 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
args: Partial<CliArgs>;
expected: string;
}> = [
{
args: { toggleInvisibleOverlay: true },
expected: 'toggleInvisibleOverlay',
},
{ args: { settings: true }, expected: 'openYomitanSettingsDelayed:1000' },
{
args: { showVisibleOverlay: true },
@@ -352,14 +339,6 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
args: { hideVisibleOverlay: true },
expected: 'setVisibleOverlayVisible:false',
},
{
args: { showInvisibleOverlay: true },
expected: 'setInvisibleOverlayVisible:true',
},
{
args: { hideInvisibleOverlay: true },
expected: 'setInvisibleOverlayVisible:false',
},
{ args: { copySubtitle: true }, expected: 'copyCurrentSubtitle' },
{
args: { copySubtitleMultiple: true },

View File

@@ -16,10 +16,8 @@ export interface CliCommandServiceDeps {
isOverlayRuntimeInitialized: () => boolean;
initializeOverlayRuntime: () => void;
toggleVisibleOverlay: () => void;
toggleInvisibleOverlay: () => void;
openYomitanSettingsDelayed: (delayMs: number) => void;
setVisibleOverlayVisible: (visible: boolean) => void;
setInvisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
mineSentenceCard: () => Promise<void>;
@@ -93,9 +91,7 @@ interface OverlayCliRuntime {
isInitialized: () => boolean;
initialize: () => void;
toggleVisible: () => void;
toggleInvisible: () => void;
setVisible: (visible: boolean) => void;
setInvisible: (visible: boolean) => void;
}
interface MiningCliRuntime {
@@ -180,14 +176,12 @@ export function createCliCommandDepsRuntime(
isOverlayRuntimeInitialized: options.overlay.isInitialized,
initializeOverlayRuntime: options.overlay.initialize,
toggleVisibleOverlay: options.overlay.toggleVisible,
toggleInvisibleOverlay: options.overlay.toggleInvisible,
openYomitanSettingsDelayed: (delayMs) => {
options.schedule(() => {
options.ui.openYomitanSettings();
}, delayMs);
},
setVisibleOverlayVisible: options.overlay.setVisible,
setInvisibleOverlayVisible: options.overlay.setInvisible,
copyCurrentSubtitle: options.mining.copyCurrentSubtitle,
startPendingMultiCopy: options.mining.startPendingMultiCopy,
mineSentenceCard: options.mining.mineSentenceCard,
@@ -242,14 +236,11 @@ export function handleCliCommand(
args.stop ||
args.toggle ||
args.toggleVisibleOverlay ||
args.toggleInvisibleOverlay ||
args.settings ||
args.show ||
args.hide ||
args.showVisibleOverlay ||
args.hideVisibleOverlay ||
args.showInvisibleOverlay ||
args.hideInvisibleOverlay ||
args.copySubtitle ||
args.copySubtitleMultiple ||
args.mineSentence ||
@@ -286,10 +277,7 @@ export function handleCliCommand(
}
const shouldStart =
args.start ||
args.toggle ||
args.toggleVisibleOverlay ||
args.toggleInvisibleOverlay;
args.start || args.toggle || args.toggleVisibleOverlay;
const needsOverlayRuntime = commandNeedsOverlayRuntime(args);
const shouldInitializeOverlayRuntime = needsOverlayRuntime || args.start;
@@ -325,18 +313,12 @@ export function handleCliCommand(
if (args.toggle || args.toggleVisibleOverlay) {
deps.toggleVisibleOverlay();
} else if (args.toggleInvisibleOverlay) {
deps.toggleInvisibleOverlay();
} else if (args.settings) {
deps.openYomitanSettingsDelayed(1000);
} else if (args.show || args.showVisibleOverlay) {
deps.setVisibleOverlayVisible(true);
} else if (args.hide || args.hideVisibleOverlay) {
deps.setVisibleOverlayVisible(false);
} else if (args.showInvisibleOverlay) {
deps.setInvisibleOverlayVisible(true);
} else if (args.hideInvisibleOverlay) {
deps.setInvisibleOverlayVisible(false);
} else if (args.copySubtitle) {
deps.copyCurrentSubtitle();
} else if (args.copySubtitleMultiple) {

View File

@@ -19,11 +19,9 @@ test('createFieldGroupingOverlayRuntime sends overlay messages and sets restore
},
}),
getVisibleOverlayVisible: () => visible,
getInvisibleOverlayVisible: () => false,
setVisibleOverlayVisible: (next) => {
visible = next;
},
setInvisibleOverlayVisible: () => {},
getResolver: () => null,
setResolver: () => {},
getRestoreVisibleOverlayOnModalClose: () => restore,
@@ -44,9 +42,7 @@ test('createFieldGroupingOverlayRuntime callback cancels when send fails', async
const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({
getMainWindow: () => null,
getVisibleOverlayVisible: () => false,
getInvisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
setInvisibleOverlayVisible: () => {},
getResolver: () => resolver,
setResolver: (next: ((choice: KikuFieldGroupingChoice) => void) | null) => {
resolver = next;
@@ -87,12 +83,10 @@ test('createFieldGroupingOverlayRuntime callback restores hidden visible overlay
const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({
getMainWindow: () => null,
getVisibleOverlayVisible: () => visible,
getInvisibleOverlayVisible: () => false,
setVisibleOverlayVisible: (nextVisible) => {
visible = nextVisible;
visibilityTransitions.push(nextVisible);
},
setInvisibleOverlayVisible: () => {},
getResolver: () => resolver as ((choice: KikuFieldGroupingChoice) => void) | null,
setResolver: (nextResolver: ((choice: KikuFieldGroupingChoice) => void) | null) => {
resolver = nextResolver;

View File

@@ -11,9 +11,7 @@ interface WindowLike {
export interface FieldGroupingOverlayRuntimeOptions<T extends string> {
getMainWindow: () => WindowLike | null;
getVisibleOverlayVisible: () => boolean;
getInvisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
setInvisibleOverlayVisible: (visible: boolean) => void;
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
getRestoreVisibleOverlayOnModalClose: () => Set<T>;
@@ -65,9 +63,7 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
) => Promise<KikuFieldGroupingChoice>) => {
return createFieldGroupingCallbackRuntime({
getVisibleOverlayVisible: options.getVisibleOverlayVisible,
getInvisibleOverlayVisible: options.getInvisibleOverlayVisible,
setVisibleOverlayVisible: options.setVisibleOverlayVisible,
setInvisibleOverlayVisible: options.setInvisibleOverlayVisible,
getResolver: options.getResolver,
setResolver: options.setResolver,
sendToVisibleOverlay,

View File

@@ -2,9 +2,7 @@ import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../typ
export function createFieldGroupingCallback(options: {
getVisibleOverlayVisible: () => boolean;
getInvisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
setInvisibleOverlayVisible: (visible: boolean) => void;
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean;
@@ -22,7 +20,6 @@ export function createFieldGroupingCallback(options: {
}
const previousVisibleOverlay = options.getVisibleOverlayVisible();
const previousInvisibleOverlay = options.getInvisibleOverlayVisible();
let settled = false;
const finish = (choice: KikuFieldGroupingChoice): void => {
@@ -36,9 +33,6 @@ export function createFieldGroupingCallback(options: {
if (!previousVisibleOverlay && options.getVisibleOverlayVisible()) {
options.setVisibleOverlayVisible(false);
}
if (options.getInvisibleOverlayVisible() !== previousInvisibleOverlay) {
options.setInvisibleOverlayVisible(previousInvisibleOverlay);
}
};
options.setResolver(finish);

View File

@@ -36,10 +36,8 @@ function createFakeIpcRegistrar(): {
test('createIpcDepsRuntime wires AniList handlers', async () => {
const calls: string[] = [];
const deps = createIpcDepsRuntime({
getInvisibleWindow: () => null,
getMainWindow: () => null,
getVisibleOverlayVisibility: () => false,
getInvisibleOverlayVisibility: () => false,
onOverlayModalClosed: () => {},
openYomitanSettings: () => {},
quitApp: () => {},
@@ -47,7 +45,6 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '',
getMpvSubtitleRenderMetrics: () => null,
getSubtitlePosition: () => null,
getSubtitleStyle: () => null,
saveSubtitlePosition: () => {},
@@ -64,7 +61,6 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
setRuntimeOption: () => ({ ok: true }),
cycleRuntimeOption: () => ({ ok: true }),
reportOverlayContentBounds: () => {},
reportHoveredSubtitleToken: () => {},
getAnilistStatus: () => ({ tokenStatus: 'resolved' }),
clearAnilistToken: () => {
calls.push('clearAnilistToken');
@@ -101,20 +97,15 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
const cycles: Array<{ id: string; direction: 1 | -1 }> = [];
registerIpcHandlers(
{
getInvisibleWindow: () => null,
isVisibleOverlayVisible: () => false,
setInvisibleIgnoreMouseEvents: () => {},
onOverlayModalClosed: () => {},
openYomitanSettings: () => {},
quitApp: () => {},
toggleDevTools: () => {},
getVisibleOverlayVisibility: () => false,
toggleVisibleOverlay: () => {},
getInvisibleOverlayVisibility: () => false,
tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '',
getMpvSubtitleRenderMetrics: () => null,
getSubtitlePosition: () => null,
getSubtitleStyle: () => null,
saveSubtitlePosition: () => {},
@@ -138,7 +129,6 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
return { ok: true };
},
reportOverlayContentBounds: () => {},
reportHoveredSubtitleToken: () => {},
getAnilistStatus: () => ({}),
clearAnilistToken: () => {},
openAnilistSetup: () => {},
@@ -176,25 +166,24 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const saves: unknown[] = [];
const modals: unknown[] = [];
const closedModals: unknown[] = [];
const openedModals: unknown[] = [];
registerIpcHandlers(
{
getInvisibleWindow: () => null,
isVisibleOverlayVisible: () => false,
setInvisibleIgnoreMouseEvents: () => {},
onOverlayModalClosed: (modal) => {
modals.push(modal);
closedModals.push(modal);
},
onOverlayModalOpened: (modal) => {
openedModals.push(modal);
},
openYomitanSettings: () => {},
quitApp: () => {},
toggleDevTools: () => {},
getVisibleOverlayVisibility: () => false,
toggleVisibleOverlay: () => {},
getInvisibleOverlayVisibility: () => false,
tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '',
getMpvSubtitleRenderMetrics: () => null,
getSubtitlePosition: () => null,
getSubtitleStyle: () => null,
saveSubtitlePosition: (position) => {
@@ -214,7 +203,6 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
setRuntimeOption: () => ({ ok: true }),
cycleRuntimeOption: () => ({ ok: true }),
reportOverlayContentBounds: () => {},
reportHoveredSubtitleToken: () => {},
getAnilistStatus: () => ({}),
clearAnilistToken: () => {},
openAnilistSetup: () => {},
@@ -228,11 +216,16 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
handlers.on.get(IPC_CHANNELS.command.saveSubtitlePosition)!({}, { yPercent: 'bad' });
handlers.on.get(IPC_CHANNELS.command.saveSubtitlePosition)!({}, { yPercent: 42 });
assert.deepEqual(saves, [
{ yPercent: 42, invisibleOffsetXPx: undefined, invisibleOffsetYPx: undefined },
{ yPercent: 42 },
]);
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'not-a-modal');
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'subsync');
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'kiku');
assert.deepEqual(modals, ['subsync', 'kiku']);
assert.deepEqual(closedModals, ['subsync', 'kiku']);
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'bad');
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'subsync');
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'runtime-options');
assert.deepEqual(openedModals, ['subsync', 'runtime-options']);
});

View File

@@ -19,20 +19,16 @@ import {
} from '../../shared/ipc/validators';
export interface IpcServiceDeps {
getInvisibleWindow: () => WindowLike | null;
isVisibleOverlayVisible: () => boolean;
setInvisibleIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
onOverlayModalClosed: (modal: OverlayHostedModal) => void;
onOverlayModalOpened?: (modal: OverlayHostedModal) => void;
openYomitanSettings: () => void;
quitApp: () => void;
toggleDevTools: () => void;
getVisibleOverlayVisibility: () => boolean;
toggleVisibleOverlay: () => void;
getInvisibleOverlayVisibility: () => boolean;
tokenizeCurrentSubtitle: () => Promise<unknown>;
getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string;
getMpvSubtitleRenderMetrics: () => unknown;
getSubtitlePosition: () => unknown;
getSubtitleStyle: () => unknown;
saveSubtitlePosition: (position: SubtitlePosition) => void;
@@ -54,7 +50,6 @@ export interface IpcServiceDeps {
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown;
reportOverlayContentBounds: (payload: unknown) => void;
reportHoveredSubtitleToken: (tokenIndex: number | null) => void;
getAnilistStatus: () => unknown;
clearAnilistToken: () => void;
openAnilistSetup: () => void;
@@ -91,18 +86,16 @@ interface IpcMainRegistrar {
}
export interface IpcDepsRuntimeOptions {
getInvisibleWindow: () => WindowLike | null;
getMainWindow: () => WindowLike | null;
getVisibleOverlayVisibility: () => boolean;
getInvisibleOverlayVisibility: () => boolean;
onOverlayModalClosed: (modal: OverlayHostedModal) => void;
onOverlayModalOpened?: (modal: OverlayHostedModal) => void;
openYomitanSettings: () => void;
quitApp: () => void;
toggleVisibleOverlay: () => void;
tokenizeCurrentSubtitle: () => Promise<unknown>;
getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string;
getMpvSubtitleRenderMetrics: () => unknown;
getSubtitlePosition: () => unknown;
getSubtitleStyle: () => unknown;
saveSubtitlePosition: (position: SubtitlePosition) => void;
@@ -119,7 +112,6 @@ export interface IpcDepsRuntimeOptions {
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown;
reportOverlayContentBounds: (payload: unknown) => void;
reportHoveredSubtitleToken: (tokenIndex: number | null) => void;
getAnilistStatus: () => unknown;
clearAnilistToken: () => void;
openAnilistSetup: () => void;
@@ -130,14 +122,8 @@ export interface IpcDepsRuntimeOptions {
export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcServiceDeps {
return {
getInvisibleWindow: () => options.getInvisibleWindow(),
isVisibleOverlayVisible: options.getVisibleOverlayVisibility,
setInvisibleIgnoreMouseEvents: (ignore, eventsOptions) => {
const invisibleWindow = options.getInvisibleWindow();
if (!invisibleWindow || invisibleWindow.isDestroyed()) return;
invisibleWindow.setIgnoreMouseEvents(ignore, eventsOptions);
},
onOverlayModalClosed: options.onOverlayModalClosed,
onOverlayModalOpened: options.onOverlayModalOpened,
openYomitanSettings: options.openYomitanSettings,
quitApp: options.quitApp,
toggleDevTools: () => {
@@ -147,11 +133,9 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
},
getVisibleOverlayVisibility: options.getVisibleOverlayVisibility,
toggleVisibleOverlay: options.toggleVisibleOverlay,
getInvisibleOverlayVisibility: options.getInvisibleOverlayVisibility,
tokenizeCurrentSubtitle: options.tokenizeCurrentSubtitle,
getCurrentSubtitleRaw: options.getCurrentSubtitleRaw,
getCurrentSubtitleAss: options.getCurrentSubtitleAss,
getMpvSubtitleRenderMetrics: options.getMpvSubtitleRenderMetrics,
getSubtitlePosition: options.getSubtitlePosition,
getSubtitleStyle: options.getSubtitleStyle,
saveSubtitlePosition: options.saveSubtitlePosition,
@@ -182,7 +166,6 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
setRuntimeOption: options.setRuntimeOption,
cycleRuntimeOption: options.cycleRuntimeOption,
reportOverlayContentBounds: options.reportOverlayContentBounds,
reportHoveredSubtitleToken: options.reportHoveredSubtitleToken,
getAnilistStatus: options.getAnilistStatus,
clearAnilistToken: options.clearAnilistToken,
openAnilistSetup: options.openAnilistSetup,
@@ -200,17 +183,7 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
const parsedOptions = parseOptionalForwardingOptions(options);
const senderWindow = BrowserWindow.fromWebContents((event as IpcMainEvent).sender);
if (senderWindow && !senderWindow.isDestroyed()) {
const invisibleWindow = deps.getInvisibleWindow();
if (
senderWindow === invisibleWindow &&
deps.isVisibleOverlayVisible() &&
invisibleWindow &&
!invisibleWindow.isDestroyed()
) {
deps.setInvisibleIgnoreMouseEvents(true, { forward: true });
} else {
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
}
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
}
},
);
@@ -220,6 +193,12 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
if (!parsedModal) return;
deps.onOverlayModalClosed(parsedModal);
});
ipc.on(IPC_CHANNELS.command.overlayModalOpened, (_event: unknown, modal: unknown) => {
const parsedModal = parseOverlayHostedModal(modal);
if (!parsedModal) return;
if (!deps.onOverlayModalOpened) return;
deps.onOverlayModalOpened(parsedModal);
});
ipc.on(IPC_CHANNELS.command.openYomitanSettings, () => {
deps.openYomitanSettings();
@@ -245,10 +224,6 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return deps.getVisibleOverlayVisibility();
});
ipc.handle(IPC_CHANNELS.request.getInvisibleOverlayVisibility, () => {
return deps.getInvisibleOverlayVisibility();
});
ipc.handle(IPC_CHANNELS.request.getCurrentSubtitle, async () => {
return await deps.tokenizeCurrentSubtitle();
});
@@ -261,10 +236,6 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return deps.getCurrentSubtitleAss();
});
ipc.handle(IPC_CHANNELS.request.getMpvSubtitleRenderMetrics, () => {
return deps.getMpvSubtitleRenderMetrics();
});
ipc.handle(IPC_CHANNELS.request.getSubtitlePosition, () => {
return deps.getSubtitlePosition();
});
@@ -358,17 +329,6 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
deps.reportOverlayContentBounds(payload);
});
ipc.on('subtitle-token-hover:set', (_event: unknown, tokenIndex: unknown) => {
if (tokenIndex === null) {
deps.reportHoveredSubtitleToken(null);
return;
}
if (!Number.isInteger(tokenIndex) || (tokenIndex as number) < 0) {
return;
}
deps.reportHoveredSubtitleToken(tokenIndex as number);
});
ipc.handle(IPC_CHANNELS.request.getAnilistStatus, () => {
return deps.getAnilistStatus();
});

View File

@@ -33,13 +33,51 @@ test('sendToVisibleOverlayRuntime restores visibility flag when opening hidden o
assert.deepEqual(sent, [['runtime-options:open']]);
});
test('sendToVisibleOverlayRuntime waits for overlay page before sending open command', () => {
const sent: unknown[][] = [];
const restoreSet = new Set<'runtime-options' | 'subsync'>();
let loading = true;
let currentURL = '';
const finishCallbacks: Array<() => void> = [];
const ok = sendToVisibleOverlayRuntime({
mainWindow: {
isDestroyed: () => false,
webContents: {
isLoading: () => loading,
getURL: () => currentURL,
send: (...args: unknown[]) => {
sent.push(args);
},
once: (_event: string, callback: () => void) => {
finishCallbacks.push(callback);
},
},
} as unknown as Electron.BrowserWindow,
visibleOverlayVisible: false,
setVisibleOverlayVisible: () => {},
channel: 'runtime-options:open',
restoreOnModalClose: 'runtime-options',
restoreVisibleOverlayOnModalClose: restoreSet,
});
assert.equal(ok, true);
assert.deepEqual(sent, []);
assert.equal(restoreSet.has('runtime-options'), true);
loading = false;
currentURL = 'file:///overlay/index.html?layer=visible';
assert.ok(finishCallbacks[0]);
finishCallbacks[0]!();
assert.deepEqual(sent, [['runtime-options:open']]);
});
test('createFieldGroupingCallbackRuntime cancels when overlay request cannot be sent', async () => {
let resolver: ((choice: KikuFieldGroupingChoice) => void) | null = null;
const callback = createFieldGroupingCallbackRuntime<'runtime-options' | 'subsync'>({
getVisibleOverlayVisible: () => false,
getInvisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
setInvisibleOverlayVisible: () => {},
getResolver: () => resolver,
setResolver: (next) => {
resolver = next;

View File

@@ -26,27 +26,32 @@ export function sendToVisibleOverlayRuntime<T extends string>(options: {
options.mainWindow!.webContents.send(options.channel, options.payload);
}
};
if (options.mainWindow.webContents.isLoading()) {
const getURL = options.mainWindow.webContents.getURL;
const currentURL =
typeof getURL === 'function' ? getURL.call(options.mainWindow.webContents) : 'ready';
const isReady =
!options.mainWindow.webContents.isLoading() &&
currentURL !== '' &&
currentURL !== 'about:blank';
if (!isReady) {
options.mainWindow.webContents.once('did-finish-load', () => {
if (
options.mainWindow &&
!options.mainWindow.isDestroyed() &&
!options.mainWindow.webContents.isLoading()
) {
if (!options.mainWindow || options.mainWindow.isDestroyed()) return;
if (!options.mainWindow.webContents.isLoading()) {
sendNow();
}
});
return true;
}
sendNow();
return true;
}
export function createFieldGroupingCallbackRuntime<T extends string>(options: {
getVisibleOverlayVisible: () => boolean;
getInvisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
setInvisibleOverlayVisible: (visible: boolean) => void;
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
sendToVisibleOverlay: (
@@ -57,9 +62,7 @@ export function createFieldGroupingCallbackRuntime<T extends string>(options: {
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
return createFieldGroupingCallback({
getVisibleOverlayVisible: options.getVisibleOverlayVisible,
getInvisibleOverlayVisible: options.getInvisibleOverlayVisible,
setVisibleOverlayVisible: options.setVisibleOverlayVisible,
setInvisibleOverlayVisible: options.setInvisibleOverlayVisible,
getResolver: options.getResolver,
setResolver: options.setResolver,
sendRequestToVisibleOverlay: (data) =>

View File

@@ -28,7 +28,7 @@ test('sanitizeOverlayContentMeasurement accepts valid payload with null rect', (
test('sanitizeOverlayContentMeasurement rejects invalid ranges', () => {
const measurement = sanitizeOverlayContentMeasurement(
{
layer: 'invisible',
layer: 'visible',
measuredAtMs: 100,
viewport: { width: 0, height: 1080 },
contentRect: { x: 0, y: 0, width: 100, height: 20 },
@@ -39,7 +39,7 @@ test('sanitizeOverlayContentMeasurement rejects invalid ranges', () => {
assert.equal(measurement, null);
});
test('overlay measurement store keeps latest payload per layer', () => {
test('overlay measurement store keeps latest payload for visible layer', () => {
const store = createOverlayContentMeasurementStore({
now: () => 1000,
warn: () => {
@@ -53,17 +53,9 @@ test('overlay measurement store keeps latest payload per layer', () => {
viewport: { width: 1280, height: 720 },
contentRect: { x: 50, y: 60, width: 400, height: 80 },
});
const invisible = store.report({
layer: 'invisible',
measuredAtMs: 910,
viewport: { width: 1280, height: 720 },
contentRect: { x: 20, y: 30, width: 300, height: 40 },
});
assert.equal(visible?.layer, 'visible');
assert.equal(invisible?.layer, 'invisible');
assert.equal(store.getLatestByLayer('visible')?.contentRect?.width, 400);
assert.equal(store.getLatestByLayer('invisible')?.contentRect?.height, 40);
});
test('overlay measurement store rate-limits invalid payload warnings', () => {

View File

@@ -28,7 +28,7 @@ export function sanitizeOverlayContentMeasurement(
} | null;
};
if (candidate.layer !== 'visible' && candidate.layer !== 'invisible') {
if (candidate.layer !== 'visible') {
return null;
}
@@ -112,7 +112,6 @@ export function createOverlayContentMeasurementStore(options?: {
const warn = options?.warn ?? ((message: string) => logger.warn(message));
const latestByLayer: OverlayMeasurementStore = {
visible: null,
invisible: null,
};
let droppedInvalid = 0;

View File

@@ -10,16 +10,11 @@ import {
export function initializeOverlayRuntime(options: {
backendOverride: string | null;
getInitialInvisibleOverlayVisibility: () => boolean;
createMainWindow: () => void;
createInvisibleWindow: () => void;
registerGlobalShortcuts: () => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void;
isVisibleOverlayVisible: () => boolean;
isInvisibleOverlayVisible: () => boolean;
updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
getOverlayWindows: () => BrowserWindow[];
syncOverlayShortcuts: () => void;
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
@@ -38,12 +33,8 @@ export function initializeOverlayRuntime(options: {
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string;
}): {
invisibleOverlayVisible: boolean;
} {
}): void {
options.createMainWindow();
options.createInvisibleWindow();
const invisibleOverlayVisible = options.getInitialInvisibleOverlayVisibility();
options.registerGlobalShortcuts();
const windowTracker = createWindowTracker(options.backendOverride, options.getMpvSocketPath());
@@ -51,17 +42,12 @@ export function initializeOverlayRuntime(options: {
if (windowTracker) {
windowTracker.onGeometryChange = (geometry: WindowGeometry) => {
options.updateVisibleOverlayBounds(geometry);
options.updateInvisibleOverlayBounds(geometry);
};
windowTracker.onWindowFound = (geometry: WindowGeometry) => {
options.updateVisibleOverlayBounds(geometry);
options.updateInvisibleOverlayBounds(geometry);
if (options.isVisibleOverlayVisible()) {
options.updateVisibleOverlayVisibility();
}
if (options.isInvisibleOverlayVisible()) {
options.updateInvisibleOverlayVisibility();
}
};
windowTracker.onWindowLost = () => {
for (const window of options.getOverlayWindows()) {
@@ -101,7 +87,4 @@ export function initializeOverlayRuntime(options: {
}
options.updateVisibleOverlayVisibility();
options.updateInvisibleOverlayVisibility();
return { invisibleOverlayVisible };
}

View File

@@ -10,7 +10,6 @@ import {
function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
return {
toggleVisibleOverlayGlobal: null,
toggleInvisibleOverlayGlobal: null,
copySubtitle: null,
copySubtitleMultiple: null,
updateLastCardFromClipboard: null,

View File

@@ -0,0 +1,265 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './overlay-visibility';
type WindowTrackerStub = {
isTracking: () => boolean;
getGeometry: () => { x: number; y: number; width: number; height: number } | null;
};
function createMainWindowRecorder() {
const calls: string[] = [];
const window = {
isDestroyed: () => false,
hide: () => {
calls.push('hide');
},
show: () => {
calls.push('show');
},
focus: () => {
calls.push('focus');
},
setIgnoreMouseEvents: () => {
calls.push('mouse-ignore');
},
};
return { window, calls };
}
test('macOS keeps visible overlay hidden while tracker is not ready and emits one loading OSD', () => {
const { window, calls } = createMainWindowRecorder();
let trackerWarning = false;
const osdMessages: string[] = [];
const tracker: WindowTrackerStub = {
isTracking: () => false,
getGeometry: () => null,
};
const run = () =>
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: trackerWarning,
setTrackerNotReadyWarningShown: (shown: boolean) => {
trackerWarning = shown;
},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
showOverlayLoadingOsd: (message: string) => {
osdMessages.push(message);
},
} as never);
run();
run();
assert.equal(trackerWarning, true);
assert.deepEqual(osdMessages, ['Overlay loading...']);
assert.ok(calls.includes('hide'));
assert.ok(!calls.includes('show'));
});
test('non-macOS keeps fallback visible overlay behavior when tracker is not ready', () => {
const { window, calls } = createMainWindowRecorder();
let trackerWarning = false;
const tracker: WindowTrackerStub = {
isTracking: () => false,
getGeometry: () => null,
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: trackerWarning,
setTrackerNotReadyWarningShown: (shown: boolean) => {
trackerWarning = shown;
},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
showOverlayLoadingOsd: () => {
calls.push('osd');
},
resolveFallbackBounds: () => ({ x: 12, y: 24, width: 640, height: 360 }),
} as never);
assert.equal(trackerWarning, true);
assert.ok(calls.includes('update-bounds'));
assert.ok(calls.includes('show'));
assert.ok(calls.includes('focus'));
assert.ok(!calls.includes('osd'));
});
test('macOS keeps visible overlay hidden while tracker is not initialized yet', () => {
const { window, calls } = createMainWindowRecorder();
let trackerWarning = false;
const osdMessages: string[] = [];
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: null,
trackerNotReadyWarningShown: trackerWarning,
setTrackerNotReadyWarningShown: (shown: boolean) => {
trackerWarning = shown;
},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
showOverlayLoadingOsd: (message: string) => {
osdMessages.push(message);
},
} as never);
assert.equal(trackerWarning, true);
assert.deepEqual(osdMessages, ['Overlay loading...']);
assert.ok(calls.includes('hide'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('update-bounds'));
});
test('setVisibleOverlayVisible does not mutate mpv subtitle visibility directly', () => {
const calls: string[] = [];
setVisibleOverlayVisible({
visible: true,
setVisibleOverlayVisibleState: (visible) => {
calls.push(`state:${visible}`);
},
updateVisibleOverlayVisibility: () => {
calls.push('update');
},
});
assert.deepEqual(calls, ['state:true', 'update']);
});
test('macOS loading OSD can show again after overlay is hidden and retried', () => {
const { window, calls } = createMainWindowRecorder();
const osdMessages: string[] = [];
let trackerWarning = false;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: null,
trackerNotReadyWarningShown: trackerWarning,
setTrackerNotReadyWarningShown: (shown: boolean) => {
trackerWarning = shown;
calls.push(`warn:${shown ? 'yes' : 'no'}`);
},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
showOverlayLoadingOsd: (message: string) => {
osdMessages.push(message);
},
} as never);
updateVisibleOverlayVisibility({
visibleOverlayVisible: false,
mainWindow: window as never,
windowTracker: null,
trackerNotReadyWarningShown: trackerWarning,
setTrackerNotReadyWarningShown: (shown: boolean) => {
trackerWarning = shown;
calls.push(`warn:${shown ? 'yes' : 'no'}`);
},
updateVisibleOverlayBounds: () => {},
ensureOverlayWindowLevel: () => {},
syncPrimaryOverlayWindowLayer: () => {},
enforceOverlayLayerOrder: () => {},
syncOverlayShortcuts: () => {},
isMacOSPlatform: true,
showOverlayLoadingOsd: () => {},
} as never);
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: null,
trackerNotReadyWarningShown: trackerWarning,
setTrackerNotReadyWarningShown: (shown: boolean) => {
trackerWarning = shown;
calls.push(`warn:${shown ? 'yes' : 'no'}`);
},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
showOverlayLoadingOsd: (message: string) => {
osdMessages.push(message);
},
} as never);
assert.deepEqual(osdMessages, ['Overlay loading...', 'Overlay loading...']);
});

View File

@@ -1,4 +1,4 @@
import { BrowserWindow, screen } from 'electron';
import type { BrowserWindow } from 'electron';
import { BaseWindowTracker } from '../../window-trackers';
import { WindowGeometry } from '../../types';
@@ -10,14 +10,19 @@ export function updateVisibleOverlayVisibility(args: {
setTrackerNotReadyWarningShown: (shown: boolean) => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
enforceOverlayLayerOrder: () => void;
syncOverlayShortcuts: () => void;
isMacOSPlatform?: boolean;
showOverlayLoadingOsd?: (message: string) => void;
resolveFallbackBounds: () => WindowGeometry;
}): void {
if (!args.mainWindow || args.mainWindow.isDestroyed()) {
return;
}
if (!args.visibleOverlayVisible) {
args.setTrackerNotReadyWarningShown(false);
args.mainWindow.hide();
args.syncOverlayShortcuts();
return;
@@ -29,6 +34,8 @@ export function updateVisibleOverlayVisibility(args: {
if (geometry) {
args.updateVisibleOverlayBounds(geometry);
}
args.syncPrimaryOverlayWindowLayer('visible');
args.mainWindow.setIgnoreMouseEvents(false);
args.ensureOverlayWindowLevel(args.mainWindow);
args.mainWindow.show();
args.mainWindow.focus();
@@ -38,7 +45,18 @@ export function updateVisibleOverlayVisibility(args: {
}
if (!args.windowTracker) {
if (args.isMacOSPlatform) {
if (!args.trackerNotReadyWarningShown) {
args.setTrackerNotReadyWarningShown(true);
args.showOverlayLoadingOsd?.('Overlay loading...');
}
args.mainWindow.hide();
args.syncOverlayShortcuts();
return;
}
args.setTrackerNotReadyWarningShown(false);
args.syncPrimaryOverlayWindowLayer('visible');
args.mainWindow.setIgnoreMouseEvents(false);
args.ensureOverlayWindowLevel(args.mainWindow);
args.mainWindow.show();
args.mainWindow.focus();
@@ -49,16 +67,21 @@ export function updateVisibleOverlayVisibility(args: {
if (!args.trackerNotReadyWarningShown) {
args.setTrackerNotReadyWarningShown(true);
if (args.isMacOSPlatform) {
args.showOverlayLoadingOsd?.('Overlay loading...');
}
}
const cursorPoint = screen.getCursorScreenPoint();
const display = screen.getDisplayNearestPoint(cursorPoint);
const fallbackBounds = display.workArea;
args.updateVisibleOverlayBounds({
x: fallbackBounds.x,
y: fallbackBounds.y,
width: fallbackBounds.width,
height: fallbackBounds.height,
});
if (args.isMacOSPlatform) {
args.mainWindow.hide();
args.syncOverlayShortcuts();
return;
}
const fallbackBounds = args.resolveFallbackBounds();
args.updateVisibleOverlayBounds(fallbackBounds);
args.syncPrimaryOverlayWindowLayer('visible');
args.mainWindow.setIgnoreMouseEvents(false);
args.ensureOverlayWindowLevel(args.mainWindow);
args.mainWindow.show();
args.mainWindow.focus();
@@ -66,111 +89,11 @@ export function updateVisibleOverlayVisibility(args: {
args.syncOverlayShortcuts();
}
export function updateInvisibleOverlayVisibility(args: {
invisibleWindow: BrowserWindow | null;
visibleOverlayVisible: boolean;
invisibleOverlayVisible: boolean;
windowTracker: BaseWindowTracker | null;
updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void;
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
enforceOverlayLayerOrder: () => void;
syncOverlayShortcuts: () => void;
}): void {
if (!args.invisibleWindow || args.invisibleWindow.isDestroyed()) {
return;
}
if (args.visibleOverlayVisible) {
args.invisibleWindow.hide();
args.syncOverlayShortcuts();
return;
}
const showInvisibleWithoutFocus = (): void => {
args.ensureOverlayWindowLevel(args.invisibleWindow!);
if (typeof args.invisibleWindow!.showInactive === 'function') {
args.invisibleWindow!.showInactive();
} else {
args.invisibleWindow!.show();
}
args.enforceOverlayLayerOrder();
};
if (!args.invisibleOverlayVisible) {
args.invisibleWindow.hide();
args.syncOverlayShortcuts();
return;
}
if (args.windowTracker && args.windowTracker.isTracking()) {
const geometry = args.windowTracker.getGeometry();
if (geometry) {
args.updateInvisibleOverlayBounds(geometry);
}
showInvisibleWithoutFocus();
args.syncOverlayShortcuts();
return;
}
if (!args.windowTracker) {
showInvisibleWithoutFocus();
args.syncOverlayShortcuts();
return;
}
const cursorPoint = screen.getCursorScreenPoint();
const display = screen.getDisplayNearestPoint(cursorPoint);
const fallbackBounds = display.workArea;
args.updateInvisibleOverlayBounds({
x: fallbackBounds.x,
y: fallbackBounds.y,
width: fallbackBounds.width,
height: fallbackBounds.height,
});
showInvisibleWithoutFocus();
args.syncOverlayShortcuts();
}
export function syncInvisibleOverlayMousePassthrough(options: {
hasInvisibleWindow: () => boolean;
setIgnoreMouseEvents: (ignore: boolean, extra?: { forward: boolean }) => void;
visibleOverlayVisible: boolean;
invisibleOverlayVisible: boolean;
}): void {
if (!options.hasInvisibleWindow()) return;
if (options.visibleOverlayVisible) {
options.setIgnoreMouseEvents(true, { forward: true });
} else if (options.invisibleOverlayVisible) {
options.setIgnoreMouseEvents(false);
}
}
export function setVisibleOverlayVisible(options: {
visible: boolean;
setVisibleOverlayVisibleState: (visible: boolean) => void;
updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
syncInvisibleOverlayMousePassthrough: () => void;
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
isMpvConnected: () => boolean;
setMpvSubVisibility: (visible: boolean) => void;
}): void {
options.setVisibleOverlayVisibleState(options.visible);
options.updateVisibleOverlayVisibility();
options.updateInvisibleOverlayVisibility();
options.syncInvisibleOverlayMousePassthrough();
if (options.shouldBindVisibleOverlayToMpvSubVisibility() && options.isMpvConnected()) {
options.setMpvSubVisibility(!options.visible);
}
}
export function setInvisibleOverlayVisible(options: {
visible: boolean;
setInvisibleOverlayVisibleState: (visible: boolean) => void;
updateInvisibleOverlayVisibility: () => void;
syncInvisibleOverlayMousePassthrough: () => void;
}): void {
options.setInvisibleOverlayVisibleState(options.visible);
options.updateInvisibleOverlayVisibility();
options.syncInvisibleOverlayMousePassthrough();
}

View File

@@ -1,7 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
getInitialInvisibleOverlayVisibility,
isAutoUpdateEnabledRuntime,
shouldAutoInitializeOverlayRuntimeFromConfig,
shouldBindVisibleOverlayToMpvSubVisibility,
@@ -10,9 +9,6 @@ import {
const BASE_CONFIG = {
auto_start_overlay: false,
bind_visible_overlay_to_mpv_sub_visibility: true,
invisibleOverlay: {
startupVisibility: 'platform-default' as const,
},
ankiConnect: {
behavior: {
autoUpdateNewCards: true,
@@ -20,26 +16,7 @@ const BASE_CONFIG = {
},
};
test('getInitialInvisibleOverlayVisibility handles visibility + platform', () => {
assert.equal(
getInitialInvisibleOverlayVisibility(
{ ...BASE_CONFIG, invisibleOverlay: { startupVisibility: 'visible' } },
'linux',
),
true,
);
assert.equal(
getInitialInvisibleOverlayVisibility(
{ ...BASE_CONFIG, invisibleOverlay: { startupVisibility: 'hidden' } },
'darwin',
),
false,
);
assert.equal(getInitialInvisibleOverlayVisibility(BASE_CONFIG, 'linux'), false);
assert.equal(getInitialInvisibleOverlayVisibility(BASE_CONFIG, 'darwin'), true);
});
test('shouldAutoInitializeOverlayRuntimeFromConfig respects auto start and visible startup', () => {
test('shouldAutoInitializeOverlayRuntimeFromConfig respects auto start', () => {
assert.equal(shouldAutoInitializeOverlayRuntimeFromConfig(BASE_CONFIG), false);
assert.equal(
shouldAutoInitializeOverlayRuntimeFromConfig({
@@ -48,13 +25,6 @@ test('shouldAutoInitializeOverlayRuntimeFromConfig respects auto start and visib
}),
true,
);
assert.equal(
shouldAutoInitializeOverlayRuntimeFromConfig({
...BASE_CONFIG,
invisibleOverlay: { startupVisibility: 'visible' },
}),
true,
);
});
test('shouldBindVisibleOverlayToMpvSubVisibility returns config value', () => {

View File

@@ -5,14 +5,12 @@ const logger = createLogger('main:shortcut');
export interface GlobalShortcutConfig {
toggleVisibleOverlayGlobal: string | null | undefined;
toggleInvisibleOverlayGlobal: string | null | undefined;
openJimaku?: string | null | undefined;
}
export interface RegisterGlobalShortcutsServiceOptions {
shortcuts: GlobalShortcutConfig;
onToggleVisibleOverlay: () => void;
onToggleInvisibleOverlay: () => void;
onOpenYomitanSettings: () => void;
onOpenJimaku?: () => void;
isDev: boolean;
@@ -21,9 +19,7 @@ export interface RegisterGlobalShortcutsServiceOptions {
export function registerGlobalShortcuts(options: RegisterGlobalShortcutsServiceOptions): void {
const visibleShortcut = options.shortcuts.toggleVisibleOverlayGlobal;
const invisibleShortcut = options.shortcuts.toggleInvisibleOverlayGlobal;
const normalizedVisible = visibleShortcut?.replace(/\s+/g, '').toLowerCase();
const normalizedInvisible = invisibleShortcut?.replace(/\s+/g, '').toLowerCase();
const normalizedJimaku = options.shortcuts.openJimaku?.replace(/\s+/g, '').toLowerCase();
const normalizedSettings = 'alt+shift+y';
@@ -38,31 +34,10 @@ export function registerGlobalShortcuts(options: RegisterGlobalShortcutsServiceO
}
}
if (invisibleShortcut && normalizedInvisible && normalizedInvisible !== normalizedVisible) {
const toggleInvisibleRegistered = globalShortcut.register(invisibleShortcut, () => {
options.onToggleInvisibleOverlay();
});
if (!toggleInvisibleRegistered) {
logger.warn(
`Failed to register global shortcut toggleInvisibleOverlayGlobal: ${invisibleShortcut}`,
);
}
} else if (
invisibleShortcut &&
normalizedInvisible &&
normalizedInvisible === normalizedVisible
) {
logger.warn(
'Skipped registering toggleInvisibleOverlayGlobal because it collides with toggleVisibleOverlayGlobal',
);
}
if (options.shortcuts.openJimaku && options.onOpenJimaku) {
if (
normalizedJimaku &&
(normalizedJimaku === normalizedVisible ||
normalizedJimaku === normalizedInvisible ||
normalizedJimaku === normalizedSettings)
(normalizedJimaku === normalizedVisible || normalizedJimaku === normalizedSettings)
) {
logger.warn(
'Skipped registering openJimaku because it collides with another global shortcut',

View File

@@ -10,14 +10,11 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
stop: false,
toggle: false,
toggleVisibleOverlay: false,
toggleInvisibleOverlay: false,
settings: false,
show: false,
hide: false,
showVisibleOverlay: false,
hideVisibleOverlay: false,
showInvisibleOverlay: false,
hideInvisibleOverlay: false,
copySubtitle: false,
copySubtitleMultiple: false,
mineSentence: false,

View File

@@ -19,9 +19,6 @@ interface RuntimeAutoUpdateOptionManagerLike {
export interface RuntimeConfigLike {
auto_start_overlay?: boolean;
bind_visible_overlay_to_mpv_sub_visibility: boolean;
invisibleOverlay: {
startupVisibility: 'visible' | 'hidden' | 'platform-default';
};
ankiConnect?: {
behavior?: {
autoUpdateNewCards?: boolean;
@@ -155,21 +152,8 @@ function getStartupCriticalConfigErrors(config: AppReadyConfigLike): string[] {
return errors;
}
export function getInitialInvisibleOverlayVisibility(
config: RuntimeConfigLike,
platform: NodeJS.Platform,
): boolean {
const visibility = config.invisibleOverlay.startupVisibility;
if (visibility === 'visible') return true;
if (visibility === 'hidden') return false;
if (platform === 'linux') return false;
return true;
}
export function shouldAutoInitializeOverlayRuntimeFromConfig(config: RuntimeConfigLike): boolean {
if (config.auto_start_overlay === true) return true;
if (config.invisibleOverlay.startupVisibility === 'visible') return true;
return false;
return config.auto_start_overlay === true;
}
export function shouldBindVisibleOverlayToMpvSubVisibility(config: RuntimeConfigLike): boolean {

View File

@@ -101,20 +101,7 @@ export function loadSubtitlePosition(
const data = fs.readFileSync(positionPath, 'utf-8');
const parsed = JSON.parse(data) as Partial<SubtitlePosition>;
if (parsed && typeof parsed.yPercent === 'number' && Number.isFinite(parsed.yPercent)) {
const position: SubtitlePosition = { yPercent: parsed.yPercent };
if (
typeof parsed.invisibleOffsetXPx === 'number' &&
Number.isFinite(parsed.invisibleOffsetXPx)
) {
position.invisibleOffsetXPx = parsed.invisibleOffsetXPx;
}
if (
typeof parsed.invisibleOffsetYPx === 'number' &&
Number.isFinite(parsed.invisibleOffsetYPx)
) {
position.invisibleOffsetYPx = parsed.invisibleOffsetYPx;
}
return position;
return { yPercent: parsed.yPercent };
}
return options.fallbackPosition;
} catch (err) {

View File

@@ -2,7 +2,6 @@ import { Config } from '../../types';
export interface ConfiguredShortcuts {
toggleVisibleOverlayGlobal: string | null | undefined;
toggleInvisibleOverlayGlobal: string | null | undefined;
copySubtitle: string | null | undefined;
copySubtitleMultiple: string | null | undefined;
updateLastCardFromClipboard: string | null | undefined;
@@ -33,10 +32,6 @@ export function resolveConfiguredShortcuts(
config.shortcuts?.toggleVisibleOverlayGlobal ??
defaultConfig.shortcuts?.toggleVisibleOverlayGlobal,
),
toggleInvisibleOverlayGlobal: normalizeShortcut(
config.shortcuts?.toggleInvisibleOverlayGlobal ??
defaultConfig.shortcuts?.toggleInvisibleOverlayGlobal,
),
copySubtitle: normalizeShortcut(
config.shortcuts?.copySubtitle ?? defaultConfig.shortcuts?.copySubtitle,
),

View File

@@ -17,9 +17,7 @@ export interface CliCommandRuntimeServiceContext {
isOverlayInitialized: () => boolean;
initializeOverlay: () => void;
toggleVisibleOverlay: () => void;
toggleInvisibleOverlay: () => void;
setVisibleOverlay: (visible: boolean) => void;
setInvisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
mineSentenceCard: () => Promise<void>;
@@ -74,9 +72,7 @@ function createCliCommandDepsFromContext(
isInitialized: context.isOverlayInitialized,
initialize: context.initializeOverlay,
toggleVisible: context.toggleVisibleOverlay,
toggleInvisible: context.toggleInvisibleOverlay,
setVisible: context.setVisibleOverlay,
setInvisible: context.setInvisibleOverlay,
},
mining: {
copyCurrentSubtitle: context.copyCurrentSubtitle,

View File

@@ -53,11 +53,10 @@ export function createSubsyncRuntimeDeps(params: SubsyncRuntimeDepsParams): Subs
}
export interface MainIpcRuntimeServiceDepsParams {
getInvisibleWindow: IpcDepsRuntimeOptions['getInvisibleWindow'];
getMainWindow: IpcDepsRuntimeOptions['getMainWindow'];
getVisibleOverlayVisibility: IpcDepsRuntimeOptions['getVisibleOverlayVisibility'];
getInvisibleOverlayVisibility: IpcDepsRuntimeOptions['getInvisibleOverlayVisibility'];
onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed'];
onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened'];
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
quitApp: IpcDepsRuntimeOptions['quitApp'];
toggleVisibleOverlay: IpcDepsRuntimeOptions['toggleVisibleOverlay'];
@@ -65,7 +64,6 @@ export interface MainIpcRuntimeServiceDepsParams {
getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw'];
getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss'];
focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow'];
getMpvSubtitleRenderMetrics: IpcDepsRuntimeOptions['getMpvSubtitleRenderMetrics'];
getSubtitlePosition: IpcDepsRuntimeOptions['getSubtitlePosition'];
getSubtitleStyle: IpcDepsRuntimeOptions['getSubtitleStyle'];
saveSubtitlePosition: IpcDepsRuntimeOptions['saveSubtitlePosition'];
@@ -81,7 +79,6 @@ export interface MainIpcRuntimeServiceDepsParams {
setRuntimeOption: IpcDepsRuntimeOptions['setRuntimeOption'];
cycleRuntimeOption: IpcDepsRuntimeOptions['cycleRuntimeOption'];
reportOverlayContentBounds: IpcDepsRuntimeOptions['reportOverlayContentBounds'];
reportHoveredSubtitleToken: IpcDepsRuntimeOptions['reportHoveredSubtitleToken'];
getAnilistStatus: IpcDepsRuntimeOptions['getAnilistStatus'];
clearAnilistToken: IpcDepsRuntimeOptions['clearAnilistToken'];
openAnilistSetup: IpcDepsRuntimeOptions['openAnilistSetup'];
@@ -132,9 +129,7 @@ export interface CliCommandRuntimeServiceDepsParams {
isInitialized: CliCommandDepsRuntimeOptions['overlay']['isInitialized'];
initialize: CliCommandDepsRuntimeOptions['overlay']['initialize'];
toggleVisible: CliCommandDepsRuntimeOptions['overlay']['toggleVisible'];
toggleInvisible: CliCommandDepsRuntimeOptions['overlay']['toggleInvisible'];
setVisible: CliCommandDepsRuntimeOptions['overlay']['setVisible'];
setInvisible: CliCommandDepsRuntimeOptions['overlay']['setInvisible'];
};
mining: {
copyCurrentSubtitle: CliCommandDepsRuntimeOptions['mining']['copyCurrentSubtitle'];
@@ -192,18 +187,16 @@ export function createMainIpcRuntimeServiceDeps(
params: MainIpcRuntimeServiceDepsParams,
): IpcDepsRuntimeOptions {
return {
getInvisibleWindow: params.getInvisibleWindow,
getMainWindow: params.getMainWindow,
getVisibleOverlayVisibility: params.getVisibleOverlayVisibility,
getInvisibleOverlayVisibility: params.getInvisibleOverlayVisibility,
onOverlayModalClosed: params.onOverlayModalClosed,
onOverlayModalOpened: params.onOverlayModalOpened,
openYomitanSettings: params.openYomitanSettings,
quitApp: params.quitApp,
toggleVisibleOverlay: params.toggleVisibleOverlay,
tokenizeCurrentSubtitle: params.tokenizeCurrentSubtitle,
getCurrentSubtitleRaw: params.getCurrentSubtitleRaw,
getCurrentSubtitleAss: params.getCurrentSubtitleAss,
getMpvSubtitleRenderMetrics: params.getMpvSubtitleRenderMetrics,
getSubtitlePosition: params.getSubtitlePosition,
getSubtitleStyle: params.getSubtitleStyle,
saveSubtitlePosition: params.saveSubtitlePosition,
@@ -220,7 +213,6 @@ export function createMainIpcRuntimeServiceDeps(
setRuntimeOption: params.setRuntimeOption,
cycleRuntimeOption: params.cycleRuntimeOption,
reportOverlayContentBounds: params.reportOverlayContentBounds,
reportHoveredSubtitleToken: params.reportHoveredSubtitleToken,
getAnilistStatus: params.getAnilistStatus,
clearAnilistToken: params.clearAnilistToken,
openAnilistSetup: params.openAnilistSetup,
@@ -279,9 +271,7 @@ export function createCliCommandRuntimeServiceDeps(
isInitialized: params.overlay.isInitialized,
initialize: params.overlay.initialize,
toggleVisible: params.overlay.toggleVisible,
toggleInvisible: params.overlay.toggleInvisible,
setVisible: params.overlay.setVisible,
setInvisible: params.overlay.setInvisible,
},
mining: {
copyCurrentSubtitle: params.mining.copyCurrentSubtitle,

View File

@@ -2,50 +2,31 @@ import type { BrowserWindow } from 'electron';
import type { BaseWindowTracker } from '../window-trackers';
import type { WindowGeometry } from '../types';
import {
syncInvisibleOverlayMousePassthrough,
updateInvisibleOverlayVisibility,
updateVisibleOverlayVisibility,
} from '../core/services';
import { updateVisibleOverlayVisibility } from '../core/services';
export interface OverlayVisibilityRuntimeDeps {
getMainWindow: () => BrowserWindow | null;
getInvisibleWindow: () => BrowserWindow | null;
getVisibleOverlayVisible: () => boolean;
getInvisibleOverlayVisible: () => boolean;
getWindowTracker: () => BaseWindowTracker | null;
getTrackerNotReadyWarningShown: () => boolean;
setTrackerNotReadyWarningShown: (shown: boolean) => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void;
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
enforceOverlayLayerOrder: () => void;
syncOverlayShortcuts: () => void;
isMacOSPlatform: () => boolean;
showOverlayLoadingOsd: (message: string) => void;
resolveFallbackBounds: () => WindowGeometry;
}
export interface OverlayVisibilityRuntimeService {
updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
syncInvisibleOverlayMousePassthrough: () => void;
}
export function createOverlayVisibilityRuntimeService(
deps: OverlayVisibilityRuntimeDeps,
): OverlayVisibilityRuntimeService {
const hasInvisibleWindow = (): boolean => {
const invisibleWindow = deps.getInvisibleWindow();
return Boolean(invisibleWindow && !invisibleWindow.isDestroyed());
};
const setIgnoreMouseEvents = (
ignore: boolean,
options?: Parameters<BrowserWindow['setIgnoreMouseEvents']>[1],
): void => {
const invisibleWindow = deps.getInvisibleWindow();
if (!invisibleWindow || invisibleWindow.isDestroyed()) return;
invisibleWindow.setIgnoreMouseEvents(ignore, options);
};
return {
updateVisibleOverlayVisibility(): void {
updateVisibleOverlayVisibility({
@@ -59,31 +40,13 @@ export function createOverlayVisibilityRuntimeService(
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
deps.updateVisibleOverlayBounds(geometry),
ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window),
syncPrimaryOverlayWindowLayer: (layer: 'visible') =>
deps.syncPrimaryOverlayWindowLayer(layer),
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
});
},
updateInvisibleOverlayVisibility(): void {
updateInvisibleOverlayVisibility({
invisibleWindow: deps.getInvisibleWindow(),
visibleOverlayVisible: deps.getVisibleOverlayVisible(),
invisibleOverlayVisible: deps.getInvisibleOverlayVisible(),
windowTracker: deps.getWindowTracker(),
updateInvisibleOverlayBounds: (geometry: WindowGeometry) =>
deps.updateInvisibleOverlayBounds(geometry),
ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window),
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
});
},
syncInvisibleOverlayMousePassthrough(): void {
syncInvisibleOverlayMousePassthrough({
hasInvisibleWindow,
setIgnoreMouseEvents,
visibleOverlayVisible: deps.getVisibleOverlayVisible(),
invisibleOverlayVisible: deps.getInvisibleOverlayVisible(),
isMacOSPlatform: deps.isMacOSPlatform(),
showOverlayLoadingOsd: (message: string) => deps.showOverlayLoadingOsd(message),
resolveFallbackBounds: () => deps.resolveFallbackBounds(),
});
},
};

View File

@@ -19,14 +19,12 @@ test('restore windows on activate deps builder maps all restoration callbacks',
const calls: string[] = [];
const deps = createBuildRestoreWindowsOnActivateMainDepsHandler({
createMainWindow: () => calls.push('main'),
createInvisibleWindow: () => calls.push('invisible'),
updateVisibleOverlayVisibility: () => calls.push('visible'),
updateInvisibleOverlayVisibility: () => calls.push('invisible-visible'),
syncOverlayMpvSubtitleSuppression: () => calls.push('mpv-sync'),
})();
deps.createMainWindow();
deps.createInvisibleWindow();
deps.updateVisibleOverlayVisibility();
deps.updateInvisibleOverlayVisibility();
assert.deepEqual(calls, ['main', 'invisible', 'visible', 'invisible-visible']);
deps.syncOverlayMpvSubtitleSuppression();
assert.deepEqual(calls, ['main', 'visible', 'mpv-sync']);
});

View File

@@ -10,14 +10,12 @@ export function createBuildShouldRestoreWindowsOnActivateMainDepsHandler(deps: {
export function createBuildRestoreWindowsOnActivateMainDepsHandler(deps: {
createMainWindow: () => void;
createInvisibleWindow: () => void;
updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
}) {
return () => ({
createMainWindow: () => deps.createMainWindow(),
createInvisibleWindow: () => deps.createInvisibleWindow(),
updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(),
updateInvisibleOverlayVisibility: () => deps.updateInvisibleOverlayVisibility(),
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
});
}

View File

@@ -50,26 +50,18 @@ test('initialize overlay runtime main deps map build options and callbacks', ()
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntimeCore: (value) => {
calls.push(`core:${JSON.stringify(value)}`);
return { invisibleOverlayVisible: true };
},
buildOptions: () => options,
setInvisibleOverlayVisible: (visible) => calls.push(`set-invisible:${visible}`),
setOverlayRuntimeInitialized: (initialized) => calls.push(`set-initialized:${initialized}`),
startBackgroundWarmups: () => calls.push('warmups'),
})();
assert.equal(deps.isOverlayRuntimeInitialized(), false);
assert.equal(deps.buildOptions(), options);
assert.deepEqual(deps.initializeOverlayRuntimeCore(options), { invisibleOverlayVisible: true });
deps.setInvisibleOverlayVisible(true);
assert.equal(deps.initializeOverlayRuntimeCore(options), undefined);
deps.setOverlayRuntimeInitialized(true);
deps.startBackgroundWarmups();
assert.deepEqual(calls, [
'core:{"id":"opts"}',
'set-invisible:true',
'set-initialized:true',
'warmups',
]);
assert.deepEqual(calls, ['core:{"id":"opts"}', 'set-initialized:true', 'warmups']);
});
test('open yomitan settings main deps map async open callbacks', async () => {

View File

@@ -45,9 +45,8 @@ export function createBuildDestroyTrayMainDepsHandler<TTray>(deps: {
export function createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler<TOptions>(deps: {
isOverlayRuntimeInitialized: () => boolean;
initializeOverlayRuntimeCore: (options: TOptions) => { invisibleOverlayVisible: boolean };
initializeOverlayRuntimeCore: (options: TOptions) => void;
buildOptions: () => TOptions;
setInvisibleOverlayVisible: (visible: boolean) => void;
setOverlayRuntimeInitialized: (initialized: boolean) => void;
startBackgroundWarmups: () => void;
}) {
@@ -55,7 +54,6 @@ export function createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler<TOpt
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
initializeOverlayRuntimeCore: (options: TOptions) => deps.initializeOverlayRuntimeCore(options),
buildOptions: () => deps.buildOptions(),
setInvisibleOverlayVisible: (visible: boolean) => deps.setInvisibleOverlayVisible(visible),
setOverlayRuntimeInitialized: (initialized: boolean) =>
deps.setOverlayRuntimeInitialized(initialized),
startBackgroundWarmups: () => deps.startBackgroundWarmups(),

View File

@@ -18,9 +18,7 @@ test('build cli command context deps maps handlers and values', () => {
isOverlayInitialized: () => true,
initializeOverlay: () => calls.push('init'),
toggleVisibleOverlay: () => calls.push('toggle-visible'),
toggleInvisibleOverlay: () => calls.push('toggle-invisible'),
setVisibleOverlay: (visible) => calls.push(`set-visible:${visible}`),
setInvisibleOverlay: (visible) => calls.push(`set-invisible:${visible}`),
copyCurrentSubtitle: () => calls.push('copy'),
startPendingMultiCopy: (ms) => calls.push(`multi:${ms}`),
mineSentenceCard: async () => {

View File

@@ -15,9 +15,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
isOverlayInitialized: () => boolean;
initializeOverlay: () => void;
toggleVisibleOverlay: () => void;
toggleInvisibleOverlay: () => void;
setVisibleOverlay: (visible: boolean) => void;
setInvisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
mineSentenceCard: () => Promise<void>;
@@ -60,9 +58,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
isOverlayInitialized: deps.isOverlayInitialized,
initializeOverlay: deps.initializeOverlay,
toggleVisibleOverlay: deps.toggleVisibleOverlay,
toggleInvisibleOverlay: deps.toggleInvisibleOverlay,
setVisibleOverlay: deps.setVisibleOverlay,
setInvisibleOverlay: deps.setInvisibleOverlay,
copyCurrentSubtitle: deps.copyCurrentSubtitle,
startPendingMultiCopy: deps.startPendingMultiCopy,
mineSentenceCard: deps.mineSentenceCard,

View File

@@ -20,9 +20,7 @@ test('cli command context factory composes main deps and context handlers', () =
showMpvOsd: (text) => calls.push(`osd:${text}`),
initializeOverlayRuntime: () => calls.push('init-overlay'),
toggleVisibleOverlay: () => calls.push('toggle-visible'),
toggleInvisibleOverlay: () => calls.push('toggle-invisible'),
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
setInvisibleOverlayVisible: (visible) => calls.push(`set-invisible:${visible}`),
copyCurrentSubtitle: () => calls.push('copy-sub'),
startPendingMultiCopy: (timeoutMs) => calls.push(`multi:${timeoutMs}`),
mineSentenceCard: async () => {},
@@ -73,16 +71,8 @@ test('cli command context factory composes main deps and context handlers', () =
context.setSocketPath('/tmp/new.sock');
context.showOsd('hello');
context.setVisibleOverlay(true);
context.setInvisibleOverlay(false);
context.toggleVisibleOverlay();
context.toggleInvisibleOverlay();
assert.equal(appState.mpvSocketPath, '/tmp/new.sock');
assert.deepEqual(calls, [
'osd:hello',
'set-visible:true',
'set-invisible:false',
'toggle-visible',
'toggle-invisible',
]);
assert.deepEqual(calls, ['osd:hello', 'set-visible:true', 'toggle-visible']);
});

View File

@@ -23,9 +23,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
initializeOverlayRuntime: () => calls.push('init-overlay'),
toggleVisibleOverlay: () => calls.push('toggle-visible'),
toggleInvisibleOverlay: () => calls.push('toggle-invisible'),
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
setInvisibleOverlayVisible: (visible) => calls.push(`set-invisible:${visible}`),
copyCurrentSubtitle: () => calls.push('copy-sub'),
startPendingMultiCopy: (timeoutMs) => calls.push(`multi:${timeoutMs}`),
@@ -103,16 +101,9 @@ test('cli command context main deps builder maps state and callbacks', async ()
deps.showOsd('hello');
deps.initializeOverlay();
deps.setVisibleOverlay(true);
deps.setInvisibleOverlay(false);
deps.printHelp();
assert.deepEqual(calls, [
'osd:hello',
'init-overlay',
'set-visible:true',
'set-invisible:false',
'help',
]);
assert.deepEqual(calls, ['osd:hello', 'init-overlay', 'set-visible:true', 'help']);
const retry = await deps.retryAnilistQueueNow();
assert.deepEqual(retry, { ok: true, message: 'ok' });

View File

@@ -18,9 +18,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
initializeOverlayRuntime: () => void;
toggleVisibleOverlay: () => void;
toggleInvisibleOverlay: () => void;
setVisibleOverlayVisible: (visible: boolean) => void;
setInvisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
@@ -70,9 +68,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
isOverlayInitialized: () => deps.appState.overlayRuntimeInitialized,
initializeOverlay: () => deps.initializeOverlayRuntime(),
toggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
toggleInvisibleOverlay: () => deps.toggleInvisibleOverlay(),
setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible),
setInvisibleOverlay: (visible: boolean) => deps.setInvisibleOverlayVisible(visible),
copyCurrentSubtitle: () => deps.copyCurrentSubtitle(),
startPendingMultiCopy: (timeoutMs: number) => deps.startPendingMultiCopy(timeoutMs),
mineSentenceCard: () => deps.mineSentenceCard(),

View File

@@ -24,9 +24,7 @@ function createDeps() {
isOverlayInitialized: () => true,
initializeOverlay: () => {},
toggleVisibleOverlay: () => {},
toggleInvisibleOverlay: () => {},
setVisibleOverlay: () => {},
setInvisibleOverlay: () => {},
copyCurrentSubtitle: () => {},
startPendingMultiCopy: () => {},
mineSentenceCard: async () => {},

View File

@@ -20,9 +20,7 @@ export type CliCommandContextFactoryDeps = {
isOverlayInitialized: () => boolean;
initializeOverlay: () => void;
toggleVisibleOverlay: () => void;
toggleInvisibleOverlay: () => void;
setVisibleOverlay: (visible: boolean) => void;
setInvisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
mineSentenceCard: () => Promise<void>;
@@ -72,9 +70,7 @@ export function createCliCommandContext(
isOverlayInitialized: deps.isOverlayInitialized,
initializeOverlay: deps.initializeOverlay,
toggleVisibleOverlay: deps.toggleVisibleOverlay,
toggleInvisibleOverlay: deps.toggleInvisibleOverlay,
setVisibleOverlay: deps.setVisibleOverlay,
setInvisibleOverlay: deps.setInvisibleOverlay,
copyCurrentSubtitle: deps.copyCurrentSubtitle,
startPendingMultiCopy: deps.startPendingMultiCopy,
mineSentenceCard: deps.mineSentenceCard,

View File

@@ -32,10 +32,8 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
showMpvOsd: () => {},
},
mainDeps: {
getInvisibleWindow: () => null,
getMainWindow: () => null,
getVisibleOverlayVisibility: () => false,
getInvisibleOverlayVisibility: () => false,
focusMainWindow: () => {},
onOverlayModalClosed: () => {},
openYomitanSettings: () => {},
@@ -44,7 +42,6 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '',
getMpvSubtitleRenderMetrics: () => ({}) as never,
getSubtitlePosition: () => ({}) as never,
getSubtitleStyle: () => ({}) as never,
saveSubtitlePosition: () => {},
@@ -56,7 +53,6 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
getAnkiConnectStatus: () => false,
getRuntimeOptions: () => [],
reportOverlayContentBounds: () => {},
reportHoveredSubtitleToken: () => {},
getAnilistStatus: () => ({}) as never,
clearAnilistToken: () => {},
openAnilistSetup: () => {},

View File

@@ -68,12 +68,14 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibilityForInvisibleOverlay: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},

View File

@@ -14,7 +14,6 @@ test('composeShortcutRuntimes returns callable shortcut runtime handlers', () =>
getConfiguredShortcuts: () => ({}) as never,
registerGlobalShortcutsCore: () => {},
toggleVisibleOverlay: () => {},
toggleInvisibleOverlay: () => {},
openYomitanSettings: () => {},
isDev: false,
getMainWindow: () => null,

View File

@@ -1,7 +1,6 @@
import type { RuntimeOptionsManager } from '../../runtime-options';
import type { JimakuApiResponse, JimakuLanguagePreference, ResolvedConfig } from '../../types';
import {
getInitialInvisibleOverlayVisibility as getInitialInvisibleOverlayVisibilityCore,
getJimakuLanguagePreference as getJimakuLanguagePreferenceCore,
getJimakuMaxEntryResults as getJimakuMaxEntryResultsCore,
isAutoUpdateEnabledRuntime as isAutoUpdateEnabledRuntimeCore,
@@ -14,14 +13,12 @@ import {
export type ConfigDerivedRuntimeDeps = {
getResolvedConfig: () => ResolvedConfig;
getRuntimeOptionsManager: () => RuntimeOptionsManager | null;
platform: NodeJS.Platform;
defaultJimakuLanguagePreference: JimakuLanguagePreference;
defaultJimakuMaxEntryResults: number;
defaultJimakuApiBaseUrl: string;
};
export function createConfigDerivedRuntime(deps: ConfigDerivedRuntimeDeps): {
getInitialInvisibleOverlayVisibility: () => boolean;
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
isAutoUpdateEnabledRuntime: () => boolean;
@@ -34,8 +31,6 @@ export function createConfigDerivedRuntime(deps: ConfigDerivedRuntimeDeps): {
) => Promise<JimakuApiResponse<T>>;
} {
return {
getInitialInvisibleOverlayVisibility: () =>
getInitialInvisibleOverlayVisibilityCore(deps.getResolvedConfig(), deps.platform),
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
shouldAutoInitializeOverlayRuntimeFromConfigCore(deps.getResolvedConfig()),
shouldBindVisibleOverlayToMpvSubVisibility: () =>

View File

@@ -15,9 +15,7 @@ test('field grouping overlay main deps builder maps window visibility and resolv
},
}),
getVisibleOverlayVisible: () => true,
getInvisibleOverlayVisible: () => false,
setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`),
setInvisibleOverlayVisible: (visible) => calls.push(`invisible:${visible}`),
getResolver: () => resolver,
setResolver: (nextResolver) => {
calls.push(`set-resolver:${nextResolver ? 'set' : 'null'}`);
@@ -31,17 +29,10 @@ test('field grouping overlay main deps builder maps window visibility and resolv
assert.equal(deps.getMainWindow()?.isDestroyed(), false);
assert.equal(deps.getVisibleOverlayVisible(), true);
assert.equal(deps.getInvisibleOverlayVisible(), false);
assert.equal(deps.getResolver(), resolver);
assert.equal(deps.getRestoreVisibleOverlayOnModalClose(), modalSet);
deps.setVisibleOverlayVisible(true);
deps.setInvisibleOverlayVisible(false);
deps.setResolver(null);
assert.equal(deps.sendToVisibleOverlay('kiku:open', 1), true);
assert.deepEqual(calls, [
'visible:true',
'invisible:false',
'set-resolver:null',
'send:kiku:open:1',
]);
assert.deepEqual(calls, ['visible:true', 'set-resolver:null', 'send:kiku:open:1']);
});

View File

@@ -24,9 +24,7 @@ export function createBuildFieldGroupingOverlayMainDepsHandler<TModal extends st
return (): BuiltFieldGroupingOverlayMainDeps<TModal> => ({
getMainWindow: () => deps.getMainWindow(),
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
getInvisibleOverlayVisible: () => deps.getInvisibleOverlayVisible(),
setVisibleOverlayVisible: (visible: boolean) => deps.setVisibleOverlayVisible(visible),
setInvisibleOverlayVisible: (visible: boolean) => deps.setInvisibleOverlayVisible(visible),
getResolver: () => deps.getResolver(),
setResolver: (resolver) => deps.setResolver(resolver),
getRestoreVisibleOverlayOnModalClose: () => deps.getRestoreVisibleOverlayOnModalClose(),

View File

@@ -28,7 +28,6 @@ test('register global shortcuts main deps map callbacks and flags', () => {
getConfiguredShortcuts: () => ({ copySubtitle: 's' } as never),
registerGlobalShortcutsCore: () => calls.push('register'),
toggleVisibleOverlay: () => calls.push('toggle-visible'),
toggleInvisibleOverlay: () => calls.push('toggle-invisible'),
openYomitanSettings: () => calls.push('open-yomitan'),
isDev: true,
getMainWindow: () => mainWindow as never,
@@ -38,17 +37,15 @@ test('register global shortcuts main deps map callbacks and flags', () => {
deps.registerGlobalShortcutsCore({
shortcuts: deps.getConfiguredShortcuts(),
onToggleVisibleOverlay: () => undefined,
onToggleInvisibleOverlay: () => undefined,
onOpenYomitanSettings: () => undefined,
isDev: deps.isDev,
getMainWindow: deps.getMainWindow,
});
deps.onToggleVisibleOverlay();
deps.onToggleInvisibleOverlay();
deps.onOpenYomitanSettings();
assert.equal(deps.isDev, true);
assert.deepEqual(deps.getMainWindow(), mainWindow);
assert.deepEqual(calls, ['register', 'toggle-visible', 'toggle-invisible', 'open-yomitan']);
assert.deepEqual(calls, ['register', 'toggle-visible', 'open-yomitan']);
});
test('refresh global shortcuts main deps map passthrough handlers', () => {

View File

@@ -19,7 +19,6 @@ export function createBuildRegisterGlobalShortcutsMainDepsHandler(deps: {
getConfiguredShortcuts: () => RegisterGlobalShortcutsServiceOptions['shortcuts'];
registerGlobalShortcutsCore: (options: RegisterGlobalShortcutsServiceOptions) => void;
toggleVisibleOverlay: () => void;
toggleInvisibleOverlay: () => void;
openYomitanSettings: () => void;
isDev: boolean;
getMainWindow: RegisterGlobalShortcutsServiceOptions['getMainWindow'];
@@ -29,7 +28,6 @@ export function createBuildRegisterGlobalShortcutsMainDepsHandler(deps: {
registerGlobalShortcutsCore: (options: RegisterGlobalShortcutsServiceOptions) =>
deps.registerGlobalShortcutsCore(options),
onToggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
onToggleInvisibleOverlay: () => deps.toggleInvisibleOverlay(),
onOpenYomitanSettings: () => deps.openYomitanSettings(),
isDev: deps.isDev,
getMainWindow: deps.getMainWindow,

View File

@@ -6,7 +6,6 @@ import { createGlobalShortcutsRuntimeHandlers } from './global-shortcuts-runtime
function createShortcuts(): ConfiguredShortcuts {
return {
toggleVisibleOverlayGlobal: 'CommandOrControl+Shift+O',
toggleInvisibleOverlayGlobal: 'CommandOrControl+Shift+I',
copySubtitle: 's',
copySubtitleMultiple: 'CommandOrControl+s',
updateLastCardFromClipboard: 'c',
@@ -38,7 +37,6 @@ test('global shortcuts runtime handlers compose get/register/refresh flow', () =
assert.equal(options.shortcuts, shortcuts);
},
toggleVisibleOverlay: () => calls.push('toggle-visible'),
toggleInvisibleOverlay: () => calls.push('toggle-invisible'),
openYomitanSettings: () => calls.push('open-yomitan'),
isDev: false,
getMainWindow: () => null,

View File

@@ -10,7 +10,6 @@ import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
function createShortcuts(): ConfiguredShortcuts {
return {
toggleVisibleOverlayGlobal: 'CommandOrControl+Shift+O',
toggleInvisibleOverlayGlobal: 'CommandOrControl+Shift+I',
copySubtitle: 's',
copySubtitleMultiple: 'CommandOrControl+s',
updateLastCardFromClipboard: 'c',
@@ -58,18 +57,16 @@ test('register global shortcuts handler passes through callbacks and shortcuts',
assert.equal(options.isDev, true);
assert.equal(options.getMainWindow(), mainWindow);
options.onToggleVisibleOverlay();
options.onToggleInvisibleOverlay();
options.onOpenYomitanSettings();
},
onToggleVisibleOverlay: () => calls.push('toggle-visible'),
onToggleInvisibleOverlay: () => calls.push('toggle-invisible'),
onOpenYomitanSettings: () => calls.push('open-yomitan'),
isDev: true,
getMainWindow: () => mainWindow,
});
registerGlobalShortcuts();
assert.deepEqual(calls, ['register', 'toggle-visible', 'toggle-invisible', 'open-yomitan']);
assert.deepEqual(calls, ['register', 'toggle-visible', 'open-yomitan']);
});
test('refresh global and overlay shortcuts unregisters then re-registers', () => {

View File

@@ -18,7 +18,6 @@ export function createRegisterGlobalShortcutsHandler(deps: {
getConfiguredShortcuts: () => RegisterGlobalShortcutsServiceOptions['shortcuts'];
registerGlobalShortcutsCore: (options: RegisterGlobalShortcutsServiceOptions) => void;
onToggleVisibleOverlay: () => void;
onToggleInvisibleOverlay: () => void;
onOpenYomitanSettings: () => void;
isDev: boolean;
getMainWindow: RegisterGlobalShortcutsServiceOptions['getMainWindow'];
@@ -27,7 +26,6 @@ export function createRegisterGlobalShortcutsHandler(deps: {
deps.registerGlobalShortcutsCore({
shortcuts: deps.getConfiguredShortcuts(),
onToggleVisibleOverlay: deps.onToggleVisibleOverlay,
onToggleInvisibleOverlay: deps.onToggleInvisibleOverlay,
onOpenYomitanSettings: deps.onOpenYomitanSettings,
isDev: deps.isDev,
getMainWindow: deps.getMainWindow,

View File

@@ -24,9 +24,6 @@ function createArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggleOverlay: false,
hideOverlay: false,
showOverlay: false,
toggleInvisibleOverlay: false,
hideInvisibleOverlay: false,
showInvisibleOverlay: false,
copyCurrentSubtitle: false,
multiCopy: false,
mineSentence: false,

View File

@@ -11,6 +11,7 @@ test('mpv connection handler reports stop and quits when disconnect guard passes
const handler = createHandleMpvConnectionChangeHandler({
reportJellyfinRemoteStopped: () => calls.push('report-stop'),
refreshDiscordPresence: () => calls.push('presence-refresh'),
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
hasInitialJellyfinPlayArg: () => true,
isOverlayRuntimeInitialized: () => false,
isQuitOnDisconnectArmed: () => true,
@@ -26,6 +27,27 @@ test('mpv connection handler reports stop and quits when disconnect guard passes
assert.deepEqual(calls, ['presence-refresh', 'report-stop', 'schedule', 'quit']);
});
test('mpv connection handler syncs overlay subtitle suppression on connect', () => {
const calls: string[] = [];
const handler = createHandleMpvConnectionChangeHandler({
reportJellyfinRemoteStopped: () => calls.push('report-stop'),
refreshDiscordPresence: () => calls.push('presence-refresh'),
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
hasInitialJellyfinPlayArg: () => true,
isOverlayRuntimeInitialized: () => false,
isQuitOnDisconnectArmed: () => true,
scheduleQuitCheck: () => {
calls.push('schedule');
},
isMpvConnected: () => false,
quitApp: () => calls.push('quit'),
});
handler({ connected: true });
assert.deepEqual(calls, ['presence-refresh', 'sync-overlay-mpv-sub']);
});
test('mpv subtitle timing handler ignores blank subtitle lines', () => {
const calls: string[] = [];
const handler = createHandleMpvSubtitleTimingHandler({

View File

@@ -18,6 +18,7 @@ type MpvEventClient = {
export function createHandleMpvConnectionChangeHandler(deps: {
reportJellyfinRemoteStopped: () => void;
refreshDiscordPresence: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
hasInitialJellyfinPlayArg: () => boolean;
isOverlayRuntimeInitialized: () => boolean;
isQuitOnDisconnectArmed: () => boolean;
@@ -27,7 +28,10 @@ export function createHandleMpvConnectionChangeHandler(deps: {
}) {
return ({ connected }: { connected: boolean }): void => {
deps.refreshDiscordPresence();
if (connected) return;
if (connected) {
deps.syncOverlayMpvSubtitleSuppression();
return;
}
deps.reportJellyfinRemoteStopped();
if (!deps.hasInitialJellyfinPlayArg()) return;
if (deps.isOverlayRuntimeInitialized()) return;

View File

@@ -1,161 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { PartOfSpeech, type SubtitleData } from '../../types';
import {
HOVER_TOKEN_MESSAGE,
HOVER_SCRIPT_NAME,
buildHoveredTokenMessageCommand,
buildHoveredTokenPayload,
createApplyHoveredTokenOverlayHandler,
} from './mpv-hover-highlight';
const SUBTITLE: SubtitleData = {
text: '昨日は雨だった。',
tokens: [
{
surface: '昨日',
reading: 'きのう',
headword: '昨日',
startPos: 0,
endPos: 2,
partOfSpeech: PartOfSpeech.noun,
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
{
surface: 'は',
reading: 'は',
headword: 'は',
startPos: 2,
endPos: 3,
partOfSpeech: PartOfSpeech.particle,
isMerged: false,
isKnown: true,
isNPlusOneTarget: false,
},
{
surface: '雨',
reading: 'あめ',
headword: '雨',
startPos: 3,
endPos: 4,
partOfSpeech: PartOfSpeech.noun,
isMerged: false,
isKnown: false,
isNPlusOneTarget: true,
},
{
surface: 'だった。',
reading: 'だった。',
headword: 'だ',
startPos: 4,
endPos: 8,
partOfSpeech: PartOfSpeech.other,
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
],
};
test('buildHoveredTokenPayload normalizes metadata and strips empty tokens', () => {
const payload = buildHoveredTokenPayload({
subtitle: SUBTITLE,
hoveredTokenIndex: 2,
revision: 5,
});
assert.equal(payload.revision, 5);
assert.equal(payload.subtitle, '昨日は雨だった。');
assert.equal(payload.hoveredTokenIndex, 2);
assert.equal(payload.tokens.length, 4);
assert.equal(payload.tokens[0]?.text, '昨日');
assert.equal(payload.tokens[0]?.index, 0);
assert.equal(payload.tokens[1]?.index, 1);
assert.equal(payload.colors.hover, 'C6A0F6');
});
test('buildHoveredTokenPayload normalizes hover color override', () => {
const payload = buildHoveredTokenPayload({
subtitle: SUBTITLE,
hoveredTokenIndex: 1,
revision: 7,
hoverColor: '#c6a0f6',
});
assert.equal(payload.colors.hover, 'C6A0F6');
});
test('buildHoveredTokenMessageCommand sends script-message-to subminer payload', () => {
const payload = buildHoveredTokenPayload({
subtitle: SUBTITLE,
hoveredTokenIndex: 0,
revision: 1,
});
const command = buildHoveredTokenMessageCommand(payload);
assert.equal(command[0], 'script-message-to');
assert.equal(command[1], HOVER_SCRIPT_NAME);
assert.equal(command[2], HOVER_TOKEN_MESSAGE);
const raw = command[3] as string;
const parsed = JSON.parse(raw);
assert.equal(parsed.revision, 1);
assert.equal(parsed.hoveredTokenIndex, 0);
assert.equal(parsed.subtitle, '昨日は雨だった。');
assert.equal(parsed.tokens.length, 4);
});
test('createApplyHoveredTokenOverlayHandler sends clear payload when hovered token is missing', () => {
const commands: Array<(string | number)[]> = [];
const apply = createApplyHoveredTokenOverlayHandler({
getMpvClient: () => ({
connected: true,
send: ({ command }: { command: (string | number)[] }) => {
commands.push(command);
return true;
},
}),
getCurrentSubtitleData: () => SUBTITLE,
getHoveredTokenIndex: () => null,
getHoveredSubtitleRevision: () => 3,
getHoverTokenColor: () => null,
});
apply();
const parsed = JSON.parse(commands[0]?.[3] as string);
assert.equal(parsed.hoveredTokenIndex, null);
assert.equal(parsed.subtitle, null);
assert.equal(parsed.tokens.length, 0);
});
test('createApplyHoveredTokenOverlayHandler sends highlight payload when hover is active', () => {
const commands: Array<(string | number)[]> = [];
const apply = createApplyHoveredTokenOverlayHandler({
getMpvClient: () => ({
connected: true,
send: ({ command }: { command: (string | number)[] }) => {
commands.push(command);
return true;
},
}),
getCurrentSubtitleData: () => SUBTITLE,
getHoveredTokenIndex: () => 0,
getHoveredSubtitleRevision: () => 3,
getHoverTokenColor: () => '#c6a0f6',
});
apply();
const parsed = JSON.parse(commands[0]?.[3] as string);
assert.equal(parsed.hoveredTokenIndex, 0);
assert.equal(parsed.subtitle, '昨日は雨だった。');
assert.equal(parsed.tokens.length, 4);
assert.equal(parsed.colors.hover, 'C6A0F6');
assert.equal(commands[0]?.[0], 'script-message-to');
assert.equal(commands[0]?.[1], HOVER_SCRIPT_NAME);
});

View File

@@ -1,138 +0,0 @@
import type { SubtitleData } from '../../types';
export const HOVER_SCRIPT_NAME = 'subminer';
export const HOVER_TOKEN_MESSAGE = 'subminer-hover-token';
const DEFAULT_HOVER_TOKEN_COLOR = 'C6A0F6';
const DEFAULT_TOKEN_COLOR = 'FFFFFF';
export type HoverPayloadToken = {
text: string;
index: number;
startPos: number | null;
endPos: number | null;
};
export type HoverTokenPayload = {
revision: number;
subtitle: string | null;
hoveredTokenIndex: number | null;
tokens: HoverPayloadToken[];
colors: {
base: string;
hover: string;
};
};
type HoverTokenInput = {
subtitle: SubtitleData | null;
hoveredTokenIndex: number | null;
revision: number;
hoverColor?: string | null;
};
function normalizeHexColor(color: string | null | undefined, fallback: string): string {
if (typeof color !== 'string') {
return fallback;
}
const normalized = color.trim().replace(/^#/, '').toUpperCase();
return /^[0-9A-F]{6}$/.test(normalized) ? normalized : fallback;
}
function sanitizeSubtitleText(text: string): string {
return text
.replace(/\\N/g, '\n')
.replace(/\\n/g, '\n')
.replace(/\{[^}]*\}/g, '')
.trim();
}
function sanitizeTokenSurface(surface: unknown): string {
return typeof surface === 'string' ? surface : '';
}
function hasHoveredToken(subtitle: SubtitleData | null, hoveredTokenIndex: number | null): boolean {
if (!subtitle || hoveredTokenIndex === null || hoveredTokenIndex < 0) {
return false;
}
return subtitle.tokens?.some((token, index) => index === hoveredTokenIndex) ?? false;
}
export function buildHoveredTokenPayload(input: HoverTokenInput): HoverTokenPayload {
const { subtitle, hoveredTokenIndex, revision, hoverColor } = input;
const tokens: HoverPayloadToken[] = [];
if (subtitle?.tokens && subtitle.tokens.length > 0) {
for (let tokenIndex = 0; tokenIndex < subtitle.tokens.length; tokenIndex += 1) {
const token = subtitle.tokens[tokenIndex];
if (!token) {
continue;
}
const surface = sanitizeTokenSurface(token?.surface);
if (!surface || surface.trim().length === 0) {
continue;
}
tokens.push({
text: surface,
index: tokenIndex,
startPos: Number.isFinite(token.startPos) ? token.startPos : null,
endPos: Number.isFinite(token.endPos) ? token.endPos : null,
});
}
}
return {
revision,
subtitle: subtitle ? sanitizeSubtitleText(subtitle.text) : null,
hoveredTokenIndex:
hoveredTokenIndex !== null && hoveredTokenIndex >= 0 ? hoveredTokenIndex : null,
tokens,
colors: {
base: DEFAULT_TOKEN_COLOR,
hover: normalizeHexColor(hoverColor, DEFAULT_HOVER_TOKEN_COLOR),
},
};
}
export function buildHoveredTokenMessageCommand(payload: HoverTokenPayload): (string | number)[] {
return [
'script-message-to',
HOVER_SCRIPT_NAME,
HOVER_TOKEN_MESSAGE,
JSON.stringify(payload),
];
}
export function createApplyHoveredTokenOverlayHandler(deps: {
getMpvClient: () => {
connected: boolean;
send: (payload: { command: (string | number)[] }) => boolean;
} | null;
getCurrentSubtitleData: () => SubtitleData | null;
getHoveredTokenIndex: () => number | null;
getHoveredSubtitleRevision: () => number;
getHoverTokenColor: () => string | null;
}) {
return (): void => {
const mpvClient = deps.getMpvClient();
if (!mpvClient || !mpvClient.connected) {
return;
}
const subtitle = deps.getCurrentSubtitleData();
const hoveredTokenIndex = deps.getHoveredTokenIndex();
const revision = deps.getHoveredSubtitleRevision();
const hoverColor = deps.getHoverTokenColor();
const payload = buildHoveredTokenPayload({
subtitle: subtitle && hasHoveredToken(subtitle, hoveredTokenIndex) ? subtitle : null,
hoveredTokenIndex: hoveredTokenIndex,
revision,
hoverColor,
});
mpvClient.send({ command: buildHoveredTokenMessageCommand(payload) });
};
}

View File

@@ -51,6 +51,7 @@ test('media path change handler reports stop for empty path and probes media key
const handler = createHandleMpvMediaPathChangeHandler({
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
reportJellyfinRemoteStopped: () => calls.push('stopped'),
restoreMpvSubVisibilityForInvisibleOverlay: () => calls.push('restore-mpv-sub'),
getCurrentAnilistMediaKey: () => 'show:1',
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
@@ -63,6 +64,7 @@ test('media path change handler reports stop for empty path and probes media key
assert.deepEqual(calls, [
'path:',
'stopped',
'restore-mpv-sub',
'reset:show:1',
'probe:show:1',
'guess:show:1',

View File

@@ -19,12 +19,10 @@ test('overlay content measurement store main deps builder maps callbacks', () =>
test('overlay modal runtime main deps builder maps window resolvers', () => {
const mainWindow = { id: 'main' };
const invisibleWindow = { id: 'invisible' };
const modalWindow = { id: 'modal' };
const calls: string[] = [];
const deps = createBuildOverlayModalRuntimeMainDepsHandler({
getMainWindow: () => mainWindow as never,
getInvisibleWindow: () => invisibleWindow as never,
getModalWindow: () => modalWindow as never,
createModalWindow: () => modalWindow as never,
getModalGeometry: () => ({ x: 1, y: 2, width: 3, height: 4 }),
@@ -33,7 +31,6 @@ test('overlay modal runtime main deps builder maps window resolvers', () => {
})();
assert.equal(deps.getMainWindow(), mainWindow);
assert.equal(deps.getInvisibleWindow(), invisibleWindow);
assert.equal(deps.getModalWindow(), modalWindow);
assert.equal(deps.createModalWindow(), modalWindow);
assert.deepEqual(deps.getModalGeometry(), { x: 1, y: 2, width: 3, height: 4 });

View File

@@ -19,7 +19,6 @@ export function createBuildOverlayModalRuntimeMainDepsHandler(
) {
return (): OverlayWindowResolver => ({
getMainWindow: () => deps.getMainWindow(),
getInvisibleWindow: () => deps.getInvisibleWindow(),
getModalWindow: () => deps.getModalWindow(),
createModalWindow: () => deps.createModalWindow(),
getModalGeometry: () => deps.getModalGeometry(),

View File

@@ -15,7 +15,6 @@ test('overlay runtime bootstrap handlers compose options builder and bootstrap h
ankiIntegration: null as unknown,
};
let initialized = false;
let invisibleOverlayVisible = false;
let warmupsStarted = 0;
const { initializeOverlayRuntime } = createOverlayRuntimeBootstrapHandlers({
@@ -23,21 +22,16 @@ test('overlay runtime bootstrap handlers compose options builder and bootstrap h
appState,
overlayManager: {
getVisibleOverlayVisible: () => true,
getInvisibleOverlayVisible: () => false,
},
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => {},
updateInvisibleOverlayVisibility: () => {},
},
overlayShortcutsRuntime: {
syncOverlayShortcuts: () => {},
},
getInitialInvisibleOverlayVisibility: () => false,
createMainWindow: () => {},
createInvisibleWindow: () => {},
registerGlobalShortcuts: () => {},
updateVisibleOverlayBounds: () => {},
updateInvisibleOverlayBounds: () => {},
getOverlayWindows: () => [],
getResolvedConfig: () => ({}),
showDesktopNotification: () => {},
@@ -52,10 +46,7 @@ test('overlay runtime bootstrap handlers compose options builder and bootstrap h
},
initializeOverlayRuntimeBootstrapDeps: {
isOverlayRuntimeInitialized: () => initialized,
initializeOverlayRuntimeCore: () => ({ invisibleOverlayVisible: true }),
setInvisibleOverlayVisible: (visible) => {
invisibleOverlayVisible = visible;
},
initializeOverlayRuntimeCore: () => {},
setOverlayRuntimeInitialized: (next) => {
initialized = next;
},
@@ -68,7 +59,6 @@ test('overlay runtime bootstrap handlers compose options builder and bootstrap h
initializeOverlayRuntime();
initializeOverlayRuntime();
assert.equal(invisibleOverlayVisible, true);
assert.equal(initialized, true);
assert.equal(warmupsStarted, 1);
});

View File

@@ -8,10 +8,8 @@ test('overlay runtime bootstrap no-ops when already initialized', () => {
isOverlayRuntimeInitialized: () => true,
initializeOverlayRuntimeCore: () => {
coreCalls += 1;
return { invisibleOverlayVisible: false };
},
buildOptions: () => ({} as never),
setInvisibleOverlayVisible: () => {},
setOverlayRuntimeInitialized: () => {},
startBackgroundWarmups: () => {},
});
@@ -27,15 +25,11 @@ test('overlay runtime bootstrap runs core init and applies post-init state', ()
isOverlayRuntimeInitialized: () => initialized,
initializeOverlayRuntimeCore: () => {
calls.push('core');
return { invisibleOverlayVisible: true };
},
buildOptions: () => {
calls.push('options');
return {} as never;
},
setInvisibleOverlayVisible: (visible) => {
calls.push(`invisible:${visible ? 'yes' : 'no'}`);
},
setOverlayRuntimeInitialized: (value) => {
initialized = value;
calls.push(`initialized:${value ? 'yes' : 'no'}`);
@@ -47,5 +41,5 @@ test('overlay runtime bootstrap runs core init and applies post-init state', ()
initialize();
assert.equal(initialized, true);
assert.deepEqual(calls, ['options', 'core', 'invisible:yes', 'initialized:yes', 'warmups']);
assert.deepEqual(calls, ['options', 'core', 'initialized:yes', 'warmups']);
});

View File

@@ -9,16 +9,11 @@ import type {
type InitializeOverlayRuntimeCore = (options: {
backendOverride: string | null;
getInitialInvisibleOverlayVisibility: () => boolean;
createMainWindow: () => void;
createInvisibleWindow: () => void;
registerGlobalShortcuts: () => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void;
isVisibleOverlayVisible: () => boolean;
isInvisibleOverlayVisible: () => boolean;
updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
getOverlayWindows: () => BrowserWindow[];
syncOverlayShortcuts: () => void;
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
@@ -35,20 +30,18 @@ type InitializeOverlayRuntimeCore = (options: {
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string;
}) => { invisibleOverlayVisible: boolean };
}) => void;
export function createInitializeOverlayRuntimeHandler(deps: {
isOverlayRuntimeInitialized: () => boolean;
initializeOverlayRuntimeCore: InitializeOverlayRuntimeCore;
buildOptions: () => Parameters<InitializeOverlayRuntimeCore>[0];
setInvisibleOverlayVisible: (visible: boolean) => void;
setOverlayRuntimeInitialized: (initialized: boolean) => void;
startBackgroundWarmups: () => void;
}) {
return (): void => {
if (deps.isOverlayRuntimeInitialized()) return;
const result = deps.initializeOverlayRuntimeCore(deps.buildOptions());
deps.setInvisibleOverlayVisible(result.invisibleOverlayVisible);
deps.initializeOverlayRuntimeCore(deps.buildOptions());
deps.setOverlayRuntimeInitialized(true);
deps.startBackgroundWarmups();
};

View File

@@ -57,14 +57,12 @@ test('set overlay debug visualization main deps builder maps callbacks', () => {
setOverlayDebugVisualizationEnabledRuntime: () => calls.push('set-runtime'),
getCurrentEnabled: () => false,
setCurrentEnabled: () => calls.push('set-current'),
broadcastToOverlayWindows: () => calls.push('broadcast'),
})();
deps.setOverlayDebugVisualizationEnabledRuntime(false, true, () => {}, () => {});
deps.setOverlayDebugVisualizationEnabledRuntime(false, true, () => {});
assert.equal(deps.getCurrentEnabled(), false);
deps.setCurrentEnabled(true);
deps.broadcastToOverlayWindows('overlay:debug');
assert.deepEqual(calls, ['set-runtime', 'set-current', 'broadcast']);
assert.deepEqual(calls, ['set-runtime', 'set-current']);
});
test('open runtime options palette main deps builder maps callbacks', () => {

View File

@@ -65,18 +65,14 @@ export function createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler(
currentEnabled,
nextEnabled,
setCurrentEnabled,
broadcastToOverlayWindows,
) =>
deps.setOverlayDebugVisualizationEnabledRuntime(
currentEnabled,
nextEnabled,
setCurrentEnabled,
broadcastToOverlayWindows,
),
getCurrentEnabled: () => deps.getCurrentEnabled(),
setCurrentEnabled: (enabled: boolean) => deps.setCurrentEnabled(enabled),
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) =>
deps.broadcastToOverlayWindows(channel, ...args),
});
}

View File

@@ -104,22 +104,21 @@ test('set overlay debug visualization enabled delegates with current state and b
const calls: string[] = [];
let current = false;
const setEnabled = createSetOverlayDebugVisualizationEnabledHandler({
setOverlayDebugVisualizationEnabledRuntime: (curr, next, setCurrent, broadcast) => {
setOverlayDebugVisualizationEnabledRuntime: (curr, next, setCurrent) => {
calls.push(`runtime:${curr}->${next}`);
setCurrent(next);
broadcast('overlay-debug:set', next);
// no renderer-level side effects for this legacy debug path.
},
getCurrentEnabled: () => current,
setCurrentEnabled: (enabled) => {
current = enabled;
calls.push(`set:${enabled}`);
},
broadcastToOverlayWindows: (channel, value) => calls.push(`emit:${channel}:${value}`),
});
setEnabled(true);
assert.equal(current, true);
assert.deepEqual(calls, ['runtime:false->true', 'set:true', 'emit:overlay-debug:set:true']);
assert.deepEqual(calls, ['runtime:false->true', 'set:true']);
});
test('open runtime options palette handler delegates to runtime', () => {

View File

@@ -65,18 +65,15 @@ export function createSetOverlayDebugVisualizationEnabledHandler(deps: {
currentEnabled: boolean,
nextEnabled: boolean,
setCurrentEnabled: (enabled: boolean) => void,
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void,
) => void;
getCurrentEnabled: () => boolean;
setCurrentEnabled: (enabled: boolean) => void;
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void;
}) {
return (enabled: boolean): void => {
deps.setOverlayDebugVisualizationEnabledRuntime(
deps.getCurrentEnabled(),
enabled,
(next) => deps.setCurrentEnabled(next),
(channel, ...args) => deps.broadcastToOverlayWindows(channel, ...args),
);
};
}

View File

@@ -19,21 +19,16 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
appState,
overlayManager: {
getVisibleOverlayVisible: () => true,
getInvisibleOverlayVisible: () => false,
},
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => calls.push('update-visible'),
updateInvisibleOverlayVisibility: () => calls.push('update-invisible'),
},
overlayShortcutsRuntime: {
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
},
getInitialInvisibleOverlayVisibility: () => true,
createMainWindow: () => calls.push('create-main'),
createInvisibleWindow: () => calls.push('create-invisible'),
registerGlobalShortcuts: () => calls.push('register-shortcuts'),
updateVisibleOverlayBounds: () => calls.push('visible-bounds'),
updateInvisibleOverlayBounds: () => calls.push('invisible-bounds'),
getOverlayWindows: () => [],
getResolvedConfig: () => ({}),
showDesktopNotification: () => calls.push('notify'),
@@ -48,19 +43,14 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
const deps = build();
assert.equal(deps.getBackendOverride(), 'x11');
assert.equal(deps.getInitialInvisibleOverlayVisibility(), true);
assert.equal(deps.isVisibleOverlayVisible(), true);
assert.equal(deps.isInvisibleOverlayVisible(), false);
assert.equal(deps.getMpvSocketPath(), '/tmp/mpv.sock');
assert.equal(deps.getKnownWordCacheStatePath(), '/tmp/known-words-cache.json');
deps.createMainWindow();
deps.createInvisibleWindow();
deps.registerGlobalShortcuts();
deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
deps.updateInvisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
deps.updateVisibleOverlayVisibility();
deps.updateInvisibleOverlayVisibility();
deps.syncOverlayShortcuts();
deps.showDesktopNotification('title', {});
@@ -73,12 +63,9 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
assert.deepEqual(calls, [
'create-main',
'create-invisible',
'register-shortcuts',
'visible-bounds',
'invisible-bounds',
'update-visible',
'update-invisible',
'sync-shortcuts',
'notify',
]);

View File

@@ -17,18 +17,14 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
};
overlayManager: {
getVisibleOverlayVisible: () => boolean;
getInvisibleOverlayVisible: () => boolean;
};
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
};
overlayShortcutsRuntime: {
syncOverlayShortcuts: () => void;
};
getInitialInvisibleOverlayVisibility: () => boolean;
createMainWindow: () => void;
createInvisibleWindow: () => void;
registerGlobalShortcuts: () => void;
updateVisibleOverlayBounds: (geometry: {
x: number;
@@ -36,12 +32,6 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
width: number;
height: number;
}) => void;
updateInvisibleOverlayBounds: (geometry: {
x: number;
y: number;
width: number;
height: number;
}) => void;
getOverlayWindows: OverlayRuntimeOptionsMainDeps['getOverlayWindows'];
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
@@ -50,9 +40,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
}) {
return (): OverlayRuntimeOptionsMainDeps => ({
getBackendOverride: () => deps.appState.backendOverride,
getInitialInvisibleOverlayVisibility: () => deps.getInitialInvisibleOverlayVisibility(),
createMainWindow: () => deps.createMainWindow(),
createInvisibleWindow: () => deps.createInvisibleWindow(),
registerGlobalShortcuts: () => deps.registerGlobalShortcuts(),
updateVisibleOverlayBounds: (geometry: {
x: number;
@@ -60,18 +48,9 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
width: number;
height: number;
}) => deps.updateVisibleOverlayBounds(geometry),
updateInvisibleOverlayBounds: (geometry: {
x: number;
y: number;
width: number;
height: number;
}) => deps.updateInvisibleOverlayBounds(geometry),
isVisibleOverlayVisible: () => deps.overlayManager.getVisibleOverlayVisible(),
isInvisibleOverlayVisible: () => deps.overlayManager.getInvisibleOverlayVisible(),
updateVisibleOverlayVisibility: () =>
deps.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
updateInvisibleOverlayVisibility: () =>
deps.overlayVisibilityRuntime.updateInvisibleOverlayVisibility(),
getOverlayWindows: () => deps.getOverlayWindows(),
syncOverlayShortcuts: () => deps.overlayShortcutsRuntime.syncOverlayShortcuts(),
setWindowTracker: (tracker) => {

View File

@@ -6,16 +6,11 @@ test('build initialize overlay runtime options maps dependencies', () => {
const calls: string[] = [];
const buildOptions = createBuildInitializeOverlayRuntimeOptionsHandler({
getBackendOverride: () => 'x11',
getInitialInvisibleOverlayVisibility: () => true,
createMainWindow: () => calls.push('create-main'),
createInvisibleWindow: () => calls.push('create-invisible'),
registerGlobalShortcuts: () => calls.push('register-shortcuts'),
updateVisibleOverlayBounds: () => calls.push('update-visible-bounds'),
updateInvisibleOverlayBounds: () => calls.push('update-invisible-bounds'),
isVisibleOverlayVisible: () => true,
isInvisibleOverlayVisible: () => false,
updateVisibleOverlayVisibility: () => calls.push('update-visible'),
updateInvisibleOverlayVisibility: () => calls.push('update-invisible'),
getOverlayWindows: () => [],
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
setWindowTracker: () => calls.push('set-tracker'),
@@ -37,18 +32,13 @@ test('build initialize overlay runtime options maps dependencies', () => {
const options = buildOptions();
assert.equal(options.backendOverride, 'x11');
assert.equal(options.getInitialInvisibleOverlayVisibility(), true);
assert.equal(options.isVisibleOverlayVisible(), true);
assert.equal(options.isInvisibleOverlayVisible(), false);
assert.equal(options.getMpvSocketPath(), '/tmp/mpv.sock');
assert.equal(options.getKnownWordCacheStatePath(), '/tmp/known-words-cache.json');
options.createMainWindow();
options.createInvisibleWindow();
options.registerGlobalShortcuts();
options.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
options.updateInvisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
options.updateVisibleOverlayVisibility();
options.updateInvisibleOverlayVisibility();
options.syncOverlayShortcuts();
options.setWindowTracker(null);
options.setAnkiIntegration(null);
@@ -56,12 +46,9 @@ test('build initialize overlay runtime options maps dependencies', () => {
assert.deepEqual(calls, [
'create-main',
'create-invisible',
'register-shortcuts',
'update-visible-bounds',
'update-invisible-bounds',
'update-visible',
'update-invisible',
'sync-shortcuts',
'set-tracker',
'set-anki',

View File

@@ -9,16 +9,11 @@ import type { BaseWindowTracker } from '../../window-trackers';
type OverlayRuntimeOptions = {
backendOverride: string | null;
getInitialInvisibleOverlayVisibility: () => boolean;
createMainWindow: () => void;
createInvisibleWindow: () => void;
registerGlobalShortcuts: () => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void;
isVisibleOverlayVisible: () => boolean;
isInvisibleOverlayVisible: () => boolean;
updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
getOverlayWindows: () => BrowserWindow[];
syncOverlayShortcuts: () => void;
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
@@ -39,16 +34,11 @@ type OverlayRuntimeOptions = {
export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
getBackendOverride: () => string | null;
getInitialInvisibleOverlayVisibility: () => boolean;
createMainWindow: () => void;
createInvisibleWindow: () => void;
registerGlobalShortcuts: () => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void;
isVisibleOverlayVisible: () => boolean;
isInvisibleOverlayVisible: () => boolean;
updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
getOverlayWindows: () => BrowserWindow[];
syncOverlayShortcuts: () => void;
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
@@ -68,16 +58,11 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
}) {
return (): OverlayRuntimeOptions => ({
backendOverride: deps.getBackendOverride(),
getInitialInvisibleOverlayVisibility: deps.getInitialInvisibleOverlayVisibility,
createMainWindow: deps.createMainWindow,
createInvisibleWindow: deps.createInvisibleWindow,
registerGlobalShortcuts: deps.registerGlobalShortcuts,
updateVisibleOverlayBounds: deps.updateVisibleOverlayBounds,
updateInvisibleOverlayBounds: deps.updateInvisibleOverlayBounds,
isVisibleOverlayVisible: deps.isVisibleOverlayVisible,
isInvisibleOverlayVisible: deps.isInvisibleOverlayVisible,
updateVisibleOverlayVisibility: deps.updateVisibleOverlayVisibility,
updateInvisibleOverlayVisibility: deps.updateInvisibleOverlayVisibility,
getOverlayWindows: deps.getOverlayWindows,
syncOverlayShortcuts: deps.syncOverlayShortcuts,
setWindowTracker: deps.setWindowTracker,

View File

@@ -1,9 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildSetInvisibleOverlayVisibleMainDepsHandler,
createBuildSetVisibleOverlayVisibleMainDepsHandler,
createBuildToggleInvisibleOverlayMainDepsHandler,
createBuildToggleVisibleOverlayMainDepsHandler,
} from './overlay-visibility-actions-main-deps';
@@ -14,45 +12,14 @@ test('overlay visibility action main deps builders map callbacks', () => {
setVisibleOverlayVisibleCore: () => calls.push('visible-core'),
setVisibleOverlayVisibleState: (visible) => calls.push(`visible-state:${visible}`),
updateVisibleOverlayVisibility: () => calls.push('update-visible'),
updateInvisibleOverlayVisibility: () => calls.push('update-invisible'),
syncInvisibleOverlayMousePassthrough: () => calls.push('sync'),
shouldBindVisibleOverlayToMpvSubVisibility: () => true,
isMpvConnected: () => true,
setMpvSubVisibility: (visible) => calls.push(`mpv:${visible}`),
})();
setVisible.setVisibleOverlayVisibleCore({
visible: true,
setVisibleOverlayVisibleState: () => {},
updateVisibleOverlayVisibility: () => {},
updateInvisibleOverlayVisibility: () => {},
syncInvisibleOverlayMousePassthrough: () => {},
shouldBindVisibleOverlayToMpvSubVisibility: () => true,
isMpvConnected: () => true,
setMpvSubVisibility: () => {},
});
setVisible.setVisibleOverlayVisibleState(true);
setVisible.updateVisibleOverlayVisibility();
setVisible.updateInvisibleOverlayVisibility();
setVisible.syncInvisibleOverlayMousePassthrough();
assert.equal(setVisible.shouldBindVisibleOverlayToMpvSubVisibility(), true);
assert.equal(setVisible.isMpvConnected(), true);
setVisible.setMpvSubVisibility(false);
const setInvisible = createBuildSetInvisibleOverlayVisibleMainDepsHandler({
setInvisibleOverlayVisibleCore: () => calls.push('invisible-core'),
setInvisibleOverlayVisibleState: (visible) => calls.push(`invisible-state:${visible}`),
updateInvisibleOverlayVisibility: () => calls.push('update-only-invisible'),
syncInvisibleOverlayMousePassthrough: () => calls.push('sync-only'),
})();
setInvisible.setInvisibleOverlayVisibleCore({
visible: false,
setInvisibleOverlayVisibleState: () => {},
updateInvisibleOverlayVisibility: () => {},
syncInvisibleOverlayMousePassthrough: () => {},
});
setInvisible.setInvisibleOverlayVisibleState(false);
setInvisible.updateInvisibleOverlayVisibility();
setInvisible.syncInvisibleOverlayMousePassthrough();
const toggleVisible = createBuildToggleVisibleOverlayMainDepsHandler({
getVisibleOverlayVisible: () => false,
@@ -61,25 +28,10 @@ test('overlay visibility action main deps builders map callbacks', () => {
assert.equal(toggleVisible.getVisibleOverlayVisible(), false);
toggleVisible.setVisibleOverlayVisible(true);
const toggleInvisible = createBuildToggleInvisibleOverlayMainDepsHandler({
getInvisibleOverlayVisible: () => true,
setInvisibleOverlayVisible: (visible) => calls.push(`toggle-invisible:${visible}`),
})();
assert.equal(toggleInvisible.getInvisibleOverlayVisible(), true);
toggleInvisible.setInvisibleOverlayVisible(false);
assert.deepEqual(calls, [
'visible-core',
'visible-state:true',
'update-visible',
'update-invisible',
'sync',
'mpv:false',
'invisible-core',
'invisible-state:false',
'update-only-invisible',
'sync-only',
'toggle-visible:true',
'toggle-invisible:false',
]);
});

View File

@@ -1,14 +1,10 @@
import type {
createSetInvisibleOverlayVisibleHandler,
createSetVisibleOverlayVisibleHandler,
createToggleInvisibleOverlayHandler,
createToggleVisibleOverlayHandler,
} from './overlay-visibility-actions';
type SetVisibleOverlayVisibleMainDeps = Parameters<typeof createSetVisibleOverlayVisibleHandler>[0];
type SetInvisibleOverlayVisibleMainDeps = Parameters<typeof createSetInvisibleOverlayVisibleHandler>[0];
type ToggleVisibleOverlayMainDeps = Parameters<typeof createToggleVisibleOverlayHandler>[0];
type ToggleInvisibleOverlayMainDeps = Parameters<typeof createToggleInvisibleOverlayHandler>[0];
export function createBuildSetVisibleOverlayVisibleMainDepsHandler(
deps: SetVisibleOverlayVisibleMainDeps,
@@ -17,22 +13,6 @@ export function createBuildSetVisibleOverlayVisibleMainDepsHandler(
setVisibleOverlayVisibleCore: (options) => deps.setVisibleOverlayVisibleCore(options),
setVisibleOverlayVisibleState: (visible: boolean) => deps.setVisibleOverlayVisibleState(visible),
updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(),
updateInvisibleOverlayVisibility: () => deps.updateInvisibleOverlayVisibility(),
syncInvisibleOverlayMousePassthrough: () => deps.syncInvisibleOverlayMousePassthrough(),
shouldBindVisibleOverlayToMpvSubVisibility: () => deps.shouldBindVisibleOverlayToMpvSubVisibility(),
isMpvConnected: () => deps.isMpvConnected(),
setMpvSubVisibility: (visible: boolean) => deps.setMpvSubVisibility(visible),
});
}
export function createBuildSetInvisibleOverlayVisibleMainDepsHandler(
deps: SetInvisibleOverlayVisibleMainDeps,
) {
return (): SetInvisibleOverlayVisibleMainDeps => ({
setInvisibleOverlayVisibleCore: (options) => deps.setInvisibleOverlayVisibleCore(options),
setInvisibleOverlayVisibleState: (visible: boolean) => deps.setInvisibleOverlayVisibleState(visible),
updateInvisibleOverlayVisibility: () => deps.updateInvisibleOverlayVisibility(),
syncInvisibleOverlayMousePassthrough: () => deps.syncInvisibleOverlayMousePassthrough(),
});
}
@@ -42,12 +22,3 @@ export function createBuildToggleVisibleOverlayMainDepsHandler(deps: ToggleVisib
setVisibleOverlayVisible: (visible: boolean) => deps.setVisibleOverlayVisible(visible),
});
}
export function createBuildToggleInvisibleOverlayMainDepsHandler(
deps: ToggleInvisibleOverlayMainDeps,
) {
return (): ToggleInvisibleOverlayMainDeps => ({
getInvisibleOverlayVisible: () => deps.getInvisibleOverlayVisible(),
setInvisibleOverlayVisible: (visible: boolean) => deps.setInvisibleOverlayVisible(visible),
});
}

View File

@@ -1,9 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createSetInvisibleOverlayVisibleHandler,
createSetVisibleOverlayVisibleHandler,
createToggleInvisibleOverlayHandler,
createToggleVisibleOverlayHandler,
} from './overlay-visibility-actions';
@@ -14,17 +12,9 @@ test('set visible overlay handler forwards dependencies to core', () => {
calls.push(`core:${options.visible}`);
options.setVisibleOverlayVisibleState(options.visible);
options.updateVisibleOverlayVisibility();
options.updateInvisibleOverlayVisibility();
options.syncInvisibleOverlayMousePassthrough();
options.setMpvSubVisibility(!options.visible);
},
setVisibleOverlayVisibleState: (visible) => calls.push(`state:${visible}`),
updateVisibleOverlayVisibility: () => calls.push('update-visible'),
updateInvisibleOverlayVisibility: () => calls.push('update-invisible'),
syncInvisibleOverlayMousePassthrough: () => calls.push('sync-mouse'),
shouldBindVisibleOverlayToMpvSubVisibility: () => true,
isMpvConnected: () => true,
setMpvSubVisibility: (visible) => calls.push(`mpv-sub:${visible}`),
});
setVisible(true);
@@ -32,30 +22,9 @@ test('set visible overlay handler forwards dependencies to core', () => {
'core:true',
'state:true',
'update-visible',
'update-invisible',
'sync-mouse',
'mpv-sub:false',
]);
});
test('set invisible overlay handler forwards dependencies to core', () => {
const calls: string[] = [];
const setInvisible = createSetInvisibleOverlayVisibleHandler({
setInvisibleOverlayVisibleCore: (options) => {
calls.push(`core:${options.visible}`);
options.setInvisibleOverlayVisibleState(options.visible);
options.updateInvisibleOverlayVisibility();
options.syncInvisibleOverlayMousePassthrough();
},
setInvisibleOverlayVisibleState: (visible) => calls.push(`state:${visible}`),
updateInvisibleOverlayVisibility: () => calls.push('update-invisible'),
syncInvisibleOverlayMousePassthrough: () => calls.push('sync-mouse'),
});
setInvisible(false);
assert.deepEqual(calls, ['core:false', 'state:false', 'update-invisible', 'sync-mouse']);
});
test('toggle visible overlay flips current visible state', () => {
const calls: string[] = [];
let current = false;
@@ -71,19 +40,3 @@ test('toggle visible overlay flips current visible state', () => {
toggle();
assert.deepEqual(calls, ['set:true', 'set:false']);
});
test('toggle invisible overlay flips current invisible state', () => {
const calls: string[] = [];
let current = true;
const toggle = createToggleInvisibleOverlayHandler({
getInvisibleOverlayVisible: () => current,
setInvisibleOverlayVisible: (visible) => {
current = visible;
calls.push(`set:${visible}`);
},
});
toggle();
toggle();
assert.deepEqual(calls, ['set:false', 'set:true']);
});

View File

@@ -3,52 +3,15 @@ export function createSetVisibleOverlayVisibleHandler(deps: {
visible: boolean;
setVisibleOverlayVisibleState: (visible: boolean) => void;
updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
syncInvisibleOverlayMousePassthrough: () => void;
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
isMpvConnected: () => boolean;
setMpvSubVisibility: (visible: boolean) => void;
}) => void;
setVisibleOverlayVisibleState: (visible: boolean) => void;
updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
syncInvisibleOverlayMousePassthrough: () => void;
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
isMpvConnected: () => boolean;
setMpvSubVisibility: (visible: boolean) => void;
}) {
return (visible: boolean): void => {
deps.setVisibleOverlayVisibleCore({
visible,
setVisibleOverlayVisibleState: deps.setVisibleOverlayVisibleState,
updateVisibleOverlayVisibility: deps.updateVisibleOverlayVisibility,
updateInvisibleOverlayVisibility: deps.updateInvisibleOverlayVisibility,
syncInvisibleOverlayMousePassthrough: deps.syncInvisibleOverlayMousePassthrough,
shouldBindVisibleOverlayToMpvSubVisibility:
deps.shouldBindVisibleOverlayToMpvSubVisibility,
isMpvConnected: deps.isMpvConnected,
setMpvSubVisibility: deps.setMpvSubVisibility,
});
};
}
export function createSetInvisibleOverlayVisibleHandler(deps: {
setInvisibleOverlayVisibleCore: (options: {
visible: boolean;
setInvisibleOverlayVisibleState: (visible: boolean) => void;
updateInvisibleOverlayVisibility: () => void;
syncInvisibleOverlayMousePassthrough: () => void;
}) => void;
setInvisibleOverlayVisibleState: (visible: boolean) => void;
updateInvisibleOverlayVisibility: () => void;
syncInvisibleOverlayMousePassthrough: () => void;
}) {
return (visible: boolean): void => {
deps.setInvisibleOverlayVisibleCore({
visible,
setInvisibleOverlayVisibleState: deps.setInvisibleOverlayVisibleState,
updateInvisibleOverlayVisibility: deps.updateInvisibleOverlayVisibility,
syncInvisibleOverlayMousePassthrough: deps.syncInvisibleOverlayMousePassthrough,
});
};
}
@@ -61,12 +24,3 @@ export function createToggleVisibleOverlayHandler(deps: {
deps.setVisibleOverlayVisible(!deps.getVisibleOverlayVisible());
};
}
export function createToggleInvisibleOverlayHandler(deps: {
getInvisibleOverlayVisible: () => boolean;
setInvisibleOverlayVisible: (visible: boolean) => void;
}) {
return (): void => {
deps.setInvisibleOverlayVisible(!deps.getInvisibleOverlayVisible());
};
}

View File

@@ -8,14 +8,11 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
const calls: string[] = [];
let trackerNotReadyWarningShown = false;
const mainWindow = { id: 'main' } as never;
const invisibleWindow = { id: 'invisible' } as never;
const tracker = { id: 'tracker' } as unknown as BaseWindowTracker;
const deps = createBuildOverlayVisibilityRuntimeMainDepsHandler({
getMainWindow: () => mainWindow,
getInvisibleWindow: () => invisibleWindow,
getVisibleOverlayVisible: () => true,
getInvisibleOverlayVisible: () => false,
getWindowTracker: () => tracker,
getTrackerNotReadyWarningShown: () => trackerNotReadyWarningShown,
setTrackerNotReadyWarningShown: (shown) => {
@@ -23,30 +20,35 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
calls.push(`tracker-warning:${shown}`);
},
updateVisibleOverlayBounds: () => calls.push('visible-bounds'),
updateInvisibleOverlayBounds: () => calls.push('invisible-bounds'),
ensureOverlayWindowLevel: () => calls.push('ensure-level'),
syncPrimaryOverlayWindowLayer: (layer) => calls.push(`primary-layer:${layer}`),
enforceOverlayLayerOrder: () => calls.push('enforce-order'),
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
isMacOSPlatform: () => true,
showOverlayLoadingOsd: () => calls.push('overlay-loading-osd'),
resolveFallbackBounds: () => ({ x: 0, y: 0, width: 20, height: 20 }),
})();
assert.equal(deps.getMainWindow(), mainWindow);
assert.equal(deps.getInvisibleWindow(), invisibleWindow);
assert.equal(deps.getVisibleOverlayVisible(), true);
assert.equal(deps.getInvisibleOverlayVisible(), false);
assert.equal(deps.getTrackerNotReadyWarningShown(), false);
deps.setTrackerNotReadyWarningShown(true);
deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
deps.updateInvisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
deps.ensureOverlayWindowLevel(mainWindow);
deps.syncPrimaryOverlayWindowLayer('visible');
deps.enforceOverlayLayerOrder();
deps.syncOverlayShortcuts();
assert.equal(deps.isMacOSPlatform(), true);
deps.showOverlayLoadingOsd('Overlay loading...');
assert.deepEqual(deps.resolveFallbackBounds(), { x: 0, y: 0, width: 20, height: 20 });
assert.equal(trackerNotReadyWarningShown, true);
assert.deepEqual(calls, [
'tracker-warning:true',
'visible-bounds',
'invisible-bounds',
'ensure-level',
'primary-layer:visible',
'enforce-order',
'sync-shortcuts',
'overlay-loading-osd',
]);
});

View File

@@ -7,18 +7,19 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
) {
return (): OverlayVisibilityRuntimeDeps => ({
getMainWindow: () => deps.getMainWindow(),
getInvisibleWindow: () => deps.getInvisibleWindow(),
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
getInvisibleOverlayVisible: () => deps.getInvisibleOverlayVisible(),
getWindowTracker: () => deps.getWindowTracker(),
getTrackerNotReadyWarningShown: () => deps.getTrackerNotReadyWarningShown(),
setTrackerNotReadyWarningShown: (shown: boolean) => deps.setTrackerNotReadyWarningShown(shown),
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
deps.updateVisibleOverlayBounds(geometry),
updateInvisibleOverlayBounds: (geometry: WindowGeometry) =>
deps.updateInvisibleOverlayBounds(geometry),
ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window),
syncPrimaryOverlayWindowLayer: (layer: 'visible') =>
deps.syncPrimaryOverlayWindowLayer(layer),
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
isMacOSPlatform: () => deps.isMacOSPlatform(),
showOverlayLoadingOsd: (message: string) => deps.showOverlayLoadingOsd(message),
resolveFallbackBounds: () => deps.resolveFallbackBounds(),
});
}

View File

@@ -4,10 +4,7 @@ import { createOverlayVisibilityRuntime } from './overlay-visibility-runtime';
test('overlay visibility runtime wires set/toggle handlers through composed deps', () => {
let visible = false;
let invisible = true;
let setVisibleCoreCalls = 0;
let setInvisibleCoreCalls = 0;
let lastBoundSubVisibility: boolean | null = null;
const runtime = createOverlayVisibilityRuntime({
setVisibleOverlayVisibleDeps: {
@@ -15,44 +12,17 @@ test('overlay visibility runtime wires set/toggle handlers through composed deps
setVisibleCoreCalls += 1;
options.setVisibleOverlayVisibleState(options.visible);
options.updateVisibleOverlayVisibility();
options.updateInvisibleOverlayVisibility();
options.syncInvisibleOverlayMousePassthrough();
if (options.shouldBindVisibleOverlayToMpvSubVisibility() && options.isMpvConnected()) {
options.setMpvSubVisibility(options.visible);
}
},
setVisibleOverlayVisibleState: (nextVisible) => {
visible = nextVisible;
},
updateVisibleOverlayVisibility: () => {},
updateInvisibleOverlayVisibility: () => {},
syncInvisibleOverlayMousePassthrough: () => {},
shouldBindVisibleOverlayToMpvSubVisibility: () => true,
isMpvConnected: () => true,
setMpvSubVisibility: (nextVisible) => {
lastBoundSubVisibility = nextVisible;
},
},
setInvisibleOverlayVisibleDeps: {
setInvisibleOverlayVisibleCore: (options) => {
setInvisibleCoreCalls += 1;
options.setInvisibleOverlayVisibleState(options.visible);
options.updateInvisibleOverlayVisibility();
options.syncInvisibleOverlayMousePassthrough();
},
setInvisibleOverlayVisibleState: (nextVisible) => {
invisible = nextVisible;
},
updateInvisibleOverlayVisibility: () => {},
syncInvisibleOverlayMousePassthrough: () => {},
},
getVisibleOverlayVisible: () => visible,
getInvisibleOverlayVisible: () => invisible,
});
runtime.setVisibleOverlayVisible(true);
assert.equal(visible, true);
assert.equal(lastBoundSubVisibility, true);
runtime.toggleVisibleOverlay();
assert.equal(visible, false);
@@ -63,12 +33,5 @@ test('overlay visibility runtime wires set/toggle handlers through composed deps
runtime.toggleOverlay();
assert.equal(visible, false);
runtime.setInvisibleOverlayVisible(false);
assert.equal(invisible, false);
runtime.toggleInvisibleOverlay();
assert.equal(invisible, true);
assert.equal(setVisibleCoreCalls, 4);
assert.equal(setInvisibleCoreCalls, 2);
});

View File

@@ -1,13 +1,9 @@
import {
createSetInvisibleOverlayVisibleHandler,
createSetVisibleOverlayVisibleHandler,
createToggleInvisibleOverlayHandler,
createToggleVisibleOverlayHandler,
} from './overlay-visibility-actions';
import {
createBuildSetInvisibleOverlayVisibleMainDepsHandler,
createBuildSetVisibleOverlayVisibleMainDepsHandler,
createBuildToggleInvisibleOverlayMainDepsHandler,
createBuildToggleVisibleOverlayMainDepsHandler,
} from './overlay-visibility-actions-main-deps';
import { createSetOverlayVisibleHandler, createToggleOverlayHandler } from './overlay-main-actions';
@@ -19,15 +15,10 @@ import {
type SetVisibleOverlayVisibleMainDeps = Parameters<
typeof createBuildSetVisibleOverlayVisibleMainDepsHandler
>[0];
type SetInvisibleOverlayVisibleMainDeps = Parameters<
typeof createBuildSetInvisibleOverlayVisibleMainDepsHandler
>[0];
export type OverlayVisibilityRuntimeDeps = {
setVisibleOverlayVisibleDeps: SetVisibleOverlayVisibleMainDeps;
setInvisibleOverlayVisibleDeps: SetInvisibleOverlayVisibleMainDeps;
getVisibleOverlayVisible: () => boolean;
getInvisibleOverlayVisible: () => boolean;
};
export function createOverlayVisibilityRuntime(deps: OverlayVisibilityRuntimeDeps) {
@@ -38,25 +29,12 @@ export function createOverlayVisibilityRuntime(deps: OverlayVisibilityRuntimeDep
setVisibleOverlayVisibleMainDeps,
);
const setInvisibleOverlayVisibleMainDeps = createBuildSetInvisibleOverlayVisibleMainDepsHandler(
deps.setInvisibleOverlayVisibleDeps,
)();
const setInvisibleOverlayVisible = createSetInvisibleOverlayVisibleHandler(
setInvisibleOverlayVisibleMainDeps,
);
const toggleVisibleOverlayMainDeps = createBuildToggleVisibleOverlayMainDepsHandler({
getVisibleOverlayVisible: deps.getVisibleOverlayVisible,
setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible),
})();
const toggleVisibleOverlay = createToggleVisibleOverlayHandler(toggleVisibleOverlayMainDeps);
const toggleInvisibleOverlayMainDeps = createBuildToggleInvisibleOverlayMainDepsHandler({
getInvisibleOverlayVisible: deps.getInvisibleOverlayVisible,
setInvisibleOverlayVisible: (visible) => setInvisibleOverlayVisible(visible),
})();
const toggleInvisibleOverlay = createToggleInvisibleOverlayHandler(toggleInvisibleOverlayMainDeps);
const setOverlayVisibleMainDeps = createBuildSetOverlayVisibleMainDepsHandler({
setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible),
})();
@@ -69,9 +47,7 @@ export function createOverlayVisibilityRuntime(deps: OverlayVisibilityRuntimeDep
return {
setVisibleOverlayVisible,
setInvisibleOverlayVisible,
toggleVisibleOverlay,
toggleInvisibleOverlay,
setOverlayVisible,
toggleOverlay,
};

View File

@@ -3,7 +3,6 @@ import test from 'node:test';
import {
createBuildEnforceOverlayLayerOrderMainDepsHandler,
createBuildEnsureOverlayWindowLevelMainDepsHandler,
createBuildUpdateInvisibleOverlayBoundsMainDepsHandler,
createBuildUpdateVisibleOverlayBoundsMainDepsHandler,
} from './overlay-window-layout-main-deps';
@@ -11,14 +10,9 @@ test('overlay window layout main deps builders map callbacks', () => {
const calls: string[] = [];
const visible = createBuildUpdateVisibleOverlayBoundsMainDepsHandler({
setOverlayWindowBounds: (layer) => calls.push(`visible:${layer}`),
setOverlayWindowBounds: () => calls.push('visible'),
})();
visible.setOverlayWindowBounds('visible', { x: 0, y: 0, width: 1, height: 1 });
const invisible = createBuildUpdateInvisibleOverlayBoundsMainDepsHandler({
setOverlayWindowBounds: (layer) => calls.push(`invisible:${layer}`),
})();
invisible.setOverlayWindowBounds('invisible', { x: 0, y: 0, width: 1, height: 1 });
visible.setOverlayWindowBounds({ x: 0, y: 0, width: 1, height: 1 });
const level = createBuildEnsureOverlayWindowLevelMainDepsHandler({
ensureOverlayWindowLevelCore: () => calls.push('ensure'),
@@ -28,27 +22,20 @@ test('overlay window layout main deps builders map callbacks', () => {
const order = createBuildEnforceOverlayLayerOrderMainDepsHandler({
enforceOverlayLayerOrderCore: () => calls.push('order'),
getVisibleOverlayVisible: () => true,
getInvisibleOverlayVisible: () => false,
getMainWindow: () => ({ kind: 'main' }),
getInvisibleWindow: () => ({ kind: 'invisible' }),
ensureOverlayWindowLevel: () => calls.push('order-level'),
})();
order.enforceOverlayLayerOrderCore({
visibleOverlayVisible: true,
invisibleOverlayVisible: false,
mainWindow: null,
invisibleWindow: null,
ensureOverlayWindowLevel: () => {},
});
assert.equal(order.getVisibleOverlayVisible(), true);
assert.equal(order.getInvisibleOverlayVisible(), false);
assert.deepEqual(order.getMainWindow(), { kind: 'main' });
assert.deepEqual(order.getInvisibleWindow(), { kind: 'invisible' });
order.ensureOverlayWindowLevel({});
assert.deepEqual(calls, [
'visible:visible',
'invisible:invisible',
'visible',
'ensure',
'order',
'order-level',

View File

@@ -1,12 +1,10 @@
import type {
createEnforceOverlayLayerOrderHandler,
createEnsureOverlayWindowLevelHandler,
createUpdateInvisibleOverlayBoundsHandler,
createUpdateVisibleOverlayBoundsHandler,
} from './overlay-window-layout';
type UpdateVisibleOverlayBoundsMainDeps = Parameters<typeof createUpdateVisibleOverlayBoundsHandler>[0];
type UpdateInvisibleOverlayBoundsMainDeps = Parameters<typeof createUpdateInvisibleOverlayBoundsHandler>[0];
type EnsureOverlayWindowLevelMainDeps = Parameters<typeof createEnsureOverlayWindowLevelHandler>[0];
type EnforceOverlayLayerOrderMainDeps = Parameters<typeof createEnforceOverlayLayerOrderHandler>[0];
@@ -14,15 +12,7 @@ export function createBuildUpdateVisibleOverlayBoundsMainDepsHandler(
deps: UpdateVisibleOverlayBoundsMainDeps,
) {
return (): UpdateVisibleOverlayBoundsMainDeps => ({
setOverlayWindowBounds: (layer, geometry) => deps.setOverlayWindowBounds(layer, geometry),
});
}
export function createBuildUpdateInvisibleOverlayBoundsMainDepsHandler(
deps: UpdateInvisibleOverlayBoundsMainDeps,
) {
return (): UpdateInvisibleOverlayBoundsMainDeps => ({
setOverlayWindowBounds: (layer, geometry) => deps.setOverlayWindowBounds(layer, geometry),
setOverlayWindowBounds: (geometry) => deps.setOverlayWindowBounds(geometry),
});
}
@@ -40,9 +30,7 @@ export function createBuildEnforceOverlayLayerOrderMainDepsHandler(
return (): EnforceOverlayLayerOrderMainDeps => ({
enforceOverlayLayerOrderCore: (params) => deps.enforceOverlayLayerOrderCore(params),
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
getInvisibleOverlayVisible: () => deps.getInvisibleOverlayVisible(),
getMainWindow: () => deps.getMainWindow(),
getInvisibleWindow: () => deps.getInvisibleWindow(),
ensureOverlayWindowLevel: (window: unknown) => deps.ensureOverlayWindowLevel(window),
});
}

View File

@@ -3,26 +3,17 @@ import assert from 'node:assert/strict';
import {
createEnforceOverlayLayerOrderHandler,
createEnsureOverlayWindowLevelHandler,
createUpdateInvisibleOverlayBoundsHandler,
createUpdateVisibleOverlayBoundsHandler,
} from './overlay-window-layout';
test('visible bounds handler writes visible layer geometry', () => {
const calls: string[] = [];
const calls: Array<{ x: number; y: number; width: number; height: number }> = [];
const handleVisible = createUpdateVisibleOverlayBoundsHandler({
setOverlayWindowBounds: (layer) => calls.push(layer),
setOverlayWindowBounds: (geometry) => calls.push(geometry),
});
handleVisible({ x: 0, y: 0, width: 100, height: 50 });
assert.deepEqual(calls, ['visible']);
});
test('invisible bounds handler writes invisible layer geometry', () => {
const calls: string[] = [];
const handleInvisible = createUpdateInvisibleOverlayBoundsHandler({
setOverlayWindowBounds: (layer) => calls.push(layer),
});
handleInvisible({ x: 0, y: 0, width: 100, height: 50 });
assert.deepEqual(calls, ['invisible']);
const geometry = { x: 0, y: 0, width: 100, height: 50 };
handleVisible(geometry);
assert.deepEqual(calls, [geometry]);
});
test('ensure overlay window level handler delegates to core', () => {
@@ -39,15 +30,12 @@ test('enforce overlay layer order handler forwards resolved state', () => {
const enforce = createEnforceOverlayLayerOrderHandler({
enforceOverlayLayerOrderCore: (params) => {
calls.push(params.visibleOverlayVisible ? 'visible-on' : 'visible-off');
calls.push(params.invisibleOverlayVisible ? 'invisible-on' : 'invisible-off');
params.ensureOverlayWindowLevel({});
},
getVisibleOverlayVisible: () => true,
getInvisibleOverlayVisible: () => false,
getMainWindow: () => ({}),
getInvisibleWindow: () => ({}),
ensureOverlayWindowLevel: () => calls.push('ensure-level'),
});
enforce();
assert.deepEqual(calls, ['visible-on', 'invisible-off', 'ensure-level']);
assert.deepEqual(calls, ['visible-on', 'ensure-level']);
});

View File

@@ -1,18 +1,10 @@
import type { WindowGeometry } from '../../types';
export function createUpdateVisibleOverlayBoundsHandler(deps: {
setOverlayWindowBounds: (layer: 'visible' | 'invisible', geometry: WindowGeometry) => void;
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
}) {
return (geometry: WindowGeometry): void => {
deps.setOverlayWindowBounds('visible', geometry);
};
}
export function createUpdateInvisibleOverlayBoundsHandler(deps: {
setOverlayWindowBounds: (layer: 'visible' | 'invisible', geometry: WindowGeometry) => void;
}) {
return (geometry: WindowGeometry): void => {
deps.setOverlayWindowBounds('invisible', geometry);
deps.setOverlayWindowBounds(geometry);
};
}
@@ -27,23 +19,17 @@ export function createEnsureOverlayWindowLevelHandler(deps: {
export function createEnforceOverlayLayerOrderHandler(deps: {
enforceOverlayLayerOrderCore: (params: {
visibleOverlayVisible: boolean;
invisibleOverlayVisible: boolean;
mainWindow: unknown;
invisibleWindow: unknown;
ensureOverlayWindowLevel: (window: unknown) => void;
}) => void;
getVisibleOverlayVisible: () => boolean;
getInvisibleOverlayVisible: () => boolean;
getMainWindow: () => unknown;
getInvisibleWindow: () => unknown;
ensureOverlayWindowLevel: (window: unknown) => void;
}) {
return (): void => {
deps.enforceOverlayLayerOrderCore({
visibleOverlayVisible: deps.getVisibleOverlayVisible(),
invisibleOverlayVisible: deps.getInvisibleOverlayVisible(),
mainWindow: deps.getMainWindow(),
invisibleWindow: deps.getInvisibleWindow(),
ensureOverlayWindowLevel: deps.ensureOverlayWindowLevel,
});
};

View File

@@ -64,7 +64,6 @@ test('config derived runtime main deps builder maps callbacks', () => {
const deps = createBuildConfigDerivedRuntimeMainDepsHandler({
getResolvedConfig: () => ({ jimaku: {} } as never),
getRuntimeOptionsManager: () => null,
platform: 'darwin',
defaultJimakuLanguagePreference: 'ja',
defaultJimakuMaxEntryResults: 20,
defaultJimakuApiBaseUrl: 'https://api.example.com',
@@ -72,7 +71,6 @@ test('config derived runtime main deps builder maps callbacks', () => {
assert.deepEqual(deps.getResolvedConfig(), { jimaku: {} });
assert.equal(deps.getRuntimeOptionsManager(), null);
assert.equal(deps.platform, 'darwin');
assert.equal(deps.defaultJimakuLanguagePreference, 'ja');
assert.equal(deps.defaultJimakuMaxEntryResults, 20);
assert.equal(deps.defaultJimakuApiBaseUrl, 'https://api.example.com');

View File

@@ -35,7 +35,6 @@ export function createBuildConfigDerivedRuntimeMainDepsHandler(deps: ConfigDeriv
return (): ConfigDerivedRuntimeDeps => ({
getResolvedConfig: () => deps.getResolvedConfig(),
getRuntimeOptionsManager: () => deps.getRuntimeOptionsManager(),
platform: deps.platform,
defaultJimakuLanguagePreference: deps.defaultJimakuLanguagePreference,
defaultJimakuMaxEntryResults: deps.defaultJimakuMaxEntryResults,
defaultJimakuApiBaseUrl: deps.defaultJimakuApiBaseUrl,

View File

@@ -1,5 +1,6 @@
import type { Keybinding } from '../../types';
import type { RendererContext } from '../context';
import { hasYomitanPopupIframe, isYomitanPopupIframe } from '../yomitan-popup.js';
export function createKeyboardHandlers(
ctx: RendererContext,
@@ -14,11 +15,6 @@ export function createKeyboardHandlers(
fallbackUsed: boolean;
fallbackUnavailable: boolean;
}) => void;
saveInvisiblePositionEdit: () => void;
cancelInvisiblePositionEdit: () => void;
setInvisiblePositionEditMode: (enabled: boolean) => void;
applyInvisibleSubtitleOffsetPosition: () => void;
updateInvisiblePositionEditHud: () => void;
appendClipboardVideoToQueue: () => void;
},
) {
@@ -32,9 +28,6 @@ export function createKeyboardHandlers(
['KeyS', { type: 'mpv', command: ['script-message', 'subminer-start'] }],
['Shift+KeyS', { type: 'mpv', command: ['script-message', 'subminer-stop'] }],
['KeyT', { type: 'mpv', command: ['script-message', 'subminer-toggle'] }],
['KeyI', { type: 'mpv', command: ['script-message', 'subminer-toggle-invisible'] }],
['Shift+KeyI', { type: 'mpv', command: ['script-message', 'subminer-show-invisible'] }],
['KeyU', { type: 'mpv', command: ['script-message', 'subminer-hide-invisible'] }],
['KeyO', { type: 'mpv', command: ['script-message', 'subminer-options'] }],
['KeyR', { type: 'mpv', command: ['script-message', 'subminer-restart'] }],
['KeyC', { type: 'mpv', command: ['script-message', 'subminer-status'] }],
@@ -46,10 +39,9 @@ export function createKeyboardHandlers(
if (!(target instanceof Element)) return false;
if (target.closest('.modal')) return true;
if (ctx.dom.subtitleContainer.contains(target)) return true;
if (target.tagName === 'IFRAME' && target.id?.startsWith('yomitan-popup')) {
if (isYomitanPopupIframe(target)) return true;
if (target.closest && target.closest('iframe.yomitan-popup, iframe[id^="yomitan-popup"]'))
return true;
}
if (target.closest && target.closest('iframe[id^="yomitan-popup"]')) return true;
return false;
}
@@ -63,15 +55,6 @@ export function createKeyboardHandlers(
return parts.join('+');
}
function isInvisiblePositionToggleShortcut(e: KeyboardEvent): boolean {
return (
e.code === ctx.platform.invisiblePositionEditToggleCode &&
!e.altKey &&
e.shiftKey &&
(e.ctrlKey || e.metaKey)
);
}
function resolveSessionHelpChordBinding(): {
bindingKey: 'KeyH' | 'KeyK';
fallbackUsed: boolean;
@@ -113,69 +96,6 @@ export function createKeyboardHandlers(
});
}
function handleInvisiblePositionEditKeydown(e: KeyboardEvent): boolean {
if (!ctx.platform.isInvisibleLayer) return false;
if (isInvisiblePositionToggleShortcut(e)) {
e.preventDefault();
if (ctx.state.invisiblePositionEditMode) {
options.cancelInvisiblePositionEdit();
} else {
options.setInvisiblePositionEditMode(true);
}
return true;
}
if (!ctx.state.invisiblePositionEditMode) return false;
const step = e.shiftKey
? ctx.platform.invisiblePositionStepFastPx
: ctx.platform.invisiblePositionStepPx;
if (e.key === 'Escape') {
e.preventDefault();
options.cancelInvisiblePositionEdit();
return true;
}
if (e.key === 'Enter' || ((e.ctrlKey || e.metaKey) && e.code === 'KeyS')) {
e.preventDefault();
options.saveInvisiblePositionEdit();
return true;
}
if (
e.key === 'ArrowUp' ||
e.key === 'ArrowDown' ||
e.key === 'ArrowLeft' ||
e.key === 'ArrowRight' ||
e.key === 'h' ||
e.key === 'j' ||
e.key === 'k' ||
e.key === 'l' ||
e.key === 'H' ||
e.key === 'J' ||
e.key === 'K' ||
e.key === 'L'
) {
e.preventDefault();
if (e.key === 'ArrowUp' || e.key === 'k' || e.key === 'K') {
ctx.state.invisibleSubtitleOffsetYPx += step;
} else if (e.key === 'ArrowDown' || e.key === 'j' || e.key === 'J') {
ctx.state.invisibleSubtitleOffsetYPx -= step;
} else if (e.key === 'ArrowLeft' || e.key === 'h' || e.key === 'H') {
ctx.state.invisibleSubtitleOffsetXPx -= step;
} else if (e.key === 'ArrowRight' || e.key === 'l' || e.key === 'L') {
ctx.state.invisibleSubtitleOffsetXPx += step;
}
options.applyInvisibleSubtitleOffsetPosition();
options.updateInvisiblePositionEditHud();
return true;
}
return true;
}
function resetChord(): void {
ctx.state.chordPending = false;
if (ctx.state.chordTimeout !== null) {
@@ -188,9 +108,7 @@ export function createKeyboardHandlers(
updateKeybindings(await window.electronAPI.getKeybindings());
document.addEventListener('keydown', (e: KeyboardEvent) => {
const yomitanPopup = document.querySelector('iframe[id^="yomitan-popup"]');
if (yomitanPopup) return;
if (handleInvisiblePositionEditKeydown(e)) return;
if (hasYomitanPopupIframe(document)) return;
if (ctx.state.runtimeOptionsModalOpen) {
options.handleRuntimeOptionsKeydown(e);

View File

@@ -1,20 +1,50 @@
import type { ModalStateReader, RendererContext } from '../context';
import {
YOMITAN_POPUP_HIDDEN_EVENT,
YOMITAN_POPUP_SHOWN_EVENT,
hasYomitanPopupIframe,
isYomitanPopupIframe,
} from '../yomitan-popup.js';
export function createMouseHandlers(
ctx: RendererContext,
options: {
modalStateReader: ModalStateReader;
applyInvisibleSubtitleLayoutFromMpvMetrics: (metrics: any, source: string) => void;
applyYPercent: (yPercent: number) => void;
getCurrentYPercent: () => number;
persistSubtitlePositionPatch: (patch: { yPercent: number }) => void;
reportHoveredTokenIndex: (tokenIndex: number | null) => void;
},
) {
const wordSegmenter =
typeof Intl !== 'undefined' && 'Segmenter' in Intl
? new Intl.Segmenter(undefined, { granularity: 'word' })
: null;
let yomitanPopupVisible = false;
function enablePopupInteraction(): void {
yomitanPopupVisible = true;
ctx.dom.overlay.classList.add('interactive');
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(false);
}
if (ctx.platform.isMacOSPlatform) {
window.focus();
}
}
function disablePopupInteractionIfIdle(): void {
if (hasYomitanPopupIframe(document)) {
yomitanPopupVisible = true;
return;
}
yomitanPopupVisible = false;
if (
!ctx.state.isOverSubtitle &&
!options.modalStateReader.isAnyModalOpen()
) {
ctx.dom.overlay.classList.remove('interactive');
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
}
}
}
function handleMouseEnter(): void {
ctx.state.isOverSubtitle = true;
@@ -26,17 +56,8 @@ export function createMouseHandlers(
function handleMouseLeave(): void {
ctx.state.isOverSubtitle = false;
const yomitanPopup = document.querySelector('iframe[id^="yomitan-popup"]');
if (
!yomitanPopup &&
!options.modalStateReader.isAnyModalOpen() &&
!ctx.state.invisiblePositionEditMode
) {
ctx.dom.overlay.classList.remove('interactive');
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
}
}
if (yomitanPopupVisible) return;
disablePopupInteractionIfIdle();
}
function setupDragging(): void {
@@ -75,238 +96,8 @@ export function createMouseHandlers(
});
}
function getCaretTextPointRange(clientX: number, clientY: number): Range | null {
const documentWithCaretApi = document as Document & {
caretRangeFromPoint?: (x: number, y: number) => Range | null;
caretPositionFromPoint?: (
x: number,
y: number,
) => { offsetNode: Node; offset: number } | null;
};
if (typeof documentWithCaretApi.caretRangeFromPoint === 'function') {
return documentWithCaretApi.caretRangeFromPoint(clientX, clientY);
}
if (typeof documentWithCaretApi.caretPositionFromPoint === 'function') {
const caretPosition = documentWithCaretApi.caretPositionFromPoint(clientX, clientY);
if (!caretPosition) return null;
const range = document.createRange();
range.setStart(caretPosition.offsetNode, caretPosition.offset);
range.collapse(true);
return range;
}
return null;
}
function getTextOffsetWithinSubtitleRoot(targetNode: Text, targetOffset: number): number | null {
const clampedTargetOffset = Math.max(0, Math.min(targetOffset, targetNode.data.length));
const walker = document.createTreeWalker(ctx.dom.subtitleRoot, NodeFilter.SHOW_ALL);
let totalOffset = 0;
let node: Node | null = walker.currentNode;
while (node) {
if (node.nodeType === Node.TEXT_NODE) {
const textNode = node as Text;
if (textNode === targetNode) {
return totalOffset + clampedTargetOffset;
}
totalOffset += textNode.data.length;
} else if (
node.nodeType === Node.ELEMENT_NODE &&
(node as Element).tagName.toUpperCase() === 'BR'
) {
totalOffset += 1;
}
node = walker.nextNode();
}
return null;
}
function resolveHoveredInvisibleTokenIndex(event: MouseEvent): number | null {
if (!(event.target instanceof Node)) {
return null;
}
if (!ctx.dom.subtitleRoot.contains(event.target)) {
return null;
}
if (ctx.state.invisibleTokenHoverRanges.length === 0) {
return null;
}
const caretRange = getCaretTextPointRange(event.clientX, event.clientY);
if (!caretRange) {
return null;
}
if (caretRange.startContainer.nodeType !== Node.TEXT_NODE) {
return null;
}
if (!ctx.dom.subtitleRoot.contains(caretRange.startContainer)) {
return null;
}
const textOffset = getTextOffsetWithinSubtitleRoot(
caretRange.startContainer as Text,
caretRange.startOffset,
);
if (textOffset === null) {
return null;
}
for (const range of ctx.state.invisibleTokenHoverRanges) {
if (textOffset >= range.start && textOffset < range.end) {
return range.tokenIndex;
}
}
return null;
}
function getWordBoundsAtOffset(
text: string,
offset: number,
): { start: number; end: number } | null {
if (!text || text.length === 0) return null;
const clampedOffset = Math.max(0, Math.min(offset, text.length));
const probeIndex = clampedOffset >= text.length ? Math.max(0, text.length - 1) : clampedOffset;
if (wordSegmenter) {
for (const part of wordSegmenter.segment(text)) {
const start = part.index;
const end = start + part.segment.length;
if (probeIndex >= start && probeIndex < end) {
if (part.isWordLike === false) return null;
return { start, end };
}
}
}
const isBoundary = (char: string): boolean =>
/[\s\u3000.,!?;:()[\]{}"'`~<>/\\|@#$%^&*+=\-、。・「」『』【】〈〉《》]/.test(char);
const probeChar = text[probeIndex];
if (!probeChar || isBoundary(probeChar)) return null;
let start = probeIndex;
while (start > 0 && !isBoundary(text[start - 1] ?? '')) {
start -= 1;
}
let end = probeIndex + 1;
while (end < text.length && !isBoundary(text[end] ?? '')) {
end += 1;
}
if (end <= start) return null;
return { start, end };
}
function updateHoverWordSelection(event: MouseEvent): void {
if (!ctx.platform.isInvisibleLayer || !ctx.platform.isMacOSPlatform) return;
if (event.buttons !== 0) return;
if (!(event.target instanceof Node)) return;
if (!ctx.dom.subtitleRoot.contains(event.target)) return;
const caretRange = getCaretTextPointRange(event.clientX, event.clientY);
if (!caretRange) return;
if (caretRange.startContainer.nodeType !== Node.TEXT_NODE) return;
if (!ctx.dom.subtitleRoot.contains(caretRange.startContainer)) return;
const textNode = caretRange.startContainer as Text;
const wordBounds = getWordBoundsAtOffset(textNode.data, caretRange.startOffset);
if (!wordBounds) return;
const selectionKey = `${wordBounds.start}:${wordBounds.end}:${textNode.data.slice(
wordBounds.start,
wordBounds.end,
)}`;
if (
selectionKey === ctx.state.lastHoverSelectionKey &&
textNode === ctx.state.lastHoverSelectionNode
) {
return;
}
const selection = window.getSelection();
if (!selection) return;
const range = document.createRange();
range.setStart(textNode, wordBounds.start);
range.setEnd(textNode, wordBounds.end);
selection.removeAllRanges();
selection.addRange(range);
ctx.state.lastHoverSelectionKey = selectionKey;
ctx.state.lastHoverSelectionNode = textNode;
}
function setupInvisibleHoverSelection(): void {
if (!ctx.platform.isInvisibleLayer || !ctx.platform.isMacOSPlatform) return;
ctx.dom.subtitleRoot.addEventListener('mousemove', (event: MouseEvent) => {
updateHoverWordSelection(event);
});
ctx.dom.subtitleRoot.addEventListener('mouseleave', () => {
ctx.state.lastHoverSelectionKey = '';
ctx.state.lastHoverSelectionNode = null;
});
}
function setupInvisibleTokenHoverReporter(): void {
if (!ctx.platform.isInvisibleLayer) return;
let pendingNullHoverTimer: ReturnType<typeof setTimeout> | null = null;
const clearPendingNullHoverTimer = (): void => {
if (pendingNullHoverTimer !== null) {
clearTimeout(pendingNullHoverTimer);
pendingNullHoverTimer = null;
}
};
const reportHoveredToken = (tokenIndex: number | null): void => {
if (ctx.state.lastHoveredTokenIndex === tokenIndex) return;
ctx.state.lastHoveredTokenIndex = tokenIndex;
options.reportHoveredTokenIndex(tokenIndex);
};
const queueNullHoveredToken = (): void => {
if (pendingNullHoverTimer !== null) return;
pendingNullHoverTimer = setTimeout(() => {
pendingNullHoverTimer = null;
reportHoveredToken(null);
}, 120);
};
ctx.dom.subtitleRoot.addEventListener('mousemove', (event: MouseEvent) => {
const tokenIndex = resolveHoveredInvisibleTokenIndex(event);
if (tokenIndex === null) {
queueNullHoveredToken();
return;
}
clearPendingNullHoverTimer();
reportHoveredToken(tokenIndex);
});
ctx.dom.subtitleRoot.addEventListener('mouseleave', () => {
clearPendingNullHoverTimer();
reportHoveredToken(null);
});
}
function setupResizeHandler(): void {
window.addEventListener('resize', () => {
if (ctx.platform.isInvisibleLayer) {
if (!ctx.state.mpvSubtitleRenderMetrics) return;
options.applyInvisibleSubtitleLayoutFromMpvMetrics(
ctx.state.mpvSubtitleRenderMetrics,
'resize',
);
return;
}
options.applyYPercent(options.getCurrentYPercent());
});
}
@@ -325,39 +116,31 @@ export function createMouseHandlers(
}
function setupYomitanObserver(): void {
yomitanPopupVisible = hasYomitanPopupIframe(document);
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
enablePopupInteraction();
});
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
disablePopupInteractionIfIdle();
});
const observer = new MutationObserver((mutations: MutationRecord[]) => {
for (const mutation of mutations) {
mutation.addedNodes.forEach((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
const element = node as Element;
if (
element.tagName === 'IFRAME' &&
element.id &&
element.id.startsWith('yomitan-popup')
) {
ctx.dom.overlay.classList.add('interactive');
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(false);
}
if (isYomitanPopupIframe(element)) {
enablePopupInteraction();
}
});
mutation.removedNodes.forEach((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
const element = node as Element;
if (
element.tagName === 'IFRAME' &&
element.id &&
element.id.startsWith('yomitan-popup')
) {
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(true, {
forward: true,
});
}
}
if (isYomitanPopupIframe(element)) {
disablePopupInteractionIfIdle();
}
});
}
@@ -373,8 +156,6 @@ export function createMouseHandlers(
handleMouseEnter,
handleMouseLeave,
setupDragging,
setupInvisibleHoverSelection,
setupInvisibleTokenHoverReporter,
setupResizeHandler,
setupSelectionObserver,
setupYomitanObserver,

View File

@@ -251,7 +251,6 @@ export function createJimakuModal(
}
function openJimakuModal(): void {
if (ctx.platform.isInvisibleLayer) return;
if (ctx.state.jimakuModalOpen) return;
ctx.state.jimakuModalOpen = true;

View File

@@ -66,7 +66,6 @@ export function createKikuModal(
original: KikuDuplicateCardInfo;
duplicate: KikuDuplicateCardInfo;
}): void {
if (ctx.platform.isInvisibleLayer) return;
if (ctx.state.kikuModalOpen) return;
ctx.state.kikuModalOpen = true;

View File

@@ -162,8 +162,6 @@ export function createRuntimeOptionsModal(
}
async function openRuntimeOptionsModal(): Promise<void> {
if (ctx.platform.isInvisibleLayer) return;
const optionsList = await window.electronAPI.getRuntimeOptions();
updateRuntimeOptions(optionsList);

View File

@@ -96,7 +96,6 @@ const OVERLAY_SHORTCUTS: Array<{
{ key: 'openRuntimeOptions', label: 'Open runtime options' },
{ key: 'openJimaku', label: 'Open jimaku' },
{ key: 'toggleVisibleOverlayGlobal', label: 'Show/hide visible overlay' },
{ key: 'toggleInvisibleOverlayGlobal', label: 'Show/hide invisible overlay' },
];
function buildOverlayShortcutSections(shortcuts: RuntimeShortcutConfig): SessionHelpSection[] {

View File

@@ -45,8 +45,6 @@ export function createSubsyncModal(
}
function openSubsyncModal(payload: SubsyncManualPayload): void {
if (ctx.platform.isInvisibleLayer) return;
ctx.state.subsyncSubmitting = false;
ctx.dom.subsyncRunButton.disabled = false;
ctx.state.subsyncSourceTracks = payload.sourceTracks;

View File

@@ -3,8 +3,8 @@ import type { RendererContext } from './context';
const MEASUREMENT_DEBOUNCE_MS = 80;
function isMeasurableOverlayLayer(layer: string): layer is 'visible' | 'invisible' {
return layer === 'visible' || layer === 'invisible';
function isMeasurableOverlayLayer(layer: string): layer is 'visible' {
return layer === 'visible';
}
function round2(value: number): number {

View File

@@ -0,0 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
function readWorkspaceFile(relativePath: string): string {
return fs.readFileSync(path.join(process.cwd(), relativePath), 'utf8');
}
test('keyboard chord map no longer emits legacy invisible overlay script messages', () => {
const keyboardSource = readWorkspaceFile('src/renderer/handlers/keyboard.ts');
assert.doesNotMatch(keyboardSource, /subminer-toggle-invisible/);
assert.doesNotMatch(keyboardSource, /subminer-show-invisible/);
assert.doesNotMatch(keyboardSource, /subminer-hide-invisible/);
});
test('overlay layer contracts no longer advertise invisible renderer layer', () => {
const typesSource = readWorkspaceFile('src/types.ts');
assert.doesNotMatch(typesSource, /export type OverlayLayer = 'visible' \| 'invisible'/);
assert.doesNotMatch(
typesSource,
/getOverlayLayer:\s*\(\)\s*=>\s*'visible'\s*\|\s*'invisible'\s*\|\s*'modal'\s*\|\s*null/,
);
});
test('renderer stylesheet no longer contains invisible-layer selectors', () => {
const cssSource = readWorkspaceFile('src/renderer/style.css');
assert.doesNotMatch(cssSource, /body\.layer-invisible/);
});
test('top-level docs avoid stale overlay-layers wording', () => {
const docsReadmeSource = readWorkspaceFile('docs/README.md');
assert.doesNotMatch(docsReadmeSource, /overlay layers/i);
});

View File

@@ -1,36 +1,11 @@
import type { ModalStateReader, RendererContext } from '../context';
import type { RendererContext } from '../context';
import {
createInMemorySubtitlePositionController,
type SubtitlePositionController,
type SubtitlePositionController
} from './position-state.js';
import {
createInvisibleOffsetController,
type InvisibleOffsetController,
} from './invisible-offset.js';
import {
createMpvSubtitleLayoutController,
type MpvSubtitleLayoutController,
} from './invisible-layout.js';
type PositioningControllerOptions = {
modalStateReader: Pick<ModalStateReader, 'isAnySettingsModalOpen'>;
applySubtitleFontSize: (fontSize: number) => void;
};
export function createPositioningController(
ctx: RendererContext,
options: PositioningControllerOptions,
) {
const visible = createInMemorySubtitlePositionController(ctx);
const invisibleOffset = createInvisibleOffsetController(ctx, options.modalStateReader);
const invisibleLayout = createMpvSubtitleLayoutController(ctx, options.applySubtitleFontSize, {
applyInvisibleSubtitleOffsetPosition: invisibleOffset.applyInvisibleSubtitleOffsetPosition,
updateInvisiblePositionEditHud: invisibleOffset.updateInvisiblePositionEditHud,
});
return {
...visible,
...invisibleOffset,
...invisibleLayout,
} as SubtitlePositionController & InvisibleOffsetController & MpvSubtitleLayoutController;
): SubtitlePositionController {
return createInMemorySubtitlePositionController(ctx);
}

View File

@@ -1,243 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { MpvSubtitleRenderMetrics } from '../../types';
import {
applyTypography,
applyVerticalPosition,
resolveBaselineCompensationPx,
} from './invisible-layout-helpers.js';
const METRICS: MpvSubtitleRenderMetrics = {
subPos: 100,
subFontSize: 38,
subScale: 1,
subMarginY: 34,
subMarginX: 19,
subFont: 'sans-serif',
subSpacing: 0,
subBold: false,
subItalic: false,
subBorderSize: 2.5,
subShadowOffset: 0,
subAssOverride: 'yes',
subScaleByWindow: true,
subUseMargins: true,
osdHeight: 720,
osdDimensions: null,
};
type TypographyTestContext = {
dom: {
subtitleRoot: { style: CSSStyleDeclaration };
subtitleContainer: { style: CSSStyleDeclaration };
};
state: {
currentInvisibleSubtitleLineCount: number;
invisibleMeasuredDescentPx: number | null;
};
platform: {
isMacOSPlatform: boolean;
};
};
function withMockedComputedLineHeight(lineHeightPx: number, callback: () => void): void {
const originalGetComputedStyle = (globalThis as { getComputedStyle?: unknown }).getComputedStyle;
Object.defineProperty(globalThis, 'getComputedStyle', {
configurable: true,
value: () =>
({
lineHeight: `${lineHeightPx}px`,
}) as CSSStyleDeclaration,
});
try {
callback();
} finally {
if (typeof originalGetComputedStyle === 'function') {
Object.defineProperty(globalThis, 'getComputedStyle', {
configurable: true,
value: originalGetComputedStyle,
});
} else {
Reflect.deleteProperty(globalThis, 'getComputedStyle');
}
}
}
function createStyle(initial: Record<string, string> = {}): CSSStyleDeclaration {
const values: Record<string, string> = { ...initial };
const target = {
setProperty: (name: string, value: string) => {
values[name] = value;
},
getPropertyValue: (name: string) => values[name] ?? '',
} as unknown as CSSStyleDeclaration;
return new Proxy(target, {
get(obj, prop) {
if (typeof prop === 'string') {
if (prop in obj) return obj[prop as keyof CSSStyleDeclaration];
return values[prop] ?? '';
}
return obj[prop as keyof CSSStyleDeclaration];
},
set(_obj, prop, value) {
if (typeof prop === 'string') {
values[prop] = String(value);
return true;
}
return false;
},
});
}
function createContext(options: {
isMacOSPlatform: boolean;
lineCount: number;
bottomPx?: number;
topPx?: number;
}): TypographyTestContext {
const subtitleRoot = { style: createStyle() };
const subtitleContainer = {
style: createStyle({
bottom: typeof options.bottomPx === 'number' ? `${options.bottomPx}px` : '',
top: typeof options.topPx === 'number' ? `${options.topPx}px` : '',
}),
};
return {
dom: { subtitleRoot, subtitleContainer },
state: {
currentInvisibleSubtitleLineCount: options.lineCount,
invisibleMeasuredDescentPx: null,
},
platform: {
isMacOSPlatform: options.isMacOSPlatform,
},
};
}
test('resolveBaselineCompensationPx uses measured descent when present', () => {
const compensation = resolveBaselineCompensationPx(10, 2.5, 1);
assert.equal(compensation, 16);
});
test('resolveBaselineCompensationPx falls back to border and shadow compensation when descent missing', () => {
const compensation = resolveBaselineCompensationPx(null, 2.5, 1);
assert.equal(compensation, 17.5);
});
test('applyTypography keeps macOS default letter spacing neutral when mpv spacing is zero', () => {
const ctx = createContext({
isMacOSPlatform: true,
lineCount: 1,
bottomPx: 120,
});
withMockedComputedLineHeight(34, () => {
applyTypography(ctx as never, {
metrics: { ...METRICS, subSpacing: 0 },
pxPerScaledPixel: 1,
effectiveFontSize: 34,
});
});
assert.equal(ctx.dom.subtitleRoot.style.getPropertyValue('letter-spacing'), '0px');
});
test('applyTypography applies full mpv letter spacing scale on macOS', () => {
const ctx = createContext({
isMacOSPlatform: true,
lineCount: 1,
bottomPx: 120,
});
withMockedComputedLineHeight(34, () => {
applyTypography(ctx as never, {
metrics: { ...METRICS, subSpacing: 1.5 },
pxPerScaledPixel: 2,
effectiveFontSize: 34,
});
});
assert.equal(ctx.dom.subtitleRoot.style.getPropertyValue('letter-spacing'), '3px');
});
test('applyTypography uses macOS multiline-tuned line-height for invisible overlay', () => {
const ctx = createContext({
isMacOSPlatform: true,
lineCount: 3,
bottomPx: 120,
});
withMockedComputedLineHeight(34, () => {
applyTypography(ctx as never, {
metrics: METRICS,
pxPerScaledPixel: 1,
effectiveFontSize: 34,
});
});
assert.equal(ctx.dom.subtitleRoot.style.getPropertyValue('line-height'), '1.62');
});
test('applyVerticalPosition uses subtitle position margin and baseline compensation', () => {
const ctx = createContext({
isMacOSPlatform: true,
lineCount: 1,
});
applyVerticalPosition(ctx as never, {
metrics: { ...METRICS, subPos: 90 },
renderAreaHeight: 720,
topInset: 0,
bottomInset: 10,
marginY: 34,
borderPx: 2.5,
shadowPx: 0,
measuredDescentPx: null,
vAlign: 0,
});
const bottom = parseFloat(ctx.dom.subtitleContainer.style.bottom);
assert.ok(Number.isFinite(bottom));
assert.ok(bottom > 90 && bottom < 105);
});
test('applyVerticalPosition uses measured descent consistently across line counts', () => {
const single = createContext({
isMacOSPlatform: true,
lineCount: 1,
});
const dense = createContext({
isMacOSPlatform: true,
lineCount: 3,
});
applyVerticalPosition(single as never, {
metrics: METRICS,
renderAreaHeight: 720,
topInset: 0,
bottomInset: 0,
marginY: 34,
borderPx: 2.5,
shadowPx: 0,
measuredDescentPx: 12,
vAlign: 0,
});
applyVerticalPosition(dense as never, {
metrics: METRICS,
renderAreaHeight: 720,
topInset: 0,
bottomInset: 0,
marginY: 34,
borderPx: 2.5,
shadowPx: 0,
measuredDescentPx: 12,
vAlign: 0,
});
const singleBottom = parseFloat(single.dom.subtitleContainer.style.bottom);
const denseBottom = parseFloat(dense.dom.subtitleContainer.style.bottom);
assert.equal(singleBottom, denseBottom);
});

View File

@@ -1,228 +0,0 @@
import type { MpvSubtitleRenderMetrics } from '../../types';
import type { RendererContext } from '../context';
const INVISIBLE_MACOS_VERTICAL_NUDGE_PX = 5;
const INVISIBLE_MACOS_LINE_HEIGHT_SINGLE = '1.08';
const INVISIBLE_MACOS_LINE_HEIGHT_MULTI = '1.35';
const INVISIBLE_MACOS_LINE_HEIGHT_MULTI_DENSE = '1.48';
let fontMetricsCanvas: HTMLCanvasElement | null = null;
export function applyContainerBaseLayout(
ctx: RendererContext,
params: {
horizontalAvailable: number;
leftInset: number;
marginX: number;
hAlign: 0 | 1 | 2;
},
): void {
const { horizontalAvailable, leftInset, marginX, hAlign } = params;
ctx.dom.subtitleContainer.style.position = 'absolute';
ctx.dom.subtitleContainer.style.maxWidth = `${horizontalAvailable}px`;
ctx.dom.subtitleContainer.style.width = `${horizontalAvailable}px`;
ctx.dom.subtitleContainer.style.padding = '0';
ctx.dom.subtitleContainer.style.background = 'transparent';
ctx.dom.subtitleContainer.style.marginBottom = '0';
ctx.dom.subtitleContainer.style.pointerEvents = 'none';
ctx.dom.subtitleContainer.style.left = `${leftInset + marginX}px`;
ctx.dom.subtitleContainer.style.right = '';
ctx.dom.subtitleContainer.style.transform = '';
ctx.dom.subtitleContainer.style.textAlign = '';
if (hAlign === 0) {
ctx.dom.subtitleContainer.style.textAlign = 'left';
ctx.dom.subtitleRoot.style.textAlign = 'left';
} else if (hAlign === 2) {
ctx.dom.subtitleContainer.style.textAlign = 'right';
ctx.dom.subtitleRoot.style.textAlign = 'right';
} else {
ctx.dom.subtitleContainer.style.textAlign = 'center';
ctx.dom.subtitleRoot.style.textAlign = 'center';
}
ctx.dom.subtitleRoot.style.display = 'inline-block';
ctx.dom.subtitleRoot.style.maxWidth = '100%';
ctx.dom.subtitleRoot.style.pointerEvents = 'auto';
}
export function applyVerticalPosition(
ctx: RendererContext,
params: {
metrics: MpvSubtitleRenderMetrics;
renderAreaHeight: number;
topInset: number;
bottomInset: number;
marginY: number;
borderPx: number;
shadowPx: number;
measuredDescentPx: number | null;
vAlign: 0 | 1 | 2;
},
): void {
const baselineCompensationPx = resolveBaselineCompensationPx(
params.measuredDescentPx,
params.borderPx,
params.shadowPx,
);
if (params.vAlign === 2) {
ctx.dom.subtitleContainer.style.top = `${Math.max(
0,
params.topInset + params.marginY - baselineCompensationPx,
)}px`;
ctx.dom.subtitleContainer.style.bottom = '';
return;
}
if (params.vAlign === 1) {
ctx.dom.subtitleContainer.style.top = '50%';
ctx.dom.subtitleContainer.style.bottom = '';
ctx.dom.subtitleContainer.style.transform = 'translateY(-50%)';
return;
}
const subPosMargin = ((100 - params.metrics.subPos) / 100) * params.renderAreaHeight;
const effectiveMargin = Math.max(params.marginY, subPosMargin);
const bottomPx = Math.max(0, params.bottomInset + effectiveMargin + baselineCompensationPx);
ctx.dom.subtitleContainer.style.top = '';
ctx.dom.subtitleContainer.style.bottom = `${bottomPx}px`;
}
export function resolveBaselineCompensationPx(
measuredDescentPx: number | null,
borderPx: number,
shadowPx: number,
): number {
const outlineCompensationPx = Math.max(0, borderPx * 2 + shadowPx);
if (typeof measuredDescentPx === 'number' && Number.isFinite(measuredDescentPx) && measuredDescentPx > 0) {
return Math.max(0, measuredDescentPx + outlineCompensationPx);
}
return Math.max(0, (borderPx + shadowPx) * 5);
}
function resolveFontFamily(rawFont: string): string {
const strippedFont = rawFont
.replace(
/\s+(Regular|Bold|Italic|Light|Medium|Semi\s*Bold|Extra\s*Bold|Extra\s*Light|Thin|Black|Heavy|Demi\s*Bold|Book|Condensed)\s*$/i,
'',
)
.trim();
return strippedFont !== rawFont
? `"${rawFont}", "${strippedFont}", sans-serif`
: `"${rawFont}", sans-serif`;
}
export function resolveInvisibleLineHeight(lineCount: number, isMacOSPlatform: boolean): string {
if (!isMacOSPlatform) return 'normal';
if (lineCount >= 3) return INVISIBLE_MACOS_LINE_HEIGHT_MULTI_DENSE;
if (lineCount >= 2) return INVISIBLE_MACOS_LINE_HEIGHT_MULTI;
return INVISIBLE_MACOS_LINE_HEIGHT_SINGLE;
}
function resolveLetterSpacing(
spacing: number,
pxPerScaledPixel: number,
): string {
if (Math.abs(spacing) > 0.0001) {
return `${spacing * pxPerScaledPixel}px`;
}
return '0px';
}
function measureFontDescentPx(ctx: RendererContext): number | null {
if (typeof document === 'undefined') return null;
const computedStyle = getComputedStyle(ctx.dom.subtitleRoot);
const font = computedStyle.font?.trim();
if (!font) return null;
if (!fontMetricsCanvas) {
fontMetricsCanvas = document.createElement('canvas');
}
const context = fontMetricsCanvas.getContext('2d');
if (!context) return null;
context.font = font;
const metrics = context.measureText('Hg漢あ');
if (!Number.isFinite(metrics.actualBoundingBoxDescent) || metrics.actualBoundingBoxDescent <= 0) {
return null;
}
return metrics.actualBoundingBoxDescent;
}
function applyComputedLineHeightCompensation(
ctx: RendererContext,
effectiveFontSize: number,
): void {
const computedLineHeight = parseFloat(getComputedStyle(ctx.dom.subtitleRoot).lineHeight);
if (!Number.isFinite(computedLineHeight) || computedLineHeight <= effectiveFontSize) {
return;
}
const halfLeading = (computedLineHeight - effectiveFontSize) / 2;
if (halfLeading <= 0.5) return;
const currentBottom = parseFloat(ctx.dom.subtitleContainer.style.bottom);
if (Number.isFinite(currentBottom)) {
ctx.dom.subtitleContainer.style.bottom = `${Math.max(0, currentBottom - halfLeading)}px`;
}
const currentTop = parseFloat(ctx.dom.subtitleContainer.style.top);
if (Number.isFinite(currentTop)) {
ctx.dom.subtitleContainer.style.top = `${Math.max(0, currentTop - halfLeading)}px`;
}
}
function applyMacOSAdjustments(ctx: RendererContext): void {
const isMacOSPlatform = ctx.platform.isMacOSPlatform;
if (!isMacOSPlatform) return;
const currentBottom = parseFloat(ctx.dom.subtitleContainer.style.bottom);
if (!Number.isFinite(currentBottom)) return;
ctx.dom.subtitleContainer.style.bottom = `${Math.max(
0,
currentBottom + INVISIBLE_MACOS_VERTICAL_NUDGE_PX,
)}px`;
}
export function applyTypography(
ctx: RendererContext,
params: {
metrics: MpvSubtitleRenderMetrics;
pxPerScaledPixel: number;
effectiveFontSize: number;
},
): void {
const isMacOSPlatform = ctx.platform.isMacOSPlatform;
const lineCount = Math.max(1, ctx.state.currentInvisibleSubtitleLineCount);
const invisibleLineHeight = resolveInvisibleLineHeight(lineCount, isMacOSPlatform);
ctx.dom.subtitleRoot.style.setProperty('--invisible-sub-line-height', invisibleLineHeight);
ctx.dom.subtitleRoot.style.setProperty(
'line-height',
invisibleLineHeight,
isMacOSPlatform ? 'important' : '',
);
ctx.dom.subtitleRoot.style.fontFamily = resolveFontFamily(params.metrics.subFont);
ctx.dom.subtitleRoot.style.setProperty(
'letter-spacing',
resolveLetterSpacing(params.metrics.subSpacing, params.pxPerScaledPixel),
isMacOSPlatform ? 'important' : '',
);
ctx.dom.subtitleRoot.style.fontKerning = isMacOSPlatform ? 'auto' : 'none';
ctx.dom.subtitleRoot.style.fontWeight = params.metrics.subBold ? '700' : '400';
ctx.dom.subtitleRoot.style.fontStyle = params.metrics.subItalic ? 'italic' : 'normal';
ctx.dom.subtitleRoot.style.transform = '';
ctx.dom.subtitleRoot.style.transformOrigin = '';
ctx.state.invisibleMeasuredDescentPx = measureFontDescentPx(ctx);
applyComputedLineHeightCompensation(ctx, params.effectiveFontSize);
applyMacOSAdjustments(ctx);
}

Some files were not shown because too many files have changed in this diff Show More