diff --git a/changes/session-help-run-commands.md b/changes/session-help-run-commands.md new file mode 100644 index 00000000..7b7d8351 --- /dev/null +++ b/changes/session-help-run-commands.md @@ -0,0 +1,4 @@ +type: added +area: overlay + +- Session help can run the highlighted command during playback: press `Enter` or double-click a row. Hovering a row with the mouse makes it the `Enter` target. Help opened from the tray with no video loaded stays read-only. diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md index 5ecde18e..628673eb 100644 --- a/docs-site/shortcuts.md +++ b/docs-site/shortcuts.md @@ -88,6 +88,8 @@ Built into the overlay, not configurable: | `Alt+C` | Open controller setup and remapping | `shortcuts.openControllerSelect` | | `Alt+Shift+C` | Open controller debug view | `shortcuts.openControllerDebug` | +While a video is playing, session help can also run commands: highlight a row with the arrow keys or the mouse, then press `Enter` or double-click it. Fixed overlay controls, global shortcuts, `y` chords, the multi-line copy and mine actions, Open session help, and the color legend are reference only. Help opened from the tray with no video loaded is read-only. + The sidebar key has a separate mpv-side binding, `shortcuts.toggleSubtitleSidebar`. The sidebar only opens when SubMiner has parsed the active subtitle file. In the sidebar, `Enter` seeks to the focused line. The subtitle picker (`g` then `s`) is off until you turn it on in **Settings, Behavior, Subtitle Selection**. Press the second key within one second. If `g` already has an action in SubMiner or mpv, the sequence is disabled and a warning is shown. See [subtitle selection](/configuration#subtitle-selection). diff --git a/src/main.ts b/src/main.ts index 76688880..73f76ac0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3073,9 +3073,15 @@ function openSubtitleGenerationOverlay(): void { ); } +// currentMediaPath follows every mpv path change and is cleared on disconnect; the +// client's currentVideoPath outlives the connection, so a reconnect could reuse it. +function isMediaPlaybackActive(): boolean { + return appState.mpvClient?.connected === true && Boolean(appState.currentMediaPath?.trim()); +} + function openSessionHelpOverlay(): void { openOverlayHostedModalWithOsd( - openSessionHelpModalRuntime, + (deps) => openSessionHelpModalRuntime({ ...deps, isMediaPlaybackActive }), 'Session help overlay unavailable.', 'Failed to open session help overlay.', ); diff --git a/src/main/runtime/session-help-open.test.ts b/src/main/runtime/session-help-open.test.ts new file mode 100644 index 00000000..ab84021a --- /dev/null +++ b/src/main/runtime/session-help-open.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { IPC_CHANNELS } from '../../shared/ipc/contracts'; +import { openSessionHelpModal } from './session-help-open'; + +test('session help open tells the renderer whether commands can run', async () => { + for (const playing of [true, false]) { + const sent: Array<{ channel: string; payload: unknown }> = []; + const opened = await openSessionHelpModal({ + ensureOverlayStartupPrereqs: () => {}, + ensureOverlayWindowsReadyForVisibilityActions: () => {}, + sendToActiveOverlayWindow: (channel, payload) => { + sent.push({ channel, payload }); + return true; + }, + waitForModalOpen: async () => true, + logWarn: () => {}, + isMediaPlaybackActive: () => playing, + }); + + assert.equal(opened, true); + assert.deepEqual(sent, [ + { channel: IPC_CHANNELS.event.sessionHelpOpen, payload: { commandsEnabled: playing } }, + ]); + } +}); diff --git a/src/main/runtime/session-help-open.ts b/src/main/runtime/session-help-open.ts index 86b7b59f..c2c26806 100644 --- a/src/main/runtime/session-help-open.ts +++ b/src/main/runtime/session-help-open.ts @@ -1,5 +1,6 @@ import type { OverlayHostedModal } from '../../shared/ipc/contracts'; import { IPC_CHANNELS } from '../../shared/ipc/contracts'; +import type { SessionHelpOpenPayload } from '../../types/runtime'; import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open'; const SESSION_HELP_MODAL: OverlayHostedModal = 'session-help'; @@ -18,6 +19,8 @@ export async function openSessionHelpModal(deps: { ) => boolean; waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; logWarn: (message: string) => void; + /** True when mpv has a video loaded; gates running commands from help rows. */ + isMediaPlaybackActive: () => boolean; }): Promise { return await retryOverlayModalOpen( { @@ -29,8 +32,11 @@ export async function openSessionHelpModal(deps: { timeoutMs: SESSION_HELP_OPEN_TIMEOUT_MS, retryWarning: 'Session help modal did not acknowledge modal open on first attempt; retrying dedicated modal window.', - sendOpen: () => - openOverlayHostedModal( + sendOpen: () => { + const payload: SessionHelpOpenPayload = { + commandsEnabled: deps.isMediaPlaybackActive(), + }; + return openOverlayHostedModal( { ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs, ensureOverlayWindowsReadyForVisibilityActions: @@ -40,9 +46,11 @@ export async function openSessionHelpModal(deps: { { channel: IPC_CHANNELS.event.sessionHelpOpen, modal: SESSION_HELP_MODAL, + payload, preferModalWindow: true, }, - ), + ); + }, }, ); } diff --git a/src/preload.ts b/src/preload.ts index f494c7ad..df99d0a3 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -61,6 +61,7 @@ import type { ControllerConfigUpdate, ControllerPreferenceUpdate, ResolvedControllerConfig, + SessionHelpOpenPayload, SessionNumericSelectionStartPayload, SubtitleMiningContext, YoutubePickerOpenPayload, @@ -177,7 +178,15 @@ function createLatestValueIpcListenerWithPayload( } const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen); -const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen); +const onOpenSessionHelpEvent = createQueuedIpcListenerWithPayload( + IPC_CHANNELS.event.sessionHelpOpen, + (payload) => ({ + commandsEnabled: + typeof payload === 'object' && + payload !== null && + (payload as Partial).commandsEnabled === true, + }), +); const onOpenChangelogEvent = createQueuedIpcListener(IPC_CHANNELS.event.changelogOpen); const onOpenCharacterDictionaryManagerEvent = createQueuedIpcListener( IPC_CHANNELS.event.characterDictionaryManagerOpen, diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 68db6a1f..5a7f25d1 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -1,6 +1,7 @@ import type { CompiledSessionBinding, PrimarySubMode, ShortcutsConfig } from '../../types'; import type { RendererContext } from '../context'; import { createMpvInputForwarding } from './mpv-input-forwarding'; +import { dispatchConfiguredMpvCommand } from '../utils/mpv-command-dispatch'; import { YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_SHOWN_EVENT, @@ -257,7 +258,10 @@ export function createKeyboardHandlers( } if (binding.actionType === 'mpv-command') { - dispatchConfiguredMpvCommand(binding.command); + dispatchConfiguredMpvCommand(binding.command, { + getPlaybackPaused: options.getPlaybackPaused, + sendMpvCommand: (command) => window.electronAPI.sendMpvCommand(command), + }); return; } @@ -566,31 +570,6 @@ export function createKeyboardHandlers( }); } - function isSubtitleSeekCommand( - command: (string | number)[] | undefined, - ): command is [string, number] { - return Array.isArray(command) && command[0] === 'sub-seek' && typeof command[1] === 'number'; - } - - function dispatchConfiguredMpvCommand(command: (string | number)[]): void { - if (!isSubtitleSeekCommand(command)) { - window.electronAPI.sendMpvCommand(command); - return; - } - - void options - .getPlaybackPaused() - .then((paused) => { - window.electronAPI.sendMpvCommand(command); - if (paused !== false) { - window.electronAPI.sendMpvCommand(['set_property', 'pause', 'yes']); - } - }) - .catch(() => { - window.electronAPI.sendMpvCommand(command); - }); - } - type ScanModifierState = { shiftKey?: boolean; ctrlKey?: boolean; diff --git a/src/renderer/modals/session-help-commands.test.ts b/src/renderer/modals/session-help-commands.test.ts new file mode 100644 index 00000000..3c2c9536 --- /dev/null +++ b/src/renderer/modals/session-help-commands.test.ts @@ -0,0 +1,378 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { CompiledSessionBinding } from '../../types.js'; +import { createRendererState } from '../state.js'; +import { buildSessionHelpSections, createSessionHelpModal } from './session-help.js'; + +/** Just enough DOM for the help modal to render rows and route row events. */ +class FakeElement { + children: FakeElement[] = []; + parent: FakeElement | null = null; + dataset: Record = {}; + style: Record = {}; + textContent = ''; + value = ''; + tabIndex = 0; + type = ''; + private classes = new Set(); + private listeners = new Map void>>(); + + classList = { + add: (...tokens: string[]) => tokens.forEach((token) => this.classes.add(token)), + remove: (...tokens: string[]) => tokens.forEach((token) => this.classes.delete(token)), + toggle: (token: string, force?: boolean) => { + const on = force ?? !this.classes.has(token); + if (on) this.classes.add(token); + else this.classes.delete(token); + return on; + }, + contains: (token: string) => this.classes.has(token), + }; + + set className(value: string) { + this.classes = new Set(value.split(/\s+/).filter(Boolean)); + } + + get className(): string { + return [...this.classes].join(' '); + } + + set innerHTML(_value: string) { + this.children = []; + } + + appendChild(child: FakeElement): FakeElement { + child.parent = this; + this.children.push(child); + return child; + } + + insertBefore(child: FakeElement, ref: FakeElement): FakeElement { + child.parent = this; + const index = this.children.indexOf(ref); + this.children.splice(index < 0 ? this.children.length : index, 0, child); + return child; + } + + addEventListener(type: string, listener: (event: unknown) => void): void { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + + dispatch(type: string, event: Record): void { + for (const listener of this.listeners.get(type) ?? []) listener(event); + } + + focus(): void { + (globalThis.document as unknown as { activeElement: unknown }).activeElement = this; + } + + contains(node: unknown): boolean { + for (let current = node as FakeElement | null; current; current = current.parent) { + if (current === this) return true; + } + return false; + } + + closest(selector: string): FakeElement | null { + for (let current: FakeElement | null = this; current; current = current.parent) { + if (current.classList.contains(selector.slice(1))) return current; + } + return null; + } + + querySelectorAll(selector: string): FakeElement[] { + const matches: FakeElement[] = []; + const visit = (node: FakeElement) => { + for (const child of node.children) { + if (child.classList.contains(selector.slice(1))) matches.push(child); + visit(child); + } + }; + visit(this); + return matches; + } + + setAttribute(): void {} + removeEventListener(): void {} + select(): void {} + scrollIntoView(): void {} + getClientRects(): unknown[] { + return [{}]; + } +} + +const SESSION_BINDINGS: CompiledSessionBinding[] = [ + { + sourcePath: 'stats.toggleKey', + originalKey: 'Backquote', + key: { code: 'Backquote', modifiers: [] }, + actionType: 'session-action', + actionId: 'toggleStatsOverlay', + }, + { + sourcePath: 'shortcuts.toggleVisibleOverlayGlobal', + originalKey: 'KeyO', + key: { code: 'KeyO', modifiers: ['alt'] }, + actionType: 'session-action', + actionId: 'toggleVisibleOverlay', + }, +]; + +function withFakeDom( + run: (harness: ReturnType) => Promise, + options: HarnessOptions = {}, +) { + return async () => { + const globals = globalThis as Record; + const saved = ['window', 'document', 'HTMLElement', 'Element'].map((key) => [ + key, + globals[key], + ]); + try { + await run(createHarness(options)); + } finally { + for (const [key, value] of saved) { + Object.defineProperty(globalThis, key as string, { + configurable: true, + writable: true, + value, + }); + } + } + }; +} + +type HarnessOptions = { failActions?: boolean }; + +function createHarness(options: HarnessOptions) { + const mpvCommands: (string | number)[][] = []; + const sessionActions: string[] = []; + const define = (key: string, value: unknown) => + Object.defineProperty(globalThis, key, { configurable: true, writable: true, value }); + + define('HTMLElement', FakeElement); + define('Element', FakeElement); + define('document', { + activeElement: null, + createElement: () => new FakeElement(), + addEventListener: () => {}, + removeEventListener: () => {}, + }); + define('window', { + electronAPI: { + focusMainWindow: async () => {}, + setIgnoreMouseEvents: () => {}, + notifyOverlayModalClosed: () => {}, + getSessionBindings: async () => SESSION_BINDINGS, + getSubtitleStyle: async () => ({}), + getMarkWatchedKey: async () => null, + getSubtitleSidebarSnapshot: async () => ({ config: { toggleKey: null } }), + getRuntimeOptions: async () => [], + sendMpvCommand: (command: (string | number)[]) => mpvCommands.push(command), + dispatchSessionAction: async (actionId: string) => { + sessionActions.push(actionId); + if (options.failActions) throw new Error('boom'); + }, + }, + focus: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + setTimeout: (callback: () => void) => setTimeout(callback, 0), + clearTimeout: (id: unknown) => clearTimeout(id as ReturnType), + }); + + const dom = { + overlay: new FakeElement(), + sessionHelpModal: new FakeElement(), + sessionHelpFilter: new FakeElement(), + sessionHelpContent: new FakeElement(), + sessionHelpClose: new FakeElement(), + sessionHelpShortcut: new FakeElement(), + sessionHelpWarning: new FakeElement(), + sessionHelpStatus: new FakeElement(), + }; + const state = createRendererState(); + const modal = createSessionHelpModal( + { + state, + dom, + platform: { + overlayLayer: 'modal', + isModalLayer: true, + isLinuxPlatform: true, + isMacOSPlatform: false, + isWindowsPlatform: false, + shouldToggleMouseIgnore: false, + }, + } as never, + { + modalStateReader: { isAnyModalOpen: () => false }, + syncSettingsModalSubtitleSuppression: () => {}, + }, + ); + modal.wireDomEvents(); + + async function open(commandsEnabled: boolean): Promise { + modal.openSessionHelpModal( + { bindingKey: 'KeyH', fallbackUsed: false, fallbackUnavailable: false }, + { commandsEnabled }, + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + /** Keydown targets the focused element, as in the browser; returns whether default was prevented. */ + function pressEnter(target = (document as unknown as { activeElement: unknown }).activeElement) { + let prevented = false; + modal.handleSessionHelpKeydown({ + key: 'Enter', + target, + ctrlKey: false, + metaKey: false, + altKey: false, + shiftKey: false, + preventDefault: () => { + prevented = true; + }, + } as KeyboardEvent); + return prevented; + } + + const rows = () => dom.sessionHelpContent.querySelectorAll('.session-help-item'); + return { dom, state, open, pressEnter, rows, mpvCommands, sessionActions }; +} + +test('session help rows carry runnable commands except numeric and self-opening actions', () => { + const sections = buildSessionHelpSections({ + sessionBindings: [ + { + sourcePath: 'keybindings[0].key', + originalKey: 'Space', + key: { code: 'Space', modifiers: [] }, + actionType: 'mpv-command', + command: ['cycle', 'pause'], + }, + { + sourcePath: 'shortcuts.copySubtitleMultiple', + originalKey: 'Shift+KeyC', + key: { code: 'KeyC', modifiers: ['shift'] }, + actionType: 'session-action', + actionId: 'copySubtitleMultiple', + }, + { + sourcePath: 'shortcuts.openSessionHelp', + originalKey: 'Slash', + key: { code: 'Slash', modifiers: [] }, + actionType: 'session-action', + actionId: 'openSessionHelp', + }, + ], + markWatchedKey: 'KeyW', + subtitleStyle: {}, + }); + const rows = sections.flatMap((section) => section.rows); + const commandFor = (action: string) => rows.find((row) => row.action === action)?.command; + + assert.deepEqual(commandFor('Toggle playback'), { + actionType: 'mpv-command', + command: ['cycle', 'pause'], + }); + assert.deepEqual(commandFor('Mark video watched'), { + actionType: 'session-action', + actionId: 'markWatched', + }); + assert.equal(commandFor('Copy subtitle (multi)'), undefined); + assert.equal(commandFor('Open session help'), undefined); + assert.equal(commandFor('Toggle primary subtitle bar visibility'), undefined); +}); + +test( + 'session help runs the selected command on Enter during playback and closes', + withFakeDom(async (harness) => { + await harness.open(true); + assert.ok(harness.rows()[0]?.classList.contains('session-help-item-runnable')); + + harness.pressEnter(); + + assert.deepEqual(harness.sessionActions, ['toggleStatsOverlay']); + assert.equal(harness.state.sessionHelpModalOpen, false); + }), +); + +test( + 'session help ignores Enter when no video is playing', + withFakeDom(async (harness) => { + await harness.open(false); + assert.equal(harness.rows()[0]?.classList.contains('session-help-item-runnable'), false); + + harness.pressEnter(); + + assert.deepEqual(harness.sessionActions, []); + assert.equal(harness.state.sessionHelpModalOpen, true); + }), +); + +test( + 'session help hover picks the Enter target and double-click runs the row', + withFakeDom(async (harness) => { + await harness.open(true); + const [statsRow, overlayRow, fixedRow] = harness.rows(); + + harness.dom.sessionHelpContent.dispatch('mousemove', { target: overlayRow }); + assert.equal(harness.state.sessionHelpSelectedIndex, 1); + + harness.dom.sessionHelpContent.dispatch('dblclick', { target: fixedRow }); + assert.equal(harness.state.sessionHelpModalOpen, true); + + harness.dom.sessionHelpContent.dispatch('dblclick', { target: statsRow?.children[0] }); + assert.deepEqual(harness.sessionActions, ['toggleStatsOverlay']); + assert.equal(harness.state.sessionHelpModalOpen, false); + }), +); + +test( + 'session help reports a failed session action on the mpv OSD', + withFakeDom( + async (harness) => { + const originalConsoleError = console.error; + console.error = () => {}; + try { + await harness.open(true); + harness.pressEnter(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } finally { + console.error = originalConsoleError; + } + + assert.deepEqual(harness.mpvCommands, [['show-text', 'Command failed to run', '3000']]); + }, + { failActions: true }, + ), +); + +test( + 'session help does not run a command from Enter pressed in the filter', + withFakeDom(async (harness) => { + await harness.open(true); + // The filter's own listener already moved focus to a row before this bubbles up. + harness.rows()[0]?.focus(); + + harness.pressEnter(harness.dom.sessionHelpFilter); + + assert.deepEqual(harness.sessionActions, []); + assert.equal(harness.state.sessionHelpModalOpen, true); + }), +); + +test( + 'session help leaves Enter on the close button to its native activation', + withFakeDom(async (harness) => { + await harness.open(true); + + const prevented = harness.pressEnter(harness.dom.sessionHelpClose); + + assert.equal(prevented, false); + assert.deepEqual(harness.sessionActions, []); + assert.equal(harness.state.sessionHelpModalOpen, true); + }), +); diff --git a/src/renderer/modals/session-help-render.ts b/src/renderer/modals/session-help-render.ts index ea61a843..da2b9aa9 100644 --- a/src/renderer/modals/session-help-render.ts +++ b/src/renderer/modals/session-help-render.ts @@ -1,6 +1,10 @@ import type { SessionHelpItem, SessionHelpSection } from './session-help-sections'; -function createShortcutRow(row: SessionHelpItem, globalIndex: number): HTMLButtonElement { +function createShortcutRow( + row: SessionHelpItem, + globalIndex: number, + commandsEnabled: boolean, +): HTMLButtonElement { const button = document.createElement('button'); button.type = 'button'; button.className = 'session-help-item'; @@ -28,6 +32,14 @@ function createShortcutRow(row: SessionHelpItem, globalIndex: number): HTMLButto right.insertBefore(dot, action); } + if (commandsEnabled && row.command) { + button.classList.add('session-help-item-runnable'); + const hint = document.createElement('span'); + hint.className = 'session-help-run-hint'; + hint.textContent = 'Run ↵'; + right.appendChild(hint); + } + button.appendChild(left); button.appendChild(right); return button; @@ -54,6 +66,7 @@ export function createSessionHelpSectionNode( section: SessionHelpSection, sectionIndex: number, globalIndexMap: number[], + commandsEnabled: boolean, ): HTMLElement { const sectionNode = document.createElement('section'); sectionNode.className = 'session-help-section'; @@ -69,7 +82,7 @@ export function createSessionHelpSectionNode( section.rows.forEach((row, rowIndex) => { const globalIndex = (globalIndexMap[sectionIndex] ?? 0) + rowIndex; - const button = createShortcutRow(row, globalIndex); + const button = createShortcutRow(row, globalIndex, commandsEnabled); list.appendChild(button); }); diff --git a/src/renderer/modals/session-help-sections.ts b/src/renderer/modals/session-help-sections.ts index 3d740146..e0320aea 100644 --- a/src/renderer/modals/session-help-sections.ts +++ b/src/renderer/modals/session-help-sections.ts @@ -1,4 +1,6 @@ import type { + CompiledMpvCommandBinding, + CompiledSessionActionBinding, CompiledSessionBinding, SessionActionId, SessionKeyModifier, @@ -7,10 +9,17 @@ import type { import { SPECIAL_COMMANDS } from '../../config/definitions/shared'; import { buildColorSection, type SessionHelpSubtitleStyle } from './session-help-colors'; +/** What running a help row does; compiled session bindings satisfy this shape directly. */ +export type SessionHelpCommand = + | Pick + | Pick; + export type SessionHelpItem = { shortcut: string; action: string; color?: string; + /** Set when the help modal can run this row during playback. */ + command?: SessionHelpCommand; }; export type SessionHelpSection = { @@ -287,6 +296,21 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string { } } +// Numeric-count actions wait for a follow-up digit in the overlay, and reopening help is a no-op. +const HELP_UNRUNNABLE_ACTIONS: ReadonlySet = new Set([ + 'copySubtitleMultiple', + 'mineSentenceMultiple', + 'openSessionHelp', +]); + +function toSessionHelpCommand(binding: CompiledSessionBinding): SessionHelpCommand | undefined { + if (binding.actionType === 'mpv-command') { + return { actionType: 'mpv-command', command: binding.command }; + } + if (HELP_UNRUNNABLE_ACTIONS.has(binding.actionId)) return undefined; + return { actionType: 'session-action', actionId: binding.actionId, payload: binding.payload }; +} + function buildSessionBindingSections( sessionBindings: CompiledSessionBinding[], ): SessionHelpSection[] { @@ -294,12 +318,14 @@ function buildSessionBindingSections( for (const binding of sessionBindings) { const section = sectionForSessionBinding(binding); + const command = toSessionHelpCommand(binding); const row: SessionHelpItem = { shortcut: formatSessionKeySpec(binding.key), action: binding.actionType === 'mpv-command' ? describeCommand(binding.command) : describeSessionAction(binding.actionId, binding.payload), + ...(command ? { command } : {}), }; grouped.set(section, [...(grouped.get(section) ?? []), row]); } @@ -337,6 +363,7 @@ function buildConfiguredOverlaySections(input: { statsRows.push({ shortcut: formatKeybinding(input.markWatchedKey), action: 'Mark video watched', + command: { actionType: 'session-action', actionId: 'markWatched' }, }); } @@ -345,6 +372,7 @@ function buildConfiguredOverlaySections(input: { overlayRows.push({ shortcut: formatKeybinding(input.subtitleSidebarToggleKey), action: 'Toggle subtitle sidebar', + command: { actionType: 'session-action', actionId: 'toggleSubtitleSidebar' }, }); } diff --git a/src/renderer/modals/session-help.ts b/src/renderer/modals/session-help.ts index b66fba6d..c7f08f3c 100644 --- a/src/renderer/modals/session-help.ts +++ b/src/renderer/modals/session-help.ts @@ -1,13 +1,16 @@ 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, @@ -70,6 +73,9 @@ export function createSessionHelpModal( 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( @@ -77,7 +83,8 @@ export function createSessionHelpModal( ) as HTMLButtonElement[]; } - function setSelected(index: number): void { + /** 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; @@ -91,6 +98,10 @@ export function createSessionHelpModal( }); const activeItem = items[next]; if (!activeItem) return; + if (fromPointer) { + if (!isFilterInputFocused()) activeItem.focus({ preventScroll: true }); + return; + } activeItem.focus({ preventScroll: true }); activeItem.scrollIntoView({ block: 'nearest', @@ -117,6 +128,7 @@ export function createSessionHelpModal( 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) { @@ -134,7 +146,12 @@ export function createSessionHelpModal( ); } sections.forEach((section, sectionIndex) => { - const sectionNode = createSessionHelpSectionNode(section, sectionIndex, indexOffsets); + const sectionNode = createSessionHelpSectionNode( + section, + sectionIndex, + indexOffsets, + commandsEnabled, + ); ctx.dom.sessionHelpContent.appendChild(sectionNode); }); @@ -156,6 +173,7 @@ export function createSessionHelpModal( function showRenderError(message: string): void { helpSections = []; + visibleRows = []; helpFilterValue = ''; activeTabId = 'essentials'; ctx.dom.sessionHelpFilter.value = ''; @@ -199,12 +217,17 @@ export function createSessionHelpModal( } } - function openSessionHelpModal(opening: SessionHelpBindingInfo): void { + 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'); @@ -238,8 +261,9 @@ export function createSessionHelpModal( 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.'; + 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.'; @@ -284,10 +308,46 @@ export function createSessionHelpModal( 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; - if (isFilterInputFocused()) { + // 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) { @@ -333,6 +393,13 @@ export function createSessionHelpModal( 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; } @@ -350,13 +417,22 @@ export function createSessionHelpModal( }); ctx.dom.sessionHelpContent.addEventListener('click', (event: MouseEvent) => { - const target = event.target; - if (!(target instanceof Element)) return; - const row = target.closest('.session-help-item') as HTMLElement | null; - if (!row) return; - const index = Number.parseInt(row.dataset.sessionHelpIndex ?? '', 10); - if (!Number.isFinite(index)) return; + 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', () => { diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index b7df418c..c52e593a 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -316,7 +316,13 @@ const keyboardHandlers = createKeyboardHandlers(ctx, { handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown, handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown, handleChangelogKeydown: changelogModal.handleChangelogKeydown, - openSessionHelpModal: sessionHelpModal.openSessionHelpModal, + // Route through main so it can gate help commands on mpv playback state; + // onOpenSessionHelp re-resolves the chord hint when the open event arrives. + openSessionHelpModal: () => { + void window.electronAPI.dispatchSessionAction('openSessionHelp').catch((error: unknown) => { + console.error('Could not open session help', error); + }); + }, openControllerSelectModal: () => { if (controllerSelectModal.openControllerSelectModal()) { window.electronAPI.notifyOverlayModalOpened('controller-select'); @@ -572,9 +578,9 @@ function registerModalOpenHandlers(): void { await characterDictionaryModal.openCharacterDictionaryManagerModal(); }); }); - window.electronAPI.onOpenSessionHelp(() => { + window.electronAPI.onOpenSessionHelp((payload) => { runGuarded('session-help:open', () => { - sessionHelpModal.openSessionHelpModal(keyboardHandlers.getSessionHelpOpeningInfo()); + sessionHelpModal.openSessionHelpModal(keyboardHandlers.getSessionHelpOpeningInfo(), payload); window.electronAPI.notifyOverlayModalOpened('session-help'); }); }); diff --git a/src/renderer/style.css b/src/renderer/style.css index 1d761b94..ae6cb15a 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -4125,6 +4125,21 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent { line-height: 1.35; } +/* Runnable rows reveal their hint only on the row Enter would run. */ +.session-help-run-hint { + flex: 0 0 auto; + font-size: 11px; + font-weight: 600; + color: var(--ctp-blue); + white-space: nowrap; + opacity: 0; + transition: opacity 120ms ease; +} + +.session-help-item-runnable.active .session-help-run-hint { + opacity: 1; +} + .session-help-color-dot { width: 10px; height: 10px; diff --git a/src/renderer/utils/mpv-command-dispatch.test.ts b/src/renderer/utils/mpv-command-dispatch.test.ts new file mode 100644 index 00000000..75b45288 --- /dev/null +++ b/src/renderer/utils/mpv-command-dispatch.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { dispatchConfiguredMpvCommand } from './mpv-command-dispatch'; + +async function dispatch(command: (string | number)[], paused: boolean | null | Error) { + const sent: (string | number)[][] = []; + dispatchConfiguredMpvCommand(command, { + getPlaybackPaused: async () => { + if (paused instanceof Error) throw paused; + return paused; + }, + sendMpvCommand: (mpvCommand) => sent.push(mpvCommand), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + return sent; +} + +test('subtitle seeks keep paused or unknown playback paused', async () => { + const repaused = [ + ['sub-seek', 1], + ['set_property', 'pause', 'yes'], + ]; + assert.deepEqual(await dispatch(['sub-seek', 1], true), repaused); + assert.deepEqual(await dispatch(['sub-seek', 1], null), repaused); +}); + +test('subtitle seeks leave running playback alone', async () => { + assert.deepEqual(await dispatch(['sub-seek', -1], false), [['sub-seek', -1]]); + assert.deepEqual(await dispatch(['sub-seek', -1], new Error('ipc down')), [['sub-seek', -1]]); +}); + +test('other mpv commands are sent as-is', async () => { + assert.deepEqual(await dispatch(['cycle', 'pause'], true), [['cycle', 'pause']]); +}); + +test('a failed re-pause does not resend the subtitle seek', async () => { + const sent: (string | number)[][] = []; + const originalConsoleError = console.error; + console.error = () => {}; + try { + dispatchConfiguredMpvCommand(['sub-seek', 1], { + getPlaybackPaused: async () => true, + sendMpvCommand: (command) => { + sent.push(command); + if (command[0] === 'set_property') throw new Error('ipc closed'); + }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + } finally { + console.error = originalConsoleError; + } + + assert.deepEqual(sent, [ + ['sub-seek', 1], + ['set_property', 'pause', 'yes'], + ]); +}); diff --git a/src/renderer/utils/mpv-command-dispatch.ts b/src/renderer/utils/mpv-command-dispatch.ts new file mode 100644 index 00000000..b6f61228 --- /dev/null +++ b/src/renderer/utils/mpv-command-dispatch.ts @@ -0,0 +1,38 @@ +type MpvCommand = (string | number)[]; + +function isSubtitleSeekCommand(command: MpvCommand): command is [string, number] { + return command[0] === 'sub-seek' && typeof command[1] === 'number'; +} + +/** + * Sends a configured mpv command. Subtitle seeks re-pause afterwards unless playback + * is known to be running, so stepping lines from a paused video stays paused. + */ +export function dispatchConfiguredMpvCommand( + command: MpvCommand, + deps: { + getPlaybackPaused: () => Promise; + sendMpvCommand: (command: MpvCommand) => void; + }, +): void { + if (!isSubtitleSeekCommand(command)) { + deps.sendMpvCommand(command); + return; + } + + // The fallback only covers a failed pause lookup, so a failed re-pause never resends the seek. + void deps + .getPlaybackPaused() + .then( + (paused) => { + deps.sendMpvCommand(command); + if (paused !== false) { + deps.sendMpvCommand(['set_property', 'pause', 'yes']); + } + }, + () => { + deps.sendMpvCommand(command); + }, + ) + .catch((error: unknown) => console.error('Could not send mpv command', error)); +} diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 7e3baf23..ae886205 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -434,6 +434,11 @@ export type CharacterDictionaryManagerMutationResult = | (CharacterDictionaryManagerSnapshot & { ok: true; rebuildRequired?: boolean }) | { ok: false; message: string; entries: CharacterDictionaryManagerEntry[] }; +/** Sent with session-help:open; commands only run when a video is loaded in mpv. */ +export interface SessionHelpOpenPayload { + commandsEnabled: boolean; +} + export interface SessionNumericSelectionStartPayload { actionId: Extract; timeoutMs: number; @@ -547,7 +552,7 @@ export interface ElectronAPI { cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => Promise; onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => void; onOpenRuntimeOptions: (callback: () => void) => void; - onOpenSessionHelp: (callback: () => void) => void; + onOpenSessionHelp: (callback: (payload: SessionHelpOpenPayload) => void) => void; onOpenChangelog: (callback: () => void) => void; getChangelogSnapshot: (options?: { refresh?: boolean }) => Promise; onOpenControllerSelect: (callback: () => void) => void;