diff --git a/changes/renderer-modal-registry.md b/changes/renderer-modal-registry.md new file mode 100644 index 00000000..b3b4777c --- /dev/null +++ b/changes/renderer-modal-registry.md @@ -0,0 +1,4 @@ +type: internal +area: overlay + +- Consolidated renderer modal state handling into a descriptor registry. diff --git a/src/renderer/modal-registry.test.ts b/src/renderer/modal-registry.test.ts new file mode 100644 index 00000000..022cc270 --- /dev/null +++ b/src/renderer/modal-registry.test.ts @@ -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[] = [ + { + 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[] = ['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']); +}); diff --git a/src/renderer/modal-registry.ts b/src/renderer/modal-registry.ts new file mode 100644 index 00000000..c29a6e8a --- /dev/null +++ b/src/renderer/modal-registry.ts @@ -0,0 +1,24 @@ +export type ModalDescriptor = { + id: TId; + isOpen: () => boolean; + close: () => void; + suppressesSubtitles: boolean; +}; + +export function createModalRegistry( + descriptors: readonly ModalDescriptor[], +) { + 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(); + } + } + }, + }; +} diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 18305a98..756854a4 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -25,6 +25,7 @@ import type { SubsyncManualPayload, ConfigHotReloadPayload, } from '../types'; +import type { OverlayHostedModal } from '../shared/ipc/contracts'; import { createKeyboardHandlers } from './handlers/keyboard.js'; import { createGamepadController } from './handlers/gamepad-controller.js'; import { createMouseHandlers } from './handlers/mouse.js'; @@ -53,6 +54,7 @@ import { } from './overlay-notifications.js'; import { createOverlayNotificationHistoryPanel } from './overlay-notification-history.js'; import { createRendererState } from './state.js'; +import { createModalRegistry, type ModalDescriptor } from './modal-registry.js'; import { createSubtitleRenderer } from './subtitle-render.js'; import { isYomitanPopupVisible, registerYomitanLookupListener } from './yomitan-popup.js'; import { @@ -74,36 +76,89 @@ const ctx = { 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[]; + +const modalRegistry = createModalRegistry(modalDescriptors); + function isAnySettingsModalOpen(): boolean { - return ( - 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 - ); + return modalRegistry.isAnySuppressingSubtitlesOpen(); } function isAnyModalOpen(): boolean { - return ( - 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 - ); + return modalRegistry.isAnyOpen(); } function isControllerInputBlocked(): boolean { @@ -251,59 +306,12 @@ function getSubtitleTextForPreview(data: SubtitleData | string): string { return ''; } -function getActiveModal(): string | null { - if (ctx.state.controllerSelectModalOpen) return 'controller-select'; - 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 getActiveModal(): OverlayHostedModal | null { + return modalRegistry.getActive(); } function dismissActiveUiAfterError(): void { - if (ctx.state.controllerSelectModalOpen) { - 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(); - } - + modalRegistry.dismissOpen(); syncSettingsModalSubtitleSuppression(); } diff --git a/stats/src/lib/formatters.test.ts b/stats/src/lib/formatters.test.ts index f7759172..ca960aed 100644 --- a/stats/src/lib/formatters.test.ts +++ b/stats/src/lib/formatters.test.ts @@ -3,28 +3,52 @@ import test from 'node:test'; 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"', () => { - 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"', () => { - assert.equal(formatRelativeDate(Date.now()), 'just now'); + withFixedNow((now) => { + assert.equal(formatRelativeDate(now), '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"', () => { - 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"', () => { - 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"', () => { - 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"', () => { @@ -52,12 +76,16 @@ test('formatRelativeDate: two calendar days ago returns "2d 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', () => { - const ts = Date.now() - 10 * 86_400_000; - assert.equal(formatRelativeDate(ts), new Date(ts).toLocaleDateString()); + withFixedNow((now) => { + const ts = now - 10 * 86_400_000; + assert.equal(formatRelativeDate(ts), new Date(ts).toLocaleDateString()); + }); }); 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', () => { - const now = Date.now(); - const oneDayMs = 24 * 60 * 60_000; - assert.equal(formatSessionDayLabel(now), 'Today'); - assert.equal(formatSessionDayLabel(now - oneDayMs), 'Yesterday'); + withFixedNow((now) => { + const oneDayMs = 24 * 60 * 60_000; + assert.equal(formatSessionDayLabel(now), 'Today'); + assert.equal(formatSessionDayLabel(now - oneDayMs), 'Yesterday'); + }); }); test('formatSessionDayLabel includes year for past-year dates', () => { - const now = new Date(); - const sameDayLastYear = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()).getTime(); - const label = formatSessionDayLabel(sameDayLastYear); - 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); + const fixedNow = new Date(2027, 2, 16, 12, 0, 0).getTime(); + withFixedNow((now) => { + const current = new Date(now); + const sameDayLastYear = new Date( + current.getFullYear() - 1, + current.getMonth(), + current.getDate(), + ).getTime(); + const label = formatSessionDayLabel(sameDayLastYear); + 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); }); diff --git a/stats/src/lib/formatters.ts b/stats/src/lib/formatters.ts index d6b6b5e9..7923e747 100644 --- a/stats/src/lib/formatters.ts +++ b/stats/src/lib/formatters.ts @@ -66,7 +66,7 @@ export function formatSessionDayLabel(sessionStartedAtMs: number): string { if (day === today - 1) return 'Yesterday'; 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, { month: 'long', day: 'numeric',