mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 16:46:06 -08:00
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:
1 parent
6d0f37ac21
commit
b99409bbcd
60 files changed
+1900
-219
No files matched your search
@@ -16,6 +16,7 @@ export function resolveControllerConfigForGamepad(
|
||||
if (!profile) return config;
|
||||
return {
|
||||
...config,
|
||||
faceButtonLabels: profile.faceButtonLabels ?? config.faceButtonLabels,
|
||||
buttonIndices: profile.buttonIndices,
|
||||
bindings: profile.bindings,
|
||||
reviewBindings: profile.reviewBindings ?? config.reviewBindings,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
getControllerReviewInputContext,
|
||||
} from './controller-screen-layout.js';
|
||||
import type { GamepadLike } from './gamepad-controller.js';
|
||||
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
|
||||
|
||||
const CONFIG: ResolvedControllerConfig = { ...CORE_DEFAULT_CONFIG.controller, enabled: true };
|
||||
const { repeatDelayMs, repeatIntervalMs } = CONFIG;
|
||||
@@ -155,3 +156,33 @@ test('remapped duplicate card bindings replace the defaults', () => {
|
||||
|
||||
assert.deepEqual(actions, ['cancelMerge']);
|
||||
});
|
||||
|
||||
test('dialog hints follow the button style of the controller in use', () => {
|
||||
const config: ResolvedControllerConfig = {
|
||||
...CONFIG,
|
||||
profiles: {
|
||||
'pad-ps': {
|
||||
label: 'DualSense',
|
||||
faceButtonLabels: 'playstation',
|
||||
buttonIndices: CONFIG.buttonIndices,
|
||||
bindings: CONFIG.bindings,
|
||||
reviewBindings: CONFIG.reviewBindings,
|
||||
fieldGroupingBindings: CONFIG.fieldGroupingBindings,
|
||||
},
|
||||
},
|
||||
};
|
||||
const hintsFor = (gamepadId: string) => {
|
||||
const padConfig = resolveControllerConfigForGamepad(config, gamepadId);
|
||||
return formatControllerScreenHints(
|
||||
CONTROLLER_FIELD_GROUPING_SCREEN,
|
||||
padConfig.fieldGroupingBindings,
|
||||
padConfig,
|
||||
'standard',
|
||||
);
|
||||
};
|
||||
|
||||
assert.equal(hintsFor('pad-ps').continue, '╳');
|
||||
assert.equal(hintsFor('pad-ps').toggleView, '△');
|
||||
// Controllers without their own style use controller.faceButtonLabels.
|
||||
assert.equal(hintsFor('other-pad').continue, 'A');
|
||||
});
|
||||
@@ -70,6 +70,10 @@ const VOID_ACTIONS = [
|
||||
'previousEntry',
|
||||
'nextEntry',
|
||||
'toggleMpvPause',
|
||||
'previousSubtitle',
|
||||
'nextSubtitle',
|
||||
'replaySubtitle',
|
||||
'playNextSubtitle',
|
||||
] as const;
|
||||
const DELTA_ACTIONS = ['moveSelection', 'scrollPopup', 'jumpPopup'] as const;
|
||||
|
||||
@@ -114,6 +118,10 @@ function createHarness(
|
||||
previousEntry: () => {},
|
||||
nextEntry: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
previousSubtitle: () => {},
|
||||
nextSubtitle: () => {},
|
||||
replaySubtitle: () => {},
|
||||
playNextSubtitle: () => {},
|
||||
setSecondarySubHeld: () => {},
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
@@ -288,6 +296,7 @@ test('gamepad controller uses active controller profile bindings before global b
|
||||
profiles: {
|
||||
'pad-profile': {
|
||||
label: 'Profile Pad',
|
||||
faceButtonLabels: globalConfig.faceButtonLabels,
|
||||
buttonIndices: globalConfig.buttonIndices,
|
||||
bindings: { ...globalConfig.bindings, toggleLookup: { kind: 'button', buttonIndex: 11 } },
|
||||
reviewBindings: globalConfig.reviewBindings,
|
||||
@@ -427,7 +436,8 @@ for (const c of [
|
||||
|
||||
harness.poll(0);
|
||||
|
||||
assert.deepEqual(harness.calls, c.fired ? ['playCurrentAudio', 'toggleMpvPause'] : []);
|
||||
// Pause works outside keyboard-only mode, so it is handled before the popup actions.
|
||||
assert.deepEqual(harness.calls, c.fired ? ['toggleMpvPause', 'playCurrentAudio'] : []);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -784,3 +794,99 @@ test('gamepad controller still ignores a button held when the screen opened', ()
|
||||
|
||||
assert.deepEqual(events, ['activate:pad-1:latch']);
|
||||
});
|
||||
|
||||
const SUBTITLE_NAVIGATION_BINDINGS = {
|
||||
previousSubtitle: { kind: 'button', buttonIndex: 10 },
|
||||
nextSubtitle: { kind: 'button', buttonIndex: 11 },
|
||||
replaySubtitle: { kind: 'button', buttonIndex: 8 },
|
||||
playNextSubtitle: { kind: 'button', buttonIndex: 16 },
|
||||
} as const;
|
||||
|
||||
test('gamepad controller leaves subtitle navigation unbound by default', () => {
|
||||
for (const action of [
|
||||
'previousSubtitle',
|
||||
'nextSubtitle',
|
||||
'replaySubtitle',
|
||||
'playNextSubtitle',
|
||||
] as const) {
|
||||
assert.deepEqual(CORE_DEFAULT_CONFIG.controller.bindings[action], { kind: 'none' }, action);
|
||||
}
|
||||
});
|
||||
|
||||
test('gamepad controller navigates subtitles once per press, even outside keyboard-only mode', () => {
|
||||
let pressed: number[] = [];
|
||||
const harness = createHarness({
|
||||
gamepads: () => [
|
||||
createGamepad('pad-1', {
|
||||
buttons: Object.fromEntries(pressed.map((index) => [index, PRESSED])),
|
||||
}),
|
||||
],
|
||||
config: createControllerConfig({ bindings: SUBTITLE_NAVIGATION_BINDINGS }),
|
||||
deps: { getKeyboardModeEnabled: () => false },
|
||||
});
|
||||
|
||||
harness.poll(0);
|
||||
for (const index of [11, 10, 8, 16]) {
|
||||
pressed = [index];
|
||||
// Holding the button does not repeat.
|
||||
harness.poll(16, 500, 1_000);
|
||||
pressed = [];
|
||||
harness.poll(1_016);
|
||||
}
|
||||
|
||||
assert.deepEqual(harness.calls, [
|
||||
'nextSubtitle',
|
||||
'previousSubtitle',
|
||||
'replaySubtitle',
|
||||
'playNextSubtitle',
|
||||
]);
|
||||
});
|
||||
|
||||
test('gamepad controller pauses subtitle navigation while a blocking modal is open', () => {
|
||||
let blocked = true;
|
||||
let pressed = true;
|
||||
const harness = createHarness({
|
||||
gamepads: () => [createGamepad('pad-1', { buttons: pressed ? { 11: PRESSED } : {} })],
|
||||
config: createControllerConfig({ bindings: SUBTITLE_NAVIGATION_BINDINGS }),
|
||||
deps: { getInteractionBlocked: () => blocked },
|
||||
});
|
||||
|
||||
harness.poll(0, 16);
|
||||
// Still held when the modal closes: not a fresh press.
|
||||
blocked = false;
|
||||
harness.poll(32);
|
||||
assert.deepEqual(harness.calls, []);
|
||||
|
||||
pressed = false;
|
||||
harness.poll(48);
|
||||
pressed = true;
|
||||
harness.poll(64);
|
||||
assert.deepEqual(harness.calls, ['nextSubtitle']);
|
||||
});
|
||||
|
||||
test('gamepad controller pauses mpv outside keyboard-only mode, but not under a blocking modal', () => {
|
||||
let blocked = false;
|
||||
let pressed = false;
|
||||
const harness = createHarness({
|
||||
gamepads: () => [createGamepad('pad-1', { buttons: pressed ? { 9: PRESSED } : {} })],
|
||||
config: createControllerConfig({
|
||||
bindings: { toggleMpvPause: { kind: 'button', buttonIndex: 9 } },
|
||||
}),
|
||||
deps: {
|
||||
getKeyboardModeEnabled: () => false,
|
||||
getInteractionBlocked: () => blocked,
|
||||
},
|
||||
});
|
||||
|
||||
harness.poll(0);
|
||||
pressed = true;
|
||||
// Held: fires once.
|
||||
harness.poll(16, 500);
|
||||
pressed = false;
|
||||
harness.poll(516);
|
||||
blocked = true;
|
||||
pressed = true;
|
||||
harness.poll(532);
|
||||
|
||||
assert.deepEqual(harness.calls, ['toggleMpvPause']);
|
||||
});
|
||||
@@ -18,6 +18,8 @@ export type GamepadLike = {
|
||||
index: number;
|
||||
connected: boolean;
|
||||
mapping: string;
|
||||
/** When the pad last reported new input (Gamepad API). */
|
||||
timestamp?: number;
|
||||
axes: readonly number[];
|
||||
buttons: readonly ControllerButtonState[];
|
||||
};
|
||||
@@ -56,6 +58,10 @@ type GamepadControllerOptions = {
|
||||
previousEntry: () => void;
|
||||
nextEntry: () => void;
|
||||
toggleMpvPause: () => void;
|
||||
previousSubtitle: () => void;
|
||||
nextSubtitle: () => void;
|
||||
replaySubtitle: () => void;
|
||||
playNextSubtitle: () => void;
|
||||
setSecondarySubHeld: (held: boolean) => void;
|
||||
scrollPopup: (deltaPixels: number) => void;
|
||||
jumpPopup: (deltaPixels: number) => void;
|
||||
@@ -73,14 +79,16 @@ export type HoldState = {
|
||||
initialFired: boolean;
|
||||
};
|
||||
|
||||
const DPAD_BUTTON_INDEX = {
|
||||
/** Buttons read as the D-pad on every pad, whatever its reported mapping. */
|
||||
export const DPAD_BUTTON_INDEX = {
|
||||
up: 12,
|
||||
down: 13,
|
||||
left: 14,
|
||||
right: 15,
|
||||
} as const;
|
||||
|
||||
const DPAD_AXIS_INDEX = {
|
||||
/** Axes some raw (non-standard) pads use for the D-pad. */
|
||||
export const DPAD_AXIS_INDEX = {
|
||||
horizontal: 6,
|
||||
vertical: 7,
|
||||
} as const;
|
||||
@@ -276,6 +284,15 @@ const POPUP_ONLY_ACTIONS = [
|
||||
'nextEntry',
|
||||
] as const;
|
||||
|
||||
/** Playback and subtitle actions; like the secondary hold, they do not need keyboard-only mode. */
|
||||
const WATCHING_ACTIONS = [
|
||||
'toggleMpvPause',
|
||||
'previousSubtitle',
|
||||
'nextSubtitle',
|
||||
'replaySubtitle',
|
||||
'playNextSubtitle',
|
||||
] as const;
|
||||
|
||||
export function createGamepadController(options: GamepadControllerOptions) {
|
||||
let previousActions = new Map<string, boolean>();
|
||||
let selectionHold = createHoldState();
|
||||
@@ -312,6 +329,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
index: gamepad.index,
|
||||
mapping: gamepad.mapping,
|
||||
connected: gamepad.connected,
|
||||
timestamp: gamepad.timestamp,
|
||||
})) satisfies ControllerDeviceInfo[],
|
||||
activeGamepadId,
|
||||
rawAxes: activeGamepad?.axes ? [...activeGamepad.axes] : [],
|
||||
@@ -401,7 +419,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
['playCurrentAudio', config.bindings.playCurrentAudio],
|
||||
['previousEntry', config.bindings.previousEntry],
|
||||
['nextEntry', config.bindings.nextEntry],
|
||||
['toggleMpvPause', config.bindings.toggleMpvPause],
|
||||
...WATCHING_ACTIONS.map((actionKey) => [actionKey, config.bindings[actionKey]] as const),
|
||||
] as const;
|
||||
|
||||
for (const [actionKey, binding] of discreteActions) {
|
||||
@@ -503,6 +521,19 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
),
|
||||
);
|
||||
|
||||
// Pause and subtitle navigation also work while simply watching; a blocking modal pauses them.
|
||||
if (activeConfig.enabled && !options.getInteractionBlocked()) {
|
||||
for (const actionKey of WATCHING_ACTIONS) {
|
||||
handleActionEdge(
|
||||
actionKey,
|
||||
activeConfig.bindings[actionKey],
|
||||
activeGamepad,
|
||||
activeConfig,
|
||||
options[actionKey],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (activeConfig.enabled) {
|
||||
handleActionEdge(
|
||||
'toggleKeyboardOnlyMode',
|
||||
@@ -628,14 +659,6 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
handleActionEdge(
|
||||
'toggleMpvPause',
|
||||
activeConfig.bindings.toggleMpvPause,
|
||||
activeGamepad,
|
||||
activeConfig,
|
||||
options.toggleMpvPause,
|
||||
);
|
||||
|
||||
handleSelectionAxis(
|
||||
resolveAxisBindingValue(
|
||||
activeGamepad,
|
||||
|
||||
@@ -538,10 +538,15 @@ function createKeyboardHandlerHarness() {
|
||||
let changelogKeydownCount = 0;
|
||||
let jimakuKeydownCount = 0;
|
||||
|
||||
// Synthetic pointer/mouse events dispatched on words: the lookup's fallback scan.
|
||||
let wordEventCount = 0;
|
||||
const createWordNode = (left: number) => ({
|
||||
classList: createClassList(),
|
||||
getBoundingClientRect: () => ({ left, top: 10, width: 30, height: 20 }),
|
||||
dispatchEvent: () => true,
|
||||
dispatchEvent: () => {
|
||||
wordEventCount += 1;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
let wordNodes = [createWordNode(10), createWordNode(80), createWordNode(150)];
|
||||
|
||||
@@ -618,6 +623,7 @@ function createKeyboardHandlerHarness() {
|
||||
playlistBrowserKeydownCount: () => playlistBrowserKeydownCount,
|
||||
changelogKeydownCount: () => changelogKeydownCount,
|
||||
jimakuKeydownCount: () => jimakuKeydownCount,
|
||||
wordEventCount: () => wordEventCount,
|
||||
setWordCount: (count: number) => {
|
||||
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
|
||||
},
|
||||
@@ -2554,3 +2560,57 @@ test('controller popup entry navigation forwards Alt+Up / Alt+Down only while th
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: leaving the mode cancels a lookup whose popup has not appeared yet', async () => {
|
||||
const { ctx, handlers, testGlobals, wordEventCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
// Without a popup, the lookup falls back to a synthetic scan shortly after.
|
||||
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY', ctrlKey: true });
|
||||
await wait(100);
|
||||
const afterFallback = wordEventCount();
|
||||
assert.ok(afterFallback > 0, 'the fallback scan runs');
|
||||
|
||||
// Leaving the mode before that fallback runs must cancel it.
|
||||
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY', ctrlKey: true });
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
await wait(100);
|
||||
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
|
||||
assert.equal(wordEventCount(), afterFallback, 'no scan after the mode is off');
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: leaving the mode closes an open popup', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
const closeEvents = () =>
|
||||
testGlobals.commandEvents.filter(
|
||||
(event) => event.type === 'setVisible' && event.visible === false,
|
||||
);
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
testGlobals.setPopupVisible(true);
|
||||
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
await wait(0);
|
||||
|
||||
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
|
||||
assert.equal(closeEvents().length, 1);
|
||||
|
||||
// Turning the mode on again leaves a popup alone.
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
await wait(0);
|
||||
assert.equal(closeEvents().length, 1);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
@@ -41,6 +41,8 @@ export function createKeyboardHandlers(
|
||||
openControllerSelectModal?: () => void;
|
||||
openControllerDebugModal?: () => void;
|
||||
getPlaybackPaused: () => Promise<boolean | null>;
|
||||
/** Keyboard-only mode turned on or off (keyboard shortcut or controller). */
|
||||
onKeyboardDrivenModeChanged?: (enabled: boolean) => void;
|
||||
toggleSubtitleSidebarModal?: () => void;
|
||||
},
|
||||
) {
|
||||
@@ -550,6 +552,7 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
|
||||
function setKeyboardDrivenModeEnabled(enabled: boolean): void {
|
||||
const changed = ctx.state.keyboardDrivenModeEnabled !== enabled;
|
||||
ctx.state.keyboardDrivenModeEnabled = enabled;
|
||||
ctx.state.keyboardSelectionVisible = enabled;
|
||||
if (!enabled) {
|
||||
@@ -560,6 +563,10 @@ export function createKeyboardHandlers(
|
||||
clearNativeSubtitleSelection();
|
||||
}
|
||||
syncKeyboardTokenSelection();
|
||||
// Leaving the mode means going back to watching: close the popup it was used for.
|
||||
// Done after the mode is off, so focus is not pulled back to the overlay.
|
||||
if (changed && !enabled) closeLookupWindow();
|
||||
if (changed) options.onKeyboardDrivenModeChanged?.(enabled);
|
||||
}
|
||||
|
||||
function toggleKeyboardDrivenMode(): void {
|
||||
@@ -756,6 +763,12 @@ export function createKeyboardHandlers(
|
||||
// released) and attention without a pane, so callers like the Kiku modal
|
||||
// opener leave nothing behind.
|
||||
function closeLookupWindow(): boolean {
|
||||
// Also when no popup shows yet: a lookup that has not opened one must not open it
|
||||
// later through its fallback scan (for example after leaving keyboard-only mode).
|
||||
if (lookupScanFallbackTimer !== null) {
|
||||
clearTimeout(lookupScanFallbackTimer);
|
||||
lookupScanFallbackTimer = null;
|
||||
}
|
||||
if (
|
||||
!ctx.state.yomitanPopupVisible &&
|
||||
!isYomitanPopupVisible(document) &&
|
||||
@@ -764,10 +777,6 @@ export function createKeyboardHandlers(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (lookupScanFallbackTimer !== null) {
|
||||
clearTimeout(lookupScanFallbackTimer);
|
||||
lookupScanFallbackTimer = null;
|
||||
}
|
||||
dispatchYomitanPopupVisibility(false);
|
||||
dispatchYomitanFrontendClearActiveTextSource();
|
||||
clearNativeSubtitleSelection();
|
||||
|
||||
@@ -509,6 +509,21 @@ test('popup hidden on macOS releases click-away capture back to mpv', (t) => {
|
||||
assert.deepEqual(h.ignoreCalls.at(-1), { ignore: true, forward: true });
|
||||
});
|
||||
|
||||
test('a pointer leaving a popup that just closed does not mark it open again', (t) => {
|
||||
const h = createMouseHarness(t, { platform: 'macos', popupHostVisible: true });
|
||||
h.handlers.setupYomitanObserver();
|
||||
h.fire('window', YOMITAN_POPUP_MOUSE_ENTER_EVENT);
|
||||
|
||||
// The popup hides under the pointer: hidden, then the leave it causes.
|
||||
h.popupHostVisible = false;
|
||||
h.fire('window', YOMITAN_POPUP_HIDDEN_EVENT);
|
||||
h.fire('window', YOMITAN_POPUP_MOUSE_LEAVE_EVENT);
|
||||
|
||||
assert.equal(h.ctx.state.yomitanPopupVisible, false);
|
||||
assert.equal(h.ctx.state.isOverYomitanPopup, false);
|
||||
assert.equal(h.isInteractive(), false);
|
||||
});
|
||||
|
||||
test('yomitan popup visibility marks primary subtitle hover hold while enabled', (t) => {
|
||||
const h = createMouseHarness(t);
|
||||
h.ctx.state.primaryVisibleOnYomitanPopup = true;
|
||||
|
||||
@@ -505,7 +505,10 @@ export function createMouseHandlers(
|
||||
|
||||
window.addEventListener(YOMITAN_POPUP_MOUSE_LEAVE_EVENT, () => {
|
||||
ctx.state.isOverYomitanPopup = false;
|
||||
reconcilePopupInteraction({ assumeVisible: true });
|
||||
// A popup that hides under a resting pointer also reports a leave, right after its
|
||||
// hidden event. Only keep a popup marked open if it still was; otherwise the next
|
||||
// keyboard or controller word move would look it up again and reopen it.
|
||||
reconcilePopupInteraction({ assumeVisible: yomitanPopupVisible });
|
||||
});
|
||||
|
||||
window.addEventListener('focus', () => {
|
||||
|
||||
@@ -913,6 +913,10 @@
|
||||
<span>Preferred Controller</span>
|
||||
<select id="controllerSelectPicker"></select>
|
||||
</label>
|
||||
<label class="controller-select-field">
|
||||
<span>Button Style</span>
|
||||
<select id="controllerSelectFaceButtonStyle"></select>
|
||||
</label>
|
||||
<div id="controllerSelectSummary" class="controller-select-summary"></div>
|
||||
<div id="controllerConfigList" class="controller-config-list"></div>
|
||||
<div id="controllerSelectStatus" class="runtime-options-status"></div>
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createKeyboardModeAutoPause } from './keyboard-mode-auto-pause';
|
||||
|
||||
function setup(options: { enabled?: boolean; paused?: boolean | null } = {}) {
|
||||
const commands: string[] = [];
|
||||
const state = {
|
||||
enabled: options.enabled ?? true,
|
||||
paused: options.paused === undefined ? false : options.paused,
|
||||
};
|
||||
const autoPause = createKeyboardModeAutoPause({
|
||||
isEnabled: () => state.enabled,
|
||||
getPlaybackPaused: async () => state.paused,
|
||||
sendMpvCommand: (command) => {
|
||||
commands.push(command.join(' '));
|
||||
if (command[1] === 'pause') {
|
||||
state.paused = command[2] === 'yes';
|
||||
// mpv reports the new pause state back.
|
||||
autoPause.handlePlaybackPauseChanged(state.paused);
|
||||
}
|
||||
},
|
||||
});
|
||||
/** The user pauses or plays from mpv, the controller or the keyboard. */
|
||||
const userSetsPause = (paused: boolean) => {
|
||||
state.paused = paused;
|
||||
autoPause.handlePlaybackPauseChanged(paused);
|
||||
};
|
||||
return { autoPause, commands, state, userSetsPause };
|
||||
}
|
||||
|
||||
test('entering keyboard-only mode pauses a playing video and leaving resumes it', async () => {
|
||||
const { autoPause, commands } = setup();
|
||||
|
||||
await autoPause.handleModeChanged(true);
|
||||
assert.equal(autoPause.isPausedByMode(), true);
|
||||
await autoPause.handleModeChanged(false);
|
||||
|
||||
assert.deepEqual(commands, ['set_property pause yes', 'set_property pause no']);
|
||||
});
|
||||
|
||||
test('the setting is off: keyboard-only mode leaves playback alone', async () => {
|
||||
const { autoPause, commands } = setup({ enabled: false });
|
||||
|
||||
await autoPause.handleModeChanged(true);
|
||||
await autoPause.handleModeChanged(false);
|
||||
|
||||
assert.deepEqual(commands, []);
|
||||
});
|
||||
|
||||
test('an already paused video stays paused when leaving the mode', async () => {
|
||||
const { autoPause, commands } = setup({ paused: true });
|
||||
|
||||
await autoPause.handleModeChanged(true);
|
||||
await autoPause.handleModeChanged(false);
|
||||
|
||||
assert.deepEqual(commands, []);
|
||||
});
|
||||
|
||||
test('playing the video yourself in the mode means leaving does not touch it', async () => {
|
||||
const { autoPause, commands, userSetsPause } = setup();
|
||||
|
||||
await autoPause.handleModeChanged(true);
|
||||
userSetsPause(false);
|
||||
await autoPause.handleModeChanged(false);
|
||||
|
||||
assert.deepEqual(commands, ['set_property pause yes']);
|
||||
});
|
||||
|
||||
test('playing and pausing again yourself also keeps the video paused on leaving', async () => {
|
||||
const { autoPause, commands, state, userSetsPause } = setup();
|
||||
|
||||
await autoPause.handleModeChanged(true);
|
||||
userSetsPause(false);
|
||||
userSetsPause(true);
|
||||
await autoPause.handleModeChanged(false);
|
||||
|
||||
assert.deepEqual(commands, ['set_property pause yes']);
|
||||
assert.equal(state.paused, true);
|
||||
});
|
||||
|
||||
test('leaving before the pause lookup returns does not pause afterwards', async () => {
|
||||
const commands: string[] = [];
|
||||
let answer: (paused: boolean) => void = () => {};
|
||||
const autoPause = createKeyboardModeAutoPause({
|
||||
isEnabled: () => true,
|
||||
getPlaybackPaused: () => new Promise((resolve) => (answer = resolve)),
|
||||
sendMpvCommand: (command) => commands.push(command.join(' ')),
|
||||
});
|
||||
|
||||
const entering = autoPause.handleModeChanged(true);
|
||||
await autoPause.handleModeChanged(false);
|
||||
answer(false);
|
||||
await entering;
|
||||
|
||||
assert.deepEqual(commands, []);
|
||||
assert.equal(autoPause.isPausedByMode(), false);
|
||||
});
|
||||
|
||||
test("a pause made while the pause lookup is pending stays the user's", async () => {
|
||||
const commands: string[] = [];
|
||||
let answer: (paused: boolean) => void = () => {};
|
||||
const autoPause = createKeyboardModeAutoPause({
|
||||
isEnabled: () => true,
|
||||
getPlaybackPaused: () => new Promise((resolve) => (answer = resolve)),
|
||||
sendMpvCommand: (command) => commands.push(command.join(' ')),
|
||||
});
|
||||
|
||||
const entering = autoPause.handleModeChanged(true);
|
||||
// The user pauses before the lookup answers; the answer still says "playing".
|
||||
autoPause.handlePlaybackPauseChanged(true);
|
||||
answer(false);
|
||||
await entering;
|
||||
await autoPause.handleModeChanged(false);
|
||||
|
||||
assert.deepEqual(commands, [], "leaving does not resume the user's pause");
|
||||
assert.equal(autoPause.isPausedByMode(), false);
|
||||
});
|
||||
|
||||
test('an unknown pause state is left alone', async () => {
|
||||
const { autoPause, commands } = setup({ paused: null });
|
||||
|
||||
await autoPause.handleModeChanged(true);
|
||||
await autoPause.handleModeChanged(false);
|
||||
|
||||
assert.deepEqual(commands, []);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
type MpvCommand = Array<string | number>;
|
||||
|
||||
/**
|
||||
* Optional pause while keyboard-only mode is on (`subtitleStyle.autoPauseVideoOnKeyboardMode`).
|
||||
*
|
||||
* Entering the mode pauses a playing video. Leaving it resumes only a pause the mode made
|
||||
* itself: if the video was already paused, or the user played or paused it while in the
|
||||
* mode, leaving does nothing. The user taking over is seen through mpv's pause changes.
|
||||
*/
|
||||
export function createKeyboardModeAutoPause(deps: {
|
||||
isEnabled: () => boolean;
|
||||
getPlaybackPaused: () => Promise<boolean | null>;
|
||||
sendMpvCommand: (command: MpvCommand) => void;
|
||||
}) {
|
||||
let pausedByMode = false;
|
||||
/** Our own pause command is still to be reported back by mpv. */
|
||||
let awaitingOwnPause = false;
|
||||
/** Bumped on every mode change so a slow pause lookup cannot act on a stale mode. */
|
||||
let generation = 0;
|
||||
/** Bumped on every pause change, so a lookup answered after one is known to be stale. */
|
||||
let playbackChanges = 0;
|
||||
|
||||
async function enterMode(current: number): Promise<void> {
|
||||
if (!deps.isEnabled()) return;
|
||||
const changesBefore = playbackChanges;
|
||||
let paused: boolean | null = null;
|
||||
try {
|
||||
paused = await deps.getPlaybackPaused();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// Left the mode again (or re-entered) while waiting: this request is stale. So is the
|
||||
// answer if the user paused or played meanwhile: that playback state is theirs.
|
||||
if (current !== generation || playbackChanges !== changesBefore || paused !== false) return;
|
||||
pausedByMode = true;
|
||||
awaitingOwnPause = true;
|
||||
deps.sendMpvCommand(['set_property', 'pause', 'yes']);
|
||||
}
|
||||
|
||||
function leaveMode(): void {
|
||||
if (!pausedByMode) return;
|
||||
pausedByMode = false;
|
||||
awaitingOwnPause = false;
|
||||
deps.sendMpvCommand(['set_property', 'pause', 'no']);
|
||||
}
|
||||
|
||||
return {
|
||||
/** Keyboard-only mode turned on or off, from the keyboard or the controller. */
|
||||
handleModeChanged(enabled: boolean): Promise<void> {
|
||||
generation += 1;
|
||||
if (enabled) return enterMode(generation);
|
||||
leaveMode();
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
/** mpv's pause property changed, from any source. */
|
||||
handlePlaybackPauseChanged(paused: boolean): void {
|
||||
playbackChanges += 1;
|
||||
if (!pausedByMode) return;
|
||||
if (paused && awaitingOwnPause) {
|
||||
awaitingOwnPause = false;
|
||||
return;
|
||||
}
|
||||
// Anything else (a resume, or a pause after one) is the user's own playback choice.
|
||||
pausedByMode = false;
|
||||
awaitingOwnPause = false;
|
||||
},
|
||||
|
||||
isPausedByMode: (): boolean => pausedByMode,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { CORE_DEFAULT_CONFIG } from '../../config/definitions/defaults-core';
|
||||
import type { ResolvedControllerConfig } from '../../types';
|
||||
import { findControllerBindingIssues } from './controller-binding-issues.js';
|
||||
import { CONTROLLER_BINDING_DEFINITIONS } from './controller-config-form.js';
|
||||
|
||||
type Bindings = ResolvedControllerConfig['bindings'];
|
||||
const DEFAULTS = CORE_DEFAULT_CONFIG.controller.bindings as Bindings;
|
||||
|
||||
const issuesWith = (changes: Partial<Bindings>) =>
|
||||
findControllerBindingIssues(CONTROLLER_BINDING_DEFINITIONS, { ...DEFAULTS, ...changes });
|
||||
|
||||
test('the default bindings share no input', () => {
|
||||
assert.deepEqual(issuesWith({}), {});
|
||||
});
|
||||
|
||||
test('two actions on the same button both warn', () => {
|
||||
// Next Audio is on R1 (button 5) by default.
|
||||
assert.deepEqual(issuesWith({ nextSubtitle: { kind: 'button', buttonIndex: 5 } }), {
|
||||
nextSubtitle: 'Same as Next Audio',
|
||||
nextAudio: 'Same as Next Subtitle',
|
||||
});
|
||||
});
|
||||
|
||||
test('a button on the D-pad clashes with a D-pad row that is still on', () => {
|
||||
assert.deepEqual(issuesWith({ nextSubtitle: { kind: 'button', buttonIndex: 15 } }), {
|
||||
nextSubtitle: 'Same as Token Move (D-pad)',
|
||||
'leftStickHorizontal:dpad': 'Same as Next Subtitle',
|
||||
});
|
||||
// Clearing the D-pad row (as the docs advise) removes the warning.
|
||||
assert.deepEqual(
|
||||
issuesWith({
|
||||
nextSubtitle: { kind: 'button', buttonIndex: 15 },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'none' },
|
||||
}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
test('a D-pad reported as a hat axis also counts as the D-pad', () => {
|
||||
assert.deepEqual(
|
||||
issuesWith({ previousEntry: { kind: 'axis', axisIndex: 7, direction: 'negative' } }),
|
||||
{
|
||||
previousEntry: 'Same as Popup Scroll (D-pad)',
|
||||
'leftStickVertical:dpad': 'Same as Previous Entry',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('a stick direction clashes with a stick action on that axis', () => {
|
||||
const bindings = {
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
holdSecondarySub: { kind: 'axis', axisIndex: 3, direction: 'negative' },
|
||||
} as const;
|
||||
assert.deepEqual(issuesWith(bindings), {
|
||||
holdSecondarySub: 'Same as Popup Jump (Stick)',
|
||||
rightStickVertical: 'Same as Show Secondary Subtitle (Hold)',
|
||||
});
|
||||
});
|
||||
|
||||
test('opposite directions of one stick are different inputs', () => {
|
||||
assert.deepEqual(
|
||||
issuesWith({
|
||||
previousSubtitle: { kind: 'axis', axisIndex: 2, direction: 'negative' },
|
||||
nextSubtitle: { kind: 'axis', axisIndex: 2, direction: 'positive' },
|
||||
}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
test('two stick actions on the same stick both warn', () => {
|
||||
assert.deepEqual(
|
||||
issuesWith({ leftStickVertical: { kind: 'axis', axisIndex: 0, dpadFallback: 'vertical' } }),
|
||||
{
|
||||
leftStickHorizontal: 'Same as Popup Scroll (Stick)',
|
||||
leftStickVertical: 'Same as Token Move (Stick)',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('Alt Horizontal does nothing, so it never clashes', () => {
|
||||
// On standard pads the right stick's vertical axis is 3, the default of Alt Horizontal.
|
||||
assert.deepEqual(
|
||||
issuesWith({ rightStickVertical: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' } }),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
test('a row shared by several actions lists them all', () => {
|
||||
assert.equal(
|
||||
issuesWith({
|
||||
mineCard: { kind: 'button', buttonIndex: 5 },
|
||||
nextSubtitle: { kind: 'button', buttonIndex: 5 },
|
||||
}).nextAudio,
|
||||
'Same as Mine Card, Next Subtitle',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { DPAD_AXIS_INDEX, DPAD_BUTTON_INDEX } from '../handlers/gamepad-controller.js';
|
||||
import type {
|
||||
ControllerDpadFallback,
|
||||
ResolvedControllerAxisBinding,
|
||||
ResolvedControllerConfig,
|
||||
ResolvedControllerDiscreteBinding,
|
||||
} from '../../types';
|
||||
|
||||
type ControllerBindings = ResolvedControllerConfig['bindings'];
|
||||
type ControllerBindingActionId = keyof ControllerBindings;
|
||||
|
||||
/** What the checker needs from an Alt+C row definition. */
|
||||
export type ControllerBindingIssueDefinition = {
|
||||
id: ControllerBindingActionId;
|
||||
label: string;
|
||||
bindingType: 'discrete' | 'axis';
|
||||
};
|
||||
|
||||
/**
|
||||
* Rows that do nothing in SubMiner: no action reads `rightStickHorizontal`, so it cannot
|
||||
* clash with anything.
|
||||
*/
|
||||
const INACTIVE_BINDINGS: ReadonlySet<ControllerBindingActionId> = new Set(['rightStickHorizontal']);
|
||||
|
||||
/** Row key of a stick binding's D-pad row in Alt+C. */
|
||||
export function getControllerDpadRowKey(actionId: ControllerBindingActionId): string {
|
||||
return `${actionId}:dpad`;
|
||||
}
|
||||
|
||||
const buttonKey = (buttonIndex: number) => `button:${buttonIndex}`;
|
||||
const axisKey = (axisIndex: number, direction: 'negative' | 'positive') =>
|
||||
`axis:${axisIndex}:${direction}`;
|
||||
|
||||
function discreteInputs(binding: ResolvedControllerDiscreteBinding): string[] {
|
||||
if (binding.kind === 'none') return [];
|
||||
if ('buttonIndex' in binding) return [buttonKey(binding.buttonIndex)];
|
||||
return [axisKey(binding.axisIndex, binding.direction)];
|
||||
}
|
||||
|
||||
function stickInputs(binding: ResolvedControllerAxisBinding): string[] {
|
||||
if (binding.kind === 'none') return [];
|
||||
return [axisKey(binding.axisIndex, 'negative'), axisKey(binding.axisIndex, 'positive')];
|
||||
}
|
||||
|
||||
/** The D-pad is read from buttons 12-15 and, on some raw pads, from a hat axis. */
|
||||
function dpadInputs(fallback: ControllerDpadFallback): string[] {
|
||||
if (fallback === 'horizontal') {
|
||||
return [
|
||||
buttonKey(DPAD_BUTTON_INDEX.left),
|
||||
buttonKey(DPAD_BUTTON_INDEX.right),
|
||||
axisKey(DPAD_AXIS_INDEX.horizontal, 'negative'),
|
||||
axisKey(DPAD_AXIS_INDEX.horizontal, 'positive'),
|
||||
];
|
||||
}
|
||||
if (fallback === 'vertical') {
|
||||
return [
|
||||
buttonKey(DPAD_BUTTON_INDEX.up),
|
||||
buttonKey(DPAD_BUTTON_INDEX.down),
|
||||
axisKey(DPAD_AXIS_INDEX.vertical, 'negative'),
|
||||
axisKey(DPAD_AXIS_INDEX.vertical, 'positive'),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Warnings for the main Alt+C list, keyed by row (the action id, or its D-pad row key):
|
||||
* a row warns when another row uses one of its inputs. Every such pair fires together at
|
||||
* some point, since the situations nest (always, then keyboard-only mode, then popup open),
|
||||
* so a shared input is always worth pointing out. Dialog layouts are checked separately:
|
||||
* they pause these actions while open.
|
||||
*/
|
||||
export function findControllerBindingIssues(
|
||||
definitions: readonly ControllerBindingIssueDefinition[],
|
||||
bindings: ControllerBindings,
|
||||
): Partial<Record<string, string>> {
|
||||
const rows: { key: string; label: string; inputs: string[] }[] = [];
|
||||
for (const definition of definitions) {
|
||||
if (INACTIVE_BINDINGS.has(definition.id)) continue;
|
||||
const binding = bindings[definition.id];
|
||||
if (!binding) continue;
|
||||
if (definition.bindingType === 'axis') {
|
||||
const axisBinding = binding as ResolvedControllerAxisBinding;
|
||||
rows.push({
|
||||
key: definition.id,
|
||||
label: `${definition.label} (Stick)`,
|
||||
inputs: stickInputs(axisBinding),
|
||||
});
|
||||
rows.push({
|
||||
key: getControllerDpadRowKey(definition.id),
|
||||
label: `${definition.label} (D-pad)`,
|
||||
inputs: dpadInputs(axisBinding.kind === 'none' ? 'none' : axisBinding.dpadFallback),
|
||||
});
|
||||
} else {
|
||||
rows.push({
|
||||
key: definition.id,
|
||||
label: definition.label,
|
||||
inputs: discreteInputs(binding as ResolvedControllerDiscreteBinding),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const issues: Partial<Record<string, string>> = {};
|
||||
for (const row of rows) {
|
||||
const inputs = new Set(row.inputs);
|
||||
const clashes = rows
|
||||
.filter((other) => other !== row && other.inputs.some((input) => inputs.has(input)))
|
||||
.map((other) => other.label);
|
||||
if (clashes.length > 0) issues[row.key] = `Same as ${clashes.join(', ')}`;
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { CORE_DEFAULT_CONFIG } from '../../config/definitions/defaults-core';
|
||||
import { getDefaultControllerFieldGroupingBindings } from '../../shared/controller-field-grouping';
|
||||
import { getDefaultControllerReviewBindings } from '../../shared/controller-review';
|
||||
import { CONTROLLER_SCREENS } from '../../shared/controller-screens';
|
||||
import type { ResolvedControllerConfig } from '../../types';
|
||||
import type { ControllerFaceButtonLabels, ResolvedControllerConfig } from '../../types';
|
||||
import type { ControllerScreenLearning } from './controller-config-form';
|
||||
import { createControllerConfigForm } from './controller-config-form.js';
|
||||
|
||||
@@ -24,6 +24,10 @@ const BINDINGS: ResolvedControllerConfig['bindings'] = {
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
playNextSubtitle: { kind: 'none' },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
@@ -45,6 +49,12 @@ class FakeElement {
|
||||
this.className = [...new Set([...this.classes(), ...tokens])].join(' ');
|
||||
},
|
||||
contains: (token: string) => this.classes().includes(token),
|
||||
toggle: (token: string, force?: boolean) => {
|
||||
const on = force ?? !this.classes().includes(token);
|
||||
const rest = this.classes().filter((existing) => existing !== token);
|
||||
this.className = (on ? [...rest, token] : rest).join(' ');
|
||||
return on;
|
||||
},
|
||||
};
|
||||
|
||||
set innerHTML(value: string) {
|
||||
@@ -99,7 +109,7 @@ function renderForm(
|
||||
options: {
|
||||
bindings?: typeof BINDINGS;
|
||||
mapping?: string;
|
||||
faceButtonLabels?: 'xbox' | 'nintendo';
|
||||
faceButtonLabels?: ControllerFaceButtonLabels;
|
||||
} = {},
|
||||
) {
|
||||
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
@@ -148,7 +158,7 @@ test('controller config form renders grouped rows with friendly binding labels',
|
||||
|
||||
assert.deepEqual(
|
||||
findAll(container, 'controller-config-group').map((el) => el.textContent),
|
||||
['Lookup', 'Playback', 'Popup Audio', 'Navigation'],
|
||||
['Lookup', 'Playback', 'Subtitles', 'Popup Audio', 'Navigation'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
findAll(container, 'controller-config-row').map((row) => [
|
||||
@@ -165,6 +175,10 @@ test('controller config form renders grouped rows with friendly binding labels',
|
||||
['Toggle MPV Pause', 'R3 / RS'],
|
||||
['Show Secondary Subtitle (Hold)', 'None'],
|
||||
['Quit MPV', 'Back / Select'],
|
||||
['Previous Subtitle', 'None'],
|
||||
['Next Subtitle', 'None'],
|
||||
['Replay Subtitle', 'None'],
|
||||
['Play Next Subtitle', 'None'],
|
||||
['Previous Audio', 'None'],
|
||||
['Next Audio', 'RB / R1'],
|
||||
['Play Current Audio', 'LB / L1'],
|
||||
@@ -180,7 +194,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'],
|
||||
);
|
||||
assert.deepEqual(findAll(container, 'expanded'), []);
|
||||
assert.deepEqual(findAll(container, 'controller-config-edit-panel'), []);
|
||||
@@ -297,6 +311,7 @@ type ReviewFormOptions = {
|
||||
reviewBindings?: ResolvedControllerConfig['reviewBindings'];
|
||||
fieldGroupingBindings?: ResolvedControllerConfig['fieldGroupingBindings'];
|
||||
learning?: ControllerScreenLearning | null;
|
||||
faceButtonLabels?: ControllerFaceButtonLabels;
|
||||
};
|
||||
|
||||
/** Renders the form with the dialog groups; `act` runs while `document` is still stubbed. */
|
||||
@@ -340,7 +355,7 @@ function withReviewForm(
|
||||
},
|
||||
getScreenInputContext: () => ({
|
||||
mapping: 'standard',
|
||||
faceButtonLabels: 'xbox',
|
||||
faceButtonLabels: options.faceButtonLabels ?? 'xbox',
|
||||
buttonIndices: CORE_DEFAULT_CONFIG.controller.buttonIndices,
|
||||
}),
|
||||
getScreenLearning: () => options.learning ?? null,
|
||||
@@ -430,6 +445,43 @@ test('controller config form warns about review bindings that clash', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('controller config form warns when main actions share an input', () => {
|
||||
const { container, row } = renderForm(null, {
|
||||
bindings: { ...BINDINGS, nextSubtitle: { kind: 'button', buttonIndex: 15 } },
|
||||
mapping: 'standard',
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
findAll(container, 'controller-config-warning').map((el) => el.textContent),
|
||||
['\u26a0 Same as Token Move (D-pad)', '\u26a0 Same as Next Subtitle'],
|
||||
);
|
||||
assert.equal(row('Next Subtitle').classList.contains('has-warning'), true);
|
||||
assert.equal(row('Token Move (D-pad)').classList.contains('has-warning'), true);
|
||||
assert.equal(row('Next Audio').classList.contains('has-warning'), false);
|
||||
});
|
||||
|
||||
test('an edit panel slides in when it opens, not each time the list is redrawn', () => {
|
||||
withReviewForm({}, ({ container, reviewRow }) => {
|
||||
const panel = () => findOne(container, 'controller-config-edit-panel');
|
||||
const kept = () => panel().classList.contains('controller-config-edit-panel-kept');
|
||||
|
||||
reviewRow('Toggle Lookup').click();
|
||||
assert.equal(kept(), false, 'opening animates');
|
||||
|
||||
// Any other click redraws the whole list; the open panel is recreated as it was.
|
||||
findOne(container, 'controller-config-group-toggle').click();
|
||||
assert.equal(kept(), true);
|
||||
|
||||
reviewRow('Toggle Lookup').click();
|
||||
assert.deepEqual(findAll(container, 'controller-config-edit-panel'), []);
|
||||
reviewRow('Toggle Lookup').click();
|
||||
assert.equal(kept(), false, 'reopening animates again');
|
||||
|
||||
reviewRow('Close Lookup').click();
|
||||
assert.equal(kept(), false, 'another row opening animates');
|
||||
});
|
||||
});
|
||||
|
||||
/** The dialog group headers, in order. */
|
||||
const groupHeaders = (container: FakeElement) =>
|
||||
findAll(container, 'controller-config-group-toggle');
|
||||
@@ -483,3 +535,30 @@ test('learning a duplicate card action opens only that group', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('controller config form names face buttons with PlayStation symbols when configured', () => {
|
||||
const badge = (row: FakeElement) => findOne(row, 'controller-config-badge').textContent;
|
||||
const bindings: typeof BINDINGS = {
|
||||
...BINDINGS,
|
||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
||||
mineCard: { kind: 'button', buttonIndex: 2 },
|
||||
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
||||
};
|
||||
|
||||
const form = renderForm(null, { bindings, mapping: 'standard', faceButtonLabels: 'playstation' });
|
||||
assert.equal(badge(form.row('Toggle Lookup')), '╳');
|
||||
assert.equal(badge(form.row('Close Lookup')), '○');
|
||||
assert.equal(badge(form.row('Mine Card')), '□');
|
||||
assert.equal(badge(form.row('Toggle Keyboard-Only Mode')), '△');
|
||||
assert.equal(badge(form.row('Next Audio')), 'RB / R1');
|
||||
|
||||
// The dialog groups print the same symbols.
|
||||
withReviewForm({ faceButtonLabels: 'playstation' }, ({ container, reviewRow }) => {
|
||||
findOne(container, 'controller-config-group-toggle').click();
|
||||
assert.equal(badgeOf(reviewRow('Use this timing')), '╳');
|
||||
assert.equal(badgeOf(reviewRow('Keep editing')), '○');
|
||||
assert.equal(badgeOf(reviewRow('Play selection')), '□');
|
||||
assert.equal(badgeOf(reviewRow('Reset timing')), '△');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
findControllerScreenBindingIssues,
|
||||
getControllerFaceButtonName,
|
||||
formatControllerReviewBinding,
|
||||
type ControllerReviewInputContext,
|
||||
type ControllerScreenActionDefinition,
|
||||
@@ -7,6 +8,10 @@ import {
|
||||
type ControllerScreenDefinition,
|
||||
} from '../../shared/controller-review';
|
||||
import type { ControllerScreenId } from '../../shared/controller-screens';
|
||||
import {
|
||||
findControllerBindingIssues,
|
||||
getControllerDpadRowKey,
|
||||
} from './controller-binding-issues.js';
|
||||
import type { ResolvedControllerReviewBinding } from '../../types';
|
||||
|
||||
/** A dialog layout shown as a collapsible group in Alt+C. */
|
||||
@@ -102,6 +107,34 @@ export const CONTROLLER_BINDING_DEFINITIONS: ControllerBindingDefinition[] = [
|
||||
bindingType: 'discrete',
|
||||
defaultBinding: { kind: 'button', buttonIndex: 6 },
|
||||
},
|
||||
{
|
||||
id: 'previousSubtitle',
|
||||
label: 'Previous Subtitle',
|
||||
group: 'Subtitles',
|
||||
bindingType: 'discrete',
|
||||
defaultBinding: { kind: 'none' },
|
||||
},
|
||||
{
|
||||
id: 'nextSubtitle',
|
||||
label: 'Next Subtitle',
|
||||
group: 'Subtitles',
|
||||
bindingType: 'discrete',
|
||||
defaultBinding: { kind: 'none' },
|
||||
},
|
||||
{
|
||||
id: 'replaySubtitle',
|
||||
label: 'Replay Subtitle',
|
||||
group: 'Subtitles',
|
||||
bindingType: 'discrete',
|
||||
defaultBinding: { kind: 'none' },
|
||||
},
|
||||
{
|
||||
id: 'playNextSubtitle',
|
||||
label: 'Play Next Subtitle',
|
||||
group: 'Subtitles',
|
||||
bindingType: 'discrete',
|
||||
defaultBinding: { kind: 'none' },
|
||||
},
|
||||
{
|
||||
id: 'previousAudio',
|
||||
label: 'Previous Audio',
|
||||
@@ -240,9 +273,6 @@ const W3C_STANDARD_AXIS_NAMES: Record<number, string> = {
|
||||
type ControllerMapping = string | null | undefined;
|
||||
type FaceButtonLabels = ResolvedControllerConfig['faceButtonLabels'] | undefined;
|
||||
|
||||
/** Nintendo-style pads print B at the bottom and A on the right; positions 0-3 are the same. */
|
||||
const NINTENDO_FACE_BUTTON_NAMES: Record<number, string> = { 0: 'B', 1: 'A', 2: 'Y', 3: 'X' };
|
||||
|
||||
const DPAD_FALLBACK_LABELS: Record<ControllerDpadFallback, string> = {
|
||||
none: 'None',
|
||||
horizontal: 'D-pad \u2194',
|
||||
@@ -254,9 +284,13 @@ export function getFriendlyButtonName(
|
||||
mapping?: ControllerMapping,
|
||||
faceButtonLabels?: FaceButtonLabels,
|
||||
): string {
|
||||
const nintendoName =
|
||||
faceButtonLabels === 'nintendo' ? NINTENDO_FACE_BUTTON_NAMES[buttonIndex] : undefined;
|
||||
if (nintendoName) return nintendoName;
|
||||
// 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;
|
||||
const names = mapping === 'standard' ? W3C_STANDARD_BUTTON_NAMES : RAW_LAYOUT_BUTTON_NAMES;
|
||||
return names[buttonIndex] ?? `Button ${buttonIndex}`;
|
||||
}
|
||||
@@ -326,6 +360,8 @@ export function createControllerConfigForm(options: {
|
||||
getControllerMapping?: () => ControllerMapping;
|
||||
/** `controller.faceButtonLabels`; names the face buttons as printed on the pad. */
|
||||
getFaceButtonLabels?: () => FaceButtonLabels;
|
||||
/** True until the window sees a controller: rows are a preview and actions are dimmed. */
|
||||
isEditingLocked?: () => boolean;
|
||||
/** Dialog layouts (review, field grouping) of the controller being configured. */
|
||||
getScreenGroups?: () => ControllerScreenGroup[];
|
||||
getScreenInputContext?: () => ControllerReviewInputContext;
|
||||
@@ -336,14 +372,35 @@ export function createControllerConfigForm(options: {
|
||||
onScreenResetAll?: (screenId: ControllerScreenId) => void;
|
||||
}) {
|
||||
let expandedRowKey: ExpandedRowKey | null = null;
|
||||
/**
|
||||
* Row whose edit panel the previous render showed. Every change rebuilds the list, so a
|
||||
* panel that stays open is recreated; it only slides in when it was not open before.
|
||||
*/
|
||||
let previousPanelRowKey: ExpandedRowKey | null = null;
|
||||
let panelRowKey: ExpandedRowKey | null = null;
|
||||
/** Dialog groups start collapsed; this holds the opened ones. */
|
||||
const expandedScreens = new Set<ControllerScreenId>();
|
||||
|
||||
function render(): void {
|
||||
panelRowKey = null;
|
||||
try {
|
||||
renderRows();
|
||||
} finally {
|
||||
previousPanelRowKey = panelRowKey;
|
||||
}
|
||||
}
|
||||
|
||||
function renderRows(): void {
|
||||
options.container.innerHTML = '';
|
||||
options.container.classList?.toggle(
|
||||
'controller-config-locked',
|
||||
options.isEditingLocked?.() ?? false,
|
||||
);
|
||||
let lastGroup = '';
|
||||
const learningActionId = options.getLearningActionId();
|
||||
const dpadLearningActionId = options.getDpadLearningActionId();
|
||||
const bindings = options.getBindings();
|
||||
const issues = findControllerBindingIssues(CONTROLLER_BINDING_DEFINITIONS, bindings);
|
||||
|
||||
// Auto-expand when learning starts
|
||||
if (learningActionId) {
|
||||
@@ -361,17 +418,23 @@ export function createControllerConfigForm(options: {
|
||||
lastGroup = definition.group;
|
||||
}
|
||||
|
||||
const binding = options.getBindings()[definition.id];
|
||||
const binding = bindings[definition.id];
|
||||
|
||||
if (definition.bindingType === 'axis') {
|
||||
renderAxisStickRow(definition, binding as ResolvedControllerAxisBinding, learningActionId);
|
||||
renderAxisStickRow(
|
||||
definition,
|
||||
binding as ResolvedControllerAxisBinding,
|
||||
learningActionId,
|
||||
issues[definition.id],
|
||||
);
|
||||
renderAxisDpadRow(
|
||||
definition,
|
||||
binding as ResolvedControllerAxisBinding,
|
||||
dpadLearningActionId,
|
||||
issues[getControllerDpadRowKey(definition.id)],
|
||||
);
|
||||
} else {
|
||||
renderDiscreteRow(definition, binding, learningActionId);
|
||||
renderDiscreteRow(definition, binding, learningActionId, issues[definition.id]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,7 +548,7 @@ export function createControllerConfigForm(options: {
|
||||
: holdLabel
|
||||
? `Holding ${holdLabel}: press another button for a combo, or let go to use ${holdLabel} alone\u2026`
|
||||
: 'Press a button, or hold one and press another for a combo\u2026';
|
||||
const panel = createEditPanel(hint, isLearning, {
|
||||
const panel = createEditPanel(rowKey, hint, isLearning, {
|
||||
onLearn: learn,
|
||||
onClear: (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -501,6 +564,7 @@ export function createControllerConfigForm(options: {
|
||||
definition: ControllerBindingDefinition,
|
||||
binding: ResolvedControllerConfig['bindings'][ControllerBindingActionId],
|
||||
learningActionId: ControllerBindingActionId | null,
|
||||
issue: string | undefined,
|
||||
): void {
|
||||
const rowKey = definition.id as string;
|
||||
const isExpanded = expandedRowKey === rowKey;
|
||||
@@ -526,6 +590,7 @@ export function createControllerConfigForm(options: {
|
||||
e.stopPropagation();
|
||||
options.onReset(definition.id);
|
||||
},
|
||||
issue,
|
||||
);
|
||||
row.addEventListener('click', () => {
|
||||
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
||||
@@ -537,7 +602,7 @@ export function createControllerConfigForm(options: {
|
||||
const hint = isLearning
|
||||
? 'Press a button, trigger, or move a stick\u2026'
|
||||
: `Currently: ${formatControllerBindingSummary(binding)}`;
|
||||
const panel = createEditPanel(hint, isLearning, {
|
||||
const panel = createEditPanel(rowKey, hint, isLearning, {
|
||||
onLearn: (e) => {
|
||||
e.stopPropagation();
|
||||
options.onLearn(definition.id, definition.bindingType);
|
||||
@@ -559,6 +624,7 @@ export function createControllerConfigForm(options: {
|
||||
definition: ControllerBindingDefinition,
|
||||
binding: ResolvedControllerAxisBinding,
|
||||
learningActionId: ControllerBindingActionId | null,
|
||||
issue: string | undefined,
|
||||
): void {
|
||||
const rowKey = definition.id as string;
|
||||
const isExpanded = expandedRowKey === rowKey;
|
||||
@@ -580,6 +646,7 @@ export function createControllerConfigForm(options: {
|
||||
e.stopPropagation();
|
||||
options.onReset(definition.id);
|
||||
},
|
||||
issue,
|
||||
);
|
||||
row.addEventListener('click', () => {
|
||||
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
||||
@@ -590,7 +657,7 @@ export function createControllerConfigForm(options: {
|
||||
if (isExpanded) {
|
||||
const summary = binding.kind === 'none' ? 'Disabled' : `Axis ${binding.axisIndex}`;
|
||||
const hint = isLearning ? 'Move a stick or trigger\u2026' : `Currently: ${summary}`;
|
||||
const panel = createEditPanel(hint, isLearning, {
|
||||
const panel = createEditPanel(rowKey, hint, isLearning, {
|
||||
onLearn: (e) => {
|
||||
e.stopPropagation();
|
||||
options.onLearn(definition.id, 'axis');
|
||||
@@ -612,8 +679,9 @@ export function createControllerConfigForm(options: {
|
||||
definition: ControllerBindingDefinition,
|
||||
binding: ResolvedControllerAxisBinding,
|
||||
dpadLearningActionId: ControllerBindingActionId | null,
|
||||
issue: string | undefined,
|
||||
): void {
|
||||
const rowKey = `${definition.id as string}:dpad`;
|
||||
const rowKey = getControllerDpadRowKey(definition.id);
|
||||
const isExpanded = expandedRowKey === rowKey;
|
||||
const isLearning = dpadLearningActionId === definition.id;
|
||||
|
||||
@@ -636,6 +704,7 @@ export function createControllerConfigForm(options: {
|
||||
e.stopPropagation();
|
||||
options.onDpadReset(definition.id);
|
||||
},
|
||||
issue,
|
||||
);
|
||||
row.addEventListener('click', () => {
|
||||
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
||||
@@ -647,7 +716,7 @@ export function createControllerConfigForm(options: {
|
||||
const hint = isLearning
|
||||
? 'Press a D-pad direction\u2026'
|
||||
: `Currently: ${DPAD_FALLBACK_LABELS[dpadFallback]}`;
|
||||
const panel = createEditPanel(hint, isLearning, {
|
||||
const panel = createEditPanel(rowKey, hint, isLearning, {
|
||||
onLearn: (e) => {
|
||||
e.stopPropagation();
|
||||
options.onDpadLearn(definition.id);
|
||||
@@ -729,6 +798,7 @@ export function createControllerConfigForm(options: {
|
||||
}
|
||||
|
||||
function createEditPanel(
|
||||
rowKey: ExpandedRowKey,
|
||||
hintText: string,
|
||||
isLearning: boolean,
|
||||
callbacks: {
|
||||
@@ -739,6 +809,8 @@ export function createControllerConfigForm(options: {
|
||||
): HTMLDivElement {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'controller-config-edit-panel';
|
||||
if (rowKey === previousPanelRowKey) panel.classList.add('controller-config-edit-panel-kept');
|
||||
panelRowKey = rowKey;
|
||||
|
||||
const inner = document.createElement('div');
|
||||
inner.className = 'controller-config-edit-inner';
|
||||
|
||||
@@ -77,6 +77,10 @@ test('controller debug modal renders active controller axes, buttons, and config
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
playNextSubtitle: { kind: 'none' },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
@@ -198,6 +202,10 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
playNextSubtitle: { kind: 'none' },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
@@ -318,6 +326,10 @@ test('controller debug modal stays closed and notifies when controller support i
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
playNextSubtitle: { kind: 'none' },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
|
||||
@@ -2,7 +2,11 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createRendererState } from '../state.js';
|
||||
import { createControllerSelectModal } from './controller-select.js';
|
||||
import {
|
||||
createControllerSelectModal,
|
||||
findMostRecentlyUsedIndex,
|
||||
shortControllerName,
|
||||
} from './controller-select.js';
|
||||
import { getDefaultControllerReviewBindings } from '../../shared/controller-review';
|
||||
import { getDefaultControllerFieldGroupingBindings } from '../../shared/controller-field-grouping';
|
||||
|
||||
@@ -159,6 +163,10 @@ function buildContext() {
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
playNextSubtitle: { kind: 'none' },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
@@ -179,6 +187,7 @@ function buildContext() {
|
||||
controllerSelectModal: { classList: createClassList(['hidden']), setAttribute: () => {} },
|
||||
controllerSelectClose: createFakeElement(),
|
||||
controllerSelectPicker: createFakeElement(),
|
||||
controllerSelectFaceButtonStyle: createFakeElement(),
|
||||
controllerSelectSummary: createFakeElement(),
|
||||
controllerConfigList: createFakeElement(),
|
||||
controllerSelectStatus: { textContent: '', classList: createClassList() },
|
||||
@@ -328,6 +337,7 @@ test('controller select modal reset control stores the default binding in the se
|
||||
state.controllerConfig.profiles = {
|
||||
'pad-1': {
|
||||
label: 'pad-1',
|
||||
faceButtonLabels: state.controllerConfig.faceButtonLabels,
|
||||
buttonIndices: state.controllerConfig.buttonIndices,
|
||||
bindings: {
|
||||
...state.controllerConfig.bindings,
|
||||
@@ -429,7 +439,7 @@ test('controller select modal binding badge starts learn mode and persists bindi
|
||||
}
|
||||
});
|
||||
|
||||
test('controller select modal learn mode falls back to global bindings without a controller', async () => {
|
||||
test('controller select modal locks editing until this window sees a controller', async () => {
|
||||
const domHandle = installFakeDom();
|
||||
const saved: unknown[] = [];
|
||||
|
||||
@@ -448,44 +458,45 @@ test('controller select modal learn mode falls back to global bindings without a
|
||||
|
||||
try {
|
||||
const { state, dom } = buildContext();
|
||||
// A freshly opened window sees no pad until a button is pressed in it.
|
||||
state.connectedGamepads = [];
|
||||
// No controller visible and none saved: edits go to the global bindings.
|
||||
state.controllerConfig!.preferredGamepadId = '';
|
||||
state.activeGamepadId = null;
|
||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
notifyControllerDisabled: () => {},
|
||||
});
|
||||
|
||||
modal.wireDomEvents();
|
||||
modal.openControllerSelectModal();
|
||||
|
||||
const firstRow = dom.controllerConfigList.children[1];
|
||||
firstRow.dispatch('click');
|
||||
const editPanel = dom.controllerConfigList.children[2];
|
||||
const learnButton = editPanel.children[0].children[1].children[0];
|
||||
learnButton.dispatch('click');
|
||||
const press = 'Press any button on your controller to edit its bindings.';
|
||||
assert.equal(dom.controllerSelectStatus.textContent, press);
|
||||
assert.equal(dom.controllerConfigList.classList.contains('controller-config-locked'), true);
|
||||
assert.equal(dom.controllerSelectFaceButtonStyle.disabled, true);
|
||||
|
||||
state.controllerRawButtons = Array.from({ length: 12 }, () => ({
|
||||
value: 0,
|
||||
pressed: false,
|
||||
touched: false,
|
||||
// Learn and Clear do nothing yet.
|
||||
const row = findReviewRow(dom.controllerConfigList, 'Toggle Lookup');
|
||||
row.children[1].children[0].dispatch('click');
|
||||
state.controllerRawButtons = Array.from({ length: 12 }, (_, index) => ({
|
||||
value: index === 11 ? 1 : 0,
|
||||
pressed: index === 11,
|
||||
touched: index === 11,
|
||||
}));
|
||||
state.controllerRawButtons[11] = { value: 1, pressed: true, touched: true };
|
||||
modal.updateDevices();
|
||||
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(saved, []);
|
||||
assert.equal(dom.controllerSelectStatus.textContent, press, 'kept across frames');
|
||||
|
||||
assert.deepEqual(saved.at(-1), {
|
||||
bindings: {
|
||||
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
||||
},
|
||||
});
|
||||
assert.deepEqual(state.controllerConfig?.bindings.toggleLookup, {
|
||||
kind: 'button',
|
||||
buttonIndex: 11,
|
||||
});
|
||||
// The press makes the pad visible: editing unlocks for it.
|
||||
state.connectedGamepads = [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }];
|
||||
state.activeGamepadId = 'pad-1';
|
||||
modal.updateDevices();
|
||||
assert.equal(dom.controllerConfigList.classList.contains('controller-config-locked'), false);
|
||||
assert.equal(dom.controllerSelectFaceButtonStyle.disabled, false);
|
||||
assert.equal(
|
||||
dom.controllerSelectStatus.textContent,
|
||||
'Choose a controller or click Learn to remap an action.',
|
||||
);
|
||||
} finally {
|
||||
domHandle.restore();
|
||||
}
|
||||
@@ -799,7 +810,7 @@ test('controller select modal learns a duplicate card button and saves it to the
|
||||
}
|
||||
});
|
||||
|
||||
test('controller select modal shows and edits the saved controller before this window sees it', async () => {
|
||||
test('controller select modal previews the saved controller, then edits it once seen', async () => {
|
||||
const domHandle = installFakeDom();
|
||||
const saved: unknown[] = [];
|
||||
|
||||
@@ -825,6 +836,7 @@ test('controller select modal shows and edits the saved controller before this w
|
||||
config.profiles = {
|
||||
'pad-1': {
|
||||
label: 'pad-1',
|
||||
faceButtonLabels: config.faceButtonLabels,
|
||||
buttonIndices: config.buttonIndices,
|
||||
bindings: config.bindings,
|
||||
fieldGroupingBindings: config.fieldGroupingBindings,
|
||||
@@ -846,18 +858,31 @@ test('controller select modal shows and edits the saved controller before this w
|
||||
modal.wireDomEvents();
|
||||
modal.openControllerSelectModal();
|
||||
|
||||
// The preview is the saved controller's layout, labelled as such.
|
||||
assert.equal(dom.controllerSelectSummary.textContent, 'Showing pad-1 (saved).');
|
||||
findByClass(dom.controllerConfigList, 'controller-config-group-toggle').dispatch('click');
|
||||
const row = findReviewRow(dom.controllerConfigList, 'Start \u22120.1s');
|
||||
assert.equal(row.children[1].children[0].textContent, 'L2 + D-pad \u25b6');
|
||||
const waiting = 'Press any button on your controller so this window can see it.';
|
||||
assert.equal(dom.controllerSelectStatus.textContent, waiting);
|
||||
const startBack = () => findReviewRow(dom.controllerConfigList, 'Start \u22120.1s');
|
||||
assert.equal(startBack().children[1].children[0].textContent, 'L2 + D-pad \u25b6');
|
||||
const press = 'Press any button on your controller to edit its bindings.';
|
||||
assert.equal(dom.controllerSelectStatus.textContent, press);
|
||||
// Controller polling keeps publishing snapshots while no pad is visible.
|
||||
modal.updateDevices();
|
||||
modal.updateDevices();
|
||||
assert.equal(dom.controllerSelectStatus.textContent, waiting, 'the prompt stays');
|
||||
assert.equal(dom.controllerSelectStatus.textContent, press, 'the prompt stays');
|
||||
|
||||
// Reset goes to the saved controller's profile, not the global layout.
|
||||
row.children[1].children[1].dispatch('click');
|
||||
// Reset is locked until the window sees the pad.
|
||||
startBack().children[1].children[1].dispatch('click');
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(saved, []);
|
||||
|
||||
state.connectedGamepads = [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }];
|
||||
state.activeGamepadId = 'pad-1';
|
||||
modal.updateDevices();
|
||||
assert.equal(
|
||||
dom.controllerSelectStatus.textContent,
|
||||
'Choose a controller or click Learn to remap an action.',
|
||||
);
|
||||
startBack().children[1].children[1].dispatch('click');
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(saved.at(-1), {
|
||||
profiles: {
|
||||
@@ -873,19 +898,19 @@ test('controller select modal shows and edits the saved controller before this w
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const afterReset = dom.controllerSelectStatus.textContent;
|
||||
assert.equal(afterReset, 'Start \u22120.1s reset.');
|
||||
assert.equal(dom.controllerSelectStatus.textContent, 'Start \u22120.1s reset.');
|
||||
modal.updateDevices();
|
||||
assert.equal(dom.controllerSelectStatus.textContent, afterReset, 'the reset message stays');
|
||||
assert.equal(
|
||||
dom.controllerSelectStatus.textContent,
|
||||
'Start \u22120.1s reset.',
|
||||
'the reset message stays',
|
||||
);
|
||||
|
||||
// The pad appears, then disappears again.
|
||||
state.connectedGamepads = [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }];
|
||||
state.activeGamepadId = 'pad-1';
|
||||
modal.updateDevices();
|
||||
// The pad disappears, then comes back.
|
||||
state.connectedGamepads = [];
|
||||
state.activeGamepadId = null;
|
||||
modal.updateDevices();
|
||||
assert.equal(dom.controllerSelectStatus.textContent, waiting);
|
||||
assert.equal(dom.controllerSelectStatus.textContent, press);
|
||||
state.connectedGamepads = [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }];
|
||||
state.activeGamepadId = 'pad-1';
|
||||
modal.updateDevices();
|
||||
@@ -897,3 +922,173 @@ test('controller select modal shows and edits the saved controller before this w
|
||||
domHandle.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('controller select modal previews the controller the overlay reports in use', async () => {
|
||||
const domHandle = installFakeDom();
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
focus: () => {},
|
||||
electronAPI: {
|
||||
saveControllerConfig: async () => {},
|
||||
notifyOverlayModalClosed: () => {},
|
||||
getActiveControllerId: async () => 'DualSense Wireless Controller (STANDARD GAMEPAD)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const { state, dom } = buildContext();
|
||||
state.connectedGamepads = [];
|
||||
state.activeGamepadId = null;
|
||||
const config = state.controllerConfig!;
|
||||
// The saved controller is pad-1, but the overlay is reading the DualSense.
|
||||
config.profiles = {
|
||||
'DualSense Wireless Controller (STANDARD GAMEPAD)': {
|
||||
label: 'DualSense',
|
||||
faceButtonLabels: 'playstation',
|
||||
buttonIndices: config.buttonIndices,
|
||||
bindings: { ...config.bindings, mineCard: { kind: 'button', buttonIndex: 3 } },
|
||||
reviewBindings: config.reviewBindings,
|
||||
fieldGroupingBindings: config.fieldGroupingBindings,
|
||||
},
|
||||
};
|
||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
notifyControllerDisabled: () => {},
|
||||
});
|
||||
modal.wireDomEvents();
|
||||
modal.openControllerSelectModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(
|
||||
dom.controllerSelectSummary.textContent,
|
||||
'Showing DualSense Wireless Controller (in use).',
|
||||
);
|
||||
assert.equal(
|
||||
findReviewRow(dom.controllerConfigList, 'Mine Card').children[1].children[0].textContent,
|
||||
'\u25b3',
|
||||
);
|
||||
assert.equal(dom.controllerSelectFaceButtonStyle.value, 'playstation');
|
||||
} finally {
|
||||
domHandle.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('controller select modal selects the controller you pressed when several appear', () => {
|
||||
const domHandle = installFakeDom();
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
focus: () => {},
|
||||
electronAPI: { saveControllerConfig: async () => {}, notifyOverlayModalClosed: () => {} },
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const { state, dom } = buildContext();
|
||||
state.connectedGamepads = [];
|
||||
state.activeGamepadId = null;
|
||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
notifyControllerDisabled: () => {},
|
||||
});
|
||||
modal.wireDomEvents();
|
||||
modal.openControllerSelectModal();
|
||||
|
||||
// One press reveals every pad; pad-2 has the newest input, pad-1 is the saved one.
|
||||
state.connectedGamepads = [
|
||||
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true, timestamp: 100 },
|
||||
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true, timestamp: 500 },
|
||||
];
|
||||
state.activeGamepadId = 'pad-1';
|
||||
modal.updateDevices();
|
||||
|
||||
assert.equal(state.controllerDeviceSelectedIndex, 1);
|
||||
assert.equal(state.controllerSelectGamepadId, 'pad-2', 'this window then reads pad-2');
|
||||
|
||||
modal.closeControllerSelectModal();
|
||||
assert.equal(state.controllerSelectGamepadId, null);
|
||||
} finally {
|
||||
domHandle.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('controller select modal shows and saves the button style per controller', async () => {
|
||||
const domHandle = installFakeDom();
|
||||
const saved: unknown[] = [];
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
focus: () => {},
|
||||
electronAPI: {
|
||||
saveControllerConfig: async (update: unknown) => {
|
||||
saved.push(update);
|
||||
},
|
||||
notifyOverlayModalClosed: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const { state, dom } = buildContext();
|
||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
notifyControllerDisabled: () => {},
|
||||
});
|
||||
modal.wireDomEvents();
|
||||
modal.openControllerSelectModal();
|
||||
|
||||
const stylePicker = dom.controllerSelectFaceButtonStyle;
|
||||
assert.deepEqual(
|
||||
stylePicker.children.map((option: any) => option.value),
|
||||
['xbox', 'nintendo', 'playstation'],
|
||||
);
|
||||
assert.equal(stylePicker.value, 'xbox');
|
||||
|
||||
stylePicker.value = 'playstation';
|
||||
stylePicker.dispatch('change');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(saved.at(-1), {
|
||||
profiles: { 'pad-1': { faceButtonLabels: 'playstation' } },
|
||||
});
|
||||
assert.equal(state.controllerConfig?.profiles['pad-1']?.faceButtonLabels, 'playstation');
|
||||
assert.equal(state.controllerConfig?.faceButtonLabels, 'xbox', 'other controllers keep theirs');
|
||||
assert.equal(dom.controllerSelectStatus.textContent, 'Button style: PlayStation.');
|
||||
// Toggle Lookup is on the bottom face button.
|
||||
assert.equal(
|
||||
findReviewRow(dom.controllerConfigList, 'Toggle Lookup').children[1].children[0].textContent,
|
||||
'╳',
|
||||
);
|
||||
|
||||
// The other controller still uses the default style.
|
||||
dom.controllerSelectPicker.value = 'pad-2:1';
|
||||
dom.controllerSelectPicker.dispatch('change');
|
||||
assert.equal(stylePicker.value, 'xbox');
|
||||
} finally {
|
||||
domHandle.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('shortControllerName drops the driver details in brackets', () => {
|
||||
assert.equal(
|
||||
shortControllerName('Xbox Wireless Controller (STANDARD GAMEPAD Vendor: 045e Product: 0b13)'),
|
||||
'Xbox Wireless Controller',
|
||||
);
|
||||
assert.equal(shortControllerName('pad-1'), 'pad-1');
|
||||
assert.equal(shortControllerName('(only details)'), '(only details)');
|
||||
});
|
||||
|
||||
test('findMostRecentlyUsedIndex picks the newest input and ignores missing timestamps', () => {
|
||||
assert.equal(findMostRecentlyUsedIndex([{ timestamp: 100 }, { timestamp: 500 }, {}]), 1);
|
||||
assert.equal(findMostRecentlyUsedIndex([{ timestamp: 900 }, { timestamp: Number.NaN }]), 0);
|
||||
assert.equal(findMostRecentlyUsedIndex([{}, { timestamp: 0 }]), -1);
|
||||
assert.equal(findMostRecentlyUsedIndex([]), -1);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { resolveControllerConfigForGamepad } from '../controller-profile-config.
|
||||
import { createControllerBindingCapture } from '../handlers/controller-binding-capture.js';
|
||||
import { createControllerComboCapture } from '../handlers/controller-combo-capture.js';
|
||||
import {
|
||||
CONTROLLER_FACE_BUTTON_STYLES,
|
||||
formatControllerReviewInput,
|
||||
getControllerScreenAction,
|
||||
getDefaultControllerScreenBindings,
|
||||
@@ -22,6 +23,27 @@ import {
|
||||
getDefaultDpadFallback,
|
||||
} from './controller-config-form.js';
|
||||
|
||||
const PRESS_TO_EDIT_STATUS = 'Press any button on your controller to edit its bindings.';
|
||||
|
||||
/** `8BitDo Ultimate Wireless Controller (STANDARD GAMEPAD Vendor: …)` → the name part. */
|
||||
export function shortControllerName(gamepadId: string): string {
|
||||
return gamepadId.replace(/\s*\(.*\)\s*$/, '').trim() || gamepadId;
|
||||
}
|
||||
|
||||
/** Index of the pad with the newest input (Gamepad API timestamp), or -1 if none report one. */
|
||||
export function findMostRecentlyUsedIndex(devices: ReadonlyArray<{ timestamp?: number }>): number {
|
||||
let best = -1;
|
||||
let bestTimestamp = 0;
|
||||
devices.forEach((device, index) => {
|
||||
const timestamp = device.timestamp ?? 0;
|
||||
if (Number.isFinite(timestamp) && timestamp > bestTimestamp) {
|
||||
best = index;
|
||||
bestTimestamp = timestamp;
|
||||
}
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
function clampSelectedIndex(ctx: RendererContext): void {
|
||||
if (ctx.state.connectedGamepads.length === 0) {
|
||||
ctx.state.controllerDeviceSelectedIndex = 0;
|
||||
@@ -34,11 +56,6 @@ function clampSelectedIndex(ctx: RendererContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
const NO_CONTROLLER_STATUS = 'No controllers detected.';
|
||||
const WAITING_FOR_SAVED_CONTROLLER_STATUS =
|
||||
'Press any button on your controller so this window can see it.';
|
||||
const NO_CONTROLLER_STATUSES = new Set([NO_CONTROLLER_STATUS, WAITING_FOR_SAVED_CONTROLLER_STATUS]);
|
||||
|
||||
export function createControllerSelectModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
@@ -65,17 +82,52 @@ export function createControllerSelectModal(
|
||||
holdLabel: string | null;
|
||||
} | null = null;
|
||||
|
||||
/** The controller the overlay reports in use, fetched when the window opens. */
|
||||
let overlayActiveControllerId: string | null = null;
|
||||
|
||||
async function loadOverlayActiveController(): Promise<void> {
|
||||
let gamepadId: string | null = null;
|
||||
try {
|
||||
gamepadId = (await window.electronAPI.getActiveControllerId?.()) ?? null;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!ctx.state.controllerSelectModalOpen || gamepadId === overlayActiveControllerId) return;
|
||||
overlayActiveControllerId = gamepadId;
|
||||
renderPicker();
|
||||
controllerConfigForm.render();
|
||||
}
|
||||
|
||||
function getSelectedController() {
|
||||
return ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The controller whose profile Alt+C shows and edits. A freshly opened window cannot see
|
||||
* any pad until a button is pressed in it, so until then use the saved (preferred)
|
||||
* controller rather than the global defaults it does not actually use.
|
||||
* any pad until a button is pressed in it; until then it previews the controller the
|
||||
* overlay reports in use, else the saved (preferred) one, and editing stays locked.
|
||||
*/
|
||||
function getSelectedControllerId(): string | null {
|
||||
return getSelectedController()?.id ?? (ctx.state.controllerConfig?.preferredGamepadId || null);
|
||||
return (
|
||||
getSelectedController()?.id ??
|
||||
overlayActiveControllerId ??
|
||||
(ctx.state.controllerConfig?.preferredGamepadId || null)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nothing can be changed until this window sees a controller: before that it could only
|
||||
* guess which controller you mean, and a change could land on the wrong one.
|
||||
*/
|
||||
function isEditingLocked(): boolean {
|
||||
return ctx.state.connectedGamepads.length === 0;
|
||||
}
|
||||
|
||||
/** True when editing may go ahead; otherwise says why not. */
|
||||
function canEdit(): boolean {
|
||||
if (!isEditingLocked()) return true;
|
||||
setStatus(PRESS_TO_EDIT_STATUS);
|
||||
return false;
|
||||
}
|
||||
|
||||
function getSelectedControllerConfig() {
|
||||
@@ -115,8 +167,9 @@ export function createControllerSelectModal(
|
||||
|
||||
const controllerConfigForm = createControllerConfigForm({
|
||||
container: ctx.dom.controllerConfigList,
|
||||
isEditingLocked,
|
||||
getControllerMapping: () => getSelectedController()?.mapping ?? null,
|
||||
getFaceButtonLabels: () => ctx.state.controllerConfig?.faceButtonLabels,
|
||||
getFaceButtonLabels: () => getSelectedControllerConfig()?.faceButtonLabels,
|
||||
getBindings: () =>
|
||||
getSelectedControllerConfig()?.bindings ?? {
|
||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||
@@ -131,6 +184,10 @@ export function createControllerSelectModal(
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
playNextSubtitle: { kind: 'none' },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
@@ -154,6 +211,7 @@ export function createControllerSelectModal(
|
||||
}
|
||||
: null,
|
||||
onScreenLearn: (screenId, actionId) => {
|
||||
if (!canEdit()) return;
|
||||
learningActionId = null;
|
||||
dpadLearningActionId = null;
|
||||
bindingCapture = null;
|
||||
@@ -171,6 +229,7 @@ export function createControllerSelectModal(
|
||||
);
|
||||
},
|
||||
onScreenClear: (screenId, actionId) => {
|
||||
if (!canEdit()) return;
|
||||
const screen = getControllerScreen(screenId);
|
||||
void saveScreenBindings(
|
||||
screen,
|
||||
@@ -179,6 +238,7 @@ export function createControllerSelectModal(
|
||||
);
|
||||
},
|
||||
onScreenReset: (screenId, actionId) => {
|
||||
if (!canEdit()) return;
|
||||
const screen = getControllerScreen(screenId);
|
||||
void saveScreenBindings(
|
||||
screen,
|
||||
@@ -187,6 +247,7 @@ export function createControllerSelectModal(
|
||||
);
|
||||
},
|
||||
onScreenResetAll: (screenId) => {
|
||||
if (!canEdit()) return;
|
||||
const screen = getControllerScreen(screenId);
|
||||
void saveScreenBindings(
|
||||
screen,
|
||||
@@ -197,6 +258,7 @@ export function createControllerSelectModal(
|
||||
getLearningActionId: () => learningActionId,
|
||||
getDpadLearningActionId: () => dpadLearningActionId,
|
||||
onLearn: (actionId, bindingType) => {
|
||||
if (!canEdit()) return;
|
||||
const definition = getControllerBindingDefinition(actionId);
|
||||
if (!definition) return;
|
||||
dpadLearningActionId = null;
|
||||
@@ -232,12 +294,15 @@ export function createControllerSelectModal(
|
||||
setStatus(`Waiting for input for ${definition.label}.`);
|
||||
},
|
||||
onClear: (actionId) => {
|
||||
if (!canEdit()) return;
|
||||
void saveBinding(actionId, { kind: 'none' });
|
||||
},
|
||||
onReset: (actionId) => {
|
||||
if (!canEdit()) return;
|
||||
void saveBinding(actionId, getDefaultControllerBinding(actionId));
|
||||
},
|
||||
onDpadLearn: (actionId) => {
|
||||
if (!canEdit()) return;
|
||||
const definition = getControllerBindingDefinition(actionId);
|
||||
if (!definition) return;
|
||||
learningActionId = null;
|
||||
@@ -259,9 +324,11 @@ export function createControllerSelectModal(
|
||||
setStatus(`Press a D-pad direction for ${definition.label}.`);
|
||||
},
|
||||
onDpadClear: (actionId) => {
|
||||
if (!canEdit()) return;
|
||||
void saveDpadFallback(actionId, 'none');
|
||||
},
|
||||
onDpadReset: (actionId) => {
|
||||
if (!canEdit()) return;
|
||||
void saveDpadFallback(actionId, getDefaultDpadFallback(actionId));
|
||||
},
|
||||
});
|
||||
@@ -279,9 +346,18 @@ export function createControllerSelectModal(
|
||||
function syncSelectedControllerId(): void {
|
||||
const selected = ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex];
|
||||
selectedControllerKey = selected ? getDeviceSelectionKey(selected) : null;
|
||||
// This window then reads that controller, so Learn hears the pad being configured.
|
||||
ctx.state.controllerSelectGamepadId = selected?.id ?? null;
|
||||
}
|
||||
|
||||
function syncSelectedIndexToCurrentController(): void {
|
||||
// The pad you just pressed (newest input) is the one in your hands.
|
||||
const recentIndex = findMostRecentlyUsedIndex(ctx.state.connectedGamepads);
|
||||
if (recentIndex >= 0) {
|
||||
ctx.state.controllerDeviceSelectedIndex = recentIndex;
|
||||
syncSelectedControllerId();
|
||||
return;
|
||||
}
|
||||
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
|
||||
const activeIndex = ctx.state.connectedGamepads.findIndex(
|
||||
(device) => device.id === ctx.state.activeGamepadId,
|
||||
@@ -331,12 +407,58 @@ export function createControllerSelectModal(
|
||||
ctx.dom.controllerSelectPicker.disabled = ctx.state.connectedGamepads.length === 0;
|
||||
ctx.dom.controllerSelectSummary.textContent =
|
||||
ctx.state.connectedGamepads.length === 0
|
||||
? 'No controller detected.'
|
||||
? describePreview()
|
||||
: `Active: ${ctx.state.activeGamepadId ?? 'none'} · Preferred: ${preferredId || 'none'}`;
|
||||
|
||||
lastRenderedDevicesKey = getDevicesKey();
|
||||
lastRenderedActiveGamepadId = ctx.state.activeGamepadId;
|
||||
lastRenderedPreferredId = preferredId;
|
||||
renderFaceButtonStylePicker();
|
||||
}
|
||||
|
||||
/** Which controller the locked preview shows, before this window sees one. */
|
||||
function describePreview(): string {
|
||||
const previewId = getSelectedControllerId();
|
||||
if (!previewId) return 'No controller detected.';
|
||||
const source = previewId === overlayActiveControllerId ? 'in use' : 'saved';
|
||||
return `Showing ${shortControllerName(previewId)} (${source}).`;
|
||||
}
|
||||
|
||||
/** Shows the button style of the controller being configured (its own, else the default). */
|
||||
function renderFaceButtonStylePicker(): void {
|
||||
const picker = ctx.dom.controllerSelectFaceButtonStyle;
|
||||
picker.disabled = isEditingLocked();
|
||||
const current = getSelectedControllerConfig()?.faceButtonLabels ?? 'xbox';
|
||||
picker.innerHTML = '';
|
||||
for (const style of CONTROLLER_FACE_BUTTON_STYLES) {
|
||||
const option = document.createElement('option');
|
||||
option.value = style.id;
|
||||
option.textContent = `${style.name} (${style.hint})`;
|
||||
option.selected = style.id === current;
|
||||
picker.appendChild(option);
|
||||
}
|
||||
picker.value = current;
|
||||
}
|
||||
|
||||
/** Saved for the controller being configured, like its remaps. */
|
||||
async function saveFaceButtonStyle(
|
||||
style: (typeof CONTROLLER_FACE_BUTTON_STYLES)[number],
|
||||
): Promise<void> {
|
||||
const profileId = getSelectedControllerId();
|
||||
try {
|
||||
await saveControllerConfig(
|
||||
profileId
|
||||
? { profiles: { [profileId]: { faceButtonLabels: style.id } } }
|
||||
: { faceButtonLabels: style.id },
|
||||
);
|
||||
renderFaceButtonStylePicker();
|
||||
controllerConfigForm.render();
|
||||
setStatus(`Button style: ${style.name}.`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setStatus(`Failed to save button style: ${message}`, true);
|
||||
renderFaceButtonStylePicker();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveControllerConfig(
|
||||
@@ -350,6 +472,9 @@ export function createControllerSelectModal(
|
||||
if (update.preferredGamepadLabel !== undefined) {
|
||||
ctx.state.controllerConfig.preferredGamepadLabel = update.preferredGamepadLabel;
|
||||
}
|
||||
if (update.faceButtonLabels) {
|
||||
ctx.state.controllerConfig.faceButtonLabels = update.faceButtonLabels;
|
||||
}
|
||||
if (update.bindings) {
|
||||
ctx.state.controllerConfig.bindings = {
|
||||
...ctx.state.controllerConfig.bindings,
|
||||
@@ -371,6 +496,7 @@ export function createControllerSelectModal(
|
||||
const currentProfile = ctx.state.controllerConfig.profiles[profileId];
|
||||
const baseProfile = currentProfile ?? {
|
||||
label: profileUpdate.label ?? profileId,
|
||||
faceButtonLabels: ctx.state.controllerConfig.faceButtonLabels,
|
||||
buttonIndices: ctx.state.controllerConfig.buttonIndices,
|
||||
bindings: ctx.state.controllerConfig.bindings,
|
||||
reviewBindings: ctx.state.controllerConfig.reviewBindings,
|
||||
@@ -378,6 +504,10 @@ export function createControllerSelectModal(
|
||||
};
|
||||
ctx.state.controllerConfig.profiles[profileId] = {
|
||||
label: profileUpdate.label ?? baseProfile.label,
|
||||
faceButtonLabels:
|
||||
profileUpdate.faceButtonLabels ??
|
||||
baseProfile.faceButtonLabels ??
|
||||
ctx.state.controllerConfig.faceButtonLabels,
|
||||
buttonIndices: {
|
||||
...baseProfile.buttonIndices,
|
||||
...(profileUpdate.buttonIndices ?? {}),
|
||||
@@ -568,33 +698,27 @@ export function createControllerSelectModal(
|
||||
controllerConfigForm.render();
|
||||
}
|
||||
|
||||
// Polling publishes a snapshot every frame, pad or not: only a change in the devices
|
||||
// sets the no-pad status, so it neither replaces the prompt nor a message like "Reset".
|
||||
// Polling publishes a snapshot every frame, pad or not. Until the window sees a pad,
|
||||
// editing is locked, so the only status is the prompt; it is set once, not every frame.
|
||||
const idle = !learningActionId && !dpadLearningActionId && !screenLearning;
|
||||
if (ctx.state.connectedGamepads.length === 0) {
|
||||
if (idle && shouldRender) setStatus(getNoControllerStatus());
|
||||
} else if (
|
||||
idle &&
|
||||
NO_CONTROLLER_STATUSES.has(ctx.dom.controllerSelectStatus.textContent ?? '')
|
||||
) {
|
||||
if (ctx.state.connectedGamepads.length === 0 && idle) {
|
||||
if (ctx.dom.controllerSelectStatus.textContent !== PRESS_TO_EDIT_STATUS) {
|
||||
setStatus(PRESS_TO_EDIT_STATUS);
|
||||
}
|
||||
} else if (idle && ctx.dom.controllerSelectStatus.textContent === PRESS_TO_EDIT_STATUS) {
|
||||
setStatus('Choose a controller or click Learn to remap an action.');
|
||||
}
|
||||
}
|
||||
|
||||
/** A saved controller is only waiting for a press; without one there is nothing to show. */
|
||||
function getNoControllerStatus(): string {
|
||||
return ctx.state.controllerConfig?.preferredGamepadId
|
||||
? WAITING_FOR_SAVED_CONTROLLER_STATUS
|
||||
: NO_CONTROLLER_STATUS;
|
||||
}
|
||||
|
||||
function openControllerSelectModal(): boolean {
|
||||
if (ctx.state.controllerConfig?.enabled !== true) {
|
||||
options.notifyControllerDisabled();
|
||||
return false;
|
||||
}
|
||||
ctx.state.controllerSelectModalOpen = true;
|
||||
overlayActiveControllerId = null;
|
||||
syncSelectedIndexToCurrentController();
|
||||
void loadOverlayActiveController();
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
ctx.dom.controllerSelectModal.classList.remove('hidden');
|
||||
@@ -604,7 +728,7 @@ export function createControllerSelectModal(
|
||||
renderPicker();
|
||||
controllerConfigForm.render();
|
||||
if (ctx.state.connectedGamepads.length === 0) {
|
||||
setStatus(getNoControllerStatus());
|
||||
setStatus(PRESS_TO_EDIT_STATUS);
|
||||
} else {
|
||||
setStatus('Choose a controller or click Learn to remap an action.');
|
||||
}
|
||||
@@ -618,6 +742,7 @@ export function createControllerSelectModal(
|
||||
bindingCapture = null;
|
||||
cancelScreenLearning();
|
||||
ctx.state.controllerSelectModalOpen = false;
|
||||
ctx.state.controllerSelectGamepadId = null;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.controllerSelectModal.classList.add('hidden');
|
||||
ctx.dom.controllerSelectModal.setAttribute('aria-hidden', 'true');
|
||||
@@ -687,6 +812,15 @@ export function createControllerSelectModal(
|
||||
ctx.dom.controllerSelectSave.addEventListener('click', () => {
|
||||
void saveSelectedController();
|
||||
});
|
||||
ctx.dom.controllerSelectFaceButtonStyle.addEventListener('change', () => {
|
||||
if (!canEdit()) {
|
||||
renderFaceButtonStylePicker();
|
||||
return;
|
||||
}
|
||||
const value = ctx.dom.controllerSelectFaceButtonStyle.value;
|
||||
const style = CONTROLLER_FACE_BUTTON_STYLES.find((candidate) => candidate.id === value);
|
||||
if (style) void saveFaceButtonStyle(style);
|
||||
});
|
||||
ctx.dom.controllerSelectPicker.addEventListener('change', () => {
|
||||
const selectedKey = ctx.dom.controllerSelectPicker.value;
|
||||
const selectedIndex = ctx.state.connectedGamepads.findIndex(
|
||||
|
||||
+118
-47
@@ -24,6 +24,7 @@ import type {
|
||||
SubtitlePosition,
|
||||
SubsyncManualPayload,
|
||||
ConfigHotReloadPayload,
|
||||
ResolvedControllerConfig,
|
||||
} from '../types';
|
||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||
import { createKeyboardHandlers } from './handlers/keyboard.js';
|
||||
@@ -55,6 +56,8 @@ import {
|
||||
formatMediaTimingReviewControllerHints,
|
||||
} from './handlers/media-timing-review-controller.js';
|
||||
import { resolveControllerConfigForGamepad } from './controller-profile-config.js';
|
||||
import { dispatchConfiguredMpvCommand } from './utils/mpv-command-dispatch.js';
|
||||
import { createKeyboardModeAutoPause } from './keyboard-mode-auto-pause.js';
|
||||
import { getDefaultControllerReviewBindings } from '../shared/controller-review';
|
||||
import {
|
||||
CONTROLLER_FIELD_GROUPING_SCREEN,
|
||||
@@ -339,7 +342,15 @@ const fieldGroupingControllerLayout = createControllerComboScreenLayout({
|
||||
kikuModal.handleControllerAction(action);
|
||||
},
|
||||
});
|
||||
const keyboardModeAutoPause = createKeyboardModeAutoPause({
|
||||
isEnabled: () => ctx.state.autoPauseVideoOnKeyboardMode,
|
||||
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
|
||||
sendMpvCommand: (command) => window.electronAPI.sendMpvCommand(command),
|
||||
});
|
||||
const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||
onKeyboardDrivenModeChanged: (enabled) => {
|
||||
void keyboardModeAutoPause.handleModeChanged(enabled);
|
||||
},
|
||||
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
|
||||
handleCharacterDictionaryKeydown: characterDictionaryModal.handleCharacterDictionaryKeydown,
|
||||
handleSubtitleSelectionKeydown: subtitleSelectionModal.handleKeydown,
|
||||
@@ -427,6 +438,12 @@ function applyControllerSnapshot(snapshot: {
|
||||
}
|
||||
ctx.state.connectedGamepads = snapshot.connectedGamepads;
|
||||
ctx.state.activeGamepadId = snapshot.activeGamepadId;
|
||||
// Alt+C opens in its own window that cannot see a pad until a press there; it asks main
|
||||
// which controller the overlay is reading so it can preview the right one.
|
||||
if (!ctx.platform.isModalLayer && snapshot.activeGamepadId !== reportedActiveControllerId) {
|
||||
reportedActiveControllerId = snapshot.activeGamepadId;
|
||||
window.electronAPI.reportActiveController?.(snapshot.activeGamepadId);
|
||||
}
|
||||
ctx.state.controllerRawAxes = snapshot.rawAxes;
|
||||
ctx.state.controllerRawButtons = snapshot.rawButtons;
|
||||
controllerSelectModal.updateDevices();
|
||||
@@ -467,12 +484,33 @@ function applyControllerSnapshot(snapshot: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* While Alt+C is open, its window reads the controller selected there instead of the saved
|
||||
* one, so Learn hears the pad being configured when several are connected.
|
||||
*/
|
||||
function withAltCSelectedController(config: ResolvedControllerConfig): ResolvedControllerConfig {
|
||||
const selectedId = ctx.state.controllerSelectModalOpen
|
||||
? ctx.state.controllerSelectGamepadId
|
||||
: null;
|
||||
return selectedId ? { ...config, preferredGamepadId: selectedId } : config;
|
||||
}
|
||||
|
||||
let reportedActiveControllerId: string | null | undefined;
|
||||
|
||||
function showControllerDisabledNotice(): void {
|
||||
controllerStatusIndicator.show(
|
||||
'Controller support disabled. Set controller.enabled to true in config to use controller tools.',
|
||||
);
|
||||
}
|
||||
|
||||
/** A paused video stays paused after the jump, as with the keyboard shortcut. */
|
||||
function seekSubtitleForController(delta: -1 | 1): void {
|
||||
void dispatchConfiguredMpvCommand(['sub-seek', delta], {
|
||||
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
|
||||
sendMpvCommand: (command) => window.electronAPI.sendMpvCommand(command),
|
||||
});
|
||||
}
|
||||
|
||||
function emitControllerPopupScroll(deltaPixels: number): void {
|
||||
if (deltaPixels === 0) return;
|
||||
keyboardHandlers.scrollPopupByController(0, deltaPixels);
|
||||
@@ -492,54 +530,60 @@ function startControllerPolling(): void {
|
||||
const gamepadController = createGamepadController({
|
||||
getGamepads: () => Array.from(navigator.getGamepads?.() ?? []),
|
||||
getConfig: () =>
|
||||
ctx.state.controllerConfig ?? {
|
||||
enabled: false,
|
||||
preferredGamepadId: '',
|
||||
preferredGamepadLabel: '',
|
||||
smoothScroll: true,
|
||||
scrollPixelsPerSecond: 900,
|
||||
horizontalJumpPixels: 160,
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
faceButtonLabels: 'xbox',
|
||||
repeatDelayMs: 320,
|
||||
repeatIntervalMs: 120,
|
||||
buttonIndices: {
|
||||
select: 6,
|
||||
buttonSouth: 0,
|
||||
buttonEast: 1,
|
||||
buttonWest: 2,
|
||||
buttonNorth: 3,
|
||||
leftShoulder: 4,
|
||||
rightShoulder: 5,
|
||||
leftStickPress: 9,
|
||||
rightStickPress: 10,
|
||||
leftTrigger: 6,
|
||||
rightTrigger: 7,
|
||||
withAltCSelectedController(
|
||||
ctx.state.controllerConfig ?? {
|
||||
enabled: false,
|
||||
preferredGamepadId: '',
|
||||
preferredGamepadLabel: '',
|
||||
smoothScroll: true,
|
||||
scrollPixelsPerSecond: 900,
|
||||
horizontalJumpPixels: 160,
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
faceButtonLabels: 'xbox',
|
||||
repeatDelayMs: 320,
|
||||
repeatIntervalMs: 120,
|
||||
buttonIndices: {
|
||||
select: 6,
|
||||
buttonSouth: 0,
|
||||
buttonEast: 1,
|
||||
buttonWest: 2,
|
||||
buttonNorth: 3,
|
||||
leftShoulder: 4,
|
||||
rightShoulder: 5,
|
||||
leftStickPress: 9,
|
||||
rightStickPress: 10,
|
||||
leftTrigger: 6,
|
||||
rightTrigger: 7,
|
||||
},
|
||||
bindings: {
|
||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
||||
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
||||
mineCard: { kind: 'button', buttonIndex: 2 },
|
||||
quitMpv: { kind: 'button', buttonIndex: 6 },
|
||||
previousAudio: { kind: 'none' },
|
||||
nextAudio: { kind: 'button', buttonIndex: 5 },
|
||||
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
||||
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
playNextSubtitle: { kind: 'none' },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
},
|
||||
reviewBindings: getDefaultControllerReviewBindings(),
|
||||
fieldGroupingBindings: getDefaultControllerFieldGroupingBindings(),
|
||||
profiles: {},
|
||||
},
|
||||
bindings: {
|
||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
||||
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
||||
mineCard: { kind: 'button', buttonIndex: 2 },
|
||||
quitMpv: { kind: 'button', buttonIndex: 6 },
|
||||
previousAudio: { kind: 'none' },
|
||||
nextAudio: { kind: 'button', buttonIndex: 5 },
|
||||
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
||||
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
},
|
||||
reviewBindings: getDefaultControllerReviewBindings(),
|
||||
fieldGroupingBindings: getDefaultControllerFieldGroupingBindings(),
|
||||
profiles: {},
|
||||
},
|
||||
),
|
||||
getKeyboardModeEnabled: () => ctx.state.keyboardDrivenModeEnabled,
|
||||
getLookupWindowOpen: () => isLookupPopupOpen(ctx.state, document),
|
||||
getInteractionBlocked: () => isControllerInputBlocked(),
|
||||
@@ -557,6 +601,19 @@ function startControllerPolling(): void {
|
||||
quitMpv: () => {
|
||||
window.electronAPI.sendMpvCommand(['quit']);
|
||||
},
|
||||
// Same as Shift+H / Shift+L and Ctrl+Shift+H / Ctrl+Shift+L.
|
||||
previousSubtitle: () => {
|
||||
seekSubtitleForController(-1);
|
||||
},
|
||||
nextSubtitle: () => {
|
||||
seekSubtitleForController(1);
|
||||
},
|
||||
replaySubtitle: () => {
|
||||
void window.electronAPI.dispatchSessionAction('replayCurrentSubtitle');
|
||||
},
|
||||
playNextSubtitle: () => {
|
||||
void window.electronAPI.dispatchSessionAction('playNextSubtitle');
|
||||
},
|
||||
previousAudio: () => {
|
||||
keyboardHandlers.cyclePopupAudioSourceForController(-1);
|
||||
},
|
||||
@@ -801,6 +858,20 @@ function registerKeyboardCommandHandlers(): void {
|
||||
ctx.state.controllerSuspendedByModalWindow = suspended;
|
||||
});
|
||||
|
||||
window.electronAPI.onPlaybackPauseChanged((paused: boolean) => {
|
||||
keyboardModeAutoPause.handlePlaybackPauseChanged(paused);
|
||||
});
|
||||
|
||||
// Alt+C saves in its own window; pick up remaps and the button style without a restart.
|
||||
window.electronAPI.onControllerConfigChanged(() => {
|
||||
window.electronAPI
|
||||
.getControllerConfig()
|
||||
.then((config) => {
|
||||
ctx.state.controllerConfig = config;
|
||||
})
|
||||
.catch((error: unknown) => console.error('Could not reload controller config', error));
|
||||
});
|
||||
|
||||
window.electronAPI.onKeyboardModeToggleRequested(() => {
|
||||
runGuarded('keyboard-mode-toggle:requested', () => {
|
||||
keyboardHandlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
@@ -100,6 +100,8 @@ export type RendererState = {
|
||||
controllerDebugModalOpen: boolean;
|
||||
subtitleSidebarModalOpen: boolean;
|
||||
controllerDeviceSelectedIndex: number;
|
||||
/** While Alt+C is open, the controller selected in it; that window then reads this pad. */
|
||||
controllerSelectGamepadId: string | null;
|
||||
controllerConfig: ResolvedControllerConfig | null;
|
||||
connectedGamepads: ControllerDeviceInfo[];
|
||||
activeGamepadId: string | null;
|
||||
@@ -141,6 +143,7 @@ export type RendererState = {
|
||||
jlptN5Color: string;
|
||||
preserveSubtitleLineBreaks: boolean;
|
||||
autoPauseVideoOnSubtitleHover: boolean;
|
||||
autoPauseVideoOnKeyboardMode: boolean;
|
||||
autoPauseVideoOnYomitanPopup: boolean;
|
||||
primaryVisibleOnYomitanPopup: boolean;
|
||||
frequencyDictionaryEnabled: boolean;
|
||||
@@ -241,6 +244,7 @@ export function createRendererState(): RendererState {
|
||||
controllerDebugModalOpen: false,
|
||||
subtitleSidebarModalOpen: false,
|
||||
controllerDeviceSelectedIndex: 0,
|
||||
controllerSelectGamepadId: null,
|
||||
controllerConfig: null,
|
||||
connectedGamepads: [],
|
||||
activeGamepadId: null,
|
||||
@@ -282,6 +286,7 @@ export function createRendererState(): RendererState {
|
||||
jlptN5Color: '#8aadf4',
|
||||
preserveSubtitleLineBreaks: false,
|
||||
autoPauseVideoOnSubtitleHover: false,
|
||||
autoPauseVideoOnKeyboardMode: false,
|
||||
autoPauseVideoOnYomitanPopup: false,
|
||||
primaryVisibleOnYomitanPopup: true,
|
||||
frequencyDictionaryEnabled: false,
|
||||
|
||||
+24
-1
@@ -2077,6 +2077,10 @@ body:focus-visible,
|
||||
#kikuFieldGroupingModal.controller-hints-visible [data-controller-hint]::after {
|
||||
content: attr(data-controller-hint);
|
||||
text-transform: none;
|
||||
/* Buttons use the system UI font, where ○ □ △ are small; Japanese fonts draw them (and
|
||||
the ╳ used for the PlayStation cross) at full size, and SubMiner already expects one. */
|
||||
font-family:
|
||||
'M PLUS 1', 'Noto Sans CJK JP', 'Hiragino Sans', 'Yu Gothic', 'DejaVu Sans', sans-serif;
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 5px;
|
||||
@@ -2085,7 +2089,6 @@ body:focus-visible,
|
||||
border-radius: 5px;
|
||||
background: var(--ctp-mantle);
|
||||
color: var(--ctp-subtext0);
|
||||
font-family: inherit;
|
||||
font-size: 9px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.02em;
|
||||
@@ -3485,6 +3488,8 @@ iframe[id^='yomitan-popup'],
|
||||
border-radius: 8px;
|
||||
background: rgba(24, 25, 38, 0.9);
|
||||
color: var(--ctp-text);
|
||||
/* Selects default to the system UI font, where the PlayStation ○ is small. */
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.controller-select-summary {
|
||||
@@ -3595,6 +3600,7 @@ iframe[id^='yomitan-popup'],
|
||||
padding: 2px 8px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
font-family: inherit; /* the overlay's Japanese-first fonts draw ○ □ △ at full size */
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
@@ -3630,13 +3636,30 @@ iframe[id^='yomitan-popup'],
|
||||
color: var(--ctp-overlay2);
|
||||
}
|
||||
|
||||
/* Before Alt+C sees a controller the rows are a preview: actions look unavailable. */
|
||||
.controller-config-locked .controller-config-reset-icon,
|
||||
.controller-config-locked .controller-config-edit-icon,
|
||||
.controller-config-locked .controller-config-group-action,
|
||||
.controller-config-locked .controller-config-edit-panel button {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.controller-config-edit-panel {
|
||||
/* The list is a height-capped flex column: without this the panel (overflow hidden, so no
|
||||
minimum height) is squeezed to its border once the rows overflow. */
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
animation: configEditSlideIn 180ms ease-out;
|
||||
border-bottom: 1px solid rgba(138, 173, 244, 0.15);
|
||||
background: rgba(138, 173, 244, 0.04);
|
||||
}
|
||||
|
||||
/* Redrawn while it stays open (after Learn, Reset, …): no slide-in again. */
|
||||
.controller-config-edit-panel-kept {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes configEditSlideIn {
|
||||
from {
|
||||
max-height: 0;
|
||||
|
||||
@@ -964,6 +964,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
ctx.state.preserveSubtitleLineBreaks = style.preserveLineBreaks ?? false;
|
||||
ctx.state.autoPauseVideoOnSubtitleHover = style.autoPauseVideoOnHover ?? false;
|
||||
ctx.state.autoPauseVideoOnYomitanPopup = style.autoPauseVideoOnYomitanPopup ?? false;
|
||||
ctx.state.autoPauseVideoOnKeyboardMode = style.autoPauseVideoOnKeyboardMode ?? false;
|
||||
ctx.state.primaryVisibleOnYomitanPopup = style.primaryVisibleOnYomitanPopup ?? true;
|
||||
syncPrimaryVisibleOnYomitanPopupClass(ctx);
|
||||
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n1-color', jlptColors.N1);
|
||||
|
||||
@@ -155,6 +155,7 @@ export type RendererDom = {
|
||||
controllerSelectModal: HTMLDivElement;
|
||||
controllerSelectClose: HTMLButtonElement;
|
||||
controllerSelectPicker: HTMLSelectElement;
|
||||
controllerSelectFaceButtonStyle: HTMLSelectElement;
|
||||
controllerSelectSummary: HTMLDivElement;
|
||||
controllerSelectStatus: HTMLDivElement;
|
||||
controllerConfigList: HTMLDivElement;
|
||||
@@ -432,6 +433,9 @@ export function resolveRendererDom(): RendererDom {
|
||||
controllerSelectModal: getRequiredElement<HTMLDivElement>('controllerSelectModal'),
|
||||
controllerSelectClose: getRequiredElement<HTMLButtonElement>('controllerSelectClose'),
|
||||
controllerSelectPicker: getRequiredElement<HTMLSelectElement>('controllerSelectPicker'),
|
||||
controllerSelectFaceButtonStyle: getRequiredElement<HTMLSelectElement>(
|
||||
'controllerSelectFaceButtonStyle',
|
||||
),
|
||||
controllerSelectSummary: getRequiredElement<HTMLDivElement>('controllerSelectSummary'),
|
||||
controllerSelectStatus: getRequiredElement<HTMLDivElement>('controllerSelectStatus'),
|
||||
controllerConfigList: getRequiredElement<HTMLDivElement>('controllerConfigList'),
|
||||
|
||||
Reference in new issue
Block a user