mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-10 04:19:25 -07:00
Compare commits
6 Commits
v0.6.2
...
99f4d2baaf
| Author | SHA1 | Date | |
|---|---|---|---|
| 99f4d2baaf | |||
|
f4e8c3feec
|
|||
|
d0b308f340
|
|||
| 1b56360a24 | |||
|
68833c76c4
|
|||
| 4d7c80f2e4 |
127
.agents/skills/subminer-change-verification/SKILL.md
Normal file
127
.agents/skills/subminer-change-verification/SKILL.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
---
|
||||||
|
name: "subminer-change-verification"
|
||||||
|
description: "Use when working in the SubMiner repo and you need to verify code changes actually work. Covers targeted regression checks during debugging and pre-handoff verification, with cheap-first lane selection for config, docs, launcher/plugin, runtime-compat, and optional real-runtime escalation."
|
||||||
|
---
|
||||||
|
|
||||||
|
# SubMiner Change Verification
|
||||||
|
|
||||||
|
Use this skill for SubMiner code changes. Default to cheap, repo-native verification first. Escalate only when the changed behavior actually depends on Electron, mpv, overlay/window tracking, or other GUI-sensitive runtime behavior.
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
- `scripts/classify_subminer_diff.sh`
|
||||||
|
- Emits suggested lanes and flags from explicit paths or current git changes.
|
||||||
|
- `scripts/verify_subminer_change.sh`
|
||||||
|
- Runs selected lanes, captures artifacts, and writes a compact summary.
|
||||||
|
|
||||||
|
If you need an explicit installed path, use the directory that contains this `SKILL.md`. The helper scripts live under:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export SUBMINER_VERIFY_SKILL="<path-to-skill>"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Default workflow
|
||||||
|
|
||||||
|
1. Inspect the changed files or user-requested area.
|
||||||
|
2. Run the classifier unless you already know the right lane.
|
||||||
|
3. Run the verifier with the cheapest sufficient lane set.
|
||||||
|
4. If the classifier emits `flag:real-runtime-candidate`, do not jump straight to runtime verification. First run the non-runtime lanes.
|
||||||
|
5. Escalate to explicit `--lane real-runtime --allow-real-runtime` only when cheaper lanes cannot validate the behavior claim.
|
||||||
|
6. Return:
|
||||||
|
- verification summary
|
||||||
|
- exact commands run
|
||||||
|
- artifact paths
|
||||||
|
- skipped lanes and blockers
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
Repo-source quick start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Installed-skill quick start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash "$SUBMINER_VERIFY_SKILL/scripts/classify_subminer_diff.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
Classify explicit files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh \
|
||||||
|
launcher/main.ts \
|
||||||
|
plugin/subminer/lifecycle.lua \
|
||||||
|
src/main/runtime/mpv-client-runtime-service.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Run automatic lane selection:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Installed-skill form:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash "$SUBMINER_VERIFY_SKILL/scripts/verify_subminer_change.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
Run targeted lanes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh \
|
||||||
|
--lane launcher-plugin \
|
||||||
|
--lane runtime-compat
|
||||||
|
```
|
||||||
|
|
||||||
|
Dry-run to inspect planned commands and artifact layout:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh \
|
||||||
|
--dry-run \
|
||||||
|
launcher/main.ts \
|
||||||
|
src/main.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lane guidance
|
||||||
|
|
||||||
|
- `docs`
|
||||||
|
- For `docs-site/`, `docs/`, and doc-only edits.
|
||||||
|
- `config`
|
||||||
|
- For `src/config/` and config-template-sensitive edits.
|
||||||
|
- `core`
|
||||||
|
- For general source changes where `typecheck` + `test:fast` is the best cheap signal.
|
||||||
|
- `launcher-plugin`
|
||||||
|
- For `launcher/`, `plugin/subminer/`, plugin gating scripts, and wrapper/mpv routing work.
|
||||||
|
- `runtime-compat`
|
||||||
|
- For `src/main*`, runtime/composer wiring, mpv/overlay services, window trackers, and dist-sensitive behavior.
|
||||||
|
- `real-runtime`
|
||||||
|
- Only after deliberate escalation.
|
||||||
|
|
||||||
|
## Real Runtime Escalation
|
||||||
|
|
||||||
|
Escalate only when the change claim depends on actual runtime behavior, for example:
|
||||||
|
|
||||||
|
- overlay appears, hides, or tracks a real mpv window
|
||||||
|
- mpv launch flags or pause-until-ready behavior
|
||||||
|
- plugin/socket/auto-start handshake under a real player
|
||||||
|
- macOS/window-tracker/focus-sensitive behavior
|
||||||
|
|
||||||
|
If the environment cannot support authoritative runtime verification, report the blocker explicitly. Do not silently downgrade a runtime-required claim to a pass.
|
||||||
|
|
||||||
|
## Artifact contract
|
||||||
|
|
||||||
|
The verifier writes under `.tmp/skill-verification/<timestamp>/`:
|
||||||
|
|
||||||
|
- `summary.json`
|
||||||
|
- `summary.txt`
|
||||||
|
- `classification.txt`
|
||||||
|
- `env.txt`
|
||||||
|
- `lanes.txt`
|
||||||
|
- `steps.tsv`
|
||||||
|
- `steps/*.stdout.log`
|
||||||
|
- `steps/*.stderr.log`
|
||||||
|
|
||||||
|
On failure, quote the exact failing command and point at the artifact directory.
|
||||||
163
.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
Executable file
163
.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
Executable file
@@ -0,0 +1,163 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: classify_subminer_diff.sh [path ...]
|
||||||
|
|
||||||
|
Emit suggested verification lanes for explicit paths or current local git changes.
|
||||||
|
|
||||||
|
Output format:
|
||||||
|
lane:<name>
|
||||||
|
flag:<name>
|
||||||
|
reason:<text>
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
has_item() {
|
||||||
|
local needle=$1
|
||||||
|
shift || true
|
||||||
|
local item
|
||||||
|
for item in "$@"; do
|
||||||
|
if [[ "$item" == "$needle" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
add_lane() {
|
||||||
|
local lane=$1
|
||||||
|
if ! has_item "$lane" "${LANES[@]:-}"; then
|
||||||
|
LANES+=("$lane")
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
add_flag() {
|
||||||
|
local flag=$1
|
||||||
|
if ! has_item "$flag" "${FLAGS[@]:-}"; then
|
||||||
|
FLAGS+=("$flag")
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
add_reason() {
|
||||||
|
REASONS+=("$1")
|
||||||
|
}
|
||||||
|
|
||||||
|
collect_git_paths() {
|
||||||
|
local top_level
|
||||||
|
if ! top_level=$(git rev-parse --show-toplevel 2>/dev/null); then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$top_level"
|
||||||
|
if git rev-parse --verify HEAD >/dev/null 2>&1; then
|
||||||
|
git diff --name-only --relative HEAD --
|
||||||
|
git diff --name-only --relative --cached --
|
||||||
|
else
|
||||||
|
git diff --name-only --relative --
|
||||||
|
git diff --name-only --relative --cached --
|
||||||
|
fi
|
||||||
|
git ls-files --others --exclude-standard
|
||||||
|
) | awk 'NF' | sort -u
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
declare -a PATHS=()
|
||||||
|
declare -a LANES=()
|
||||||
|
declare -a FLAGS=()
|
||||||
|
declare -a REASONS=()
|
||||||
|
|
||||||
|
if [[ $# -gt 0 ]]; then
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
PATHS+=("$1")
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
else
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[[ -n "$line" ]] && PATHS+=("$line")
|
||||||
|
done < <(collect_git_paths)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ${#PATHS[@]} -eq 0 ]]; then
|
||||||
|
add_lane "core"
|
||||||
|
add_reason "no changed paths detected -> default to core"
|
||||||
|
fi
|
||||||
|
|
||||||
|
for path in "${PATHS[@]}"; do
|
||||||
|
specialized=0
|
||||||
|
|
||||||
|
case "$path" in
|
||||||
|
docs-site/*|docs/*|changes/*|README.md)
|
||||||
|
add_lane "docs"
|
||||||
|
add_reason "$path -> docs"
|
||||||
|
specialized=1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$path" in
|
||||||
|
src/config/*|src/generate-config-example.ts|src/verify-config-example.ts|docs-site/public/config.example.jsonc|config.example.jsonc)
|
||||||
|
add_lane "config"
|
||||||
|
add_reason "$path -> config"
|
||||||
|
specialized=1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$path" in
|
||||||
|
launcher/*|plugin/subminer/*|plugin/subminer.conf|scripts/test-plugin-*|scripts/get-mpv-window-*|scripts/configure-plugin-binary-path.mjs)
|
||||||
|
add_lane "launcher-plugin"
|
||||||
|
add_reason "$path -> launcher-plugin"
|
||||||
|
add_flag "real-runtime-candidate"
|
||||||
|
add_reason "$path -> real-runtime-candidate"
|
||||||
|
specialized=1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$path" in
|
||||||
|
src/main.ts|src/main-entry.ts|src/preload.ts|src/main/*|src/core/services/mpv*|src/core/services/overlay*|src/renderer/*|src/window-trackers/*|scripts/prepare-build-assets.mjs)
|
||||||
|
add_lane "runtime-compat"
|
||||||
|
add_reason "$path -> runtime-compat"
|
||||||
|
add_flag "real-runtime-candidate"
|
||||||
|
add_reason "$path -> real-runtime-candidate"
|
||||||
|
specialized=1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [[ "$specialized" == "0" ]]; then
|
||||||
|
case "$path" in
|
||||||
|
src/*|package.json|tsconfig*.json|scripts/*|Makefile)
|
||||||
|
add_lane "core"
|
||||||
|
add_reason "$path -> core"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$path" in
|
||||||
|
package.json|src/main.ts|src/main-entry.ts|src/preload.ts)
|
||||||
|
add_flag "broad-impact"
|
||||||
|
add_reason "$path -> broad-impact"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ ${#LANES[@]} -eq 0 ]]; then
|
||||||
|
add_lane "core"
|
||||||
|
add_reason "no lane-specific matches -> default to core"
|
||||||
|
fi
|
||||||
|
|
||||||
|
for lane in "${LANES[@]}"; do
|
||||||
|
printf 'lane:%s\n' "$lane"
|
||||||
|
done
|
||||||
|
|
||||||
|
for flag in "${FLAGS[@]}"; do
|
||||||
|
printf 'flag:%s\n' "$flag"
|
||||||
|
done
|
||||||
|
|
||||||
|
for reason in "${REASONS[@]}"; do
|
||||||
|
printf 'reason:%s\n' "$reason"
|
||||||
|
done
|
||||||
566
.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh
Executable file
566
.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh
Executable file
@@ -0,0 +1,566 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: verify_subminer_change.sh [options] [path ...]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--lane <name> Force a verification lane. Repeatable.
|
||||||
|
--artifact-dir <dir> Use an explicit artifact directory.
|
||||||
|
--allow-real-runtime Allow explicit real-runtime execution.
|
||||||
|
--allow-real-gui Deprecated alias for --allow-real-runtime.
|
||||||
|
--dry-run Record planned steps without executing commands.
|
||||||
|
--help Show this help text.
|
||||||
|
|
||||||
|
If no lanes are supplied, the script classifies the provided paths. If no paths are
|
||||||
|
provided, it classifies the current local git changes.
|
||||||
|
|
||||||
|
Authoritative real-runtime verification should be requested with explicit path
|
||||||
|
arguments instead of relying on inferred local git changes.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp() {
|
||||||
|
date +%Y%m%d-%H%M%S
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp_iso() {
|
||||||
|
date -u +%Y-%m-%dT%H:%M:%SZ
|
||||||
|
}
|
||||||
|
|
||||||
|
generate_session_id() {
|
||||||
|
local tmp_dir
|
||||||
|
tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/subminer-verify-$(timestamp)-XXXXXX")
|
||||||
|
basename "$tmp_dir"
|
||||||
|
rmdir "$tmp_dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
has_item() {
|
||||||
|
local needle=$1
|
||||||
|
shift || true
|
||||||
|
local item
|
||||||
|
for item in "$@"; do
|
||||||
|
if [[ "$item" == "$needle" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize_lane_name() {
|
||||||
|
case "$1" in
|
||||||
|
real-gui)
|
||||||
|
printf '%s' "real-runtime"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf '%s' "$1"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
add_lane() {
|
||||||
|
local lane
|
||||||
|
lane=$(normalize_lane_name "$1")
|
||||||
|
if ! has_item "$lane" "${SELECTED_LANES[@]:-}"; then
|
||||||
|
SELECTED_LANES+=("$lane")
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
add_blocker() {
|
||||||
|
BLOCKERS+=("$1")
|
||||||
|
BLOCKED=1
|
||||||
|
}
|
||||||
|
|
||||||
|
append_step_record() {
|
||||||
|
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
||||||
|
"$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" >>"$STEPS_TSV"
|
||||||
|
}
|
||||||
|
|
||||||
|
record_env() {
|
||||||
|
{
|
||||||
|
printf 'repo_root=%s\n' "$REPO_ROOT"
|
||||||
|
printf 'session_id=%s\n' "$SESSION_ID"
|
||||||
|
printf 'artifact_dir=%s\n' "$ARTIFACT_DIR"
|
||||||
|
printf 'path_selection_mode=%s\n' "$PATH_SELECTION_MODE"
|
||||||
|
printf 'dry_run=%s\n' "$DRY_RUN"
|
||||||
|
printf 'allow_real_runtime=%s\n' "$ALLOW_REAL_RUNTIME"
|
||||||
|
printf 'session_home=%s\n' "$SESSION_HOME"
|
||||||
|
printf 'session_xdg_config_home=%s\n' "$SESSION_XDG_CONFIG_HOME"
|
||||||
|
printf 'session_mpv_dir=%s\n' "$SESSION_MPV_DIR"
|
||||||
|
printf 'session_logs_dir=%s\n' "$SESSION_LOGS_DIR"
|
||||||
|
printf 'session_mpv_log=%s\n' "$SESSION_MPV_LOG"
|
||||||
|
printf 'pwd=%s\n' "$(pwd)"
|
||||||
|
git rev-parse --short HEAD 2>/dev/null | sed 's/^/git_head=/' || true
|
||||||
|
git status --short 2>/dev/null || true
|
||||||
|
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
|
||||||
|
printf 'requested_paths=\n'
|
||||||
|
printf ' %s\n' "${PATH_ARGS[@]}"
|
||||||
|
fi
|
||||||
|
} >"$ARTIFACT_DIR/env.txt"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_step() {
|
||||||
|
local lane=$1
|
||||||
|
local name=$2
|
||||||
|
local command=$3
|
||||||
|
local note=${4:-}
|
||||||
|
local slug=${name//[^a-zA-Z0-9_-]/-}
|
||||||
|
local stdout_rel="steps/${slug}.stdout.log"
|
||||||
|
local stderr_rel="steps/${slug}.stderr.log"
|
||||||
|
local stdout_path="$ARTIFACT_DIR/$stdout_rel"
|
||||||
|
local stderr_path="$ARTIFACT_DIR/$stderr_rel"
|
||||||
|
local status exit_code
|
||||||
|
|
||||||
|
COMMANDS_RUN+=("$command")
|
||||||
|
printf '%s\n' "$command" >"$ARTIFACT_DIR/steps/${slug}.command.txt"
|
||||||
|
|
||||||
|
if [[ "$DRY_RUN" == "1" ]]; then
|
||||||
|
printf '[dry-run] %s\n' "$command" >"$stdout_path"
|
||||||
|
: >"$stderr_path"
|
||||||
|
status="dry-run"
|
||||||
|
exit_code=0
|
||||||
|
else
|
||||||
|
if bash -lc "cd \"$REPO_ROOT\" && $command" >"$stdout_path" 2>"$stderr_path"; then
|
||||||
|
status="passed"
|
||||||
|
exit_code=0
|
||||||
|
EXECUTED_REAL_STEPS=1
|
||||||
|
else
|
||||||
|
exit_code=$?
|
||||||
|
status="failed"
|
||||||
|
FAILED=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
append_step_record "$lane" "$name" "$status" "$exit_code" "$command" "$stdout_rel" "$stderr_rel" "$note"
|
||||||
|
printf '%s\t%s\t%s\n' "$lane" "$name" "$status"
|
||||||
|
|
||||||
|
if [[ "$status" == "failed" ]]; then
|
||||||
|
FAILURE_STEP="$name"
|
||||||
|
FAILURE_COMMAND="$command"
|
||||||
|
FAILURE_STDOUT="$stdout_rel"
|
||||||
|
FAILURE_STDERR="$stderr_rel"
|
||||||
|
return "$exit_code"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
record_nonpassing_step() {
|
||||||
|
local lane=$1
|
||||||
|
local name=$2
|
||||||
|
local status=$3
|
||||||
|
local note=$4
|
||||||
|
local slug=${name//[^a-zA-Z0-9_-]/-}
|
||||||
|
local stdout_rel="steps/${slug}.stdout.log"
|
||||||
|
local stderr_rel="steps/${slug}.stderr.log"
|
||||||
|
printf '%s\n' "$note" >"$ARTIFACT_DIR/$stdout_rel"
|
||||||
|
: >"$ARTIFACT_DIR/$stderr_rel"
|
||||||
|
append_step_record "$lane" "$name" "$status" "0" "" "$stdout_rel" "$stderr_rel" "$note"
|
||||||
|
printf '%s\t%s\t%s\n' "$lane" "$name" "$status"
|
||||||
|
}
|
||||||
|
|
||||||
|
record_skipped_step() {
|
||||||
|
record_nonpassing_step "$1" "$2" "skipped" "$3"
|
||||||
|
}
|
||||||
|
|
||||||
|
record_blocked_step() {
|
||||||
|
add_blocker "$3"
|
||||||
|
record_nonpassing_step "$1" "$2" "blocked" "$3"
|
||||||
|
}
|
||||||
|
|
||||||
|
record_failed_step() {
|
||||||
|
FAILED=1
|
||||||
|
FAILURE_STEP=$2
|
||||||
|
FAILURE_COMMAND=${FAILURE_COMMAND:-"(validation)"}
|
||||||
|
FAILURE_STDOUT="steps/${2//[^a-zA-Z0-9_-]/-}.stdout.log"
|
||||||
|
FAILURE_STDERR="steps/${2//[^a-zA-Z0-9_-]/-}.stderr.log"
|
||||||
|
add_blocker "$3"
|
||||||
|
record_nonpassing_step "$1" "$2" "failed" "$3"
|
||||||
|
}
|
||||||
|
|
||||||
|
find_real_runtime_helper() {
|
||||||
|
local candidate
|
||||||
|
for candidate in \
|
||||||
|
"$SCRIPT_DIR/run_real_runtime_smoke.sh" \
|
||||||
|
"$SCRIPT_DIR/run_real_mpv_smoke.sh"; do
|
||||||
|
if [[ -x "$candidate" ]]; then
|
||||||
|
printf '%s' "$candidate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
acquire_real_runtime_lease() {
|
||||||
|
local lease_root="$REPO_ROOT/.tmp/skill-verification/locks"
|
||||||
|
local lease_dir="$lease_root/exclusive-real-runtime"
|
||||||
|
mkdir -p "$lease_root"
|
||||||
|
if mkdir "$lease_dir" 2>/dev/null; then
|
||||||
|
REAL_RUNTIME_LEASE_DIR="$lease_dir"
|
||||||
|
printf '%s\n' "$SESSION_ID" >"$lease_dir/session_id"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local owner=""
|
||||||
|
if [[ -f "$lease_dir/session_id" ]]; then
|
||||||
|
owner=$(cat "$lease_dir/session_id")
|
||||||
|
fi
|
||||||
|
add_blocker "real-runtime lease already held${owner:+ by $owner}"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
release_real_runtime_lease() {
|
||||||
|
if [[ -n "$REAL_RUNTIME_LEASE_DIR" && -d "$REAL_RUNTIME_LEASE_DIR" ]]; then
|
||||||
|
if [[ -f "$REAL_RUNTIME_LEASE_DIR/session_id" ]]; then
|
||||||
|
local owner
|
||||||
|
owner=$(cat "$REAL_RUNTIME_LEASE_DIR/session_id")
|
||||||
|
if [[ "$owner" != "$SESSION_ID" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
rm -rf "$REAL_RUNTIME_LEASE_DIR"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
compute_final_status() {
|
||||||
|
if [[ "$FAILED" == "1" ]]; then
|
||||||
|
FINAL_STATUS="failed"
|
||||||
|
elif [[ "$BLOCKED" == "1" ]]; then
|
||||||
|
FINAL_STATUS="blocked"
|
||||||
|
elif [[ "$EXECUTED_REAL_STEPS" == "1" ]]; then
|
||||||
|
FINAL_STATUS="passed"
|
||||||
|
else
|
||||||
|
FINAL_STATUS="skipped"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
write_summary_files() {
|
||||||
|
local lane_lines
|
||||||
|
lane_lines=$(printf '%s\n' "${SELECTED_LANES[@]}")
|
||||||
|
printf '%s\n' "$lane_lines" >"$ARTIFACT_DIR/lanes.txt"
|
||||||
|
printf '%s\n' "${BLOCKERS[@]}" >"$ARTIFACT_DIR/blockers.txt"
|
||||||
|
printf '%s\n' "${PATH_ARGS[@]}" >"$ARTIFACT_DIR/requested-paths.txt"
|
||||||
|
|
||||||
|
ARTIFACT_DIR_ENV="$ARTIFACT_DIR" \
|
||||||
|
SESSION_ID_ENV="$SESSION_ID" \
|
||||||
|
FINAL_STATUS_ENV="$FINAL_STATUS" \
|
||||||
|
PATH_SELECTION_MODE_ENV="$PATH_SELECTION_MODE" \
|
||||||
|
ALLOW_REAL_RUNTIME_ENV="$ALLOW_REAL_RUNTIME" \
|
||||||
|
SESSION_HOME_ENV="$SESSION_HOME" \
|
||||||
|
SESSION_XDG_CONFIG_HOME_ENV="$SESSION_XDG_CONFIG_HOME" \
|
||||||
|
SESSION_MPV_DIR_ENV="$SESSION_MPV_DIR" \
|
||||||
|
SESSION_LOGS_DIR_ENV="$SESSION_LOGS_DIR" \
|
||||||
|
SESSION_MPV_LOG_ENV="$SESSION_MPV_LOG" \
|
||||||
|
STARTED_AT_ENV="$STARTED_AT" \
|
||||||
|
FINISHED_AT_ENV="$FINISHED_AT" \
|
||||||
|
FAILED_ENV="$FAILED" \
|
||||||
|
FAILURE_COMMAND_ENV="${FAILURE_COMMAND:-}" \
|
||||||
|
FAILURE_STDOUT_ENV="${FAILURE_STDOUT:-}" \
|
||||||
|
FAILURE_STDERR_ENV="${FAILURE_STDERR:-}" \
|
||||||
|
bun -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
function readLines(filePath) {
|
||||||
|
if (!fs.existsSync(filePath)) return [];
|
||||||
|
return fs.readFileSync(filePath, "utf8").split(/\r?\n/).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
const artifactDir = process.env.ARTIFACT_DIR_ENV;
|
||||||
|
const reportsDir = path.join(artifactDir, "reports");
|
||||||
|
const lanes = readLines(path.join(artifactDir, "lanes.txt"));
|
||||||
|
const blockers = readLines(path.join(artifactDir, "blockers.txt"));
|
||||||
|
const requestedPaths = readLines(path.join(artifactDir, "requested-paths.txt"));
|
||||||
|
const steps = readLines(path.join(artifactDir, "steps.tsv")).map((line) => {
|
||||||
|
const [lane, name, status, exitCode, command, stdout, stderr, note] = line.split("\t");
|
||||||
|
return {
|
||||||
|
lane,
|
||||||
|
name,
|
||||||
|
status,
|
||||||
|
exitCode: Number(exitCode || 0),
|
||||||
|
command,
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
note,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const summary = {
|
||||||
|
sessionId: process.env.SESSION_ID_ENV || "",
|
||||||
|
artifactDir,
|
||||||
|
reportsDir,
|
||||||
|
status: process.env.FINAL_STATUS_ENV || "failed",
|
||||||
|
selectedLanes: lanes,
|
||||||
|
failed: process.env.FAILED_ENV === "1",
|
||||||
|
failure:
|
||||||
|
process.env.FAILED_ENV === "1"
|
||||||
|
? {
|
||||||
|
command: process.env.FAILURE_COMMAND_ENV || "",
|
||||||
|
stdout: process.env.FAILURE_STDOUT_ENV || "",
|
||||||
|
stderr: process.env.FAILURE_STDERR_ENV || "",
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
blockers,
|
||||||
|
pathSelectionMode: process.env.PATH_SELECTION_MODE_ENV || "git-inferred",
|
||||||
|
requestedPaths,
|
||||||
|
allowRealRuntime: process.env.ALLOW_REAL_RUNTIME_ENV === "1",
|
||||||
|
startedAt: process.env.STARTED_AT_ENV || "",
|
||||||
|
finishedAt: process.env.FINISHED_AT_ENV || "",
|
||||||
|
env: {
|
||||||
|
home: process.env.SESSION_HOME_ENV || "",
|
||||||
|
xdgConfigHome: process.env.SESSION_XDG_CONFIG_HOME_ENV || "",
|
||||||
|
mpvDir: process.env.SESSION_MPV_DIR_ENV || "",
|
||||||
|
logsDir: process.env.SESSION_LOGS_DIR_ENV || "",
|
||||||
|
mpvLog: process.env.SESSION_MPV_LOG_ENV || "",
|
||||||
|
},
|
||||||
|
steps,
|
||||||
|
};
|
||||||
|
|
||||||
|
const summaryJson = JSON.stringify(summary, null, 2) + "\n";
|
||||||
|
fs.writeFileSync(path.join(artifactDir, "summary.json"), summaryJson);
|
||||||
|
fs.writeFileSync(path.join(reportsDir, "summary.json"), summaryJson);
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
lines.push(`session_id: ${summary.sessionId}`);
|
||||||
|
lines.push(`artifact_dir: ${artifactDir}`);
|
||||||
|
lines.push(`selected_lanes: ${lanes.join(", ") || "(none)"}`);
|
||||||
|
lines.push(`status: ${summary.status}`);
|
||||||
|
lines.push(`path_selection_mode: ${summary.pathSelectionMode}`);
|
||||||
|
if (requestedPaths.length > 0) {
|
||||||
|
lines.push(`requested_paths: ${requestedPaths.join(", ")}`);
|
||||||
|
}
|
||||||
|
if (blockers.length > 0) {
|
||||||
|
lines.push(`blockers: ${blockers.join(" | ")}`);
|
||||||
|
}
|
||||||
|
for (const step of steps) {
|
||||||
|
lines.push(`${step.lane}/${step.name}: ${step.status}`);
|
||||||
|
if (step.command) lines.push(` command: ${step.command}`);
|
||||||
|
lines.push(` stdout: ${step.stdout}`);
|
||||||
|
lines.push(` stderr: ${step.stderr}`);
|
||||||
|
if (step.note) lines.push(` note: ${step.note}`);
|
||||||
|
}
|
||||||
|
if (summary.failed) {
|
||||||
|
lines.push(`failure_command: ${process.env.FAILURE_COMMAND_ENV || ""}`);
|
||||||
|
}
|
||||||
|
const summaryText = lines.join("\n") + "\n";
|
||||||
|
fs.writeFileSync(path.join(artifactDir, "summary.txt"), summaryText);
|
||||||
|
fs.writeFileSync(path.join(reportsDir, "summary.txt"), summaryText);
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
release_real_runtime_lease
|
||||||
|
}
|
||||||
|
|
||||||
|
CLASSIFIER_OUTPUT=""
|
||||||
|
ARTIFACT_DIR=""
|
||||||
|
ALLOW_REAL_RUNTIME=0
|
||||||
|
DRY_RUN=0
|
||||||
|
FAILED=0
|
||||||
|
BLOCKED=0
|
||||||
|
EXECUTED_REAL_STEPS=0
|
||||||
|
FINAL_STATUS=""
|
||||||
|
FAILURE_STEP=""
|
||||||
|
FAILURE_COMMAND=""
|
||||||
|
FAILURE_STDOUT=""
|
||||||
|
FAILURE_STDERR=""
|
||||||
|
REAL_RUNTIME_LEASE_DIR=""
|
||||||
|
STARTED_AT=""
|
||||||
|
FINISHED_AT=""
|
||||||
|
|
||||||
|
declare -a EXPLICIT_LANES=()
|
||||||
|
declare -a SELECTED_LANES=()
|
||||||
|
declare -a PATH_ARGS=()
|
||||||
|
declare -a COMMANDS_RUN=()
|
||||||
|
declare -a BLOCKERS=()
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--lane)
|
||||||
|
EXPLICIT_LANES+=("$(normalize_lane_name "$2")")
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--artifact-dir)
|
||||||
|
ARTIFACT_DIR=$2
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--allow-real-runtime|--allow-real-gui)
|
||||||
|
ALLOW_REAL_RUNTIME=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--dry-run)
|
||||||
|
DRY_RUN=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--)
|
||||||
|
shift
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
PATH_ARGS+=("$1")
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
PATH_ARGS+=("$1")
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
|
||||||
|
SESSION_ID=$(generate_session_id)
|
||||||
|
PATH_SELECTION_MODE="git-inferred"
|
||||||
|
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
|
||||||
|
PATH_SELECTION_MODE="explicit"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$ARTIFACT_DIR" ]]; then
|
||||||
|
mkdir -p "$REPO_ROOT/.tmp/skill-verification"
|
||||||
|
ARTIFACT_DIR="$REPO_ROOT/.tmp/skill-verification/$SESSION_ID"
|
||||||
|
fi
|
||||||
|
|
||||||
|
SESSION_HOME="$ARTIFACT_DIR/home"
|
||||||
|
SESSION_XDG_CONFIG_HOME="$ARTIFACT_DIR/xdg"
|
||||||
|
SESSION_MPV_DIR="$ARTIFACT_DIR/mpv"
|
||||||
|
SESSION_LOGS_DIR="$ARTIFACT_DIR/logs"
|
||||||
|
SESSION_MPV_LOG="$SESSION_LOGS_DIR/mpv.log"
|
||||||
|
|
||||||
|
mkdir -p "$ARTIFACT_DIR/steps" "$ARTIFACT_DIR/reports" "$SESSION_HOME" "$SESSION_XDG_CONFIG_HOME" "$SESSION_MPV_DIR" "$SESSION_LOGS_DIR"
|
||||||
|
STEPS_TSV="$ARTIFACT_DIR/steps.tsv"
|
||||||
|
: >"$STEPS_TSV"
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
STARTED_AT=$(timestamp_iso)
|
||||||
|
|
||||||
|
if [[ ${#EXPLICIT_LANES[@]} -gt 0 ]]; then
|
||||||
|
local_lane=""
|
||||||
|
for local_lane in "${EXPLICIT_LANES[@]}"; do
|
||||||
|
add_lane "$local_lane"
|
||||||
|
done
|
||||||
|
printf 'reason:explicit lanes supplied\n' >"$ARTIFACT_DIR/classification.txt"
|
||||||
|
else
|
||||||
|
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
|
||||||
|
CLASSIFIER_OUTPUT=$(bash "$SCRIPT_DIR/classify_subminer_diff.sh" "${PATH_ARGS[@]}")
|
||||||
|
else
|
||||||
|
CLASSIFIER_OUTPUT=$(bash "$SCRIPT_DIR/classify_subminer_diff.sh")
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$CLASSIFIER_OUTPUT" >"$ARTIFACT_DIR/classification.txt"
|
||||||
|
while IFS= read -r line; do
|
||||||
|
case "$line" in
|
||||||
|
lane:*)
|
||||||
|
add_lane "${line#lane:}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done <<<"$CLASSIFIER_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
record_env
|
||||||
|
|
||||||
|
printf 'artifact_dir=%s\n' "$ARTIFACT_DIR"
|
||||||
|
printf 'selected_lanes=%s\n' "$(IFS=,; echo "${SELECTED_LANES[*]}")"
|
||||||
|
|
||||||
|
for lane in "${SELECTED_LANES[@]}"; do
|
||||||
|
case "$lane" in
|
||||||
|
docs)
|
||||||
|
run_step "$lane" "docs-test" "bun run docs:test" || break
|
||||||
|
[[ "$FAILED" == "1" ]] && break
|
||||||
|
run_step "$lane" "docs-build" "bun run docs:build" || break
|
||||||
|
;;
|
||||||
|
config)
|
||||||
|
run_step "$lane" "test-config" "bun run test:config" || break
|
||||||
|
;;
|
||||||
|
core)
|
||||||
|
run_step "$lane" "typecheck" "bun run typecheck" || break
|
||||||
|
[[ "$FAILED" == "1" ]] && break
|
||||||
|
run_step "$lane" "test-fast" "bun run test:fast" || break
|
||||||
|
;;
|
||||||
|
launcher-plugin)
|
||||||
|
run_step "$lane" "launcher-smoke-src" "bun run test:launcher:smoke:src" || break
|
||||||
|
[[ "$FAILED" == "1" ]] && break
|
||||||
|
run_step "$lane" "plugin-src" "bun run test:plugin:src" || break
|
||||||
|
;;
|
||||||
|
runtime-compat)
|
||||||
|
run_step "$lane" "build" "bun run build" || break
|
||||||
|
[[ "$FAILED" == "1" ]] && break
|
||||||
|
run_step "$lane" "test-runtime-compat" "bun run test:runtime:compat" || break
|
||||||
|
[[ "$FAILED" == "1" ]] && break
|
||||||
|
run_step "$lane" "test-smoke-dist" "bun run test:smoke:dist" || break
|
||||||
|
;;
|
||||||
|
real-runtime)
|
||||||
|
if [[ "$PATH_SELECTION_MODE" != "explicit" ]]; then
|
||||||
|
record_blocked_step \
|
||||||
|
"$lane" \
|
||||||
|
"real-runtime-guard" \
|
||||||
|
"real-runtime lane requires explicit paths; inferred local git changes are non-authoritative"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$ALLOW_REAL_RUNTIME" != "1" ]]; then
|
||||||
|
record_blocked_step \
|
||||||
|
"$lane" \
|
||||||
|
"real-runtime-guard" \
|
||||||
|
"real-runtime lane requested but --allow-real-runtime was not supplied"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! acquire_real_runtime_lease; then
|
||||||
|
record_blocked_step \
|
||||||
|
"$lane" \
|
||||||
|
"real-runtime-lease" \
|
||||||
|
"real-runtime lease already held; rerun after the active runtime verification finishes"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! REAL_RUNTIME_HELPER=$(find_real_runtime_helper); then
|
||||||
|
record_blocked_step \
|
||||||
|
"$lane" \
|
||||||
|
"real-runtime-helper" \
|
||||||
|
"real-runtime helper not implemented yet"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf -v REAL_RUNTIME_COMMAND \
|
||||||
|
'SESSION_ID=%q HOME=%q XDG_CONFIG_HOME=%q SUBMINER_MPV_LOG=%q bash %q' \
|
||||||
|
"$SESSION_ID" \
|
||||||
|
"$SESSION_HOME" \
|
||||||
|
"$SESSION_XDG_CONFIG_HOME" \
|
||||||
|
"$SESSION_MPV_LOG" \
|
||||||
|
"$REAL_RUNTIME_HELPER"
|
||||||
|
|
||||||
|
run_step "$lane" "real-runtime-smoke" "$REAL_RUNTIME_COMMAND" || break
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
record_failed_step "$lane" "lane-validation" "unknown lane: $lane"
|
||||||
|
break
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [[ "$FAILED" == "1" || "$BLOCKED" == "1" ]]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
FINISHED_AT=$(timestamp_iso)
|
||||||
|
compute_final_status
|
||||||
|
write_summary_files
|
||||||
|
|
||||||
|
printf 'status=%s\n' "$FINAL_STATUS"
|
||||||
|
printf 'artifact_dir=%s\n' "$ARTIFACT_DIR"
|
||||||
|
|
||||||
|
case "$FINAL_STATUS" in
|
||||||
|
failed)
|
||||||
|
printf 'result=failed\n'
|
||||||
|
printf 'failure_command=%s\n' "$FAILURE_COMMAND"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
blocked)
|
||||||
|
printf 'result=blocked\n'
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf 'result=ok\n'
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
146
.agents/skills/subminer-scrum-master/SKILL.md
Normal file
146
.agents/skills/subminer-scrum-master/SKILL.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
---
|
||||||
|
name: "subminer-scrum-master"
|
||||||
|
description: "Use in the SubMiner repo when a request should be turned into planned work and driven through execution. Assesses whether backlog tracking is warranted, creates or updates tasks when needed, records a plan, dispatches one or more subagents, and requires verification before handoff."
|
||||||
|
---
|
||||||
|
|
||||||
|
# SubMiner Scrum Master
|
||||||
|
|
||||||
|
Own workflow, not code by default.
|
||||||
|
|
||||||
|
Use this skill when the user gives a feature request, bug report, issue, refactor, or implementation ask and the agent should manage intake, planning, backlog hygiene, worker dispatch, and verification through completion.
|
||||||
|
|
||||||
|
## Core Rules
|
||||||
|
|
||||||
|
1. Decide first whether backlog tracking is warranted.
|
||||||
|
2. If backlog is needed, search first. Update existing work when it clearly matches.
|
||||||
|
3. If backlog is not needed, keep the process light. Do not invent ticket ceremony.
|
||||||
|
4. Record a plan before dispatching coding work.
|
||||||
|
5. Use parent + subtasks for multi-part work when backlog is used.
|
||||||
|
6. Dispatch conservatively. Parallelize only disjoint write scopes.
|
||||||
|
7. Require verification before handoff, typically via `subminer-change-verification`.
|
||||||
|
8. Report backlog actions, dispatched workers, verification, blockers, and remaining risks.
|
||||||
|
|
||||||
|
## Backlog Decision
|
||||||
|
|
||||||
|
Skip backlog when the request is:
|
||||||
|
- question only
|
||||||
|
- obvious mechanical edit
|
||||||
|
- tiny isolated change with no real planning
|
||||||
|
|
||||||
|
Use backlog when the work:
|
||||||
|
- needs planning or scope decisions
|
||||||
|
- spans multiple phases or subsystems
|
||||||
|
- is likely to need subagent dispatch
|
||||||
|
- should remain traceable for handoff/resume
|
||||||
|
|
||||||
|
If backlog is used:
|
||||||
|
- search existing tasks first
|
||||||
|
- create/update a standalone task for one focused deliverable
|
||||||
|
- create/update a parent task plus subtasks for multi-part work
|
||||||
|
- record the implementation plan in the task before implementation begins
|
||||||
|
|
||||||
|
## Intake Workflow
|
||||||
|
|
||||||
|
1. Parse the request.
|
||||||
|
Classify it as question, mechanical edit, bugfix, feature, refactor, investigation, or follow-up.
|
||||||
|
2. Decide whether backlog is needed.
|
||||||
|
3. If backlog is needed:
|
||||||
|
- search first
|
||||||
|
- update existing task if clearly relevant
|
||||||
|
- otherwise create the right structure
|
||||||
|
- write the implementation plan before dispatch
|
||||||
|
4. If backlog is skipped:
|
||||||
|
- write a short working plan in-thread
|
||||||
|
- proceed without fake ticketing
|
||||||
|
5. Choose execution mode:
|
||||||
|
- no subagents for trivial work
|
||||||
|
- one worker for focused work
|
||||||
|
- parallel workers only for disjoint scopes
|
||||||
|
6. Run verification before handoff.
|
||||||
|
|
||||||
|
## Dispatch Rules
|
||||||
|
|
||||||
|
The scrum master orchestrates. Workers implement.
|
||||||
|
|
||||||
|
- Do not become the default implementer unless delegation is unnecessary.
|
||||||
|
- Do not parallelize overlapping files or tightly coupled runtime work.
|
||||||
|
- Give every worker explicit ownership of files/modules.
|
||||||
|
- Tell every worker other agents may be active and they must not revert unrelated edits.
|
||||||
|
- Require each worker to report:
|
||||||
|
- changed files
|
||||||
|
- tests run
|
||||||
|
- blockers
|
||||||
|
|
||||||
|
Use worker agents for implementation and explorer agents only for bounded codebase questions.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Every nontrivial code task gets verification.
|
||||||
|
|
||||||
|
Preferred flow:
|
||||||
|
1. use `subminer-change-verification`
|
||||||
|
2. start with the cheapest sufficient lane
|
||||||
|
3. escalate only when needed
|
||||||
|
4. if worker verification is sufficient, accept it or run one final consolidating pass
|
||||||
|
|
||||||
|
Never hand off nontrivial work without stating what was verified and what was skipped.
|
||||||
|
|
||||||
|
## Pre-Handoff Policy Checks (Required)
|
||||||
|
|
||||||
|
Before handoff, always ask and answer both of these questions explicitly:
|
||||||
|
|
||||||
|
1. **Docs update required?**
|
||||||
|
2. **Changelog fragment required?**
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Do not assume silence implies "no." Record an explicit yes/no decision for each item.
|
||||||
|
- If the answer is yes, either complete the update or report the blocker before handoff.
|
||||||
|
- Include the final answers in the handoff summary even when both answers are "no."
|
||||||
|
|
||||||
|
## Failure / Scope Handling
|
||||||
|
|
||||||
|
- If a worker hits ambiguity, pause and ask the user.
|
||||||
|
- If verification fails, either:
|
||||||
|
- send the worker back with exact failure context, or
|
||||||
|
- fix it directly if it is tiny and clearly in scope
|
||||||
|
- If new scope appears, revisit backlog structure before silently expanding work.
|
||||||
|
|
||||||
|
## Representative Flows
|
||||||
|
|
||||||
|
### Trivial no-ticket work
|
||||||
|
|
||||||
|
- decide backlog is unnecessary
|
||||||
|
- keep a short plan
|
||||||
|
- implement directly or with one worker if helpful
|
||||||
|
- run targeted verification
|
||||||
|
- report outcome concisely
|
||||||
|
|
||||||
|
### Single-task implementation
|
||||||
|
|
||||||
|
- search/create/update one task
|
||||||
|
- record plan
|
||||||
|
- dispatch one worker
|
||||||
|
- integrate
|
||||||
|
- verify
|
||||||
|
- update task and report outcome
|
||||||
|
|
||||||
|
### Parent + subtasks execution
|
||||||
|
|
||||||
|
- search/create/update parent task
|
||||||
|
- create subtasks for distinct deliverables/phases
|
||||||
|
- record sequencing in the plan
|
||||||
|
- dispatch workers only where scopes are disjoint
|
||||||
|
- integrate
|
||||||
|
- run consolidated verification
|
||||||
|
- update task state and report outcome
|
||||||
|
|
||||||
|
## Output Expectations
|
||||||
|
|
||||||
|
At the end, report:
|
||||||
|
- whether backlog was used and what changed
|
||||||
|
- which workers were dispatched and what they owned
|
||||||
|
- what verification ran
|
||||||
|
- explicit answers to:
|
||||||
|
- docs update required?
|
||||||
|
- changelog fragment required?
|
||||||
|
- blockers, skips, and risks
|
||||||
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@@ -9,9 +9,6 @@ concurrency:
|
|||||||
group: release-${{ github.ref }}
|
group: release-${{ github.ref }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
quality-gate:
|
quality-gate:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -244,6 +241,8 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
needs: [build-linux, build-macos, build-windows]
|
needs: [build-linux, build-macos, build-windows]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -35,6 +35,19 @@ docs/.vitepress/cache/
|
|||||||
docs/.vitepress/dist/
|
docs/.vitepress/dist/
|
||||||
tests/*
|
tests/*
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
.tmp/
|
||||||
.codex/*
|
.codex/*
|
||||||
.agents/*
|
.agents/*
|
||||||
|
!.agents/skills/
|
||||||
|
.agents/skills/*
|
||||||
|
!.agents/skills/subminer-change-verification/
|
||||||
|
!.agents/skills/subminer-scrum-master/
|
||||||
|
.agents/skills/subminer-change-verification/*
|
||||||
|
!.agents/skills/subminer-change-verification/SKILL.md
|
||||||
|
!.agents/skills/subminer-change-verification/scripts/
|
||||||
|
.agents/skills/subminer-change-verification/scripts/*
|
||||||
|
!.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
|
||||||
|
!.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh
|
||||||
|
.agents/skills/subminer-scrum-master/*
|
||||||
|
!.agents/skills/subminer-scrum-master/SKILL.md
|
||||||
favicon.png
|
favicon.png
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
id: TASK-159
|
||||||
|
title: Add overlay controller support for keyboard-only mode
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- codex
|
||||||
|
created_date: '2026-03-11 00:30'
|
||||||
|
updated_date: '2026-03-11 04:05'
|
||||||
|
labels:
|
||||||
|
- enhancement
|
||||||
|
- renderer
|
||||||
|
- overlay
|
||||||
|
- input
|
||||||
|
dependencies:
|
||||||
|
- TASK-86
|
||||||
|
references:
|
||||||
|
- src/renderer/handlers/keyboard.ts
|
||||||
|
- src/renderer/renderer.ts
|
||||||
|
- src/renderer/state.ts
|
||||||
|
- src/renderer/index.html
|
||||||
|
- src/renderer/style.css
|
||||||
|
- src/preload.ts
|
||||||
|
- src/types.ts
|
||||||
|
- src/config/definitions/defaults-core.ts
|
||||||
|
- src/config/definitions/options-core.ts
|
||||||
|
- src/config/definitions/template-sections.ts
|
||||||
|
- config.example.jsonc
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
Add Chrome Gamepad API support to the visible overlay as a supplement to keyboard-only mode. By default SubMiner should bind to the first available controller, allow the user to pick and persist a preferred controller, expose a raw-input debug modal, and map controller actions onto the existing keyboard-only/Yomitan flow without breaking keyboard input. Also fix the current keyboard-only cleanup bug so the selected-token highlight clears when keyboard-only mode turns off or when the Yomitan popup closes.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [x] #1 Controller input is ignored unless keyboard-only mode is enabled, except the controller binding for toggling keyboard-only mode itself.
|
||||||
|
- [x] #2 Default logical mappings work: smooth popup scroll, token selection, lookup toggle/close, mining, Yomitan audio navigation/play, and mpv play/pause.
|
||||||
|
- [x] #3 Controller config supports named logical bindings plus tuning knobs (preferred controller, deadzones, smooth-scroll speed/repeat), not raw axis/button maps.
|
||||||
|
- [x] #4 `Alt+C` opens a controller selection modal listing connected controllers; saving a choice persists the preferred controller for next launch.
|
||||||
|
- [x] #5 `Alt+Shift+C` opens a debug modal showing live raw controller axes/buttons as seen by SubMiner.
|
||||||
|
- [x] #6 Keyboard-only selection highlight clears immediately when keyboard-only mode is disabled or the Yomitan popup closes.
|
||||||
|
- [x] #7 Renderer/config regression tests cover controller gating, mappings, modal behavior, persisted selection, and highlight cleanup.
|
||||||
|
- [x] #8 Docs/config example describe the controller feature and new shortcuts.
|
||||||
|
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
- Added renderer-side gamepad polling and logical action mapping in `src/renderer/handlers/gamepad-controller.ts`.
|
||||||
|
- Added controller select/debug modals, persisted preferred-controller IPC, and top-level `controller` config defaults/schema/template output.
|
||||||
|
- Added a transient in-overlay controller status indicator when a controller is first detected.
|
||||||
|
- Tuned controller defaults and routing after live testing: d-pad fallback navigation, slower repeat timing, DOM-backed popup-open detection, and direct pixel scroll/audio-source popup bridge commands.
|
||||||
|
- Reused existing keyboard-only lookup/mining/navigation flows so controller input stays a supplement to keyboard-only mode instead of a parallel input path.
|
||||||
|
- Verified keyboard-only highlight cleanup on mode-off and popup-close paths with renderer tests.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `bun test src/config/config.test.ts src/config/definitions/domain-registry.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/handlers/gamepad-controller.test.ts src/renderer/modals/controller-select.test.ts src/renderer/modals/controller-debug.test.ts src/core/services/ipc.test.ts`
|
||||||
|
- `bun test src/main/runtime/composers/ipc-runtime-composer.test.ts`
|
||||||
|
- `bun run generate:config-example`
|
||||||
|
- `bun run typecheck`
|
||||||
|
- `bun run docs:test`
|
||||||
|
- `bun run test:fast`
|
||||||
|
- `bun run test:env`
|
||||||
|
- `bun run build`
|
||||||
|
- `bun run docs:build`
|
||||||
|
- `bun run test:smoke:dist`
|
||||||
4
changes/2026-03-13-scrum-master-handoff-checks.md
Normal file
4
changes/2026-03-13-scrum-master-handoff-checks.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
type: internal
|
||||||
|
area: workflow
|
||||||
|
|
||||||
|
- Hardened the `subminer-scrum-master` skill to explicitly answer whether docs updates and changelog fragments are required before handoff.
|
||||||
4
changes/yomitan-external-profile-read-only.md
Normal file
4
changes/yomitan-external-profile-read-only.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: yomitan
|
||||||
|
|
||||||
|
- Added external-profile mode support that keeps Yomitan dictionaries shared while hardening read-only runtime behavior and first-run setup handling.
|
||||||
@@ -50,6 +50,55 @@
|
|||||||
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
|
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
|
||||||
}, // Controls logging verbosity.
|
}, // Controls logging verbosity.
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Controller Support
|
||||||
|
// Gamepad support for the visible overlay while keyboard-only mode is active.
|
||||||
|
// Use the selection modal to save a preferred controller by id for future launches.
|
||||||
|
// Trigger input mode can be auto, digital-only, or analog-thresholded depending on the controller.
|
||||||
|
// Override controller.buttonIndices when your pad reports non-standard raw button numbers.
|
||||||
|
// ==========================================
|
||||||
|
"controller": {
|
||||||
|
"enabled": true, // Enable overlay controller support through the Chrome Gamepad API. Values: true | false
|
||||||
|
"preferredGamepadId": "", // Preferred controller id saved from the controller selection modal.
|
||||||
|
"preferredGamepadLabel": "", // Preferred controller display label saved for diagnostics.
|
||||||
|
"smoothScroll": true, // Use smooth scrolling for controller-driven popup scroll input. Values: true | false
|
||||||
|
"scrollPixelsPerSecond": 900, // Base popup scroll speed for controller stick input.
|
||||||
|
"horizontalJumpPixels": 160, // Popup page-jump distance for controller jump input.
|
||||||
|
"stickDeadzone": 0.2, // Deadzone applied to controller stick axes.
|
||||||
|
"triggerInputMode": "auto", // How controller triggers are interpreted: auto, pressed-only, or thresholded analog. Values: auto | digital | analog
|
||||||
|
"triggerDeadzone": 0.5, // Minimum analog trigger value required when trigger input uses auto or analog mode.
|
||||||
|
"repeatDelayMs": 320, // Delay before repeating held controller actions.
|
||||||
|
"repeatIntervalMs": 120, // Repeat interval for held controller actions.
|
||||||
|
"buttonIndices": {
|
||||||
|
"select": 6, // Raw button index used for the controller select/minus/back button.
|
||||||
|
"buttonSouth": 0, // Raw button index used for controller south/A button input.
|
||||||
|
"buttonEast": 1, // Raw button index used for controller east/B button input.
|
||||||
|
"buttonWest": 2, // Raw button index used for controller west/X button input.
|
||||||
|
"buttonNorth": 3, // Raw button index used for controller north/Y button input.
|
||||||
|
"leftShoulder": 4, // Raw button index used for controller left shoulder input.
|
||||||
|
"rightShoulder": 5, // Raw button index used for controller right shoulder input.
|
||||||
|
"leftStickPress": 9, // Raw button index used for controller L3 input.
|
||||||
|
"rightStickPress": 10, // Raw button index used for controller R3 input.
|
||||||
|
"leftTrigger": 6, // Raw button index used for controller L2 input.
|
||||||
|
"rightTrigger": 7 // Raw button index used for controller R2 input.
|
||||||
|
}, // Button indices setting.
|
||||||
|
"bindings": {
|
||||||
|
"toggleLookup": "buttonSouth", // Controller binding for toggling lookup. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||||
|
"closeLookup": "buttonEast", // Controller binding for closing lookup. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||||
|
"toggleKeyboardOnlyMode": "buttonNorth", // Controller binding for toggling keyboard-only mode. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||||
|
"mineCard": "buttonWest", // Controller binding for mining the active card. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||||
|
"quitMpv": "select", // Controller binding for quitting mpv. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||||
|
"previousAudio": "none", // Controller binding for previous Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||||
|
"nextAudio": "rightShoulder", // Controller binding for next Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||||
|
"playCurrentAudio": "leftShoulder", // Controller binding for playing the current Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||||
|
"toggleMpvPause": "leftStickPress", // Controller binding for toggling mpv play/pause. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||||
|
"leftStickHorizontal": "leftStickX", // Axis binding used for left/right token selection. Values: leftStickX | leftStickY | rightStickX | rightStickY
|
||||||
|
"leftStickVertical": "leftStickY", // Axis binding used for primary popup scrolling. Values: leftStickX | leftStickY | rightStickX | rightStickY
|
||||||
|
"rightStickHorizontal": "rightStickX", // Axis binding reserved for alternate right-stick mappings. Values: leftStickX | leftStickY | rightStickX | rightStickY
|
||||||
|
"rightStickVertical": "rightStickY" // Axis binding used for popup page jumps. Values: leftStickX | leftStickY | rightStickX | rightStickY
|
||||||
|
} // Bindings setting.
|
||||||
|
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Startup Warmups
|
// Startup Warmups
|
||||||
// Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
|
// Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ The configuration file includes several main sections:
|
|||||||
|
|
||||||
- [**Keybindings**](#keybindings) - MPV command shortcuts
|
- [**Keybindings**](#keybindings) - MPV command shortcuts
|
||||||
- [**Shortcuts Configuration**](#shortcuts-configuration) - Overlay keyboard shortcuts
|
- [**Shortcuts Configuration**](#shortcuts-configuration) - Overlay keyboard shortcuts
|
||||||
|
- [**Controller Support**](#controller-support) - Gamepad support for keyboard-only mode
|
||||||
- [**Manual Card Update Shortcuts**](#manual-card-update-shortcuts) - Shortcuts for manual Anki card workflows
|
- [**Manual Card Update Shortcuts**](#manual-card-update-shortcuts) - Shortcuts for manual Anki card workflows
|
||||||
- [**Session Help Modal**](#session-help-modal) - In-overlay shortcut reference
|
- [**Session Help Modal**](#session-help-modal) - In-overlay shortcut reference
|
||||||
- [**Runtime Option Palette**](#runtime-option-palette) - Live, session-only option toggles
|
- [**Runtime Option Palette**](#runtime-option-palette) - Live, session-only option toggles
|
||||||
@@ -504,6 +505,88 @@ Set any shortcut to `null` to disable it.
|
|||||||
|
|
||||||
Feature-dependent shortcuts/keybindings only run when their related integration is enabled. For example, Anki/Kiku shortcuts require `ankiConnect.enabled` (and Kiku-specific behavior where applicable), and Jellyfin remote startup behavior requires Jellyfin to be enabled.
|
Feature-dependent shortcuts/keybindings only run when their related integration is enabled. For example, Anki/Kiku shortcuts require `ankiConnect.enabled` (and Kiku-specific behavior where applicable), and Jellyfin remote startup behavior requires Jellyfin to be enabled.
|
||||||
|
|
||||||
|
### Controller Support
|
||||||
|
|
||||||
|
SubMiner can read controllers through the Chrome Gamepad API and map them onto the existing keyboard-only overlay workflow.
|
||||||
|
|
||||||
|
Important behavior:
|
||||||
|
|
||||||
|
- Controller input is only active while keyboard-only mode is enabled.
|
||||||
|
- Keyboard-only mode continues to work normally without a controller.
|
||||||
|
- By default SubMiner uses the first connected controller.
|
||||||
|
- `Alt+C` opens the controller selection modal and saves the selected controller for future launches.
|
||||||
|
- `Alt+Shift+C` opens a live debug modal showing raw axes/button values plus a ready-to-copy `buttonIndices` config block.
|
||||||
|
- Turning keyboard-only mode off clears the keyboard-only token highlight state.
|
||||||
|
- Closing the Yomitan popup clears the temporary native text-selection fill, but keeps controller token selection active.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"controller": {
|
||||||
|
"enabled": true,
|
||||||
|
"preferredGamepadId": "",
|
||||||
|
"preferredGamepadLabel": "",
|
||||||
|
"smoothScroll": true,
|
||||||
|
"scrollPixelsPerSecond": 900,
|
||||||
|
"horizontalJumpPixels": 160,
|
||||||
|
"stickDeadzone": 0.2,
|
||||||
|
"triggerInputMode": "auto",
|
||||||
|
"triggerDeadzone": 0.5,
|
||||||
|
"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": "buttonSouth",
|
||||||
|
"closeLookup": "buttonEast",
|
||||||
|
"toggleKeyboardOnlyMode": "buttonNorth",
|
||||||
|
"mineCard": "buttonWest",
|
||||||
|
"quitMpv": "select",
|
||||||
|
"previousAudio": "none",
|
||||||
|
"nextAudio": "rightShoulder",
|
||||||
|
"playCurrentAudio": "leftShoulder",
|
||||||
|
"toggleMpvPause": "leftStickPress",
|
||||||
|
"leftStickHorizontal": "leftStickX",
|
||||||
|
"leftStickVertical": "leftStickY",
|
||||||
|
"rightStickHorizontal": "rightStickX",
|
||||||
|
"rightStickVertical": "rightStickY"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Default logical mapping:
|
||||||
|
|
||||||
|
- Left stick up/down: scroll Yomitan popup
|
||||||
|
- Left stick left/right: move subtitle token selection
|
||||||
|
- Right stick up/down: page-jump through Yomitan popup
|
||||||
|
- Right stick left/right: unused by default
|
||||||
|
- `A`: toggle lookup
|
||||||
|
- `B`: close lookup
|
||||||
|
- `Y`: toggle keyboard-only mode
|
||||||
|
- `X`: mine card
|
||||||
|
- `Minus` / `Select`: quit mpv
|
||||||
|
- `L1`: play current Yomitan audio (falls back to the first available track)
|
||||||
|
- `R1`: move to the next available Yomitan audio track
|
||||||
|
- `L3`: toggle mpv pause
|
||||||
|
- `L2` / `R2`: unbound by default
|
||||||
|
|
||||||
|
If you choose to bind `L2` or `R2` manually, set `triggerInputMode` to `analog` and tune `triggerDeadzone` when your controller reports triggers as analog values instead of digital pressed/not-pressed buttons. `auto` accepts either style and remains the default.
|
||||||
|
|
||||||
|
If your controller reports non-standard raw button numbers, override `controller.buttonIndices` using values from the `Alt+Shift+C` debug modal.
|
||||||
|
|
||||||
|
Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing, and `buttonIndices` to match your controller. See [config.example.jsonc](/config.example.jsonc) for the full generated comments for every controller field.
|
||||||
|
|
||||||
### Manual Card Update Shortcuts
|
### Manual Card Update Shortcuts
|
||||||
|
|
||||||
When `behavior.autoUpdateNewCards` is set to `false`, new cards are detected but not automatically updated. Use these keyboard shortcuts for manual control:
|
When `behavior.autoUpdateNewCards` is set to `false`, new cards are detected but not automatically updated. Use these keyboard shortcuts for manual control:
|
||||||
|
|||||||
@@ -59,6 +59,22 @@ Jimaku search, field-grouping, runtime options, and manual subsync open as modal
|
|||||||
3. Yomitan detects the selection and opens its lookup popup.
|
3. Yomitan detects the selection and opens its lookup popup.
|
||||||
4. From the popup, add the word to Anki.
|
4. From the popup, add the word to Anki.
|
||||||
|
|
||||||
|
### Controller Workflow
|
||||||
|
|
||||||
|
With a gamepad connected and keyboard-only mode enabled, the full mining loop works without a mouse or keyboard:
|
||||||
|
|
||||||
|
1. **Navigate** — push the left stick left/right to move the token highlight across subtitle words.
|
||||||
|
2. **Look up** — press `A` to trigger Yomitan lookup on the highlighted word.
|
||||||
|
3. **Browse the popup** — push the left stick up/down to smooth-scroll through the Yomitan popup, or use the right stick for larger jumps.
|
||||||
|
4. **Cycle audio** — press `R1` to move to the next dictionary audio entry, `L1` to play the current one.
|
||||||
|
5. **Mine** — press `X` to create an Anki card for the current sentence (same as `Ctrl+S`).
|
||||||
|
6. **Close** — press `B` to dismiss the Yomitan popup and return to subtitle navigation.
|
||||||
|
7. **Pause/resume** — press `L3` (left stick click) to toggle mpv pause at any time.
|
||||||
|
|
||||||
|
The controller and keyboard can be used interchangeably — switching mid-session is seamless. Toggle keyboard-only mode on or off with `Y` on the controller.
|
||||||
|
|
||||||
|
See [Usage — Controller Support](/usage#controller-support) for setup details and [Configuration — Controller Support](/configuration#controller-support) for the full mapping and tuning options.
|
||||||
|
|
||||||
## Creating Anki Cards
|
## Creating Anki Cards
|
||||||
|
|
||||||
There are three ways to create cards, depending on your workflow.
|
There are three ways to create cards, depending on your workflow.
|
||||||
|
|||||||
@@ -50,6 +50,55 @@
|
|||||||
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
|
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
|
||||||
}, // Controls logging verbosity.
|
}, // Controls logging verbosity.
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Controller Support
|
||||||
|
// Gamepad support for the visible overlay while keyboard-only mode is active.
|
||||||
|
// Use the selection modal to save a preferred controller by id for future launches.
|
||||||
|
// Trigger input mode can be auto, digital-only, or analog-thresholded depending on the controller.
|
||||||
|
// Override controller.buttonIndices when your pad reports non-standard raw button numbers.
|
||||||
|
// ==========================================
|
||||||
|
"controller": {
|
||||||
|
"enabled": true, // Enable overlay controller support through the Chrome Gamepad API. Values: true | false
|
||||||
|
"preferredGamepadId": "", // Preferred controller id saved from the controller selection modal.
|
||||||
|
"preferredGamepadLabel": "", // Preferred controller display label saved for diagnostics.
|
||||||
|
"smoothScroll": true, // Use smooth scrolling for controller-driven popup scroll input. Values: true | false
|
||||||
|
"scrollPixelsPerSecond": 900, // Base popup scroll speed for controller stick input.
|
||||||
|
"horizontalJumpPixels": 160, // Popup page-jump distance for controller jump input.
|
||||||
|
"stickDeadzone": 0.2, // Deadzone applied to controller stick axes.
|
||||||
|
"triggerInputMode": "auto", // How controller triggers are interpreted: auto, pressed-only, or thresholded analog. Values: auto | digital | analog
|
||||||
|
"triggerDeadzone": 0.5, // Minimum analog trigger value required when trigger input uses auto or analog mode.
|
||||||
|
"repeatDelayMs": 320, // Delay before repeating held controller actions.
|
||||||
|
"repeatIntervalMs": 120, // Repeat interval for held controller actions.
|
||||||
|
"buttonIndices": {
|
||||||
|
"select": 6, // Raw button index used for the controller select/minus/back button.
|
||||||
|
"buttonSouth": 0, // Raw button index used for controller south/A button input.
|
||||||
|
"buttonEast": 1, // Raw button index used for controller east/B button input.
|
||||||
|
"buttonWest": 2, // Raw button index used for controller west/X button input.
|
||||||
|
"buttonNorth": 3, // Raw button index used for controller north/Y button input.
|
||||||
|
"leftShoulder": 4, // Raw button index used for controller left shoulder input.
|
||||||
|
"rightShoulder": 5, // Raw button index used for controller right shoulder input.
|
||||||
|
"leftStickPress": 9, // Raw button index used for controller L3 input.
|
||||||
|
"rightStickPress": 10, // Raw button index used for controller R3 input.
|
||||||
|
"leftTrigger": 6, // Raw button index used for controller L2 input.
|
||||||
|
"rightTrigger": 7 // Raw button index used for controller R2 input.
|
||||||
|
}, // Button indices setting.
|
||||||
|
"bindings": {
|
||||||
|
"toggleLookup": "buttonSouth", // Controller binding for toggling lookup. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||||
|
"closeLookup": "buttonEast", // Controller binding for closing lookup. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||||
|
"toggleKeyboardOnlyMode": "buttonNorth", // Controller binding for toggling keyboard-only mode. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||||
|
"mineCard": "buttonWest", // Controller binding for mining the active card. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||||
|
"quitMpv": "select", // Controller binding for quitting mpv. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||||
|
"previousAudio": "none", // Controller binding for previous Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||||
|
"nextAudio": "rightShoulder", // Controller binding for next Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||||
|
"playCurrentAudio": "leftShoulder", // Controller binding for playing the current Yomitan audio. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||||
|
"toggleMpvPause": "leftStickPress", // Controller binding for toggling mpv play/pause. Values: none | select | buttonSouth | buttonEast | buttonNorth | buttonWest | leftShoulder | rightShoulder | leftStickPress | rightStickPress | leftTrigger | rightTrigger
|
||||||
|
"leftStickHorizontal": "leftStickX", // Axis binding used for left/right token selection. Values: leftStickX | leftStickY | rightStickX | rightStickY
|
||||||
|
"leftStickVertical": "leftStickY", // Axis binding used for primary popup scrolling. Values: leftStickX | leftStickY | rightStickX | rightStickY
|
||||||
|
"rightStickHorizontal": "rightStickX", // Axis binding reserved for alternate right-stick mappings. Values: leftStickX | leftStickY | rightStickX | rightStickY
|
||||||
|
"rightStickVertical": "rightStickY" // Axis binding used for popup page jumps. Values: leftStickX | leftStickY | rightStickX | rightStickY
|
||||||
|
} // Bindings setting.
|
||||||
|
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Startup Warmups
|
// Startup Warmups
|
||||||
// Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
|
// Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
|
||||||
|
|||||||
@@ -69,6 +69,17 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
|
|||||||
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
||||||
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
||||||
|
|
||||||
|
## Controller Shortcuts
|
||||||
|
|
||||||
|
These overlay-local shortcuts are fixed and open controller utilities for the Chrome Gamepad API integration.
|
||||||
|
|
||||||
|
| Shortcut | Action | Configurable |
|
||||||
|
| ------------- | ------------------------------ | ------------ |
|
||||||
|
| `Alt+C` | Open controller selection modal | Fixed |
|
||||||
|
| `Alt+Shift+C` | Open controller debug modal | Fixed |
|
||||||
|
|
||||||
|
Controller input only drives the overlay while keyboard-only mode is enabled. The controller mapping and tuning live under the top-level `controller` config block; keyboard-only mode still works normally without a controller.
|
||||||
|
|
||||||
## MPV Plugin Chords
|
## MPV Plugin Chords
|
||||||
|
|
||||||
When the mpv plugin is installed, all commands use a `y` chord prefix — press `y`, then the second key within 1 second.
|
When the mpv plugin is installed, all commands use a `y` chord prefix — press `y`, then the second key within 1 second.
|
||||||
|
|||||||
@@ -246,6 +246,45 @@ Notes:
|
|||||||
- `--whisper-threads`
|
- `--whisper-threads`
|
||||||
- `--yt-subgen-audio-format`
|
- `--yt-subgen-audio-format`
|
||||||
|
|
||||||
|
## Controller Support
|
||||||
|
|
||||||
|
SubMiner supports gamepad/controller input for couch-friendly usage via the Chrome Gamepad API. Controller input drives the overlay while keyboard-only mode is enabled.
|
||||||
|
|
||||||
|
### Getting Started
|
||||||
|
|
||||||
|
1. Connect a controller before or after launching SubMiner.
|
||||||
|
2. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding.
|
||||||
|
3. Use the left stick to navigate subtitle tokens and the right stick to scroll the Yomitan popup.
|
||||||
|
4. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup.
|
||||||
|
|
||||||
|
By default SubMiner uses the first connected controller. Press `Alt+C` in the overlay to open the controller selection modal and persist your preferred controller across sessions. Press `Alt+Shift+C` to open a live debug modal showing raw axes and button values.
|
||||||
|
|
||||||
|
### Default Button Mapping
|
||||||
|
|
||||||
|
| Button | Action |
|
||||||
|
| ------ | ------ |
|
||||||
|
| `A` (South) | Toggle lookup |
|
||||||
|
| `B` (East) | Close lookup |
|
||||||
|
| `Y` (North) | Toggle keyboard-only mode |
|
||||||
|
| `X` (West) | Mine card |
|
||||||
|
| `L1` | Play current Yomitan audio |
|
||||||
|
| `R1` | Next Yomitan audio track |
|
||||||
|
| `L3` (left stick press) | Toggle mpv pause |
|
||||||
|
| `Select` / `Minus` | Quit mpv |
|
||||||
|
| `L2` / `R2` | Unbound (available for custom bindings) |
|
||||||
|
|
||||||
|
### Analog Controls
|
||||||
|
|
||||||
|
| Input | Action |
|
||||||
|
| ----- | ------ |
|
||||||
|
| Left stick horizontal | Move token selection left/right |
|
||||||
|
| Left stick vertical | Smooth scroll Yomitan popup |
|
||||||
|
| Right stick horizontal | Jump inside popup (horizontal) |
|
||||||
|
| Right stick vertical | Smooth scroll popup (vertical) |
|
||||||
|
| D-pad | Fallback for stick navigation |
|
||||||
|
|
||||||
|
All button and axis mappings are configurable under the `controller` config block. See [Configuration — Controller Support](/configuration#controller-support) for the full options.
|
||||||
|
|
||||||
## Keybindings
|
## Keybindings
|
||||||
|
|
||||||
See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining shortcuts, overlay controls, and customization.
|
See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining shortcuts, overlay controls, and customization.
|
||||||
|
|||||||
110
docs/plans/2026-03-11-overlay-controller-support-design.md
Normal file
110
docs/plans/2026-03-11-overlay-controller-support-design.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# Overlay Controller Support Design
|
||||||
|
|
||||||
|
**Date:** 2026-03-11
|
||||||
|
**Backlog:** `TASK-159`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add controller support to the visible overlay through the Chrome Gamepad API without replacing the existing keyboard-only workflow. Controller input should only supplement keyboard-only mode, preserve existing behavior, and expose controller selection plus raw-input debugging in overlay-local modals.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Poll connected gamepads from the visible overlay renderer.
|
||||||
|
- Default to the first connected controller unless config specifies a preferred controller.
|
||||||
|
- Add logical controller bindings and tuning knobs to config.
|
||||||
|
- Add `Alt+C` controller selection modal.
|
||||||
|
- Add `Alt+Shift+C` controller debug modal.
|
||||||
|
- Map controller actions onto existing keyboard-only/Yomitan behaviors.
|
||||||
|
- Fix stale selected-token highlight cleanup when keyboard-only mode turns off or popup closes.
|
||||||
|
|
||||||
|
Out of scope for this pass:
|
||||||
|
|
||||||
|
- Raw arbitrary axis/button index remapping in config.
|
||||||
|
- Controller support outside the visible overlay renderer.
|
||||||
|
- Haptics or vibration.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Use a renderer-local controller runtime. The overlay already owns keyboard-only token selection, Yomitan popup integration, and modal UX, and the Gamepad API is browser-native. A renderer module can poll `navigator.getGamepads()` on animation frames, normalize sticks/buttons into logical actions, and call the same helpers used by keyboard-only mode.
|
||||||
|
|
||||||
|
Avoid synthetic keyboard events as the primary implementation. Analog sticks need deadzones, continuous smooth scrolling, and per-action repeat behavior that do not fit cleanly into key event emulation. Direct logical actions keep tests clear and make the debug modal show the exact values the runtime uses.
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
Controller actions are active only while keyboard-only mode is enabled, except the controller action that toggles keyboard-only mode can always fire so the user can enter the mode from the controller.
|
||||||
|
|
||||||
|
Default logical mappings:
|
||||||
|
|
||||||
|
- left stick vertical: smooth Yomitan popup/window scroll when popup is open
|
||||||
|
- left stick horizontal: move token selection left/right
|
||||||
|
- right stick vertical: smooth Yomitan popup/window scroll
|
||||||
|
- right stick horizontal: jump horizontally inside Yomitan popup/window
|
||||||
|
- `A`: toggle lookup
|
||||||
|
- `B`: close lookup
|
||||||
|
- `Y`: toggle keyboard-only mode
|
||||||
|
- `X`: mine card
|
||||||
|
- `L1` / `R1`: previous / next Yomitan audio
|
||||||
|
- `R2`: activate current Yomitan audio button
|
||||||
|
- `L2`: toggle mpv play/pause
|
||||||
|
|
||||||
|
Selection-highlight cleanup:
|
||||||
|
|
||||||
|
- disabling keyboard-only mode clears the selected token class immediately
|
||||||
|
- closing the Yomitan popup also clears the selected token class if keyboard-only mode is no longer active
|
||||||
|
- helper ownership should live in the shared keyboard-only selection sync path so keyboard and controller exits stay consistent
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
Add a top-level `controller` block in resolved config with:
|
||||||
|
|
||||||
|
- `enabled`
|
||||||
|
- `preferredGamepadId`
|
||||||
|
- `preferredGamepadLabel`
|
||||||
|
- `smoothScroll`
|
||||||
|
- `scrollPixelsPerSecond`
|
||||||
|
- `horizontalJumpPixels`
|
||||||
|
- `stickDeadzone`
|
||||||
|
- `triggerDeadzone`
|
||||||
|
- `repeatDelayMs`
|
||||||
|
- `repeatIntervalMs`
|
||||||
|
- `bindings` logical fields for the named actions/sticks
|
||||||
|
|
||||||
|
Persist the preferred controller by stable browser-exposed `id` when possible, with label stored as a diagnostic/display fallback.
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
Controller selection modal:
|
||||||
|
|
||||||
|
- overlay-hosted modal in the visible renderer
|
||||||
|
- lists currently connected controllers
|
||||||
|
- highlights current active choice
|
||||||
|
- selecting one persists config and makes it the active controller immediately if connected
|
||||||
|
|
||||||
|
Controller debug modal:
|
||||||
|
|
||||||
|
- overlay-hosted modal
|
||||||
|
- shows selected controller and all connected controllers
|
||||||
|
- live raw axis array values
|
||||||
|
- live raw button values, pressed flags, and touched flags if available
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Test first:
|
||||||
|
|
||||||
|
- controller gating outside keyboard-only mode
|
||||||
|
- logical mapping to existing helpers
|
||||||
|
- continuous stick scroll and repeat behavior
|
||||||
|
- modal open shortcuts
|
||||||
|
- preferred-controller selection persistence
|
||||||
|
- highlight cleanup on keyboard-only disable and popup close
|
||||||
|
- config defaults/parse/template generation coverage
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Browser gamepad identity strings can differ across OS/browser/runtime versions.
|
||||||
|
Mitigation: match by exact preferred id first; fall back to first connected controller.
|
||||||
|
- Continuous stick input can spam actions.
|
||||||
|
Mitigation: deadzones plus repeat throttling and frame-time-based smooth scroll.
|
||||||
|
- Popup DOM/audio controls may vary.
|
||||||
|
Mitigation: target stable Yomitan popup/document selectors and cover with focused renderer tests.
|
||||||
|
|
||||||
245
docs/plans/2026-03-11-overlay-controller-support.md
Normal file
245
docs/plans/2026-03-11-overlay-controller-support.md
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
# Overlay Controller Support Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Add Chrome Gamepad API controller support to the visible overlay as a supplement to keyboard-only mode, including controller selection/debug modals, config-backed logical bindings, and selected-token highlight cleanup.
|
||||||
|
|
||||||
|
**Architecture:** Keep controller support in the visible overlay renderer. Poll and normalize gamepad state in a dedicated runtime, route logical actions into the existing keyboard-only/Yomitan helpers, and persist preferred-controller config through the existing config pipeline and preload bridge.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, Bun tests, Electron preload IPC, renderer DOM modals, Chrome Gamepad API
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Track work and lock the design
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `backlog/tasks/task-159 - Add-overlay-controller-support-for-keyboard-only-mode.md`
|
||||||
|
- Create: `docs/plans/2026-03-11-overlay-controller-support-design.md`
|
||||||
|
- Create: `docs/plans/2026-03-11-overlay-controller-support.md`
|
||||||
|
|
||||||
|
**Step 1: Record the approved scope**
|
||||||
|
|
||||||
|
Capture controller-only-in-keyboard-mode behavior, the modal shortcuts, config scope, and the stale selection-highlight cleanup requirement.
|
||||||
|
|
||||||
|
**Step 2: Verify the written scope matches the approved design**
|
||||||
|
|
||||||
|
Run: `sed -n '1,220p' backlog/tasks/task-159\\ -\\ Add-overlay-controller-support-for-keyboard-only-mode.md && sed -n '1,240p' docs/plans/2026-03-11-overlay-controller-support-design.md`
|
||||||
|
|
||||||
|
Expected: task and design doc both mention controller selection/debug modals and highlight cleanup.
|
||||||
|
|
||||||
|
### Task 2: Add failing config tests and defaults
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/config/config.test.ts`
|
||||||
|
- Modify: `src/config/definitions/defaults-core.ts`
|
||||||
|
- Modify: `src/config/definitions/options-core.ts`
|
||||||
|
- Modify: `src/config/definitions/template-sections.ts`
|
||||||
|
- Modify: `src/types.ts`
|
||||||
|
- Modify: `config.example.jsonc`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add coverage asserting a new `controller` config block resolves with the expected defaults and accepts logical-field overrides.
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `bun test src/config/config.test.ts`
|
||||||
|
|
||||||
|
Expected: FAIL because `controller` config is not defined yet.
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Add the controller config types/defaults/registry/template wiring and regenerate the example config if needed.
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `bun test src/config/config.test.ts`
|
||||||
|
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
### Task 3: Add failing keyboard-selection cleanup tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/renderer/handlers/keyboard.test.ts`
|
||||||
|
- Modify: `src/renderer/handlers/keyboard.ts`
|
||||||
|
- Modify: `src/renderer/state.ts`
|
||||||
|
|
||||||
|
**Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Add tests for:
|
||||||
|
|
||||||
|
- turning keyboard-only mode off clears `.keyboard-selected`
|
||||||
|
- closing the popup clears stale selection highlight when keyboard-only mode is off
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `bun test src/renderer/handlers/keyboard.test.ts`
|
||||||
|
|
||||||
|
Expected: FAIL because selection cleanup is incomplete today.
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Centralize selection clearing in the keyboard-only sync helpers and popup-close flow.
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `bun test src/renderer/handlers/keyboard.test.ts`
|
||||||
|
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
### Task 4: Add failing controller runtime tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/renderer/handlers/gamepad-controller.test.ts`
|
||||||
|
- Create: `src/renderer/handlers/gamepad-controller.ts`
|
||||||
|
- Modify: `src/renderer/context.ts`
|
||||||
|
- Modify: `src/renderer/state.ts`
|
||||||
|
- Modify: `src/renderer/renderer.ts`
|
||||||
|
|
||||||
|
**Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Cover:
|
||||||
|
|
||||||
|
- first connected controller is selected by default
|
||||||
|
- preferred controller wins when connected
|
||||||
|
- controller actions are ignored unless keyboard-only mode is enabled, except keyboard-only toggle
|
||||||
|
- stick/button mappings invoke the expected logical helpers
|
||||||
|
- smooth scroll and repeat throttling behavior
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `bun test src/renderer/handlers/gamepad-controller.test.ts`
|
||||||
|
|
||||||
|
Expected: FAIL because controller runtime does not exist.
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Add a renderer-local polling runtime with deadzone handling, action edge detection, repeat timing, and helper callbacks into the keyboard/Yomitan flow.
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `bun test src/renderer/handlers/gamepad-controller.test.ts`
|
||||||
|
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
### Task 5: Add failing controller modal tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/renderer/index.html`
|
||||||
|
- Modify: `src/renderer/style.css`
|
||||||
|
- Create: `src/renderer/modals/controller-select.ts`
|
||||||
|
- Create: `src/renderer/modals/controller-select.test.ts`
|
||||||
|
- Create: `src/renderer/modals/controller-debug.ts`
|
||||||
|
- Create: `src/renderer/modals/controller-debug.test.ts`
|
||||||
|
- Modify: `src/renderer/renderer.ts`
|
||||||
|
- Modify: `src/renderer/context.ts`
|
||||||
|
- Modify: `src/renderer/state.ts`
|
||||||
|
|
||||||
|
**Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Add tests for:
|
||||||
|
|
||||||
|
- `Alt+C` opens controller selection modal
|
||||||
|
- `Alt+Shift+C` opens controller debug modal
|
||||||
|
- selection modal renders connected controllers and persists the chosen device
|
||||||
|
- debug modal shows live axes/buttons state
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `bun test src/renderer/modals/controller-select.test.ts src/renderer/modals/controller-debug.test.ts`
|
||||||
|
|
||||||
|
Expected: FAIL because modals and shortcuts do not exist.
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Add modal DOM, renderer modules, modal state wiring, and controller runtime integration.
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `bun test src/renderer/modals/controller-select.test.ts src/renderer/modals/controller-debug.test.ts`
|
||||||
|
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
### Task 6: Persist controller preference through preload/main wiring
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/preload.ts`
|
||||||
|
- Modify: `src/types.ts`
|
||||||
|
- Modify: `src/shared/ipc/contracts.ts`
|
||||||
|
- Modify: `src/core/services/ipc.ts`
|
||||||
|
- Modify: `src/main.ts`
|
||||||
|
- Modify: related main/runtime tests as needed
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add coverage for reading current controller config and saving preferred-controller changes from the renderer.
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `bun test src/core/services/ipc.test.ts`
|
||||||
|
|
||||||
|
Expected: FAIL because no controller preference IPC exists yet.
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Expose renderer-safe getters/setters for the controller config fields needed by the selection modal/runtime.
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `bun test src/core/services/ipc.test.ts`
|
||||||
|
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
### Task 7: Update docs and config example
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `config.example.jsonc`
|
||||||
|
- Modify: `README.md`
|
||||||
|
- Modify: relevant docs under `docs-site/` for shortcuts/usage/troubleshooting if touched by current docs structure
|
||||||
|
|
||||||
|
**Step 1: Write the failing doc/config check if needed**
|
||||||
|
|
||||||
|
If config example generation is covered by tests, add/refresh the failing assertion first.
|
||||||
|
|
||||||
|
**Step 2: Implement the docs**
|
||||||
|
|
||||||
|
Document controller behavior, modal shortcuts, config block, and the keyboard-only-only activation rule.
|
||||||
|
|
||||||
|
**Step 3: Run doc/config verification**
|
||||||
|
|
||||||
|
Run: `bun run test:config`
|
||||||
|
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
### Task 8: Run the handoff gate and update the backlog task
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backlog/tasks/task-159 - Add-overlay-controller-support-for-keyboard-only-mode.md`
|
||||||
|
|
||||||
|
**Step 1: Run targeted verification**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
- `bun test src/config/config.test.ts`
|
||||||
|
- `bun test src/renderer/handlers/keyboard.test.ts`
|
||||||
|
- `bun test src/renderer/handlers/gamepad-controller.test.ts`
|
||||||
|
- `bun test src/renderer/modals/controller-select.test.ts`
|
||||||
|
- `bun test src/renderer/modals/controller-debug.test.ts`
|
||||||
|
- `bun test src/core/services/ipc.test.ts`
|
||||||
|
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 2: Run broader gate**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
- `bun run typecheck`
|
||||||
|
- `bun run test:fast`
|
||||||
|
- `bun run test:env`
|
||||||
|
- `bun run build`
|
||||||
|
|
||||||
|
Expected: PASS, or document exact blockers/failures.
|
||||||
|
|
||||||
|
**Step 3: Update backlog notes**
|
||||||
|
|
||||||
|
Fill in implementation notes, verification commands, and final summary in `TASK-159`.
|
||||||
131
scripts/subminer-change-verification.test.ts
Normal file
131
scripts/subminer-change-verification.test.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const repoRoot = process.cwd();
|
||||||
|
const classifyScript = path.join(
|
||||||
|
repoRoot,
|
||||||
|
'.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh',
|
||||||
|
);
|
||||||
|
const verifyScript = path.join(
|
||||||
|
repoRoot,
|
||||||
|
'.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh',
|
||||||
|
);
|
||||||
|
|
||||||
|
function withTempDir<T>(fn: (dir: string) => T): T {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-change-verification-test-'));
|
||||||
|
try {
|
||||||
|
return fn(dir);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runBash(args: string[]) {
|
||||||
|
return spawnSync('bash', args, {
|
||||||
|
cwd: repoRoot,
|
||||||
|
env: process.env,
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArtifactDir(stdout: string): string {
|
||||||
|
const match = stdout.match(/^artifact_dir=(.+)$/m);
|
||||||
|
assert.ok(match, `expected artifact_dir in stdout, got:\n${stdout}`);
|
||||||
|
return match[1] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSummaryJson(artifactDir: string) {
|
||||||
|
return JSON.parse(fs.readFileSync(path.join(artifactDir, 'summary.json'), 'utf8')) as {
|
||||||
|
sessionId: string;
|
||||||
|
status: string;
|
||||||
|
selectedLanes: string[];
|
||||||
|
blockers?: string[];
|
||||||
|
artifactDir: string;
|
||||||
|
pathSelectionMode?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('classifier marks launcher and plugin paths as real-runtime candidates', () => {
|
||||||
|
const result = runBash([classifyScript, 'launcher/mpv.ts', 'plugin/subminer/process.lua']);
|
||||||
|
|
||||||
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||||
|
assert.match(result.stdout, /^lane:launcher-plugin$/m);
|
||||||
|
assert.match(result.stdout, /^flag:real-runtime-candidate$/m);
|
||||||
|
assert.doesNotMatch(result.stdout, /real-gui-candidate/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifier blocks requested real-runtime lane when runtime execution is not allowed', () => {
|
||||||
|
withTempDir((root) => {
|
||||||
|
const artifactDir = path.join(root, 'artifacts');
|
||||||
|
const result = runBash([
|
||||||
|
verifyScript,
|
||||||
|
'--dry-run',
|
||||||
|
'--artifact-dir',
|
||||||
|
artifactDir,
|
||||||
|
'--lane',
|
||||||
|
'real-runtime',
|
||||||
|
'launcher/mpv.ts',
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.notEqual(result.status, 0, result.stdout);
|
||||||
|
assert.match(result.stdout, /^result=blocked$/m);
|
||||||
|
|
||||||
|
const summary = readSummaryJson(artifactDir);
|
||||||
|
assert.equal(summary.status, 'blocked');
|
||||||
|
assert.deepEqual(summary.selectedLanes, ['real-runtime']);
|
||||||
|
assert.ok(summary.sessionId.length > 0);
|
||||||
|
assert.ok(summary.blockers?.some((entry) => entry.includes('--allow-real-runtime')));
|
||||||
|
assert.equal(fs.existsSync(path.join(artifactDir, 'reports', 'summary.json')), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifier fails closed for unknown lanes', () => {
|
||||||
|
withTempDir((root) => {
|
||||||
|
const artifactDir = path.join(root, 'artifacts');
|
||||||
|
const result = runBash([
|
||||||
|
verifyScript,
|
||||||
|
'--dry-run',
|
||||||
|
'--artifact-dir',
|
||||||
|
artifactDir,
|
||||||
|
'--lane',
|
||||||
|
'not-a-lane',
|
||||||
|
'src/main.ts',
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.notEqual(result.status, 0, result.stdout);
|
||||||
|
assert.match(result.stdout, /^result=failed$/m);
|
||||||
|
|
||||||
|
const summary = readSummaryJson(artifactDir);
|
||||||
|
assert.equal(summary.status, 'failed');
|
||||||
|
assert.deepEqual(summary.selectedLanes, ['not-a-lane']);
|
||||||
|
assert.ok(summary.blockers?.some((entry) => entry.includes('unknown lane')));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifier allocates unique session ids and artifact roots by default', () => {
|
||||||
|
const first = runBash([verifyScript, '--dry-run', '--lane', 'core', 'src/main.ts']);
|
||||||
|
const second = runBash([verifyScript, '--dry-run', '--lane', 'core', 'src/main.ts']);
|
||||||
|
|
||||||
|
assert.equal(first.status, 0, first.stderr || first.stdout);
|
||||||
|
assert.equal(second.status, 0, second.stderr || second.stdout);
|
||||||
|
|
||||||
|
const firstArtifactDir = parseArtifactDir(first.stdout);
|
||||||
|
const secondArtifactDir = parseArtifactDir(second.stdout);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const firstSummary = readSummaryJson(firstArtifactDir);
|
||||||
|
const secondSummary = readSummaryJson(secondArtifactDir);
|
||||||
|
|
||||||
|
assert.notEqual(firstSummary.sessionId, secondSummary.sessionId);
|
||||||
|
assert.notEqual(firstSummary.artifactDir, secondSummary.artifactDir);
|
||||||
|
assert.equal(firstSummary.pathSelectionMode, 'explicit');
|
||||||
|
assert.equal(secondSummary.pathSelectionMode, 'explicit');
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(firstArtifactDir, { recursive: true, force: true });
|
||||||
|
fs.rmSync(secondArtifactDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1107,6 +1107,135 @@ test('parses global shortcuts and startup settings', () => {
|
|||||||
assert.equal(config.youtubeSubgen.fixWithAi, true);
|
assert.equal(config.youtubeSubgen.fixWithAi, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parses controller settings with logical bindings and tuning knobs', () => {
|
||||||
|
const dir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"controller": {
|
||||||
|
"enabled": true,
|
||||||
|
"preferredGamepadId": "Xbox Wireless Controller (STANDARD GAMEPAD Vendor: 045e Product: 0b13)",
|
||||||
|
"preferredGamepadLabel": "Xbox Wireless Controller",
|
||||||
|
"smoothScroll": false,
|
||||||
|
"scrollPixelsPerSecond": 1440,
|
||||||
|
"horizontalJumpPixels": 180,
|
||||||
|
"stickDeadzone": 0.3,
|
||||||
|
"triggerInputMode": "analog",
|
||||||
|
"triggerDeadzone": 0.4,
|
||||||
|
"repeatDelayMs": 220,
|
||||||
|
"repeatIntervalMs": 70,
|
||||||
|
"buttonIndices": {
|
||||||
|
"select": 6,
|
||||||
|
"leftStickPress": 9,
|
||||||
|
"rightStickPress": 10
|
||||||
|
},
|
||||||
|
"bindings": {
|
||||||
|
"toggleLookup": "buttonWest",
|
||||||
|
"closeLookup": "buttonEast",
|
||||||
|
"toggleKeyboardOnlyMode": "buttonNorth",
|
||||||
|
"mineCard": "buttonSouth",
|
||||||
|
"quitMpv": "select",
|
||||||
|
"previousAudio": "leftShoulder",
|
||||||
|
"nextAudio": "rightShoulder",
|
||||||
|
"playCurrentAudio": "none",
|
||||||
|
"toggleMpvPause": "leftStickPress",
|
||||||
|
"leftStickHorizontal": "rightStickX",
|
||||||
|
"leftStickVertical": "rightStickY",
|
||||||
|
"rightStickHorizontal": "leftStickX",
|
||||||
|
"rightStickVertical": "leftStickY"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new ConfigService(dir);
|
||||||
|
const config = service.getConfig();
|
||||||
|
|
||||||
|
assert.equal(config.controller.enabled, true);
|
||||||
|
assert.equal(
|
||||||
|
config.controller.preferredGamepadId,
|
||||||
|
'Xbox Wireless Controller (STANDARD GAMEPAD Vendor: 045e Product: 0b13)',
|
||||||
|
);
|
||||||
|
assert.equal(config.controller.preferredGamepadLabel, 'Xbox Wireless Controller');
|
||||||
|
assert.equal(config.controller.smoothScroll, false);
|
||||||
|
assert.equal(config.controller.scrollPixelsPerSecond, 1440);
|
||||||
|
assert.equal(config.controller.horizontalJumpPixels, 180);
|
||||||
|
assert.equal(config.controller.stickDeadzone, 0.3);
|
||||||
|
assert.equal(config.controller.triggerInputMode, 'analog');
|
||||||
|
assert.equal(config.controller.triggerDeadzone, 0.4);
|
||||||
|
assert.equal(config.controller.repeatDelayMs, 220);
|
||||||
|
assert.equal(config.controller.repeatIntervalMs, 70);
|
||||||
|
assert.equal(config.controller.buttonIndices.select, 6);
|
||||||
|
assert.equal(config.controller.buttonIndices.leftStickPress, 9);
|
||||||
|
assert.equal(config.controller.bindings.toggleLookup, 'buttonWest');
|
||||||
|
assert.equal(config.controller.bindings.quitMpv, 'select');
|
||||||
|
assert.equal(config.controller.bindings.playCurrentAudio, 'none');
|
||||||
|
assert.equal(config.controller.bindings.toggleMpvPause, 'leftStickPress');
|
||||||
|
assert.equal(config.controller.bindings.leftStickHorizontal, 'rightStickX');
|
||||||
|
assert.equal(config.controller.bindings.rightStickVertical, 'leftStickY');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('controller positive-number tuning rejects sub-unit values that floor to zero', () => {
|
||||||
|
const dir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"controller": {
|
||||||
|
"scrollPixelsPerSecond": 0.5,
|
||||||
|
"horizontalJumpPixels": 0.2,
|
||||||
|
"repeatDelayMs": 0.9,
|
||||||
|
"repeatIntervalMs": 0.1
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new ConfigService(dir);
|
||||||
|
const config = service.getConfig();
|
||||||
|
const warnings = service.getWarnings();
|
||||||
|
|
||||||
|
assert.equal(config.controller.scrollPixelsPerSecond, DEFAULT_CONFIG.controller.scrollPixelsPerSecond);
|
||||||
|
assert.equal(config.controller.horizontalJumpPixels, DEFAULT_CONFIG.controller.horizontalJumpPixels);
|
||||||
|
assert.equal(config.controller.repeatDelayMs, DEFAULT_CONFIG.controller.repeatDelayMs);
|
||||||
|
assert.equal(config.controller.repeatIntervalMs, DEFAULT_CONFIG.controller.repeatIntervalMs);
|
||||||
|
assert.equal(warnings.some((warning) => warning.path === 'controller.scrollPixelsPerSecond'), true);
|
||||||
|
assert.equal(warnings.some((warning) => warning.path === 'controller.horizontalJumpPixels'), true);
|
||||||
|
assert.equal(warnings.some((warning) => warning.path === 'controller.repeatDelayMs'), true);
|
||||||
|
assert.equal(warnings.some((warning) => warning.path === 'controller.repeatIntervalMs'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('controller button index config rejects fractional values', () => {
|
||||||
|
const dir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"controller": {
|
||||||
|
"buttonIndices": {
|
||||||
|
"select": 6.5,
|
||||||
|
"leftStickPress": 9.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new ConfigService(dir);
|
||||||
|
const config = service.getConfig();
|
||||||
|
const warnings = service.getWarnings();
|
||||||
|
|
||||||
|
assert.equal(config.controller.buttonIndices.select, DEFAULT_CONFIG.controller.buttonIndices.select);
|
||||||
|
assert.equal(
|
||||||
|
config.controller.buttonIndices.leftStickPress,
|
||||||
|
DEFAULT_CONFIG.controller.buttonIndices.leftStickPress,
|
||||||
|
);
|
||||||
|
assert.equal(warnings.some((warning) => warning.path === 'controller.buttonIndices.select'), true);
|
||||||
|
assert.equal(
|
||||||
|
warnings.some((warning) => warning.path === 'controller.buttonIndices.leftStickPress'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('runtime options registry is centralized', () => {
|
test('runtime options registry is centralized', () => {
|
||||||
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
|
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
|
||||||
assert.deepEqual(ids, [
|
assert.deepEqual(ids, [
|
||||||
@@ -1639,6 +1768,7 @@ test('template generator includes known keys', () => {
|
|||||||
const output = generateConfigTemplate(DEFAULT_CONFIG);
|
const output = generateConfigTemplate(DEFAULT_CONFIG);
|
||||||
assert.match(output, /"ai":/);
|
assert.match(output, /"ai":/);
|
||||||
assert.match(output, /"ankiConnect":/);
|
assert.match(output, /"ankiConnect":/);
|
||||||
|
assert.match(output, /"controller":/);
|
||||||
assert.match(output, /"logging":/);
|
assert.match(output, /"logging":/);
|
||||||
assert.match(output, /"websocket":/);
|
assert.match(output, /"websocket":/);
|
||||||
assert.match(output, /"discordPresence":/);
|
assert.match(output, /"discordPresence":/);
|
||||||
@@ -1663,6 +1793,14 @@ test('template generator includes known keys', () => {
|
|||||||
output,
|
output,
|
||||||
/"enabled": true,? \/\/ Annotated subtitle websocket server enabled state\. Values: true \| false/,
|
/"enabled": true,? \/\/ Annotated subtitle websocket server enabled state\. Values: true \| false/,
|
||||||
);
|
);
|
||||||
|
assert.match(
|
||||||
|
output,
|
||||||
|
/"scrollPixelsPerSecond": 900,? \/\/ Base popup scroll speed for controller stick input\./,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
output,
|
||||||
|
/"triggerInputMode": "auto",? \/\/ How controller triggers are interpreted: auto, pressed-only, or thresholded analog\. Values: auto \| digital \| analog/,
|
||||||
|
);
|
||||||
assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./);
|
assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./);
|
||||||
assert.match(
|
assert.match(
|
||||||
output,
|
output,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const {
|
|||||||
annotationWebsocket,
|
annotationWebsocket,
|
||||||
logging,
|
logging,
|
||||||
texthooker,
|
texthooker,
|
||||||
|
controller,
|
||||||
shortcuts,
|
shortcuts,
|
||||||
secondarySub,
|
secondarySub,
|
||||||
subsync,
|
subsync,
|
||||||
@@ -43,6 +44,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
|||||||
annotationWebsocket,
|
annotationWebsocket,
|
||||||
logging,
|
logging,
|
||||||
texthooker,
|
texthooker,
|
||||||
|
controller,
|
||||||
ankiConnect,
|
ankiConnect,
|
||||||
shortcuts,
|
shortcuts,
|
||||||
secondarySub,
|
secondarySub,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
| 'annotationWebsocket'
|
| 'annotationWebsocket'
|
||||||
| 'logging'
|
| 'logging'
|
||||||
| 'texthooker'
|
| 'texthooker'
|
||||||
|
| 'controller'
|
||||||
| 'shortcuts'
|
| 'shortcuts'
|
||||||
| 'secondarySub'
|
| 'secondarySub'
|
||||||
| 'subsync'
|
| 'subsync'
|
||||||
@@ -31,6 +32,47 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
launchAtStartup: true,
|
launchAtStartup: true,
|
||||||
openBrowser: true,
|
openBrowser: true,
|
||||||
},
|
},
|
||||||
|
controller: {
|
||||||
|
enabled: true,
|
||||||
|
preferredGamepadId: '',
|
||||||
|
preferredGamepadLabel: '',
|
||||||
|
smoothScroll: true,
|
||||||
|
scrollPixelsPerSecond: 900,
|
||||||
|
horizontalJumpPixels: 160,
|
||||||
|
stickDeadzone: 0.2,
|
||||||
|
triggerInputMode: 'auto',
|
||||||
|
triggerDeadzone: 0.5,
|
||||||
|
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: 'buttonSouth',
|
||||||
|
closeLookup: 'buttonEast',
|
||||||
|
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||||
|
mineCard: 'buttonWest',
|
||||||
|
quitMpv: 'select',
|
||||||
|
previousAudio: 'none',
|
||||||
|
nextAudio: 'rightShoulder',
|
||||||
|
playCurrentAudio: 'leftShoulder',
|
||||||
|
toggleMpvPause: 'leftStickPress',
|
||||||
|
leftStickHorizontal: 'leftStickX',
|
||||||
|
leftStickVertical: 'leftStickY',
|
||||||
|
rightStickHorizontal: 'rightStickX',
|
||||||
|
rightStickVertical: 'rightStickY',
|
||||||
|
},
|
||||||
|
},
|
||||||
shortcuts: {
|
shortcuts: {
|
||||||
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
|
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
|
||||||
copySubtitle: 'CommandOrControl+C',
|
copySubtitle: 'CommandOrControl+C',
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ test('config option registry includes critical paths and has unique entries', ()
|
|||||||
for (const requiredPath of [
|
for (const requiredPath of [
|
||||||
'logging.level',
|
'logging.level',
|
||||||
'annotationWebsocket.enabled',
|
'annotationWebsocket.enabled',
|
||||||
|
'controller.enabled',
|
||||||
|
'controller.scrollPixelsPerSecond',
|
||||||
'startupWarmups.lowPowerMode',
|
'startupWarmups.lowPowerMode',
|
||||||
'subtitleStyle.enableJlpt',
|
'subtitleStyle.enableJlpt',
|
||||||
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
||||||
@@ -39,6 +41,7 @@ test('config template sections include expected domains and unique keys', () =>
|
|||||||
const requiredKeys: (typeof keys)[number][] = [
|
const requiredKeys: (typeof keys)[number][] = [
|
||||||
'websocket',
|
'websocket',
|
||||||
'annotationWebsocket',
|
'annotationWebsocket',
|
||||||
|
'controller',
|
||||||
'startupWarmups',
|
'startupWarmups',
|
||||||
'subtitleStyle',
|
'subtitleStyle',
|
||||||
'ankiConnect',
|
'ankiConnect',
|
||||||
|
|||||||
@@ -4,6 +4,21 @@ import { ConfigOptionRegistryEntry } from './shared';
|
|||||||
export function buildCoreConfigOptionRegistry(
|
export function buildCoreConfigOptionRegistry(
|
||||||
defaultConfig: ResolvedConfig,
|
defaultConfig: ResolvedConfig,
|
||||||
): ConfigOptionRegistryEntry[] {
|
): ConfigOptionRegistryEntry[] {
|
||||||
|
const controllerButtonEnumValues = [
|
||||||
|
'none',
|
||||||
|
'select',
|
||||||
|
'buttonSouth',
|
||||||
|
'buttonEast',
|
||||||
|
'buttonNorth',
|
||||||
|
'buttonWest',
|
||||||
|
'leftShoulder',
|
||||||
|
'rightShoulder',
|
||||||
|
'leftStickPress',
|
||||||
|
'rightStickPress',
|
||||||
|
'leftTrigger',
|
||||||
|
'rightTrigger',
|
||||||
|
];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
path: 'logging.level',
|
path: 'logging.level',
|
||||||
@@ -12,6 +27,230 @@ export function buildCoreConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.logging.level,
|
defaultValue: defaultConfig.logging.level,
|
||||||
description: 'Minimum log level for runtime logging.',
|
description: 'Minimum log level for runtime logging.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.enabled',
|
||||||
|
kind: 'boolean',
|
||||||
|
defaultValue: defaultConfig.controller.enabled,
|
||||||
|
description: 'Enable overlay controller support through the Chrome Gamepad API.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.preferredGamepadId',
|
||||||
|
kind: 'string',
|
||||||
|
defaultValue: defaultConfig.controller.preferredGamepadId,
|
||||||
|
description: 'Preferred controller id saved from the controller selection modal.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.preferredGamepadLabel',
|
||||||
|
kind: 'string',
|
||||||
|
defaultValue: defaultConfig.controller.preferredGamepadLabel,
|
||||||
|
description: 'Preferred controller display label saved for diagnostics.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.smoothScroll',
|
||||||
|
kind: 'boolean',
|
||||||
|
defaultValue: defaultConfig.controller.smoothScroll,
|
||||||
|
description: 'Use smooth scrolling for controller-driven popup scroll input.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.scrollPixelsPerSecond',
|
||||||
|
kind: 'number',
|
||||||
|
defaultValue: defaultConfig.controller.scrollPixelsPerSecond,
|
||||||
|
description: 'Base popup scroll speed for controller stick input.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.horizontalJumpPixels',
|
||||||
|
kind: 'number',
|
||||||
|
defaultValue: defaultConfig.controller.horizontalJumpPixels,
|
||||||
|
description: 'Popup page-jump distance for controller jump input.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.stickDeadzone',
|
||||||
|
kind: 'number',
|
||||||
|
defaultValue: defaultConfig.controller.stickDeadzone,
|
||||||
|
description: 'Deadzone applied to controller stick axes.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.triggerInputMode',
|
||||||
|
kind: 'enum',
|
||||||
|
enumValues: ['auto', 'digital', 'analog'],
|
||||||
|
defaultValue: defaultConfig.controller.triggerInputMode,
|
||||||
|
description: 'How controller triggers are interpreted: auto, pressed-only, or thresholded analog.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.triggerDeadzone',
|
||||||
|
kind: 'number',
|
||||||
|
defaultValue: defaultConfig.controller.triggerDeadzone,
|
||||||
|
description: 'Minimum analog trigger value required when trigger input uses auto or analog mode.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.repeatDelayMs',
|
||||||
|
kind: 'number',
|
||||||
|
defaultValue: defaultConfig.controller.repeatDelayMs,
|
||||||
|
description: 'Delay before repeating held controller actions.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.repeatIntervalMs',
|
||||||
|
kind: 'number',
|
||||||
|
defaultValue: defaultConfig.controller.repeatIntervalMs,
|
||||||
|
description: 'Repeat interval for held controller actions.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.buttonIndices.select',
|
||||||
|
kind: 'number',
|
||||||
|
defaultValue: defaultConfig.controller.buttonIndices.select,
|
||||||
|
description: 'Raw button index used for the controller select/minus/back button.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.buttonIndices.buttonSouth',
|
||||||
|
kind: 'number',
|
||||||
|
defaultValue: defaultConfig.controller.buttonIndices.buttonSouth,
|
||||||
|
description: 'Raw button index used for controller south/A button input.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.buttonIndices.buttonEast',
|
||||||
|
kind: 'number',
|
||||||
|
defaultValue: defaultConfig.controller.buttonIndices.buttonEast,
|
||||||
|
description: 'Raw button index used for controller east/B button input.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.buttonIndices.buttonNorth',
|
||||||
|
kind: 'number',
|
||||||
|
defaultValue: defaultConfig.controller.buttonIndices.buttonNorth,
|
||||||
|
description: 'Raw button index used for controller north/Y button input.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.buttonIndices.buttonWest',
|
||||||
|
kind: 'number',
|
||||||
|
defaultValue: defaultConfig.controller.buttonIndices.buttonWest,
|
||||||
|
description: 'Raw button index used for controller west/X button input.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.buttonIndices.leftShoulder',
|
||||||
|
kind: 'number',
|
||||||
|
defaultValue: defaultConfig.controller.buttonIndices.leftShoulder,
|
||||||
|
description: 'Raw button index used for controller left shoulder input.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.buttonIndices.rightShoulder',
|
||||||
|
kind: 'number',
|
||||||
|
defaultValue: defaultConfig.controller.buttonIndices.rightShoulder,
|
||||||
|
description: 'Raw button index used for controller right shoulder input.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.buttonIndices.leftStickPress',
|
||||||
|
kind: 'number',
|
||||||
|
defaultValue: defaultConfig.controller.buttonIndices.leftStickPress,
|
||||||
|
description: 'Raw button index used for controller L3 input.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.buttonIndices.rightStickPress',
|
||||||
|
kind: 'number',
|
||||||
|
defaultValue: defaultConfig.controller.buttonIndices.rightStickPress,
|
||||||
|
description: 'Raw button index used for controller R3 input.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.buttonIndices.leftTrigger',
|
||||||
|
kind: 'number',
|
||||||
|
defaultValue: defaultConfig.controller.buttonIndices.leftTrigger,
|
||||||
|
description: 'Raw button index used for controller L2 input.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.buttonIndices.rightTrigger',
|
||||||
|
kind: 'number',
|
||||||
|
defaultValue: defaultConfig.controller.buttonIndices.rightTrigger,
|
||||||
|
description: 'Raw button index used for controller R2 input.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.bindings.toggleLookup',
|
||||||
|
kind: 'enum',
|
||||||
|
enumValues: controllerButtonEnumValues,
|
||||||
|
defaultValue: defaultConfig.controller.bindings.toggleLookup,
|
||||||
|
description: 'Controller binding for toggling lookup.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.bindings.closeLookup',
|
||||||
|
kind: 'enum',
|
||||||
|
enumValues: controllerButtonEnumValues,
|
||||||
|
defaultValue: defaultConfig.controller.bindings.closeLookup,
|
||||||
|
description: 'Controller binding for closing lookup.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.bindings.toggleKeyboardOnlyMode',
|
||||||
|
kind: 'enum',
|
||||||
|
enumValues: controllerButtonEnumValues,
|
||||||
|
defaultValue: defaultConfig.controller.bindings.toggleKeyboardOnlyMode,
|
||||||
|
description: 'Controller binding for toggling keyboard-only mode.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.bindings.mineCard',
|
||||||
|
kind: 'enum',
|
||||||
|
enumValues: controllerButtonEnumValues,
|
||||||
|
defaultValue: defaultConfig.controller.bindings.mineCard,
|
||||||
|
description: 'Controller binding for mining the active card.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.bindings.quitMpv',
|
||||||
|
kind: 'enum',
|
||||||
|
enumValues: controllerButtonEnumValues,
|
||||||
|
defaultValue: defaultConfig.controller.bindings.quitMpv,
|
||||||
|
description: 'Controller binding for quitting mpv.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.bindings.previousAudio',
|
||||||
|
kind: 'enum',
|
||||||
|
enumValues: controllerButtonEnumValues,
|
||||||
|
defaultValue: defaultConfig.controller.bindings.previousAudio,
|
||||||
|
description: 'Controller binding for previous Yomitan audio.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.bindings.nextAudio',
|
||||||
|
kind: 'enum',
|
||||||
|
enumValues: controllerButtonEnumValues,
|
||||||
|
defaultValue: defaultConfig.controller.bindings.nextAudio,
|
||||||
|
description: 'Controller binding for next Yomitan audio.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.bindings.playCurrentAudio',
|
||||||
|
kind: 'enum',
|
||||||
|
enumValues: controllerButtonEnumValues,
|
||||||
|
defaultValue: defaultConfig.controller.bindings.playCurrentAudio,
|
||||||
|
description: 'Controller binding for playing the current Yomitan audio.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.bindings.toggleMpvPause',
|
||||||
|
kind: 'enum',
|
||||||
|
enumValues: controllerButtonEnumValues,
|
||||||
|
defaultValue: defaultConfig.controller.bindings.toggleMpvPause,
|
||||||
|
description: 'Controller binding for toggling mpv play/pause.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.bindings.leftStickHorizontal',
|
||||||
|
kind: 'enum',
|
||||||
|
enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'],
|
||||||
|
defaultValue: defaultConfig.controller.bindings.leftStickHorizontal,
|
||||||
|
description: 'Axis binding used for left/right token selection.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.bindings.leftStickVertical',
|
||||||
|
kind: 'enum',
|
||||||
|
enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'],
|
||||||
|
defaultValue: defaultConfig.controller.bindings.leftStickVertical,
|
||||||
|
description: 'Axis binding used for primary popup scrolling.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.bindings.rightStickHorizontal',
|
||||||
|
kind: 'enum',
|
||||||
|
enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'],
|
||||||
|
defaultValue: defaultConfig.controller.bindings.rightStickHorizontal,
|
||||||
|
description: 'Axis binding reserved for alternate right-stick mappings.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'controller.bindings.rightStickVertical',
|
||||||
|
kind: 'enum',
|
||||||
|
enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'],
|
||||||
|
defaultValue: defaultConfig.controller.bindings.rightStickVertical,
|
||||||
|
description: 'Axis binding used for popup page jumps.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'texthooker.launchAtStartup',
|
path: 'texthooker.launchAtStartup',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'],
|
description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'],
|
||||||
key: 'logging',
|
key: 'logging',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Controller Support',
|
||||||
|
description: [
|
||||||
|
'Gamepad support for the visible overlay while keyboard-only mode is active.',
|
||||||
|
'Use the selection modal to save a preferred controller by id for future launches.',
|
||||||
|
'Trigger input mode can be auto, digital-only, or analog-thresholded depending on the controller.',
|
||||||
|
'Override controller.buttonIndices when your pad reports non-standard raw button numbers.',
|
||||||
|
],
|
||||||
|
key: 'controller',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Startup Warmups',
|
title: 'Startup Warmups',
|
||||||
description: [
|
description: [
|
||||||
|
|||||||
@@ -3,6 +3,21 @@ import { asBoolean, asNumber, asString, isObject } from './shared';
|
|||||||
|
|
||||||
export function applyCoreDomainConfig(context: ResolveContext): void {
|
export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||||
const { src, resolved, warn } = context;
|
const { src, resolved, warn } = context;
|
||||||
|
const controllerButtonBindings = [
|
||||||
|
'none',
|
||||||
|
'select',
|
||||||
|
'buttonSouth',
|
||||||
|
'buttonEast',
|
||||||
|
'buttonNorth',
|
||||||
|
'buttonWest',
|
||||||
|
'leftShoulder',
|
||||||
|
'rightShoulder',
|
||||||
|
'leftStickPress',
|
||||||
|
'rightStickPress',
|
||||||
|
'leftTrigger',
|
||||||
|
'rightTrigger',
|
||||||
|
] as const;
|
||||||
|
const controllerAxisBindings = ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'] as const;
|
||||||
|
|
||||||
if (isObject(src.texthooker)) {
|
if (isObject(src.texthooker)) {
|
||||||
const launchAtStartup = asBoolean(src.texthooker.launchAtStartup);
|
const launchAtStartup = asBoolean(src.texthooker.launchAtStartup);
|
||||||
@@ -101,6 +116,170 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isObject(src.controller)) {
|
||||||
|
const enabled = asBoolean(src.controller.enabled);
|
||||||
|
if (enabled !== undefined) {
|
||||||
|
resolved.controller.enabled = enabled;
|
||||||
|
} else if (src.controller.enabled !== undefined) {
|
||||||
|
warn(
|
||||||
|
'controller.enabled',
|
||||||
|
src.controller.enabled,
|
||||||
|
resolved.controller.enabled,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferredGamepadId = asString(src.controller.preferredGamepadId);
|
||||||
|
if (preferredGamepadId !== undefined) {
|
||||||
|
resolved.controller.preferredGamepadId = preferredGamepadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferredGamepadLabel = asString(src.controller.preferredGamepadLabel);
|
||||||
|
if (preferredGamepadLabel !== undefined) {
|
||||||
|
resolved.controller.preferredGamepadLabel = preferredGamepadLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const smoothScroll = asBoolean(src.controller.smoothScroll);
|
||||||
|
if (smoothScroll !== undefined) {
|
||||||
|
resolved.controller.smoothScroll = smoothScroll;
|
||||||
|
} else if (src.controller.smoothScroll !== undefined) {
|
||||||
|
warn(
|
||||||
|
'controller.smoothScroll',
|
||||||
|
src.controller.smoothScroll,
|
||||||
|
resolved.controller.smoothScroll,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerInputMode = asString(src.controller.triggerInputMode);
|
||||||
|
if (
|
||||||
|
triggerInputMode === 'auto' ||
|
||||||
|
triggerInputMode === 'digital' ||
|
||||||
|
triggerInputMode === 'analog'
|
||||||
|
) {
|
||||||
|
resolved.controller.triggerInputMode = triggerInputMode;
|
||||||
|
} else if (src.controller.triggerInputMode !== undefined) {
|
||||||
|
warn(
|
||||||
|
'controller.triggerInputMode',
|
||||||
|
src.controller.triggerInputMode,
|
||||||
|
resolved.controller.triggerInputMode,
|
||||||
|
"Expected 'auto', 'digital', or 'analog'.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundedNumberKeys = [
|
||||||
|
'scrollPixelsPerSecond',
|
||||||
|
'horizontalJumpPixels',
|
||||||
|
'repeatDelayMs',
|
||||||
|
'repeatIntervalMs',
|
||||||
|
] as const;
|
||||||
|
for (const key of boundedNumberKeys) {
|
||||||
|
const value = asNumber(src.controller[key]);
|
||||||
|
if (value !== undefined && Math.floor(value) > 0) {
|
||||||
|
resolved.controller[key] = Math.floor(value) as (typeof resolved.controller)[typeof key];
|
||||||
|
} else if (src.controller[key] !== undefined) {
|
||||||
|
warn(`controller.${key}`, src.controller[key], resolved.controller[key], 'Expected positive number.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadzoneKeys = ['stickDeadzone', 'triggerDeadzone'] as const;
|
||||||
|
for (const key of deadzoneKeys) {
|
||||||
|
const value = asNumber(src.controller[key]);
|
||||||
|
if (value !== undefined && value >= 0 && value <= 1) {
|
||||||
|
resolved.controller[key] = value as (typeof resolved.controller)[typeof key];
|
||||||
|
} else if (src.controller[key] !== undefined) {
|
||||||
|
warn(`controller.${key}`, src.controller[key], resolved.controller[key], 'Expected number between 0 and 1.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isObject(src.controller.buttonIndices)) {
|
||||||
|
const buttonIndexKeys = [
|
||||||
|
'select',
|
||||||
|
'buttonSouth',
|
||||||
|
'buttonEast',
|
||||||
|
'buttonNorth',
|
||||||
|
'buttonWest',
|
||||||
|
'leftShoulder',
|
||||||
|
'rightShoulder',
|
||||||
|
'leftStickPress',
|
||||||
|
'rightStickPress',
|
||||||
|
'leftTrigger',
|
||||||
|
'rightTrigger',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const key of buttonIndexKeys) {
|
||||||
|
const value = asNumber(src.controller.buttonIndices[key]);
|
||||||
|
if (value !== undefined && value >= 0 && Number.isInteger(value)) {
|
||||||
|
resolved.controller.buttonIndices[key] = value;
|
||||||
|
} else if (src.controller.buttonIndices[key] !== undefined) {
|
||||||
|
warn(
|
||||||
|
`controller.buttonIndices.${key}`,
|
||||||
|
src.controller.buttonIndices[key],
|
||||||
|
resolved.controller.buttonIndices[key],
|
||||||
|
'Expected non-negative integer.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isObject(src.controller.bindings)) {
|
||||||
|
const buttonBindingKeys = [
|
||||||
|
'toggleLookup',
|
||||||
|
'closeLookup',
|
||||||
|
'toggleKeyboardOnlyMode',
|
||||||
|
'mineCard',
|
||||||
|
'quitMpv',
|
||||||
|
'previousAudio',
|
||||||
|
'nextAudio',
|
||||||
|
'playCurrentAudio',
|
||||||
|
'toggleMpvPause',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const key of buttonBindingKeys) {
|
||||||
|
const value = asString(src.controller.bindings[key]);
|
||||||
|
if (
|
||||||
|
value !== undefined &&
|
||||||
|
controllerButtonBindings.includes(value as (typeof controllerButtonBindings)[number])
|
||||||
|
) {
|
||||||
|
resolved.controller.bindings[key] =
|
||||||
|
value as (typeof resolved.controller.bindings)[typeof key];
|
||||||
|
} else if (src.controller.bindings[key] !== undefined) {
|
||||||
|
warn(
|
||||||
|
`controller.bindings.${key}`,
|
||||||
|
src.controller.bindings[key],
|
||||||
|
resolved.controller.bindings[key],
|
||||||
|
`Expected one of: ${controllerButtonBindings.join(', ')}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const axisBindingKeys = [
|
||||||
|
'leftStickHorizontal',
|
||||||
|
'leftStickVertical',
|
||||||
|
'rightStickHorizontal',
|
||||||
|
'rightStickVertical',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const key of axisBindingKeys) {
|
||||||
|
const value = asString(src.controller.bindings[key]);
|
||||||
|
if (
|
||||||
|
value !== undefined &&
|
||||||
|
controllerAxisBindings.includes(value as (typeof controllerAxisBindings)[number])
|
||||||
|
) {
|
||||||
|
resolved.controller.bindings[key] =
|
||||||
|
value as (typeof resolved.controller.bindings)[typeof key];
|
||||||
|
} else if (src.controller.bindings[key] !== undefined) {
|
||||||
|
warn(
|
||||||
|
`controller.bindings.${key}`,
|
||||||
|
src.controller.bindings[key],
|
||||||
|
resolved.controller.bindings[key],
|
||||||
|
`Expected one of: ${controllerAxisBindings.join(', ')}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (Array.isArray(src.keybindings)) {
|
if (Array.isArray(src.keybindings)) {
|
||||||
resolved.keybindings = src.keybindings.filter(
|
resolved.keybindings = src.keybindings.filter(
|
||||||
(entry): entry is { key: string; command: (string | number)[] | null } => {
|
(entry): entry is { key: string; command: (string | number)[] | null } => {
|
||||||
|
|||||||
@@ -53,6 +53,48 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
|||||||
handleMpvCommand: () => {},
|
handleMpvCommand: () => {},
|
||||||
getKeybindings: () => [],
|
getKeybindings: () => [],
|
||||||
getConfiguredShortcuts: () => ({}),
|
getConfiguredShortcuts: () => ({}),
|
||||||
|
getControllerConfig: () => ({
|
||||||
|
enabled: true,
|
||||||
|
preferredGamepadId: '',
|
||||||
|
preferredGamepadLabel: '',
|
||||||
|
smoothScroll: true,
|
||||||
|
scrollPixelsPerSecond: 960,
|
||||||
|
horizontalJumpPixels: 160,
|
||||||
|
stickDeadzone: 0.2,
|
||||||
|
triggerInputMode: 'auto',
|
||||||
|
triggerDeadzone: 0.5,
|
||||||
|
repeatDelayMs: 220,
|
||||||
|
repeatIntervalMs: 80,
|
||||||
|
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: 'buttonSouth',
|
||||||
|
closeLookup: 'buttonEast',
|
||||||
|
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||||
|
mineCard: 'buttonWest',
|
||||||
|
quitMpv: 'select',
|
||||||
|
previousAudio: 'leftShoulder',
|
||||||
|
nextAudio: 'rightShoulder',
|
||||||
|
playCurrentAudio: 'rightTrigger',
|
||||||
|
toggleMpvPause: 'leftTrigger',
|
||||||
|
leftStickHorizontal: 'leftStickX',
|
||||||
|
leftStickVertical: 'leftStickY',
|
||||||
|
rightStickHorizontal: 'rightStickX',
|
||||||
|
rightStickVertical: 'rightStickY',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
saveControllerPreference: () => {},
|
||||||
getSecondarySubMode: () => 'hover',
|
getSecondarySubMode: () => 'hover',
|
||||||
getMpvClient: () => null,
|
getMpvClient: () => null,
|
||||||
focusMainWindow: () => {},
|
focusMainWindow: () => {},
|
||||||
@@ -117,6 +159,48 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
|||||||
handleMpvCommand: () => {},
|
handleMpvCommand: () => {},
|
||||||
getKeybindings: () => [],
|
getKeybindings: () => [],
|
||||||
getConfiguredShortcuts: () => ({}),
|
getConfiguredShortcuts: () => ({}),
|
||||||
|
getControllerConfig: () => ({
|
||||||
|
enabled: true,
|
||||||
|
preferredGamepadId: '',
|
||||||
|
preferredGamepadLabel: '',
|
||||||
|
smoothScroll: true,
|
||||||
|
scrollPixelsPerSecond: 960,
|
||||||
|
horizontalJumpPixels: 160,
|
||||||
|
stickDeadzone: 0.2,
|
||||||
|
triggerInputMode: 'auto',
|
||||||
|
triggerDeadzone: 0.5,
|
||||||
|
repeatDelayMs: 220,
|
||||||
|
repeatIntervalMs: 80,
|
||||||
|
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: 'buttonSouth',
|
||||||
|
closeLookup: 'buttonEast',
|
||||||
|
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||||
|
mineCard: 'buttonWest',
|
||||||
|
quitMpv: 'select',
|
||||||
|
previousAudio: 'leftShoulder',
|
||||||
|
nextAudio: 'rightShoulder',
|
||||||
|
playCurrentAudio: 'rightTrigger',
|
||||||
|
toggleMpvPause: 'leftTrigger',
|
||||||
|
leftStickHorizontal: 'leftStickX',
|
||||||
|
leftStickVertical: 'leftStickY',
|
||||||
|
rightStickHorizontal: 'rightStickX',
|
||||||
|
rightStickVertical: 'rightStickY',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
saveControllerPreference: () => {},
|
||||||
getSecondarySubMode: () => 'hover',
|
getSecondarySubMode: () => 'hover',
|
||||||
getCurrentSecondarySub: () => '',
|
getCurrentSecondarySub: () => '',
|
||||||
focusMainWindow: () => {},
|
focusMainWindow: () => {},
|
||||||
@@ -173,11 +257,19 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
|||||||
const getPlaybackPausedHandler = handlers.handle.get(IPC_CHANNELS.request.getPlaybackPaused);
|
const getPlaybackPausedHandler = handlers.handle.get(IPC_CHANNELS.request.getPlaybackPaused);
|
||||||
assert.ok(getPlaybackPausedHandler);
|
assert.ok(getPlaybackPausedHandler);
|
||||||
assert.equal(getPlaybackPausedHandler!({}), null);
|
assert.equal(getPlaybackPausedHandler!({}), null);
|
||||||
|
|
||||||
|
const getControllerConfigHandler = handlers.handle.get(IPC_CHANNELS.request.getControllerConfig);
|
||||||
|
assert.ok(getControllerConfigHandler);
|
||||||
|
assert.equal(
|
||||||
|
(getControllerConfigHandler!({}) as { scrollPixelsPerSecond: number }).scrollPixelsPerSecond,
|
||||||
|
960,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||||
const saves: unknown[] = [];
|
const saves: unknown[] = [];
|
||||||
|
const controllerSaves: unknown[] = [];
|
||||||
const closedModals: unknown[] = [];
|
const closedModals: unknown[] = [];
|
||||||
const openedModals: unknown[] = [];
|
const openedModals: unknown[] = [];
|
||||||
registerIpcHandlers(
|
registerIpcHandlers(
|
||||||
@@ -207,6 +299,50 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
|||||||
handleMpvCommand: () => {},
|
handleMpvCommand: () => {},
|
||||||
getKeybindings: () => [],
|
getKeybindings: () => [],
|
||||||
getConfiguredShortcuts: () => ({}),
|
getConfiguredShortcuts: () => ({}),
|
||||||
|
getControllerConfig: () => ({
|
||||||
|
enabled: true,
|
||||||
|
preferredGamepadId: '',
|
||||||
|
preferredGamepadLabel: '',
|
||||||
|
smoothScroll: true,
|
||||||
|
scrollPixelsPerSecond: 960,
|
||||||
|
horizontalJumpPixels: 160,
|
||||||
|
stickDeadzone: 0.2,
|
||||||
|
triggerInputMode: 'auto',
|
||||||
|
triggerDeadzone: 0.5,
|
||||||
|
repeatDelayMs: 220,
|
||||||
|
repeatIntervalMs: 80,
|
||||||
|
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: 'buttonSouth',
|
||||||
|
closeLookup: 'buttonEast',
|
||||||
|
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||||
|
mineCard: 'buttonWest',
|
||||||
|
quitMpv: 'select',
|
||||||
|
previousAudio: 'leftShoulder',
|
||||||
|
nextAudio: 'rightShoulder',
|
||||||
|
playCurrentAudio: 'rightTrigger',
|
||||||
|
toggleMpvPause: 'leftTrigger',
|
||||||
|
leftStickHorizontal: 'leftStickX',
|
||||||
|
leftStickVertical: 'leftStickY',
|
||||||
|
rightStickHorizontal: 'rightStickX',
|
||||||
|
rightStickVertical: 'rightStickY',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
saveControllerPreference: (update) => {
|
||||||
|
controllerSaves.push(update);
|
||||||
|
},
|
||||||
getSecondarySubMode: () => 'hover',
|
getSecondarySubMode: () => 'hover',
|
||||||
getCurrentSecondarySub: () => '',
|
getCurrentSecondarySub: () => '',
|
||||||
focusMainWindow: () => {},
|
focusMainWindow: () => {},
|
||||||
@@ -240,3 +376,204 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
|||||||
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'runtime-options');
|
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'runtime-options');
|
||||||
assert.deepEqual(openedModals, ['subsync', 'runtime-options']);
|
assert.deepEqual(openedModals, ['subsync', 'runtime-options']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('registerIpcHandlers awaits saveControllerPreference through request-response IPC', async () => {
|
||||||
|
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||||
|
const controllerSaves: unknown[] = [];
|
||||||
|
registerIpcHandlers(
|
||||||
|
{
|
||||||
|
onOverlayModalClosed: () => {},
|
||||||
|
openYomitanSettings: () => {},
|
||||||
|
quitApp: () => {},
|
||||||
|
toggleDevTools: () => {},
|
||||||
|
getVisibleOverlayVisibility: () => false,
|
||||||
|
toggleVisibleOverlay: () => {},
|
||||||
|
tokenizeCurrentSubtitle: async () => null,
|
||||||
|
getCurrentSubtitleRaw: () => '',
|
||||||
|
getCurrentSubtitleAss: () => '',
|
||||||
|
getPlaybackPaused: () => false,
|
||||||
|
getSubtitlePosition: () => null,
|
||||||
|
getSubtitleStyle: () => null,
|
||||||
|
saveSubtitlePosition: () => {},
|
||||||
|
getMecabStatus: () => ({ available: false, enabled: false, path: null }),
|
||||||
|
setMecabEnabled: () => {},
|
||||||
|
handleMpvCommand: () => {},
|
||||||
|
getKeybindings: () => [],
|
||||||
|
getConfiguredShortcuts: () => ({}),
|
||||||
|
getControllerConfig: () => ({
|
||||||
|
enabled: true,
|
||||||
|
preferredGamepadId: '',
|
||||||
|
preferredGamepadLabel: '',
|
||||||
|
smoothScroll: true,
|
||||||
|
scrollPixelsPerSecond: 960,
|
||||||
|
horizontalJumpPixels: 160,
|
||||||
|
stickDeadzone: 0.2,
|
||||||
|
triggerInputMode: 'auto',
|
||||||
|
triggerDeadzone: 0.5,
|
||||||
|
repeatDelayMs: 220,
|
||||||
|
repeatIntervalMs: 80,
|
||||||
|
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: 'buttonSouth',
|
||||||
|
closeLookup: 'buttonEast',
|
||||||
|
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||||
|
mineCard: 'buttonWest',
|
||||||
|
quitMpv: 'select',
|
||||||
|
previousAudio: 'leftShoulder',
|
||||||
|
nextAudio: 'rightShoulder',
|
||||||
|
playCurrentAudio: 'rightTrigger',
|
||||||
|
toggleMpvPause: 'leftTrigger',
|
||||||
|
leftStickHorizontal: 'leftStickX',
|
||||||
|
leftStickVertical: 'leftStickY',
|
||||||
|
rightStickHorizontal: 'rightStickX',
|
||||||
|
rightStickVertical: 'rightStickY',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
saveControllerPreference: async (update) => {
|
||||||
|
await Promise.resolve();
|
||||||
|
controllerSaves.push(update);
|
||||||
|
},
|
||||||
|
getSecondarySubMode: () => 'hover',
|
||||||
|
getCurrentSecondarySub: () => '',
|
||||||
|
focusMainWindow: () => {},
|
||||||
|
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
|
||||||
|
getAnkiConnectStatus: () => false,
|
||||||
|
getRuntimeOptions: () => [],
|
||||||
|
setRuntimeOption: () => ({ ok: true }),
|
||||||
|
cycleRuntimeOption: () => ({ ok: true }),
|
||||||
|
reportOverlayContentBounds: () => {},
|
||||||
|
getAnilistStatus: () => ({}),
|
||||||
|
clearAnilistToken: () => {},
|
||||||
|
openAnilistSetup: () => {},
|
||||||
|
getAnilistQueueStatus: () => ({}),
|
||||||
|
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||||
|
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
||||||
|
},
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerPreference);
|
||||||
|
assert.ok(saveHandler);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
async () => {
|
||||||
|
await saveHandler!({}, { preferredGamepadId: 12 });
|
||||||
|
},
|
||||||
|
/Invalid controller preference payload/,
|
||||||
|
);
|
||||||
|
await saveHandler!({}, {
|
||||||
|
preferredGamepadId: 'pad-1',
|
||||||
|
preferredGamepadLabel: 'Pad 1',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(controllerSaves, [
|
||||||
|
{
|
||||||
|
preferredGamepadId: 'pad-1',
|
||||||
|
preferredGamepadLabel: 'Pad 1',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registerIpcHandlers rejects malformed controller preference payloads', async () => {
|
||||||
|
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||||
|
registerIpcHandlers(
|
||||||
|
{
|
||||||
|
onOverlayModalClosed: () => {},
|
||||||
|
openYomitanSettings: () => {},
|
||||||
|
quitApp: () => {},
|
||||||
|
toggleDevTools: () => {},
|
||||||
|
getVisibleOverlayVisibility: () => false,
|
||||||
|
toggleVisibleOverlay: () => {},
|
||||||
|
tokenizeCurrentSubtitle: async () => null,
|
||||||
|
getCurrentSubtitleRaw: () => '',
|
||||||
|
getCurrentSubtitleAss: () => '',
|
||||||
|
getPlaybackPaused: () => false,
|
||||||
|
getSubtitlePosition: () => null,
|
||||||
|
getSubtitleStyle: () => null,
|
||||||
|
saveSubtitlePosition: () => {},
|
||||||
|
getMecabStatus: () => ({ available: false, enabled: false, path: null }),
|
||||||
|
setMecabEnabled: () => {},
|
||||||
|
handleMpvCommand: () => {},
|
||||||
|
getKeybindings: () => [],
|
||||||
|
getConfiguredShortcuts: () => ({}),
|
||||||
|
getControllerConfig: () => ({
|
||||||
|
enabled: true,
|
||||||
|
preferredGamepadId: '',
|
||||||
|
preferredGamepadLabel: '',
|
||||||
|
smoothScroll: true,
|
||||||
|
scrollPixelsPerSecond: 960,
|
||||||
|
horizontalJumpPixels: 160,
|
||||||
|
stickDeadzone: 0.2,
|
||||||
|
triggerInputMode: 'auto',
|
||||||
|
triggerDeadzone: 0.5,
|
||||||
|
repeatDelayMs: 220,
|
||||||
|
repeatIntervalMs: 80,
|
||||||
|
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: 'buttonSouth',
|
||||||
|
closeLookup: 'buttonEast',
|
||||||
|
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||||
|
mineCard: 'buttonWest',
|
||||||
|
quitMpv: 'select',
|
||||||
|
previousAudio: 'leftShoulder',
|
||||||
|
nextAudio: 'rightShoulder',
|
||||||
|
playCurrentAudio: 'rightTrigger',
|
||||||
|
toggleMpvPause: 'leftTrigger',
|
||||||
|
leftStickHorizontal: 'leftStickX',
|
||||||
|
leftStickVertical: 'leftStickY',
|
||||||
|
rightStickHorizontal: 'rightStickX',
|
||||||
|
rightStickVertical: 'rightStickY',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
saveControllerPreference: async () => {},
|
||||||
|
getSecondarySubMode: () => 'hover',
|
||||||
|
getCurrentSecondarySub: () => '',
|
||||||
|
focusMainWindow: () => {},
|
||||||
|
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
|
||||||
|
getAnkiConnectStatus: () => false,
|
||||||
|
getRuntimeOptions: () => [],
|
||||||
|
setRuntimeOption: () => ({ ok: true }),
|
||||||
|
cycleRuntimeOption: () => ({ ok: true }),
|
||||||
|
reportOverlayContentBounds: () => {},
|
||||||
|
getAnilistStatus: () => ({}),
|
||||||
|
clearAnilistToken: () => {},
|
||||||
|
openAnilistSetup: () => {},
|
||||||
|
getAnilistQueueStatus: () => ({}),
|
||||||
|
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||||
|
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
||||||
|
},
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerPreference);
|
||||||
|
await assert.rejects(
|
||||||
|
async () => {
|
||||||
|
await saveHandler!({}, { preferredGamepadId: 12 });
|
||||||
|
},
|
||||||
|
/Invalid controller preference payload/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import electron from 'electron';
|
import electron from 'electron';
|
||||||
import type { IpcMainEvent } from 'electron';
|
import type { IpcMainEvent } from 'electron';
|
||||||
import type {
|
import type {
|
||||||
|
ControllerPreferenceUpdate,
|
||||||
|
ResolvedControllerConfig,
|
||||||
RuntimeOptionId,
|
RuntimeOptionId,
|
||||||
RuntimeOptionValue,
|
RuntimeOptionValue,
|
||||||
SubtitlePosition,
|
SubtitlePosition,
|
||||||
@@ -10,6 +12,7 @@ import type {
|
|||||||
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
|
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||||
import {
|
import {
|
||||||
parseMpvCommand,
|
parseMpvCommand,
|
||||||
|
parseControllerPreferenceUpdate,
|
||||||
parseOptionalForwardingOptions,
|
parseOptionalForwardingOptions,
|
||||||
parseOverlayHostedModal,
|
parseOverlayHostedModal,
|
||||||
parseRuntimeOptionDirection,
|
parseRuntimeOptionDirection,
|
||||||
@@ -45,6 +48,8 @@ export interface IpcServiceDeps {
|
|||||||
handleMpvCommand: (command: Array<string | number>) => void;
|
handleMpvCommand: (command: Array<string | number>) => void;
|
||||||
getKeybindings: () => unknown;
|
getKeybindings: () => unknown;
|
||||||
getConfiguredShortcuts: () => unknown;
|
getConfiguredShortcuts: () => unknown;
|
||||||
|
getControllerConfig: () => ResolvedControllerConfig;
|
||||||
|
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
|
||||||
getSecondarySubMode: () => unknown;
|
getSecondarySubMode: () => unknown;
|
||||||
getCurrentSecondarySub: () => string;
|
getCurrentSecondarySub: () => string;
|
||||||
focusMainWindow: () => void;
|
focusMainWindow: () => void;
|
||||||
@@ -108,6 +113,8 @@ export interface IpcDepsRuntimeOptions {
|
|||||||
handleMpvCommand: (command: Array<string | number>) => void;
|
handleMpvCommand: (command: Array<string | number>) => void;
|
||||||
getKeybindings: () => unknown;
|
getKeybindings: () => unknown;
|
||||||
getConfiguredShortcuts: () => unknown;
|
getConfiguredShortcuts: () => unknown;
|
||||||
|
getControllerConfig: () => ResolvedControllerConfig;
|
||||||
|
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
|
||||||
getSecondarySubMode: () => unknown;
|
getSecondarySubMode: () => unknown;
|
||||||
getMpvClient: () => MpvClientLike | null;
|
getMpvClient: () => MpvClientLike | null;
|
||||||
focusMainWindow: () => void;
|
focusMainWindow: () => void;
|
||||||
@@ -159,6 +166,8 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
|||||||
handleMpvCommand: options.handleMpvCommand,
|
handleMpvCommand: options.handleMpvCommand,
|
||||||
getKeybindings: options.getKeybindings,
|
getKeybindings: options.getKeybindings,
|
||||||
getConfiguredShortcuts: options.getConfiguredShortcuts,
|
getConfiguredShortcuts: options.getConfiguredShortcuts,
|
||||||
|
getControllerConfig: options.getControllerConfig,
|
||||||
|
saveControllerPreference: options.saveControllerPreference,
|
||||||
getSecondarySubMode: options.getSecondarySubMode,
|
getSecondarySubMode: options.getSecondarySubMode,
|
||||||
getCurrentSecondarySub: () => options.getMpvClient()?.currentSecondarySubText || '',
|
getCurrentSecondarySub: () => options.getMpvClient()?.currentSecondarySubText || '',
|
||||||
focusMainWindow: () => {
|
focusMainWindow: () => {
|
||||||
@@ -256,6 +265,14 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
deps.saveSubtitlePosition(parsedPosition);
|
deps.saveSubtitlePosition(parsedPosition);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipc.handle(IPC_CHANNELS.command.saveControllerPreference, async (_event: unknown, update: unknown) => {
|
||||||
|
const parsedUpdate = parseControllerPreferenceUpdate(update);
|
||||||
|
if (!parsedUpdate) {
|
||||||
|
throw new Error('Invalid controller preference payload');
|
||||||
|
}
|
||||||
|
await deps.saveControllerPreference(parsedUpdate);
|
||||||
|
});
|
||||||
|
|
||||||
ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => {
|
ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => {
|
||||||
return deps.getMecabStatus();
|
return deps.getMecabStatus();
|
||||||
});
|
});
|
||||||
@@ -279,6 +296,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
return deps.getConfiguredShortcuts();
|
return deps.getConfiguredShortcuts();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipc.handle(IPC_CHANNELS.request.getControllerConfig, () => {
|
||||||
|
return deps.getControllerConfig();
|
||||||
|
});
|
||||||
|
|
||||||
ipc.handle(IPC_CHANNELS.request.getSecondarySubMode, () => {
|
ipc.handle(IPC_CHANNELS.request.getSecondarySubMode, () => {
|
||||||
return deps.getSecondarySubMode();
|
return deps.getSecondarySubMode();
|
||||||
});
|
});
|
||||||
|
|||||||
12
src/main.ts
12
src/main.ts
@@ -359,7 +359,8 @@ import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command';
|
|||||||
import { registerIpcRuntimeServices } from './main/ipc-runtime';
|
import { registerIpcRuntimeServices } from './main/ipc-runtime';
|
||||||
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
|
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
|
||||||
import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
|
import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
|
||||||
import { createOverlayModalRuntimeService, type OverlayHostedModal } from './main/overlay-runtime';
|
import { createOverlayModalRuntimeService } from './main/overlay-runtime';
|
||||||
|
import type { OverlayHostedModal } from './shared/ipc/contracts';
|
||||||
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
|
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
|
||||||
import {
|
import {
|
||||||
createFrequencyDictionaryRuntimeService,
|
createFrequencyDictionaryRuntimeService,
|
||||||
@@ -3454,6 +3455,15 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
getMecabTokenizer: () => appState.mecabTokenizer,
|
getMecabTokenizer: () => appState.mecabTokenizer,
|
||||||
getKeybindings: () => appState.keybindings,
|
getKeybindings: () => appState.keybindings,
|
||||||
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
||||||
|
getControllerConfig: () => getResolvedConfig().controller,
|
||||||
|
saveControllerPreference: ({ preferredGamepadId, preferredGamepadLabel }) => {
|
||||||
|
configService.patchRawConfig({
|
||||||
|
controller: {
|
||||||
|
preferredGamepadId,
|
||||||
|
preferredGamepadLabel,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
getSecondarySubMode: () => appState.secondarySubMode,
|
getSecondarySubMode: () => appState.secondarySubMode,
|
||||||
getMpvClient: () => appState.mpvClient,
|
getMpvClient: () => appState.mpvClient,
|
||||||
getAnkiConnectStatus: () => appState.ankiIntegration !== null,
|
getAnkiConnectStatus: () => appState.ankiIntegration !== null,
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ export interface MainIpcRuntimeServiceDepsParams {
|
|||||||
handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand'];
|
handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand'];
|
||||||
getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
|
getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
|
||||||
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
|
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
|
||||||
|
getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig'];
|
||||||
|
saveControllerPreference: IpcDepsRuntimeOptions['saveControllerPreference'];
|
||||||
getSecondarySubMode: IpcDepsRuntimeOptions['getSecondarySubMode'];
|
getSecondarySubMode: IpcDepsRuntimeOptions['getSecondarySubMode'];
|
||||||
getMpvClient: IpcDepsRuntimeOptions['getMpvClient'];
|
getMpvClient: IpcDepsRuntimeOptions['getMpvClient'];
|
||||||
runSubsyncManual: IpcDepsRuntimeOptions['runSubsyncManual'];
|
runSubsyncManual: IpcDepsRuntimeOptions['runSubsyncManual'];
|
||||||
@@ -213,6 +215,8 @@ export function createMainIpcRuntimeServiceDeps(
|
|||||||
handleMpvCommand: params.handleMpvCommand,
|
handleMpvCommand: params.handleMpvCommand,
|
||||||
getKeybindings: params.getKeybindings,
|
getKeybindings: params.getKeybindings,
|
||||||
getConfiguredShortcuts: params.getConfiguredShortcuts,
|
getConfiguredShortcuts: params.getConfiguredShortcuts,
|
||||||
|
getControllerConfig: params.getControllerConfig,
|
||||||
|
saveControllerPreference: params.saveControllerPreference,
|
||||||
focusMainWindow: params.focusMainWindow ?? (() => {}),
|
focusMainWindow: params.focusMainWindow ?? (() => {}),
|
||||||
getSecondarySubMode: params.getSecondarySubMode,
|
getSecondarySubMode: params.getSecondarySubMode,
|
||||||
getMpvClient: params.getMpvClient,
|
getMpvClient: params.getMpvClient,
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import type { BrowserWindow } from 'electron';
|
import type { BrowserWindow } from 'electron';
|
||||||
|
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||||
import type { WindowGeometry } from '../types';
|
import type { WindowGeometry } from '../types';
|
||||||
|
|
||||||
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
|
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
|
||||||
|
|
||||||
type OverlayHostedModal = 'runtime-options' | 'subsync' | 'jimaku' | 'kiku';
|
|
||||||
|
|
||||||
export interface OverlayWindowResolver {
|
export interface OverlayWindowResolver {
|
||||||
getMainWindow: () => BrowserWindow | null;
|
getMainWindow: () => BrowserWindow | null;
|
||||||
getModalWindow: () => BrowserWindow | null;
|
getModalWindow: () => BrowserWindow | null;
|
||||||
@@ -294,5 +293,3 @@ export function createOverlayModalRuntimeService(
|
|||||||
getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose,
|
getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { OverlayHostedModal };
|
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
|||||||
getMecabTokenizer: () => null,
|
getMecabTokenizer: () => null,
|
||||||
getKeybindings: () => [],
|
getKeybindings: () => [],
|
||||||
getConfiguredShortcuts: () => ({}) as never,
|
getConfiguredShortcuts: () => ({}) as never,
|
||||||
|
getControllerConfig: () => ({}) as never,
|
||||||
|
saveControllerPreference: () => {},
|
||||||
getSecondarySubMode: () => 'hover' as never,
|
getSecondarySubMode: () => 'hover' as never,
|
||||||
getMpvClient: () => null,
|
getMpvClient: () => null,
|
||||||
getAnkiConnectStatus: () => false,
|
getAnkiConnectStatus: () => false,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { OverlayHostedModal } from '../overlay-runtime';
|
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||||
import type { AppendClipboardVideoToQueueRuntimeDeps } from './clipboard-queue';
|
import type { AppendClipboardVideoToQueueRuntimeDeps } from './clipboard-queue';
|
||||||
|
|
||||||
export function createSetOverlayVisibleHandler(deps: {
|
export function createSetOverlayVisibleHandler(deps: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { RuntimeOptionState } from '../../types';
|
import type { RuntimeOptionState } from '../../types';
|
||||||
import type { OverlayHostedModal } from '../overlay-runtime';
|
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||||
|
|
||||||
type RuntimeOptionsManagerLike = {
|
type RuntimeOptionsManagerLike = {
|
||||||
listOptions: () => RuntimeOptionState[];
|
listOptions: () => RuntimeOptionState[];
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ import type {
|
|||||||
OverlayContentMeasurement,
|
OverlayContentMeasurement,
|
||||||
ShortcutsConfig,
|
ShortcutsConfig,
|
||||||
ConfigHotReloadPayload,
|
ConfigHotReloadPayload,
|
||||||
|
ControllerPreferenceUpdate,
|
||||||
|
ResolvedControllerConfig,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
||||||
|
|
||||||
@@ -205,6 +207,10 @@ const electronAPI: ElectronAPI = {
|
|||||||
ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings),
|
ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings),
|
||||||
getConfiguredShortcuts: (): Promise<Required<ShortcutsConfig>> =>
|
getConfiguredShortcuts: (): Promise<Required<ShortcutsConfig>> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts),
|
ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts),
|
||||||
|
getControllerConfig: (): Promise<ResolvedControllerConfig> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.request.getControllerConfig),
|
||||||
|
saveControllerPreference: (update: ControllerPreferenceUpdate): Promise<void> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.command.saveControllerPreference, update),
|
||||||
|
|
||||||
getJimakuMediaInfo: (): Promise<JimakuMediaInfo> =>
|
getJimakuMediaInfo: (): Promise<JimakuMediaInfo> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.jimakuGetMediaInfo),
|
ipcRenderer.invoke(IPC_CHANNELS.request.jimakuGetMediaInfo),
|
||||||
@@ -292,10 +298,10 @@ const electronAPI: ElectronAPI = {
|
|||||||
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
|
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
|
||||||
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
|
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue),
|
ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue),
|
||||||
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => {
|
notifyOverlayModalClosed: (modal) => {
|
||||||
ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal);
|
ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal);
|
||||||
},
|
},
|
||||||
notifyOverlayModalOpened: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => {
|
notifyOverlayModalOpened: (modal) => {
|
||||||
ipcRenderer.send(IPC_CHANNELS.command.overlayModalOpened, modal);
|
ipcRenderer.send(IPC_CHANNELS.command.overlayModalOpened, modal);
|
||||||
},
|
},
|
||||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {
|
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {
|
||||||
|
|||||||
107
src/renderer/controller-status-indicator.test.ts
Normal file
107
src/renderer/controller-status-indicator.test.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { createControllerStatusIndicator } from './controller-status-indicator.js';
|
||||||
|
|
||||||
|
function createClassList(initialTokens: string[] = []) {
|
||||||
|
const tokens = new Set(initialTokens);
|
||||||
|
return {
|
||||||
|
add: (...entries: string[]) => {
|
||||||
|
for (const entry of entries) tokens.add(entry);
|
||||||
|
},
|
||||||
|
remove: (...entries: string[]) => {
|
||||||
|
for (const entry of entries) tokens.delete(entry);
|
||||||
|
},
|
||||||
|
contains: (entry: string) => tokens.has(entry),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('controller status indicator shows once when a controller is first detected and auto-hides', () => {
|
||||||
|
let nextTimerId = 1;
|
||||||
|
const scheduled = new Map<number, () => void>();
|
||||||
|
const classList = createClassList(['hidden']);
|
||||||
|
const toast = {
|
||||||
|
textContent: '',
|
||||||
|
classList,
|
||||||
|
};
|
||||||
|
|
||||||
|
const indicator = createControllerStatusIndicator(
|
||||||
|
{ controllerStatusToast: toast } as never,
|
||||||
|
{
|
||||||
|
durationMs: 1500,
|
||||||
|
setTimeout: (callback: () => void) => {
|
||||||
|
const id = nextTimerId++;
|
||||||
|
scheduled.set(id, callback);
|
||||||
|
return id as never;
|
||||||
|
},
|
||||||
|
clearTimeout: (id) => {
|
||||||
|
scheduled.delete(id as never as number);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
indicator.update({
|
||||||
|
connectedGamepads: [],
|
||||||
|
activeGamepadId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(classList.contains('hidden'), true);
|
||||||
|
assert.equal(toast.textContent, '');
|
||||||
|
|
||||||
|
indicator.update({
|
||||||
|
connectedGamepads: [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }],
|
||||||
|
activeGamepadId: 'pad-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(classList.contains('hidden'), false);
|
||||||
|
assert.match(toast.textContent, /controller detected/i);
|
||||||
|
assert.match(toast.textContent, /pad-1/i);
|
||||||
|
assert.equal(scheduled.size, 1);
|
||||||
|
|
||||||
|
indicator.update({
|
||||||
|
connectedGamepads: [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }],
|
||||||
|
activeGamepadId: 'pad-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(scheduled.size, 1);
|
||||||
|
|
||||||
|
const [hide] = scheduled.values();
|
||||||
|
hide?.();
|
||||||
|
|
||||||
|
assert.equal(classList.contains('hidden'), true);
|
||||||
|
assert.equal(toast.textContent, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('controller status indicator announces newly detected controllers after startup', () => {
|
||||||
|
const toast = {
|
||||||
|
textContent: '',
|
||||||
|
classList: createClassList(['hidden']),
|
||||||
|
};
|
||||||
|
|
||||||
|
const indicator = createControllerStatusIndicator(
|
||||||
|
{ controllerStatusToast: toast } as never,
|
||||||
|
{
|
||||||
|
setTimeout: () => 1 as never,
|
||||||
|
clearTimeout: () => {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
indicator.update({
|
||||||
|
connectedGamepads: [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }],
|
||||||
|
activeGamepadId: 'pad-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.classList.add('hidden');
|
||||||
|
toast.textContent = '';
|
||||||
|
|
||||||
|
indicator.update({
|
||||||
|
connectedGamepads: [
|
||||||
|
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
|
||||||
|
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
|
||||||
|
],
|
||||||
|
activeGamepadId: 'pad-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(toast.classList.contains('hidden'), false);
|
||||||
|
assert.match(toast.textContent, /pad-2/i);
|
||||||
|
});
|
||||||
69
src/renderer/controller-status-indicator.ts
Normal file
69
src/renderer/controller-status-indicator.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import type { ControllerDeviceInfo } from '../types';
|
||||||
|
|
||||||
|
type ControllerSnapshot = {
|
||||||
|
connectedGamepads: ControllerDeviceInfo[];
|
||||||
|
activeGamepadId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ControllerStatusIndicatorOptions = {
|
||||||
|
durationMs?: number;
|
||||||
|
setTimeout?: (callback: () => void, delay: number) => ReturnType<typeof setTimeout>;
|
||||||
|
clearTimeout?: (timer: ReturnType<typeof setTimeout> | number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getDeviceLabel(device: ControllerDeviceInfo | undefined): string {
|
||||||
|
if (!device) return 'Controller';
|
||||||
|
return device.id || `Gamepad ${device.index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createControllerStatusIndicator(
|
||||||
|
dom: {
|
||||||
|
controllerStatusToast: {
|
||||||
|
textContent: string;
|
||||||
|
classList: { add: (...entries: string[]) => void; remove: (...entries: string[]) => void };
|
||||||
|
};
|
||||||
|
},
|
||||||
|
options: ControllerStatusIndicatorOptions = {},
|
||||||
|
) {
|
||||||
|
const durationMs = options.durationMs ?? 2200;
|
||||||
|
const scheduleTimeout = options.setTimeout ?? globalThis.setTimeout;
|
||||||
|
const cancelTimeout =
|
||||||
|
options.clearTimeout ??
|
||||||
|
((timer: ReturnType<typeof setTimeout> | number) =>
|
||||||
|
globalThis.clearTimeout(timer as ReturnType<typeof setTimeout>));
|
||||||
|
let hideTimeout: ReturnType<typeof setTimeout> | number | null = null;
|
||||||
|
let previousConnectedIds = new Set<string>();
|
||||||
|
|
||||||
|
function show(message: string): void {
|
||||||
|
if (hideTimeout !== null) {
|
||||||
|
cancelTimeout(hideTimeout);
|
||||||
|
hideTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
dom.controllerStatusToast.textContent = message;
|
||||||
|
dom.controllerStatusToast.classList.remove('hidden');
|
||||||
|
hideTimeout = scheduleTimeout(() => {
|
||||||
|
dom.controllerStatusToast.classList.add('hidden');
|
||||||
|
dom.controllerStatusToast.textContent = '';
|
||||||
|
hideTimeout = null;
|
||||||
|
}, durationMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(snapshot: ControllerSnapshot): void {
|
||||||
|
const newDevices = snapshot.connectedGamepads.filter(
|
||||||
|
(device) => !previousConnectedIds.has(device.id),
|
||||||
|
);
|
||||||
|
if (newDevices.length > 0) {
|
||||||
|
const activeDevice = snapshot.connectedGamepads.find(
|
||||||
|
(device) => device.id === snapshot.activeGamepadId,
|
||||||
|
);
|
||||||
|
const announcedDevice =
|
||||||
|
newDevices.find((device) => device.id === snapshot.activeGamepadId) ?? newDevices[0] ?? activeDevice;
|
||||||
|
show(`Controller detected: ${getDeviceLabel(announcedDevice)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
previousConnectedIds = new Set(snapshot.connectedGamepads.map((device) => device.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { update };
|
||||||
|
}
|
||||||
645
src/renderer/handlers/gamepad-controller.test.ts
Normal file
645
src/renderer/handlers/gamepad-controller.test.ts
Normal file
@@ -0,0 +1,645 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import type { ResolvedControllerConfig } from '../../types';
|
||||||
|
import { createGamepadController } from './gamepad-controller.js';
|
||||||
|
|
||||||
|
type TestGamepad = {
|
||||||
|
id: string;
|
||||||
|
index: number;
|
||||||
|
connected: boolean;
|
||||||
|
mapping: string;
|
||||||
|
axes: number[];
|
||||||
|
buttons: Array<{ value: number; pressed?: boolean; touched?: boolean }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createGamepad(
|
||||||
|
id: string,
|
||||||
|
options: Partial<Pick<TestGamepad, 'index' | 'axes' | 'buttons'>> = {},
|
||||||
|
): TestGamepad {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
index: options.index ?? 0,
|
||||||
|
connected: true,
|
||||||
|
mapping: 'standard',
|
||||||
|
axes: options.axes ?? [0, 0, 0, 0],
|
||||||
|
buttons:
|
||||||
|
options.buttons ??
|
||||||
|
Array.from({ length: 16 }, () => ({
|
||||||
|
value: 0,
|
||||||
|
pressed: false,
|
||||||
|
touched: false,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createControllerConfig(
|
||||||
|
overrides: Omit<Partial<ResolvedControllerConfig>, 'bindings' | 'buttonIndices'> & {
|
||||||
|
bindings?: Partial<ResolvedControllerConfig['bindings']>;
|
||||||
|
buttonIndices?: Partial<ResolvedControllerConfig['buttonIndices']>;
|
||||||
|
} = {},
|
||||||
|
): ResolvedControllerConfig {
|
||||||
|
const { bindings: bindingOverrides, buttonIndices: buttonIndexOverrides, ...restOverrides } =
|
||||||
|
overrides;
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
preferredGamepadId: '',
|
||||||
|
preferredGamepadLabel: '',
|
||||||
|
smoothScroll: true,
|
||||||
|
scrollPixelsPerSecond: 900,
|
||||||
|
horizontalJumpPixels: 160,
|
||||||
|
stickDeadzone: 0.2,
|
||||||
|
triggerInputMode: 'auto',
|
||||||
|
triggerDeadzone: 0.5,
|
||||||
|
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,
|
||||||
|
...(buttonIndexOverrides ?? {}),
|
||||||
|
},
|
||||||
|
bindings: {
|
||||||
|
toggleLookup: 'buttonSouth',
|
||||||
|
closeLookup: 'buttonEast',
|
||||||
|
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||||
|
mineCard: 'buttonWest',
|
||||||
|
quitMpv: 'select',
|
||||||
|
previousAudio: 'none',
|
||||||
|
nextAudio: 'rightShoulder',
|
||||||
|
playCurrentAudio: 'leftShoulder',
|
||||||
|
toggleMpvPause: 'leftStickPress',
|
||||||
|
leftStickHorizontal: 'leftStickX',
|
||||||
|
leftStickVertical: 'leftStickY',
|
||||||
|
rightStickHorizontal: 'rightStickX',
|
||||||
|
rightStickVertical: 'rightStickY',
|
||||||
|
...(bindingOverrides ?? {}),
|
||||||
|
},
|
||||||
|
...restOverrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('gamepad controller selects the first connected controller by default', () => {
|
||||||
|
const updates: string[] = [];
|
||||||
|
const controller = createGamepadController({
|
||||||
|
getGamepads: () => [null, createGamepad('pad-2', { index: 1 }), createGamepad('pad-3', { index: 2 })],
|
||||||
|
getConfig: () => createControllerConfig(),
|
||||||
|
getKeyboardModeEnabled: () => false,
|
||||||
|
getLookupWindowOpen: () => false,
|
||||||
|
getInteractionBlocked: () => false,
|
||||||
|
toggleKeyboardMode: () => {},
|
||||||
|
toggleLookup: () => {},
|
||||||
|
closeLookup: () => {},
|
||||||
|
moveSelection: () => {},
|
||||||
|
mineCard: () => {},
|
||||||
|
quitMpv: () => {},
|
||||||
|
previousAudio: () => {},
|
||||||
|
nextAudio: () => {},
|
||||||
|
playCurrentAudio: () => {},
|
||||||
|
toggleMpvPause: () => {},
|
||||||
|
scrollPopup: () => {},
|
||||||
|
jumpPopup: () => {},
|
||||||
|
onState: (state) => {
|
||||||
|
updates.push(state.activeGamepadId ?? 'none');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.poll(0);
|
||||||
|
|
||||||
|
assert.equal(controller.getActiveGamepadId(), 'pad-2');
|
||||||
|
assert.deepEqual(updates.at(-1), 'pad-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gamepad controller prefers saved controller id when connected', () => {
|
||||||
|
const controller = createGamepadController({
|
||||||
|
getGamepads: () => [createGamepad('pad-1'), createGamepad('pad-2', { index: 1 })],
|
||||||
|
getConfig: () => createControllerConfig({ preferredGamepadId: 'pad-2' }),
|
||||||
|
getKeyboardModeEnabled: () => false,
|
||||||
|
getLookupWindowOpen: () => false,
|
||||||
|
getInteractionBlocked: () => false,
|
||||||
|
toggleKeyboardMode: () => {},
|
||||||
|
toggleLookup: () => {},
|
||||||
|
closeLookup: () => {},
|
||||||
|
moveSelection: () => {},
|
||||||
|
mineCard: () => {},
|
||||||
|
quitMpv: () => {},
|
||||||
|
previousAudio: () => {},
|
||||||
|
nextAudio: () => {},
|
||||||
|
playCurrentAudio: () => {},
|
||||||
|
toggleMpvPause: () => {},
|
||||||
|
scrollPopup: () => {},
|
||||||
|
jumpPopup: () => {},
|
||||||
|
onState: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.poll(0);
|
||||||
|
|
||||||
|
assert.equal(controller.getActiveGamepadId(), 'pad-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gamepad controller allows keyboard-mode toggle while other actions stay gated', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const buttons = Array.from({ length: 8 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||||
|
buttons[0] = { value: 1, pressed: true, touched: true };
|
||||||
|
buttons[3] = { value: 1, pressed: true, touched: true };
|
||||||
|
|
||||||
|
const controller = createGamepadController({
|
||||||
|
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||||
|
getConfig: () => createControllerConfig(),
|
||||||
|
getKeyboardModeEnabled: () => false,
|
||||||
|
getLookupWindowOpen: () => false,
|
||||||
|
getInteractionBlocked: () => false,
|
||||||
|
toggleKeyboardMode: () => calls.push('toggle-keyboard-mode'),
|
||||||
|
toggleLookup: () => calls.push('toggle-lookup'),
|
||||||
|
closeLookup: () => {},
|
||||||
|
moveSelection: () => {},
|
||||||
|
mineCard: () => {},
|
||||||
|
quitMpv: () => {},
|
||||||
|
previousAudio: () => {},
|
||||||
|
nextAudio: () => {},
|
||||||
|
playCurrentAudio: () => {},
|
||||||
|
toggleMpvPause: () => {},
|
||||||
|
scrollPopup: () => {},
|
||||||
|
jumpPopup: () => {},
|
||||||
|
onState: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.poll(0);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['toggle-keyboard-mode']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gamepad controller does not toggle keyboard mode when controller support is disabled', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const buttons = Array.from({ length: 8 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||||
|
buttons[3] = { value: 1, pressed: true, touched: true };
|
||||||
|
|
||||||
|
const controller = createGamepadController({
|
||||||
|
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||||
|
getConfig: () => createControllerConfig({ enabled: false }),
|
||||||
|
getKeyboardModeEnabled: () => false,
|
||||||
|
getLookupWindowOpen: () => false,
|
||||||
|
getInteractionBlocked: () => false,
|
||||||
|
toggleKeyboardMode: () => calls.push('toggle-keyboard-mode'),
|
||||||
|
toggleLookup: () => {},
|
||||||
|
closeLookup: () => {},
|
||||||
|
moveSelection: () => {},
|
||||||
|
mineCard: () => {},
|
||||||
|
quitMpv: () => {},
|
||||||
|
previousAudio: () => {},
|
||||||
|
nextAudio: () => {},
|
||||||
|
playCurrentAudio: () => {},
|
||||||
|
toggleMpvPause: () => {},
|
||||||
|
scrollPopup: () => {},
|
||||||
|
jumpPopup: () => {},
|
||||||
|
onState: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.poll(0);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gamepad controller does not treat blocked held inputs as fresh edges when interaction resumes', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const selectionCalls: number[] = [];
|
||||||
|
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||||
|
buttons[0] = { value: 1, pressed: true, touched: true };
|
||||||
|
let axes = [0.9, 0, 0, 0];
|
||||||
|
let keyboardModeEnabled = true;
|
||||||
|
let interactionBlocked = true;
|
||||||
|
|
||||||
|
const controller = createGamepadController({
|
||||||
|
getGamepads: () => [createGamepad('pad-1', { buttons, axes })],
|
||||||
|
getConfig: () => createControllerConfig(),
|
||||||
|
getKeyboardModeEnabled: () => keyboardModeEnabled,
|
||||||
|
getLookupWindowOpen: () => false,
|
||||||
|
getInteractionBlocked: () => interactionBlocked,
|
||||||
|
toggleKeyboardMode: () => {},
|
||||||
|
toggleLookup: () => calls.push('toggle-lookup'),
|
||||||
|
closeLookup: () => {},
|
||||||
|
moveSelection: (delta) => selectionCalls.push(delta),
|
||||||
|
mineCard: () => {},
|
||||||
|
quitMpv: () => {},
|
||||||
|
previousAudio: () => {},
|
||||||
|
nextAudio: () => {},
|
||||||
|
playCurrentAudio: () => {},
|
||||||
|
toggleMpvPause: () => {},
|
||||||
|
scrollPopup: () => {},
|
||||||
|
jumpPopup: () => {},
|
||||||
|
onState: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.poll(0);
|
||||||
|
interactionBlocked = false;
|
||||||
|
controller.poll(100);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
assert.deepEqual(selectionCalls, []);
|
||||||
|
|
||||||
|
buttons[0] = { value: 0, pressed: false, touched: false };
|
||||||
|
axes = [0, 0, 0, 0];
|
||||||
|
controller.poll(200);
|
||||||
|
|
||||||
|
buttons[0] = { value: 1, pressed: true, touched: true };
|
||||||
|
axes = [0.9, 0, 0, 0];
|
||||||
|
controller.poll(300);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['toggle-lookup']);
|
||||||
|
assert.deepEqual(selectionCalls, [1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gamepad controller maps left stick horizontal movement to token selection repeats', () => {
|
||||||
|
const calls: number[] = [];
|
||||||
|
let axes = [0.9, 0, 0, 0];
|
||||||
|
const controller = createGamepadController({
|
||||||
|
getGamepads: () => [createGamepad('pad-1', { axes })],
|
||||||
|
getConfig: () => createControllerConfig(),
|
||||||
|
getKeyboardModeEnabled: () => true,
|
||||||
|
getLookupWindowOpen: () => false,
|
||||||
|
getInteractionBlocked: () => false,
|
||||||
|
toggleKeyboardMode: () => {},
|
||||||
|
toggleLookup: () => {},
|
||||||
|
closeLookup: () => {},
|
||||||
|
moveSelection: (delta) => calls.push(delta),
|
||||||
|
mineCard: () => {},
|
||||||
|
quitMpv: () => {},
|
||||||
|
previousAudio: () => {},
|
||||||
|
nextAudio: () => {},
|
||||||
|
playCurrentAudio: () => {},
|
||||||
|
toggleMpvPause: () => {},
|
||||||
|
scrollPopup: () => {},
|
||||||
|
jumpPopup: () => {},
|
||||||
|
onState: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.poll(0);
|
||||||
|
controller.poll(100);
|
||||||
|
controller.poll(260);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [1]);
|
||||||
|
|
||||||
|
controller.poll(340);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [1, 1]);
|
||||||
|
|
||||||
|
axes = [0, 0, 0, 0];
|
||||||
|
controller.poll(360);
|
||||||
|
axes = [-0.9, 0, 0, 0];
|
||||||
|
controller.poll(380);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [1, 1, -1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gamepad controller maps L1 play-current, R1 next-audio, and popup navigation', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const scrollCalls: number[] = [];
|
||||||
|
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||||
|
buttons[8] = { value: 1, pressed: true, touched: true };
|
||||||
|
buttons[4] = { value: 1, pressed: true, touched: true };
|
||||||
|
buttons[5] = { value: 1, pressed: true, touched: true };
|
||||||
|
buttons[6] = { value: 0.8, pressed: true, touched: true };
|
||||||
|
buttons[7] = { value: 0.9, pressed: true, touched: true };
|
||||||
|
|
||||||
|
const controller = createGamepadController({
|
||||||
|
getGamepads: () =>
|
||||||
|
[
|
||||||
|
createGamepad('pad-1', {
|
||||||
|
axes: [0, -0.75, 0.1, 0, 0.8],
|
||||||
|
buttons,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
getConfig: () =>
|
||||||
|
createControllerConfig({
|
||||||
|
bindings: {
|
||||||
|
playCurrentAudio: 'leftShoulder',
|
||||||
|
nextAudio: 'rightShoulder',
|
||||||
|
previousAudio: 'none',
|
||||||
|
toggleMpvPause: 'leftTrigger',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getKeyboardModeEnabled: () => true,
|
||||||
|
getLookupWindowOpen: () => true,
|
||||||
|
getInteractionBlocked: () => false,
|
||||||
|
toggleKeyboardMode: () => {},
|
||||||
|
toggleLookup: () => {},
|
||||||
|
closeLookup: () => {},
|
||||||
|
moveSelection: () => {},
|
||||||
|
mineCard: () => {},
|
||||||
|
quitMpv: () => calls.push('quit-mpv'),
|
||||||
|
previousAudio: () => calls.push('prev-audio'),
|
||||||
|
nextAudio: () => calls.push('next-audio'),
|
||||||
|
playCurrentAudio: () => calls.push('play-audio'),
|
||||||
|
toggleMpvPause: () => calls.push('toggle-mpv-pause'),
|
||||||
|
scrollPopup: (delta) => scrollCalls.push(delta),
|
||||||
|
jumpPopup: (delta) => calls.push(`jump:${delta}`),
|
||||||
|
onState: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.poll(0);
|
||||||
|
controller.poll(100);
|
||||||
|
|
||||||
|
assert.equal(calls.includes('next-audio'), true);
|
||||||
|
assert.equal(calls.includes('play-audio'), true);
|
||||||
|
assert.equal(calls.includes('prev-audio'), false);
|
||||||
|
assert.equal(calls.includes('toggle-mpv-pause'), true);
|
||||||
|
assert.equal(calls.includes('quit-mpv'), true);
|
||||||
|
assert.deepEqual(scrollCalls.map((value) => Math.round(value)), [-67]);
|
||||||
|
assert.equal(calls.includes('jump:160'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gamepad controller maps quit mpv select binding from raw button 6 by default', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||||
|
buttons[6] = { value: 1, pressed: true, touched: true };
|
||||||
|
|
||||||
|
const controller = createGamepadController({
|
||||||
|
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||||
|
getConfig: () => createControllerConfig({ bindings: { quitMpv: 'select' } }),
|
||||||
|
getKeyboardModeEnabled: () => true,
|
||||||
|
getLookupWindowOpen: () => false,
|
||||||
|
getInteractionBlocked: () => false,
|
||||||
|
toggleKeyboardMode: () => {},
|
||||||
|
toggleLookup: () => {},
|
||||||
|
closeLookup: () => {},
|
||||||
|
moveSelection: () => {},
|
||||||
|
mineCard: () => {},
|
||||||
|
quitMpv: () => calls.push('quit-mpv'),
|
||||||
|
previousAudio: () => {},
|
||||||
|
nextAudio: () => {},
|
||||||
|
playCurrentAudio: () => {},
|
||||||
|
toggleMpvPause: () => {},
|
||||||
|
scrollPopup: () => {},
|
||||||
|
jumpPopup: () => {},
|
||||||
|
onState: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.poll(0);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['quit-mpv']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gamepad controller honors configured raw button index overrides', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||||
|
buttons[11] = { value: 1, pressed: true, touched: true };
|
||||||
|
|
||||||
|
const controller = createGamepadController({
|
||||||
|
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||||
|
getConfig: () =>
|
||||||
|
createControllerConfig({
|
||||||
|
buttonIndices: {
|
||||||
|
select: 11,
|
||||||
|
},
|
||||||
|
bindings: { quitMpv: 'select' },
|
||||||
|
}),
|
||||||
|
getKeyboardModeEnabled: () => true,
|
||||||
|
getLookupWindowOpen: () => false,
|
||||||
|
getInteractionBlocked: () => false,
|
||||||
|
toggleKeyboardMode: () => {},
|
||||||
|
toggleLookup: () => {},
|
||||||
|
closeLookup: () => {},
|
||||||
|
moveSelection: () => {},
|
||||||
|
mineCard: () => {},
|
||||||
|
quitMpv: () => calls.push('quit-mpv'),
|
||||||
|
previousAudio: () => {},
|
||||||
|
nextAudio: () => {},
|
||||||
|
playCurrentAudio: () => {},
|
||||||
|
toggleMpvPause: () => {},
|
||||||
|
scrollPopup: () => {},
|
||||||
|
jumpPopup: () => {},
|
||||||
|
onState: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.poll(0);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['quit-mpv']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gamepad controller maps right stick vertical to popup jump and ignores horizontal movement', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
let axes = [0, 0, 0.85, 0, 0];
|
||||||
|
|
||||||
|
const controller = createGamepadController({
|
||||||
|
getGamepads: () => [createGamepad('pad-1', { axes })],
|
||||||
|
getConfig: () => createControllerConfig(),
|
||||||
|
getKeyboardModeEnabled: () => true,
|
||||||
|
getLookupWindowOpen: () => true,
|
||||||
|
getInteractionBlocked: () => false,
|
||||||
|
toggleKeyboardMode: () => {},
|
||||||
|
toggleLookup: () => {},
|
||||||
|
closeLookup: () => {},
|
||||||
|
moveSelection: () => {},
|
||||||
|
mineCard: () => {},
|
||||||
|
quitMpv: () => {},
|
||||||
|
previousAudio: () => {},
|
||||||
|
nextAudio: () => {},
|
||||||
|
playCurrentAudio: () => {},
|
||||||
|
toggleMpvPause: () => {},
|
||||||
|
scrollPopup: () => {},
|
||||||
|
jumpPopup: (delta) => calls.push(`jump:${delta}`),
|
||||||
|
onState: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.poll(0);
|
||||||
|
controller.poll(100);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
|
||||||
|
axes = [0, 0, 0.85, 0, -0.85];
|
||||||
|
controller.poll(200);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['jump:-160']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gamepad controller maps d-pad left/right to selection and d-pad up/down to popup scroll', () => {
|
||||||
|
const selectionCalls: number[] = [];
|
||||||
|
const scrollCalls: number[] = [];
|
||||||
|
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||||
|
buttons[15] = { value: 1, pressed: false, touched: true };
|
||||||
|
buttons[12] = { value: 1, pressed: false, touched: true };
|
||||||
|
|
||||||
|
const controller = createGamepadController({
|
||||||
|
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||||
|
getConfig: () => createControllerConfig(),
|
||||||
|
getKeyboardModeEnabled: () => true,
|
||||||
|
getLookupWindowOpen: () => true,
|
||||||
|
getInteractionBlocked: () => false,
|
||||||
|
toggleKeyboardMode: () => {},
|
||||||
|
toggleLookup: () => {},
|
||||||
|
closeLookup: () => {},
|
||||||
|
moveSelection: (delta) => selectionCalls.push(delta),
|
||||||
|
mineCard: () => {},
|
||||||
|
quitMpv: () => {},
|
||||||
|
previousAudio: () => {},
|
||||||
|
nextAudio: () => {},
|
||||||
|
playCurrentAudio: () => {},
|
||||||
|
toggleMpvPause: () => {},
|
||||||
|
scrollPopup: (delta) => scrollCalls.push(delta),
|
||||||
|
jumpPopup: () => {},
|
||||||
|
onState: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.poll(0);
|
||||||
|
controller.poll(100);
|
||||||
|
|
||||||
|
assert.deepEqual(selectionCalls, [1]);
|
||||||
|
assert.deepEqual(scrollCalls.map((value) => Math.round(value)), [-90]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gamepad controller maps d-pad axes 6 and 7 to selection and popup scroll', () => {
|
||||||
|
const selectionCalls: number[] = [];
|
||||||
|
const scrollCalls: number[] = [];
|
||||||
|
|
||||||
|
const controller = createGamepadController({
|
||||||
|
getGamepads: () => [createGamepad('pad-1', { axes: [0, 0, 0, 0, 0, 0, 1, -1] })],
|
||||||
|
getConfig: () => createControllerConfig(),
|
||||||
|
getKeyboardModeEnabled: () => true,
|
||||||
|
getLookupWindowOpen: () => true,
|
||||||
|
getInteractionBlocked: () => false,
|
||||||
|
toggleKeyboardMode: () => {},
|
||||||
|
toggleLookup: () => {},
|
||||||
|
closeLookup: () => {},
|
||||||
|
moveSelection: (delta) => selectionCalls.push(delta),
|
||||||
|
mineCard: () => {},
|
||||||
|
quitMpv: () => {},
|
||||||
|
previousAudio: () => {},
|
||||||
|
nextAudio: () => {},
|
||||||
|
playCurrentAudio: () => {},
|
||||||
|
toggleMpvPause: () => {},
|
||||||
|
scrollPopup: (delta) => scrollCalls.push(delta),
|
||||||
|
jumpPopup: () => {},
|
||||||
|
onState: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.poll(0);
|
||||||
|
controller.poll(100);
|
||||||
|
|
||||||
|
assert.deepEqual(selectionCalls, [1]);
|
||||||
|
assert.deepEqual(scrollCalls.map((value) => Math.round(value)), [-90]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gamepad controller trigger analog mode uses trigger values above threshold', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||||
|
buttons[6] = { value: 0.7, pressed: false, touched: true };
|
||||||
|
buttons[7] = { value: 0.8, pressed: false, touched: true };
|
||||||
|
|
||||||
|
const controller = createGamepadController({
|
||||||
|
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||||
|
getConfig: () =>
|
||||||
|
createControllerConfig({
|
||||||
|
triggerInputMode: 'analog',
|
||||||
|
triggerDeadzone: 0.6,
|
||||||
|
bindings: {
|
||||||
|
playCurrentAudio: 'rightTrigger',
|
||||||
|
toggleMpvPause: 'leftTrigger',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getKeyboardModeEnabled: () => true,
|
||||||
|
getLookupWindowOpen: () => true,
|
||||||
|
getInteractionBlocked: () => false,
|
||||||
|
toggleKeyboardMode: () => {},
|
||||||
|
toggleLookup: () => {},
|
||||||
|
closeLookup: () => {},
|
||||||
|
moveSelection: () => {},
|
||||||
|
mineCard: () => {},
|
||||||
|
quitMpv: () => {},
|
||||||
|
previousAudio: () => {},
|
||||||
|
nextAudio: () => {},
|
||||||
|
playCurrentAudio: () => calls.push('play-audio'),
|
||||||
|
toggleMpvPause: () => calls.push('toggle-mpv-pause'),
|
||||||
|
scrollPopup: () => {},
|
||||||
|
jumpPopup: () => {},
|
||||||
|
onState: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.poll(0);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['play-audio', 'toggle-mpv-pause']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gamepad controller trigger digital mode uses pressed state only', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||||
|
buttons[6] = { value: 0.9, pressed: true, touched: true };
|
||||||
|
buttons[7] = { value: 0.9, pressed: true, touched: true };
|
||||||
|
|
||||||
|
const controller = createGamepadController({
|
||||||
|
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||||
|
getConfig: () =>
|
||||||
|
createControllerConfig({
|
||||||
|
triggerInputMode: 'digital',
|
||||||
|
triggerDeadzone: 1,
|
||||||
|
bindings: {
|
||||||
|
playCurrentAudio: 'rightTrigger',
|
||||||
|
toggleMpvPause: 'leftTrigger',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getKeyboardModeEnabled: () => true,
|
||||||
|
getLookupWindowOpen: () => true,
|
||||||
|
getInteractionBlocked: () => false,
|
||||||
|
toggleKeyboardMode: () => {},
|
||||||
|
toggleLookup: () => {},
|
||||||
|
closeLookup: () => {},
|
||||||
|
moveSelection: () => {},
|
||||||
|
mineCard: () => {},
|
||||||
|
quitMpv: () => {},
|
||||||
|
previousAudio: () => {},
|
||||||
|
nextAudio: () => {},
|
||||||
|
playCurrentAudio: () => calls.push('play-audio'),
|
||||||
|
toggleMpvPause: () => calls.push('toggle-mpv-pause'),
|
||||||
|
scrollPopup: () => {},
|
||||||
|
jumpPopup: () => {},
|
||||||
|
onState: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.poll(0);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['play-audio', 'toggle-mpv-pause']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gamepad controller maps L3 to mpv pause and keeps unbound audio action inactive', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||||
|
buttons[9] = { value: 1, pressed: true, touched: true };
|
||||||
|
|
||||||
|
const controller = createGamepadController({
|
||||||
|
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
||||||
|
getConfig: () =>
|
||||||
|
createControllerConfig({
|
||||||
|
bindings: {
|
||||||
|
toggleMpvPause: 'leftStickPress',
|
||||||
|
playCurrentAudio: 'none',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getKeyboardModeEnabled: () => true,
|
||||||
|
getLookupWindowOpen: () => true,
|
||||||
|
getInteractionBlocked: () => false,
|
||||||
|
toggleKeyboardMode: () => {},
|
||||||
|
toggleLookup: () => {},
|
||||||
|
closeLookup: () => {},
|
||||||
|
moveSelection: () => {},
|
||||||
|
mineCard: () => {},
|
||||||
|
quitMpv: () => {},
|
||||||
|
previousAudio: () => {},
|
||||||
|
nextAudio: () => {},
|
||||||
|
playCurrentAudio: () => calls.push('play-audio'),
|
||||||
|
toggleMpvPause: () => calls.push('toggle-mpv-pause'),
|
||||||
|
scrollPopup: () => {},
|
||||||
|
jumpPopup: () => {},
|
||||||
|
onState: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.poll(0);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['toggle-mpv-pause']);
|
||||||
|
});
|
||||||
571
src/renderer/handlers/gamepad-controller.ts
Normal file
571
src/renderer/handlers/gamepad-controller.ts
Normal file
@@ -0,0 +1,571 @@
|
|||||||
|
import type {
|
||||||
|
ControllerAxisBinding,
|
||||||
|
ControllerButtonBinding,
|
||||||
|
ControllerDeviceInfo,
|
||||||
|
ControllerRuntimeSnapshot,
|
||||||
|
ControllerTriggerInputMode,
|
||||||
|
ResolvedControllerConfig,
|
||||||
|
} from '../../types';
|
||||||
|
|
||||||
|
type ControllerButtonState = {
|
||||||
|
value: number;
|
||||||
|
pressed?: boolean;
|
||||||
|
touched?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GamepadLike = {
|
||||||
|
id: string;
|
||||||
|
index: number;
|
||||||
|
connected: boolean;
|
||||||
|
mapping: string;
|
||||||
|
axes: readonly number[];
|
||||||
|
buttons: readonly ControllerButtonState[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type GamepadControllerOptions = {
|
||||||
|
getGamepads: () => Array<GamepadLike | null>;
|
||||||
|
getConfig: () => ResolvedControllerConfig;
|
||||||
|
getKeyboardModeEnabled: () => boolean;
|
||||||
|
getLookupWindowOpen: () => boolean;
|
||||||
|
getInteractionBlocked: () => boolean;
|
||||||
|
toggleKeyboardMode: () => void;
|
||||||
|
toggleLookup: () => void;
|
||||||
|
closeLookup: () => void;
|
||||||
|
moveSelection: (delta: -1 | 1) => void;
|
||||||
|
mineCard: () => void;
|
||||||
|
quitMpv: () => void;
|
||||||
|
previousAudio: () => void;
|
||||||
|
nextAudio: () => void;
|
||||||
|
playCurrentAudio: () => void;
|
||||||
|
toggleMpvPause: () => void;
|
||||||
|
scrollPopup: (deltaPixels: number) => void;
|
||||||
|
jumpPopup: (deltaPixels: number) => void;
|
||||||
|
onState: (state: ControllerRuntimeSnapshot) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HoldState = {
|
||||||
|
repeatStarted: boolean;
|
||||||
|
direction: -1 | 1 | null;
|
||||||
|
lastFireAt: number;
|
||||||
|
initialFired: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_BUTTON_INDEX_BY_BINDING: Record<Exclude<ControllerButtonBinding, 'none'>, number> = {
|
||||||
|
select: 8,
|
||||||
|
buttonSouth: 0,
|
||||||
|
buttonEast: 1,
|
||||||
|
buttonWest: 2,
|
||||||
|
buttonNorth: 3,
|
||||||
|
leftShoulder: 4,
|
||||||
|
rightShoulder: 5,
|
||||||
|
leftStickPress: 9,
|
||||||
|
rightStickPress: 10,
|
||||||
|
leftTrigger: 6,
|
||||||
|
rightTrigger: 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
const AXIS_INDEX_BY_BINDING: Record<ControllerAxisBinding, number> = {
|
||||||
|
leftStickX: 0,
|
||||||
|
leftStickY: 1,
|
||||||
|
rightStickX: 3,
|
||||||
|
rightStickY: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DPAD_BUTTON_INDEX = {
|
||||||
|
up: 12,
|
||||||
|
down: 13,
|
||||||
|
left: 14,
|
||||||
|
right: 15,
|
||||||
|
} as const;
|
||||||
|
const DPAD_AXIS_INDEX = {
|
||||||
|
horizontal: 6,
|
||||||
|
vertical: 7,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function isTriggerBinding(binding: ControllerButtonBinding): boolean {
|
||||||
|
return binding === 'leftTrigger' || binding === 'rightTrigger';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveButtonIndex(
|
||||||
|
config: ResolvedControllerConfig,
|
||||||
|
binding: ControllerButtonBinding,
|
||||||
|
): number {
|
||||||
|
if (binding === 'none') {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return config.buttonIndices[binding] ?? DEFAULT_BUTTON_INDEX_BY_BINDING[binding];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeButtonState(
|
||||||
|
gamepad: GamepadLike,
|
||||||
|
config: ResolvedControllerConfig,
|
||||||
|
binding: ControllerButtonBinding,
|
||||||
|
triggerInputMode: ControllerTriggerInputMode,
|
||||||
|
triggerDeadzone: number,
|
||||||
|
): boolean {
|
||||||
|
if (binding === 'none') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const button = gamepad.buttons[resolveButtonIndex(config, binding)];
|
||||||
|
if (isTriggerBinding(binding)) {
|
||||||
|
return normalizeTriggerState(button, triggerInputMode, triggerDeadzone);
|
||||||
|
}
|
||||||
|
return normalizeRawButtonState(button, triggerDeadzone);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRawButtonState(
|
||||||
|
button: ControllerButtonState | undefined,
|
||||||
|
triggerDeadzone: number,
|
||||||
|
): boolean {
|
||||||
|
if (!button) return false;
|
||||||
|
return Boolean(button.pressed) || button.value >= triggerDeadzone;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTriggerState(
|
||||||
|
button: ControllerButtonState | undefined,
|
||||||
|
mode: ControllerTriggerInputMode,
|
||||||
|
triggerDeadzone: number,
|
||||||
|
): boolean {
|
||||||
|
if (!button) return false;
|
||||||
|
if (mode === 'digital') {
|
||||||
|
return Boolean(button.pressed);
|
||||||
|
}
|
||||||
|
if (mode === 'analog') {
|
||||||
|
return button.value >= triggerDeadzone;
|
||||||
|
}
|
||||||
|
return Boolean(button.pressed) || button.value >= triggerDeadzone;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAxisValue(gamepad: GamepadLike, binding: ControllerAxisBinding): number {
|
||||||
|
return gamepad.axes[AXIS_INDEX_BY_BINDING[binding]] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGamepadAxis(gamepad: GamepadLike, axisIndex: number): number {
|
||||||
|
const value = gamepad.axes[axisIndex];
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDpadValue(
|
||||||
|
gamepad: GamepadLike,
|
||||||
|
negativeIndex: number,
|
||||||
|
positiveIndex: number,
|
||||||
|
triggerDeadzone: number,
|
||||||
|
): number {
|
||||||
|
const negative = gamepad.buttons[negativeIndex];
|
||||||
|
const positive = gamepad.buttons[positiveIndex];
|
||||||
|
return (
|
||||||
|
(normalizeRawButtonState(positive, triggerDeadzone) ? 1 : 0) -
|
||||||
|
(normalizeRawButtonState(negative, triggerDeadzone) ? 1 : 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDpadAxisValue(
|
||||||
|
gamepad: GamepadLike,
|
||||||
|
axisIndex: number,
|
||||||
|
): number {
|
||||||
|
const value = resolveGamepadAxis(gamepad, axisIndex);
|
||||||
|
if (Math.abs(value) < 0.5) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.sign(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDpadHorizontalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
|
||||||
|
const axisValue = resolveDpadAxisValue(gamepad, DPAD_AXIS_INDEX.horizontal);
|
||||||
|
if (axisValue !== 0) {
|
||||||
|
return axisValue;
|
||||||
|
}
|
||||||
|
return resolveDpadValue(gamepad, DPAD_BUTTON_INDEX.left, DPAD_BUTTON_INDEX.right, triggerDeadzone);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDpadVerticalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
|
||||||
|
const axisValue = resolveDpadAxisValue(gamepad, DPAD_AXIS_INDEX.vertical);
|
||||||
|
if (axisValue !== 0) {
|
||||||
|
return axisValue;
|
||||||
|
}
|
||||||
|
return resolveDpadValue(gamepad, DPAD_BUTTON_INDEX.up, DPAD_BUTTON_INDEX.down, triggerDeadzone);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConnectedGamepads(gamepads: Array<GamepadLike | null>): GamepadLike[] {
|
||||||
|
return gamepads
|
||||||
|
.filter((gamepad): gamepad is GamepadLike => Boolean(gamepad?.connected))
|
||||||
|
.sort((left, right) => left.index - right.index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHoldState(): HoldState {
|
||||||
|
return {
|
||||||
|
repeatStarted: false,
|
||||||
|
direction: null,
|
||||||
|
lastFireAt: 0,
|
||||||
|
initialFired: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldFireHeldAction(state: HoldState, now: number, repeatDelayMs: number, repeatIntervalMs: number): boolean {
|
||||||
|
if (!state.initialFired) {
|
||||||
|
state.initialFired = true;
|
||||||
|
state.lastFireAt = now;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = now - state.lastFireAt;
|
||||||
|
const threshold = state.repeatStarted ? repeatIntervalMs : repeatDelayMs;
|
||||||
|
if (elapsed < threshold) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.repeatStarted = true;
|
||||||
|
state.lastFireAt = now;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetHeldAction(state: HoldState): void {
|
||||||
|
state.repeatStarted = false;
|
||||||
|
state.direction = null;
|
||||||
|
state.lastFireAt = 0;
|
||||||
|
state.initialFired = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncHeldActionBlocked(
|
||||||
|
state: HoldState,
|
||||||
|
value: number,
|
||||||
|
now: number,
|
||||||
|
activationThreshold: number,
|
||||||
|
): void {
|
||||||
|
if (Math.abs(value) < activationThreshold) {
|
||||||
|
resetHeldAction(state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const direction = value > 0 ? 1 : -1;
|
||||||
|
state.repeatStarted = false;
|
||||||
|
state.direction = direction;
|
||||||
|
state.lastFireAt = now;
|
||||||
|
state.initialFired = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGamepadController(options: GamepadControllerOptions) {
|
||||||
|
let previousButtons = new Map<ControllerButtonBinding, boolean>();
|
||||||
|
let selectionHold = createHoldState();
|
||||||
|
let jumpHold = createHoldState();
|
||||||
|
let activeGamepadId: string | null = null;
|
||||||
|
let lastPollAt: number | null = null;
|
||||||
|
|
||||||
|
function getConnectedGamepads(): GamepadLike[] {
|
||||||
|
return resolveConnectedGamepads(options.getGamepads());
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveActiveGamepad(
|
||||||
|
gamepads: GamepadLike[],
|
||||||
|
config: ResolvedControllerConfig,
|
||||||
|
): GamepadLike | null {
|
||||||
|
if (gamepads.length === 0) return null;
|
||||||
|
if (config.preferredGamepadId.trim().length > 0) {
|
||||||
|
const preferred = gamepads.find((gamepad) => gamepad.id === config.preferredGamepadId);
|
||||||
|
if (preferred) {
|
||||||
|
return preferred;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return gamepads[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishState(gamepads: GamepadLike[], activeGamepad: GamepadLike | null): void {
|
||||||
|
activeGamepadId = activeGamepad?.id ?? null;
|
||||||
|
options.onState({
|
||||||
|
connectedGamepads: gamepads.map((gamepad) => ({
|
||||||
|
id: gamepad.id,
|
||||||
|
index: gamepad.index,
|
||||||
|
mapping: gamepad.mapping,
|
||||||
|
connected: gamepad.connected,
|
||||||
|
})) satisfies ControllerDeviceInfo[],
|
||||||
|
activeGamepadId,
|
||||||
|
rawAxes: activeGamepad?.axes ? [...activeGamepad.axes] : [],
|
||||||
|
rawButtons: activeGamepad?.buttons
|
||||||
|
? activeGamepad.buttons.map((button) => ({
|
||||||
|
value: button.value,
|
||||||
|
pressed: Boolean(button.pressed),
|
||||||
|
touched: button.touched,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleButtonEdge(
|
||||||
|
binding: ControllerButtonBinding,
|
||||||
|
isPressed: boolean,
|
||||||
|
action: () => void,
|
||||||
|
): void {
|
||||||
|
if (binding === 'none') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wasPressed = previousButtons.get(binding) ?? false;
|
||||||
|
previousButtons.set(binding, isPressed);
|
||||||
|
if (!wasPressed && isPressed) {
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectionAxis(
|
||||||
|
value: number,
|
||||||
|
now: number,
|
||||||
|
config: ResolvedControllerConfig,
|
||||||
|
): void {
|
||||||
|
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
|
||||||
|
if (Math.abs(value) < activationThreshold) {
|
||||||
|
resetHeldAction(selectionHold);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const direction = value > 0 ? 1 : -1;
|
||||||
|
if (selectionHold.direction !== direction) {
|
||||||
|
resetHeldAction(selectionHold);
|
||||||
|
selectionHold.direction = direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldFireHeldAction(selectionHold, now, config.repeatDelayMs, config.repeatIntervalMs)) {
|
||||||
|
options.moveSelection(direction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleJumpAxis(
|
||||||
|
value: number,
|
||||||
|
now: number,
|
||||||
|
config: ResolvedControllerConfig,
|
||||||
|
): void {
|
||||||
|
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
|
||||||
|
if (Math.abs(value) < activationThreshold) {
|
||||||
|
resetHeldAction(jumpHold);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const direction = value > 0 ? 1 : -1;
|
||||||
|
if (jumpHold.direction !== direction) {
|
||||||
|
resetHeldAction(jumpHold);
|
||||||
|
jumpHold.direction = direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldFireHeldAction(jumpHold, now, config.repeatDelayMs, config.repeatIntervalMs)) {
|
||||||
|
options.jumpPopup(direction * config.horizontalJumpPixels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncBlockedInteractionState(
|
||||||
|
activeGamepad: GamepadLike,
|
||||||
|
config: ResolvedControllerConfig,
|
||||||
|
now: number,
|
||||||
|
): void {
|
||||||
|
const buttonBindings = new Set<ControllerButtonBinding>([
|
||||||
|
config.bindings.toggleKeyboardOnlyMode,
|
||||||
|
config.bindings.toggleLookup,
|
||||||
|
config.bindings.closeLookup,
|
||||||
|
config.bindings.mineCard,
|
||||||
|
config.bindings.quitMpv,
|
||||||
|
config.bindings.previousAudio,
|
||||||
|
config.bindings.nextAudio,
|
||||||
|
config.bindings.playCurrentAudio,
|
||||||
|
config.bindings.toggleMpvPause,
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const binding of buttonBindings) {
|
||||||
|
if (binding === 'none') continue;
|
||||||
|
previousButtons.set(
|
||||||
|
binding,
|
||||||
|
normalizeButtonState(
|
||||||
|
activeGamepad,
|
||||||
|
config,
|
||||||
|
binding,
|
||||||
|
config.triggerInputMode,
|
||||||
|
config.triggerDeadzone,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectionValue = (() => {
|
||||||
|
const axisValue = resolveAxisValue(activeGamepad, config.bindings.leftStickHorizontal);
|
||||||
|
if (Math.abs(axisValue) >= Math.max(config.stickDeadzone, 0.55)) {
|
||||||
|
return axisValue;
|
||||||
|
}
|
||||||
|
return resolveDpadHorizontalValue(activeGamepad, config.triggerDeadzone);
|
||||||
|
})();
|
||||||
|
syncHeldActionBlocked(selectionHold, selectionValue, now, Math.max(config.stickDeadzone, 0.55));
|
||||||
|
|
||||||
|
if (options.getLookupWindowOpen()) {
|
||||||
|
syncHeldActionBlocked(
|
||||||
|
jumpHold,
|
||||||
|
resolveAxisValue(activeGamepad, config.bindings.rightStickVertical),
|
||||||
|
now,
|
||||||
|
Math.max(config.stickDeadzone, 0.55),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
resetHeldAction(jumpHold);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function poll(now: number): void {
|
||||||
|
const elapsedMs = lastPollAt === null ? 0 : Math.max(now - lastPollAt, 0);
|
||||||
|
lastPollAt = now;
|
||||||
|
const config = options.getConfig();
|
||||||
|
const connectedGamepads = getConnectedGamepads();
|
||||||
|
const activeGamepad = resolveActiveGamepad(connectedGamepads, config);
|
||||||
|
publishState(connectedGamepads, activeGamepad);
|
||||||
|
|
||||||
|
if (!activeGamepad) {
|
||||||
|
previousButtons = new Map();
|
||||||
|
resetHeldAction(selectionHold);
|
||||||
|
resetHeldAction(jumpHold);
|
||||||
|
lastPollAt = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interactionAllowed =
|
||||||
|
config.enabled &&
|
||||||
|
options.getKeyboardModeEnabled() &&
|
||||||
|
!options.getInteractionBlocked();
|
||||||
|
if (config.enabled) {
|
||||||
|
handleButtonEdge(
|
||||||
|
config.bindings.toggleKeyboardOnlyMode,
|
||||||
|
normalizeButtonState(
|
||||||
|
activeGamepad,
|
||||||
|
config,
|
||||||
|
config.bindings.toggleKeyboardOnlyMode,
|
||||||
|
config.triggerInputMode,
|
||||||
|
config.triggerDeadzone,
|
||||||
|
),
|
||||||
|
options.toggleKeyboardMode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!interactionAllowed) {
|
||||||
|
syncBlockedInteractionState(activeGamepad, config, now);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleButtonEdge(
|
||||||
|
config.bindings.toggleLookup,
|
||||||
|
normalizeButtonState(
|
||||||
|
activeGamepad,
|
||||||
|
config,
|
||||||
|
config.bindings.toggleLookup,
|
||||||
|
config.triggerInputMode,
|
||||||
|
config.triggerDeadzone,
|
||||||
|
),
|
||||||
|
options.toggleLookup,
|
||||||
|
);
|
||||||
|
handleButtonEdge(
|
||||||
|
config.bindings.closeLookup,
|
||||||
|
normalizeButtonState(
|
||||||
|
activeGamepad,
|
||||||
|
config,
|
||||||
|
config.bindings.closeLookup,
|
||||||
|
config.triggerInputMode,
|
||||||
|
config.triggerDeadzone,
|
||||||
|
),
|
||||||
|
options.closeLookup,
|
||||||
|
);
|
||||||
|
handleButtonEdge(
|
||||||
|
config.bindings.mineCard,
|
||||||
|
normalizeButtonState(
|
||||||
|
activeGamepad,
|
||||||
|
config,
|
||||||
|
config.bindings.mineCard,
|
||||||
|
config.triggerInputMode,
|
||||||
|
config.triggerDeadzone,
|
||||||
|
),
|
||||||
|
options.mineCard,
|
||||||
|
);
|
||||||
|
handleButtonEdge(
|
||||||
|
config.bindings.quitMpv,
|
||||||
|
normalizeButtonState(
|
||||||
|
activeGamepad,
|
||||||
|
config,
|
||||||
|
config.bindings.quitMpv,
|
||||||
|
config.triggerInputMode,
|
||||||
|
config.triggerDeadzone,
|
||||||
|
),
|
||||||
|
options.quitMpv,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (options.getLookupWindowOpen()) {
|
||||||
|
handleButtonEdge(
|
||||||
|
config.bindings.previousAudio,
|
||||||
|
normalizeButtonState(
|
||||||
|
activeGamepad,
|
||||||
|
config,
|
||||||
|
config.bindings.previousAudio,
|
||||||
|
config.triggerInputMode,
|
||||||
|
config.triggerDeadzone,
|
||||||
|
),
|
||||||
|
options.previousAudio,
|
||||||
|
);
|
||||||
|
handleButtonEdge(
|
||||||
|
config.bindings.nextAudio,
|
||||||
|
normalizeButtonState(
|
||||||
|
activeGamepad,
|
||||||
|
config,
|
||||||
|
config.bindings.nextAudio,
|
||||||
|
config.triggerInputMode,
|
||||||
|
config.triggerDeadzone,
|
||||||
|
),
|
||||||
|
options.nextAudio,
|
||||||
|
);
|
||||||
|
handleButtonEdge(
|
||||||
|
config.bindings.playCurrentAudio,
|
||||||
|
normalizeButtonState(
|
||||||
|
activeGamepad,
|
||||||
|
config,
|
||||||
|
config.bindings.playCurrentAudio,
|
||||||
|
config.triggerInputMode,
|
||||||
|
config.triggerDeadzone,
|
||||||
|
),
|
||||||
|
options.playCurrentAudio,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dpadVertical = resolveDpadVerticalValue(activeGamepad, config.triggerDeadzone);
|
||||||
|
const primaryScroll = resolveAxisValue(activeGamepad, config.bindings.leftStickVertical);
|
||||||
|
if (elapsedMs > 0) {
|
||||||
|
if (Math.abs(primaryScroll) >= config.stickDeadzone) {
|
||||||
|
options.scrollPopup((primaryScroll * config.scrollPixelsPerSecond * elapsedMs) / 1000);
|
||||||
|
}
|
||||||
|
if (dpadVertical !== 0) {
|
||||||
|
options.scrollPopup((dpadVertical * config.scrollPixelsPerSecond * elapsedMs) / 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleJumpAxis(
|
||||||
|
resolveAxisValue(activeGamepad, config.bindings.rightStickVertical),
|
||||||
|
now,
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
resetHeldAction(jumpHold);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleButtonEdge(
|
||||||
|
config.bindings.toggleMpvPause,
|
||||||
|
normalizeButtonState(
|
||||||
|
activeGamepad,
|
||||||
|
config,
|
||||||
|
config.bindings.toggleMpvPause,
|
||||||
|
config.triggerInputMode,
|
||||||
|
config.triggerDeadzone,
|
||||||
|
),
|
||||||
|
options.toggleMpvPause,
|
||||||
|
);
|
||||||
|
|
||||||
|
handleSelectionAxis(
|
||||||
|
(() => {
|
||||||
|
const axisValue = resolveAxisValue(activeGamepad, config.bindings.leftStickHorizontal);
|
||||||
|
if (Math.abs(axisValue) >= Math.max(config.stickDeadzone, 0.55)) {
|
||||||
|
return axisValue;
|
||||||
|
}
|
||||||
|
return resolveDpadHorizontalValue(activeGamepad, config.triggerDeadzone);
|
||||||
|
})(),
|
||||||
|
now,
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
poll,
|
||||||
|
getActiveGamepadId: (): string | null => activeGamepadId,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,7 +3,10 @@ import test from 'node:test';
|
|||||||
|
|
||||||
import { createKeyboardHandlers } from './keyboard.js';
|
import { createKeyboardHandlers } from './keyboard.js';
|
||||||
import { createRendererState } from '../state.js';
|
import { createRendererState } from '../state.js';
|
||||||
import { YOMITAN_POPUP_COMMAND_EVENT } from '../yomitan-popup.js';
|
import {
|
||||||
|
YOMITAN_POPUP_COMMAND_EVENT,
|
||||||
|
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||||
|
} from '../yomitan-popup.js';
|
||||||
|
|
||||||
type CommandEventDetail = {
|
type CommandEventDetail = {
|
||||||
type?: string;
|
type?: string;
|
||||||
@@ -11,6 +14,9 @@ type CommandEventDetail = {
|
|||||||
key?: string;
|
key?: string;
|
||||||
code?: string;
|
code?: string;
|
||||||
repeat?: boolean;
|
repeat?: boolean;
|
||||||
|
direction?: number;
|
||||||
|
deltaX?: number;
|
||||||
|
deltaY?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function createClassList() {
|
function createClassList() {
|
||||||
@@ -44,9 +50,12 @@ function installKeyboardTestGlobals() {
|
|||||||
const previousMouseEvent = (globalThis as { MouseEvent?: unknown }).MouseEvent;
|
const previousMouseEvent = (globalThis as { MouseEvent?: unknown }).MouseEvent;
|
||||||
|
|
||||||
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
|
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
|
||||||
|
const windowListeners = new Map<string, Array<(event: unknown) => void>>();
|
||||||
const commandEvents: CommandEventDetail[] = [];
|
const commandEvents: CommandEventDetail[] = [];
|
||||||
const mpvCommands: Array<Array<string | number>> = [];
|
const mpvCommands: Array<Array<string | number>> = [];
|
||||||
let playbackPausedResponse: boolean | null = false;
|
let playbackPausedResponse: boolean | null = false;
|
||||||
|
let selectionClearCount = 0;
|
||||||
|
let selectionAddCount = 0;
|
||||||
|
|
||||||
let popupVisible = false;
|
let popupVisible = false;
|
||||||
|
|
||||||
@@ -60,8 +69,12 @@ function installKeyboardTestGlobals() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selection = {
|
const selection = {
|
||||||
removeAllRanges: () => {},
|
removeAllRanges: () => {
|
||||||
addRange: () => {},
|
selectionClearCount += 1;
|
||||||
|
},
|
||||||
|
addRange: () => {
|
||||||
|
selectionAddCount += 1;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const overlayFocusCalls: Array<{ preventScroll?: boolean }> = [];
|
const overlayFocusCalls: Array<{ preventScroll?: boolean }> = [];
|
||||||
@@ -96,12 +109,20 @@ function installKeyboardTestGlobals() {
|
|||||||
Object.defineProperty(globalThis, 'window', {
|
Object.defineProperty(globalThis, 'window', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: {
|
value: {
|
||||||
addEventListener: () => {},
|
addEventListener: (type: string, listener: (event: unknown) => void) => {
|
||||||
|
const listeners = windowListeners.get(type) ?? [];
|
||||||
|
listeners.push(listener);
|
||||||
|
windowListeners.set(type, listeners);
|
||||||
|
},
|
||||||
dispatchEvent: (event: Event) => {
|
dispatchEvent: (event: Event) => {
|
||||||
if (event.type === YOMITAN_POPUP_COMMAND_EVENT) {
|
if (event.type === YOMITAN_POPUP_COMMAND_EVENT) {
|
||||||
const detail = (event as Event & { detail?: CommandEventDetail }).detail;
|
const detail = (event as Event & { detail?: CommandEventDetail }).detail;
|
||||||
commandEvents.push(detail ?? {});
|
commandEvents.push(detail ?? {});
|
||||||
}
|
}
|
||||||
|
const listeners = windowListeners.get(event.type) ?? [];
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener(event);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
getComputedStyle: () => ({
|
getComputedStyle: () => ({
|
||||||
@@ -192,6 +213,13 @@ function installKeyboardTestGlobals() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dispatchWindowEvent(type: string): void {
|
||||||
|
const listeners = windowListeners.get(type) ?? [];
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener(new Event(type));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function restore() {
|
function restore() {
|
||||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
@@ -224,6 +252,7 @@ function installKeyboardTestGlobals() {
|
|||||||
windowFocusCalls: () => windowFocusCalls,
|
windowFocusCalls: () => windowFocusCalls,
|
||||||
dispatchKeydown,
|
dispatchKeydown,
|
||||||
dispatchFocusInOnPopup,
|
dispatchFocusInOnPopup,
|
||||||
|
dispatchWindowEvent,
|
||||||
setPopupVisible: (value: boolean) => {
|
setPopupVisible: (value: boolean) => {
|
||||||
popupVisible = value;
|
popupVisible = value;
|
||||||
},
|
},
|
||||||
@@ -231,6 +260,8 @@ function installKeyboardTestGlobals() {
|
|||||||
setPlaybackPausedResponse: (value: boolean | null) => {
|
setPlaybackPausedResponse: (value: boolean | null) => {
|
||||||
playbackPausedResponse = value;
|
playbackPausedResponse = value;
|
||||||
},
|
},
|
||||||
|
selectionClearCount: () => selectionClearCount,
|
||||||
|
selectionAddCount: () => selectionAddCount,
|
||||||
restore,
|
restore,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -238,6 +269,9 @@ function installKeyboardTestGlobals() {
|
|||||||
function createKeyboardHandlerHarness() {
|
function createKeyboardHandlerHarness() {
|
||||||
const testGlobals = installKeyboardTestGlobals();
|
const testGlobals = installKeyboardTestGlobals();
|
||||||
const subtitleRootClassList = createClassList();
|
const subtitleRootClassList = createClassList();
|
||||||
|
let controllerSelectOpenCount = 0;
|
||||||
|
let controllerDebugOpenCount = 0;
|
||||||
|
let controllerSelectKeydownCount = 0;
|
||||||
|
|
||||||
const createWordNode = (left: number) => ({
|
const createWordNode = (left: number) => ({
|
||||||
classList: createClassList(),
|
classList: createClassList(),
|
||||||
@@ -270,16 +304,30 @@ function createKeyboardHandlerHarness() {
|
|||||||
handleSubsyncKeydown: () => false,
|
handleSubsyncKeydown: () => false,
|
||||||
handleKikuKeydown: () => false,
|
handleKikuKeydown: () => false,
|
||||||
handleJimakuKeydown: () => false,
|
handleJimakuKeydown: () => false,
|
||||||
|
handleControllerSelectKeydown: () => {
|
||||||
|
controllerSelectKeydownCount += 1;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
handleControllerDebugKeydown: () => false,
|
||||||
handleSessionHelpKeydown: () => false,
|
handleSessionHelpKeydown: () => false,
|
||||||
openSessionHelpModal: () => {},
|
openSessionHelpModal: () => {},
|
||||||
appendClipboardVideoToQueue: () => {},
|
appendClipboardVideoToQueue: () => {},
|
||||||
getPlaybackPaused: () => testGlobals.getPlaybackPaused(),
|
getPlaybackPaused: () => testGlobals.getPlaybackPaused(),
|
||||||
|
openControllerSelectModal: () => {
|
||||||
|
controllerSelectOpenCount += 1;
|
||||||
|
},
|
||||||
|
openControllerDebugModal: () => {
|
||||||
|
controllerDebugOpenCount += 1;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ctx,
|
ctx,
|
||||||
handlers,
|
handlers,
|
||||||
testGlobals,
|
testGlobals,
|
||||||
|
controllerSelectOpenCount: () => controllerSelectOpenCount,
|
||||||
|
controllerDebugOpenCount: () => controllerDebugOpenCount,
|
||||||
|
controllerSelectKeydownCount: () => controllerSelectKeydownCount,
|
||||||
setWordCount: (count: number) => {
|
setWordCount: (count: number) => {
|
||||||
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
|
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
|
||||||
},
|
},
|
||||||
@@ -418,6 +466,93 @@ test('keyboard mode: repeated popup navigation keys are forwarded while popup is
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: controller helpers dispatch popup audio play/cycle and scroll bridge commands', async () => {
|
||||||
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
ctx.state.yomitanPopupVisible = true;
|
||||||
|
testGlobals.setPopupVisible(true);
|
||||||
|
|
||||||
|
assert.equal(handlers.playCurrentAudioForController(), true);
|
||||||
|
assert.equal(handlers.cyclePopupAudioSourceForController(1), true);
|
||||||
|
assert.equal(handlers.scrollPopupByController(48, -24), true);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
testGlobals.commandEvents.slice(-3),
|
||||||
|
[
|
||||||
|
{ type: 'playCurrentAudio' },
|
||||||
|
{ type: 'cycleAudioSource', direction: 1 },
|
||||||
|
{ type: 'scrollBy', deltaX: 48, deltaY: -24 },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: Alt+Shift+C opens controller debug modal', async () => {
|
||||||
|
const { testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({
|
||||||
|
key: 'C',
|
||||||
|
code: 'KeyC',
|
||||||
|
altKey: true,
|
||||||
|
shiftKey: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(controllerDebugOpenCount(), 1);
|
||||||
|
} finally {
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: Alt+Shift+C opens controller debug modal even while popup is visible', async () => {
|
||||||
|
const { ctx, testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
ctx.state.yomitanPopupVisible = true;
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({
|
||||||
|
key: 'C',
|
||||||
|
code: 'KeyC',
|
||||||
|
altKey: true,
|
||||||
|
shiftKey: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(controllerDebugOpenCount(), 1);
|
||||||
|
} finally {
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: controller select modal handles arrow keys before yomitan popup', async () => {
|
||||||
|
const { ctx, testGlobals, handlers, controllerSelectKeydownCount } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
ctx.state.controllerSelectModalOpen = true;
|
||||||
|
ctx.state.yomitanPopupVisible = true;
|
||||||
|
testGlobals.setPopupVisible(true);
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' });
|
||||||
|
|
||||||
|
assert.equal(controllerSelectKeydownCount(), 1);
|
||||||
|
assert.equal(
|
||||||
|
testGlobals.commandEvents.some(
|
||||||
|
(event) => event.type === 'forwardKeyDown' && event.code === 'ArrowDown',
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('keyboard mode: h moves left when popup is closed', async () => {
|
test('keyboard mode: h moves left when popup is closed', async () => {
|
||||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
@@ -490,6 +625,153 @@ test('keyboard mode: opening lookup restores overlay keyboard focus', async () =
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: turning mode off clears selected token highlight', async () => {
|
||||||
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
|
||||||
|
ctx.state.keyboardSelectedWordIndex = 1;
|
||||||
|
handlers.syncKeyboardTokenSelection();
|
||||||
|
const wordNodes = ctx.dom.subtitleRoot.querySelectorAll();
|
||||||
|
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
|
||||||
|
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
|
||||||
|
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
|
||||||
|
assert.equal(ctx.state.keyboardSelectedWordIndex, null);
|
||||||
|
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), false);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: popup hidden after mode off clears stale selected token highlight', async () => {
|
||||||
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
|
||||||
|
ctx.state.keyboardSelectedWordIndex = 1;
|
||||||
|
ctx.state.yomitanPopupVisible = true;
|
||||||
|
testGlobals.setPopupVisible(true);
|
||||||
|
handlers.syncKeyboardTokenSelection();
|
||||||
|
|
||||||
|
const wordNodes = ctx.dom.subtitleRoot.querySelectorAll();
|
||||||
|
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
|
||||||
|
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
ctx.state.yomitanPopupVisible = false;
|
||||||
|
testGlobals.setPopupVisible(false);
|
||||||
|
testGlobals.dispatchWindowEvent(YOMITAN_POPUP_HIDDEN_EVENT);
|
||||||
|
|
||||||
|
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
|
||||||
|
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), false);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: closing lookup keeps controller selection but clears native text selection', async () => {
|
||||||
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
|
||||||
|
ctx.state.keyboardSelectedWordIndex = 1;
|
||||||
|
handlers.syncKeyboardTokenSelection();
|
||||||
|
|
||||||
|
const wordNodes = ctx.dom.subtitleRoot.querySelectorAll();
|
||||||
|
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
|
||||||
|
assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), false);
|
||||||
|
|
||||||
|
handlers.handleLookupWindowToggleRequested();
|
||||||
|
await wait(0);
|
||||||
|
assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), true);
|
||||||
|
assert.equal(testGlobals.selectionAddCount() > 0, true);
|
||||||
|
|
||||||
|
ctx.state.yomitanPopupVisible = true;
|
||||||
|
testGlobals.setPopupVisible(true);
|
||||||
|
handlers.closeLookupWindow();
|
||||||
|
ctx.state.yomitanPopupVisible = false;
|
||||||
|
testGlobals.setPopupVisible(false);
|
||||||
|
testGlobals.dispatchWindowEvent(YOMITAN_POPUP_HIDDEN_EVENT);
|
||||||
|
await wait(0);
|
||||||
|
|
||||||
|
assert.equal(ctx.state.keyboardDrivenModeEnabled, true);
|
||||||
|
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
|
||||||
|
assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), false);
|
||||||
|
assert.equal(testGlobals.selectionClearCount() > 0, true);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: closing lookup clears yomitan active text source so same token can reopen immediately', async () => {
|
||||||
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
|
||||||
|
ctx.state.keyboardSelectedWordIndex = 1;
|
||||||
|
handlers.syncKeyboardTokenSelection();
|
||||||
|
|
||||||
|
handlers.handleLookupWindowToggleRequested();
|
||||||
|
await wait(0);
|
||||||
|
|
||||||
|
ctx.state.yomitanPopupVisible = true;
|
||||||
|
testGlobals.setPopupVisible(true);
|
||||||
|
handlers.handleLookupWindowToggleRequested();
|
||||||
|
await wait(0);
|
||||||
|
|
||||||
|
const closeCommands = testGlobals.commandEvents.filter(
|
||||||
|
(event) => event.type === 'setVisible' || event.type === 'clearActiveTextSource',
|
||||||
|
);
|
||||||
|
assert.deepEqual(closeCommands.slice(-2), [
|
||||||
|
{ type: 'setVisible', visible: false },
|
||||||
|
{ type: 'clearActiveTextSource' },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: lookup toggle closes popup when DOM visibility is the source of truth', async () => {
|
||||||
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
|
||||||
|
ctx.state.keyboardSelectedWordIndex = 1;
|
||||||
|
handlers.syncKeyboardTokenSelection();
|
||||||
|
ctx.state.yomitanPopupVisible = false;
|
||||||
|
testGlobals.setPopupVisible(true);
|
||||||
|
|
||||||
|
handlers.handleLookupWindowToggleRequested();
|
||||||
|
await wait(0);
|
||||||
|
|
||||||
|
const closeCommands = testGlobals.commandEvents.filter(
|
||||||
|
(event) => event.type === 'setVisible' || event.type === 'clearActiveTextSource',
|
||||||
|
);
|
||||||
|
assert.deepEqual(closeCommands.slice(-2), [
|
||||||
|
{ type: 'setVisible', visible: false },
|
||||||
|
{ type: 'clearActiveTextSource' },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('keyboard mode: moving right beyond end jumps next subtitle and resets selector to start', async () => {
|
test('keyboard mode: moving right beyond end jumps next subtitle and resets selector to start', async () => {
|
||||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
@@ -538,6 +820,52 @@ test('keyboard mode: moving left beyond start jumps previous subtitle and sets s
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: empty subtitle gap left and right still seek adjacent subtitle lines', async () => {
|
||||||
|
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
|
||||||
|
setWordCount(0);
|
||||||
|
handlers.syncKeyboardTokenSelection();
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: 'ArrowRight', code: 'ArrowRight' });
|
||||||
|
await wait(0);
|
||||||
|
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', 1]);
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: 'ArrowLeft', code: 'ArrowLeft' });
|
||||||
|
await wait(0);
|
||||||
|
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', -1]);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('controller mode: empty subtitle gap horizontal move still seeks adjacent subtitle lines', async () => {
|
||||||
|
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
|
||||||
|
setWordCount(0);
|
||||||
|
handlers.syncKeyboardTokenSelection();
|
||||||
|
|
||||||
|
assert.equal(handlers.moveSelectionForController(1), true);
|
||||||
|
await wait(0);
|
||||||
|
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', 1]);
|
||||||
|
|
||||||
|
assert.equal(handlers.moveSelectionForController(-1), true);
|
||||||
|
await wait(0);
|
||||||
|
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', -1]);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('keyboard mode: popup-open edge jump refreshes lookup on the new subtitle selection', async () => {
|
test('keyboard mode: popup-open edge jump refreshes lookup on the new subtitle selection', async () => {
|
||||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
@@ -570,6 +898,28 @@ test('keyboard mode: popup-open edge jump refreshes lookup on the new subtitle s
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: natural subtitle advance resets selector to the start of the new line', async () => {
|
||||||
|
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
|
||||||
|
setWordCount(3);
|
||||||
|
ctx.state.keyboardSelectedWordIndex = 2;
|
||||||
|
handlers.syncKeyboardTokenSelection();
|
||||||
|
|
||||||
|
handlers.handleSubtitleContentUpdated();
|
||||||
|
setWordCount(4);
|
||||||
|
handlers.syncKeyboardTokenSelection();
|
||||||
|
|
||||||
|
assert.equal(ctx.state.keyboardSelectedWordIndex, 0);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('keyboard mode: edge jump while paused re-applies paused state after subtitle seek', async () => {
|
test('keyboard mode: edge jump while paused re-applies paused state after subtitle seek', async () => {
|
||||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export function createKeyboardHandlers(
|
|||||||
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
|
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleKikuKeydown: (e: KeyboardEvent) => boolean;
|
handleKikuKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
||||||
|
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
|
||||||
|
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
|
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
|
||||||
openSessionHelpModal: (opening: {
|
openSessionHelpModal: (opening: {
|
||||||
bindingKey: 'KeyH' | 'KeyK';
|
bindingKey: 'KeyH' | 'KeyK';
|
||||||
@@ -23,6 +25,8 @@ export function createKeyboardHandlers(
|
|||||||
}) => void;
|
}) => void;
|
||||||
appendClipboardVideoToQueue: () => void;
|
appendClipboardVideoToQueue: () => void;
|
||||||
getPlaybackPaused: () => Promise<boolean | null>;
|
getPlaybackPaused: () => Promise<boolean | null>;
|
||||||
|
openControllerSelectModal: () => void;
|
||||||
|
openControllerDebugModal: () => void;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
|
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
|
||||||
@@ -30,6 +34,7 @@ export function createKeyboardHandlers(
|
|||||||
const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected';
|
const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected';
|
||||||
let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null;
|
let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null;
|
||||||
let pendingLookupRefreshAfterSubtitleSeek = false;
|
let pendingLookupRefreshAfterSubtitleSeek = false;
|
||||||
|
let resetSelectionToStartOnNextSubtitleSync = false;
|
||||||
|
|
||||||
const CHORD_MAP = new Map<
|
const CHORD_MAP = new Map<
|
||||||
string,
|
string,
|
||||||
@@ -105,6 +110,39 @@ export function createKeyboardHandlers(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dispatchYomitanPopupCycleAudioSource(direction: -1 | 1) {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||||
|
detail: {
|
||||||
|
type: 'cycleAudioSource',
|
||||||
|
direction,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchYomitanPopupPlayCurrentAudio() {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||||
|
detail: {
|
||||||
|
type: 'playCurrentAudio',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchYomitanPopupScrollBy(deltaX: number, deltaY: number) {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||||
|
detail: {
|
||||||
|
type: 'scrollBy',
|
||||||
|
deltaX,
|
||||||
|
deltaY,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function dispatchYomitanFrontendScanSelectedText() {
|
function dispatchYomitanFrontendScanSelectedText() {
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||||
@@ -115,6 +153,16 @@ export function createKeyboardHandlers(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dispatchYomitanFrontendClearActiveTextSource() {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||||
|
detail: {
|
||||||
|
type: 'clearActiveTextSource',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isPrimaryModifierPressed(e: KeyboardEvent): boolean {
|
function isPrimaryModifierPressed(e: KeyboardEvent): boolean {
|
||||||
return e.ctrlKey || e.metaKey;
|
return e.ctrlKey || e.metaKey;
|
||||||
}
|
}
|
||||||
@@ -129,23 +177,39 @@ export function createKeyboardHandlers(
|
|||||||
return isPrimaryModifierPressed(e) && !e.altKey && !e.shiftKey && isYKey && !e.repeat;
|
return isPrimaryModifierPressed(e) && !e.altKey && !e.shiftKey && isYKey && !e.repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isControllerModalShortcut(e: KeyboardEvent): boolean {
|
||||||
|
return !e.ctrlKey && !e.metaKey && e.altKey && !e.repeat && e.code === 'KeyC';
|
||||||
|
}
|
||||||
|
|
||||||
function getSubtitleWordNodes(): HTMLElement[] {
|
function getSubtitleWordNodes(): HTMLElement[] {
|
||||||
return Array.from(
|
return Array.from(
|
||||||
ctx.dom.subtitleRoot.querySelectorAll<HTMLElement>('.word[data-token-index]'),
|
ctx.dom.subtitleRoot.querySelectorAll<HTMLElement>('.word[data-token-index]'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncKeyboardTokenSelection(): void {
|
function clearKeyboardSelectedWordClasses(wordNodes: HTMLElement[] = getSubtitleWordNodes()): void {
|
||||||
const wordNodes = getSubtitleWordNodes();
|
|
||||||
for (const wordNode of wordNodes) {
|
for (const wordNode of wordNodes) {
|
||||||
wordNode.classList.remove(KEYBOARD_SELECTED_WORD_CLASS);
|
wordNode.classList.remove(KEYBOARD_SELECTED_WORD_CLASS);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearNativeSubtitleSelection(): void {
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
ctx.dom.subtitleRoot.classList.remove('has-selection');
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncKeyboardTokenSelection(): void {
|
||||||
|
const wordNodes = getSubtitleWordNodes();
|
||||||
|
clearKeyboardSelectedWordClasses(wordNodes);
|
||||||
|
|
||||||
if (!ctx.state.keyboardDrivenModeEnabled || wordNodes.length === 0) {
|
if (!ctx.state.keyboardDrivenModeEnabled || wordNodes.length === 0) {
|
||||||
ctx.state.keyboardSelectedWordIndex = null;
|
ctx.state.keyboardSelectedWordIndex = null;
|
||||||
|
ctx.state.keyboardSelectionVisible = false;
|
||||||
if (!ctx.state.keyboardDrivenModeEnabled) {
|
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||||
pendingSelectionAnchorAfterSubtitleSeek = null;
|
pendingSelectionAnchorAfterSubtitleSeek = null;
|
||||||
pendingLookupRefreshAfterSubtitleSeek = false;
|
pendingLookupRefreshAfterSubtitleSeek = false;
|
||||||
|
resetSelectionToStartOnNextSubtitleSync = false;
|
||||||
|
clearNativeSubtitleSelection();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -153,7 +217,9 @@ export function createKeyboardHandlers(
|
|||||||
if (pendingSelectionAnchorAfterSubtitleSeek) {
|
if (pendingSelectionAnchorAfterSubtitleSeek) {
|
||||||
ctx.state.keyboardSelectedWordIndex =
|
ctx.state.keyboardSelectedWordIndex =
|
||||||
pendingSelectionAnchorAfterSubtitleSeek === 'start' ? 0 : wordNodes.length - 1;
|
pendingSelectionAnchorAfterSubtitleSeek === 'start' ? 0 : wordNodes.length - 1;
|
||||||
|
ctx.state.keyboardSelectionVisible = true;
|
||||||
pendingSelectionAnchorAfterSubtitleSeek = null;
|
pendingSelectionAnchorAfterSubtitleSeek = null;
|
||||||
|
resetSelectionToStartOnNextSubtitleSync = false;
|
||||||
const shouldRefreshLookup =
|
const shouldRefreshLookup =
|
||||||
pendingLookupRefreshAfterSubtitleSeek &&
|
pendingLookupRefreshAfterSubtitleSeek &&
|
||||||
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document));
|
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document));
|
||||||
@@ -165,23 +231,32 @@ export function createKeyboardHandlers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resetSelectionToStartOnNextSubtitleSync) {
|
||||||
|
ctx.state.keyboardSelectedWordIndex = 0;
|
||||||
|
ctx.state.keyboardSelectionVisible = true;
|
||||||
|
resetSelectionToStartOnNextSubtitleSync = false;
|
||||||
|
}
|
||||||
|
|
||||||
const selectedIndex = Math.min(
|
const selectedIndex = Math.min(
|
||||||
Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0),
|
Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0),
|
||||||
wordNodes.length - 1,
|
wordNodes.length - 1,
|
||||||
);
|
);
|
||||||
ctx.state.keyboardSelectedWordIndex = selectedIndex;
|
ctx.state.keyboardSelectedWordIndex = selectedIndex;
|
||||||
const selectedWordNode = wordNodes[selectedIndex];
|
const selectedWordNode = wordNodes[selectedIndex];
|
||||||
if (selectedWordNode) {
|
if (selectedWordNode && ctx.state.keyboardSelectionVisible) {
|
||||||
selectedWordNode.classList.add(KEYBOARD_SELECTED_WORD_CLASS);
|
selectedWordNode.classList.add(KEYBOARD_SELECTED_WORD_CLASS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setKeyboardDrivenModeEnabled(enabled: boolean): void {
|
function setKeyboardDrivenModeEnabled(enabled: boolean): void {
|
||||||
ctx.state.keyboardDrivenModeEnabled = enabled;
|
ctx.state.keyboardDrivenModeEnabled = enabled;
|
||||||
|
ctx.state.keyboardSelectionVisible = enabled;
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
ctx.state.keyboardSelectedWordIndex = null;
|
ctx.state.keyboardSelectedWordIndex = null;
|
||||||
pendingSelectionAnchorAfterSubtitleSeek = null;
|
pendingSelectionAnchorAfterSubtitleSeek = null;
|
||||||
pendingLookupRefreshAfterSubtitleSeek = false;
|
pendingLookupRefreshAfterSubtitleSeek = false;
|
||||||
|
resetSelectionToStartOnNextSubtitleSync = false;
|
||||||
|
clearNativeSubtitleSelection();
|
||||||
}
|
}
|
||||||
syncKeyboardTokenSelection();
|
syncKeyboardTokenSelection();
|
||||||
}
|
}
|
||||||
@@ -213,6 +288,7 @@ export function createKeyboardHandlers(
|
|||||||
|
|
||||||
const nextIndex = currentIndex + delta;
|
const nextIndex = currentIndex + delta;
|
||||||
ctx.state.keyboardSelectedWordIndex = nextIndex;
|
ctx.state.keyboardSelectedWordIndex = nextIndex;
|
||||||
|
ctx.state.keyboardSelectionVisible = true;
|
||||||
syncKeyboardTokenSelection();
|
syncKeyboardTokenSelection();
|
||||||
return 'moved';
|
return 'moved';
|
||||||
}
|
}
|
||||||
@@ -316,6 +392,7 @@ export function createKeyboardHandlers(
|
|||||||
const selectedWordNode = wordNodes[selectedIndex];
|
const selectedWordNode = wordNodes[selectedIndex];
|
||||||
if (!selectedWordNode) return false;
|
if (!selectedWordNode) return false;
|
||||||
|
|
||||||
|
ctx.state.keyboardSelectionVisible = true;
|
||||||
syncKeyboardTokenSelection();
|
syncKeyboardTokenSelection();
|
||||||
selectWordNodeText(selectedWordNode);
|
selectWordNodeText(selectedWordNode);
|
||||||
|
|
||||||
@@ -347,19 +424,105 @@ export function createKeyboardHandlers(
|
|||||||
toggleKeyboardDrivenMode();
|
toggleKeyboardDrivenMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSubtitleContentUpdated(): void {
|
||||||
|
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pendingSelectionAnchorAfterSubtitleSeek) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resetSelectionToStartOnNextSubtitleSync = true;
|
||||||
|
}
|
||||||
|
|
||||||
function handleLookupWindowToggleRequested(): void {
|
function handleLookupWindowToggleRequested(): void {
|
||||||
if (ctx.state.yomitanPopupVisible) {
|
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
||||||
dispatchYomitanPopupVisibility(false);
|
closeLookupWindow();
|
||||||
if (ctx.state.keyboardDrivenModeEnabled) {
|
|
||||||
queueMicrotask(() => {
|
|
||||||
restoreOverlayKeyboardFocus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
triggerLookupForSelectedWord();
|
triggerLookupForSelectedWord();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeLookupWindow(): boolean {
|
||||||
|
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchYomitanPopupVisibility(false);
|
||||||
|
dispatchYomitanFrontendClearActiveTextSource();
|
||||||
|
clearNativeSubtitleSelection();
|
||||||
|
if (ctx.state.keyboardDrivenModeEnabled) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
restoreOverlayKeyboardFocus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveSelectionForController(delta: -1 | 1): boolean {
|
||||||
|
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document);
|
||||||
|
const result = moveKeyboardSelection(delta);
|
||||||
|
if (result === 'no-words') {
|
||||||
|
seekAdjacentSubtitleAndQueueSelection(delta, popupVisible);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result === 'start-boundary' || result === 'end-boundary') {
|
||||||
|
seekAdjacentSubtitleAndQueueSelection(delta, popupVisible);
|
||||||
|
} else if (popupVisible && result === 'moved') {
|
||||||
|
triggerLookupForSelectedWord();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function forwardPopupKeydownForController(
|
||||||
|
key: string,
|
||||||
|
code: string,
|
||||||
|
repeat: boolean = true,
|
||||||
|
): boolean {
|
||||||
|
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
dispatchYomitanPopupKeydown(key, code, [], repeat);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mineSelectedFromController(): boolean {
|
||||||
|
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
dispatchYomitanPopupMineSelected();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cyclePopupAudioSourceForController(direction: -1 | 1): boolean {
|
||||||
|
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
dispatchYomitanPopupCycleAudioSource(direction);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function playCurrentAudioForController(): boolean {
|
||||||
|
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
dispatchYomitanPopupPlayCurrentAudio();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollPopupByController(deltaX: number, deltaY: number): boolean {
|
||||||
|
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
dispatchYomitanPopupScrollBy(deltaX, deltaY);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function restoreOverlayKeyboardFocus(): void {
|
function restoreOverlayKeyboardFocus(): void {
|
||||||
void window.electronAPI.focusMainWindow();
|
void window.electronAPI.focusMainWindow();
|
||||||
window.focus();
|
window.focus();
|
||||||
@@ -401,17 +564,17 @@ export function createKeyboardHandlers(
|
|||||||
const key = e.code;
|
const key = e.code;
|
||||||
if (key === 'ArrowLeft') {
|
if (key === 'ArrowLeft') {
|
||||||
const result = moveKeyboardSelection(-1);
|
const result = moveKeyboardSelection(-1);
|
||||||
if (result === 'start-boundary') {
|
if (result === 'start-boundary' || result === 'no-words') {
|
||||||
seekAdjacentSubtitleAndQueueSelection(-1, false);
|
seekAdjacentSubtitleAndQueueSelection(-1, false);
|
||||||
}
|
}
|
||||||
return result !== 'no-words';
|
return true;
|
||||||
}
|
}
|
||||||
if (key === 'ArrowRight' || key === 'KeyL') {
|
if (key === 'ArrowRight' || key === 'KeyL') {
|
||||||
const result = moveKeyboardSelection(1);
|
const result = moveKeyboardSelection(1);
|
||||||
if (result === 'end-boundary') {
|
if (result === 'end-boundary' || result === 'no-words') {
|
||||||
seekAdjacentSubtitleAndQueueSelection(1, false);
|
seekAdjacentSubtitleAndQueueSelection(1, false);
|
||||||
}
|
}
|
||||||
return result !== 'no-words';
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -428,7 +591,7 @@ export function createKeyboardHandlers(
|
|||||||
const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document);
|
const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document);
|
||||||
if (key === 'ArrowLeft' || key === 'KeyH') {
|
if (key === 'ArrowLeft' || key === 'KeyH') {
|
||||||
const result = moveKeyboardSelection(-1);
|
const result = moveKeyboardSelection(-1);
|
||||||
if (result === 'start-boundary') {
|
if (result === 'start-boundary' || result === 'no-words') {
|
||||||
seekAdjacentSubtitleAndQueueSelection(-1, popupVisible);
|
seekAdjacentSubtitleAndQueueSelection(-1, popupVisible);
|
||||||
} else if (popupVisible && result === 'moved') {
|
} else if (popupVisible && result === 'moved') {
|
||||||
triggerLookupForSelectedWord();
|
triggerLookupForSelectedWord();
|
||||||
@@ -438,7 +601,7 @@ export function createKeyboardHandlers(
|
|||||||
|
|
||||||
if (key === 'ArrowRight' || key === 'KeyL') {
|
if (key === 'ArrowRight' || key === 'KeyL') {
|
||||||
const result = moveKeyboardSelection(1);
|
const result = moveKeyboardSelection(1);
|
||||||
if (result === 'end-boundary') {
|
if (result === 'end-boundary' || result === 'no-words') {
|
||||||
seekAdjacentSubtitleAndQueueSelection(1, popupVisible);
|
seekAdjacentSubtitleAndQueueSelection(1, popupVisible);
|
||||||
} else if (popupVisible && result === 'moved') {
|
} else if (popupVisible && result === 'moved') {
|
||||||
triggerLookupForSelectedWord();
|
triggerLookupForSelectedWord();
|
||||||
@@ -540,7 +703,9 @@ export function createKeyboardHandlers(
|
|||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
||||||
|
clearNativeSubtitleSelection();
|
||||||
if (!ctx.state.keyboardDrivenModeEnabled) {
|
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||||
|
syncKeyboardTokenSelection();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
restoreOverlayKeyboardFocus();
|
restoreOverlayKeyboardFocus();
|
||||||
@@ -593,13 +758,6 @@ export function createKeyboardHandlers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
|
||||||
if (handleYomitanPopupKeybind(e)) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ctx.state.runtimeOptionsModalOpen) {
|
if (ctx.state.runtimeOptionsModalOpen) {
|
||||||
options.handleRuntimeOptionsKeydown(e);
|
options.handleRuntimeOptionsKeydown(e);
|
||||||
return;
|
return;
|
||||||
@@ -616,11 +774,29 @@ export function createKeyboardHandlers(
|
|||||||
options.handleJimakuKeydown(e);
|
options.handleJimakuKeydown(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (ctx.state.controllerSelectModalOpen) {
|
||||||
|
options.handleControllerSelectKeydown(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ctx.state.controllerDebugModalOpen) {
|
||||||
|
options.handleControllerDebugKeydown(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (ctx.state.sessionHelpModalOpen) {
|
if (ctx.state.sessionHelpModalOpen) {
|
||||||
options.handleSessionHelpKeydown(e);
|
options.handleSessionHelpKeydown(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) &&
|
||||||
|
!isControllerModalShortcut(e)
|
||||||
|
) {
|
||||||
|
if (handleYomitanPopupKeybind(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (ctx.state.keyboardDrivenModeEnabled && handleKeyboardDrivenModeNavigation(e)) {
|
if (ctx.state.keyboardDrivenModeEnabled && handleKeyboardDrivenModeNavigation(e)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return;
|
return;
|
||||||
@@ -671,6 +847,16 @@ export function createKeyboardHandlers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isControllerModalShortcut(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.shiftKey) {
|
||||||
|
options.openControllerDebugModal();
|
||||||
|
} else {
|
||||||
|
options.openControllerSelectModal();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const keyString = keyEventToString(e);
|
const keyString = keyEventToString(e);
|
||||||
const command = ctx.state.keybindingsMap.get(keyString);
|
const command = ctx.state.keybindingsMap.get(keyString);
|
||||||
|
|
||||||
@@ -707,7 +893,15 @@ export function createKeyboardHandlers(
|
|||||||
setupMpvInputForwarding,
|
setupMpvInputForwarding,
|
||||||
updateKeybindings,
|
updateKeybindings,
|
||||||
syncKeyboardTokenSelection,
|
syncKeyboardTokenSelection,
|
||||||
|
handleSubtitleContentUpdated,
|
||||||
handleKeyboardModeToggleRequested,
|
handleKeyboardModeToggleRequested,
|
||||||
handleLookupWindowToggleRequested,
|
handleLookupWindowToggleRequested,
|
||||||
|
closeLookupWindow,
|
||||||
|
moveSelectionForController,
|
||||||
|
forwardPopupKeydownForController,
|
||||||
|
mineSelectedFromController,
|
||||||
|
cyclePopupAudioSourceForController,
|
||||||
|
playCurrentAudioForController,
|
||||||
|
scrollPopupByController,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,12 @@
|
|||||||
<body>
|
<body>
|
||||||
<!-- Programmatic focus fallback target for Electron/window focus management. -->
|
<!-- Programmatic focus fallback target for Electron/window focus management. -->
|
||||||
<div id="overlay" tabindex="-1">
|
<div id="overlay" tabindex="-1">
|
||||||
|
<div
|
||||||
|
id="controllerStatusToast"
|
||||||
|
class="controller-status-toast hidden"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
></div>
|
||||||
<div
|
<div
|
||||||
id="overlayErrorToast"
|
id="overlayErrorToast"
|
||||||
class="overlay-error-toast hidden"
|
class="overlay-error-toast hidden"
|
||||||
@@ -192,6 +198,62 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="controllerSelectModal" class="modal hidden" aria-hidden="true">
|
||||||
|
<div class="modal-content runtime-modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title">Controller Selection</div>
|
||||||
|
<button id="controllerSelectClose" class="modal-close" type="button">Close</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="controllerSelectHint" class="runtime-options-hint">
|
||||||
|
Arrow keys: select controller · Enter: save · Esc: close
|
||||||
|
</div>
|
||||||
|
<ul id="controllerSelectList" class="runtime-options-list"></ul>
|
||||||
|
<div id="controllerSelectStatus" class="runtime-options-status"></div>
|
||||||
|
<div class="subsync-footer">
|
||||||
|
<button id="controllerSelectSave" class="kiku-confirm-button" type="button">
|
||||||
|
Save Controller
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="controllerDebugModal" class="modal hidden" aria-hidden="true">
|
||||||
|
<div class="modal-content runtime-modal-content controller-debug-content">
|
||||||
|
<div
|
||||||
|
id="controllerDebugToast"
|
||||||
|
class="controller-debug-toast hidden"
|
||||||
|
aria-live="polite"
|
||||||
|
></div>
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title">Controller Debug</div>
|
||||||
|
<button id="controllerDebugClose" class="modal-close" type="button">Close</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="controllerDebugStatus" class="runtime-options-status"></div>
|
||||||
|
<div id="controllerDebugSummary" class="controller-debug-summary"></div>
|
||||||
|
<div class="controller-debug-grid">
|
||||||
|
<div>
|
||||||
|
<div class="jimaku-section-title">Axes</div>
|
||||||
|
<pre id="controllerDebugAxes" class="controller-debug-pre"></pre>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="jimaku-section-title">Buttons</div>
|
||||||
|
<pre id="controllerDebugButtons" class="controller-debug-pre"></pre>
|
||||||
|
</div>
|
||||||
|
<div class="controller-debug-span">
|
||||||
|
<div class="controller-debug-section-header">
|
||||||
|
<div class="jimaku-section-title">Config</div>
|
||||||
|
<button id="controllerDebugCopy" class="kiku-cancel-button" type="button">
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre id="controllerDebugButtonIndices" class="controller-debug-pre"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="sessionHelpModal" class="modal hidden" aria-hidden="true">
|
<div id="sessionHelpModal" class="modal hidden" aria-hidden="true">
|
||||||
<div class="modal-content session-help-content">
|
<div class="modal-content session-help-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
|
|||||||
237
src/renderer/modals/controller-debug.test.ts
Normal file
237
src/renderer/modals/controller-debug.test.ts
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { createRendererState } from '../state.js';
|
||||||
|
import { createControllerDebugModal } from './controller-debug.js';
|
||||||
|
|
||||||
|
function createClassList(initialTokens: string[] = []) {
|
||||||
|
const tokens = new Set(initialTokens);
|
||||||
|
return {
|
||||||
|
add: (...entries: string[]) => {
|
||||||
|
for (const entry of entries) tokens.add(entry);
|
||||||
|
},
|
||||||
|
remove: (...entries: string[]) => {
|
||||||
|
for (const entry of entries) tokens.delete(entry);
|
||||||
|
},
|
||||||
|
contains: (entry: string) => tokens.has(entry),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('controller debug modal renders active controller axes, buttons, and config-ready button indices', () => {
|
||||||
|
const globals = globalThis as typeof globalThis & { window?: unknown };
|
||||||
|
const previousWindow = globals.window;
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
electronAPI: {
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = createRendererState();
|
||||||
|
state.connectedGamepads = [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }];
|
||||||
|
state.activeGamepadId = 'pad-1';
|
||||||
|
state.controllerRawAxes = [0.5, -0.25];
|
||||||
|
state.controllerRawButtons = [{ value: 1, pressed: true, touched: true }];
|
||||||
|
state.controllerConfig = {
|
||||||
|
enabled: true,
|
||||||
|
preferredGamepadId: '',
|
||||||
|
preferredGamepadLabel: '',
|
||||||
|
smoothScroll: true,
|
||||||
|
scrollPixelsPerSecond: 900,
|
||||||
|
horizontalJumpPixels: 160,
|
||||||
|
stickDeadzone: 0.2,
|
||||||
|
triggerInputMode: 'auto',
|
||||||
|
triggerDeadzone: 0.5,
|
||||||
|
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: 'buttonSouth',
|
||||||
|
closeLookup: 'buttonEast',
|
||||||
|
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||||
|
mineCard: 'buttonWest',
|
||||||
|
quitMpv: 'select',
|
||||||
|
previousAudio: 'none',
|
||||||
|
nextAudio: 'rightShoulder',
|
||||||
|
playCurrentAudio: 'leftShoulder',
|
||||||
|
toggleMpvPause: 'leftStickPress',
|
||||||
|
leftStickHorizontal: 'leftStickX',
|
||||||
|
leftStickVertical: 'leftStickY',
|
||||||
|
rightStickHorizontal: 'rightStickX',
|
||||||
|
rightStickVertical: 'rightStickY',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
dom: {
|
||||||
|
overlay: { classList: createClassList() },
|
||||||
|
controllerDebugModal: {
|
||||||
|
classList: createClassList(['hidden']),
|
||||||
|
setAttribute: () => {},
|
||||||
|
},
|
||||||
|
controllerDebugClose: { addEventListener: () => {} },
|
||||||
|
controllerDebugToast: { textContent: '', classList: createClassList(['hidden']) },
|
||||||
|
controllerDebugStatus: { textContent: '', classList: createClassList() },
|
||||||
|
controllerDebugSummary: { textContent: '' },
|
||||||
|
controllerDebugAxes: { textContent: '' },
|
||||||
|
controllerDebugButtons: { textContent: '' },
|
||||||
|
controllerDebugButtonIndices: { textContent: '' },
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
|
||||||
|
const modal = createControllerDebugModal(ctx as never, {
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.openControllerDebugModal();
|
||||||
|
|
||||||
|
assert.match(ctx.dom.controllerDebugStatus.textContent, /pad-1/);
|
||||||
|
assert.match(ctx.dom.controllerDebugSummary.textContent, /standard/);
|
||||||
|
assert.match(ctx.dom.controllerDebugAxes.textContent, /axis\[0\] = 0\.500/);
|
||||||
|
assert.match(ctx.dom.controllerDebugButtons.textContent, /button\[0\] value=1\.000 pressed=true/);
|
||||||
|
assert.match(ctx.dom.controllerDebugButtonIndices.textContent, /"buttonIndices": \{/);
|
||||||
|
assert.match(ctx.dom.controllerDebugButtonIndices.textContent, /"select": 6/);
|
||||||
|
assert.match(ctx.dom.controllerDebugButtonIndices.textContent, /"leftStickPress": 9/);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('controller debug modal copies buttonIndices config to clipboard', async () => {
|
||||||
|
const globals = globalThis as typeof globalThis & {
|
||||||
|
window?: unknown;
|
||||||
|
navigator?: unknown;
|
||||||
|
};
|
||||||
|
const previousWindow = globals.window;
|
||||||
|
const previousNavigator = globals.navigator;
|
||||||
|
const copied: string[] = [];
|
||||||
|
const handlers: { copy: null | (() => void) } = { copy: null };
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
electronAPI: {
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'navigator', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
clipboard: {
|
||||||
|
writeText: async (text: string) => {
|
||||||
|
copied.push(text);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = createRendererState();
|
||||||
|
state.controllerConfig = {
|
||||||
|
enabled: true,
|
||||||
|
preferredGamepadId: '',
|
||||||
|
preferredGamepadLabel: '',
|
||||||
|
smoothScroll: true,
|
||||||
|
scrollPixelsPerSecond: 900,
|
||||||
|
horizontalJumpPixels: 160,
|
||||||
|
stickDeadzone: 0.2,
|
||||||
|
triggerInputMode: 'auto',
|
||||||
|
triggerDeadzone: 0.5,
|
||||||
|
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: 'buttonSouth',
|
||||||
|
closeLookup: 'buttonEast',
|
||||||
|
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||||
|
mineCard: 'buttonWest',
|
||||||
|
quitMpv: 'select',
|
||||||
|
previousAudio: 'none',
|
||||||
|
nextAudio: 'rightShoulder',
|
||||||
|
playCurrentAudio: 'leftShoulder',
|
||||||
|
toggleMpvPause: 'leftStickPress',
|
||||||
|
leftStickHorizontal: 'leftStickX',
|
||||||
|
leftStickVertical: 'leftStickY',
|
||||||
|
rightStickHorizontal: 'rightStickX',
|
||||||
|
rightStickVertical: 'rightStickY',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
dom: {
|
||||||
|
overlay: { classList: createClassList() },
|
||||||
|
controllerDebugModal: {
|
||||||
|
classList: createClassList(['hidden']),
|
||||||
|
setAttribute: () => {},
|
||||||
|
},
|
||||||
|
controllerDebugClose: { addEventListener: () => {} },
|
||||||
|
controllerDebugCopy: {
|
||||||
|
addEventListener: (_event: string, handler: () => void) => {
|
||||||
|
handlers.copy = handler;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
controllerDebugToast: { textContent: '', classList: createClassList(['hidden']) },
|
||||||
|
controllerDebugStatus: { textContent: '', classList: createClassList() },
|
||||||
|
controllerDebugSummary: { textContent: '' },
|
||||||
|
controllerDebugAxes: { textContent: '' },
|
||||||
|
controllerDebugButtons: { textContent: '' },
|
||||||
|
controllerDebugButtonIndices: { textContent: '' },
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
|
||||||
|
const modal = createControllerDebugModal(ctx as never, {
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.wireDomEvents();
|
||||||
|
modal.openControllerDebugModal();
|
||||||
|
if (handlers.copy) {
|
||||||
|
handlers.copy();
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
assert.deepEqual(copied, [ctx.dom.controllerDebugButtonIndices.textContent]);
|
||||||
|
assert.match(ctx.dom.controllerDebugStatus.textContent, /Copied controller buttonIndices config/);
|
||||||
|
assert.match(ctx.dom.controllerDebugToast.textContent, /Copied controller buttonIndices config/);
|
||||||
|
assert.equal(ctx.dom.controllerDebugToast.classList.contains('hidden'), false);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'navigator', {
|
||||||
|
configurable: true,
|
||||||
|
value: previousNavigator,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
192
src/renderer/modals/controller-debug.ts
Normal file
192
src/renderer/modals/controller-debug.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import type { ModalStateReader, RendererContext } from '../context';
|
||||||
|
|
||||||
|
function formatAxes(values: number[]): string {
|
||||||
|
if (values.length === 0) return 'No controller axes available.';
|
||||||
|
return values.map((value, index) => `axis[${index}] = ${value.toFixed(3)}`).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatButtons(
|
||||||
|
values: Array<{ value: number; pressed: boolean; touched?: boolean }>,
|
||||||
|
): string {
|
||||||
|
if (values.length === 0) return 'No controller buttons available.';
|
||||||
|
return values
|
||||||
|
.map(
|
||||||
|
(button, index) =>
|
||||||
|
`button[${index}] value=${button.value.toFixed(3)} pressed=${button.pressed} touched=${button.touched ?? false}`,
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatButtonIndices(
|
||||||
|
value:
|
||||||
|
| {
|
||||||
|
select: number;
|
||||||
|
buttonSouth: number;
|
||||||
|
buttonEast: number;
|
||||||
|
buttonNorth: number;
|
||||||
|
buttonWest: number;
|
||||||
|
leftShoulder: number;
|
||||||
|
rightShoulder: number;
|
||||||
|
leftStickPress: number;
|
||||||
|
rightStickPress: number;
|
||||||
|
leftTrigger: number;
|
||||||
|
rightTrigger: number;
|
||||||
|
}
|
||||||
|
| null,
|
||||||
|
): string {
|
||||||
|
if (!value) {
|
||||||
|
return 'No controller config loaded.';
|
||||||
|
}
|
||||||
|
return `"buttonIndices": ${JSON.stringify(value, null, 2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeTextToClipboard(text: string): Promise<void> {
|
||||||
|
if (!navigator.clipboard?.writeText) {
|
||||||
|
throw new Error('Clipboard API unavailable.');
|
||||||
|
}
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createControllerDebugModal(
|
||||||
|
ctx: RendererContext,
|
||||||
|
options: {
|
||||||
|
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||||
|
syncSettingsModalSubtitleSuppression: () => void;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function setStatus(message: string, isError: boolean = false): void {
|
||||||
|
ctx.dom.controllerDebugStatus.textContent = message;
|
||||||
|
if (isError) {
|
||||||
|
ctx.dom.controllerDebugStatus.classList.add('error');
|
||||||
|
} else {
|
||||||
|
ctx.dom.controllerDebugStatus.classList.remove('error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearToastTimer(): void {
|
||||||
|
if (toastTimer === null) return;
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideToast(): void {
|
||||||
|
clearToastTimer();
|
||||||
|
ctx.dom.controllerDebugToast.classList.add('hidden');
|
||||||
|
ctx.dom.controllerDebugToast.classList.remove('error');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message: string, isError: boolean = false): void {
|
||||||
|
clearToastTimer();
|
||||||
|
ctx.dom.controllerDebugToast.textContent = message;
|
||||||
|
ctx.dom.controllerDebugToast.classList.remove('hidden');
|
||||||
|
if (isError) {
|
||||||
|
ctx.dom.controllerDebugToast.classList.add('error');
|
||||||
|
} else {
|
||||||
|
ctx.dom.controllerDebugToast.classList.remove('error');
|
||||||
|
}
|
||||||
|
toastTimer = setTimeout(() => {
|
||||||
|
hideToast();
|
||||||
|
}, 1800);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(): void {
|
||||||
|
const activeDevice = ctx.state.connectedGamepads.find(
|
||||||
|
(device) => device.id === ctx.state.activeGamepadId,
|
||||||
|
);
|
||||||
|
setStatus(
|
||||||
|
activeDevice?.id ??
|
||||||
|
(ctx.state.connectedGamepads.length > 0 ? 'Controller connected.' : 'No controller detected.'),
|
||||||
|
);
|
||||||
|
ctx.dom.controllerDebugSummary.textContent =
|
||||||
|
ctx.state.connectedGamepads.length > 0
|
||||||
|
? ctx.state.connectedGamepads
|
||||||
|
.map((device) => {
|
||||||
|
const tags = [
|
||||||
|
`#${device.index}`,
|
||||||
|
device.mapping,
|
||||||
|
device.id === ctx.state.activeGamepadId ? 'active' : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
return `${device.id || `Gamepad ${device.index}`} (${tags.join(', ')})`;
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
: 'Connect a controller and press any button to populate raw input values.';
|
||||||
|
ctx.dom.controllerDebugAxes.textContent = formatAxes(ctx.state.controllerRawAxes);
|
||||||
|
ctx.dom.controllerDebugButtons.textContent = formatButtons(ctx.state.controllerRawButtons);
|
||||||
|
ctx.dom.controllerDebugButtonIndices.textContent = formatButtonIndices(
|
||||||
|
ctx.state.controllerConfig?.buttonIndices ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyButtonIndicesToClipboard(): Promise<void> {
|
||||||
|
const text = ctx.dom.controllerDebugButtonIndices.textContent.trim();
|
||||||
|
if (text.length === 0 || text === 'No controller config loaded.') {
|
||||||
|
setStatus('No buttonIndices config available to copy.', true);
|
||||||
|
showToast('No buttonIndices config available to copy.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await writeTextToClipboard(text);
|
||||||
|
setStatus('Copied controller buttonIndices config.');
|
||||||
|
showToast('Copied controller buttonIndices config.');
|
||||||
|
} catch {
|
||||||
|
setStatus('Failed to copy controller buttonIndices config.', true);
|
||||||
|
showToast('Failed to copy controller buttonIndices config.', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openControllerDebugModal(): void {
|
||||||
|
ctx.state.controllerDebugModalOpen = true;
|
||||||
|
options.syncSettingsModalSubtitleSuppression();
|
||||||
|
ctx.dom.overlay.classList.add('interactive');
|
||||||
|
ctx.dom.controllerDebugModal.classList.remove('hidden');
|
||||||
|
ctx.dom.controllerDebugModal.setAttribute('aria-hidden', 'false');
|
||||||
|
hideToast();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeControllerDebugModal(): void {
|
||||||
|
if (!ctx.state.controllerDebugModalOpen) return;
|
||||||
|
ctx.state.controllerDebugModalOpen = false;
|
||||||
|
options.syncSettingsModalSubtitleSuppression();
|
||||||
|
ctx.dom.controllerDebugModal.classList.add('hidden');
|
||||||
|
ctx.dom.controllerDebugModal.setAttribute('aria-hidden', 'true');
|
||||||
|
hideToast();
|
||||||
|
window.electronAPI.notifyOverlayModalClosed('controller-debug');
|
||||||
|
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||||
|
ctx.dom.overlay.classList.remove('interactive');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleControllerDebugKeydown(event: KeyboardEvent): boolean {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
closeControllerDebugModal();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSnapshot(): void {
|
||||||
|
if (!ctx.state.controllerDebugModalOpen) return;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireDomEvents(): void {
|
||||||
|
ctx.dom.controllerDebugClose.addEventListener('click', () => {
|
||||||
|
closeControllerDebugModal();
|
||||||
|
});
|
||||||
|
ctx.dom.controllerDebugCopy.addEventListener('click', () => {
|
||||||
|
void copyButtonIndicesToClipboard();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
openControllerDebugModal,
|
||||||
|
closeControllerDebugModal,
|
||||||
|
handleControllerDebugKeydown,
|
||||||
|
updateSnapshot,
|
||||||
|
wireDomEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
727
src/renderer/modals/controller-select.test.ts
Normal file
727
src/renderer/modals/controller-select.test.ts
Normal file
@@ -0,0 +1,727 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { createRendererState } from '../state.js';
|
||||||
|
import { createControllerSelectModal } from './controller-select.js';
|
||||||
|
|
||||||
|
function createClassList(initialTokens: string[] = []) {
|
||||||
|
const tokens = new Set(initialTokens);
|
||||||
|
return {
|
||||||
|
add: (...entries: string[]) => {
|
||||||
|
for (const entry of entries) tokens.add(entry);
|
||||||
|
},
|
||||||
|
remove: (...entries: string[]) => {
|
||||||
|
for (const entry of entries) tokens.delete(entry);
|
||||||
|
},
|
||||||
|
toggle: (entry: string, force?: boolean) => {
|
||||||
|
if (force === undefined) {
|
||||||
|
if (tokens.has(entry)) tokens.delete(entry);
|
||||||
|
else tokens.add(entry);
|
||||||
|
return tokens.has(entry);
|
||||||
|
}
|
||||||
|
if (force) tokens.add(entry);
|
||||||
|
else tokens.delete(entry);
|
||||||
|
return force;
|
||||||
|
},
|
||||||
|
contains: (entry: string) => tokens.has(entry),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('controller select modal saves the selected preferred controller', async () => {
|
||||||
|
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||||
|
const previousWindow = globals.window;
|
||||||
|
const previousDocument = globals.document;
|
||||||
|
const saved: Array<{ preferredGamepadId: string; preferredGamepadLabel: string }> = [];
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
focus: () => {},
|
||||||
|
electronAPI: {
|
||||||
|
saveControllerPreference: async (update: {
|
||||||
|
preferredGamepadId: string;
|
||||||
|
preferredGamepadLabel: string;
|
||||||
|
}) => {
|
||||||
|
saved.push(update);
|
||||||
|
},
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
createElement: () => ({
|
||||||
|
className: '',
|
||||||
|
textContent: '',
|
||||||
|
classList: createClassList(),
|
||||||
|
appendChild: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const overlayClassList = createClassList();
|
||||||
|
const state = createRendererState();
|
||||||
|
state.controllerConfig = {
|
||||||
|
enabled: true,
|
||||||
|
preferredGamepadId: 'pad-2',
|
||||||
|
preferredGamepadLabel: 'pad-2',
|
||||||
|
smoothScroll: true,
|
||||||
|
scrollPixelsPerSecond: 960,
|
||||||
|
horizontalJumpPixels: 160,
|
||||||
|
stickDeadzone: 0.2,
|
||||||
|
triggerInputMode: 'auto',
|
||||||
|
triggerDeadzone: 0.5,
|
||||||
|
repeatDelayMs: 220,
|
||||||
|
repeatIntervalMs: 80,
|
||||||
|
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: 'buttonSouth',
|
||||||
|
closeLookup: 'buttonEast',
|
||||||
|
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||||
|
mineCard: 'buttonWest',
|
||||||
|
quitMpv: 'select',
|
||||||
|
previousAudio: 'leftShoulder',
|
||||||
|
nextAudio: 'rightShoulder',
|
||||||
|
playCurrentAudio: 'rightTrigger',
|
||||||
|
toggleMpvPause: 'leftTrigger',
|
||||||
|
leftStickHorizontal: 'leftStickX',
|
||||||
|
leftStickVertical: 'leftStickY',
|
||||||
|
rightStickHorizontal: 'rightStickX',
|
||||||
|
rightStickVertical: 'rightStickY',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
state.connectedGamepads = [
|
||||||
|
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
|
||||||
|
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
|
||||||
|
];
|
||||||
|
state.activeGamepadId = 'pad-2';
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
dom: {
|
||||||
|
overlay: { classList: overlayClassList, focus: () => {} },
|
||||||
|
controllerSelectModal: {
|
||||||
|
classList: createClassList(['hidden']),
|
||||||
|
setAttribute: () => {},
|
||||||
|
},
|
||||||
|
controllerSelectClose: { addEventListener: () => {} },
|
||||||
|
controllerSelectHint: { textContent: '' },
|
||||||
|
controllerSelectStatus: { textContent: '', classList: createClassList() },
|
||||||
|
controllerSelectList: {
|
||||||
|
innerHTML: '',
|
||||||
|
appendChild: () => {},
|
||||||
|
},
|
||||||
|
controllerSelectSave: { addEventListener: () => {} },
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
|
||||||
|
const modal = createControllerSelectModal(ctx as never, {
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.openControllerSelectModal();
|
||||||
|
assert.equal(state.controllerDeviceSelectedIndex, 1);
|
||||||
|
|
||||||
|
await modal.handleControllerSelectKeydown({
|
||||||
|
key: 'Enter',
|
||||||
|
preventDefault: () => {},
|
||||||
|
} as KeyboardEvent);
|
||||||
|
|
||||||
|
assert.deepEqual(saved, [
|
||||||
|
{
|
||||||
|
preferredGamepadId: 'pad-2',
|
||||||
|
preferredGamepadLabel: 'pad-2',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('controller select modal preserves manual selection while controller polling updates', async () => {
|
||||||
|
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||||
|
const previousWindow = globals.window;
|
||||||
|
const previousDocument = globals.document;
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
focus: () => {},
|
||||||
|
electronAPI: {
|
||||||
|
saveControllerPreference: async () => {},
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
createElement: () => ({
|
||||||
|
className: '',
|
||||||
|
textContent: '',
|
||||||
|
classList: createClassList(),
|
||||||
|
appendChild: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = createRendererState();
|
||||||
|
state.controllerConfig = {
|
||||||
|
enabled: true,
|
||||||
|
preferredGamepadId: 'pad-1',
|
||||||
|
preferredGamepadLabel: 'pad-1',
|
||||||
|
smoothScroll: true,
|
||||||
|
scrollPixelsPerSecond: 960,
|
||||||
|
horizontalJumpPixels: 160,
|
||||||
|
stickDeadzone: 0.2,
|
||||||
|
triggerInputMode: 'auto',
|
||||||
|
triggerDeadzone: 0.5,
|
||||||
|
repeatDelayMs: 220,
|
||||||
|
repeatIntervalMs: 80,
|
||||||
|
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: 'buttonSouth',
|
||||||
|
closeLookup: 'buttonEast',
|
||||||
|
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||||
|
mineCard: 'buttonWest',
|
||||||
|
quitMpv: 'select',
|
||||||
|
previousAudio: 'none',
|
||||||
|
nextAudio: 'rightShoulder',
|
||||||
|
playCurrentAudio: 'leftShoulder',
|
||||||
|
toggleMpvPause: 'leftStickPress',
|
||||||
|
leftStickHorizontal: 'leftStickX',
|
||||||
|
leftStickVertical: 'leftStickY',
|
||||||
|
rightStickHorizontal: 'rightStickX',
|
||||||
|
rightStickVertical: 'rightStickY',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
state.connectedGamepads = [
|
||||||
|
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
|
||||||
|
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
|
||||||
|
];
|
||||||
|
state.activeGamepadId = 'pad-1';
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
dom: {
|
||||||
|
overlay: { classList: createClassList(), focus: () => {} },
|
||||||
|
controllerSelectModal: {
|
||||||
|
classList: createClassList(['hidden']),
|
||||||
|
setAttribute: () => {},
|
||||||
|
},
|
||||||
|
controllerSelectClose: { addEventListener: () => {} },
|
||||||
|
controllerSelectHint: { textContent: '' },
|
||||||
|
controllerSelectStatus: { textContent: '', classList: createClassList() },
|
||||||
|
controllerSelectList: {
|
||||||
|
innerHTML: '',
|
||||||
|
appendChild: () => {},
|
||||||
|
},
|
||||||
|
controllerSelectSave: { addEventListener: () => {} },
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
|
||||||
|
const modal = createControllerSelectModal(ctx as never, {
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.openControllerSelectModal();
|
||||||
|
assert.equal(state.controllerDeviceSelectedIndex, 0);
|
||||||
|
|
||||||
|
modal.handleControllerSelectKeydown({
|
||||||
|
key: 'ArrowDown',
|
||||||
|
preventDefault: () => {},
|
||||||
|
} as KeyboardEvent);
|
||||||
|
assert.equal(state.controllerDeviceSelectedIndex, 1);
|
||||||
|
|
||||||
|
modal.updateDevices();
|
||||||
|
|
||||||
|
assert.equal(state.controllerDeviceSelectedIndex, 1);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('controller select modal prefers active controller over saved preferred controller', () => {
|
||||||
|
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||||
|
const previousWindow = globals.window;
|
||||||
|
const previousDocument = globals.document;
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
focus: () => {},
|
||||||
|
electronAPI: {
|
||||||
|
saveControllerPreference: async () => {},
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
createElement: () => ({
|
||||||
|
className: '',
|
||||||
|
textContent: '',
|
||||||
|
classList: createClassList(),
|
||||||
|
appendChild: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = createRendererState();
|
||||||
|
state.controllerConfig = {
|
||||||
|
enabled: true,
|
||||||
|
preferredGamepadId: 'pad-1',
|
||||||
|
preferredGamepadLabel: 'pad-1',
|
||||||
|
smoothScroll: true,
|
||||||
|
scrollPixelsPerSecond: 960,
|
||||||
|
horizontalJumpPixels: 160,
|
||||||
|
stickDeadzone: 0.2,
|
||||||
|
triggerInputMode: 'auto',
|
||||||
|
triggerDeadzone: 0.5,
|
||||||
|
repeatDelayMs: 220,
|
||||||
|
repeatIntervalMs: 80,
|
||||||
|
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: 'buttonSouth',
|
||||||
|
closeLookup: 'buttonEast',
|
||||||
|
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||||
|
mineCard: 'buttonWest',
|
||||||
|
quitMpv: 'select',
|
||||||
|
previousAudio: 'none',
|
||||||
|
nextAudio: 'rightShoulder',
|
||||||
|
playCurrentAudio: 'leftShoulder',
|
||||||
|
toggleMpvPause: 'leftStickPress',
|
||||||
|
leftStickHorizontal: 'leftStickX',
|
||||||
|
leftStickVertical: 'leftStickY',
|
||||||
|
rightStickHorizontal: 'rightStickX',
|
||||||
|
rightStickVertical: 'rightStickY',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
state.connectedGamepads = [
|
||||||
|
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
|
||||||
|
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
|
||||||
|
];
|
||||||
|
state.activeGamepadId = 'pad-2';
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
dom: {
|
||||||
|
overlay: { classList: createClassList(), focus: () => {} },
|
||||||
|
controllerSelectModal: {
|
||||||
|
classList: createClassList(['hidden']),
|
||||||
|
setAttribute: () => {},
|
||||||
|
},
|
||||||
|
controllerSelectClose: { addEventListener: () => {} },
|
||||||
|
controllerSelectHint: { textContent: '' },
|
||||||
|
controllerSelectStatus: { textContent: '', classList: createClassList() },
|
||||||
|
controllerSelectList: {
|
||||||
|
innerHTML: '',
|
||||||
|
appendChild: () => {},
|
||||||
|
},
|
||||||
|
controllerSelectSave: { addEventListener: () => {} },
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
|
||||||
|
const modal = createControllerSelectModal(ctx as never, {
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.openControllerSelectModal();
|
||||||
|
|
||||||
|
assert.equal(state.controllerDeviceSelectedIndex, 1);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('controller select modal preserves saved status across polling updates', async () => {
|
||||||
|
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||||
|
const previousWindow = globals.window;
|
||||||
|
const previousDocument = globals.document;
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
focus: () => {},
|
||||||
|
electronAPI: {
|
||||||
|
saveControllerPreference: async () => {},
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
createElement: () => ({
|
||||||
|
className: '',
|
||||||
|
textContent: '',
|
||||||
|
classList: createClassList(),
|
||||||
|
appendChild: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = createRendererState();
|
||||||
|
state.controllerConfig = {
|
||||||
|
enabled: true,
|
||||||
|
preferredGamepadId: 'pad-1',
|
||||||
|
preferredGamepadLabel: 'pad-1',
|
||||||
|
smoothScroll: true,
|
||||||
|
scrollPixelsPerSecond: 960,
|
||||||
|
horizontalJumpPixels: 160,
|
||||||
|
stickDeadzone: 0.2,
|
||||||
|
triggerInputMode: 'auto',
|
||||||
|
triggerDeadzone: 0.5,
|
||||||
|
repeatDelayMs: 220,
|
||||||
|
repeatIntervalMs: 80,
|
||||||
|
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: 'buttonSouth',
|
||||||
|
closeLookup: 'buttonEast',
|
||||||
|
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||||
|
mineCard: 'buttonWest',
|
||||||
|
quitMpv: 'select',
|
||||||
|
previousAudio: 'none',
|
||||||
|
nextAudio: 'rightShoulder',
|
||||||
|
playCurrentAudio: 'leftShoulder',
|
||||||
|
toggleMpvPause: 'leftStickPress',
|
||||||
|
leftStickHorizontal: 'leftStickX',
|
||||||
|
leftStickVertical: 'leftStickY',
|
||||||
|
rightStickHorizontal: 'rightStickX',
|
||||||
|
rightStickVertical: 'rightStickY',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
state.connectedGamepads = [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }];
|
||||||
|
state.activeGamepadId = 'pad-1';
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
dom: {
|
||||||
|
overlay: { classList: createClassList(), focus: () => {} },
|
||||||
|
controllerSelectModal: {
|
||||||
|
classList: createClassList(['hidden']),
|
||||||
|
setAttribute: () => {},
|
||||||
|
},
|
||||||
|
controllerSelectClose: { addEventListener: () => {} },
|
||||||
|
controllerSelectHint: { textContent: '' },
|
||||||
|
controllerSelectStatus: { textContent: '', classList: createClassList() },
|
||||||
|
controllerSelectList: {
|
||||||
|
innerHTML: '',
|
||||||
|
appendChild: () => {},
|
||||||
|
},
|
||||||
|
controllerSelectSave: { addEventListener: () => {} },
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
|
||||||
|
const modal = createControllerSelectModal(ctx as never, {
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.openControllerSelectModal();
|
||||||
|
await modal.handleControllerSelectKeydown({
|
||||||
|
key: 'Enter',
|
||||||
|
preventDefault: () => {},
|
||||||
|
} as KeyboardEvent);
|
||||||
|
modal.updateDevices();
|
||||||
|
|
||||||
|
assert.match(ctx.dom.controllerSelectStatus.textContent, /Saved preferred controller/);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('controller select modal surfaces save errors without mutating saved preference', async () => {
|
||||||
|
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||||
|
const previousWindow = globals.window;
|
||||||
|
const previousDocument = globals.document;
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
focus: () => {},
|
||||||
|
electronAPI: {
|
||||||
|
saveControllerPreference: async () => {
|
||||||
|
throw new Error('disk write failed');
|
||||||
|
},
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
createElement: () => ({
|
||||||
|
className: '',
|
||||||
|
textContent: '',
|
||||||
|
classList: createClassList(),
|
||||||
|
appendChild: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = createRendererState();
|
||||||
|
state.controllerConfig = {
|
||||||
|
enabled: true,
|
||||||
|
preferredGamepadId: 'pad-1',
|
||||||
|
preferredGamepadLabel: 'pad-1',
|
||||||
|
smoothScroll: true,
|
||||||
|
scrollPixelsPerSecond: 960,
|
||||||
|
horizontalJumpPixels: 160,
|
||||||
|
stickDeadzone: 0.2,
|
||||||
|
triggerInputMode: 'auto',
|
||||||
|
triggerDeadzone: 0.5,
|
||||||
|
repeatDelayMs: 220,
|
||||||
|
repeatIntervalMs: 80,
|
||||||
|
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: 'buttonSouth',
|
||||||
|
closeLookup: 'buttonEast',
|
||||||
|
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||||
|
mineCard: 'buttonWest',
|
||||||
|
quitMpv: 'select',
|
||||||
|
previousAudio: 'none',
|
||||||
|
nextAudio: 'rightShoulder',
|
||||||
|
playCurrentAudio: 'leftShoulder',
|
||||||
|
toggleMpvPause: 'leftStickPress',
|
||||||
|
leftStickHorizontal: 'leftStickX',
|
||||||
|
leftStickVertical: 'leftStickY',
|
||||||
|
rightStickHorizontal: 'rightStickX',
|
||||||
|
rightStickVertical: 'rightStickY',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
state.connectedGamepads = [{ id: 'pad-2', index: 1, mapping: 'standard', connected: true }];
|
||||||
|
state.activeGamepadId = 'pad-2';
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
dom: {
|
||||||
|
overlay: { classList: createClassList(), focus: () => {} },
|
||||||
|
controllerSelectModal: {
|
||||||
|
classList: createClassList(['hidden']),
|
||||||
|
setAttribute: () => {},
|
||||||
|
},
|
||||||
|
controllerSelectClose: { addEventListener: () => {} },
|
||||||
|
controllerSelectHint: { textContent: '' },
|
||||||
|
controllerSelectStatus: { textContent: '', classList: createClassList() },
|
||||||
|
controllerSelectList: {
|
||||||
|
innerHTML: '',
|
||||||
|
appendChild: () => {},
|
||||||
|
},
|
||||||
|
controllerSelectSave: { addEventListener: () => {} },
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
|
||||||
|
const modal = createControllerSelectModal(ctx as never, {
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.openControllerSelectModal();
|
||||||
|
await modal.handleControllerSelectKeydown({
|
||||||
|
key: 'Enter',
|
||||||
|
preventDefault: () => {},
|
||||||
|
} as KeyboardEvent);
|
||||||
|
|
||||||
|
assert.match(ctx.dom.controllerSelectStatus.textContent, /Failed to save preferred controller/);
|
||||||
|
assert.equal(state.controllerConfig.preferredGamepadId, 'pad-1');
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('controller select modal does not rerender unchanged device snapshots every poll', () => {
|
||||||
|
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||||
|
const previousWindow = globals.window;
|
||||||
|
const previousDocument = globals.document;
|
||||||
|
let appendCount = 0;
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
focus: () => {},
|
||||||
|
electronAPI: {
|
||||||
|
saveControllerPreference: async () => {},
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
createElement: () => ({
|
||||||
|
className: '',
|
||||||
|
textContent: '',
|
||||||
|
classList: createClassList(),
|
||||||
|
appendChild: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = createRendererState();
|
||||||
|
state.controllerConfig = {
|
||||||
|
enabled: true,
|
||||||
|
preferredGamepadId: 'pad-1',
|
||||||
|
preferredGamepadLabel: 'pad-1',
|
||||||
|
smoothScroll: true,
|
||||||
|
scrollPixelsPerSecond: 960,
|
||||||
|
horizontalJumpPixels: 160,
|
||||||
|
stickDeadzone: 0.2,
|
||||||
|
triggerInputMode: 'auto',
|
||||||
|
triggerDeadzone: 0.5,
|
||||||
|
repeatDelayMs: 220,
|
||||||
|
repeatIntervalMs: 80,
|
||||||
|
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: 'buttonSouth',
|
||||||
|
closeLookup: 'buttonEast',
|
||||||
|
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||||
|
mineCard: 'buttonWest',
|
||||||
|
quitMpv: 'select',
|
||||||
|
previousAudio: 'none',
|
||||||
|
nextAudio: 'rightShoulder',
|
||||||
|
playCurrentAudio: 'leftShoulder',
|
||||||
|
toggleMpvPause: 'leftStickPress',
|
||||||
|
leftStickHorizontal: 'leftStickX',
|
||||||
|
leftStickVertical: 'leftStickY',
|
||||||
|
rightStickHorizontal: 'rightStickX',
|
||||||
|
rightStickVertical: 'rightStickY',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
state.connectedGamepads = [
|
||||||
|
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
|
||||||
|
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
|
||||||
|
];
|
||||||
|
state.activeGamepadId = 'pad-1';
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
dom: {
|
||||||
|
overlay: { classList: createClassList(), focus: () => {} },
|
||||||
|
controllerSelectModal: {
|
||||||
|
classList: createClassList(['hidden']),
|
||||||
|
setAttribute: () => {},
|
||||||
|
},
|
||||||
|
controllerSelectClose: { addEventListener: () => {} },
|
||||||
|
controllerSelectHint: { textContent: '' },
|
||||||
|
controllerSelectStatus: { textContent: '', classList: createClassList() },
|
||||||
|
controllerSelectList: {
|
||||||
|
innerHTML: '',
|
||||||
|
appendChild: () => {
|
||||||
|
appendCount += 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
controllerSelectSave: { addEventListener: () => {} },
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
|
||||||
|
const modal = createControllerSelectModal(ctx as never, {
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.openControllerSelectModal();
|
||||||
|
const initialAppendCount = appendCount;
|
||||||
|
|
||||||
|
modal.updateDevices();
|
||||||
|
|
||||||
|
assert.equal(appendCount, initialAppendCount);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
264
src/renderer/modals/controller-select.ts
Normal file
264
src/renderer/modals/controller-select.ts
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
import type { ModalStateReader, RendererContext } from '../context';
|
||||||
|
|
||||||
|
function clampSelectedIndex(ctx: RendererContext): void {
|
||||||
|
if (ctx.state.connectedGamepads.length === 0) {
|
||||||
|
ctx.state.controllerDeviceSelectedIndex = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.state.controllerDeviceSelectedIndex = Math.min(
|
||||||
|
Math.max(ctx.state.controllerDeviceSelectedIndex, 0),
|
||||||
|
ctx.state.connectedGamepads.length - 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createControllerSelectModal(
|
||||||
|
ctx: RendererContext,
|
||||||
|
options: {
|
||||||
|
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||||
|
syncSettingsModalSubtitleSuppression: () => void;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
let selectedControllerId: string | null = null;
|
||||||
|
let lastRenderedDevicesKey = '';
|
||||||
|
let lastRenderedActiveGamepadId: string | null = null;
|
||||||
|
let lastRenderedPreferredId = '';
|
||||||
|
|
||||||
|
function getDevicesKey(): string {
|
||||||
|
return ctx.state.connectedGamepads
|
||||||
|
.map((device) => `${device.id}|${device.index}|${device.mapping}|${device.connected}`)
|
||||||
|
.join('||');
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSelectedControllerId(): void {
|
||||||
|
const selected = ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex];
|
||||||
|
selectedControllerId = selected?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSelectedIndexToCurrentController(): void {
|
||||||
|
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
|
||||||
|
const activeIndex = ctx.state.connectedGamepads.findIndex(
|
||||||
|
(device) => device.id === ctx.state.activeGamepadId,
|
||||||
|
);
|
||||||
|
if (activeIndex >= 0) {
|
||||||
|
ctx.state.controllerDeviceSelectedIndex = activeIndex;
|
||||||
|
syncSelectedControllerId();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const preferredIndex = ctx.state.connectedGamepads.findIndex((device) => device.id === preferredId);
|
||||||
|
if (preferredIndex >= 0) {
|
||||||
|
ctx.state.controllerDeviceSelectedIndex = preferredIndex;
|
||||||
|
syncSelectedControllerId();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clampSelectedIndex(ctx);
|
||||||
|
syncSelectedControllerId();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(message: string, isError = false): void {
|
||||||
|
ctx.dom.controllerSelectStatus.textContent = message;
|
||||||
|
ctx.dom.controllerSelectStatus.classList.toggle('error', isError);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList(): void {
|
||||||
|
ctx.dom.controllerSelectList.innerHTML = '';
|
||||||
|
clampSelectedIndex(ctx);
|
||||||
|
|
||||||
|
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
|
||||||
|
ctx.state.connectedGamepads.forEach((device, index) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'runtime-options-list-entry';
|
||||||
|
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = 'runtime-options-item runtime-options-item-button';
|
||||||
|
button.classList.toggle('active', index === ctx.state.controllerDeviceSelectedIndex);
|
||||||
|
|
||||||
|
const label = document.createElement('div');
|
||||||
|
label.className = 'runtime-options-label';
|
||||||
|
label.textContent = device.id || `Gamepad ${device.index}`;
|
||||||
|
|
||||||
|
const meta = document.createElement('div');
|
||||||
|
meta.className = 'runtime-options-value';
|
||||||
|
const tags = [
|
||||||
|
`Index ${device.index}`,
|
||||||
|
device.mapping || 'unknown mapping',
|
||||||
|
device.id === ctx.state.activeGamepadId ? 'active' : null,
|
||||||
|
device.id === preferredId ? 'saved' : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
meta.textContent = tags.join(' · ');
|
||||||
|
|
||||||
|
button.appendChild(label);
|
||||||
|
button.appendChild(meta);
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
ctx.state.controllerDeviceSelectedIndex = index;
|
||||||
|
syncSelectedControllerId();
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
button.addEventListener('dblclick', () => {
|
||||||
|
ctx.state.controllerDeviceSelectedIndex = index;
|
||||||
|
syncSelectedControllerId();
|
||||||
|
void saveSelectedController();
|
||||||
|
});
|
||||||
|
li.appendChild(button);
|
||||||
|
|
||||||
|
ctx.dom.controllerSelectList.appendChild(li);
|
||||||
|
});
|
||||||
|
|
||||||
|
lastRenderedDevicesKey = getDevicesKey();
|
||||||
|
lastRenderedActiveGamepadId = ctx.state.activeGamepadId;
|
||||||
|
lastRenderedPreferredId = preferredId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDevices(): void {
|
||||||
|
if (!ctx.state.controllerSelectModalOpen) return;
|
||||||
|
if (selectedControllerId) {
|
||||||
|
const preservedIndex = ctx.state.connectedGamepads.findIndex(
|
||||||
|
(device) => device.id === selectedControllerId,
|
||||||
|
);
|
||||||
|
if (preservedIndex >= 0) {
|
||||||
|
ctx.state.controllerDeviceSelectedIndex = preservedIndex;
|
||||||
|
} else {
|
||||||
|
syncSelectedIndexToCurrentController();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
syncSelectedIndexToCurrentController();
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
|
||||||
|
const shouldRender =
|
||||||
|
getDevicesKey() !== lastRenderedDevicesKey ||
|
||||||
|
ctx.state.activeGamepadId !== lastRenderedActiveGamepadId ||
|
||||||
|
preferredId !== lastRenderedPreferredId;
|
||||||
|
if (shouldRender) {
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.state.connectedGamepads.length === 0) {
|
||||||
|
setStatus('No controllers detected.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentStatus = ctx.dom.controllerSelectStatus.textContent.trim();
|
||||||
|
if (
|
||||||
|
currentStatus !== 'No controller selected.' &&
|
||||||
|
!currentStatus.startsWith('Saved preferred controller:')
|
||||||
|
) {
|
||||||
|
setStatus('Select a controller to save as preferred.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSelectedController(): Promise<void> {
|
||||||
|
const selected = ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex];
|
||||||
|
if (!selected) {
|
||||||
|
setStatus('No controller selected.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.electronAPI.saveControllerPreference({
|
||||||
|
preferredGamepadId: selected.id,
|
||||||
|
preferredGamepadLabel: selected.id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
setStatus(`Failed to save preferred controller: ${message}`, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.state.controllerConfig) {
|
||||||
|
ctx.state.controllerConfig.preferredGamepadId = selected.id;
|
||||||
|
ctx.state.controllerConfig.preferredGamepadLabel = selected.id;
|
||||||
|
}
|
||||||
|
syncSelectedControllerId();
|
||||||
|
renderList();
|
||||||
|
setStatus(`Saved preferred controller: ${selected.id || `Gamepad ${selected.index}`}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openControllerSelectModal(): void {
|
||||||
|
ctx.state.controllerSelectModalOpen = true;
|
||||||
|
syncSelectedIndexToCurrentController();
|
||||||
|
options.syncSettingsModalSubtitleSuppression();
|
||||||
|
ctx.dom.overlay.classList.add('interactive');
|
||||||
|
ctx.dom.controllerSelectModal.classList.remove('hidden');
|
||||||
|
ctx.dom.controllerSelectModal.setAttribute('aria-hidden', 'false');
|
||||||
|
window.focus();
|
||||||
|
ctx.dom.overlay.focus({ preventScroll: true });
|
||||||
|
renderList();
|
||||||
|
if (ctx.state.connectedGamepads.length === 0) {
|
||||||
|
setStatus('No controllers detected.');
|
||||||
|
} else {
|
||||||
|
setStatus('Select a controller to save as preferred.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeControllerSelectModal(): void {
|
||||||
|
if (!ctx.state.controllerSelectModalOpen) return;
|
||||||
|
ctx.state.controllerSelectModalOpen = false;
|
||||||
|
options.syncSettingsModalSubtitleSuppression();
|
||||||
|
ctx.dom.controllerSelectModal.classList.add('hidden');
|
||||||
|
ctx.dom.controllerSelectModal.setAttribute('aria-hidden', 'true');
|
||||||
|
window.electronAPI.notifyOverlayModalClosed('controller-select');
|
||||||
|
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||||
|
ctx.dom.overlay.classList.remove('interactive');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleControllerSelectKeydown(event: KeyboardEvent): boolean {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
closeControllerSelectModal();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowDown' || event.key === 'j' || event.key === 'J') {
|
||||||
|
event.preventDefault();
|
||||||
|
if (ctx.state.connectedGamepads.length > 0) {
|
||||||
|
ctx.state.controllerDeviceSelectedIndex = Math.min(
|
||||||
|
ctx.state.connectedGamepads.length - 1,
|
||||||
|
ctx.state.controllerDeviceSelectedIndex + 1,
|
||||||
|
);
|
||||||
|
syncSelectedControllerId();
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowUp' || event.key === 'k' || event.key === 'K') {
|
||||||
|
event.preventDefault();
|
||||||
|
if (ctx.state.connectedGamepads.length > 0) {
|
||||||
|
ctx.state.controllerDeviceSelectedIndex = Math.max(
|
||||||
|
0,
|
||||||
|
ctx.state.controllerDeviceSelectedIndex - 1,
|
||||||
|
);
|
||||||
|
syncSelectedControllerId();
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
void saveSelectedController();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireDomEvents(): void {
|
||||||
|
ctx.dom.controllerSelectClose.addEventListener('click', () => {
|
||||||
|
closeControllerSelectModal();
|
||||||
|
});
|
||||||
|
ctx.dom.controllerSelectSave.addEventListener('click', () => {
|
||||||
|
void saveSelectedController();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
openControllerSelectModal,
|
||||||
|
closeControllerSelectModal,
|
||||||
|
handleControllerSelectKeydown,
|
||||||
|
updateDevices,
|
||||||
|
wireDomEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -26,7 +26,11 @@ import type {
|
|||||||
ConfigHotReloadPayload,
|
ConfigHotReloadPayload,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { createKeyboardHandlers } from './handlers/keyboard.js';
|
import { createKeyboardHandlers } from './handlers/keyboard.js';
|
||||||
|
import { createGamepadController } from './handlers/gamepad-controller.js';
|
||||||
import { createMouseHandlers } from './handlers/mouse.js';
|
import { createMouseHandlers } from './handlers/mouse.js';
|
||||||
|
import { createControllerStatusIndicator } from './controller-status-indicator.js';
|
||||||
|
import { createControllerDebugModal } from './modals/controller-debug.js';
|
||||||
|
import { createControllerSelectModal } from './modals/controller-select.js';
|
||||||
import { createJimakuModal } from './modals/jimaku.js';
|
import { createJimakuModal } from './modals/jimaku.js';
|
||||||
import { createKikuModal } from './modals/kiku.js';
|
import { createKikuModal } from './modals/kiku.js';
|
||||||
import { createSessionHelpModal } from './modals/session-help.js';
|
import { createSessionHelpModal } from './modals/session-help.js';
|
||||||
@@ -36,6 +40,7 @@ import { createPositioningController } from './positioning.js';
|
|||||||
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
|
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
|
||||||
import { createRendererState } from './state.js';
|
import { createRendererState } from './state.js';
|
||||||
import { createSubtitleRenderer } from './subtitle-render.js';
|
import { createSubtitleRenderer } from './subtitle-render.js';
|
||||||
|
import { isYomitanPopupVisible } from './yomitan-popup.js';
|
||||||
import {
|
import {
|
||||||
createRendererRecoveryController,
|
createRendererRecoveryController,
|
||||||
registerRendererGlobalErrorHandlers,
|
registerRendererGlobalErrorHandlers,
|
||||||
@@ -55,6 +60,8 @@ const ctx = {
|
|||||||
|
|
||||||
function isAnySettingsModalOpen(): boolean {
|
function isAnySettingsModalOpen(): boolean {
|
||||||
return (
|
return (
|
||||||
|
ctx.state.controllerSelectModalOpen ||
|
||||||
|
ctx.state.controllerDebugModalOpen ||
|
||||||
ctx.state.runtimeOptionsModalOpen ||
|
ctx.state.runtimeOptionsModalOpen ||
|
||||||
ctx.state.subsyncModalOpen ||
|
ctx.state.subsyncModalOpen ||
|
||||||
ctx.state.kikuModalOpen ||
|
ctx.state.kikuModalOpen ||
|
||||||
@@ -65,6 +72,8 @@ function isAnySettingsModalOpen(): boolean {
|
|||||||
|
|
||||||
function isAnyModalOpen(): boolean {
|
function isAnyModalOpen(): boolean {
|
||||||
return (
|
return (
|
||||||
|
ctx.state.controllerSelectModalOpen ||
|
||||||
|
ctx.state.controllerDebugModalOpen ||
|
||||||
ctx.state.jimakuModalOpen ||
|
ctx.state.jimakuModalOpen ||
|
||||||
ctx.state.kikuModalOpen ||
|
ctx.state.kikuModalOpen ||
|
||||||
ctx.state.runtimeOptionsModalOpen ||
|
ctx.state.runtimeOptionsModalOpen ||
|
||||||
@@ -92,6 +101,15 @@ const subsyncModal = createSubsyncModal(ctx, {
|
|||||||
modalStateReader: { isAnyModalOpen },
|
modalStateReader: { isAnyModalOpen },
|
||||||
syncSettingsModalSubtitleSuppression,
|
syncSettingsModalSubtitleSuppression,
|
||||||
});
|
});
|
||||||
|
const controllerSelectModal = createControllerSelectModal(ctx, {
|
||||||
|
modalStateReader: { isAnyModalOpen },
|
||||||
|
syncSettingsModalSubtitleSuppression,
|
||||||
|
});
|
||||||
|
const controllerDebugModal = createControllerDebugModal(ctx, {
|
||||||
|
modalStateReader: { isAnyModalOpen },
|
||||||
|
syncSettingsModalSubtitleSuppression,
|
||||||
|
});
|
||||||
|
const controllerStatusIndicator = createControllerStatusIndicator(ctx.dom);
|
||||||
const sessionHelpModal = createSessionHelpModal(ctx, {
|
const sessionHelpModal = createSessionHelpModal(ctx, {
|
||||||
modalStateReader: { isAnyModalOpen },
|
modalStateReader: { isAnyModalOpen },
|
||||||
syncSettingsModalSubtitleSuppression,
|
syncSettingsModalSubtitleSuppression,
|
||||||
@@ -109,12 +127,22 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
|||||||
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
|
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
|
||||||
handleKikuKeydown: kikuModal.handleKikuKeydown,
|
handleKikuKeydown: kikuModal.handleKikuKeydown,
|
||||||
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
|
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
|
||||||
|
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
|
||||||
|
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
|
||||||
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
||||||
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
|
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
|
||||||
appendClipboardVideoToQueue: () => {
|
appendClipboardVideoToQueue: () => {
|
||||||
void window.electronAPI.appendClipboardVideoToQueue();
|
void window.electronAPI.appendClipboardVideoToQueue();
|
||||||
},
|
},
|
||||||
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
|
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
|
||||||
|
openControllerSelectModal: () => {
|
||||||
|
controllerSelectModal.openControllerSelectModal();
|
||||||
|
window.electronAPI.notifyOverlayModalOpened('controller-select');
|
||||||
|
},
|
||||||
|
openControllerDebugModal: () => {
|
||||||
|
controllerDebugModal.openControllerDebugModal();
|
||||||
|
window.electronAPI.notifyOverlayModalOpened('controller-debug');
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const mouseHandlers = createMouseHandlers(ctx, {
|
const mouseHandlers = createMouseHandlers(ctx, {
|
||||||
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
|
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
|
||||||
@@ -132,6 +160,7 @@ const mouseHandlers = createMouseHandlers(ctx, {
|
|||||||
let lastSubtitlePreview = '';
|
let lastSubtitlePreview = '';
|
||||||
let lastSecondarySubtitlePreview = '';
|
let lastSecondarySubtitlePreview = '';
|
||||||
let overlayErrorToastTimeout: ReturnType<typeof setTimeout> | null = null;
|
let overlayErrorToastTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let controllerAnimationFrameId: number | null = null;
|
||||||
|
|
||||||
function truncateForErrorLog(text: string): string {
|
function truncateForErrorLog(text: string): string {
|
||||||
const normalized = text.replace(/\s+/g, ' ').trim();
|
const normalized = text.replace(/\s+/g, ' ').trim();
|
||||||
@@ -152,6 +181,8 @@ function getSubtitleTextForPreview(data: SubtitleData | string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getActiveModal(): string | null {
|
function getActiveModal(): string | null {
|
||||||
|
if (ctx.state.controllerSelectModalOpen) return 'controller-select';
|
||||||
|
if (ctx.state.controllerDebugModalOpen) return 'controller-debug';
|
||||||
if (ctx.state.jimakuModalOpen) return 'jimaku';
|
if (ctx.state.jimakuModalOpen) return 'jimaku';
|
||||||
if (ctx.state.kikuModalOpen) return 'kiku';
|
if (ctx.state.kikuModalOpen) return 'kiku';
|
||||||
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
|
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
|
||||||
@@ -161,6 +192,12 @@ function getActiveModal(): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function dismissActiveUiAfterError(): void {
|
function dismissActiveUiAfterError(): void {
|
||||||
|
if (ctx.state.controllerSelectModalOpen) {
|
||||||
|
controllerSelectModal.closeControllerSelectModal();
|
||||||
|
}
|
||||||
|
if (ctx.state.controllerDebugModalOpen) {
|
||||||
|
controllerDebugModal.closeControllerDebugModal();
|
||||||
|
}
|
||||||
if (ctx.state.jimakuModalOpen) {
|
if (ctx.state.jimakuModalOpen) {
|
||||||
jimakuModal.closeJimakuModal();
|
jimakuModal.closeJimakuModal();
|
||||||
}
|
}
|
||||||
@@ -180,6 +217,132 @@ function dismissActiveUiAfterError(): void {
|
|||||||
syncSettingsModalSubtitleSuppression();
|
syncSettingsModalSubtitleSuppression();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyControllerSnapshot(snapshot: {
|
||||||
|
connectedGamepads: Array<{ id: string; index: number; mapping: string; connected: boolean }>;
|
||||||
|
activeGamepadId: string | null;
|
||||||
|
rawAxes: number[];
|
||||||
|
rawButtons: Array<{ value: number; pressed: boolean; touched?: boolean }>;
|
||||||
|
}): void {
|
||||||
|
controllerStatusIndicator.update({
|
||||||
|
connectedGamepads: snapshot.connectedGamepads,
|
||||||
|
activeGamepadId: snapshot.activeGamepadId,
|
||||||
|
});
|
||||||
|
ctx.state.connectedGamepads = snapshot.connectedGamepads;
|
||||||
|
ctx.state.activeGamepadId = snapshot.activeGamepadId;
|
||||||
|
ctx.state.controllerRawAxes = snapshot.rawAxes;
|
||||||
|
ctx.state.controllerRawButtons = snapshot.rawButtons;
|
||||||
|
controllerSelectModal.updateDevices();
|
||||||
|
controllerDebugModal.updateSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitControllerPopupScroll(deltaPixels: number): void {
|
||||||
|
if (deltaPixels === 0) return;
|
||||||
|
keyboardHandlers.scrollPopupByController(0, deltaPixels);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitControllerPopupJump(deltaPixels: number): void {
|
||||||
|
if (deltaPixels === 0) return;
|
||||||
|
keyboardHandlers.scrollPopupByController(0, deltaPixels * 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startControllerPolling(): void {
|
||||||
|
if (controllerAnimationFrameId !== null) {
|
||||||
|
cancelAnimationFrame(controllerAnimationFrameId);
|
||||||
|
controllerAnimationFrameId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gamepadController = createGamepadController({
|
||||||
|
getGamepads: () => Array.from(navigator.getGamepads?.() ?? []),
|
||||||
|
getConfig: () =>
|
||||||
|
ctx.state.controllerConfig ?? {
|
||||||
|
enabled: true,
|
||||||
|
preferredGamepadId: '',
|
||||||
|
preferredGamepadLabel: '',
|
||||||
|
smoothScroll: true,
|
||||||
|
scrollPixelsPerSecond: 900,
|
||||||
|
horizontalJumpPixels: 160,
|
||||||
|
stickDeadzone: 0.2,
|
||||||
|
triggerInputMode: 'auto',
|
||||||
|
triggerDeadzone: 0.5,
|
||||||
|
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: 'buttonSouth',
|
||||||
|
closeLookup: 'buttonEast',
|
||||||
|
toggleKeyboardOnlyMode: 'buttonNorth',
|
||||||
|
mineCard: 'buttonWest',
|
||||||
|
quitMpv: 'select',
|
||||||
|
previousAudio: 'none',
|
||||||
|
nextAudio: 'rightShoulder',
|
||||||
|
playCurrentAudio: 'leftShoulder',
|
||||||
|
toggleMpvPause: 'leftStickPress',
|
||||||
|
leftStickHorizontal: 'leftStickX',
|
||||||
|
leftStickVertical: 'leftStickY',
|
||||||
|
rightStickHorizontal: 'rightStickX',
|
||||||
|
rightStickVertical: 'rightStickY',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getKeyboardModeEnabled: () => ctx.state.keyboardDrivenModeEnabled,
|
||||||
|
getLookupWindowOpen: () => ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document),
|
||||||
|
getInteractionBlocked: () => isAnyModalOpen(),
|
||||||
|
toggleKeyboardMode: () => keyboardHandlers.handleKeyboardModeToggleRequested(),
|
||||||
|
toggleLookup: () => keyboardHandlers.handleLookupWindowToggleRequested(),
|
||||||
|
closeLookup: () => {
|
||||||
|
keyboardHandlers.closeLookupWindow();
|
||||||
|
},
|
||||||
|
moveSelection: (delta) => {
|
||||||
|
keyboardHandlers.moveSelectionForController(delta);
|
||||||
|
},
|
||||||
|
mineCard: () => {
|
||||||
|
keyboardHandlers.mineSelectedFromController();
|
||||||
|
},
|
||||||
|
quitMpv: () => {
|
||||||
|
window.electronAPI.sendMpvCommand(['quit']);
|
||||||
|
},
|
||||||
|
previousAudio: () => {
|
||||||
|
keyboardHandlers.cyclePopupAudioSourceForController(-1);
|
||||||
|
},
|
||||||
|
nextAudio: () => {
|
||||||
|
keyboardHandlers.cyclePopupAudioSourceForController(1);
|
||||||
|
},
|
||||||
|
playCurrentAudio: () => {
|
||||||
|
keyboardHandlers.playCurrentAudioForController();
|
||||||
|
},
|
||||||
|
toggleMpvPause: () => {
|
||||||
|
window.electronAPI.sendMpvCommand(['cycle', 'pause']);
|
||||||
|
},
|
||||||
|
scrollPopup: (deltaPixels) => {
|
||||||
|
emitControllerPopupScroll(deltaPixels);
|
||||||
|
},
|
||||||
|
jumpPopup: (deltaPixels) => {
|
||||||
|
emitControllerPopupJump(deltaPixels);
|
||||||
|
},
|
||||||
|
onState: (snapshot) => {
|
||||||
|
applyControllerSnapshot(snapshot);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const poll = (now: number): void => {
|
||||||
|
gamepadController.poll(now);
|
||||||
|
controllerAnimationFrameId = requestAnimationFrame(poll);
|
||||||
|
};
|
||||||
|
|
||||||
|
controllerAnimationFrameId = requestAnimationFrame(poll);
|
||||||
|
}
|
||||||
|
|
||||||
function restoreOverlayInteractionAfterError(): void {
|
function restoreOverlayInteractionAfterError(): void {
|
||||||
ctx.state.isOverSubtitle = false;
|
ctx.state.isOverSubtitle = false;
|
||||||
ctx.dom.overlay.classList.remove('interactive');
|
ctx.dom.overlay.classList.remove('interactive');
|
||||||
@@ -298,6 +461,7 @@ async function init(): Promise<void> {
|
|||||||
window.electronAPI.onSubtitle((data: SubtitleData) => {
|
window.electronAPI.onSubtitle((data: SubtitleData) => {
|
||||||
runGuarded('subtitle:update', () => {
|
runGuarded('subtitle:update', () => {
|
||||||
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
|
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
|
||||||
|
keyboardHandlers.handleSubtitleContentUpdated();
|
||||||
subtitleRenderer.renderSubtitle(data);
|
subtitleRenderer.renderSubtitle(data);
|
||||||
measurementReporter.schedule();
|
measurementReporter.schedule();
|
||||||
});
|
});
|
||||||
@@ -317,6 +481,7 @@ async function init(): Promise<void> {
|
|||||||
initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw();
|
initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw();
|
||||||
}
|
}
|
||||||
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(initialSubtitle));
|
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(initialSubtitle));
|
||||||
|
keyboardHandlers.handleSubtitleContentUpdated();
|
||||||
subtitleRenderer.renderSubtitle(initialSubtitle);
|
subtitleRenderer.renderSubtitle(initialSubtitle);
|
||||||
measurementReporter.schedule();
|
measurementReporter.schedule();
|
||||||
|
|
||||||
@@ -355,6 +520,8 @@ async function init(): Promise<void> {
|
|||||||
kikuModal.wireDomEvents();
|
kikuModal.wireDomEvents();
|
||||||
runtimeOptionsModal.wireDomEvents();
|
runtimeOptionsModal.wireDomEvents();
|
||||||
subsyncModal.wireDomEvents();
|
subsyncModal.wireDomEvents();
|
||||||
|
controllerSelectModal.wireDomEvents();
|
||||||
|
controllerDebugModal.wireDomEvents();
|
||||||
sessionHelpModal.wireDomEvents();
|
sessionHelpModal.wireDomEvents();
|
||||||
|
|
||||||
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => {
|
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => {
|
||||||
@@ -373,6 +540,13 @@ async function init(): Promise<void> {
|
|||||||
mouseHandlers.setupDragging();
|
mouseHandlers.setupDragging();
|
||||||
|
|
||||||
await keyboardHandlers.setupMpvInputForwarding();
|
await keyboardHandlers.setupMpvInputForwarding();
|
||||||
|
try {
|
||||||
|
ctx.state.controllerConfig = await window.electronAPI.getControllerConfig();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load controller config.', error);
|
||||||
|
ctx.state.controllerConfig = null;
|
||||||
|
}
|
||||||
|
startControllerPolling();
|
||||||
|
|
||||||
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
|
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
|
||||||
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);
|
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
|
ControllerButtonSnapshot,
|
||||||
|
ControllerDeviceInfo,
|
||||||
|
ResolvedControllerConfig,
|
||||||
JimakuEntry,
|
JimakuEntry,
|
||||||
JimakuFileEntry,
|
JimakuFileEntry,
|
||||||
KikuDuplicateCardInfo,
|
KikuDuplicateCardInfo,
|
||||||
@@ -53,6 +56,15 @@ export type RendererState = {
|
|||||||
subsyncSourceTracks: SubsyncSourceTrack[];
|
subsyncSourceTracks: SubsyncSourceTrack[];
|
||||||
subsyncSubmitting: boolean;
|
subsyncSubmitting: boolean;
|
||||||
|
|
||||||
|
controllerSelectModalOpen: boolean;
|
||||||
|
controllerDebugModalOpen: boolean;
|
||||||
|
controllerDeviceSelectedIndex: number;
|
||||||
|
controllerConfig: ResolvedControllerConfig | null;
|
||||||
|
connectedGamepads: ControllerDeviceInfo[];
|
||||||
|
activeGamepadId: string | null;
|
||||||
|
controllerRawAxes: number[];
|
||||||
|
controllerRawButtons: ControllerButtonSnapshot[];
|
||||||
|
|
||||||
sessionHelpModalOpen: boolean;
|
sessionHelpModalOpen: boolean;
|
||||||
sessionHelpSelectedIndex: number;
|
sessionHelpSelectedIndex: number;
|
||||||
|
|
||||||
@@ -82,6 +94,7 @@ export type RendererState = {
|
|||||||
chordPending: boolean;
|
chordPending: boolean;
|
||||||
chordTimeout: ReturnType<typeof setTimeout> | null;
|
chordTimeout: ReturnType<typeof setTimeout> | null;
|
||||||
keyboardDrivenModeEnabled: boolean;
|
keyboardDrivenModeEnabled: boolean;
|
||||||
|
keyboardSelectionVisible: boolean;
|
||||||
keyboardSelectedWordIndex: number | null;
|
keyboardSelectedWordIndex: number | null;
|
||||||
yomitanPopupVisible: boolean;
|
yomitanPopupVisible: boolean;
|
||||||
};
|
};
|
||||||
@@ -122,6 +135,15 @@ export function createRendererState(): RendererState {
|
|||||||
subsyncSourceTracks: [],
|
subsyncSourceTracks: [],
|
||||||
subsyncSubmitting: false,
|
subsyncSubmitting: false,
|
||||||
|
|
||||||
|
controllerSelectModalOpen: false,
|
||||||
|
controllerDebugModalOpen: false,
|
||||||
|
controllerDeviceSelectedIndex: 0,
|
||||||
|
controllerConfig: null,
|
||||||
|
connectedGamepads: [],
|
||||||
|
activeGamepadId: null,
|
||||||
|
controllerRawAxes: [],
|
||||||
|
controllerRawButtons: [],
|
||||||
|
|
||||||
sessionHelpModalOpen: false,
|
sessionHelpModalOpen: false,
|
||||||
sessionHelpSelectedIndex: 0,
|
sessionHelpSelectedIndex: 0,
|
||||||
|
|
||||||
@@ -151,6 +173,7 @@ export function createRendererState(): RendererState {
|
|||||||
chordPending: false,
|
chordPending: false,
|
||||||
chordTimeout: null,
|
chordTimeout: null,
|
||||||
keyboardDrivenModeEnabled: false,
|
keyboardDrivenModeEnabled: false,
|
||||||
|
keyboardSelectionVisible: false,
|
||||||
keyboardSelectedWordIndex: null,
|
keyboardSelectedWordIndex: null,
|
||||||
yomitanPopupVisible: false,
|
yomitanPopupVisible: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -55,6 +55,34 @@ body {
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.controller-status-toast {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
left: 16px;
|
||||||
|
max-width: min(360px, calc(100vw - 32px));
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(138, 213, 202, 0.45);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(10, 44, 40, 0.94), rgba(8, 28, 33, 0.94));
|
||||||
|
color: rgba(228, 255, 251, 0.98);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-6px);
|
||||||
|
transition:
|
||||||
|
opacity 160ms ease,
|
||||||
|
transform 160ms ease;
|
||||||
|
z-index: 1300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controller-status-toast:not(.hidden) {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
.overlay-error-toast {
|
.overlay-error-toast {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 16px;
|
top: 16px;
|
||||||
@@ -321,6 +349,12 @@ body.settings-modal-open #subtitleContainer {
|
|||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.settings-modal-open iframe.yomitan-popup,
|
||||||
|
body.settings-modal-open iframe[id^='yomitan-popup'] {
|
||||||
|
display: none !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
#subtitleRoot .c {
|
#subtitleRoot .c {
|
||||||
display: inline;
|
display: inline;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -1013,6 +1047,10 @@ iframe[id^='yomitan-popup'] {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.runtime-options-list-entry {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
.runtime-options-item {
|
.runtime-options-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1022,7 +1060,15 @@ iframe[id^='yomitan-popup'] {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.runtime-options-item:last-child {
|
.runtime-options-item-button {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-options-list-entry:last-child .runtime-options-item {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1030,6 +1076,11 @@ iframe[id^='yomitan-popup'] {
|
|||||||
background: rgba(100, 180, 255, 0.15);
|
background: rgba(100, 180, 255, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.runtime-options-item-button:focus-visible {
|
||||||
|
outline: 2px solid rgba(100, 180, 255, 0.85);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
.runtime-options-label {
|
.runtime-options-label {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -1055,12 +1106,84 @@ iframe[id^='yomitan-popup'] {
|
|||||||
color: #ff8f8f;
|
color: #ff8f8f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.controller-debug-content {
|
||||||
|
position: relative;
|
||||||
|
width: min(760px, 94%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controller-debug-toast {
|
||||||
|
position: absolute;
|
||||||
|
top: 18px;
|
||||||
|
right: 56px;
|
||||||
|
z-index: 2;
|
||||||
|
max-width: min(320px, calc(100% - 88px));
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(120, 214, 168, 0.34);
|
||||||
|
background: rgba(20, 38, 30, 0.96);
|
||||||
|
color: rgba(220, 255, 232, 0.98);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.3;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controller-debug-toast.error {
|
||||||
|
border-color: rgba(255, 143, 143, 0.34);
|
||||||
|
background: rgba(52, 22, 24, 0.96);
|
||||||
|
color: rgba(255, 225, 225, 0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controller-debug-summary {
|
||||||
|
min-height: 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.86);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controller-debug-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controller-debug-span {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controller-debug-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controller-debug-pre {
|
||||||
|
min-height: 220px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: rgba(0, 0, 0, 0.38);
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.session-help-content {
|
.session-help-content {
|
||||||
width: min(760px, 92%);
|
width: min(760px, 92%);
|
||||||
max-height: 84%;
|
max-height: 84%;
|
||||||
color: rgba(255, 255, 255, 0.95);
|
color: rgba(255, 255, 255, 0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.controller-debug-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.session-help-shortcut,
|
.session-help-shortcut,
|
||||||
.session-help-warning,
|
.session-help-warning,
|
||||||
.session-help-status {
|
.session-help-status {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export type RendererDom = {
|
|||||||
subtitleRoot: HTMLElement;
|
subtitleRoot: HTMLElement;
|
||||||
subtitleContainer: HTMLElement;
|
subtitleContainer: HTMLElement;
|
||||||
overlay: HTMLElement;
|
overlay: HTMLElement;
|
||||||
|
controllerStatusToast: HTMLDivElement;
|
||||||
overlayErrorToast: HTMLDivElement;
|
overlayErrorToast: HTMLDivElement;
|
||||||
secondarySubContainer: HTMLElement;
|
secondarySubContainer: HTMLElement;
|
||||||
secondarySubRoot: HTMLElement;
|
secondarySubRoot: HTMLElement;
|
||||||
@@ -56,6 +57,23 @@ export type RendererDom = {
|
|||||||
subsyncRunButton: HTMLButtonElement;
|
subsyncRunButton: HTMLButtonElement;
|
||||||
subsyncStatus: HTMLDivElement;
|
subsyncStatus: HTMLDivElement;
|
||||||
|
|
||||||
|
controllerSelectModal: HTMLDivElement;
|
||||||
|
controllerSelectClose: HTMLButtonElement;
|
||||||
|
controllerSelectHint: HTMLDivElement;
|
||||||
|
controllerSelectStatus: HTMLDivElement;
|
||||||
|
controllerSelectList: HTMLUListElement;
|
||||||
|
controllerSelectSave: HTMLButtonElement;
|
||||||
|
|
||||||
|
controllerDebugModal: HTMLDivElement;
|
||||||
|
controllerDebugClose: HTMLButtonElement;
|
||||||
|
controllerDebugCopy: HTMLButtonElement;
|
||||||
|
controllerDebugToast: HTMLDivElement;
|
||||||
|
controllerDebugStatus: HTMLDivElement;
|
||||||
|
controllerDebugSummary: HTMLDivElement;
|
||||||
|
controllerDebugAxes: HTMLPreElement;
|
||||||
|
controllerDebugButtons: HTMLPreElement;
|
||||||
|
controllerDebugButtonIndices: HTMLPreElement;
|
||||||
|
|
||||||
sessionHelpModal: HTMLDivElement;
|
sessionHelpModal: HTMLDivElement;
|
||||||
sessionHelpClose: HTMLButtonElement;
|
sessionHelpClose: HTMLButtonElement;
|
||||||
sessionHelpShortcut: HTMLDivElement;
|
sessionHelpShortcut: HTMLDivElement;
|
||||||
@@ -78,6 +96,7 @@ export function resolveRendererDom(): RendererDom {
|
|||||||
subtitleRoot: getRequiredElement<HTMLElement>('subtitleRoot'),
|
subtitleRoot: getRequiredElement<HTMLElement>('subtitleRoot'),
|
||||||
subtitleContainer: getRequiredElement<HTMLElement>('subtitleContainer'),
|
subtitleContainer: getRequiredElement<HTMLElement>('subtitleContainer'),
|
||||||
overlay: getRequiredElement<HTMLElement>('overlay'),
|
overlay: getRequiredElement<HTMLElement>('overlay'),
|
||||||
|
controllerStatusToast: getRequiredElement<HTMLDivElement>('controllerStatusToast'),
|
||||||
overlayErrorToast: getRequiredElement<HTMLDivElement>('overlayErrorToast'),
|
overlayErrorToast: getRequiredElement<HTMLDivElement>('overlayErrorToast'),
|
||||||
secondarySubContainer: getRequiredElement<HTMLElement>('secondarySubContainer'),
|
secondarySubContainer: getRequiredElement<HTMLElement>('secondarySubContainer'),
|
||||||
secondarySubRoot: getRequiredElement<HTMLElement>('secondarySubRoot'),
|
secondarySubRoot: getRequiredElement<HTMLElement>('secondarySubRoot'),
|
||||||
@@ -132,6 +151,23 @@ export function resolveRendererDom(): RendererDom {
|
|||||||
subsyncRunButton: getRequiredElement<HTMLButtonElement>('subsyncRun'),
|
subsyncRunButton: getRequiredElement<HTMLButtonElement>('subsyncRun'),
|
||||||
subsyncStatus: getRequiredElement<HTMLDivElement>('subsyncStatus'),
|
subsyncStatus: getRequiredElement<HTMLDivElement>('subsyncStatus'),
|
||||||
|
|
||||||
|
controllerSelectModal: getRequiredElement<HTMLDivElement>('controllerSelectModal'),
|
||||||
|
controllerSelectClose: getRequiredElement<HTMLButtonElement>('controllerSelectClose'),
|
||||||
|
controllerSelectHint: getRequiredElement<HTMLDivElement>('controllerSelectHint'),
|
||||||
|
controllerSelectStatus: getRequiredElement<HTMLDivElement>('controllerSelectStatus'),
|
||||||
|
controllerSelectList: getRequiredElement<HTMLUListElement>('controllerSelectList'),
|
||||||
|
controllerSelectSave: getRequiredElement<HTMLButtonElement>('controllerSelectSave'),
|
||||||
|
|
||||||
|
controllerDebugModal: getRequiredElement<HTMLDivElement>('controllerDebugModal'),
|
||||||
|
controllerDebugClose: getRequiredElement<HTMLButtonElement>('controllerDebugClose'),
|
||||||
|
controllerDebugCopy: getRequiredElement<HTMLButtonElement>('controllerDebugCopy'),
|
||||||
|
controllerDebugToast: getRequiredElement<HTMLDivElement>('controllerDebugToast'),
|
||||||
|
controllerDebugStatus: getRequiredElement<HTMLDivElement>('controllerDebugStatus'),
|
||||||
|
controllerDebugSummary: getRequiredElement<HTMLDivElement>('controllerDebugSummary'),
|
||||||
|
controllerDebugAxes: getRequiredElement<HTMLPreElement>('controllerDebugAxes'),
|
||||||
|
controllerDebugButtons: getRequiredElement<HTMLPreElement>('controllerDebugButtons'),
|
||||||
|
controllerDebugButtonIndices: getRequiredElement<HTMLPreElement>('controllerDebugButtonIndices'),
|
||||||
|
|
||||||
sessionHelpModal: getRequiredElement<HTMLDivElement>('sessionHelpModal'),
|
sessionHelpModal: getRequiredElement<HTMLDivElement>('sessionHelpModal'),
|
||||||
sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'),
|
sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'),
|
||||||
sessionHelpShortcut: getRequiredElement<HTMLDivElement>('sessionHelpShortcut'),
|
sessionHelpShortcut: getRequiredElement<HTMLDivElement>('sessionHelpShortcut'),
|
||||||
|
|||||||
24
src/renderer/yomitan-display-scroll.test.ts
Normal file
24
src/renderer/yomitan-display-scroll.test.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
// @ts-expect-error Vendor Yomitan modules are JS-only in this repo.
|
||||||
|
import { Display } from '../../vendor/subminer-yomitan/ext/js/display/display.js';
|
||||||
|
|
||||||
|
test('yomitan display scroll bridge uses popup scroll container instead of window scroll', () => {
|
||||||
|
let scrolledTo: { x: number; y: number } | null = null;
|
||||||
|
const result = Display.prototype._onMessageScrollBy.call(
|
||||||
|
{
|
||||||
|
_windowScroll: {
|
||||||
|
x: 24,
|
||||||
|
y: 80,
|
||||||
|
to(x: number, y: number) {
|
||||||
|
scrolledTo = { x, y };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ deltaX: 12, deltaY: -20 },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result, true);
|
||||||
|
assert.deepEqual(scrolledTo, { x: 36, y: 60 });
|
||||||
|
});
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
import type { OverlayContentMeasurement, RuntimeOptionId, RuntimeOptionValue } from '../../types';
|
import type { OverlayContentMeasurement, RuntimeOptionId, RuntimeOptionValue } from '../../types';
|
||||||
|
|
||||||
export const OVERLAY_HOSTED_MODALS = ['runtime-options', 'subsync', 'jimaku', 'kiku'] as const;
|
export const OVERLAY_HOSTED_MODALS = [
|
||||||
|
'runtime-options',
|
||||||
|
'subsync',
|
||||||
|
'jimaku',
|
||||||
|
'kiku',
|
||||||
|
'controller-select',
|
||||||
|
'controller-debug',
|
||||||
|
] as const;
|
||||||
export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
|
export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
|
||||||
|
|
||||||
export const IPC_CHANNELS = {
|
export const IPC_CHANNELS = {
|
||||||
@@ -12,6 +19,7 @@ export const IPC_CHANNELS = {
|
|||||||
toggleDevTools: 'toggle-dev-tools',
|
toggleDevTools: 'toggle-dev-tools',
|
||||||
toggleOverlay: 'toggle-overlay',
|
toggleOverlay: 'toggle-overlay',
|
||||||
saveSubtitlePosition: 'save-subtitle-position',
|
saveSubtitlePosition: 'save-subtitle-position',
|
||||||
|
saveControllerPreference: 'save-controller-preference',
|
||||||
setMecabEnabled: 'set-mecab-enabled',
|
setMecabEnabled: 'set-mecab-enabled',
|
||||||
mpvCommand: 'mpv-command',
|
mpvCommand: 'mpv-command',
|
||||||
setAnkiConnectEnabled: 'set-anki-connect-enabled',
|
setAnkiConnectEnabled: 'set-anki-connect-enabled',
|
||||||
@@ -32,6 +40,7 @@ export const IPC_CHANNELS = {
|
|||||||
getMecabStatus: 'get-mecab-status',
|
getMecabStatus: 'get-mecab-status',
|
||||||
getKeybindings: 'get-keybindings',
|
getKeybindings: 'get-keybindings',
|
||||||
getConfigShortcuts: 'get-config-shortcuts',
|
getConfigShortcuts: 'get-config-shortcuts',
|
||||||
|
getControllerConfig: 'get-controller-config',
|
||||||
getSecondarySubMode: 'get-secondary-sub-mode',
|
getSecondarySubMode: 'get-secondary-sub-mode',
|
||||||
getCurrentSecondarySub: 'get-current-secondary-sub',
|
getCurrentSecondarySub: 'get-current-secondary-sub',
|
||||||
focusMainWindow: 'focus-main-window',
|
focusMainWindow: 'focus-main-window',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
ControllerPreferenceUpdate,
|
||||||
JimakuDownloadQuery,
|
JimakuDownloadQuery,
|
||||||
JimakuFilesQuery,
|
JimakuFilesQuery,
|
||||||
JimakuSearchQuery,
|
JimakuSearchQuery,
|
||||||
@@ -48,6 +49,16 @@ export function parseSubtitlePosition(value: unknown): SubtitlePosition | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseControllerPreferenceUpdate(value: unknown): ControllerPreferenceUpdate | null {
|
||||||
|
if (!isObject(value)) return null;
|
||||||
|
if (typeof value.preferredGamepadId !== 'string') return null;
|
||||||
|
if (typeof value.preferredGamepadLabel !== 'string') return null;
|
||||||
|
return {
|
||||||
|
preferredGamepadId: value.preferredGamepadId,
|
||||||
|
preferredGamepadLabel: value.preferredGamepadLabel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function parseSubsyncManualRunRequest(value: unknown): SubsyncManualRunRequest | null {
|
export function parseSubsyncManualRunRequest(value: unknown): SubsyncManualRunRequest | null {
|
||||||
if (!isObject(value)) return null;
|
if (!isObject(value)) return null;
|
||||||
const { engine, sourceTrackId } = value;
|
const { engine, sourceTrackId } = value;
|
||||||
|
|||||||
128
src/types.ts
128
src/types.ts
@@ -375,6 +375,94 @@ export interface ShortcutsConfig {
|
|||||||
openJimaku?: string | null;
|
openJimaku?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ControllerButtonBinding =
|
||||||
|
| 'none'
|
||||||
|
| 'select'
|
||||||
|
| 'buttonSouth'
|
||||||
|
| 'buttonEast'
|
||||||
|
| 'buttonNorth'
|
||||||
|
| 'buttonWest'
|
||||||
|
| 'leftShoulder'
|
||||||
|
| 'rightShoulder'
|
||||||
|
| 'leftStickPress'
|
||||||
|
| 'rightStickPress'
|
||||||
|
| 'leftTrigger'
|
||||||
|
| 'rightTrigger';
|
||||||
|
|
||||||
|
export type ControllerAxisBinding = 'leftStickX' | 'leftStickY' | 'rightStickX' | 'rightStickY';
|
||||||
|
export type ControllerTriggerInputMode = 'auto' | 'digital' | 'analog';
|
||||||
|
|
||||||
|
export interface ControllerBindingsConfig {
|
||||||
|
toggleLookup?: ControllerButtonBinding;
|
||||||
|
closeLookup?: ControllerButtonBinding;
|
||||||
|
toggleKeyboardOnlyMode?: ControllerButtonBinding;
|
||||||
|
mineCard?: ControllerButtonBinding;
|
||||||
|
quitMpv?: ControllerButtonBinding;
|
||||||
|
previousAudio?: ControllerButtonBinding;
|
||||||
|
nextAudio?: ControllerButtonBinding;
|
||||||
|
playCurrentAudio?: ControllerButtonBinding;
|
||||||
|
toggleMpvPause?: ControllerButtonBinding;
|
||||||
|
leftStickHorizontal?: ControllerAxisBinding;
|
||||||
|
leftStickVertical?: ControllerAxisBinding;
|
||||||
|
rightStickHorizontal?: ControllerAxisBinding;
|
||||||
|
rightStickVertical?: ControllerAxisBinding;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ControllerButtonIndicesConfig {
|
||||||
|
select?: number;
|
||||||
|
buttonSouth?: number;
|
||||||
|
buttonEast?: number;
|
||||||
|
buttonNorth?: number;
|
||||||
|
buttonWest?: number;
|
||||||
|
leftShoulder?: number;
|
||||||
|
rightShoulder?: number;
|
||||||
|
leftStickPress?: number;
|
||||||
|
rightStickPress?: number;
|
||||||
|
leftTrigger?: number;
|
||||||
|
rightTrigger?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ControllerConfig {
|
||||||
|
enabled?: boolean;
|
||||||
|
preferredGamepadId?: string;
|
||||||
|
preferredGamepadLabel?: string;
|
||||||
|
smoothScroll?: boolean;
|
||||||
|
scrollPixelsPerSecond?: number;
|
||||||
|
horizontalJumpPixels?: number;
|
||||||
|
stickDeadzone?: number;
|
||||||
|
triggerInputMode?: ControllerTriggerInputMode;
|
||||||
|
triggerDeadzone?: number;
|
||||||
|
repeatDelayMs?: number;
|
||||||
|
repeatIntervalMs?: number;
|
||||||
|
buttonIndices?: ControllerButtonIndicesConfig;
|
||||||
|
bindings?: ControllerBindingsConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ControllerPreferenceUpdate {
|
||||||
|
preferredGamepadId: string;
|
||||||
|
preferredGamepadLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ControllerDeviceInfo {
|
||||||
|
id: string;
|
||||||
|
index: number;
|
||||||
|
mapping: string;
|
||||||
|
connected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ControllerButtonSnapshot {
|
||||||
|
value: number;
|
||||||
|
pressed: boolean;
|
||||||
|
touched?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ControllerRuntimeSnapshot {
|
||||||
|
connectedGamepads: ControllerDeviceInfo[];
|
||||||
|
activeGamepadId: string | null;
|
||||||
|
rawAxes: number[];
|
||||||
|
rawButtons: ControllerButtonSnapshot[];
|
||||||
|
}
|
||||||
|
|
||||||
export type JimakuLanguagePreference = 'ja' | 'en' | 'none';
|
export type JimakuLanguagePreference = 'ja' | 'en' | 'none';
|
||||||
|
|
||||||
export interface JimakuConfig {
|
export interface JimakuConfig {
|
||||||
@@ -491,6 +579,7 @@ export interface Config {
|
|||||||
websocket?: WebSocketConfig;
|
websocket?: WebSocketConfig;
|
||||||
annotationWebsocket?: AnnotationWebSocketConfig;
|
annotationWebsocket?: AnnotationWebSocketConfig;
|
||||||
texthooker?: TexthookerConfig;
|
texthooker?: TexthookerConfig;
|
||||||
|
controller?: ControllerConfig;
|
||||||
ankiConnect?: AnkiConnectConfig;
|
ankiConnect?: AnkiConnectConfig;
|
||||||
shortcuts?: ShortcutsConfig;
|
shortcuts?: ShortcutsConfig;
|
||||||
secondarySub?: SecondarySubConfig;
|
secondarySub?: SecondarySubConfig;
|
||||||
@@ -519,6 +608,21 @@ export interface ResolvedConfig {
|
|||||||
websocket: Required<WebSocketConfig>;
|
websocket: Required<WebSocketConfig>;
|
||||||
annotationWebsocket: Required<AnnotationWebSocketConfig>;
|
annotationWebsocket: Required<AnnotationWebSocketConfig>;
|
||||||
texthooker: Required<TexthookerConfig>;
|
texthooker: Required<TexthookerConfig>;
|
||||||
|
controller: {
|
||||||
|
enabled: boolean;
|
||||||
|
preferredGamepadId: string;
|
||||||
|
preferredGamepadLabel: string;
|
||||||
|
smoothScroll: boolean;
|
||||||
|
scrollPixelsPerSecond: number;
|
||||||
|
horizontalJumpPixels: number;
|
||||||
|
stickDeadzone: number;
|
||||||
|
triggerInputMode: ControllerTriggerInputMode;
|
||||||
|
triggerDeadzone: number;
|
||||||
|
repeatDelayMs: number;
|
||||||
|
repeatIntervalMs: number;
|
||||||
|
buttonIndices: Required<ControllerButtonIndicesConfig>;
|
||||||
|
bindings: Required<ControllerBindingsConfig>;
|
||||||
|
};
|
||||||
ankiConnect: AnkiConnectConfig & {
|
ankiConnect: AnkiConnectConfig & {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -846,6 +950,8 @@ export interface ConfigHotReloadPayload {
|
|||||||
secondarySubMode: SecondarySubMode;
|
secondarySubMode: SecondarySubMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ResolvedControllerConfig = ResolvedConfig['controller'];
|
||||||
|
|
||||||
export interface SubtitleHoverTokenPayload {
|
export interface SubtitleHoverTokenPayload {
|
||||||
tokenIndex: number | null;
|
tokenIndex: number | null;
|
||||||
}
|
}
|
||||||
@@ -870,6 +976,8 @@ export interface ElectronAPI {
|
|||||||
sendMpvCommand: (command: (string | number)[]) => void;
|
sendMpvCommand: (command: (string | number)[]) => void;
|
||||||
getKeybindings: () => Promise<Keybinding[]>;
|
getKeybindings: () => Promise<Keybinding[]>;
|
||||||
getConfiguredShortcuts: () => Promise<Required<ShortcutsConfig>>;
|
getConfiguredShortcuts: () => Promise<Required<ShortcutsConfig>>;
|
||||||
|
getControllerConfig: () => Promise<ResolvedControllerConfig>;
|
||||||
|
saveControllerPreference: (update: ControllerPreferenceUpdate) => Promise<void>;
|
||||||
getJimakuMediaInfo: () => Promise<JimakuMediaInfo>;
|
getJimakuMediaInfo: () => Promise<JimakuMediaInfo>;
|
||||||
jimakuSearchEntries: (query: JimakuSearchQuery) => Promise<JimakuApiResponse<JimakuEntry[]>>;
|
jimakuSearchEntries: (query: JimakuSearchQuery) => Promise<JimakuApiResponse<JimakuEntry[]>>;
|
||||||
jimakuListFiles: (query: JimakuFilesQuery) => Promise<JimakuApiResponse<JimakuFileEntry[]>>;
|
jimakuListFiles: (query: JimakuFilesQuery) => Promise<JimakuApiResponse<JimakuFileEntry[]>>;
|
||||||
@@ -903,8 +1011,24 @@ export interface ElectronAPI {
|
|||||||
onKeyboardModeToggleRequested: (callback: () => void) => void;
|
onKeyboardModeToggleRequested: (callback: () => void) => void;
|
||||||
onLookupWindowToggleRequested: (callback: () => void) => void;
|
onLookupWindowToggleRequested: (callback: () => void) => void;
|
||||||
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
|
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
|
||||||
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void;
|
notifyOverlayModalClosed: (
|
||||||
notifyOverlayModalOpened: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void;
|
modal:
|
||||||
|
| 'runtime-options'
|
||||||
|
| 'subsync'
|
||||||
|
| 'jimaku'
|
||||||
|
| 'kiku'
|
||||||
|
| 'controller-select'
|
||||||
|
| 'controller-debug',
|
||||||
|
) => void;
|
||||||
|
notifyOverlayModalOpened: (
|
||||||
|
modal:
|
||||||
|
| 'runtime-options'
|
||||||
|
| 'subsync'
|
||||||
|
| 'jimaku'
|
||||||
|
| 'kiku'
|
||||||
|
| 'controller-select'
|
||||||
|
| 'controller-debug',
|
||||||
|
) => void;
|
||||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
||||||
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
|
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
2
vendor/subminer-yomitan
vendored
2
vendor/subminer-yomitan
vendored
Submodule vendor/subminer-yomitan updated: 66cb7a06f1...979a162904
Reference in New Issue
Block a user