import type { ModalStateReader, RendererContext } from '../context'; import type { SessionHelpOpenPayload } from '../../types/runtime'; import type { RuntimeOptionId, RuntimeOptionState } from '../../types/runtime-options'; import { buildSessionHelpSections, type SessionHelpItem, type SessionHelpSection, type SessionHelpTabId, } from './session-help-sections'; import { createSessionHelpSectionNode } from './session-help-render'; import { buildVisibleSessionHelpSections, createSessionHelpTabBar } from './session-help-tabs'; import { createModalFocusGuard } from './modal-focus-guard'; import { dispatchConfiguredMpvCommand } from '../utils/mpv-command-dispatch'; export { buildSessionHelpSections, describeSessionHelpCommand, formatSessionHelpKeybinding, } from './session-help-sections'; type SessionHelpBindingInfo = { bindingKey: 'KeyH' | 'KeyK'; fallbackUsed: boolean; fallbackUnavailable: boolean; }; /** * Tiers only render when known-word highlighting is also on, matching * getKnownWordMaturityEnabled in anki-integration/known-word-maturity. */ export function isKnownWordMaturityLegendEnabled(runtimeOptions: RuntimeOptionState[]): boolean { const isOn = (id: RuntimeOptionId): boolean => runtimeOptions.some((option) => option.id === id && option.value === true); return ( isOn('subtitle.annotation.knownWords.highlightEnabled') && isOn('subtitle.annotation.knownWords.maturityEnabled') ); } /** * Maturity coloring is a live runtime toggle, so the color legend reads it from * runtime options instead of the resolved subtitle style. A missing or failing * runtime-options call falls back to the flat known-word color. */ async function readKnownWordMaturityEnabled(): Promise { try { return isKnownWordMaturityLegendEnabled(await window.electronAPI.getRuntimeOptions()); } catch { return false; } } function formatBindingHint(info: SessionHelpBindingInfo): string { if (info.bindingKey === 'KeyK' && info.fallbackUsed) { return info.fallbackUnavailable ? 'Y-K (fallback and conflict noted)' : 'Y-K (fallback)'; } return 'Y-H'; } export function createSessionHelpModal( ctx: RendererContext, options: { modalStateReader: Pick; syncSettingsModalSubtitleSuppression: () => void; }, ) { let priorFocus: Element | null = null; let openBinding: SessionHelpBindingInfo = { bindingKey: 'KeyH', fallbackUsed: false, fallbackUnavailable: false, }; let helpFilterValue = ''; let helpSections: SessionHelpSection[] = []; let activeTabId: SessionHelpTabId = 'essentials'; // Rows backed by a command run on Enter / double-click, but only while a video is playing. let commandsEnabled = false; let visibleRows: SessionHelpItem[] = []; function getItems(): HTMLButtonElement[] { return Array.from( ctx.dom.sessionHelpContent.querySelectorAll('.session-help-item'), ) as HTMLButtonElement[]; } /** Pointer selection skips scrolling and leaves filter typing focus alone. */ function setSelected(index: number, fromPointer = false): void { const items = getItems(); if (items.length === 0) return; const wrappedIndex = index % items.length; const next = wrappedIndex < 0 ? wrappedIndex + items.length : wrappedIndex; ctx.state.sessionHelpSelectedIndex = next; items.forEach((item, idx) => { item.classList.toggle('active', idx === next); item.tabIndex = idx === next ? 0 : -1; }); const activeItem = items[next]; if (!activeItem) return; if (fromPointer) { if (!isFilterInputFocused()) activeItem.focus({ preventScroll: true }); return; } activeItem.focus({ preventScroll: true }); activeItem.scrollIntoView({ block: 'nearest', inline: 'nearest', }); } const focus = createModalFocusGuard({ isOpen: () => ctx.state.sessionHelpModalOpen, getModalRoot: () => ctx.dom.sessionHelpModal, getPreferredFocusTargets: () => getItems(), getFallbackFocusTarget: () => ctx.dom.sessionHelpClose, isModalLayer: ctx.platform.isModalLayer, }); function isFilterInputFocused(): boolean { return document.activeElement === ctx.dom.sessionHelpFilter; } function focusFilterInput(): void { ctx.dom.sessionHelpFilter.focus({ preventScroll: true }); ctx.dom.sessionHelpFilter.select(); } function applyFilterAndRender(): void { const sections = buildVisibleSessionHelpSections(helpSections, activeTabId, helpFilterValue); visibleRows = sections.flatMap((section) => section.rows); const indexOffsets: number[] = []; let running = 0; for (const section of sections) { indexOffsets.push(running); running += section.rows.length; } ctx.dom.sessionHelpContent.innerHTML = ''; if (!helpFilterValue.trim()) { ctx.dom.sessionHelpContent.appendChild( createSessionHelpTabBar(helpSections, activeTabId, (tabId) => { activeTabId = tabId; applyFilterAndRender(); }), ); } sections.forEach((section, sectionIndex) => { const sectionNode = createSessionHelpSectionNode( section, sectionIndex, indexOffsets, commandsEnabled, ); ctx.dom.sessionHelpContent.appendChild(sectionNode); }); if (getItems().length === 0) { ctx.dom.sessionHelpContent.classList.add('session-help-content-no-results'); ctx.dom.sessionHelpContent.textContent = helpFilterValue ? 'No matching shortcuts found.' : 'No active shortcuts in this tab.'; ctx.state.sessionHelpSelectedIndex = 0; return; } ctx.dom.sessionHelpContent.classList.remove('session-help-content-no-results'); if (isFilterInputFocused()) return; setSelected(0); } function showRenderError(message: string): void { helpSections = []; visibleRows = []; helpFilterValue = ''; activeTabId = 'essentials'; ctx.dom.sessionHelpFilter.value = ''; ctx.dom.sessionHelpContent.classList.add('session-help-content-no-results'); ctx.dom.sessionHelpContent.textContent = message; ctx.state.sessionHelpSelectedIndex = 0; } async function render(): Promise { try { const [ sessionBindings, styleConfig, markWatchedKey, subtitleSidebarToggleKey, knownWordMaturityEnabled, ] = await Promise.all([ window.electronAPI.getSessionBindings(), window.electronAPI.getSubtitleStyle(), window.electronAPI.getMarkWatchedKey(), window.electronAPI .getSubtitleSidebarSnapshot() .then((snapshot) => snapshot.config.toggleKey) .catch(() => undefined), readKnownWordMaturityEnabled(), ]); helpSections = buildSessionHelpSections({ sessionBindings, markWatchedKey, subtitleSidebarToggleKey, subtitleStyle: styleConfig ?? {}, knownWordMaturityEnabled, }); applyFilterAndRender(); return true; } catch (error) { const message = error instanceof Error ? error.message : 'Unable to load session help data.'; showRenderError(`Session help failed to load: ${message}`); return false; } } function openSessionHelpModal( opening: SessionHelpBindingInfo, payload: SessionHelpOpenPayload = { commandsEnabled: false }, ): void { openBinding = opening; commandsEnabled = payload.commandsEnabled; priorFocus = document.activeElement; ctx.state.sessionHelpModalOpen = true; helpSections = []; visibleRows = []; 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) { ctx.dom.sessionHelpWarning.textContent = 'Both Y-H and Y-K are bound; Y-K remains the fallback for this session.'; } else if (openBinding.fallbackUsed) { ctx.dom.sessionHelpWarning.textContent = 'Y-H is already bound; using Y-K as fallback.'; } else { ctx.dom.sessionHelpWarning.textContent = ''; } ctx.dom.sessionHelpStatus.textContent = 'Loading session help data...'; focus.attach(); focus.requestOverlayFocus(); window.focus(); focus.enforceModalFocus(); void render().then((dataLoaded) => { if (!ctx.state.sessionHelpModalOpen) return; if (dataLoaded) { ctx.dom.sessionHelpStatus.textContent = commandsEnabled ? 'Use Arrow keys, J/K/H/L, mouse, click, or / then type to filter. Enter or double-click runs a command. Esc closes.' : '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 { if (!ctx.state.sessionHelpModalOpen) return; ctx.state.sessionHelpModalOpen = false; 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'); } focus.detach(); if (priorFocus instanceof HTMLElement && priorFocus.isConnected) { priorFocus.focus({ preventScroll: true }); return; } if (ctx.dom.overlay instanceof HTMLElement) { // Overlay remains `tabindex="-1"` to allow programmatic focus for fallback. ctx.dom.overlay.focus({ preventScroll: true }); } if (ctx.platform.shouldToggleMouseIgnore) { if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { window.electronAPI.setIgnoreMouseEvents(true, { forward: true }); } else { window.electronAPI.setIgnoreMouseEvents(false); } } ctx.dom.sessionHelpFilter.value = ''; helpFilterValue = ''; window.focus(); } /** * Closes help first so a command that opens another modal (or needs mpv focus) * lands after help has released the overlay. */ function runSelectedCommand(): void { if (!commandsEnabled) return; const command = visibleRows[ctx.state.sessionHelpSelectedIndex]?.command; if (!command) return; closeSessionHelpModal(); if (command.actionType === 'mpv-command') { dispatchConfiguredMpvCommand(command.command, { getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(), sendMpvCommand: (mpvCommand) => window.electronAPI.sendMpvCommand(mpvCommand), }); return; } // Help is already closed, so surface failures on the mpv OSD. void window.electronAPI .dispatchSessionAction(command.actionId, command.payload) .catch((error: unknown) => { console.error(`Session help could not run ${command.actionId}`, error); window.electronAPI.sendMpvCommand(['show-text', 'Command failed to run', '3000']); }); } function getRowIndex(target: EventTarget | null): number | null { if (!(target instanceof Element)) return null; const row = target.closest('.session-help-item') as HTMLElement | null; if (!row) return null; const index = Number.parseInt(row.dataset.sessionHelpIndex ?? '', 10); return Number.isFinite(index) ? index : null; } function handleSessionHelpKeydown(e: KeyboardEvent): boolean { if (!ctx.state.sessionHelpModalOpen) return false; // The filter's own Enter listener moves focus to the list before this bubbled // handler runs, so check the event target too or filter Enter would run a command. if (isFilterInputFocused() || e.target === ctx.dom.sessionHelpFilter) { if (e.key === 'Escape') { e.preventDefault(); if (!helpFilterValue) { closeSessionHelpModal(); return true; } helpFilterValue = ''; ctx.dom.sessionHelpFilter.value = ''; applyFilterAndRender(); focus.focusFallbackTarget(); return true; } return false; } if (e.key === 'Escape') { e.preventDefault(); closeSessionHelpModal(); return true; } const items = getItems(); if (items.length === 0) return true; if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { e.preventDefault(); focusFilterInput(); return true; } const key = e.key.toLowerCase(); if (key === 'arrowdown' || key === 'j' || key === 'l') { e.preventDefault(); setSelected(ctx.state.sessionHelpSelectedIndex + 1); return true; } if (key === 'arrowup' || key === 'k' || key === 'h') { e.preventDefault(); setSelected(ctx.state.sessionHelpSelectedIndex - 1); return true; } // Only rows run commands; Enter on the close or tab buttons keeps its native activation. if (e.key === 'Enter' && getRowIndex(e.target) !== null) { e.preventDefault(); runSelectedCommand(); return true; } return true; } function wireDomEvents(): void { ctx.dom.sessionHelpFilter.addEventListener('input', () => { helpFilterValue = ctx.dom.sessionHelpFilter.value; applyFilterAndRender(); }); ctx.dom.sessionHelpFilter.addEventListener('keydown', (event: KeyboardEvent) => { if (event.key === 'Enter') { event.preventDefault(); focus.focusFallbackTarget(); } }); ctx.dom.sessionHelpContent.addEventListener('click', (event: MouseEvent) => { const index = getRowIndex(event.target); if (index !== null) setSelected(index); }); // Hovering a row makes it the Enter target. ctx.dom.sessionHelpContent.addEventListener('mousemove', (event: MouseEvent) => { const index = getRowIndex(event.target); if (index === null || index === ctx.state.sessionHelpSelectedIndex) return; setSelected(index, true); }); ctx.dom.sessionHelpContent.addEventListener('dblclick', (event: MouseEvent) => { const index = getRowIndex(event.target); if (index === null) return; setSelected(index); runSelectedCommand(); }); ctx.dom.sessionHelpClose.addEventListener('click', () => { closeSessionHelpModal(); }); } return { closeSessionHelpModal, handleSessionHelpKeydown, openSessionHelpModal, wireDomEvents, }; }