From 14ef7e823cd173ac4d87fa69a8f551d8f36a9845 Mon Sep 17 00:00:00 2001 From: YashaNoos <109368517+YashaNoos@users.noreply.github.com> Date: Thu, 5 Nov 2026 06:54:00 +0100 Subject: [PATCH] Controller: character navigation in keyboard-only mode, plain Xbox face names (#326) Co-authored-by: Claude Opus 5.5 Co-authored-by: Claude --- changes/controller-xbox-face-names.md | 4 + changes/keyboard-character-navigation.md | 4 + config.example.jsonc | 3 + docs-site/public/config.example.jsonc | 3 + docs-site/usage.md | 3 + src/config/definitions/defaults-core.ts | 1 + src/config/definitions/options-core.ts | 6 + src/config/resolve/controller.ts | 1 + src/core/services/ipc.test.ts | 1 + .../handlers/gamepad-controller.test.ts | 29 ++++ src/renderer/handlers/gamepad-controller.ts | 10 ++ .../keyboard-character-cursor.test.ts | 66 ++++++++ .../handlers/keyboard-character-cursor.ts | 118 ++++++++++++++ src/renderer/handlers/keyboard.test.ts | 146 +++++++++++++++++- src/renderer/handlers/keyboard.ts | 127 ++++++++++++++- .../modals/controller-config-form.test.ts | 12 +- src/renderer/modals/controller-config-form.ts | 26 ++-- src/renderer/modals/controller-debug.test.ts | 3 + src/renderer/modals/controller-select.test.ts | 1 + src/renderer/modals/controller-select.ts | 1 + src/renderer/renderer.ts | 4 + src/renderer/state.ts | 6 + src/renderer/style.css | 20 +++ src/shared/ipc/validators.ts | 1 + src/types/runtime.ts | 2 + 25 files changed, 569 insertions(+), 29 deletions(-) create mode 100644 changes/controller-xbox-face-names.md create mode 100644 changes/keyboard-character-navigation.md create mode 100644 src/renderer/handlers/keyboard-character-cursor.test.ts create mode 100644 src/renderer/handlers/keyboard-character-cursor.ts diff --git a/changes/controller-xbox-face-names.md b/changes/controller-xbox-face-names.md new file mode 100644 index 00000000..7afa0173 --- /dev/null +++ b/changes/controller-xbox-face-names.md @@ -0,0 +1,4 @@ +type: changed +area: overlay + +- With the Xbox **Button Style**, `Alt+C` names the face buttons `A`, `B`, `X` and `Y` instead of `A / Cross`, `B / Circle`, `X / Square` and `Y / Triangle`; PlayStation and Nintendo already showed their own names. diff --git a/changes/keyboard-character-navigation.md b/changes/keyboard-character-navigation.md new file mode 100644 index 00000000..a1aca7fc --- /dev/null +++ b/changes/keyboard-character-navigation.md @@ -0,0 +1,4 @@ +type: added +area: overlay + +- Keyboard-only mode can move by character instead of by word: press `C`, or the new **Toggle Character Navigation** controller binding (unbound by default). The cursor box turns orange and covers one character, and a lookup searches from it, which reaches words the subtitle split cuts in two. diff --git a/config.example.jsonc b/config.example.jsonc index 067e6de1..473ad2f9 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -169,6 +169,9 @@ "nextEntry": { "kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis }, // Controller binding descriptor that selects the next word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. + "toggleCharacterNavigation": { + "kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis + }, // 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. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "previousSubtitle": { "kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis }, // Controller binding descriptor that jumps to the previous subtitle line, like Shift+H. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 067e6de1..473ad2f9 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -169,6 +169,9 @@ "nextEntry": { "kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis }, // Controller binding descriptor that selects the next word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. + "toggleCharacterNavigation": { + "kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis + }, // 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. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "previousSubtitle": { "kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis }, // Controller binding descriptor that jumps to the previous subtitle line, like Shift+H. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. diff --git a/docs-site/usage.md b/docs-site/usage.md index 2ae61b01..fb5cbb4f 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -184,6 +184,7 @@ Press `Alt+C` to choose a controller and remap buttons. Click an action's **Lear | Unbound by default | Previous / next subtitle line | | Unbound by default | Replay the current line, then pause | | Unbound by default | Play the next line, then pause | +| Unbound by default | Move by word or by character | The secondary subtitle hold works with or without keyboard-only mode. Assign it with **Learn** on the **Show Secondary Subtitle (Hold)** row in `Alt+C`. While held, the secondary subtitle shows as if its mode were `visible`; releasing the button returns it to your chosen mode. @@ -191,6 +192,8 @@ The **Subtitles** rows in `Alt+C` do the same as the keyboard shortcuts: **Previ **Previous Entry** and **Next Entry** move one word entry at a time in the open popup, and Mine Card then mines the selected entry. They send Yomitan's default `Alt+Up` / `Alt+Down` entry shortcuts, so they stop working if you rebind those in Yomitan's keyboard settings. If you assign them to the D-pad, click **Clear** on the **Popup Scroll (D-pad)** row too, or the D-pad will also scroll the popup. +When the subtitle is split in the wrong place, a word can start in the middle of a box (in もっとハ|キ|ハキしなくちゃ, ハキハキ starts at the last character of the first box) and moving by word never reaches it. In keyboard-only mode, press `C` on the keyboard, or the **Toggle Character Navigation** button (unbound by default, under **Navigation** in `Alt+C`), to move one character at a time: the box turns orange and covers one character, and a lookup searches from that character, so Yomitan finds the longest word starting there. Press it again to go back to moving by word. It stays on until you switch it back, even after leaving keyboard-only mode. + The media timing review and duplicate card (field grouping) dialogs have their own controller layouts, which you can change in the **Review media timing** and **Duplicate card (field grouping)** groups of `Alt+C`; see [Review media timing](/anki-integration#with-a-controller) and [Field grouping](/anki-integration#field-grouping-with-a-controller). On controllers that report the W3C standard layout, the default quit button lands on `L2` instead of `Select`, and the default pause button on `Start` instead of `L3`. The default right-stick popup jump does nothing on these controllers; click **Learn** on **Popup Jump (Stick)** and move the right stick up or down to set it. Remap anything else with `Alt+C`, which names buttons according to the layout your controller reports. All options are in [Configuration](/configuration#controller-support). diff --git a/src/config/definitions/defaults-core.ts b/src/config/definitions/defaults-core.ts index 165adea9..24e4b95d 100644 --- a/src/config/definitions/defaults-core.ts +++ b/src/config/definitions/defaults-core.ts @@ -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' }, diff --git a/src/config/definitions/options-core.ts b/src/config/definitions/options-core.ts index 809f0dc4..e2d8c8a6 100644 --- a/src/config/definitions/options-core.ts +++ b/src/config/definitions/options-core.ts @@ -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, diff --git a/src/config/resolve/controller.ts b/src/config/resolve/controller.ts index a0904242..e4f8cba9 100644 --- a/src/config/resolve/controller.ts +++ b/src/config/resolve/controller.ts @@ -101,6 +101,7 @@ const CONTROLLER_DISCRETE_BINDING_KEYS = [ 'holdSecondarySub', 'previousEntry', 'nextEntry', + 'toggleCharacterNavigation', 'previousSubtitle', 'nextSubtitle', 'replaySubtitle', diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index ab7573de..5cb7e1eb 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -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 }, diff --git a/src/renderer/handlers/gamepad-controller.test.ts b/src/renderer/handlers/gamepad-controller.test.ts index 05a6b17b..f29348ee 100644 --- a/src/renderer/handlers/gamepad-controller.test.ts +++ b/src/renderer/handlers/gamepad-controller.test.ts @@ -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 = {}; + 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 } })], diff --git a/src/renderer/handlers/gamepad-controller.ts b/src/renderer/handlers/gamepad-controller.ts index e343c2f9..95d63eec 100644 --- a/src/renderer/handlers/gamepad-controller.ts +++ b/src/renderer/handlers/gamepad-controller.ts @@ -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); diff --git a/src/renderer/handlers/keyboard-character-cursor.test.ts b/src/renderer/handlers/keyboard-character-cursor.test.ts new file mode 100644 index 00000000..9f3e3dcc --- /dev/null +++ b/src/renderer/handlers/keyboard-character-cursor.test.ts @@ -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); +}); diff --git a/src/renderer/handlers/keyboard-character-cursor.ts b/src/renderer/handlers/keyboard-character-cursor.ts new file mode 100644 index 00000000..6bcbff87 --- /dev/null +++ b/src/renderer/handlers/keyboard-character-cursor.ts @@ -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(`.${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; + }, + }; +} diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index 5074aca0..de60fecf 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -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(); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index b974d563..06e5f895 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -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, diff --git a/src/renderer/modals/controller-config-form.test.ts b/src/renderer/modals/controller-config-form.test.ts index 0268f067..23bd9a0d 100644 --- a/src/renderer/modals/controller-config-form.test.ts +++ b/src/renderer/modals/controller-config-form.test.ts @@ -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'), []); diff --git a/src/renderer/modals/controller-config-form.ts b/src/renderer/modals/controller-config-form.ts index c5bdda42..5e7a3a10 100644 --- a/src/renderer/modals/controller-config-form.ts +++ b/src/renderer/modals/controller-config-form.ts @@ -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 = { - 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 = { // 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 = { - 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}`; } diff --git a/src/renderer/modals/controller-debug.test.ts b/src/renderer/modals/controller-debug.test.ts index 747f3ad9..96052b38 100644 --- a/src/renderer/modals/controller-debug.test.ts +++ b/src/renderer/modals/controller-debug.test.ts @@ -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' }, diff --git a/src/renderer/modals/controller-select.test.ts b/src/renderer/modals/controller-select.test.ts index 6005f7ee..fbc807f6 100644 --- a/src/renderer/modals/controller-select.test.ts +++ b/src/renderer/modals/controller-select.test.ts @@ -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' }, diff --git a/src/renderer/modals/controller-select.ts b/src/renderer/modals/controller-select.ts index 1c3ce479..d0627058 100644 --- a/src/renderer/modals/controller-select.ts +++ b/src/renderer/modals/controller-select.ts @@ -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' }, diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index d49eb539..6bb052ee 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -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']); }, diff --git a/src/renderer/state.ts b/src/renderer/state.ts index a65337ef..03dfa57e 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -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, diff --git a/src/renderer/style.css b/src/renderer/style.css index 64a4a0b0..bad1856c 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -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; diff --git a/src/shared/ipc/validators.ts b/src/shared/ipc/validators.ts index 9193776d..acc8521a 100644 --- a/src/shared/ipc/validators.ts +++ b/src/shared/ipc/validators.ts @@ -222,6 +222,7 @@ function parseControllerBindings(value: unknown): ControllerConfigUpdate['bindin 'holdSecondarySub', 'previousEntry', 'nextEntry', + 'toggleCharacterNavigation', 'previousSubtitle', 'nextSubtitle', 'replaySubtitle', diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 119bf194..fc98be57 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -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;