Controller: subtitle line actions, Button Style, keyboard-only mode pause, Alt+C fixes (#316)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
YashaNoosandClaude Opus 5.5 authored and GitHub committed 2026-11-02 21:41:46 -08:00
1 parent 6d0f37ac21
commit b99409bbcd
60 files changed
+1900 -219

No files matched your search

+54
View File
@@ -6,6 +6,8 @@ import {
controllerReviewInputSourceKey,
formatControllerReviewBinding,
formatControllerReviewInput,
getControllerFaceButtonName,
isControllerFaceButtonLabels,
parseControllerReviewBinding,
resolveControllerReviewInput,
type ControllerReviewInputContext,
@@ -148,3 +150,55 @@ test('Alt+C updates accept field grouping bindings, checked against that dialog'
);
assert.equal(parseControllerConfigUpdate({ reviewBindings: { back: { kind: 'none' } } }), null);
});
test('PlayStation pads print symbols at the Xbox positions', () => {
const playstation = { ...standardXbox, faceButtonLabels: 'playstation' as const };
const name = (input: Parameters<typeof formatControllerReviewInput>[0]) =>
formatControllerReviewInput(input, playstation);
// Same physical buttons as Xbox: A is the bottom button, shown as ╳.
assert.equal(
controllerReviewInputSourceKey(
resolveControllerReviewInput({ kind: 'face', letter: 'a' }, playstation),
),
'button:0',
);
assert.deepEqual(
(['a', 'b', 'x', 'y'] as const).map((letter) => name({ kind: 'face', letter })),
['╳', '○', '□', '△'],
);
assert.equal(name({ kind: 'button', buttonIndex: 3 }), '△');
assert.equal(
formatControllerReviewBinding(
{
kind: 'combo',
press: { kind: 'face', letter: 'a' },
hold: { kind: 'button', buttonIndex: 4 },
},
playstation,
),
'L1 + ╳',
);
assert.equal(getControllerFaceButtonName(1, 'nintendo'), 'A');
assert.equal(getControllerFaceButtonName(4, 'playstation'), null);
assert.equal(isControllerFaceButtonLabels('playstation'), true);
assert.equal(isControllerFaceButtonLabels('sega'), false);
});
test('Alt+C updates accept a button style globally and per controller', () => {
const plain = (value: unknown) => JSON.parse(JSON.stringify(value));
assert.deepEqual(
plain(
parseControllerConfigUpdate({
faceButtonLabels: 'xbox',
profiles: { 'pad-1': { faceButtonLabels: 'playstation' } },
}),
),
{ faceButtonLabels: 'xbox', profiles: { 'pad-1': { faceButtonLabels: 'playstation' } } },
);
assert.equal(parseControllerConfigUpdate({ faceButtonLabels: 'sega' }), null);
assert.equal(
parseControllerConfigUpdate({ profiles: { 'pad-1': { faceButtonLabels: 1 } } }),
null,
);
});
+50 -11
View File
@@ -246,15 +246,35 @@ export type ControllerReviewInputSource =
type FacePosition = 'south' | 'east' | 'west' | 'north';
/** Xbox (and PlayStation) pads put A at the bottom; Nintendo-style pads put it on the right. */
/**
* Where each layout letter sits. Xbox pads put A at the bottom, Nintendo-style pads on the
* right. PlayStation pads follow the Xbox layout, so A is ╳, B ○, X □ and Y △.
*/
const FACE_POSITIONS: Record<
ControllerFaceButtonLabels,
Record<ControllerFaceLetter, FacePosition>
> = {
xbox: { a: 'south', b: 'east', x: 'west', y: 'north' },
nintendo: { a: 'east', b: 'south', x: 'north', y: 'west' },
playstation: { a: 'south', b: 'east', x: 'west', y: 'north' },
};
/** Every face button style, in the order Alt+C offers them. */
export const CONTROLLER_FACE_BUTTON_STYLES: readonly {
id: ControllerFaceButtonLabels;
name: string;
/** Tells the layouts apart, since 8BitDo and other pads come in more than one. */
hint: string;
}[] = [
{ id: 'xbox', name: 'Xbox', hint: 'A at the bottom, B on the right' },
{ id: 'nintendo', name: 'Nintendo', hint: 'B at the bottom, A on the right' },
{ id: 'playstation', name: 'PlayStation', hint: '\u2573 at the bottom, \u25cb on the right' },
];
export function isControllerFaceButtonLabels(value: unknown): value is ControllerFaceButtonLabels {
return CONTROLLER_FACE_BUTTON_STYLES.some((style) => style.id === value);
}
const POSITION_INDEX_KEYS = {
south: 'buttonSouth',
east: 'buttonEast',
@@ -359,11 +379,6 @@ const DPAD_GLYPHS: Record<ControllerDpadDirection, string> = {
down: '▼',
};
const LETTER_AT_POSITION: Record<ControllerFaceButtonLabels, Record<FacePosition, string>> = {
xbox: { south: 'A', east: 'B', west: 'X', north: 'Y' },
nintendo: { south: 'B', east: 'A', west: 'Y', north: 'X' },
};
const FACE_POSITION_BY_INDEX: Record<number, FacePosition> = {
0: 'south',
1: 'east',
@@ -371,6 +386,32 @@ const FACE_POSITION_BY_INDEX: Record<number, FacePosition> = {
3: 'north',
};
/** What is printed at each position, per style. */
const LETTER_AT_POSITION: Record<ControllerFaceButtonLabels, Record<FacePosition, string>> = {
xbox: { south: 'A', east: 'B', west: 'X', north: 'Y' },
nintendo: { south: 'B', east: 'A', west: 'Y', north: 'X' },
playstation: { south: '\u2573', east: '\u25cb', west: '\u25a1', north: '\u25b3' },
};
/** The printed name of the face button at index 0-3 (south, east, west, north), if any. */
export function getControllerFaceButtonName(
buttonIndex: number,
faceButtonLabels: ControllerFaceButtonLabels,
): string | null {
const position = FACE_POSITION_BY_INDEX[buttonIndex];
if (!position) return null;
return (LETTER_AT_POSITION[faceButtonLabels] ?? LETTER_AT_POSITION.xbox)[position];
}
/** How a layout letter is printed on this style, e.g. `a` is `A` on Xbox and `╳` on PlayStation. */
function formatFaceLetter(
letter: ControllerFaceLetter,
faceButtonLabels: ControllerFaceButtonLabels,
): string {
const positions = FACE_POSITIONS[faceButtonLabels] ?? FACE_POSITIONS.xbox;
return (LETTER_AT_POSITION[faceButtonLabels] ?? LETTER_AT_POSITION.xbox)[positions[letter]];
}
const STANDARD_BUTTON_NAMES: Record<number, string> = {
4: 'L1',
5: 'R1',
@@ -420,10 +461,8 @@ function formatSource(
}
const standard = isStandardMapping(context.mapping);
if (source.kind === 'button') {
const position = FACE_POSITION_BY_INDEX[source.buttonIndex];
if (position) {
return (LETTER_AT_POSITION[context.faceButtonLabels] ?? LETTER_AT_POSITION.xbox)[position];
}
const faceName = getControllerFaceButtonName(source.buttonIndex, context.faceButtonLabels);
if (faceName) return faceName;
const names = standard ? STANDARD_BUTTON_NAMES : RAW_BUTTON_NAMES;
return names[source.buttonIndex] ?? `Button ${source.buttonIndex}`;
}
@@ -448,7 +487,7 @@ export function formatControllerReviewInput(
options: { dpadPrefix?: boolean } = {},
): string {
// Letters are shown as printed, whatever position they resolve to.
if (input.kind === 'face') return input.letter.toUpperCase();
if (input.kind === 'face') return formatFaceLetter(input.letter, context.faceButtonLabels);
return formatSource(
resolveControllerReviewInput(input, context),
context,
+4
View File
@@ -34,6 +34,7 @@ export const IPC_CHANNELS = {
statsNativeDialogOpened: 'stats:native-dialog-opened',
statsNativeDialogClosed: 'stats:native-dialog-closed',
saveControllerConfig: 'save-controller-config',
reportActiveController: 'controller:report-active',
mpvCommand: 'mpv-command',
refreshKnownWords: 'anki:refresh-known-words',
kikuFieldGroupingRespond: 'kiku:field-grouping-respond',
@@ -72,6 +73,7 @@ export const IPC_CHANNELS = {
getMarkWatchedKey: 'get-mark-watched-key',
getOverlayNotificationPosition: 'get-overlay-notification-position',
getControllerConfig: 'get-controller-config',
getActiveControllerId: 'controller:get-active',
getSecondarySubMode: 'get-secondary-sub-mode',
getCurrentSecondarySub: 'get-current-secondary-sub',
youtubePickerResolve: 'youtube:picker-resolve',
@@ -159,6 +161,8 @@ export const IPC_CHANNELS = {
sessionNumericSelectionStart: 'session:numeric-selection-start',
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
controllerSuspendedChanged: 'controller:suspended-changed',
controllerConfigChanged: 'controller:config-changed',
playbackPauseChanged: 'mpv:pause-changed',
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
sessionHelpOpen: 'session-help:open',
changelogOpen: 'changelog:open',
+13
View File
@@ -17,6 +17,7 @@ import type { RuntimeOptionId, RuntimeOptionValue } from '../../types/runtime-op
import type { SessionActionId, SessionActionPayload } from '../../types/session-bindings';
import type { SubtitlePosition } from '../../types/subtitle';
import { OVERLAY_HOSTED_MODALS, type OverlayHostedModal } from './contracts';
import { isControllerFaceButtonLabels } from '../controller-review';
import { CONTROLLER_SCREENS, parseControllerScreenBindingsPatch } from '../controller-screens';
const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'prototype']);
@@ -221,6 +222,10 @@ function parseControllerBindings(value: unknown): ControllerConfigUpdate['bindin
'holdSecondarySub',
'previousEntry',
'nextEntry',
'previousSubtitle',
'nextSubtitle',
'replaySubtitle',
'playNextSubtitle',
] as const;
for (const key of discreteKeys) {
if (value[key] === undefined) continue;
@@ -273,6 +278,10 @@ export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpd
if (typeof value.preferredGamepadLabel !== 'string') return null;
update.preferredGamepadLabel = value.preferredGamepadLabel;
}
if (value.faceButtonLabels !== undefined) {
if (!isControllerFaceButtonLabels(value.faceButtonLabels)) return null;
update.faceButtonLabels = value.faceButtonLabels;
}
if (value.buttonIndices !== undefined) {
const parsed = parseControllerButtonIndices(value.buttonIndices);
if (!parsed) return null;
@@ -297,6 +306,10 @@ export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpd
if (typeof rawProfile.label !== 'string') return null;
profile.label = rawProfile.label;
}
if (rawProfile.faceButtonLabels !== undefined) {
if (!isControllerFaceButtonLabels(rawProfile.faceButtonLabels)) return null;
profile.faceButtonLabels = rawProfile.faceButtonLabels;
}
if (rawProfile.buttonIndices !== undefined) {
const parsed = parseControllerButtonIndices(rawProfile.buttonIndices);
if (!parsed) return null;