Windows update (#49)

This commit is contained in:
2026-04-11 21:45:52 -07:00
committed by GitHub
parent 49e46e6b9b
commit 52bab1d611
168 changed files with 9732 additions and 1422 deletions
+215
View File
@@ -0,0 +1,215 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ElectronAPI, RuntimeOptionState } from '../../types';
import { createRendererState } from '../state.js';
import { createRuntimeOptionsModal } from './runtime-options.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) tokens.add(entry);
},
remove: (...entries: string[]) => {
for (const entry of entries) tokens.delete(entry);
},
toggle: (entry: string, force?: boolean) => {
if (force === undefined) {
if (tokens.has(entry)) {
tokens.delete(entry);
return false;
}
tokens.add(entry);
return true;
}
if (force) tokens.add(entry);
else tokens.delete(entry);
return force;
},
contains: (entry: string) => tokens.has(entry),
};
}
function createElementStub() {
return {
className: '',
textContent: '',
title: '',
classList: createClassList(),
appendChild: () => {},
addEventListener: () => {},
};
}
function createRuntimeOptionsListStub() {
return {
innerHTML: '',
appendChild: () => {},
querySelector: () => null,
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return { promise, resolve, reject };
}
function flushAsyncWork(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
function withRuntimeOptionsModal(
getRuntimeOptions: () => Promise<RuntimeOptionState[]>,
run: (input: {
modal: ReturnType<typeof createRuntimeOptionsModal>;
state: ReturnType<typeof createRendererState>;
overlayClassList: ReturnType<typeof createClassList>;
modalClassList: ReturnType<typeof createClassList>;
statusNode: {
textContent: string;
classList: ReturnType<typeof createClassList>;
};
syncCalls: string[];
}) => Promise<void> | void,
): Promise<void> {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const statusNode = {
textContent: '',
classList: createClassList(),
};
const overlayClassList = createClassList();
const modalClassList = createClassList(['hidden']);
const syncCalls: string[] = [];
const state = createRendererState();
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
value: {
electronAPI: {
getRuntimeOptions,
setRuntimeOptionValue: async () => ({ ok: true }),
notifyOverlayModalClosed: () => {},
} satisfies Pick<
ElectronAPI,
'getRuntimeOptions' | 'setRuntimeOptionValue' | 'notifyOverlayModalClosed'
>,
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
writable: true,
value: {
createElement: () => createElementStub(),
},
});
const modal = createRuntimeOptionsModal(
{
dom: {
overlay: { classList: overlayClassList },
runtimeOptionsModal: {
classList: modalClassList,
setAttribute: () => {},
},
runtimeOptionsClose: {
addEventListener: () => {},
},
runtimeOptionsList: createRuntimeOptionsListStub(),
runtimeOptionsStatus: statusNode,
},
state,
} as never,
{
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {
syncCalls.push('sync');
},
},
);
return Promise.resolve()
.then(() =>
run({
modal,
state,
overlayClassList,
modalClassList,
statusNode,
syncCalls,
}),
)
.finally(() => {
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
value: previousWindow,
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
writable: true,
value: previousDocument,
});
});
}
test('openRuntimeOptionsModal shows loading shell before runtime options resolve', async () => {
const deferred = createDeferred<RuntimeOptionState[]>();
await withRuntimeOptionsModal(() => deferred.promise, async (input) => {
input.modal.openRuntimeOptionsModal();
assert.equal(input.state.runtimeOptionsModalOpen, true);
assert.equal(input.overlayClassList.contains('interactive'), true);
assert.equal(input.modalClassList.contains('hidden'), false);
assert.equal(input.statusNode.textContent, 'Loading runtime options...');
assert.deepEqual(input.syncCalls, ['sync']);
deferred.resolve([
{
id: 'anki.autoUpdateNewCards',
label: 'Auto-update new cards',
scope: 'ankiConnect',
valueType: 'boolean',
value: true,
allowedValues: [true, false],
requiresRestart: false,
},
]);
await flushAsyncWork();
assert.equal(
input.statusNode.textContent,
'Use arrow keys. Click value to cycle. Enter or double-click to apply.',
);
assert.equal(input.statusNode.classList.contains('error'), false);
});
});
test('openRuntimeOptionsModal keeps modal visible when loading fails', async () => {
const deferred = createDeferred<RuntimeOptionState[]>();
await withRuntimeOptionsModal(() => deferred.promise, async (input) => {
input.modal.openRuntimeOptionsModal();
deferred.reject(new Error('boom'));
await flushAsyncWork();
assert.equal(input.state.runtimeOptionsModalOpen, true);
assert.equal(input.overlayClassList.contains('interactive'), true);
assert.equal(input.modalClassList.contains('hidden'), false);
assert.equal(input.statusNode.textContent, 'Failed to load runtime options');
assert.equal(input.statusNode.classList.contains('error'), true);
});
});
+20 -4
View File
@@ -22,6 +22,9 @@ export function createRuntimeOptionsModal(
syncSettingsModalSubtitleSuppression: () => void;
},
) {
const DEFAULT_STATUS_MESSAGE =
'Use arrow keys. Click value to cycle. Enter or double-click to apply.';
function formatRuntimeOptionValue(value: RuntimeOptionValue): string {
if (typeof value === 'boolean') {
return value ? 'On' : 'Off';
@@ -177,10 +180,13 @@ export function createRuntimeOptionsModal(
}
}
async function openRuntimeOptionsModal(): Promise<void> {
async function refreshRuntimeOptions(): Promise<void> {
const optionsList = await window.electronAPI.getRuntimeOptions();
updateRuntimeOptions(optionsList);
setRuntimeOptionsStatus(DEFAULT_STATUS_MESSAGE);
}
function showRuntimeOptionsModalShell(): void {
ctx.state.runtimeOptionsModalOpen = true;
options.syncSettingsModalSubtitleSuppression();
@@ -188,9 +194,19 @@ export function createRuntimeOptionsModal(
ctx.dom.runtimeOptionsModal.classList.remove('hidden');
ctx.dom.runtimeOptionsModal.setAttribute('aria-hidden', 'false');
setRuntimeOptionsStatus(
'Use arrow keys. Click value to cycle. Enter or double-click to apply.',
);
setRuntimeOptionsStatus('Loading runtime options...');
}
function openRuntimeOptionsModal(): void {
if (!ctx.state.runtimeOptionsModalOpen) {
showRuntimeOptionsModalShell();
} else {
setRuntimeOptionsStatus('Refreshing runtime options...');
}
void refreshRuntimeOptions().catch(() => {
setRuntimeOptionsStatus('Failed to load runtime options', true);
});
}
function handleRuntimeOptionsKeydown(e: KeyboardEvent): boolean {
+39 -26
View File
@@ -96,6 +96,10 @@ const OVERLAY_SHORTCUTS: Array<{
{ key: 'markAudioCard', label: 'Mark audio card' },
{ key: 'openRuntimeOptions', label: 'Open runtime options' },
{ key: 'openJimaku', label: 'Open jimaku' },
{ key: 'openSessionHelp', label: 'Open session help' },
{ key: 'openControllerSelect', label: 'Open controller select' },
{ key: 'openControllerDebug', label: 'Open controller debug' },
{ key: 'toggleSubtitleSidebar', label: 'Toggle subtitle sidebar' },
{ key: 'toggleVisibleOverlayGlobal', label: 'Show/hide visible overlay' },
];
@@ -104,11 +108,12 @@ function buildOverlayShortcutSections(shortcuts: RuntimeShortcutConfig): Session
for (const shortcut of OVERLAY_SHORTCUTS) {
const keybind = shortcuts[shortcut.key];
if (typeof keybind !== 'string') continue;
if (keybind.trim().length === 0) continue;
rows.push({
shortcut: formatKeybinding(keybind),
shortcut:
typeof keybind === 'string' && keybind.trim().length > 0
? formatKeybinding(keybind)
: 'Unbound',
action: shortcut.label,
});
}
@@ -586,11 +591,25 @@ export function createSessionHelpModal(
}
}
async function openSessionHelpModal(opening: SessionHelpBindingInfo): Promise<void> {
function openSessionHelpModal(opening: SessionHelpBindingInfo): void {
openBinding = opening;
priorFocus = document.activeElement;
const dataLoaded = await render();
ctx.state.sessionHelpModalOpen = true;
helpSections = [];
helpFilterValue = '';
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add('interactive');
ctx.dom.sessionHelpModal.classList.remove('hidden');
ctx.dom.sessionHelpModal.setAttribute('aria-hidden', 'false');
ctx.dom.sessionHelpModal.setAttribute('tabindex', '-1');
ctx.dom.sessionHelpFilter.value = '';
ctx.state.sessionHelpSelectedIndex = 0;
ctx.dom.sessionHelpContent.innerHTML = '';
ctx.dom.sessionHelpContent.classList.remove('session-help-content-no-results');
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(false);
}
ctx.dom.sessionHelpShortcut.textContent = `Session help opened with ${formatBindingHint(openBinding)}`;
if (openBinding.fallbackUnavailable) {
@@ -601,27 +620,7 @@ export function createSessionHelpModal(
} else {
ctx.dom.sessionHelpWarning.textContent = '';
}
if (dataLoaded) {
ctx.dom.sessionHelpStatus.textContent =
'Use Arrow keys, J/K/H/L, mouse, click, or / then type to filter. Esc closes.';
} else {
ctx.dom.sessionHelpStatus.textContent =
'Session help data is unavailable right now. Press Esc to close.';
ctx.dom.sessionHelpWarning.textContent =
'Unable to load latest shortcut settings from the runtime.';
}
ctx.state.sessionHelpModalOpen = true;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add('interactive');
ctx.dom.sessionHelpModal.classList.remove('hidden');
ctx.dom.sessionHelpModal.setAttribute('aria-hidden', 'false');
ctx.dom.sessionHelpModal.setAttribute('tabindex', '-1');
ctx.dom.sessionHelpFilter.value = '';
helpFilterValue = '';
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(false);
}
ctx.dom.sessionHelpStatus.textContent = 'Loading session help data...';
if (focusGuard === null) {
focusGuard = (event: FocusEvent) => {
@@ -639,6 +638,19 @@ export function createSessionHelpModal(
requestOverlayFocus();
window.focus();
enforceModalFocus();
void render().then((dataLoaded) => {
if (!ctx.state.sessionHelpModalOpen) return;
if (dataLoaded) {
ctx.dom.sessionHelpStatus.textContent =
'Use Arrow keys, J/K/H/L, mouse, click, or / then type to filter. Esc closes.';
} else {
ctx.dom.sessionHelpStatus.textContent =
'Session help data is unavailable right now. Press Esc to close.';
ctx.dom.sessionHelpWarning.textContent =
'Unable to load latest shortcut settings from the runtime.';
}
});
}
function closeSessionHelpModal(): void {
@@ -648,6 +660,7 @@ export function createSessionHelpModal(
options.syncSettingsModalSubtitleSuppression();
ctx.dom.sessionHelpModal.classList.add('hidden');
ctx.dom.sessionHelpModal.setAttribute('aria-hidden', 'true');
window.electronAPI.notifyOverlayModalClosed('session-help');
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
}