Merge remote-tracking branch 'origin/main' into add-hachidori-backend

This commit is contained in:
2026-09-29 18:49:49 -07:00
32 changed files with 1122 additions and 117 deletions
+36 -15
View File
@@ -3,6 +3,7 @@ import type {
MediaTimingReviewContextLine,
MediaTimingReviewDecision,
MediaTimingReviewOpenPayload,
MediaTimingReviewPreviewPosition,
} from '../../types/anki';
import type { ModalStateReader, RendererContext } from '../context';
import { createModalFocusGuard } from './modal-focus-guard';
@@ -20,8 +21,8 @@ const LINE_REVEAL_MARGIN_SECONDS = 1;
const SPEECH_LEVEL_THRESHOLD = 0.3;
const SPEECH_TAIL_SECONDS = 0.15;
const MINIMUM_TRAILING_TRIM_SECONDS = 0.1;
/** Slack past the clip length before the UI gives up waiting for mpv's end-of-clip signal. */
const PREVIEW_END_GRACE_MS = 2_500;
/** Allow buffering and output-device delays without cutting off an advancing preview. */
const PREVIEW_STALL_TIMEOUT_MS = 15_000;
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value));
@@ -303,30 +304,49 @@ export function createMediaTimingReviewModal(
previewTimer = null;
}
/**
* Drives the play button label plus the playhead sweep that mirrors the hidden audio player.
* mpv reports when the clip actually finishes (see handlePreviewEnded), which accounts for
* output latency such as Bluetooth headphones; the timer only covers a player that never does.
*/
function setPreviewPlaying(playing: boolean): void {
function armPreviewTimeout(): void {
clearPreviewTimer();
previewTimer = setTimeout(() => {
stopPreview();
setStatus('Audio preview stopped responding. Try playing the selection again.', true);
}, PREVIEW_STALL_TIMEOUT_MS);
}
/** Playback position comes from mpv, so stalls and output latency cannot outrun the cursor. */
function setPreviewPlaying(playing: boolean, finished = false): void {
previewPlaying = playing;
ctx.dom.mediaTimingReviewPlayLabel.textContent = playing ? 'Stop preview' : 'Play selection';
ctx.dom.mediaTimingReviewPlay.classList.toggle('is-playing', playing);
clearPreviewTimer();
const track = ctx.dom.mediaTimingReviewSelectionTrack;
track.classList.remove('is-previewing');
track.classList.toggle('is-previewing', playing || finished);
if (finished) track.style.setProperty('--playhead-position', 'var(--selection-end)');
if (!playing) return;
const clipSeconds = Math.max(MINIMUM_CLIP_SECONDS, selectionEnd - selectionStart);
track.style.setProperty('--playhead-duration', `${clipSeconds}s`);
void track.offsetWidth;
track.classList.add('is-previewing');
previewTimer = setTimeout(() => stopPreview(), clipSeconds * 1000 + PREVIEW_END_GRACE_MS);
track.style.setProperty('--playhead-position', 'var(--selection-start)');
armPreviewTimeout();
}
function handlePreviewPosition(position: MediaTimingReviewPreviewPosition): void {
if (
!payload ||
payload.reviewId !== position.reviewId ||
!previewPlaying ||
!Number.isFinite(position.time)
)
return;
const time = clamp(position.time, selectionStart, selectionEnd);
const span = Math.max(MINIMUM_CLIP_SECONDS, timelineEnd - timelineStart);
ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
'--playhead-position',
`${clamp(((time - timelineStart) / span) * 100, 0, 100)}%`,
);
armPreviewTimeout();
}
/** The hidden player reached the end of the clip and paused itself. */
function handlePreviewEnded(reviewId: string): void {
if (!payload || payload.reviewId !== reviewId || !previewPlaying) return;
setPreviewPlaying(false);
setPreviewPlaying(false, true);
setStatus('');
}
@@ -1026,6 +1046,7 @@ export function createMediaTimingReviewModal(
return {
openMediaTimingReviewModal,
handlePreviewEnded,
handlePreviewPosition,
requestCancel,
handleMediaTimingReviewKeydown,
wireDomEvents,
@@ -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<string, string> = {};
style: Record<string, string> = {};
textContent = '';
value = '';
tabIndex = 0;
type = '';
private classes = new Set<string>();
private listeners = new Map<string, Array<(event: unknown) => 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<string, unknown>): 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<typeof createHarness>) => Promise<void>,
options: HarnessOptions = {},
) {
return async () => {
const globals = globalThis as Record<string, unknown>;
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<typeof setTimeout>),
});
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<void> {
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);
}),
);
+15 -2
View File
@@ -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);
});
@@ -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<CompiledMpvCommandBinding, 'actionType' | 'command'>
| Pick<CompiledSessionActionBinding, 'actionType' | 'actionId' | 'payload'>;
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<SessionActionId> = 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' },
});
}
+88 -12
View File
@@ -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', () => {