Controller: layouts for the media timing review and duplicate card dialogs (#313)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YashaNoosandClaude Opus 5.5 authored and GitHub committed 2026-11-02 01:38:26 -08:00
1 parent 696462a82e
commit 6d0f37ac21
50 files changed
+4555 -46

No files matched your search

+79
View File
@@ -0,0 +1,79 @@
import type {
ControllerFieldGroupingAction,
ResolvedControllerFieldGroupingBindings,
} from '../types/runtime';
import {
combo,
dpad,
face,
type ControllerScreenActionDefinition,
type ControllerScreenDefinition,
} from './controller-review';
/** The duplicate-card dialog: pick the card to keep, then the final merge preview. */
export type ControllerFieldGroupingStep = 'select' | 'preview';
export type ControllerFieldGroupingActionDefinition = ControllerScreenActionDefinition<
ControllerFieldGroupingAction,
ControllerFieldGroupingStep
>;
/** Every field grouping action, in the order Alt+C lists them. */
export const CONTROLLER_FIELD_GROUPING_ACTIONS: readonly ControllerFieldGroupingActionDefinition[] =
[
{ id: 'selectOriginal', label: 'Original card', step: 'select', repeat: false },
{ id: 'selectNew', label: 'New card', step: 'select', repeat: false },
{
id: 'toggleDeleteDuplicate',
label: 'Delete duplicate on/off',
step: 'select',
repeat: false,
},
{ id: 'continue', label: 'Continue', step: 'select', repeat: false },
{ id: 'cancel', label: 'Cancel', step: 'select', repeat: false },
{ id: 'confirmMerge', label: 'Confirm merge', step: 'preview', repeat: false },
{ id: 'back', label: 'Back', step: 'preview', repeat: false },
{ id: 'cancelMerge', label: 'Cancel', step: 'preview', repeat: false },
{ id: 'toggleView', label: 'Compact / Full', step: 'preview', repeat: false },
{ id: 'scrollUp', label: 'Scroll up', step: 'preview', repeat: true },
{ id: 'scrollDown', label: 'Scroll down', step: 'preview', repeat: true },
];
/**
* Default layout: D-pad picks the card, A goes forward, B goes back (B on the preview
* returns to the card choice, B there cancels). Cancel on the preview is unbound, so a
* double B press is the way out unless the user binds it.
*/
const DEFAULT_CONTROLLER_FIELD_GROUPING_BINDINGS: ResolvedControllerFieldGroupingBindings = {
selectOriginal: combo(dpad('left')),
selectNew: combo(dpad('right')),
toggleDeleteDuplicate: combo(face('x')),
continue: combo(face('a')),
cancel: combo(face('b')),
confirmMerge: combo(face('a')),
back: combo(face('b')),
cancelMerge: { kind: 'none' },
toggleView: combo(face('y')),
scrollUp: combo(dpad('up')),
scrollDown: combo(dpad('down')),
};
export const CONTROLLER_FIELD_GROUPING_SCREEN: ControllerScreenDefinition<
ControllerFieldGroupingAction,
ControllerFieldGroupingStep
> = {
id: 'fieldGrouping',
title: 'Duplicate card (field grouping)',
configKey: 'fieldGroupingBindings',
describe: 'Duplicate card dialog',
steps: [
{ id: 'select', label: 'Choosing the card' },
{ id: 'preview', label: 'Merge preview' },
],
actions: CONTROLLER_FIELD_GROUPING_ACTIONS,
defaults: DEFAULT_CONTROLLER_FIELD_GROUPING_BINDINGS,
};
export function getDefaultControllerFieldGroupingBindings(): ResolvedControllerFieldGroupingBindings {
return structuredClone(DEFAULT_CONTROLLER_FIELD_GROUPING_BINDINGS);
}
+150
View File
@@ -0,0 +1,150 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { CORE_DEFAULT_CONFIG } from '../config/definitions/defaults-core';
import {
controllerReviewInputSourceKey,
formatControllerReviewBinding,
formatControllerReviewInput,
parseControllerReviewBinding,
resolveControllerReviewInput,
type ControllerReviewInputContext,
} from './controller-review';
import { parseControllerConfigUpdate } from './ipc/validators';
const buttonIndices = CORE_DEFAULT_CONFIG.controller.buttonIndices;
const standardXbox: ControllerReviewInputContext = {
mapping: 'standard',
faceButtonLabels: 'xbox',
buttonIndices,
};
test('review bindings parse none, single presses and combos; anything else is rejected', () => {
assert.deepEqual(parseControllerReviewBinding({ kind: 'none' }), { kind: 'none' });
assert.deepEqual(parseControllerReviewBinding({ press: { kind: 'face', letter: 'a' } }), {
kind: 'combo',
press: { kind: 'face', letter: 'a' },
hold: null,
});
assert.deepEqual(
parseControllerReviewBinding({
kind: 'combo',
press: { kind: 'dpad', direction: 'up' },
hold: { kind: 'button', buttonIndex: 4 },
}),
{
kind: 'combo',
press: { kind: 'dpad', direction: 'up' },
hold: { kind: 'button', buttonIndex: 4 },
},
);
for (const bad of [
null,
{},
{ kind: 'button', buttonIndex: 1 },
{ press: { kind: 'face', letter: 'z' } },
{ press: { kind: 'button', buttonIndex: -1 } },
{ press: { kind: 'axis', axisIndex: 2 } },
{ press: { kind: 'dpad', direction: 'left' }, hold: { kind: 'dpad' } },
]) {
assert.equal(parseControllerReviewBinding(bad), null, JSON.stringify(bad));
}
});
test('the same physical input resolves to the same key across layouts', () => {
const key = (input: Parameters<typeof resolveControllerReviewInput>[0], context = standardXbox) =>
controllerReviewInputSourceKey(resolveControllerReviewInput(input, context));
// Letters follow faceButtonLabels.
assert.equal(key({ kind: 'face', letter: 'a' }), 'button:0');
assert.equal(
key({ kind: 'face', letter: 'a' }, { ...standardXbox, faceButtonLabels: 'nintendo' }),
'button:1',
);
// A learned standard D-pad button and the default D-pad entry are one input.
assert.equal(key({ kind: 'button', buttonIndex: 14 }), 'dpad:left');
assert.equal(key({ kind: 'dpad', direction: 'left' }), 'dpad:left');
// Non-standard pads report the D-pad on axes 6 and 7.
const raw = { ...standardXbox, mapping: '' };
assert.equal(key({ kind: 'axis', axisIndex: 7, direction: 'positive' }, raw), 'dpad:down');
assert.equal(key({ kind: 'button', buttonIndex: 14 }, raw), 'button:14');
assert.equal(key({ kind: 'axis', axisIndex: 2, direction: 'positive' }, raw), 'axis:2:positive');
});
test('review inputs are named as printed and by layout', () => {
const name = (input: Parameters<typeof formatControllerReviewInput>[0], context = standardXbox) =>
formatControllerReviewInput(input, context, { dpadPrefix: true });
const nintendo = { ...standardXbox, faceButtonLabels: 'nintendo' as const };
const raw = { ...standardXbox, mapping: '' };
assert.equal(name({ kind: 'face', letter: 'x' }, nintendo), 'X');
assert.equal(name({ kind: 'button', buttonIndex: 0 }, nintendo), 'B');
assert.equal(name({ kind: 'button', buttonIndex: 6 }), 'L2');
assert.equal(name({ kind: 'button', buttonIndex: 6 }, raw), 'Back');
assert.equal(name({ kind: 'button', buttonIndex: 10 }), 'L3');
assert.equal(name({ kind: 'button', buttonIndex: 15 }), 'D-pad ▶');
assert.equal(name({ kind: 'axis', axisIndex: 1, direction: 'negative' }), 'LS ▲');
assert.equal(name({ kind: 'axis', axisIndex: 5, direction: 'positive' }, raw), 'R2');
assert.equal(name({ kind: 'axis', axisIndex: 9, direction: 'positive' }), 'Axis 9 +');
assert.equal(
formatControllerReviewBinding(
{
kind: 'combo',
press: { kind: 'dpad', direction: 'up' },
hold: { kind: 'button', buttonIndex: 5 },
},
standardXbox,
),
'R1 + ▲',
);
});
test('controller config updates from Alt+C accept review bindings and reject bad ones', () => {
// The validator builds prototype-less objects; compare plain copies.
const plain = (value: unknown) => JSON.parse(JSON.stringify(value));
assert.deepEqual(
plain(
parseControllerConfigUpdate({
profiles: {
'pad-1': {
reviewBindings: {
confirm: { kind: 'combo', press: { kind: 'button', buttonIndex: 1 } },
},
},
},
}),
),
{
profiles: {
'pad-1': {
reviewBindings: {
confirm: { kind: 'combo', press: { kind: 'button', buttonIndex: 1 }, hold: null },
},
},
},
},
);
assert.equal(parseControllerConfigUpdate({ reviewBindings: { nope: { kind: 'none' } } }), null);
assert.equal(
parseControllerConfigUpdate({ reviewBindings: { confirm: { press: { kind: 'face' } } } }),
null,
);
});
test('Alt+C updates accept field grouping bindings, checked against that dialog', () => {
const plain = (value: unknown) => JSON.parse(JSON.stringify(value));
assert.deepEqual(
plain(
parseControllerConfigUpdate({
profiles: { 'pad-1': { fieldGroupingBindings: { back: { kind: 'none' } } } },
}),
),
{ profiles: { 'pad-1': { fieldGroupingBindings: { back: { kind: 'none' } } } } },
);
// Each dialog only accepts its own actions.
assert.equal(
parseControllerConfigUpdate({ fieldGroupingBindings: { confirm: { kind: 'none' } } }),
null,
);
assert.equal(parseControllerConfigUpdate({ reviewBindings: { back: { kind: 'none' } } }), null);
});
+518
View File
@@ -0,0 +1,518 @@
import type {
ControllerAxisDirection,
ControllerButtonIndicesConfig,
ControllerDpadDirection,
ControllerFaceButtonLabels,
ControllerFaceLetter,
ControllerReviewAction,
ControllerReviewInput,
ResolvedControllerReviewBinding,
ResolvedControllerReviewBindings,
} from '../types/runtime';
/**
* A dialog with its own controller layout (media timing review, field grouping). Each
* action presses one on-screen control on one step of the dialog; bindings are
* press-or-combo, remappable in Alt+C, and stored under `controller.<configKey>`.
*/
export type ControllerScreenDefinition<A extends string = string, S extends string = string> = {
id: 'review' | 'fieldGrouping';
/** Alt+C group title. */
title: string;
/** Config key on `controller` and on each profile. */
configKey: 'reviewBindings' | 'fieldGroupingBindings';
/** Prefix for generated config descriptions, e.g. `Review dialog`. */
describe: string;
steps: readonly { id: S; label: string }[];
actions: readonly ControllerScreenActionDefinition<A, S>[];
defaults: Readonly<Record<A, ResolvedControllerReviewBinding>>;
};
export type ControllerScreenActionDefinition<
A extends string = string,
S extends string = string,
> = {
id: A;
label: string;
step: S;
/** Repeats while held, like a held arrow key. */
repeat: boolean;
};
export type ControllerScreenBindings<A extends string = string> = Record<
A,
ResolvedControllerReviewBinding
>;
/** The review shows either the timing editor or the "Stop reviewing?" step. */
export type ControllerReviewStep = 'editor' | 'cancel';
export type ControllerReviewActionDefinition = ControllerScreenActionDefinition<
ControllerReviewAction,
ControllerReviewStep
>;
/** Every review action, in the order Alt+C lists them. */
export const CONTROLLER_REVIEW_ACTIONS: readonly ControllerReviewActionDefinition[] = [
{ id: 'confirm', label: 'Use this timing', step: 'editor', repeat: false },
{ id: 'cancel', label: 'Cancel', step: 'editor', repeat: false },
{ id: 'play', label: 'Play selection', step: 'editor', repeat: false },
{ id: 'resetTiming', label: 'Reset timing', step: 'editor', repeat: false },
{ id: 'showEarlier', label: 'Earlier −2s', step: 'editor', repeat: true },
{ id: 'showLater', label: 'Later +2s', step: 'editor', repeat: true },
{ id: 'framePrevious', label: 'Previous screenshot frame', step: 'editor', repeat: true },
{ id: 'frameNext', label: 'Next screenshot frame', step: 'editor', repeat: true },
{ id: 'frameReset', label: 'Reset screenshot frame', step: 'editor', repeat: false },
{ id: 'startBack', label: 'Start −0.1s', step: 'editor', repeat: true },
{ id: 'startForward', label: 'Start +0.1s', step: 'editor', repeat: true },
{ id: 'previousLineAdd', label: 'Add previous line', step: 'editor', repeat: true },
{ id: 'previousLineRemove', label: 'Remove previous line', step: 'editor', repeat: true },
{ id: 'endBack', label: 'End −0.1s', step: 'editor', repeat: true },
{ id: 'endForward', label: 'End +0.1s', step: 'editor', repeat: true },
{ id: 'nextLineAdd', label: 'Add next line', step: 'editor', repeat: true },
{ id: 'nextLineRemove', label: 'Remove next line', step: 'editor', repeat: true },
{ id: 'useOriginal', label: 'Use original timing', step: 'cancel', repeat: false },
{ id: 'keepEditing', label: 'Keep editing', step: 'cancel', repeat: false },
{ id: 'skipMedia', label: 'Without media', step: 'cancel', repeat: false },
{ id: 'discard', label: 'Discard card', step: 'cancel', repeat: false },
];
const ACTION_BY_ID = new Map(CONTROLLER_REVIEW_ACTIONS.map((action) => [action.id, action]));
export function getControllerReviewAction(
id: ControllerReviewAction,
): ControllerReviewActionDefinition {
return ACTION_BY_ID.get(id)!;
}
export function isControllerReviewAction(value: string): value is ControllerReviewAction {
return ACTION_BY_ID.has(value as ControllerReviewAction);
}
export function getControllerScreenAction<A extends string, S extends string>(
screen: ControllerScreenDefinition<A, S>,
id: A,
): ControllerScreenActionDefinition<A, S> {
return screen.actions.find((action) => action.id === id)!;
}
export function isControllerScreenAction<A extends string>(
screen: ControllerScreenDefinition<A>,
value: string,
): value is A {
return screen.actions.some((action) => action.id === value);
}
export function getDefaultControllerScreenBindings<A extends string>(
screen: ControllerScreenDefinition<A>,
): ControllerScreenBindings<A> {
return structuredClone(screen.defaults) as ControllerScreenBindings<A>;
}
export const face = (letter: ControllerFaceLetter): ControllerReviewInput => ({
kind: 'face',
letter,
});
export const dpad = (direction: ControllerDpadDirection): ControllerReviewInput => ({
kind: 'dpad',
direction,
});
const LEFT_SHOULDER: ControllerReviewInput = { kind: 'button', buttonIndex: 4 };
const RIGHT_SHOULDER: ControllerReviewInput = { kind: 'button', buttonIndex: 5 };
export const combo = (
press: ControllerReviewInput,
hold: ControllerReviewInput | null = null,
): ResolvedControllerReviewBinding => ({ kind: 'combo', press, hold });
/**
* Default layout. L1 holds everything about the clip start (and lines before it), R1
* everything about the clip end (and lines after it). Face buttons follow the printed
* letter and D-pad entries accept any D-pad report, so these work on every layout.
*/
const DEFAULT_CONTROLLER_REVIEW_BINDINGS: ResolvedControllerReviewBindings = {
confirm: combo(face('a')),
cancel: combo(face('b')),
play: combo(face('x')),
resetTiming: combo(face('y')),
showEarlier: combo(dpad('left')),
showLater: combo(dpad('right')),
framePrevious: combo(dpad('up')),
frameNext: combo(dpad('down')),
frameReset: combo(RIGHT_SHOULDER, LEFT_SHOULDER),
startBack: combo(dpad('left'), LEFT_SHOULDER),
startForward: combo(dpad('right'), LEFT_SHOULDER),
previousLineAdd: combo(dpad('up'), LEFT_SHOULDER),
previousLineRemove: combo(dpad('down'), LEFT_SHOULDER),
endBack: combo(dpad('left'), RIGHT_SHOULDER),
endForward: combo(dpad('right'), RIGHT_SHOULDER),
nextLineAdd: combo(dpad('up'), RIGHT_SHOULDER),
nextLineRemove: combo(dpad('down'), RIGHT_SHOULDER),
useOriginal: combo(face('a')),
keepEditing: combo(face('b')),
skipMedia: combo(face('x')),
discard: combo(face('y')),
};
export const CONTROLLER_REVIEW_SCREEN: ControllerScreenDefinition<
ControllerReviewAction,
ControllerReviewStep
> = {
id: 'review',
title: 'Review media timing',
configKey: 'reviewBindings',
describe: 'Review dialog',
steps: [
{ id: 'editor', label: 'While editing' },
{ id: 'cancel', label: 'After Cancel' },
],
actions: CONTROLLER_REVIEW_ACTIONS,
defaults: DEFAULT_CONTROLLER_REVIEW_BINDINGS,
};
export function getDefaultControllerReviewBindings(): ResolvedControllerReviewBindings {
return structuredClone(DEFAULT_CONTROLLER_REVIEW_BINDINGS);
}
export function getDefaultControllerReviewBinding(
action: ControllerReviewAction,
): ResolvedControllerReviewBinding {
return structuredClone(DEFAULT_CONTROLLER_REVIEW_BINDINGS[action]);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isIndex(value: unknown): value is number {
return typeof value === 'number' && Number.isInteger(value) && value >= 0;
}
export function parseControllerReviewInput(value: unknown): ControllerReviewInput | null {
if (!isRecord(value)) return null;
switch (value.kind) {
case 'button':
return isIndex(value.buttonIndex) ? { kind: 'button', buttonIndex: value.buttonIndex } : null;
case 'axis':
return isIndex(value.axisIndex) &&
(value.direction === 'negative' || value.direction === 'positive')
? { kind: 'axis', axisIndex: value.axisIndex, direction: value.direction }
: null;
case 'face':
return value.letter === 'a' ||
value.letter === 'b' ||
value.letter === 'x' ||
value.letter === 'y'
? { kind: 'face', letter: value.letter }
: null;
case 'dpad':
return value.direction === 'left' ||
value.direction === 'right' ||
value.direction === 'up' ||
value.direction === 'down'
? { kind: 'dpad', direction: value.direction }
: null;
default:
return null;
}
}
/** Accepts `{ "kind": "none" }` or `{ "press": …, "hold"?: … }` (`kind: "combo"` optional). */
export function parseControllerReviewBinding(
value: unknown,
): ResolvedControllerReviewBinding | null {
if (!isRecord(value)) return null;
if (value.kind === 'none') return { kind: 'none' };
if (value.kind !== undefined && value.kind !== 'combo') return null;
const press = parseControllerReviewInput(value.press);
if (!press) return null;
if (value.hold === undefined || value.hold === null) return combo(press);
const hold = parseControllerReviewInput(value.hold);
return hold ? combo(press, hold) : null;
}
/** What a review input needs to know about the controller it runs on. */
export type ControllerReviewInputContext = {
/** Gamepad API `mapping` ("standard" or ""); unknown before the pad is seen. */
mapping: string | null | undefined;
faceButtonLabels: ControllerFaceButtonLabels;
buttonIndices: Required<ControllerButtonIndicesConfig>;
};
/** A physical input after resolving letters and D-pad reports; equal sources are the same input. */
export type ControllerReviewInputSource =
| { kind: 'button'; buttonIndex: number }
| { kind: 'axis'; axisIndex: number; direction: ControllerAxisDirection }
| { kind: 'dpad'; direction: ControllerDpadDirection };
type FacePosition = 'south' | 'east' | 'west' | 'north';
/** Xbox (and PlayStation) pads put A at the bottom; Nintendo-style pads put it on the right. */
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' },
};
const POSITION_INDEX_KEYS = {
south: 'buttonSouth',
east: 'buttonEast',
west: 'buttonWest',
north: 'buttonNorth',
} as const satisfies Record<FacePosition, keyof ControllerButtonIndicesConfig>;
/** Unknown (pad not seen yet) counts as standard, the layout most pads report. */
function isStandardMapping(mapping: string | null | undefined): boolean {
return mapping === 'standard' || mapping === null || mapping === undefined;
}
const STANDARD_DPAD_BUTTONS: Record<number, ControllerDpadDirection> = {
12: 'up',
13: 'down',
14: 'left',
15: 'right',
};
export function resolveControllerReviewInput(
input: ControllerReviewInput,
context: ControllerReviewInputContext,
): ControllerReviewInputSource {
if (input.kind === 'dpad') return input;
if (input.kind === 'face') {
const positions = FACE_POSITIONS[context.faceButtonLabels] ?? FACE_POSITIONS.xbox;
const buttonIndex = context.buttonIndices[POSITION_INDEX_KEYS[positions[input.letter]]];
return { kind: 'button', buttonIndex };
}
if (input.kind === 'button') {
const direction = isStandardMapping(context.mapping)
? STANDARD_DPAD_BUTTONS[input.buttonIndex]
: undefined;
return direction ? { kind: 'dpad', direction } : input;
}
// Non-standard pads usually report the D-pad as axes 6 (left/right) and 7 (up/down).
if (!isStandardMapping(context.mapping) && (input.axisIndex === 6 || input.axisIndex === 7)) {
const negative = input.axisIndex === 6 ? 'left' : 'up';
const positive = input.axisIndex === 6 ? 'right' : 'down';
return { kind: 'dpad', direction: input.direction === 'negative' ? negative : positive };
}
return input;
}
export function controllerReviewInputSourceKey(source: ControllerReviewInputSource): string {
if (source.kind === 'dpad') return `dpad:${source.direction}`;
if (source.kind === 'button') return `button:${source.buttonIndex}`;
return `axis:${source.axisIndex}:${source.direction}`;
}
export type CompiledControllerScreenBinding<A extends string = string> = {
action: A;
repeat: boolean;
press: ControllerReviewInputSource;
pressKey: string;
hold: ControllerReviewInputSource | null;
holdKey: string | null;
};
export type CompiledControllerReviewBinding =
CompiledControllerScreenBinding<ControllerReviewAction>;
/** The bindings active on one step of a screen, resolved to physical inputs, in Alt+C order. */
export function compileControllerScreenBindings<A extends string, S extends string>(
screen: ControllerScreenDefinition<A, S>,
bindings: Partial<ControllerScreenBindings<A>>,
step: S,
context: ControllerReviewInputContext,
): CompiledControllerScreenBinding<A>[] {
const compiled: CompiledControllerScreenBinding<A>[] = [];
for (const action of screen.actions) {
if (action.step !== step) continue;
const binding = bindings[action.id];
if (!binding || binding.kind !== 'combo') continue;
const press = resolveControllerReviewInput(binding.press, context);
const hold = binding.hold ? resolveControllerReviewInput(binding.hold, context) : null;
compiled.push({
action: action.id,
repeat: action.repeat,
press,
pressKey: controllerReviewInputSourceKey(press),
hold,
holdKey: hold ? controllerReviewInputSourceKey(hold) : null,
});
}
return compiled;
}
/** The review bindings active on one step, resolved to physical inputs, in Alt+C order. */
export function compileControllerReviewBindings(
bindings: ResolvedControllerReviewBindings,
step: ControllerReviewStep,
context: ControllerReviewInputContext,
): CompiledControllerReviewBinding[] {
return compileControllerScreenBindings(CONTROLLER_REVIEW_SCREEN, bindings, step, context);
}
const DPAD_GLYPHS: Record<ControllerDpadDirection, string> = {
left: '◀',
right: '▶',
up: '▲',
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',
2: 'west',
3: 'north',
};
const STANDARD_BUTTON_NAMES: Record<number, string> = {
4: 'L1',
5: 'R1',
6: 'L2',
7: 'R2',
8: 'Select',
9: 'Start',
10: 'L3',
11: 'R3',
16: 'Home',
};
const RAW_BUTTON_NAMES: Record<number, string> = {
4: 'L1',
5: 'R1',
6: 'Back',
7: 'Start',
8: 'L3',
9: 'R3',
};
type AxisName = { name: string; horizontal: boolean } | { name: string; trigger: true };
const STANDARD_AXIS_NAMES: Record<number, AxisName> = {
0: { name: 'LS', horizontal: true },
1: { name: 'LS', horizontal: false },
2: { name: 'RS', horizontal: true },
3: { name: 'RS', horizontal: false },
};
const RAW_AXIS_NAMES: Record<number, AxisName> = {
0: { name: 'LS', horizontal: true },
1: { name: 'LS', horizontal: false },
2: { name: 'L2', trigger: true },
3: { name: 'RS', horizontal: true },
4: { name: 'RS', horizontal: false },
5: { name: 'R2', trigger: true },
};
function formatSource(
source: ControllerReviewInputSource,
context: ControllerReviewInputContext,
dpadPrefix: boolean,
): string {
if (source.kind === 'dpad') {
return `${dpadPrefix ? 'D-pad ' : ''}${DPAD_GLYPHS[source.direction]}`;
}
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 names = standard ? STANDARD_BUTTON_NAMES : RAW_BUTTON_NAMES;
return names[source.buttonIndex] ?? `Button ${source.buttonIndex}`;
}
const axis = (standard ? STANDARD_AXIS_NAMES : RAW_AXIS_NAMES)[source.axisIndex];
const sign = source.direction === 'negative' ? '−' : '+';
if (!axis) return `Axis ${source.axisIndex} ${sign}`;
if ('trigger' in axis) return axis.name;
const glyph = axis.horizontal
? source.direction === 'negative'
? DPAD_GLYPHS.left
: DPAD_GLYPHS.right
: source.direction === 'negative'
? DPAD_GLYPHS.up
: DPAD_GLYPHS.down;
return `${axis.name} ${glyph}`;
}
/** Short name for one input, e.g. `A`, `L1`, `◀` (or `D-pad ◀`), `LS ▲`. */
export function formatControllerReviewInput(
input: ControllerReviewInput,
context: ControllerReviewInputContext,
options: { dpadPrefix?: boolean } = {},
): string {
// Letters are shown as printed, whatever position they resolve to.
if (input.kind === 'face') return input.letter.toUpperCase();
return formatSource(
resolveControllerReviewInput(input, context),
context,
options.dpadPrefix ?? false,
);
}
/** `None`, `A`, or `L1 + D-pad ▲` style label for a whole binding. */
export function formatControllerReviewBinding(
binding: ResolvedControllerReviewBinding,
context: ControllerReviewInputContext,
options: { dpadPrefix?: boolean } = {},
): string {
if (binding.kind === 'none') return 'None';
const press = formatControllerReviewInput(binding.press, context, options);
return binding.hold
? `${formatControllerReviewInput(binding.hold, context, options)} + ${press}`
: press;
}
/**
* Problems Alt+C should point out, per action: two actions on the same input in one step,
* or a button used alone that other actions on that step use as their hold button.
*/
export function findControllerScreenBindingIssues<A extends string, S extends string>(
screen: ControllerScreenDefinition<A, S>,
bindings: Partial<ControllerScreenBindings<A>>,
context: ControllerReviewInputContext,
): Partial<Record<A, string>> {
const issues: Partial<Record<A, string>> = {};
const label = (id: A) => getControllerScreenAction(screen, id).label;
for (const step of screen.steps) {
const compiled = compileControllerScreenBindings(screen, bindings, step.id, context);
const holdKeys = new Map<string, ControllerReviewInputSource>();
for (const entry of compiled) {
if (entry.hold && entry.holdKey) holdKeys.set(entry.holdKey, entry.hold);
}
const firstByInput = new Map<string, A>();
for (const entry of compiled) {
if (entry.holdKey === entry.pressKey) {
issues[entry.action] = 'Hold and press are the same button';
continue;
}
const inputKey = `${entry.holdKey ?? ''}|${entry.pressKey}`;
const first = firstByInput.get(inputKey);
if (first) {
issues[entry.action] = `Same as ${label(first)}`;
issues[first] ??= `Same as ${label(entry.action)}`;
continue;
}
firstByInput.set(inputKey, entry.action);
const holdSource = entry.holdKey === null ? holdKeys.get(entry.pressKey) : undefined;
if (holdSource) {
issues[entry.action] =
`${formatSource(holdSource, context, true)} is also a hold button on this step`;
}
}
}
return issues;
}
export function findControllerReviewBindingIssues(
bindings: ResolvedControllerReviewBindings,
context: ControllerReviewInputContext,
): Partial<Record<ControllerReviewAction, string>> {
return findControllerScreenBindingIssues(CONTROLLER_REVIEW_SCREEN, bindings, context);
}
+40
View File
@@ -0,0 +1,40 @@
import { CONTROLLER_FIELD_GROUPING_SCREEN } from './controller-field-grouping';
import {
CONTROLLER_REVIEW_SCREEN,
isControllerScreenAction,
parseControllerReviewBinding,
type ControllerScreenBindings,
type ControllerScreenDefinition,
} from './controller-review';
/** Every dialog with its own remappable controller layout, in Alt+C order. */
export const CONTROLLER_SCREENS: readonly ControllerScreenDefinition[] = [
CONTROLLER_REVIEW_SCREEN as unknown as ControllerScreenDefinition,
CONTROLLER_FIELD_GROUPING_SCREEN as unknown as ControllerScreenDefinition,
];
export type ControllerScreenId = ControllerScreenDefinition['id'];
export type ControllerScreenBindingsKey = ControllerScreenDefinition['configKey'];
export function getControllerScreen(id: ControllerScreenId): ControllerScreenDefinition {
return CONTROLLER_SCREENS.find((screen) => screen.id === id)!;
}
/**
* Parses a per-action patch for one screen (`{ "continue": { … } }`). Returns null when any
* key is not an action of that screen or any binding is malformed.
*/
export function parseControllerScreenBindingsPatch(
screen: ControllerScreenDefinition,
value: unknown,
): Partial<ControllerScreenBindings> | null {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
const bindings: Partial<ControllerScreenBindings> = {};
for (const [action, rawBinding] of Object.entries(value)) {
if (!isControllerScreenAction(screen, action)) return null;
const parsed = parseControllerReviewBinding(rawBinding);
if (!parsed) return null;
bindings[action] = parsed;
}
return bindings;
}
+1
View File
@@ -158,6 +158,7 @@ export const IPC_CHANNELS = {
playlistBrowserOpen: 'playlist-browser:open',
sessionNumericSelectionStart: 'session:numeric-selection-start',
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
controllerSuspendedChanged: 'controller:suspended-changed',
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
sessionHelpOpen: 'session-help:open',
changelogOpen: 'changelog:open',
+17
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 { CONTROLLER_SCREENS, parseControllerScreenBindingsPatch } from '../controller-screens';
const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'prototype']);
@@ -242,6 +243,20 @@ function parseControllerBindings(value: unknown): ControllerConfigUpdate['bindin
return bindings;
}
/** Copies each screen's per-action patch (`reviewBindings`, `fieldGroupingBindings`). */
function parseControllerScreenBindingPatches(
source: Record<string, unknown>,
target: Pick<ControllerConfigUpdate, 'reviewBindings' | 'fieldGroupingBindings'>,
): boolean {
for (const screen of CONTROLLER_SCREENS) {
if (source[screen.configKey] === undefined) continue;
const parsed = parseControllerScreenBindingsPatch(screen, source[screen.configKey]);
if (!parsed) return false;
(target as Record<string, unknown>)[screen.configKey] = parsed;
}
return true;
}
export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpdate | null {
if (!isObject(value)) return null;
const update: ControllerConfigUpdate = {};
@@ -269,6 +284,7 @@ export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpd
if (!parsed) return null;
update.bindings = parsed;
}
if (!parseControllerScreenBindingPatches(value, update)) return null;
if (value.profiles !== undefined) {
if (!isObject(value.profiles)) return null;
@@ -291,6 +307,7 @@ export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpd
if (!parsed) return null;
profile.bindings = parsed;
}
if (!parseControllerScreenBindingPatches(rawProfile, profile)) return null;
profiles[profileId] = profile;
}
update.profiles = profiles;