mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 04:49:49 -07:00
refactor(renderer): extract modal registry for open/dismiss/active queries (#166)
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
type: internal
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Consolidated renderer modal state handling into a descriptor registry.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { createModalRegistry, type ModalDescriptor } from './modal-registry';
|
||||||
|
|
||||||
|
test('modal registry derives open, subtitle-suppression, and active state from descriptor order', () => {
|
||||||
|
const openIds = new Set(['animetosho', 'runtime-options']);
|
||||||
|
const descriptors: ModalDescriptor<string>[] = [
|
||||||
|
{
|
||||||
|
id: 'jimaku',
|
||||||
|
isOpen: () => openIds.has('jimaku'),
|
||||||
|
close: () => {},
|
||||||
|
suppressesSubtitles: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'animetosho',
|
||||||
|
isOpen: () => openIds.has('animetosho'),
|
||||||
|
close: () => {},
|
||||||
|
suppressesSubtitles: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'runtime-options',
|
||||||
|
isOpen: () => openIds.has('runtime-options'),
|
||||||
|
close: () => {},
|
||||||
|
suppressesSubtitles: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const registry = createModalRegistry(descriptors);
|
||||||
|
|
||||||
|
assert.equal(registry.isAnyOpen(), true);
|
||||||
|
assert.equal(registry.isAnySuppressingSubtitlesOpen(), true);
|
||||||
|
assert.equal(registry.getActive(), 'animetosho');
|
||||||
|
|
||||||
|
openIds.clear();
|
||||||
|
assert.equal(registry.isAnyOpen(), false);
|
||||||
|
assert.equal(registry.isAnySuppressingSubtitlesOpen(), false);
|
||||||
|
assert.equal(registry.getActive(), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modal registry dismisses every open descriptor and skips closed descriptors', () => {
|
||||||
|
const closed: string[] = [];
|
||||||
|
const descriptors: ModalDescriptor<string>[] = ['closed', 'first-open', 'second-open'].map(
|
||||||
|
(id) => ({
|
||||||
|
id,
|
||||||
|
isOpen: () => id !== 'closed',
|
||||||
|
close: () => closed.push(id),
|
||||||
|
suppressesSubtitles: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const registry = createModalRegistry(descriptors);
|
||||||
|
|
||||||
|
registry.dismissOpen();
|
||||||
|
|
||||||
|
assert.deepEqual(closed, ['first-open', 'second-open']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export type ModalDescriptor<TId extends string> = {
|
||||||
|
id: TId;
|
||||||
|
isOpen: () => boolean;
|
||||||
|
close: () => void;
|
||||||
|
suppressesSubtitles: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createModalRegistry<TId extends string>(
|
||||||
|
descriptors: readonly ModalDescriptor<TId>[],
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
isAnyOpen: (): boolean => descriptors.some((descriptor) => descriptor.isOpen()),
|
||||||
|
isAnySuppressingSubtitlesOpen: (): boolean =>
|
||||||
|
descriptors.some((descriptor) => descriptor.suppressesSubtitles && descriptor.isOpen()),
|
||||||
|
getActive: (): TId | null => descriptors.find((descriptor) => descriptor.isOpen())?.id ?? null,
|
||||||
|
dismissOpen: (): void => {
|
||||||
|
for (const descriptor of descriptors) {
|
||||||
|
if (descriptor.isOpen()) {
|
||||||
|
descriptor.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+84
-76
@@ -25,6 +25,7 @@ import type {
|
|||||||
SubsyncManualPayload,
|
SubsyncManualPayload,
|
||||||
ConfigHotReloadPayload,
|
ConfigHotReloadPayload,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||||
import { createKeyboardHandlers } from './handlers/keyboard.js';
|
import { createKeyboardHandlers } from './handlers/keyboard.js';
|
||||||
import { createGamepadController } from './handlers/gamepad-controller.js';
|
import { createGamepadController } from './handlers/gamepad-controller.js';
|
||||||
import { createMouseHandlers } from './handlers/mouse.js';
|
import { createMouseHandlers } from './handlers/mouse.js';
|
||||||
@@ -53,6 +54,7 @@ import {
|
|||||||
} from './overlay-notifications.js';
|
} from './overlay-notifications.js';
|
||||||
import { createOverlayNotificationHistoryPanel } from './overlay-notification-history.js';
|
import { createOverlayNotificationHistoryPanel } from './overlay-notification-history.js';
|
||||||
import { createRendererState } from './state.js';
|
import { createRendererState } from './state.js';
|
||||||
|
import { createModalRegistry, type ModalDescriptor } from './modal-registry.js';
|
||||||
import { createSubtitleRenderer } from './subtitle-render.js';
|
import { createSubtitleRenderer } from './subtitle-render.js';
|
||||||
import { isYomitanPopupVisible, registerYomitanLookupListener } from './yomitan-popup.js';
|
import { isYomitanPopupVisible, registerYomitanLookupListener } from './yomitan-popup.js';
|
||||||
import {
|
import {
|
||||||
@@ -74,36 +76,89 @@ const ctx = {
|
|||||||
state: createRendererState(),
|
state: createRendererState(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const modalDescriptors = [
|
||||||
|
{
|
||||||
|
id: 'controller-select',
|
||||||
|
isOpen: () => ctx.state.controllerSelectModalOpen,
|
||||||
|
close: () => controllerSelectModal.closeControllerSelectModal(),
|
||||||
|
suppressesSubtitles: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'controller-debug',
|
||||||
|
isOpen: () => ctx.state.controllerDebugModalOpen,
|
||||||
|
close: () => controllerDebugModal.closeControllerDebugModal(),
|
||||||
|
suppressesSubtitles: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'subtitle-sidebar',
|
||||||
|
isOpen: () => ctx.state.subtitleSidebarModalOpen,
|
||||||
|
close: () => subtitleSidebarModal.closeSubtitleSidebarModal(),
|
||||||
|
suppressesSubtitles: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'jimaku',
|
||||||
|
isOpen: () => ctx.state.jimakuModalOpen,
|
||||||
|
close: () => jimakuModal.closeJimakuModal(),
|
||||||
|
suppressesSubtitles: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'animetosho',
|
||||||
|
isOpen: () => ctx.state.animetoshoModalOpen,
|
||||||
|
close: () => animetoshoModal.closeAnimetoshoModal(),
|
||||||
|
suppressesSubtitles: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'youtube-track-picker',
|
||||||
|
isOpen: () => ctx.state.youtubePickerModalOpen,
|
||||||
|
close: () => youtubePickerModal.closeYoutubePickerModal(),
|
||||||
|
suppressesSubtitles: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'playlist-browser',
|
||||||
|
isOpen: () => ctx.state.playlistBrowserModalOpen,
|
||||||
|
close: () => playlistBrowserModal.closePlaylistBrowserModal(),
|
||||||
|
suppressesSubtitles: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'kiku',
|
||||||
|
isOpen: () => ctx.state.kikuModalOpen,
|
||||||
|
close: () => kikuModal.cancelKikuFieldGrouping(),
|
||||||
|
suppressesSubtitles: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'runtime-options',
|
||||||
|
isOpen: () => ctx.state.runtimeOptionsModalOpen,
|
||||||
|
close: () => runtimeOptionsModal.closeRuntimeOptionsModal(),
|
||||||
|
suppressesSubtitles: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'character-dictionary',
|
||||||
|
isOpen: () => ctx.state.characterDictionaryModalOpen,
|
||||||
|
close: () => characterDictionaryModal.closeCharacterDictionaryModal(),
|
||||||
|
suppressesSubtitles: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'subsync',
|
||||||
|
isOpen: () => ctx.state.subsyncModalOpen,
|
||||||
|
close: () => subsyncModal.closeSubsyncModal(),
|
||||||
|
suppressesSubtitles: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'session-help',
|
||||||
|
isOpen: () => ctx.state.sessionHelpModalOpen,
|
||||||
|
close: () => sessionHelpModal.closeSessionHelpModal(),
|
||||||
|
suppressesSubtitles: true,
|
||||||
|
},
|
||||||
|
] satisfies readonly ModalDescriptor<OverlayHostedModal>[];
|
||||||
|
|
||||||
|
const modalRegistry = createModalRegistry(modalDescriptors);
|
||||||
|
|
||||||
function isAnySettingsModalOpen(): boolean {
|
function isAnySettingsModalOpen(): boolean {
|
||||||
return (
|
return modalRegistry.isAnySuppressingSubtitlesOpen();
|
||||||
ctx.state.controllerSelectModalOpen ||
|
|
||||||
ctx.state.controllerDebugModalOpen ||
|
|
||||||
ctx.state.runtimeOptionsModalOpen ||
|
|
||||||
ctx.state.characterDictionaryModalOpen ||
|
|
||||||
ctx.state.subsyncModalOpen ||
|
|
||||||
ctx.state.kikuModalOpen ||
|
|
||||||
ctx.state.jimakuModalOpen ||
|
|
||||||
ctx.state.youtubePickerModalOpen ||
|
|
||||||
ctx.state.sessionHelpModalOpen ||
|
|
||||||
ctx.state.playlistBrowserModalOpen
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAnyModalOpen(): boolean {
|
function isAnyModalOpen(): boolean {
|
||||||
return (
|
return modalRegistry.isAnyOpen();
|
||||||
ctx.state.controllerSelectModalOpen ||
|
|
||||||
ctx.state.controllerDebugModalOpen ||
|
|
||||||
ctx.state.jimakuModalOpen ||
|
|
||||||
ctx.state.animetoshoModalOpen ||
|
|
||||||
ctx.state.kikuModalOpen ||
|
|
||||||
ctx.state.runtimeOptionsModalOpen ||
|
|
||||||
ctx.state.characterDictionaryModalOpen ||
|
|
||||||
ctx.state.subsyncModalOpen ||
|
|
||||||
ctx.state.youtubePickerModalOpen ||
|
|
||||||
ctx.state.sessionHelpModalOpen ||
|
|
||||||
ctx.state.playlistBrowserModalOpen ||
|
|
||||||
ctx.state.subtitleSidebarModalOpen
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isControllerInputBlocked(): boolean {
|
function isControllerInputBlocked(): boolean {
|
||||||
@@ -251,59 +306,12 @@ function getSubtitleTextForPreview(data: SubtitleData | string): string {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getActiveModal(): string | null {
|
function getActiveModal(): OverlayHostedModal | null {
|
||||||
if (ctx.state.controllerSelectModalOpen) return 'controller-select';
|
return modalRegistry.getActive();
|
||||||
if (ctx.state.controllerDebugModalOpen) return 'controller-debug';
|
|
||||||
if (ctx.state.subtitleSidebarModalOpen) return 'subtitle-sidebar';
|
|
||||||
if (ctx.state.jimakuModalOpen) return 'jimaku';
|
|
||||||
if (ctx.state.youtubePickerModalOpen) return 'youtube-track-picker';
|
|
||||||
if (ctx.state.playlistBrowserModalOpen) return 'playlist-browser';
|
|
||||||
if (ctx.state.kikuModalOpen) return 'kiku';
|
|
||||||
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
|
|
||||||
if (ctx.state.characterDictionaryModalOpen) return 'character-dictionary';
|
|
||||||
if (ctx.state.subsyncModalOpen) return 'subsync';
|
|
||||||
if (ctx.state.sessionHelpModalOpen) return 'session-help';
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function dismissActiveUiAfterError(): void {
|
function dismissActiveUiAfterError(): void {
|
||||||
if (ctx.state.controllerSelectModalOpen) {
|
modalRegistry.dismissOpen();
|
||||||
controllerSelectModal.closeControllerSelectModal();
|
|
||||||
}
|
|
||||||
if (ctx.state.controllerDebugModalOpen) {
|
|
||||||
controllerDebugModal.closeControllerDebugModal();
|
|
||||||
}
|
|
||||||
if (ctx.state.subtitleSidebarModalOpen) {
|
|
||||||
subtitleSidebarModal.closeSubtitleSidebarModal();
|
|
||||||
}
|
|
||||||
if (ctx.state.jimakuModalOpen) {
|
|
||||||
jimakuModal.closeJimakuModal();
|
|
||||||
}
|
|
||||||
if (ctx.state.animetoshoModalOpen) {
|
|
||||||
animetoshoModal.closeAnimetoshoModal();
|
|
||||||
}
|
|
||||||
if (ctx.state.youtubePickerModalOpen) {
|
|
||||||
youtubePickerModal.closeYoutubePickerModal();
|
|
||||||
}
|
|
||||||
if (ctx.state.playlistBrowserModalOpen) {
|
|
||||||
playlistBrowserModal.closePlaylistBrowserModal();
|
|
||||||
}
|
|
||||||
if (ctx.state.runtimeOptionsModalOpen) {
|
|
||||||
runtimeOptionsModal.closeRuntimeOptionsModal();
|
|
||||||
}
|
|
||||||
if (ctx.state.characterDictionaryModalOpen) {
|
|
||||||
characterDictionaryModal.closeCharacterDictionaryModal();
|
|
||||||
}
|
|
||||||
if (ctx.state.subsyncModalOpen) {
|
|
||||||
subsyncModal.closeSubsyncModal();
|
|
||||||
}
|
|
||||||
if (ctx.state.kikuModalOpen) {
|
|
||||||
kikuModal.cancelKikuFieldGrouping();
|
|
||||||
}
|
|
||||||
if (ctx.state.sessionHelpModalOpen) {
|
|
||||||
sessionHelpModal.closeSessionHelpModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
syncSettingsModalSubtitleSuppression();
|
syncSettingsModalSubtitleSuppression();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,28 +3,52 @@ import test from 'node:test';
|
|||||||
|
|
||||||
import { epochMsFromDbTimestamp, formatRelativeDate, formatSessionDayLabel } from './formatters';
|
import { epochMsFromDbTimestamp, formatRelativeDate, formatSessionDayLabel } from './formatters';
|
||||||
|
|
||||||
|
const FIXED_NOW = new Date(2026, 2, 16, 12, 0, 0).getTime();
|
||||||
|
|
||||||
|
function withFixedNow(run: (now: number) => void, now = FIXED_NOW): void {
|
||||||
|
const realNow = Date.now;
|
||||||
|
Date.now = () => now;
|
||||||
|
try {
|
||||||
|
run(now);
|
||||||
|
} finally {
|
||||||
|
Date.now = realNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test('formatRelativeDate: future timestamps return "just now"', () => {
|
test('formatRelativeDate: future timestamps return "just now"', () => {
|
||||||
assert.equal(formatRelativeDate(Date.now() + 60_000), 'just now');
|
withFixedNow((now) => {
|
||||||
|
assert.equal(formatRelativeDate(now + 60_000), 'just now');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('formatRelativeDate: 0ms ago returns "just now"', () => {
|
test('formatRelativeDate: 0ms ago returns "just now"', () => {
|
||||||
assert.equal(formatRelativeDate(Date.now()), 'just now');
|
withFixedNow((now) => {
|
||||||
|
assert.equal(formatRelativeDate(now), 'just now');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('formatRelativeDate: 30s ago returns "just now"', () => {
|
test('formatRelativeDate: 30s ago returns "just now"', () => {
|
||||||
assert.equal(formatRelativeDate(Date.now() - 30_000), 'just now');
|
withFixedNow((now) => {
|
||||||
|
assert.equal(formatRelativeDate(now - 30_000), 'just now');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('formatRelativeDate: 5 minutes ago returns "5m ago"', () => {
|
test('formatRelativeDate: 5 minutes ago returns "5m ago"', () => {
|
||||||
assert.equal(formatRelativeDate(Date.now() - 5 * 60_000), '5m ago');
|
withFixedNow((now) => {
|
||||||
|
assert.equal(formatRelativeDate(now - 5 * 60_000), '5m ago');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('formatRelativeDate: 59 minutes ago returns "59m ago"', () => {
|
test('formatRelativeDate: 59 minutes ago returns "59m ago"', () => {
|
||||||
assert.equal(formatRelativeDate(Date.now() - 59 * 60_000), '59m ago');
|
withFixedNow((now) => {
|
||||||
|
assert.equal(formatRelativeDate(now - 59 * 60_000), '59m ago');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('formatRelativeDate: 2 hours ago returns "2h ago"', () => {
|
test('formatRelativeDate: 2 hours ago returns "2h ago"', () => {
|
||||||
assert.equal(formatRelativeDate(Date.now() - 2 * 3_600_000), '2h ago');
|
withFixedNow((now) => {
|
||||||
|
assert.equal(formatRelativeDate(now - 2 * 3_600_000), '2h ago');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('formatRelativeDate: same calendar day can return "23h ago"', () => {
|
test('formatRelativeDate: same calendar day can return "23h ago"', () => {
|
||||||
@@ -52,12 +76,16 @@ test('formatRelativeDate: two calendar days ago returns "2d ago"', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('formatRelativeDate: 5 days ago returns "5d ago"', () => {
|
test('formatRelativeDate: 5 days ago returns "5d ago"', () => {
|
||||||
assert.equal(formatRelativeDate(Date.now() - 5 * 86_400_000), '5d ago');
|
withFixedNow((now) => {
|
||||||
|
assert.equal(formatRelativeDate(now - 5 * 86_400_000), '5d ago');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('formatRelativeDate: 10 days ago returns locale date string', () => {
|
test('formatRelativeDate: 10 days ago returns locale date string', () => {
|
||||||
const ts = Date.now() - 10 * 86_400_000;
|
withFixedNow((now) => {
|
||||||
assert.equal(formatRelativeDate(ts), new Date(ts).toLocaleDateString());
|
const ts = now - 10 * 86_400_000;
|
||||||
|
assert.equal(formatRelativeDate(ts), new Date(ts).toLocaleDateString());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('formatRelativeDate: prior calendar day under 24h returns "Yesterday"', () => {
|
test('formatRelativeDate: prior calendar day under 24h returns "Yesterday"', () => {
|
||||||
@@ -81,21 +109,29 @@ test('epochMsFromDbTimestamp keeps ms timestamps as-is', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('formatSessionDayLabel formats today and yesterday', () => {
|
test('formatSessionDayLabel formats today and yesterday', () => {
|
||||||
const now = Date.now();
|
withFixedNow((now) => {
|
||||||
const oneDayMs = 24 * 60 * 60_000;
|
const oneDayMs = 24 * 60 * 60_000;
|
||||||
assert.equal(formatSessionDayLabel(now), 'Today');
|
assert.equal(formatSessionDayLabel(now), 'Today');
|
||||||
assert.equal(formatSessionDayLabel(now - oneDayMs), 'Yesterday');
|
assert.equal(formatSessionDayLabel(now - oneDayMs), 'Yesterday');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('formatSessionDayLabel includes year for past-year dates', () => {
|
test('formatSessionDayLabel includes year for past-year dates', () => {
|
||||||
const now = new Date();
|
const fixedNow = new Date(2027, 2, 16, 12, 0, 0).getTime();
|
||||||
const sameDayLastYear = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()).getTime();
|
withFixedNow((now) => {
|
||||||
const label = formatSessionDayLabel(sameDayLastYear);
|
const current = new Date(now);
|
||||||
const year = new Date(sameDayLastYear).getFullYear();
|
const sameDayLastYear = new Date(
|
||||||
assert.ok(label.includes(String(year)));
|
current.getFullYear() - 1,
|
||||||
const withoutYear = new Date(sameDayLastYear).toLocaleDateString(undefined, {
|
current.getMonth(),
|
||||||
month: 'long',
|
current.getDate(),
|
||||||
day: 'numeric',
|
).getTime();
|
||||||
});
|
const label = formatSessionDayLabel(sameDayLastYear);
|
||||||
assert.notEqual(label, withoutYear);
|
const year = new Date(sameDayLastYear).getFullYear();
|
||||||
|
assert.ok(label.includes(String(year)));
|
||||||
|
const withoutYear = new Date(sameDayLastYear).toLocaleDateString(undefined, {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
assert.notEqual(label, withoutYear);
|
||||||
|
}, fixedNow);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export function formatSessionDayLabel(sessionStartedAtMs: number): string {
|
|||||||
if (day === today - 1) return 'Yesterday';
|
if (day === today - 1) return 'Yesterday';
|
||||||
|
|
||||||
const date = new Date(sessionStartedAtMs);
|
const date = new Date(sessionStartedAtMs);
|
||||||
const includeYear = date.getFullYear() !== new Date().getFullYear();
|
const includeYear = date.getFullYear() !== new Date(Date.now()).getFullYear();
|
||||||
return date.toLocaleDateString(undefined, {
|
return date.toLocaleDateString(undefined, {
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
|||||||
Reference in New Issue
Block a user