diff --git a/backlog/tasks/task-26 - Add-session-help-modal-with-dynamic-keybinding-color-legend-and-keyboard-mouse-navigation.md b/backlog/tasks/task-26 - Add-session-help-modal-with-dynamic-keybinding-color-legend-and-keyboard-mouse-navigation.md index 6f87117..7e64405 100644 --- a/backlog/tasks/task-26 - Add-session-help-modal-with-dynamic-keybinding-color-legend-and-keyboard-mouse-navigation.md +++ b/backlog/tasks/task-26 - Add-session-help-modal-with-dynamic-keybinding-color-legend-and-keyboard-mouse-navigation.md @@ -3,7 +3,7 @@ id: TASK-26 title: >- Add session help modal with dynamic keybinding/color legend and keyboard/mouse navigation -status: To Do +status: Done assignee: [] created_date: '2026-02-13 16:49' labels: [] @@ -19,19 +19,19 @@ Create a help modal that auto-generates its content from the project/app layout ## Acceptance Criteria -- [ ] #1 Help modal content is generated automatically from current keybinding config and project/app layout rather than hardcoded static text. -- [ ] #2 Modal displays current session keybindings and active color-key mappings in a clear, grouped layout with section separation for readability. -- [ ] #3 Modal can be opened with `y-h` when available; if `y-h` is already bound, modal opens with `y-k` instead. -- [ ] #4 Close behavior: `Escape` exits the modal and returns to previous focus/state. -- [ ] #5 Modal supports mouse-based interaction for standard focus/selection actions. -- [ ] #6 Navigation inside modal supports arrow keys for movement between focusable items. -- [ ] #7 Modal supports internal navigation semantics equivalent to hjkl directional movement for users, while UI text/labels do not mention hjkl keys. -- [ ] #8 No visible UI mention of Vim key names is shown in modal labels/help copy. -- [ ] #9 Modal opens from current session without requiring a restart and reflects updated config changes without code changes. -- [ ] #10 If the shortcut is unavailable due to conflicts, user-visible fallback behavior/error is deterministic and documented. +- [x] #1 Help modal content is generated automatically from current keybinding config and project/app layout rather than hardcoded static text. +- [x] #2 Modal displays current session keybindings and active color-key mappings in a clear, grouped layout with section separation for readability. +- [x] #3 Modal can be opened with `y-h` when available; if `y-h` is already bound, modal opens with `y-k` instead. +- [x] #4 Close behavior: `Escape` exits the modal and returns to previous focus/state. +- [x] #5 Modal supports mouse-based interaction for standard focus/selection actions. +- [x] #6 Navigation inside modal supports arrow keys for movement between focusable items. +- [x] #7 Modal supports internal navigation semantics equivalent to hjkl directional movement for users, while UI text/labels do not mention hjkl keys. +- [x] #8 No visible UI mention of Vim key names is shown in modal labels/help copy. +- [x] #9 Modal opens from current session without requiring a restart and reflects updated config changes without code changes. +- [x] #10 If the shortcut is unavailable due to conflicts, user-visible fallback behavior/error is deterministic and documented. ## Definition of Done -- [ ] #1 Auto-generated help modal displays up-to-date keybinding + color mapping data and supports both keyboard (arrow/fallback path) and mouse navigation with Escape-to-close. +- [x] #1 Auto-generated help modal displays up-to-date keybinding + color mapping data and supports both keyboard (arrow/fallback path) and mouse navigation with Escape-to-close. diff --git a/docs/configuration.md b/docs/configuration.md index ec4dac0..9149681 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -270,6 +270,28 @@ When `behavior.autoUpdateNewCards` is set to `false`, new cards are detected but These shortcuts are only active when the overlay window is visible and automatically disabled when hidden. +### Session help modal + +The session help modal is opened with `Y-H` by default (falls back to `Y-K` if needed) and shows the current session keybindings and color legend. + +You can filter the modal quickly with `/`: + +- Type any part of the action name or shortcut in the search bar. +- Search is case-insensitive and ignores spaces/punctuation (`+`, `-`, `_`, `/`) so `ctrl w`, `ctrl+w`, and `ctrl+s` all match. +- Results are filtered across active MPV shortcuts, configured overlay shortcuts, and color legend items. + +While the modal is open: + +- `Esc`: close the modal (or clear the filter when text is entered) +- `↑/↓`, `j/k`: move selection +- Mouse/trackpad: click to select and activate rows + +The list is generated at runtime from: + +- Your active mpv keybindings (`keybindings`). +- Your configured overlay shortcuts (`shortcuts`, including runtime-loaded config values). +- Current subtitle color settings from `subtitleStyle`. + ### Auto-Start Overlay Control whether the overlay automatically becomes visible when it connects to mpv: diff --git a/src/core/services/ipc-service.ts b/src/core/services/ipc-service.ts index d175efb..9aaff25 100644 --- a/src/core/services/ipc-service.ts +++ b/src/core/services/ipc-service.ts @@ -21,8 +21,10 @@ export interface IpcServiceDeps { setMecabEnabled: (enabled: boolean) => void; handleMpvCommand: (command: Array) => void; getKeybindings: () => unknown; + getConfiguredShortcuts: () => unknown; getSecondarySubMode: () => unknown; getCurrentSecondarySub: () => string; + focusMainWindow: () => void; runSubsyncManual: (request: unknown) => Promise; getAnkiConnectStatus: () => boolean; getRuntimeOptions: () => unknown; @@ -33,6 +35,7 @@ export interface IpcServiceDeps { interface WindowLike { isDestroyed: () => boolean; + focus: () => void; setIgnoreMouseEvents: ( ignore: boolean, options?: { forward?: boolean }, @@ -69,8 +72,10 @@ export interface IpcDepsRuntimeOptions { getMecabTokenizer: () => MecabTokenizerLike | null; handleMpvCommand: (command: Array) => void; getKeybindings: () => unknown; + getConfiguredShortcuts: () => unknown; getSecondarySubMode: () => unknown; getMpvClient: () => MpvClientLike | null; + focusMainWindow: () => void; runSubsyncManual: (request: unknown) => Promise; getAnkiConnectStatus: () => boolean; getRuntimeOptions: () => unknown; @@ -120,9 +125,15 @@ export function createIpcDepsRuntimeService( }, handleMpvCommand: options.handleMpvCommand, getKeybindings: options.getKeybindings, + getConfiguredShortcuts: options.getConfiguredShortcuts, getSecondarySubMode: options.getSecondarySubMode, getCurrentSecondarySub: () => options.getMpvClient()?.currentSecondarySubText || "", + focusMainWindow: () => { + const mainWindow = options.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) return; + mainWindow.focus(); + }, runSubsyncManual: options.runSubsyncManual, getAnkiConnectStatus: options.getAnkiConnectStatus, getRuntimeOptions: options.getRuntimeOptions, @@ -229,6 +240,10 @@ export function registerIpcHandlersService(deps: IpcServiceDeps): void { return deps.getKeybindings(); }); + ipcMain.handle("get-config-shortcuts", () => { + return deps.getConfiguredShortcuts(); + }); + ipcMain.handle("get-secondary-sub-mode", () => { return deps.getSecondarySubMode(); }); @@ -237,6 +252,10 @@ export function registerIpcHandlersService(deps: IpcServiceDeps): void { return deps.getCurrentSecondarySub(); }); + ipcMain.handle("focus-main-window", () => { + deps.focusMainWindow(); + }); + ipcMain.handle("subsync:run-manual", async (_event, request: unknown) => { return await deps.runSubsyncManual(request); }); diff --git a/src/main.ts b/src/main.ts index 4216f0d..367ebdb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1324,6 +1324,13 @@ registerIpcRuntimeServices({ getMainWindow: () => overlayManager.getMainWindow(), getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(), getInvisibleOverlayVisibility: () => overlayManager.getInvisibleOverlayVisible(), + focusMainWindow: () => { + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) return; + if (!mainWindow.isFocused()) { + mainWindow.focus(); + } + }, onOverlayModalClosed: (modal: string) => { handleOverlayModalClosed(modal as OverlayHostedModal); }, @@ -1353,6 +1360,7 @@ registerIpcRuntimeServices({ handleMpvCommand: (command: (string | number)[]) => handleMpvCommandFromIpc(command), getKeybindings: () => appState.keybindings, + getConfiguredShortcuts: () => getConfiguredShortcuts(), getSecondarySubMode: () => appState.secondarySubMode, getMpvClient: () => appState.mpvClient, runSubsyncManual: (request: unknown) => diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index 3a45639..38f0e56 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -73,6 +73,7 @@ export interface MainIpcRuntimeServiceDepsParams { toggleVisibleOverlay: IpcDepsRuntimeOptions["toggleVisibleOverlay"]; tokenizeCurrentSubtitle: IpcDepsRuntimeOptions["tokenizeCurrentSubtitle"]; getCurrentSubtitleAss: IpcDepsRuntimeOptions["getCurrentSubtitleAss"]; + focusMainWindow?: IpcDepsRuntimeOptions["focusMainWindow"]; getMpvSubtitleRenderMetrics: IpcDepsRuntimeOptions["getMpvSubtitleRenderMetrics"]; getSubtitlePosition: IpcDepsRuntimeOptions["getSubtitlePosition"]; getSubtitleStyle: IpcDepsRuntimeOptions["getSubtitleStyle"]; @@ -80,6 +81,7 @@ export interface MainIpcRuntimeServiceDepsParams { getMecabTokenizer: IpcDepsRuntimeOptions["getMecabTokenizer"]; handleMpvCommand: IpcDepsRuntimeOptions["handleMpvCommand"]; getKeybindings: IpcDepsRuntimeOptions["getKeybindings"]; + getConfiguredShortcuts: IpcDepsRuntimeOptions["getConfiguredShortcuts"]; getSecondarySubMode: IpcDepsRuntimeOptions["getSecondarySubMode"]; getMpvClient: IpcDepsRuntimeOptions["getMpvClient"]; runSubsyncManual: IpcDepsRuntimeOptions["runSubsyncManual"]; @@ -204,6 +206,8 @@ export function createMainIpcRuntimeServiceDeps( getMecabTokenizer: params.getMecabTokenizer, handleMpvCommand: params.handleMpvCommand, getKeybindings: params.getKeybindings, + getConfiguredShortcuts: params.getConfiguredShortcuts, + focusMainWindow: params.focusMainWindow ?? (() => {}), getSecondarySubMode: params.getSecondarySubMode, getMpvClient: params.getMpvClient, runSubsyncManual: params.runSubsyncManual, diff --git a/src/preload.ts b/src/preload.ts index 2e3b7aa..df6295e 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -46,6 +46,7 @@ import type { RuntimeOptionValue, MpvSubtitleRenderMetrics, OverlayContentMeasurement, + ShortcutsConfig, } from "./types"; const overlayLayerArg = process.argv.find((arg) => @@ -145,6 +146,8 @@ const electronAPI: ElectronAPI = { getKeybindings: (): Promise => ipcRenderer.invoke("get-keybindings"), + getConfiguredShortcuts: (): Promise> => + ipcRenderer.invoke("get-config-shortcuts"), getJimakuMediaInfo: (): Promise => ipcRenderer.invoke("jimaku:get-media-info"), @@ -200,6 +203,8 @@ const electronAPI: ElectronAPI = { ipcRenderer.invoke("get-secondary-sub-mode"), getCurrentSecondarySub: (): Promise => ipcRenderer.invoke("get-current-secondary-sub"), + focusMainWindow: () => + ipcRenderer.invoke("focus-main-window") as Promise, getSubtitleStyle: (): Promise => ipcRenderer.invoke("get-subtitle-style"), onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => { diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index a9b6dc8..f2f2aee 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -8,6 +8,12 @@ export function createKeyboardHandlers( handleSubsyncKeydown: (e: KeyboardEvent) => boolean; handleKikuKeydown: (e: KeyboardEvent) => boolean; handleJimakuKeydown: (e: KeyboardEvent) => boolean; + handleSessionHelpKeydown: (e: KeyboardEvent) => boolean; + openSessionHelpModal: (opening: { + bindingKey: "KeyH" | "KeyK"; + fallbackUsed: boolean; + fallbackUnavailable: boolean; + }) => void; saveInvisiblePositionEdit: () => void; cancelInvisiblePositionEdit: () => void; setInvisiblePositionEditMode: (enabled: boolean) => void; @@ -62,6 +68,47 @@ export function createKeyboardHandlers( ); } + function resolveSessionHelpChordBinding(): { + bindingKey: "KeyH" | "KeyK"; + fallbackUsed: boolean; + fallbackUnavailable: boolean; + } { + const firstChoice = "KeyH"; + if (!ctx.state.keybindingsMap.has("KeyH")) { + return { + bindingKey: firstChoice, + fallbackUsed: false, + fallbackUnavailable: false, + }; + } + + if (ctx.state.keybindingsMap.has("KeyK")) { + return { + bindingKey: "KeyK", + fallbackUsed: true, + fallbackUnavailable: true, + }; + } + + return { + bindingKey: "KeyK", + fallbackUsed: true, + fallbackUnavailable: false, + }; + } + + function applySessionHelpChordBinding(): void { + CHORD_MAP.delete("KeyH"); + CHORD_MAP.delete("KeyK"); + const info = resolveSessionHelpChordBinding(); + CHORD_MAP.set(info.bindingKey, { + type: "electron", + action: () => { + options.openSessionHelpModal(info); + }, + }); + } + function handleInvisiblePositionEditKeydown(e: KeyboardEvent): boolean { if (!ctx.platform.isInvisibleLayer) return false; @@ -163,6 +210,10 @@ export function createKeyboardHandlers( options.handleJimakuKeydown(e); return; } + if (ctx.state.sessionHelpModalOpen) { + options.handleSessionHelpKeydown(e); + return; + } if (ctx.state.chordPending) { const modifierKeys = [ @@ -202,6 +253,7 @@ export function createKeyboardHandlers( !e.repeat ) { e.preventDefault(); + applySessionHelpChordBinding(); ctx.state.chordPending = true; ctx.state.chordTimeout = setTimeout(() => { resetChord(); diff --git a/src/renderer/index.html b/src/renderer/index.html index d31ca63..9417d9d 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -28,7 +28,7 @@ -
+
@@ -259,6 +259,30 @@
+ diff --git a/src/renderer/modals/session-help.ts b/src/renderer/modals/session-help.ts new file mode 100644 index 0000000..f9695f9 --- /dev/null +++ b/src/renderer/modals/session-help.ts @@ -0,0 +1,741 @@ +import type { Keybinding } from "../../types"; +import type { ShortcutsConfig } from "../../types"; +import { SPECIAL_COMMANDS } from "../../config/definitions"; +import type { ModalStateReader, RendererContext } from "../context"; + +type SessionHelpBindingInfo = { + bindingKey: "KeyH" | "KeyK"; + fallbackUsed: boolean; + fallbackUnavailable: boolean; +}; + +type SessionHelpItem = { + shortcut: string; + action: string; + color?: string; +}; + +type SessionHelpSection = { + title: string; + rows: SessionHelpItem[]; +}; +type RuntimeShortcutConfig = Omit, "multiCopyTimeoutMs">; + +const HEX_COLOR_RE = + /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; + +const FALLBACK_COLORS = { + knownWordColor: "#a6da95", + nPlusOneColor: "#c6a0f6", + jlptN1Color: "#ed8796", + jlptN2Color: "#f5a97f", + jlptN3Color: "#f9e2af", + jlptN4Color: "#a6e3a1", + jlptN5Color: "#8aadf4", +}; + +const KEY_NAME_MAP: Record = { + Space: "Space", + ArrowUp: "↑", + ArrowDown: "↓", + ArrowLeft: "←", + ArrowRight: "→", + Escape: "Esc", + Tab: "Tab", + Enter: "Enter", + CommandOrControl: "Cmd/Ctrl", + Ctrl: "Ctrl", + Control: "Ctrl", + Command: "Cmd", + Cmd: "Cmd", + Shift: "Shift", + Alt: "Alt", + Super: "Meta", + Meta: "Meta", + Backspace: "Backspace", +}; + +function normalizeColor(value: unknown, fallback: string): string { + if (typeof value !== "string") return fallback; + const next = value.trim(); + return HEX_COLOR_RE.test(next) ? next : fallback; +} + +function normalizeKeyToken(token: string): string { + if (KEY_NAME_MAP[token]) return KEY_NAME_MAP[token]; + if (token.startsWith("Key")) return token.slice(3); + if (token.startsWith("Digit")) return token.slice(5); + if (token.startsWith("Numpad")) return token.slice(6); + return token; +} + +function formatKeybinding(rawBinding: string): string { + const parts = rawBinding.split("+"); + const key = parts.pop(); + if (!key) return rawBinding; + const normalized = [...parts, normalizeKeyToken(key)]; + return normalized.join(" + "); +} + +const OVERLAY_SHORTCUTS: Array<{ + key: keyof RuntimeShortcutConfig; + label: string; +}> = [ + { key: "copySubtitle", label: "Copy subtitle" }, + { key: "copySubtitleMultiple", label: "Copy subtitle (multi)" }, + { key: "updateLastCardFromClipboard", label: "Update last card from clipboard" }, + { key: "triggerFieldGrouping", label: "Trigger field grouping" }, + { key: "triggerSubsync", label: "Open subtitle sync controls" }, + { key: "mineSentence", label: "Mine sentence" }, + { key: "mineSentenceMultiple", label: "Mine sentence (multi)" }, + { key: "toggleSecondarySub", label: "Toggle secondary subtitle mode" }, + { key: "markAudioCard", label: "Mark audio card" }, + { 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[] { + const rows: SessionHelpItem[] = []; + + 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), + action: shortcut.label, + }); + } + + if (rows.length === 0) return []; + return [{ title: "Overlay shortcuts", rows }]; +} + +function describeCommand(command: (string | number)[]): string { + const first = command[0]; + if (typeof first !== "string") return "Unknown action"; + + if (first === "cycle" && command[1] === "pause") return "Toggle playback"; + if (first === "seek" && typeof command[1] === "number") { + return `Seek ${command[1] > 0 ? "+" : ""}${command[1]} second(s)`; + } + if (first === "sub-seek" && typeof command[1] === "number") { + return `Shift subtitle by ${command[1]} ms`; + } + if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return "Open subtitle sync controls"; + if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return "Open runtime options"; + if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return "Replay current subtitle"; + if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return "Play next subtitle"; + if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) { + const [, rawId, rawDirection] = first.split(":"); + return `Cycle runtime option ${rawId || "option"} ${rawDirection === "prev" ? "previous" : "next"}`; + } + + return `MPV command: ${command.map((entry) => String(entry)).join(" ")}`; +} + +function sectionForCommand(command: (string | number)[]): string { + const first = command[0]; + if (typeof first !== "string") return "Other shortcuts"; + + if ( + first === "cycle" || + first === "seek" || + first === "sub-seek" || + first === SPECIAL_COMMANDS.REPLAY_SUBTITLE || + first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE + ) { + return "Playback and navigation"; + } + + if (first === "show-text" || first === "show-progress" || first.startsWith("osd")) { + return "Visual feedback"; + } + + if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) { + return "Subtitle sync"; + } + + if ( + first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN || + first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX) + ) { + return "Runtime settings"; + } + + if (first === "quit") return "System actions"; + return "Other shortcuts"; +} + +function buildBindingSections(keybindings: Keybinding[]): SessionHelpSection[] { + const grouped = new Map(); + + for (const binding of keybindings) { + const section = sectionForCommand(binding.command ?? []); + const row: SessionHelpItem = { + shortcut: formatKeybinding(binding.key), + action: describeCommand(binding.command ?? []), + }; + grouped.set(section, [...(grouped.get(section) ?? []), row]); + } + + const sectionOrder = [ + "Playback and navigation", + "Visual feedback", + "Subtitle sync", + "Runtime settings", + "System actions", + "Other shortcuts", + ]; + const sectionEntries = Array.from(grouped.entries()).sort((a, b) => { + const aIdx = sectionOrder.indexOf(a[0]); + const bIdx = sectionOrder.indexOf(b[0]); + if (aIdx === -1 && bIdx === -1) return a[0].localeCompare(b[0]); + if (aIdx === -1) return 1; + if (bIdx === -1) return -1; + return aIdx - bIdx; + }); + + return sectionEntries.map(([title, rows]) => ({ title, rows })); +} + +function buildColorSection(style: { + knownWordColor?: unknown; + nPlusOneColor?: unknown; + jlptColors?: { + N1?: unknown; + N2?: unknown; + N3?: unknown; + N4?: unknown; + N5?: unknown; + }; +}): SessionHelpSection { + return { + title: "Color legend", + rows: [ + { + shortcut: "Known words", + action: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor), + color: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor), + }, + { + shortcut: "N+1 words", + action: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor), + color: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor), + }, + { + shortcut: "JLPT N1", + action: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color), + color: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color), + }, + { + shortcut: "JLPT N2", + action: normalizeColor(style.jlptColors?.N2, FALLBACK_COLORS.jlptN2Color), + color: normalizeColor(style.jlptColors?.N2, FALLBACK_COLORS.jlptN2Color), + }, + { + shortcut: "JLPT N3", + action: normalizeColor(style.jlptColors?.N3, FALLBACK_COLORS.jlptN3Color), + color: normalizeColor(style.jlptColors?.N3, FALLBACK_COLORS.jlptN3Color), + }, + { + shortcut: "JLPT N4", + action: normalizeColor(style.jlptColors?.N4, FALLBACK_COLORS.jlptN4Color), + color: normalizeColor(style.jlptColors?.N4, FALLBACK_COLORS.jlptN4Color), + }, + { + shortcut: "JLPT N5", + action: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color), + color: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color), + }, + ], + }; +} + +function filterSections( + sections: SessionHelpSection[], + query: string, +): SessionHelpSection[] { + const normalize = (value: string): string => + value + .toLowerCase() + .replace(/commandorcontrol/gu, "ctrl") + .replace(/cmd\/ctrl/gu, "ctrl") + .replace(/[\s+\-_/]/gu, ""); + const normalized = normalize(query); + if (!normalized) return sections; + + return sections + .map((section) => { + if (normalize(section.title).includes(normalized)) { + return section; + } + + const rows = section.rows.filter( + (row) => + normalize(row.shortcut).includes(normalized) || + normalize(row.action).includes(normalized), + ); + if (rows.length === 0) return null; + return { ...section, rows }; + }) + .filter((section): section is SessionHelpSection => section !== null) + .filter((section) => section.rows.length > 0); +} + +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"; +} + +function createShortcutRow(row: SessionHelpItem): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "session-help-item"; + button.tabIndex = -1; + + const left = document.createElement("div"); + left.className = "session-help-item-left"; + const shortcut = document.createElement("span"); + shortcut.className = "session-help-key"; + shortcut.textContent = row.shortcut; + left.appendChild(shortcut); + + const right = document.createElement("div"); + right.className = "session-help-item-right"; + const action = document.createElement("span"); + action.className = "session-help-action"; + action.textContent = row.action; + right.appendChild(action); + + if (row.color) { + const dot = document.createElement("span"); + dot.className = "session-help-color-dot"; + dot.style.backgroundColor = row.color; + right.insertBefore(dot, action); + } + + button.appendChild(left); + button.appendChild(right); + return button; +} + +const SECTION_ICON: Record = { + "MPV shortcuts": "⚙", + "Playback and navigation": "▶", + "Visual feedback": "◉", + "Subtitle sync": "⟲", + "Runtime settings": "⚙", + "System actions": "◆", + "Other shortcuts": "…", + "Overlay shortcuts (configurable)": "✦", + "Overlay shortcuts": "✦", + "Color legend": "◈", +}; + +function createSectionNode( + section: SessionHelpSection, + sectionIndex: number, + onSelect: (index: number) => void, + globalIndexMap: number[], +): HTMLElement { + const sectionNode = document.createElement("section"); + sectionNode.className = "session-help-section"; + + const title = document.createElement("h3"); + title.className = "session-help-section-title"; + const icon = SECTION_ICON[section.title] ?? "•"; + title.textContent = `${icon} ${section.title}`; + sectionNode.appendChild(title); + + const list = document.createElement("div"); + list.className = "session-help-item-list"; + + section.rows.forEach((row, rowIndex) => { + const button = createShortcutRow(row); + const globalIndex = globalIndexMap[sectionIndex] + rowIndex; + button.addEventListener("click", () => onSelect(globalIndex)); + list.appendChild(button); + }); + + sectionNode.appendChild(list); + return sectionNode; +} + +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 focusGuard: ((event: FocusEvent) => void) | null = null; + let windowFocusGuard: (() => void) | null = null; + let modalPointerFocusGuard: ((event: Event) => void) | null = null; + + function getItems(): HTMLButtonElement[] { + return Array.from( + ctx.dom.sessionHelpContent.querySelectorAll(".session-help-item"), + ) as HTMLButtonElement[]; + } + + function setSelected(index: number): void { + const items = getItems(); + if (items.length === 0) return; + + const next = + ((index % items.length) + items.length) % items.length; + ctx.state.sessionHelpSelectedIndex = next; + + items.forEach((item, idx) => { + item.classList.toggle("active", idx === next); + item.tabIndex = idx === next ? 0 : -1; + }); + const activeItem = items[next]; + activeItem.focus({ preventScroll: true }); + activeItem.scrollIntoView({ + block: "nearest", + inline: "nearest", + }); + } + + function isSessionHelpModalFocusTarget(target: EventTarget | null): boolean { + return ( + target instanceof Element && + ctx.dom.sessionHelpModal.contains(target) + ); + } + + function focusFallbackTarget(): void { + void window.electronAPI.focusMainWindow(); + const items = getItems(); + const firstItem = items.find((item) => item.offsetParent !== null); + if (firstItem) { + firstItem.focus({ preventScroll: true }); + return; + } + + if (ctx.dom.sessionHelpClose instanceof HTMLElement) { + ctx.dom.sessionHelpClose.focus({ preventScroll: true }); + return; + } + + window.focus(); + } + + function enforceModalFocus(): void { + if (!ctx.state.sessionHelpModalOpen) return; + if (!isSessionHelpModalFocusTarget(document.activeElement)) { + focusFallbackTarget(); + } + } + + 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 = filterSections(helpSections, helpFilterValue); + const indexOffsets: number[] = []; + let running = 0; + for (const section of sections) { + indexOffsets.push(running); + running += section.rows.length; + } + + ctx.dom.sessionHelpContent.innerHTML = ""; + sections.forEach((section, sectionIndex) => { + const sectionNode = createSectionNode( + section, + sectionIndex, + (selectionIndex) => { + setSelected(selectionIndex); + }, + indexOffsets, + ); + 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 session shortcuts found."; + ctx.state.sessionHelpSelectedIndex = 0; + return; + } + + ctx.dom.sessionHelpContent.classList.remove("session-help-content-no-results"); + + if (isFilterInputFocused()) return; + + setSelected(0); + } + + function requestOverlayFocus(): void { + void window.electronAPI.focusMainWindow(); + } + + function addPointerFocusListener(): void { + if (modalPointerFocusGuard) return; + + modalPointerFocusGuard = () => { + requestOverlayFocus(); + enforceModalFocus(); + }; + ctx.dom.sessionHelpModal.addEventListener("pointerdown", modalPointerFocusGuard); + ctx.dom.sessionHelpModal.addEventListener("mousedown", modalPointerFocusGuard); + ctx.dom.sessionHelpModal.addEventListener("click", modalPointerFocusGuard); + } + + function removePointerFocusListener(): void { + if (!modalPointerFocusGuard) return; + ctx.dom.sessionHelpModal.removeEventListener("pointerdown", modalPointerFocusGuard); + ctx.dom.sessionHelpModal.removeEventListener("mousedown", modalPointerFocusGuard); + ctx.dom.sessionHelpModal.removeEventListener("click", modalPointerFocusGuard); + modalPointerFocusGuard = null; + } + + function startFocusRecoveryGuards(): void { + if (windowFocusGuard) return; + + windowFocusGuard = () => { + requestOverlayFocus(); + enforceModalFocus(); + }; + window.addEventListener("blur", windowFocusGuard); + window.addEventListener("focus", windowFocusGuard); + } + + function stopFocusRecoveryGuards(): void { + if (!windowFocusGuard) return; + window.removeEventListener("blur", windowFocusGuard); + window.removeEventListener("focus", windowFocusGuard); + windowFocusGuard = null; + } + + async function render(): Promise { + const [keybindings, styleConfig, shortcuts] = await Promise.all([ + window.electronAPI.getKeybindings(), + window.electronAPI.getSubtitleStyle(), + window.electronAPI.getConfiguredShortcuts(), + ]); + + const bindingSections = buildBindingSections(keybindings); + if (bindingSections.length > 0) { + const playback = bindingSections.find( + (section) => section.title === "Playback and navigation", + ); + if (playback) { + playback.title = "MPV shortcuts"; + } + } + + const shortcutSections = buildOverlayShortcutSections(shortcuts); + if (shortcutSections.length > 0) { + shortcutSections[0].title = "Overlay shortcuts (configurable)"; + } + const colorSection = buildColorSection(styleConfig ?? {}); + helpSections = [...bindingSections, ...shortcutSections, colorSection]; + applyFilterAndRender(); + } + + async function openSessionHelpModal(opening: SessionHelpBindingInfo): Promise { + openBinding = opening; + priorFocus = document.activeElement; + + await render(); + + 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 = + "Use Arrow keys, J/K/H/L, mouse, click, or / then type to filter. Esc closes."; + + 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); + } + + if (focusGuard === null) { + focusGuard = (event: FocusEvent) => { + if (!ctx.state.sessionHelpModalOpen) return; + if (!isSessionHelpModalFocusTarget(event.target)) { + event.preventDefault(); + enforceModalFocus(); + } + }; + document.addEventListener("focusin", focusGuard); + } + + addPointerFocusListener(); + startFocusRecoveryGuards(); + requestOverlayFocus(); + window.focus(); + enforceModalFocus(); + } + + 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"); + if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { + ctx.dom.overlay.classList.remove("interactive"); + } + + if (focusGuard) { + document.removeEventListener("focusin", focusGuard); + focusGuard = null; + } + removePointerFocusListener(); + stopFocusRecoveryGuards(); + + if (priorFocus instanceof HTMLElement && priorFocus.isConnected) { + priorFocus.focus({ preventScroll: true }); + return; + } + + if (ctx.dom.overlay instanceof HTMLElement) { + 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(); + } + + function handleSessionHelpKeydown(e: KeyboardEvent): boolean { + if (!ctx.state.sessionHelpModalOpen) return false; + + if (isFilterInputFocused()) { + if (e.key === "Escape") { + e.preventDefault(); + if (!helpFilterValue) { + closeSessionHelpModal(); + return true; + } + + helpFilterValue = ""; + ctx.dom.sessionHelpFilter.value = ""; + applyFilterAndRender(); + 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; + } + + 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(); + focusFallbackTarget(); + } + }); + + ctx.dom.sessionHelpClose.addEventListener("click", () => { + closeSessionHelpModal(); + }); + } + + return { + closeSessionHelpModal, + handleSessionHelpKeydown, + openSessionHelpModal, + wireDomEvents, + }; +} diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 36b571a..1d194bd 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -29,6 +29,7 @@ import { createKeyboardHandlers } from "./handlers/keyboard.js"; import { createMouseHandlers } from "./handlers/mouse.js"; import { createJimakuModal } from "./modals/jimaku.js"; import { createKikuModal } from "./modals/kiku.js"; +import { createSessionHelpModal } from "./modals/session-help.js"; import { createRuntimeOptionsModal } from "./modals/runtime-options.js"; import { createSubsyncModal } from "./modals/subsync.js"; import { createPositioningController } from "./positioning.js"; @@ -49,7 +50,8 @@ function isAnySettingsModalOpen(): boolean { ctx.state.runtimeOptionsModalOpen || ctx.state.subsyncModalOpen || ctx.state.kikuModalOpen || - ctx.state.jimakuModalOpen + ctx.state.jimakuModalOpen || + ctx.state.sessionHelpModalOpen ); } @@ -58,7 +60,8 @@ function isAnyModalOpen(): boolean { ctx.state.jimakuModalOpen || ctx.state.kikuModalOpen || ctx.state.runtimeOptionsModalOpen || - ctx.state.subsyncModalOpen + ctx.state.subsyncModalOpen || + ctx.state.sessionHelpModalOpen ); } @@ -84,6 +87,10 @@ const subsyncModal = createSubsyncModal(ctx, { modalStateReader: { isAnyModalOpen }, syncSettingsModalSubtitleSuppression, }); +const sessionHelpModal = createSessionHelpModal(ctx, { + modalStateReader: { isAnyModalOpen }, + syncSettingsModalSubtitleSuppression, +}); const kikuModal = createKikuModal(ctx, { modalStateReader: { isAnyModalOpen }, syncSettingsModalSubtitleSuppression, @@ -97,6 +104,8 @@ const keyboardHandlers = createKeyboardHandlers(ctx, { handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown, handleKikuKeydown: kikuModal.handleKikuKeydown, handleJimakuKeydown: jimakuModal.handleJimakuKeydown, + handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown, + openSessionHelpModal: sessionHelpModal.openSessionHelpModal, saveInvisiblePositionEdit: positioning.saveInvisiblePositionEdit, cancelInvisiblePositionEdit: positioning.cancelInvisiblePositionEdit, setInvisiblePositionEditMode: positioning.setInvisiblePositionEditMode, @@ -178,6 +187,7 @@ async function init(): Promise { kikuModal.wireDomEvents(); runtimeOptionsModal.wireDomEvents(); subsyncModal.wireDomEvents(); + sessionHelpModal.wireDomEvents(); window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => { runtimeOptionsModal.updateRuntimeOptions(options); diff --git a/src/renderer/state.ts b/src/renderer/state.ts index 293d99c..b56cd6f 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -54,6 +54,9 @@ export type RendererState = { subsyncSourceTracks: SubsyncSourceTrack[]; subsyncSubmitting: boolean; + sessionHelpModalOpen: boolean; + sessionHelpSelectedIndex: number; + mpvSubtitleRenderMetrics: MpvSubtitleRenderMetrics | null; invisiblePositionEditMode: boolean; invisiblePositionEditStartX: number; @@ -118,6 +121,9 @@ export function createRendererState(): RendererState { subsyncSourceTracks: [], subsyncSubmitting: false, + sessionHelpModalOpen: false, + sessionHelpSelectedIndex: 0, + mpvSubtitleRenderMetrics: null, invisiblePositionEditMode: false, invisiblePositionEditStartX: 0, diff --git a/src/renderer/style.css b/src/renderer/style.css index 3e988fa..666a79c 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -807,8 +807,198 @@ iframe[id^="yomitan-popup"] { color: #ff8f8f; } +.session-help-content { + width: min(760px, 92%); + max-height: 84%; + color: rgba(255, 255, 255, 0.95); +} + +.session-help-shortcut, +.session-help-warning, +.session-help-status { + min-height: 18px; + font-size: 13px; + color: rgba(255, 255, 255, 0.8); + line-height: 1.45; +} + +.session-help-shortcut { + font-weight: 600; + color: rgba(255, 255, 255, 0.97); +} + +.session-help-warning { + color: #f8a100; +} + +.session-help-content-list { + display: flex; + flex-direction: column; + gap: 12px; + max-height: calc(84vh - 220px); + overflow-y: auto; + padding-right: 4px; +} + +.session-help-filter { + width: 100%; + min-height: 32px; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + padding: 8px 10px; + background: rgba(0, 0, 0, 0.45); + color: #fff; + font-size: 13px; + line-height: 1.2; +} + +.session-help-filter::placeholder { + color: rgba(255, 255, 255, 0.45); +} + +.session-help-filter:focus { + outline: none; + border-color: rgba(137, 180, 255, 0.6); + box-shadow: 0 0 0 2px rgba(137, 180, 255, 0.2); +} + +.session-help-content-no-results { + color: rgba(255, 255, 255, 0.75); + padding: 12px; + font-size: 13px; +} + +.session-help-section { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + background: rgba(255, 255, 255, 0.02); + backdrop-filter: blur(1px); +} + +.session-help-section-title { + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 800; + color: rgba(255, 255, 255, 0.55); + display: flex; + align-items: center; + gap: 6px; + margin: 0; + padding: 0 4px; +} + +.session-help-item-list { + display: flex; + flex-direction: column; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + overflow: hidden; +} + +.session-help-item { + width: 100%; + min-height: 42px; + padding: 10px 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + text-align: left; + border: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + background: transparent; + color: #fff; + cursor: pointer; +} + +.session-help-item:last-child { + border-bottom: none; +} + +.session-help-item:hover, +.session-help-item:focus-visible, +.session-help-item.active { + background: rgba(137, 180, 255, 0.2); + outline: none; +} + +.session-help-item.active { + box-shadow: inset 3px 0 0 0 rgba(137, 180, 255, 0.9); +} + +.session-help-item-left { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 8px; +} + +.session-help-item-right { + min-width: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.session-help-key { + font-size: 12px; + font-weight: 700; + color: rgba(255, 255, 255, 0.95); + white-space: nowrap; + padding: 4px 9px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + border-radius: 999px; + background: rgba(137, 180, 255, 0.16); + border: 1px solid rgba(137, 180, 255, 0.35); + letter-spacing: 0.01em; +} + +.session-help-action { + font-size: 13px; + color: rgba(255, 255, 255, 0.84); + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.35; +} + +.session-help-color-dot { + width: 10px; + height: 10px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.25); + flex: 0 0 auto; +} + @media (max-width: 640px) { .kiku-cards-container { grid-template-columns: 1fr; } + + .session-help-content-list { + max-height: calc(84vh - 190px); + } + + .session-help-item { + flex-direction: column; + align-items: flex-start; + gap: 5px; + } + + .session-help-item-right { + justify-content: flex-start; + width: 100%; + } + + .session-help-key { + width: 100%; + justify-content: center; + text-align: center; + } } diff --git a/src/renderer/utils/dom.ts b/src/renderer/utils/dom.ts index 8d2cb6c..8cd998e 100644 --- a/src/renderer/utils/dom.ts +++ b/src/renderer/utils/dom.ts @@ -54,6 +54,14 @@ export type RendererDom = { subsyncSourceSelect: HTMLSelectElement; subsyncRunButton: HTMLButtonElement; subsyncStatus: HTMLDivElement; + + sessionHelpModal: HTMLDivElement; + sessionHelpClose: HTMLButtonElement; + sessionHelpShortcut: HTMLDivElement; + sessionHelpWarning: HTMLDivElement; + sessionHelpStatus: HTMLDivElement; + sessionHelpFilter: HTMLInputElement; + sessionHelpContent: HTMLDivElement; }; function getRequiredElement(id: string): T { @@ -127,5 +135,13 @@ export function resolveRendererDom(): RendererDom { subsyncSourceSelect: getRequiredElement("subsyncSourceSelect"), subsyncRunButton: getRequiredElement("subsyncRun"), subsyncStatus: getRequiredElement("subsyncStatus"), + + sessionHelpModal: getRequiredElement("sessionHelpModal"), + sessionHelpClose: getRequiredElement("sessionHelpClose"), + sessionHelpShortcut: getRequiredElement("sessionHelpShortcut"), + sessionHelpWarning: getRequiredElement("sessionHelpWarning"), + sessionHelpStatus: getRequiredElement("sessionHelpStatus"), + sessionHelpFilter: getRequiredElement("sessionHelpFilter"), + sessionHelpContent: getRequiredElement("sessionHelpContent"), }; } diff --git a/src/types.ts b/src/types.ts index 38c55a2..a0f50cf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -626,6 +626,7 @@ export interface ElectronAPI { setMecabEnabled: (enabled: boolean) => void; sendMpvCommand: (command: (string | number)[]) => void; getKeybindings: () => Promise; + getConfiguredShortcuts: () => Promise>; getJimakuMediaInfo: () => Promise; jimakuSearchEntries: ( query: JimakuSearchQuery, @@ -646,6 +647,7 @@ export interface ElectronAPI { onSecondarySubMode: (callback: (mode: SecondarySubMode) => void) => void; getSecondarySubMode: () => Promise; getCurrentSecondarySub: () => Promise; + focusMainWindow: () => Promise; getSubtitleStyle: () => Promise; onSubsyncManualOpen: ( callback: (payload: SubsyncManualPayload) => void,