mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
Controller: character navigation in keyboard-only mode, plain Xbox face names (#326)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
a8bc585f75
commit
14ef7e823c
25 files changed
+569
-29
No files matched your search
@@ -85,6 +85,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
toggleCharacterNavigation: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
|
||||
@@ -82,6 +82,12 @@ export function buildCoreConfigOptionRegistry(
|
||||
description:
|
||||
'Controller binding descriptor that selects the next word entry in the open Yomitan popup. Mine Card mines the selected entry.',
|
||||
},
|
||||
{
|
||||
id: 'toggleCharacterNavigation',
|
||||
defaultValue: defaultConfig.controller.bindings.toggleCharacterNavigation,
|
||||
description:
|
||||
'Controller binding descriptor that switches keyboard-only mode between moving by word and moving by character, like C. A lookup on a character searches from that character, which reaches words the subtitle split cuts in two.',
|
||||
},
|
||||
{
|
||||
id: 'previousSubtitle',
|
||||
defaultValue: defaultConfig.controller.bindings.previousSubtitle,
|
||||
|
||||
@@ -101,6 +101,7 @@ const CONTROLLER_DISCRETE_BINDING_KEYS = [
|
||||
'holdSecondarySub',
|
||||
'previousEntry',
|
||||
'nextEntry',
|
||||
'toggleCharacterNavigation',
|
||||
'previousSubtitle',
|
||||
'nextSubtitle',
|
||||
'replaySubtitle',
|
||||
|
||||
@@ -76,6 +76,7 @@ function createControllerConfigFixture() {
|
||||
holdSecondarySub: { kind: 'none' as const },
|
||||
previousEntry: { kind: 'none' as const },
|
||||
nextEntry: { kind: 'none' as const },
|
||||
toggleCharacterNavigation: { kind: 'none' as const },
|
||||
previousSubtitle: { kind: 'none' as const },
|
||||
nextSubtitle: { kind: 'none' as const },
|
||||
replaySubtitle: { kind: 'none' as const },
|
||||
|
||||
@@ -69,6 +69,7 @@ const VOID_ACTIONS = [
|
||||
'playCurrentAudio',
|
||||
'previousEntry',
|
||||
'nextEntry',
|
||||
'toggleCharacterNavigation',
|
||||
'toggleMpvPause',
|
||||
'previousSubtitle',
|
||||
'nextSubtitle',
|
||||
@@ -117,6 +118,7 @@ function createHarness(
|
||||
playCurrentAudio: () => {},
|
||||
previousEntry: () => {},
|
||||
nextEntry: () => {},
|
||||
toggleCharacterNavigation: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
previousSubtitle: () => {},
|
||||
nextSubtitle: () => {},
|
||||
@@ -648,6 +650,33 @@ test('gamepad controller waits for a fresh press when the popup opens under a he
|
||||
assert.deepEqual(harness.calls, []);
|
||||
});
|
||||
|
||||
test('gamepad controller switches character navigation once per press in keyboard-only mode', () => {
|
||||
assert.deepEqual(CORE_DEFAULT_CONFIG.controller.bindings.toggleCharacterNavigation, {
|
||||
kind: 'none',
|
||||
});
|
||||
let buttons: Record<number, TestButton> = {};
|
||||
let keyboardMode = true;
|
||||
const harness = createHarness({
|
||||
gamepads: () => [createGamepad('pad-1', { buttons })],
|
||||
config: createControllerConfig({
|
||||
bindings: { toggleCharacterNavigation: { kind: 'button', buttonIndex: 10 } },
|
||||
}),
|
||||
deps: { getKeyboardModeEnabled: () => keyboardMode },
|
||||
});
|
||||
|
||||
harness.poll(0);
|
||||
buttons = { 10: PRESSED };
|
||||
harness.poll(16, 32, 400);
|
||||
buttons = {};
|
||||
harness.poll(416);
|
||||
// Like the other lookup actions, it waits for keyboard-only mode.
|
||||
keyboardMode = false;
|
||||
buttons = { 10: PRESSED };
|
||||
harness.poll(432);
|
||||
|
||||
assert.deepEqual(harness.calls, ['toggleCharacterNavigation']);
|
||||
});
|
||||
|
||||
test('gamepad controller ignores popup entry navigation while the lookup is closed', () => {
|
||||
const harness = createHarness({
|
||||
gamepads: [createGamepad('pad-1', { buttons: { 12: PRESSED, 13: PRESSED } })],
|
||||
|
||||
@@ -57,6 +57,8 @@ type GamepadControllerOptions = {
|
||||
playCurrentAudio: () => void;
|
||||
previousEntry: () => void;
|
||||
nextEntry: () => void;
|
||||
/** Switches keyboard-only mode between moving by word and by character. */
|
||||
toggleCharacterNavigation: () => void;
|
||||
toggleMpvPause: () => void;
|
||||
previousSubtitle: () => void;
|
||||
nextSubtitle: () => void;
|
||||
@@ -419,6 +421,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
['playCurrentAudio', config.bindings.playCurrentAudio],
|
||||
['previousEntry', config.bindings.previousEntry],
|
||||
['nextEntry', config.bindings.nextEntry],
|
||||
['toggleCharacterNavigation', config.bindings.toggleCharacterNavigation],
|
||||
...WATCHING_ACTIONS.map((actionKey) => [actionKey, config.bindings[actionKey]] as const),
|
||||
] as const;
|
||||
|
||||
@@ -580,6 +583,13 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
activeConfig,
|
||||
options.quitMpv,
|
||||
);
|
||||
handleActionEdge(
|
||||
'toggleCharacterNavigation',
|
||||
activeConfig.bindings.toggleCharacterNavigation,
|
||||
activeGamepad,
|
||||
activeConfig,
|
||||
options.toggleCharacterNavigation,
|
||||
);
|
||||
|
||||
const activationThreshold = Math.max(activeConfig.stickDeadzone, 0.55);
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
clampCharacterCursor,
|
||||
countCharacters,
|
||||
moveCharacterCursor,
|
||||
} from './keyboard-character-cursor.js';
|
||||
|
||||
// もっとハ | キ | ハキ: the split that hides ハキハキ from word navigation.
|
||||
const LENGTHS = [4, 1, 2];
|
||||
|
||||
test('the character cursor walks a word and crosses into the next one', () => {
|
||||
assert.deepEqual(moveCharacterCursor(LENGTHS, { wordIndex: 0, charIndex: 2 }, 1), {
|
||||
position: { wordIndex: 0, charIndex: 3 },
|
||||
result: 'moved',
|
||||
});
|
||||
assert.deepEqual(moveCharacterCursor(LENGTHS, { wordIndex: 0, charIndex: 3 }, 1), {
|
||||
position: { wordIndex: 1, charIndex: 0 },
|
||||
result: 'moved',
|
||||
});
|
||||
assert.deepEqual(moveCharacterCursor(LENGTHS, { wordIndex: 2, charIndex: 0 }, -1), {
|
||||
position: { wordIndex: 1, charIndex: 0 },
|
||||
result: 'moved',
|
||||
});
|
||||
assert.deepEqual(moveCharacterCursor(LENGTHS, { wordIndex: 1, charIndex: 0 }, -1), {
|
||||
position: { wordIndex: 0, charIndex: 3 },
|
||||
result: 'moved',
|
||||
});
|
||||
});
|
||||
|
||||
test('the character cursor reports the line edges and stays put there', () => {
|
||||
assert.deepEqual(moveCharacterCursor(LENGTHS, { wordIndex: 0, charIndex: 0 }, -1), {
|
||||
position: { wordIndex: 0, charIndex: 0 },
|
||||
result: 'start-boundary',
|
||||
});
|
||||
assert.deepEqual(moveCharacterCursor(LENGTHS, { wordIndex: 2, charIndex: 1 }, 1), {
|
||||
position: { wordIndex: 2, charIndex: 1 },
|
||||
result: 'end-boundary',
|
||||
});
|
||||
assert.equal(moveCharacterCursor([], { wordIndex: 0, charIndex: 0 }, 1).result, 'no-words');
|
||||
});
|
||||
|
||||
test('words without characters are skipped', () => {
|
||||
assert.deepEqual(moveCharacterCursor([1, 0, 2], { wordIndex: 0, charIndex: 0 }, 1).position, {
|
||||
wordIndex: 2,
|
||||
charIndex: 0,
|
||||
});
|
||||
assert.deepEqual(moveCharacterCursor([1, 0, 2], { wordIndex: 2, charIndex: 0 }, -1).position, {
|
||||
wordIndex: 0,
|
||||
charIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('a position past the end lands on the last character', () => {
|
||||
assert.deepEqual(
|
||||
clampCharacterCursor(LENGTHS, { wordIndex: 9, charIndex: Number.MAX_SAFE_INTEGER }),
|
||||
{ wordIndex: 2, charIndex: 1 },
|
||||
);
|
||||
assert.equal(clampCharacterCursor([], { wordIndex: 0, charIndex: 0 }), null);
|
||||
});
|
||||
|
||||
test('characters are counted as code points', () => {
|
||||
assert.equal(countCharacters('𠮷野家'), 3);
|
||||
assert.equal(countCharacters('ハキハキ'), 4);
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Character navigation for keyboard-only mode: instead of the word boxes, the cursor sits on
|
||||
* one character, and a lookup searches from it. That reaches words the subtitle split cuts in
|
||||
* two (もっとハ|キ|ハキ… hides ハキハキ from word navigation).
|
||||
*/
|
||||
|
||||
/** The marked character, an element wrapping it inside its word span. */
|
||||
export const KEYBOARD_SELECTED_CHAR_CLASS = 'keyboard-selected-char';
|
||||
/** On the subtitle root while the character cursor is shown. */
|
||||
export const KEYBOARD_CHARACTER_NAVIGATION_CLASS = 'keyboard-character-navigation';
|
||||
|
||||
/** A word span (by index among the line's words) and a character in it. */
|
||||
export type CharacterCursorPosition = { wordIndex: number; charIndex: number };
|
||||
export type CharacterCursorMoveResult = 'moved' | 'start-boundary' | 'end-boundary' | 'no-words';
|
||||
|
||||
/** Characters are counted as code points, so a surrogate pair is one character. */
|
||||
export function countCharacters(text: string): number {
|
||||
return Array.from(text).length;
|
||||
}
|
||||
|
||||
/** The position kept inside the line; `charIndex` past the end lands on the last character. */
|
||||
export function clampCharacterCursor(
|
||||
wordLengths: readonly number[],
|
||||
position: CharacterCursorPosition,
|
||||
): CharacterCursorPosition | null {
|
||||
if (wordLengths.length === 0) return null;
|
||||
const wordIndex = Math.min(Math.max(position.wordIndex, 0), wordLengths.length - 1);
|
||||
const lastChar = Math.max((wordLengths[wordIndex] ?? 0) - 1, 0);
|
||||
const charIndex = Math.min(Math.max(position.charIndex, 0), lastChar);
|
||||
return { wordIndex, charIndex };
|
||||
}
|
||||
|
||||
/** One character left or right, crossing into the neighbouring word at its edges. */
|
||||
export function moveCharacterCursor(
|
||||
wordLengths: readonly number[],
|
||||
position: CharacterCursorPosition,
|
||||
delta: -1 | 1,
|
||||
): { position: CharacterCursorPosition; result: CharacterCursorMoveResult } {
|
||||
const start = clampCharacterCursor(wordLengths, position);
|
||||
if (!start) return { position, result: 'no-words' };
|
||||
|
||||
let { wordIndex } = start;
|
||||
let charIndex = start.charIndex + delta;
|
||||
for (;;) {
|
||||
if (charIndex >= 0 && charIndex < (wordLengths[wordIndex] ?? 0)) {
|
||||
return { position: { wordIndex, charIndex }, result: 'moved' };
|
||||
}
|
||||
wordIndex += delta;
|
||||
if (wordIndex < 0) return { position: start, result: 'start-boundary' };
|
||||
if (wordIndex >= wordLengths.length) return { position: start, result: 'end-boundary' };
|
||||
// Empty words (an image only) have no character to stop on and are skipped.
|
||||
charIndex = delta > 0 ? 0 : (wordLengths[wordIndex] ?? 0) - 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Marks and unmarks characters in the rendered subtitle. */
|
||||
export type CharacterCursorPainter = {
|
||||
getLength: (word: HTMLElement) => number;
|
||||
/** Wraps one character of `word` in a marker element and returns it. */
|
||||
paint: (word: HTMLElement, charIndex: number) => HTMLElement | null;
|
||||
/** Removes every marker under `root`, restoring the plain text; true if there was one. */
|
||||
clear: (root: HTMLElement) => boolean;
|
||||
/** Range from the marker to the end of `lastWord`: the text a lookup searches. */
|
||||
rangeToEnd: (marker: HTMLElement, lastWord: HTMLElement) => Range;
|
||||
};
|
||||
|
||||
function findCharacter(
|
||||
word: HTMLElement,
|
||||
charIndex: number,
|
||||
): { node: Text; start: number; end: number } | null {
|
||||
const walker = document.createTreeWalker(word, NodeFilter.SHOW_TEXT);
|
||||
let remaining = charIndex;
|
||||
for (let node = walker.nextNode() as Text | null; node; node = walker.nextNode() as Text | null) {
|
||||
const chars = Array.from(node.data);
|
||||
if (remaining < chars.length) {
|
||||
const start = chars.slice(0, remaining).join('').length;
|
||||
return { node, start, end: start + (chars[remaining]?.length ?? 1) };
|
||||
}
|
||||
remaining -= chars.length;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createDomCharacterCursorPainter(): CharacterCursorPainter {
|
||||
return {
|
||||
getLength: (word) => countCharacters(word.textContent ?? ''),
|
||||
paint: (word, charIndex) => {
|
||||
const found = findCharacter(word, charIndex);
|
||||
if (!found) return null;
|
||||
const range = document.createRange();
|
||||
range.setStart(found.node, found.start);
|
||||
range.setEnd(found.node, found.end);
|
||||
const marker = document.createElement('span');
|
||||
marker.className = KEYBOARD_SELECTED_CHAR_CLASS;
|
||||
range.surroundContents(marker);
|
||||
return marker;
|
||||
},
|
||||
clear: (root) => {
|
||||
const markers = Array.from(
|
||||
root.querySelectorAll<HTMLElement>(`.${KEYBOARD_SELECTED_CHAR_CLASS}`),
|
||||
);
|
||||
for (const marker of markers) {
|
||||
const parent = marker.parentNode;
|
||||
if (!parent) continue;
|
||||
while (marker.firstChild) parent.insertBefore(marker.firstChild, marker);
|
||||
parent.removeChild(marker);
|
||||
parent.normalize();
|
||||
}
|
||||
return markers.length > 0;
|
||||
},
|
||||
rangeToEnd: (marker, lastWord) => {
|
||||
const range = document.createRange();
|
||||
range.setStart(marker, 0);
|
||||
range.setEnd(lastWord, lastWord.childNodes.length);
|
||||
return range;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -152,6 +152,7 @@ function installKeyboardTestGlobals() {
|
||||
const openedModalNotifications: string[] = [];
|
||||
let selectionClearCount = 0;
|
||||
let selectionAddCount = 0;
|
||||
let mutationRecordsTaken = 0;
|
||||
|
||||
let popupVisible = false;
|
||||
let hachidoriPaneOpen = false;
|
||||
@@ -311,6 +312,10 @@ function installKeyboardTestGlobals() {
|
||||
configurable: true,
|
||||
value: class {
|
||||
observe() {}
|
||||
takeRecords() {
|
||||
mutationRecordsTaken += 1;
|
||||
return [];
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -522,6 +527,7 @@ function installKeyboardTestGlobals() {
|
||||
},
|
||||
selectionClearCount: () => selectionClearCount,
|
||||
selectionAddCount: () => selectionAddCount,
|
||||
mutationRecordsTaken: () => mutationRecordsTaken,
|
||||
restore,
|
||||
};
|
||||
}
|
||||
@@ -540,8 +546,9 @@ function createKeyboardHandlerHarness() {
|
||||
|
||||
// Synthetic pointer/mouse events dispatched on words: the lookup's fallback scan.
|
||||
let wordEventCount = 0;
|
||||
const createWordNode = (left: number) => ({
|
||||
const createWordNode = (left: number, textContent = 'ab') => ({
|
||||
classList: createClassList(),
|
||||
textContent,
|
||||
getBoundingClientRect: () => ({ left, top: 10, width: 30, height: 20 }),
|
||||
dispatchEvent: () => {
|
||||
wordEventCount += 1;
|
||||
@@ -549,6 +556,23 @@ function createKeyboardHandlerHarness() {
|
||||
},
|
||||
});
|
||||
let wordNodes = [createWordNode(10), createWordNode(80), createWordNode(150)];
|
||||
// Records the character cursor instead of wrapping text in a real DOM.
|
||||
const characterCursorLog: string[] = [];
|
||||
const characterCursor = {
|
||||
getLength: (word: { textContent: string }) => Array.from(word.textContent).length,
|
||||
paint: (word: unknown, charIndex: number) => {
|
||||
characterCursorLog.push(`paint:${wordNodes.indexOf(word as never)}:${charIndex}`);
|
||||
return {
|
||||
getBoundingClientRect: () => ({ left: 0, top: 10, width: 10, height: 20 }),
|
||||
dispatchEvent: () => true,
|
||||
};
|
||||
},
|
||||
clear: () => false,
|
||||
rangeToEnd: (_marker: unknown, lastWord: unknown) => {
|
||||
characterCursorLog.push(`rangeToEnd:${wordNodes.indexOf(lastWord as never)}`);
|
||||
return {};
|
||||
},
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
@@ -610,12 +634,18 @@ function createKeyboardHandlerHarness() {
|
||||
openControllerDebugCount += 1;
|
||||
},
|
||||
getPlaybackPaused: () => testGlobals.getPlaybackPaused(),
|
||||
characterCursor: characterCursor as never,
|
||||
});
|
||||
|
||||
return {
|
||||
ctx,
|
||||
handlers,
|
||||
testGlobals,
|
||||
characterCursorLog,
|
||||
setWordTexts: (texts: string[]) => {
|
||||
wordNodes = texts.map((text, index) => createWordNode(10 + index * 70, text));
|
||||
},
|
||||
wordNodes: () => wordNodes,
|
||||
mediaTimingReviewKeydownCount: () => mediaTimingReviewKeydownCount,
|
||||
controllerSelectKeydownCount: () => controllerSelectKeydownCount,
|
||||
openControllerSelectCount: () => openControllerSelectCount,
|
||||
@@ -913,6 +943,120 @@ test('keyboard mode: left and right move token selection while popup remains ope
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: C switches between moving by word and by character', async () => {
|
||||
const { ctx, handlers, testGlobals, characterCursorLog, setWordTexts, wordNodes } =
|
||||
createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
setWordTexts(['もっとハ', 'キ', 'ハキ']);
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
assert.equal(testGlobals.dispatchKeydown({ key: 'c', code: 'KeyC' }), true);
|
||||
assert.equal(ctx.state.keyboardCharacterNavigation, true);
|
||||
assert.equal(ctx.dom.subtitleRoot.classList.contains('keyboard-character-navigation'), true);
|
||||
assert.equal(wordNodes()[0]!.classList.contains('keyboard-selected'), false);
|
||||
// The box colour is the only sign: no message on screen.
|
||||
assert.deepEqual(testGlobals.mpvCommands, []);
|
||||
assert.equal(characterCursorLog.at(-1), 'paint:0:0');
|
||||
// Marking the character edits the subtitle, which the subtitle observer answers with
|
||||
// another sync: its own edits must be dropped, or the two loop forever.
|
||||
assert.ok(testGlobals.mutationRecordsTaken() > 0);
|
||||
|
||||
// The cursor walks the characters, crossing into the next word at its end.
|
||||
for (let i = 0; i < 4; i += 1) testGlobals.dispatchKeydown({ key: 'l', code: 'KeyL' });
|
||||
assert.deepEqual(
|
||||
[ctx.state.keyboardSelectedWordIndex, ctx.state.keyboardSelectedCharIndex],
|
||||
[1, 0],
|
||||
);
|
||||
testGlobals.dispatchKeydown({ key: 'ArrowLeft', code: 'ArrowLeft' });
|
||||
assert.equal(characterCursorLog.at(-1), 'paint:0:3');
|
||||
|
||||
// Back to words: the box covers the word that holds the character.
|
||||
testGlobals.dispatchKeydown({ key: 'c', code: 'KeyC' });
|
||||
assert.equal(ctx.state.keyboardCharacterNavigation, false);
|
||||
assert.equal(wordNodes()[0]!.classList.contains('keyboard-selected'), true);
|
||||
assert.equal(ctx.dom.subtitleRoot.classList.contains('keyboard-character-navigation'), false);
|
||||
assert.deepEqual(testGlobals.mpvCommands, []);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: character navigation lasts until toggled, even across keyboard mode', async () => {
|
||||
const { ctx, handlers, testGlobals, characterCursorLog, setWordTexts } =
|
||||
createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
setWordTexts(['もっとハ', 'キ', 'ハキ']);
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
testGlobals.dispatchKeydown({ key: 'c', code: 'KeyC' });
|
||||
// Leave the mode with the cursor in the middle of the first word.
|
||||
for (let i = 0; i < 3; i += 1) testGlobals.dispatchKeydown({ key: 'l', code: 'KeyL' });
|
||||
assert.equal(characterCursorLog.at(-1), 'paint:0:3');
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
assert.equal(ctx.dom.subtitleRoot.classList.contains('keyboard-character-navigation'), false);
|
||||
|
||||
// Coming back starts at the beginning of the line, as word mode does.
|
||||
characterCursorLog.length = 0;
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
assert.equal(ctx.state.keyboardCharacterNavigation, true);
|
||||
assert.equal(characterCursorLog.at(-1), 'paint:0:0');
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('C only switches navigation in keyboard mode, without modifiers or a pending chord', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
testGlobals.dispatchKeydown({ key: 'c', code: 'KeyC' });
|
||||
assert.equal(ctx.state.keyboardCharacterNavigation, false, 'outside keyboard mode');
|
||||
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
testGlobals.dispatchKeydown({ key: 'C', code: 'KeyC', shiftKey: true });
|
||||
testGlobals.dispatchKeydown({ key: 'c', code: 'KeyC', ctrlKey: true });
|
||||
assert.equal(ctx.state.keyboardCharacterNavigation, false, 'with a modifier');
|
||||
|
||||
// `C` is also the second key of a chord.
|
||||
ctx.state.chordPending = true;
|
||||
testGlobals.dispatchKeydown({ key: 'c', code: 'KeyC' });
|
||||
assert.equal(ctx.state.keyboardCharacterNavigation, false, 'during a chord');
|
||||
assert.equal(ctx.state.chordPending, false);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: a lookup on a character searches from it to the end of the line', async () => {
|
||||
const { ctx, handlers, testGlobals, characterCursorLog, setWordTexts } =
|
||||
createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
setWordTexts(['もっとハ', 'キ', 'ハキ']);
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
testGlobals.dispatchKeydown({ key: 'c', code: 'KeyC' });
|
||||
for (let i = 0; i < 3; i += 1) testGlobals.dispatchKeydown({ key: 'l', code: 'KeyL' });
|
||||
const added = testGlobals.selectionAddCount();
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY', ctrlKey: true });
|
||||
|
||||
assert.deepEqual(characterCursorLog.slice(-2), ['paint:0:3', 'rangeToEnd:2']);
|
||||
assert.equal(testGlobals.selectionAddCount(), added + 1);
|
||||
assert.equal(testGlobals.commandEvents.at(-1)?.type, 'scanSelectedText');
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: up/down/j/k do not open or close lookup when popup is closed', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@ import {
|
||||
isYomitanPopupVisible,
|
||||
isYomitanPopupIframe,
|
||||
} from '../yomitan-popup.js';
|
||||
import {
|
||||
KEYBOARD_CHARACTER_NAVIGATION_CLASS,
|
||||
createDomCharacterCursorPainter,
|
||||
moveCharacterCursor,
|
||||
type CharacterCursorPainter,
|
||||
} from './keyboard-character-cursor.js';
|
||||
|
||||
export function createKeyboardHandlers(
|
||||
ctx: RendererContext,
|
||||
@@ -44,8 +50,15 @@ export function createKeyboardHandlers(
|
||||
/** Keyboard-only mode turned on or off (keyboard shortcut or controller). */
|
||||
onKeyboardDrivenModeChanged?: (enabled: boolean) => void;
|
||||
toggleSubtitleSidebarModal?: () => void;
|
||||
/** Marks the character cursor in the subtitle; replaced in tests. */
|
||||
characterCursor?: CharacterCursorPainter;
|
||||
},
|
||||
) {
|
||||
const characterCursor = options.characterCursor ?? createDomCharacterCursorPainter();
|
||||
/** The marked character of the character cursor, while it is shown. */
|
||||
let characterCursorMarker: HTMLElement | null = null;
|
||||
/** Re-syncs the selection when the subtitle changes; set up with the other listeners. */
|
||||
let subtitleMutationObserver: MutationObserver | null = null;
|
||||
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
|
||||
const CHORD_TIMEOUT_MS = 1000;
|
||||
const MPV_INPUT_FORWARDING_CONFIG_LOAD_TIMEOUT_MS = 50;
|
||||
@@ -502,9 +515,29 @@ export function createKeyboardHandlers(
|
||||
ctx.dom.subtitleRoot.classList.remove('has-selection');
|
||||
}
|
||||
|
||||
function isCharacterNavigationActive(): boolean {
|
||||
return ctx.state.keyboardDrivenModeEnabled && ctx.state.keyboardCharacterNavigation;
|
||||
}
|
||||
|
||||
function syncKeyboardTokenSelection(): void {
|
||||
// Marking a character edits the subtitle, which the observer below answers with another
|
||||
// sync; drop the notifications for our own edits so the two do not loop.
|
||||
if (applyKeyboardTokenSelection()) subtitleMutationObserver?.takeRecords();
|
||||
}
|
||||
|
||||
/** Shows the word box or the character cursor; true if the subtitle markup changed. */
|
||||
function applyKeyboardTokenSelection(): boolean {
|
||||
const wordNodes = getSubtitleWordNodes();
|
||||
clearKeyboardSelectedWordClasses(wordNodes);
|
||||
const clearedCursor = characterCursor.clear(ctx.dom.subtitleRoot);
|
||||
characterCursorMarker = null;
|
||||
// Lookups select from the character to the end of the line; this class keeps that
|
||||
// selection from recolouring the rest of the line, so only the cursor box shows.
|
||||
if (isCharacterNavigationActive()) {
|
||||
ctx.dom.subtitleRoot.classList.add(KEYBOARD_CHARACTER_NAVIGATION_CLASS);
|
||||
} else {
|
||||
ctx.dom.subtitleRoot.classList.remove(KEYBOARD_CHARACTER_NAVIGATION_CLASS);
|
||||
}
|
||||
|
||||
if (!ctx.state.keyboardDrivenModeEnabled || wordNodes.length === 0) {
|
||||
ctx.state.keyboardSelectedWordIndex = null;
|
||||
@@ -515,12 +548,14 @@ export function createKeyboardHandlers(
|
||||
resetSelectionToStartOnNextSubtitleSync = false;
|
||||
clearNativeSubtitleSelection();
|
||||
}
|
||||
return;
|
||||
return clearedCursor;
|
||||
}
|
||||
|
||||
if (pendingSelectionAnchorAfterSubtitleSeek) {
|
||||
ctx.state.keyboardSelectedWordIndex =
|
||||
pendingSelectionAnchorAfterSubtitleSeek === 'start' ? 0 : wordNodes.length - 1;
|
||||
ctx.state.keyboardSelectedCharIndex =
|
||||
pendingSelectionAnchorAfterSubtitleSeek === 'start' ? 0 : Number.MAX_SAFE_INTEGER;
|
||||
ctx.state.keyboardSelectionVisible = true;
|
||||
pendingSelectionAnchorAfterSubtitleSeek = null;
|
||||
resetSelectionToStartOnNextSubtitleSync = false;
|
||||
@@ -536,6 +571,7 @@ export function createKeyboardHandlers(
|
||||
|
||||
if (resetSelectionToStartOnNextSubtitleSync) {
|
||||
ctx.state.keyboardSelectedWordIndex = 0;
|
||||
ctx.state.keyboardSelectedCharIndex = 0;
|
||||
ctx.state.keyboardSelectionVisible = true;
|
||||
resetSelectionToStartOnNextSubtitleSync = false;
|
||||
}
|
||||
@@ -546,9 +582,21 @@ export function createKeyboardHandlers(
|
||||
);
|
||||
ctx.state.keyboardSelectedWordIndex = selectedIndex;
|
||||
const selectedWordNode = wordNodes[selectedIndex];
|
||||
if (selectedWordNode && ctx.state.keyboardSelectionVisible) {
|
||||
if (!selectedWordNode || !ctx.state.keyboardSelectionVisible) return clearedCursor;
|
||||
if (!isCharacterNavigationActive()) {
|
||||
selectedWordNode.classList.add(KEYBOARD_SELECTED_WORD_CLASS);
|
||||
return clearedCursor;
|
||||
}
|
||||
const length = characterCursor.getLength(selectedWordNode);
|
||||
ctx.state.keyboardSelectedCharIndex = Math.min(
|
||||
Math.max(ctx.state.keyboardSelectedCharIndex, 0),
|
||||
Math.max(length - 1, 0),
|
||||
);
|
||||
characterCursorMarker =
|
||||
length > 0
|
||||
? characterCursor.paint(selectedWordNode, ctx.state.keyboardSelectedCharIndex)
|
||||
: null;
|
||||
return clearedCursor || characterCursorMarker !== null;
|
||||
}
|
||||
|
||||
function setKeyboardDrivenModeEnabled(enabled: boolean): void {
|
||||
@@ -557,6 +605,9 @@ export function createKeyboardHandlers(
|
||||
ctx.state.keyboardSelectionVisible = enabled;
|
||||
if (!enabled) {
|
||||
ctx.state.keyboardSelectedWordIndex = null;
|
||||
// Character navigation itself stays on, but like the word cursor it restarts at the
|
||||
// beginning of the line next time.
|
||||
ctx.state.keyboardSelectedCharIndex = 0;
|
||||
pendingSelectionAnchorAfterSubtitleSeek = null;
|
||||
pendingLookupRefreshAfterSubtitleSeek = false;
|
||||
resetSelectionToStartOnNextSubtitleSync = false;
|
||||
@@ -583,6 +634,24 @@ export function createKeyboardHandlers(
|
||||
return 'no-words';
|
||||
}
|
||||
|
||||
if (isCharacterNavigationActive()) {
|
||||
const { position, result } = moveCharacterCursor(
|
||||
wordNodes.map((wordNode) => characterCursor.getLength(wordNode)),
|
||||
{
|
||||
wordIndex: ctx.state.keyboardSelectedWordIndex ?? 0,
|
||||
charIndex: ctx.state.keyboardSelectedCharIndex,
|
||||
},
|
||||
delta,
|
||||
);
|
||||
if (result === 'moved') {
|
||||
ctx.state.keyboardSelectedWordIndex = position.wordIndex;
|
||||
ctx.state.keyboardSelectedCharIndex = position.charIndex;
|
||||
ctx.state.keyboardSelectionVisible = true;
|
||||
syncKeyboardTokenSelection();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const currentIndex = Math.min(
|
||||
Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0),
|
||||
wordNodes.length - 1,
|
||||
@@ -679,15 +748,40 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
|
||||
function selectWordNodeText(wordNode: HTMLElement): void {
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(wordNode);
|
||||
selectSubtitleRange(range);
|
||||
}
|
||||
|
||||
function selectSubtitleRange(range: Range): void {
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
ctx.dom.subtitleRoot.classList.add('has-selection');
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches keyboard-only mode between moving by word and by character (`C`, or the
|
||||
* controller binding). The cursor starts on the first character of the selected word, and
|
||||
* switching back selects the word holding the character. Kept until toggled back.
|
||||
*/
|
||||
function toggleCharacterNavigation(): boolean {
|
||||
if (!ctx.state.keyboardDrivenModeEnabled) return false;
|
||||
ctx.state.keyboardCharacterNavigation = !ctx.state.keyboardCharacterNavigation;
|
||||
ctx.state.keyboardSelectedCharIndex = 0;
|
||||
ctx.state.keyboardSelectionVisible = true;
|
||||
syncKeyboardTokenSelection();
|
||||
if (isLookupPopupOpen(ctx.state, document)) triggerLookupForSelectedWord();
|
||||
return true;
|
||||
}
|
||||
|
||||
function isCharacterNavigationToggle(e: KeyboardEvent): boolean {
|
||||
if (!ctx.state.keyboardDrivenModeEnabled || ctx.state.chordPending) return false;
|
||||
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return false;
|
||||
return e.code === 'KeyC';
|
||||
}
|
||||
|
||||
function triggerLookupForSelectedWord(): boolean {
|
||||
const wordNodes = getSubtitleWordNodes();
|
||||
if (wordNodes.length === 0) {
|
||||
@@ -704,9 +798,18 @@ export function createKeyboardHandlers(
|
||||
|
||||
ctx.state.keyboardSelectionVisible = true;
|
||||
syncKeyboardTokenSelection();
|
||||
selectWordNodeText(selectedWordNode);
|
||||
// On a character, search from it to the end of the line: Yomitan takes the longest word
|
||||
// that starts there, whatever the word boxes say.
|
||||
const marker = characterCursorMarker;
|
||||
const lastWordNode = wordNodes[wordNodes.length - 1];
|
||||
const scanTarget = marker ?? selectedWordNode;
|
||||
if (marker && lastWordNode) {
|
||||
selectSubtitleRange(characterCursor.rangeToEnd(marker, lastWordNode));
|
||||
} else {
|
||||
selectWordNodeText(selectedWordNode);
|
||||
}
|
||||
|
||||
const rect = selectedWordNode.getBoundingClientRect();
|
||||
const rect = scanTarget.getBoundingClientRect();
|
||||
const clientX = rect.left + rect.width / 2;
|
||||
const clientY = rect.top + rect.height / 2;
|
||||
|
||||
@@ -727,7 +830,7 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
// Dispatch directly on the selected token span; when overlay pointer-events are disabled,
|
||||
// elementFromPoint may resolve to the underlying video surface instead.
|
||||
emitLookupScanFallback(selectedWordNode, clientX, clientY);
|
||||
emitLookupScanFallback(scanTarget, clientX, clientY);
|
||||
}, 60);
|
||||
return true;
|
||||
}
|
||||
@@ -1113,7 +1216,7 @@ export function createKeyboardHandlers(
|
||||
});
|
||||
document.addEventListener('keyup', importedMpvBindings.keyup, true);
|
||||
|
||||
const subtitleMutationObserver = new MutationObserver(() => {
|
||||
subtitleMutationObserver = new MutationObserver(() => {
|
||||
syncKeyboardTokenSelection();
|
||||
});
|
||||
subtitleMutationObserver.observe(ctx.dom.subtitleRoot, {
|
||||
@@ -1291,6 +1394,13 @@ export function createKeyboardHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
// Before popup keybinds, so it also works while the popup is open.
|
||||
if (isCharacterNavigationToggle(e)) {
|
||||
e.preventDefault();
|
||||
if (!e.repeat) toggleCharacterNavigation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLookupPopupOpen(ctx.state, document)) {
|
||||
if (handleYomitanPopupKeybind(e)) {
|
||||
e.preventDefault();
|
||||
@@ -1413,6 +1523,7 @@ export function createKeyboardHandlers(
|
||||
handleLookupWindowToggleRequested,
|
||||
closeLookupWindow,
|
||||
moveSelectionForController,
|
||||
toggleCharacterNavigationForController: toggleCharacterNavigation,
|
||||
forwardPopupKeydownForController,
|
||||
mineSelectedFromController,
|
||||
cyclePopupAudioSourceForController,
|
||||
|
||||
@@ -24,6 +24,7 @@ const BINDINGS: ResolvedControllerConfig['bindings'] = {
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
toggleCharacterNavigation: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
@@ -166,12 +167,12 @@ test('controller config form renders grouped rows with friendly binding labels',
|
||||
findOne(row, 'controller-config-badge').textContent,
|
||||
]),
|
||||
[
|
||||
['Toggle Lookup', 'A / Cross'],
|
||||
['Close Lookup', 'B / Circle'],
|
||||
['Toggle Lookup', 'A'],
|
||||
['Close Lookup', 'B'],
|
||||
['Previous Entry', 'None'],
|
||||
['Next Entry', 'None'],
|
||||
['Mine Card', 'X / Square'],
|
||||
['Toggle Keyboard-Only Mode', 'Y / Triangle'],
|
||||
['Mine Card', 'X'],
|
||||
['Toggle Keyboard-Only Mode', 'Y'],
|
||||
['Toggle MPV Pause', 'R3 / RS'],
|
||||
['Show Secondary Subtitle (Hold)', 'None'],
|
||||
['Quit MPV', 'Back / Select'],
|
||||
@@ -182,6 +183,7 @@ test('controller config form renders grouped rows with friendly binding labels',
|
||||
['Previous Audio', 'None'],
|
||||
['Next Audio', 'RB / R1'],
|
||||
['Play Current Audio', 'LB / L1'],
|
||||
['Toggle Character Navigation', 'None'],
|
||||
['Token Move (Stick)', 'Left Stick X'],
|
||||
['Token Move (D-pad)', 'D-pad ↔'],
|
||||
['Popup Scroll (Stick)', 'Left Stick Y'],
|
||||
@@ -194,7 +196,7 @@ test('controller config form renders grouped rows with friendly binding labels',
|
||||
);
|
||||
assert.deepEqual(
|
||||
findAll(container, 'disabled').map((badge) => badge.textContent),
|
||||
['None', 'None', 'None', 'None', 'None', 'None', 'None', 'None', 'None', 'None'],
|
||||
['None', 'None', 'None', 'None', 'None', 'None', 'None', 'None', 'None', 'None', 'None'],
|
||||
);
|
||||
assert.deepEqual(findAll(container, 'expanded'), []);
|
||||
assert.deepEqual(findAll(container, 'controller-config-edit-panel'), []);
|
||||
|
||||
@@ -156,6 +156,13 @@ export const CONTROLLER_BINDING_DEFINITIONS: ControllerBindingDefinition[] = [
|
||||
bindingType: 'discrete',
|
||||
defaultBinding: { kind: 'button', buttonIndex: 4 },
|
||||
},
|
||||
{
|
||||
id: 'toggleCharacterNavigation',
|
||||
label: 'Toggle Character Navigation',
|
||||
group: 'Navigation',
|
||||
bindingType: 'discrete',
|
||||
defaultBinding: { kind: 'none' },
|
||||
},
|
||||
{
|
||||
id: 'leftStickHorizontal',
|
||||
label: 'Token Move',
|
||||
@@ -212,10 +219,6 @@ export function getDefaultDpadFallback(
|
||||
// Names for pads that do not report the W3C "standard" mapping. Raw layouts
|
||||
// vary by driver, so these are a best guess.
|
||||
const RAW_LAYOUT_BUTTON_NAMES: Record<number, string> = {
|
||||
0: 'A / Cross',
|
||||
1: 'B / Circle',
|
||||
2: 'X / Square',
|
||||
3: 'Y / Triangle',
|
||||
4: 'LB / L1',
|
||||
5: 'RB / R1',
|
||||
6: 'Back / Select',
|
||||
@@ -244,10 +247,6 @@ const RAW_LAYOUT_AXIS_NAMES: Record<number, string> = {
|
||||
// Switch and 8BitDo pads). Index meanings are fixed by the W3C Gamepad spec:
|
||||
// https://w3c.github.io/gamepad/#remapping
|
||||
const W3C_STANDARD_BUTTON_NAMES: Record<number, string> = {
|
||||
0: 'A / Cross',
|
||||
1: 'B / Circle',
|
||||
2: 'X / Square',
|
||||
3: 'Y / Triangle',
|
||||
4: 'LB / L1',
|
||||
5: 'RB / R1',
|
||||
6: 'LT / L2',
|
||||
@@ -284,13 +283,10 @@ export function getFriendlyButtonName(
|
||||
mapping?: ControllerMapping,
|
||||
faceButtonLabels?: FaceButtonLabels,
|
||||
): string {
|
||||
// The default Xbox style keeps the shared "A / Cross" names; the others print their own.
|
||||
// Face positions 0-3 are the same on raw and standard layouts.
|
||||
const styledName =
|
||||
faceButtonLabels && faceButtonLabels !== 'xbox'
|
||||
? getControllerFaceButtonName(buttonIndex, faceButtonLabels)
|
||||
: null;
|
||||
if (styledName) return styledName;
|
||||
// Face buttons print as on the pad's Button Style (Xbox when none is set): A, not
|
||||
// "A / Cross". Face positions 0-3 are the same on raw and standard layouts.
|
||||
const faceName = getControllerFaceButtonName(buttonIndex, faceButtonLabels ?? 'xbox');
|
||||
if (faceName) return faceName;
|
||||
const names = mapping === 'standard' ? W3C_STANDARD_BUTTON_NAMES : RAW_LAYOUT_BUTTON_NAMES;
|
||||
return names[buttonIndex] ?? `Button ${buttonIndex}`;
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ test('controller debug modal renders active controller axes, buttons, and config
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
toggleCharacterNavigation: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
@@ -202,6 +203,7 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
toggleCharacterNavigation: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
@@ -326,6 +328,7 @@ test('controller debug modal stays closed and notifies when controller support i
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
toggleCharacterNavigation: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
|
||||
@@ -163,6 +163,7 @@ function buildContext() {
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
toggleCharacterNavigation: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
|
||||
@@ -184,6 +184,7 @@ export function createControllerSelectModal(
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
toggleCharacterNavigation: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
|
||||
@@ -576,6 +576,7 @@ function startControllerPolling(): void {
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
toggleCharacterNavigation: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
@@ -635,6 +636,9 @@ function startControllerPolling(): void {
|
||||
nextEntry: () => {
|
||||
keyboardHandlers.movePopupEntryForController(1);
|
||||
},
|
||||
toggleCharacterNavigation: () => {
|
||||
keyboardHandlers.toggleCharacterNavigationForController();
|
||||
},
|
||||
toggleMpvPause: () => {
|
||||
window.electronAPI.sendMpvCommand(['cycle', 'pause']);
|
||||
},
|
||||
|
||||
@@ -166,6 +166,10 @@ export type RendererState = {
|
||||
keyboardDrivenModeEnabled: boolean;
|
||||
keyboardSelectionVisible: boolean;
|
||||
keyboardSelectedWordIndex: number | null;
|
||||
/** Keyboard-only mode moves by character instead of by word; kept until toggled back. */
|
||||
keyboardCharacterNavigation: boolean;
|
||||
/** Character of the selected word that the cursor is on, in character navigation. */
|
||||
keyboardSelectedCharIndex: number;
|
||||
yomitanPopupVisible: boolean;
|
||||
// Set once Hachidori's attention events identify it as the reader. Its attention
|
||||
// also covers presses that may start a selection, so yomitanPopupVisible alone
|
||||
@@ -309,6 +313,8 @@ export function createRendererState(): RendererState {
|
||||
keyboardDrivenModeEnabled: false,
|
||||
keyboardSelectionVisible: false,
|
||||
keyboardSelectedWordIndex: null,
|
||||
keyboardCharacterNavigation: false,
|
||||
keyboardSelectedCharIndex: 0,
|
||||
yomitanPopupVisible: false,
|
||||
hachidoriReaderSeen: false,
|
||||
isOverYomitanPopup: false,
|
||||
|
||||
@@ -2282,6 +2282,26 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
|
||||
0 0 18px rgba(120, 188, 255, 0.45);
|
||||
}
|
||||
|
||||
/* Character navigation in keyboard-only mode: the cursor box sits on one character, in
|
||||
orange so it reads apart from the blue word box. */
|
||||
#subtitleRoot .keyboard-selected-char {
|
||||
outline: 2px solid rgba(245, 169, 127, 0.95);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(12, 18, 28, 0.68),
|
||||
0 0 18px rgba(245, 169, 127, 0.45);
|
||||
}
|
||||
|
||||
/* A lookup on a character selects from it to the end of the line; keep that selection from
|
||||
recolouring the text (the doubled id outranks the selection colours below). */
|
||||
#subtitleRoot#subtitleRoot.keyboard-character-navigation::selection,
|
||||
#subtitleRoot#subtitleRoot.keyboard-character-navigation ::selection {
|
||||
background: transparent !important;
|
||||
color: currentcolor !important;
|
||||
-webkit-text-fill-color: currentcolor !important;
|
||||
}
|
||||
|
||||
#subtitleRoot .word[data-frequency-rank]::before {
|
||||
content: attr(data-frequency-rank);
|
||||
position: absolute;
|
||||
|
||||
@@ -222,6 +222,7 @@ function parseControllerBindings(value: unknown): ControllerConfigUpdate['bindin
|
||||
'holdSecondarySub',
|
||||
'previousEntry',
|
||||
'nextEntry',
|
||||
'toggleCharacterNavigation',
|
||||
'previousSubtitle',
|
||||
'nextSubtitle',
|
||||
'replaySubtitle',
|
||||
|
||||
@@ -242,6 +242,7 @@ export interface ControllerBindingsConfig {
|
||||
holdSecondarySub?: ControllerDiscreteBindingConfig;
|
||||
previousEntry?: ControllerDiscreteBindingConfig;
|
||||
nextEntry?: ControllerDiscreteBindingConfig;
|
||||
toggleCharacterNavigation?: ControllerDiscreteBindingConfig;
|
||||
previousSubtitle?: ControllerDiscreteBindingConfig;
|
||||
nextSubtitle?: ControllerDiscreteBindingConfig;
|
||||
replaySubtitle?: ControllerDiscreteBindingConfig;
|
||||
@@ -265,6 +266,7 @@ export interface ResolvedControllerBindingsConfig {
|
||||
holdSecondarySub?: ResolvedControllerDiscreteBinding;
|
||||
previousEntry?: ResolvedControllerDiscreteBinding;
|
||||
nextEntry?: ResolvedControllerDiscreteBinding;
|
||||
toggleCharacterNavigation?: ResolvedControllerDiscreteBinding;
|
||||
previousSubtitle?: ResolvedControllerDiscreteBinding;
|
||||
nextSubtitle?: ResolvedControllerDiscreteBinding;
|
||||
replaySubtitle?: ResolvedControllerDiscreteBinding;
|
||||
|
||||
Reference in new issue
Block a user